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

Show schedule details and safe cancellation controls

彭峰 3 дней назад
Родитель
Сommit
2cbe158dbc

+ 26 - 4
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -68,7 +68,7 @@
 
 <section class="content-card" [class.is-loading]="loading()" [attr.aria-busy]="loading()">
   <header class="schedule-content-header">
-    <div><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击课程可改期或换老师'}}</span></div>
+    <div><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击排课查看明细与可用操作'}}</span></div>
     <div class="status-legend" aria-label="排课状态图例">
       <span class="status-upcoming"><i></i>待开始</span>
       <span class="status-live"><i></i>上课中</span>
@@ -83,18 +83,40 @@
   </header>
   @if(view()==='list'){
     <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
-      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions">@if(item.statusCode>=11&&!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看完课反馈</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
+      @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions"><button type="button" (click)="openDetails(item)">查看明细</button>@if(item.statusCode>=11&&!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看完课反馈</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="openDetails(item)">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
       @empty{<tr><td colspan="7"><div class="empty">{{hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
     </tbody></table></div>
   } @else {
     <div class="table-scroll"><div class="calendar">
-      @for(coach of visibleCoaches();track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>当前 {{visibleCoachCount(coach.userId)}} 节排课</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(item.coachId===coach.userId&&!item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="item.statusCode>=11?viewFeedback(item):openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}@if(item.readOnly){ · 实时}</small></button>}}</div></div>}
+      @for(coach of visibleCoaches();track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>当前 {{visibleCoachCount(coach.userId)}} 节排课</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(item.coachId===coach.userId&&!item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openDetails(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}@if(item.readOnly){ · 实时}</small></button>}}</div></div>}
       @empty{<div class="empty">当前范围暂无已排课的陪练</div>}
-      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(!item.coachId||item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}}</div></div>}
+      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(!item.coachId||item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openDetails(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}}</div></div>}
     </div></div>
   }
 </section>
 
+@if(detail();as item){
+  <div class="backdrop" (click)="closeDetails()"></div>
+  <aside class="drawer schedule-detail-drawer" role="dialog" aria-modal="true" aria-label="排课明细" data-testid="schedule-detail">
+    <header><div><p>SCHEDULE DETAILS</p><h2>排课明细</h2><span>业务 ID {{item.generalId||'—'}}</span></div><button class="icon-button" type="button" aria-label="关闭明细" (click)="closeDetails()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
+    <div class="schedule-detail-status"><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span><span>{{item.sourceLabel||'排课记录'}}</span></div>
+    <dl class="schedule-detail-facts">
+      <div><dt>学员</dt><dd>{{item.studentName}} <small>ID {{item.studentId||'—'}}</small></dd></div>
+      <div><dt>陪练</dt><dd>{{item.coachName}} <small>ID {{item.coachId||'未分配'}}</small></dd></div>
+      <div><dt>课程</dt><dd>{{item.courseName}} <small>ID {{item.courseId||'—'}}</small></dd></div>
+      <div><dt>上课时间</dt><dd>{{item.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}} – {{item.endsAt|date:'HH:mm':'+0800'}}</dd></div>
+      <div><dt>课程标准</dt><dd>{{item.classTypeLabel}} · {{item.durationMinutes||'—'}} 分钟</dd></div>
+      <div><dt>授课方式</dt><dd>{{item.deliveryMethod||'—'}}</dd></div>
+      @if(item.cancelled&&item.cancellationReason){<div class="wide"><dt>取消原因</dt><dd>{{item.cancellationReason}}</dd></div>}
+    </dl>
+    @if(rowActions(item).canCancel){
+      <div class="schedule-detail-cancel"><label for="schedule-cancel-reason">取消原因</label><textarea id="schedule-cancel-reason" rows="2" [ngModel]="cancelReason()" (ngModelChange)="cancelReason.set($event)" placeholder="填写取消原因后确认,操作将记录在业务日志中"></textarea></div>
+    } @else if(item.readOnly){<p class="notice">这条排课来自旧系统实时数据。当前没有安全的旧系统写回通道,新后台暂不能取消;请在原业务端取消后重新同步。</p>}
+    @if(detailError()){<p class="notice error" role="alert">{{detailError()}}</p>}
+    <footer><button type="button" (click)="closeDetails()" [disabled]="saving()">关闭</button>@if(rowActions(item).canEdit){<button type="button" (click)="editFromDetails(item)">修改排课</button>}@if(rowActions(item).canCancel){<button class="danger-action" type="button" (click)="cancelFromDetails()" [disabled]="saving()">{{saving()?'正在取消…':'确认取消排课'}}</button>}</footer>
+  </aside>
+}
+
 @if(drawerOpen()){
   <div class="backdrop" (click)="closeDrawer()"></div>
   <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">

+ 13 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss

@@ -622,6 +622,19 @@ textarea:focus-visible {
   background: #f8fbfa;
 }
 
+.schedule-detail-status { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; color: #667a72; }
+.schedule-detail-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0; }
+.schedule-detail-facts > div { min-width: 0; padding: 14px; border: 1px solid #e3ebe7; border-radius: 12px; background: #fff; }
+.schedule-detail-facts .wide { grid-column: 1 / -1; }
+.schedule-detail-facts dt { margin-bottom: 7px; color: #71827a; font-size: 12px; }
+.schedule-detail-facts dd { margin: 0; color: #233c32; font-weight: 700; overflow-wrap: anywhere; }
+.schedule-detail-facts small { display: block; margin-top: 3px; color: #82918a; font-weight: 400; }
+.schedule-detail-cancel { display: grid; gap: 8px; margin-top: 22px; color: #4c675b; font-weight: 700; }
+.schedule-detail-cancel textarea { width: 100%; padding: 10px 12px; border: 1px solid #d7e1dd; border-radius: 10px; resize: vertical; }
+.schedule-detail-drawer footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: 22px; }
+.schedule-detail-drawer footer .danger-action { border-color: #f0c7c2; color: #a53c36; background: #fff1ef; }
+@media (max-width: 600px) { .schedule-detail-facts { grid-template-columns: 1fr; } }
+
 .drawer header {
   align-items: flex-start;
 }

+ 46 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -200,6 +200,52 @@ describe('排课数据加载提示', () => {
   });
 });
 
+describe('排课明细与取消', () => {
+  it('实时旧系统排课可查看明细,但不能从新后台单边取消', async () => {
+    const mutate = jasmine.createSpy('mutate');
+    await TestBed.configureTestingModule({
+      imports: [OperationsScheduleComponent],
+      providers: [
+        { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } },
+        { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
+      ],
+    }).compileComponents();
+    const fixture = TestBed.createComponent(OperationsScheduleComponent);
+    const page = fixture.componentInstance;
+    spyOn(page, 'load').and.resolveTo();
+    fixture.detectChanges();
+    page.openDetails({ generalId: 123, studentName: '学生甲', coachName: '老师乙', courseName: '单词课', readOnly: true, statusCode: 0, cancelled: false, sourceLabel: '旧系统实时数据' } as ScheduleAppointment);
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('[data-testid="schedule-detail"]')?.textContent).toContain('学生甲');
+    expect(fixture.nativeElement.querySelector('[data-testid="schedule-detail"]')?.textContent).toContain('原业务端取消');
+    await page.cancelFromDetails();
+    expect(mutate).not.toHaveBeenCalled();
+  });
+
+  it('对可写排课填写原因后调用取消接口并刷新', async () => {
+    const mutate = jasmine.createSpy('mutate').and.resolveTo({});
+    await TestBed.configureTestingModule({
+      imports: [OperationsScheduleComponent],
+      providers: [
+        { provide: OperationsService, useValue: { schedule: async () => ({ appointments: [], members: [], courses: [], coaches: [] }), mutate } },
+        { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
+      ],
+    }).compileComponents();
+    const page = TestBed.createComponent(OperationsScheduleComponent).componentInstance;
+    const load = spyOn(page, 'load').and.resolveTo();
+    const item = { targetId: 'appointment-1', updatedAt: '2026-10-04T10:00:00Z', readOnly: false, statusCode: 0, cancelled: false } as ScheduleAppointment;
+    page.openDetails(item);
+    await page.cancelFromDetails();
+    expect(mutate).not.toHaveBeenCalled();
+    expect(page.detailError()).toContain('取消原因');
+    page.cancelReason.set('学员请假');
+    await page.cancelFromDetails();
+    expect(mutate).toHaveBeenCalledOnceWith('ops/appointments/cancel', 'appointment-1', '学员请假', {}, item.updatedAt);
+    expect(page.detail()).toBeNull();
+    expect(load).toHaveBeenCalled();
+  });
+});
+
 describe('排课表单预检', () => {
   async function form(request: () => Promise<Record<string, unknown>>) {
     await TestBed.configureTestingModule({

+ 16 - 1
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -127,7 +127,7 @@ export function scheduleCoachBusy(
 
 @Component({ selector:'app-operations-schedule', standalone:true, imports: [AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit, OnDestroy {
-  readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly feedback=signal<Record<string,unknown>|null>(null); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
+  readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly feedback=signal<Record<string,unknown>|null>(null); readonly detail=signal<ScheduleAppointment|null>(null); readonly cancelReason=signal(''); readonly detailError=signal(''); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
   readonly bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null); readonly checking=signal(false);
   readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService); private readonly liveRefresh=inject(OperationsLiveRefreshService);
@@ -150,6 +150,21 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
   statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
   rowActions(item:ScheduleAppointment):ReturnType<typeof scheduleRowActions>{return scheduleRowActions(item);}
+  openDetails(item:ScheduleAppointment):void{this.detail.set(item);this.cancelReason.set('');this.detailError.set('');}
+  closeDetails():void{if(this.saving())return;this.detail.set(null);this.cancelReason.set('');this.detailError.set('');}
+  editFromDetails(item:ScheduleAppointment):void{this.closeDetails();this.openEdit(item);}
+  async cancelFromDetails():Promise<void>{
+    const item=this.detail(),reason=this.cancelReason().trim();
+    if(!item||this.saving())return;
+    if(!this.rowActions(item).canCancel){this.detailError.set(item.readOnly?'该排课仍由旧业务系统维护,新后台尚不能安全写回取消状态':'当前状态不能取消排课');return;}
+    if(!reason){this.detailError.set('请填写取消原因');return;}
+    this.saving.set(true);this.detailError.set('');
+    try{
+      await this.operations.mutate('ops/appointments/cancel',item.targetId,reason,{},item.updatedAt);
+      this.detail.set(null);this.message.set('排课已取消');await this.load();
+    }catch(error){this.detailError.set(error instanceof Error?error.message:'取消排课失败');}
+    finally{this.saving.set(false);}
+  }
   ngOnInit():void{void this.load().then(()=>{const id=Number(this.route.snapshot.queryParamMap.get('studentId'));const member=this.data()?.members.find(m=>m.userId===id);if(member){this.openCreate();this.chooseMember(member);}});this.stopLiveRefresh=this.liveRefresh.watch(['courses','appointments','account'],()=>{if(this.loading()||this.saving()||this.searching())return false;void this.load(true);return true;});}
   ngOnDestroy():void{this.stopLiveRefresh?.();}
   range():{from:string;to:string}{const from=new Date(`${this.date()}T00:00:00`);const to=new Date(from);if(this.view()==='week')to.setDate(to.getDate()+6);return{from:localDateKey(from),to:localDateKey(to)};}