Просмотр исходного кода

fix(admin): unify payroll calendar and standard tabs

彭峰 1 месяц назад
Родитель
Сommit
e6dacfc411

+ 2 - 3
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html

@@ -17,12 +17,11 @@
 } @else {
 } @else {
  @if(standards();as config){
  @if(standards();as config){
   <section class="standards-summary"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
   <section class="standards-summary"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
-  <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button></nav>
-  <button class="ui-button secondary" type="button" (click)="loadWritingMappings()">按学员核对语法写作</button>
+  <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button><button type="button" [class.active]="standardsSection()==='students'" (click)="loadWritingMappings()">按学员核对语法写作</button></nav>
  @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>先选择对应分类,再填写核对依据(至少 2 个字),即可确认。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据 <span class="required-mark">*</span></th><th>操作</th></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据'}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><div class="evidence-field"><input [(ngModel)]="row.evidence" placeholder="如:学生档案为小学五年级" /><small>必填,至少 2 个字</small></div></td><td>@if(isManager()){<button class="ui-button secondary" type="button" (click)="saveWritingMapping(row)" [disabled]="saving()" [attr.title]="!row.categoryKey?'请先选择对应分类':row.evidence.trim().length<2?'请填写核对依据(至少 2 个字)':'确认此预约分类'">{{saving()?'确认中…':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
  @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>先选择对应分类,再填写核对依据(至少 2 个字),即可确认。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据 <span class="required-mark">*</span></th><th>操作</th></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据'}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><div class="evidence-field"><input [(ngModel)]="row.evidence" placeholder="如:学生档案为小学五年级" /><small>必填,至少 2 个字</small></div></td><td>@if(isManager()){<button class="ui-button secondary" type="button" (click)="saveWritingMapping(row)" [disabled]="saving()" [attr.title]="!row.categoryKey?'请先选择对应分类':row.evidence.trim().length<2?'请填写核对依据(至少 2 个字)':'确认此预约分类'">{{saving()?'确认中…':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
  @if(standardsSection()==='rules'){
  @if(standardsSection()==='rules'){
    <section class="content-card"><header><div><strong>当前课程工资标准</strong><span>按最新价目表统一为课程分类、时长、线上和线下单价;新版本从生效日期起计算,历史工资仍使用原规则</span></div></header>
    <section class="content-card"><header><div><strong>当前课程工资标准</strong><span>按最新价目表统一为课程分类、时长、线上和线下单价;新版本从生效日期起计算,历史工资仍使用原规则</span></div></header>
-   @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="updateRuleCategory($event)">@for(category of activeCategories();track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)">@for(duration of ruleDurations();track duration){<option [ngValue]="duration">{{duration}} 分钟</option>}</select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}
+   @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="updateRuleCategory($event)">@for(category of activeCategories();track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)">@for(duration of ruleDurations();track duration){<option [ngValue]="duration">{{duration}} 分钟</option>}</select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><app-operations-date-filter class="effective-date-filter" label="生效日期" [value]="effectiveFrom()" (valueChange)="effectiveFrom.set($event)" /><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}
    <div class="table-scroll"><table class="data-table standards-table rate-matrix"><thead><tr><th>课程分类</th><th>课程时长</th><th>线上单价</th><th>线下单价</th><th>课时扣减</th><th>当前版本</th><th>操作</th></tr></thead><tbody>@for(row of standardMatrix();track row.categoryKey+':'+row.durationMinutes){<tr><td><strong>{{row.categoryName}}</strong></td><td>{{row.durationMinutes}}分钟</td><td class="money">¥{{row.online?.teacherPay|number:'1.2-2'}}</td><td class="money offline-price">¥{{row.offline?.teacherPay|number:'1.2-2'}}</td><td>{{row.online?creditSummary(row.online):row.offline?creditSummary(row.offline):'—'}}</td><td>{{row.online?.effectiveFrom||row.offline?.effectiveFrom}}<small>{{row.online?.isDefault&&row.offline?.isDefault?'系统价目':'管理员版本'}}</small></td><td>@if(isManager()){<span class="matrix-actions"><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'online')">编辑线上</button><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'offline')">编辑线下</button></span>}@else{<span class="muted-text">—</span>}</td></tr>}</tbody></table></div></section>
    <div class="table-scroll"><table class="data-table standards-table rate-matrix"><thead><tr><th>课程分类</th><th>课程时长</th><th>线上单价</th><th>线下单价</th><th>课时扣减</th><th>当前版本</th><th>操作</th></tr></thead><tbody>@for(row of standardMatrix();track row.categoryKey+':'+row.durationMinutes){<tr><td><strong>{{row.categoryName}}</strong></td><td>{{row.durationMinutes}}分钟</td><td class="money">¥{{row.online?.teacherPay|number:'1.2-2'}}</td><td class="money offline-price">¥{{row.offline?.teacherPay|number:'1.2-2'}}</td><td>{{row.online?creditSummary(row.online):row.offline?creditSummary(row.offline):'—'}}</td><td>{{row.online?.effectiveFrom||row.offline?.effectiveFrom}}<small>{{row.online?.isDefault&&row.offline?.isDefault?'系统价目':'管理员版本'}}</small></td><td>@if(isManager()){<span class="matrix-actions"><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'online')">编辑线上</button><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'offline')">编辑线下</button></span>}@else{<span class="muted-text">—</span>}</td></tr>}</tbody></table></div></section>
   <details class="content-card"><summary>工资规则修订记录</summary><div class="table-scroll"><table class="data-table"><thead><tr><th>分类</th><th>时长 / 方式</th><th>生效日期</th><th>工资</th><th>状态</th></tr></thead><tbody>@for(rule of standards()?.rules||[];track rule.objectId){@if(!rule.isDefault){<tr><td>{{rule.categoryName}}</td><td>{{rule.durationMinutes}}分钟 · {{rule.deliveryLabel}}</td><td>{{rule.effectiveFrom}}</td><td>¥{{rule.teacherPay|number:'1.2-2'}}</td><td>{{rule['superseded']?'历史版本':rule.active?'当前版本':'已停用'}}</td></tr>}}</tbody></table></div></details>
   <details class="content-card"><summary>工资规则修订记录</summary><div class="table-scroll"><table class="data-table"><thead><tr><th>分类</th><th>时长 / 方式</th><th>生效日期</th><th>工资</th><th>状态</th></tr></thead><tbody>@for(rule of standards()?.rules||[];track rule.objectId){@if(!rule.isDefault){<tr><td>{{rule.categoryName}}</td><td>{{rule.durationMinutes}}分钟 · {{rule.deliveryLabel}}</td><td>{{rule.effectiveFrom}}</td><td>¥{{rule.teacherPay|number:'1.2-2'}}</td><td>{{rule['superseded']?'历史版本':rule.active?'当前版本':'已停用'}}</td></tr>}}</tbody></table></div></details>
  } @else if(standardsSection()==='mappings'){
  } @else if(standardsSection()==='mappings'){

+ 3 - 2
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.scss

@@ -463,11 +463,12 @@
 .standards-summary span,.standards-summary strong { display:block; }
 .standards-summary span,.standards-summary strong { display:block; }
 .standards-summary span { color:#71837d;font-size:12px;font-weight:700; }
 .standards-summary span { color:#71837d;font-size:12px;font-weight:700; }
 .standards-summary strong { margin-top:8px;color:#173d32;font-size:24px; }
 .standards-summary strong { margin-top:8px;color:#173d32;font-size:24px; }
-.standards-nav { display:flex;gap:8px;margin-bottom:14px; }
-.standards-nav button { min-height:42px;padding:0 16px;border:1px solid #d7e3df;border-radius:12px;color:#50665e;background:#fff;font-weight:750; }
+.standards-nav { display:flex;gap:8px;margin-bottom:14px;overflow-x:auto;scrollbar-width:thin; }
+.standards-nav button { flex:0 0 auto;min-height:42px;padding:0 16px;border:1px solid #d7e3df;border-radius:12px;color:#50665e;background:#fff;font-weight:750;white-space:nowrap; }
 .standards-nav button.active { border-color:#176c55;color:#fff;background:#176c55; }
 .standards-nav button.active { border-color:#176c55;color:#fff;background:#176c55; }
 .standards-nav button span { margin-left:5px;padding:2px 6px;border-radius:999px;color:#9a5a00;background:#fff2d9;font-size:11px; }
 .standards-nav button span { margin-left:5px;padding:2px 6px;border-radius:999px;color:#9a5a00;background:#fff2d9;font-size:11px; }
 .standard-editor .form-grid { grid-template-columns:repeat(5,minmax(130px,1fr)) minmax(220px,1.4fr) auto;align-items:end; }
 .standard-editor .form-grid { grid-template-columns:repeat(5,minmax(130px,1fr)) minmax(220px,1.4fr) auto;align-items:end; }
+.effective-date-filter { --date-filter-height:46px; }
 .credit-preview { min-height:64px;padding:10px 13px;border:1px solid #d9e5e1;border-radius:12px;background:#f7fbf9; }
 .credit-preview { min-height:64px;padding:10px 13px;border:1px solid #d9e5e1;border-radius:12px;background:#f7fbf9; }
 .credit-preview span,.credit-preview strong { display:block; }
 .credit-preview span,.credit-preview strong { display:block; }
 .credit-preview span { color:#71837d;font-size:11px; }
 .credit-preview span { color:#71837d;font-size:11px; }

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts

@@ -9,6 +9,7 @@ import { Banknote, Brain, CalendarDays, ChevronDown, ChevronLeft, ChevronRight,
 import { utils, writeFile } from 'xlsx';
 import { utils, writeFile } from 'xlsx';
 import { CourseTeachingMapping, CreditReservationReconciliation, OperationsPage, PayRate, PayrollCoachSummary, PayrollDetail, PayrollLine, PayrollOverview, PayrollSummary, TeachingCourseCategory, TeachingCreditAccount, TeachingRule, TeachingStageKey, TeachingStandardsData } from '../admin.models';
 import { CourseTeachingMapping, CreditReservationReconciliation, OperationsPage, PayRate, PayrollCoachSummary, PayrollDetail, PayrollLine, PayrollOverview, PayrollSummary, TeachingCourseCategory, TeachingCreditAccount, TeachingRule, TeachingStageKey, TeachingStandardsData } from '../admin.models';
 import { AdminSessionService } from '../admin-session.service';
 import { AdminSessionService } from '../admin-session.service';
+import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { OperationsService } from '../operations.service';
 import { OperationsService } from '../operations.service';
 
 
 const localDateKey=(value:Date):string=>value.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
 const localDateKey=(value:Date):string=>value.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
@@ -20,7 +21,7 @@ export function teachingRateMatrix(rules:TeachingRule[],categories:TeachingCours
 
 
 interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string}
 interface WritingMappingRow {appointmentId:string;appointmentGeneralId:number;studentName:string;courseName:string;startsAt:string;grade:string;categoryKey:string;suggestedCategoryKey:string;evidence:string;updatedAt:string}
 
 
-@Component({selector:'app-operations-payroll',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
+@Component({selector:'app-operations-payroll',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule,LucideAngularModule,ReviewRewardsComponent,OperationsDateFilterComponent],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsPayrollComponent implements OnInit{
 export class OperationsPayrollComponent implements OnInit{
  readonly adjustmentCoaches=signal<Array<{coachId:number;coachName:string}>>([]);
  readonly adjustmentCoaches=signal<Array<{coachId:number;coachName:string}>>([]);
  readonly writingRows=signal<WritingMappingRow[]>([]);
  readonly writingRows=signal<WritingMappingRow[]>([]);