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

fix(admin): clarify writing mapping confirmation

彭峰 4 недель назад
Родитель
Сommit
7b96dec92a

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

@@ -19,7 +19,7 @@
   <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>
- @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>依据学生档案或已核实信息确认;课程内容相同也可属于不同学段。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据</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><input [(ngModel)]="row.evidence" placeholder="年级记录或人工核实依据" /></td><td>@if(isManager()){<button class="ui-button secondary" (click)="saveWritingMapping(row)" [disabled]="saving()||!row.categoryKey||!row.evidence.trim()">确认</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'){
    <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>}

+ 14 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.scss

@@ -87,6 +87,20 @@
   box-shadow: 0 6px 14px rgba(23, 108, 85, .2);
 }
 
+.required-mark {
+  color: #c84b45;
+}
+
+.evidence-field {
+  display: grid;
+  gap: 5px;
+}
+
+.evidence-field small {
+  color: #87968f;
+  font-size: 11px;
+}
+
 .payroll-toolbar {
   display: grid;
   grid-template-columns: 190px 220px minmax(260px, 1fr) auto auto;

+ 13 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts

@@ -80,6 +80,19 @@ describe('payroll interactions', () => {
   service.teachingStandards.and.rejectWith(new Error('标准服务暂不可用'));
   await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('工资标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse();
  });
+ it('explains missing writing evidence before submitting a mapping',async()=>{
+  const row={appointmentId:'appointment',appointmentGeneralId:1,studentName:'灵灵',courseName:'阅读写作',startsAt:'2026-09-02 19:30',grade:'',categoryKey:'primary_writing',suggestedCategoryKey:'primary_writing',evidence:'',updatedAt:'2026-09-02T12:00:00.000Z'};
+  await component.saveWritingMapping(row);
+  expect(service.mutate).not.toHaveBeenCalled();
+  expect(component.error()).toContain('请填写“灵灵”的核对依据');
+ });
+ it('submits a writing mapping once its category and evidence are complete',async()=>{
+  const row={appointmentId:'appointment',appointmentGeneralId:1,studentName:'灵灵',courseName:'阅读写作',startsAt:'2026-09-02 19:30',grade:'',categoryKey:'primary_writing',suggestedCategoryKey:'primary_writing',evidence:' 已核实为小学 ',updatedAt:'2026-09-02T12:00:00.000Z'};
+  service.mutate.and.resolveTo({updated:true});
+  await component.saveWritingMapping(row);
+  expect(service.mutate).toHaveBeenCalledWith('ops/course-mappings/student-commit','appointment','按学员记录核对语法写作分类',{categoryKey:'primary_writing',evidence:'已核实为小学'},'2026-09-02T12:00:00.000Z');
+  expect(component.message()).toBe('该预约的学段分类已确认');
+ });
  it('ignores an older batch page response',async()=>{
   let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20}));
   const old=component.loadBatchPage(2);await component.loadBatchPage(3);resolveOld({items:[],total:50,page:2,pageSize:20});await old;expect(component.page()?.page).toBe(3);

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


Некоторые файлы не были показаны из-за большого количества измененных файлов