operations-schedule.component.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { ActivatedRoute } from '@angular/router';
  2. import { CommonModule } from '@angular/common';
  3. import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
  4. import { FormsModule } from '@angular/forms';
  5. import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, LucideAngularModule, Plus, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
  6. import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
  7. import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
  8. import { OperationsService } from '../operations.service';
  9. import { ScheduleStatusFilter, ScheduleStatusTone, scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
  10. const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
  11. type SearchField='member'|'course'|'coach';
  12. export function scheduleRowActions(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'readOnly'>):{
  13. canEdit:boolean;
  14. canCancel:boolean;
  15. canRestore:boolean;
  16. canComplete:boolean;
  17. completeLabel:string;
  18. }{
  19. if(item.readOnly)return{canEdit:false,canCancel:false,canRestore:false,canComplete:false,completeLabel:''};
  20. const status=Number(item.statusCode);
  21. const canComplete=!item.cancelled&&(status===10||status===11);
  22. return{
  23. canEdit:!item.cancelled&&status===0,
  24. canCancel:!item.cancelled&&(status===0||status===10),
  25. canRestore:item.cancelled&&(status===0||status===10),
  26. canComplete,
  27. completeLabel:canComplete?(status===11?'补录上课记录':'补录结束'):'',
  28. };
  29. }
  30. export function matchesScheduleSearch(query:string,values:Array<string|number|undefined|null>):boolean{
  31. const normalizedQuery=query.trim().toLocaleLowerCase();
  32. if(!normalizedQuery)return true;
  33. const normalizedValues=values.map(value=>String(value??'').trim().toLocaleLowerCase()).filter(Boolean);
  34. if(normalizedValues.some(value=>value.includes(normalizedQuery)))return true;
  35. const queryDigits=normalizedQuery.replace(/\D/g,'');
  36. if(!queryDigits)return false;
  37. return normalizedValues.some(value=>{
  38. const valueDigits=value.replace(/\D/g,'');
  39. if(valueDigits.includes(queryDigits))return true;
  40. return value.includes('*')&&queryDigits.length>=7&&valueDigits.startsWith(queryDigits.slice(0,3))&&valueDigits.endsWith(queryDigits.slice(-4));
  41. });
  42. }
  43. export function scheduleCourseAvailable(
  44. course: Pick<ScheduleCalendarData['courses'][number], 'memberIds' | 'availableToAll'>,
  45. studentId: number,
  46. ): boolean {
  47. if (!studentId) return false;
  48. return course.availableToAll === true || (course.memberIds || []).includes(studentId);
  49. }
  50. export function scheduleCoachesWithAppointments(
  51. coaches: ScheduleCalendarData['coaches'],
  52. appointments: ScheduleAppointment[],
  53. ): ScheduleCalendarData['coaches'] {
  54. const scheduledCoachIds = new Set(
  55. appointments
  56. .filter((appointment) => appointment.coachId > 0 && !appointment.coachAccountMissing)
  57. .map((appointment) => appointment.coachId),
  58. );
  59. return coaches.filter((coach) => scheduledCoachIds.has(coach.userId));
  60. }
  61. export type SchedulePreflightCandidate = {
  62. index: number;
  63. startsAt: string;
  64. endsAt: string;
  65. reasons: string[];
  66. available: boolean;
  67. };
  68. export function schedulePreflightCandidates(source: Record<string, unknown> | null): SchedulePreflightCandidate[] {
  69. const candidates = Array.isArray(source?.['candidates']) ? source['candidates'] as Array<Record<string, unknown>> : [];
  70. const conflicts = Array.isArray(source?.['conflicts']) ? source['conflicts'] as Array<Record<string, unknown>> : [];
  71. const reasons = new Map<number, string[]>();
  72. for (const conflict of conflicts) {
  73. const index = Number(conflict['index']);
  74. if (!Number.isInteger(index) || index < 0) continue;
  75. const reason = String(conflict['reason'] || '时间冲突');
  76. const current = reasons.get(index) || [];
  77. if (!current.includes(reason)) reasons.set(index, [...current, reason]);
  78. }
  79. return candidates.map((candidate) => {
  80. const index = Number(candidate['index']);
  81. const candidateReasons = reasons.get(index) || [];
  82. return {
  83. index,
  84. startsAt: String(candidate['startsAt'] || ''),
  85. endsAt: String(candidate['endsAt'] || ''),
  86. reasons: candidateReasons,
  87. available: candidateReasons.length === 0,
  88. };
  89. });
  90. }
  91. export function scheduleCoachBusy(
  92. appointments: ScheduleAppointment[],
  93. coachId: number,
  94. startsAt: string,
  95. durationMinutes: number,
  96. editingTargetId = '',
  97. ): boolean {
  98. const start = Date.parse(startsAt);
  99. const end = start + durationMinutes * 60_000;
  100. if (!coachId || !Number.isFinite(start) || durationMinutes <= 0) return false;
  101. return appointments.some((row) => {
  102. if (row.coachId !== coachId || row.targetId === editingTargetId || row.cancelled || row.statusCode >= 30) return false;
  103. const rowStart = Date.parse(row.startsAt);
  104. const rowEnd = Date.parse(row.endsAt);
  105. return Number.isFinite(rowStart) && Number.isFinite(rowEnd) && rowStart < end && rowEnd > start;
  106. });
  107. }
  108. @Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
  109. export class OperationsScheduleComponent implements OnInit {
  110. 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);
  111. 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);
  112. 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);
  113. readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService);
  114. readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0);
  115. readonly visibleAppointments=computed(()=>(this.data()?.appointments||[]).filter(item=>scheduleMatchesStatus(item,this.statusFilter())&&matchesScheduleSearch(this.scheduleSearch(),[item.studentName,item.coachName,item.courseName,item.studentId,item.coachId,item.courseId,item.generalId])));
  116. readonly visibleCoaches=computed(()=>scheduleCoachesWithAppointments(this.data()?.coaches||[],this.visibleAppointments()));
  117. readonly unassignedCount=computed(()=>this.visibleAppointments().filter(item=>!item.coachId||item.coachAccountMissing).length);
  118. readonly hasScheduleFilters=computed(()=>Boolean(this.scheduleSearch().trim())||this.statusFilter()!=='all');
  119. readonly filteredMembers=computed(()=>(this.data()?.members||[]).filter(member=>matchesScheduleSearch(this.memberSearch(),[member.displayName,member.mobileMasked,member.userId])).slice(0,12));
  120. readonly filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>scheduleCourseAvailable(course,this.studentId())&&matchesScheduleSearch(this.courseSearch(),[course.courseName,course.categoryName,course.courseId])).slice(0,12));
  121. readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
  122. readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).sort((a,b)=>this.mode()==='time'?(Number(this.coachBusyAtSelectedTime(a.userId))-Number(this.coachBusyAtSelectedTime(b.userId)))||(Number(b.availabilityConfigured)-Number(a.availabilityConfigured))||a.displayName.localeCompare(b.displayName,'zh-CN'):a.displayName.localeCompare(b.displayName,'zh-CN')).slice(0,12));
  123. readonly previewCandidates=computed(()=>schedulePreflightCandidates(this.preview()));
  124. private initialRange=true; private saveKey=''; private saveFingerprint='';
  125. previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
  126. previewAvailableCount():number{return Number(this.preview()?.['availableCount']||0);}
  127. canSavePreview():boolean{return Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
  128. saveButtonLabel():string{if(this.saving())return'保存中…';if(!this.preview())return'请先检查冲突';if(this.previewConflictCount()&&this.previewCandidates().length>1&&this.previewAvailableCount()>0)return`仅保存 ${this.previewAvailableCount()} 节无冲突排课`;return this.editing()?'确认修改':'确认保存';}
  129. statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
  130. statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
  131. rowActions(item:ScheduleAppointment):ReturnType<typeof scheduleRowActions>{return scheduleRowActions(item);}
  132. 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);}});}
  133. 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)};}
  134. async load():Promise<void>{this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter());if(this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range();this.data.set(await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter()));this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{this.loading.set(false);}}
  135. move(days:number):void{const value=new Date(`${this.date()}T00:00:00`);value.setDate(value.getDate()+days);this.date.set(localDateKey(value));void this.load();}
  136. showLatest():void{const latest=this.data()?.dataDateTo;if(!latest)return;this.date.set(latest);void this.load();}
  137. async applyScheduleFilters():Promise<void>{if(this.searching())return;this.searching.set(true);this.scheduleSearch.set(this.scheduleSearchInput().trim());this.statusFilter.set(this.statusFilterInput());await new Promise(resolve=>setTimeout(resolve,220));this.searching.set(false);}
  138. clearScheduleFilters():void{this.scheduleSearchInput.set('');this.statusFilterInput.set('all');this.scheduleSearch.set('');this.statusFilter.set('all');this.searching.set(false);}
  139. visibleCoachCount(coachId:number):number{return this.visibleAppointments().filter(item=>item.coachId===coachId).length;}
  140. openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
  141. openEdit(item:ScheduleAppointment):void{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
  142. closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
  143. async viewFeedback(item:ScheduleAppointment):Promise<void>{this.saving.set(true);this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId:item.targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'完课反馈加载失败');}finally{this.saving.set(false);}}
  144. closeFeedback():void{this.feedback.set(null);}
  145. setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}
  146. openSearch(field:SearchField):void{this.activeSearch.set(field);}
  147. updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');this.preview.set(null);this.openSearch('member');}
  148. updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.bindingId.set('');this.openSearch('course');}
  149. updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
  150. chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}const selectedCourse=this.data()?.courses.find(course=>course.courseId===this.courseId()&&course.bindingId===this.bindingId());this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);if(selectedCourse&&!scheduleCourseAvailable(selectedCourse,member.userId)){this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');}this.activeSearch.set(null);this.preview.set(null);}
  151. chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.bindingId.set(course.bindingId||'');this.courseSearch.set(course.courseName);const first=course.supportedDurations?.[0];if(first)this.durationMinutes.set(first);this.activeSearch.set(null);this.preview.set(null);if(!course.mappingConfirmed)this.message.set('该开课分类缺少有效课时规则,请在排课与工资标准中配置。');}
  152. chooseCoach(coach:ScheduleCalendarData['coaches'][number]):void{this.coachId.set(coach.userId);this.coachSearch.set(`${coach.displayName}${coach.mobileMasked?` · ${coach.mobileMasked}`:''}`);this.activeSearch.set(null);this.preview.set(null);}
  153. clearCoach():void{this.coachId.set(0);this.coachSearch.set('');this.activeSearch.set(null);this.preview.set(null);}
  154. updateDraftDate(value:string):void{if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return;this.date.set(value);this.preview.set(null);void this.load();}
  155. updateClassType(value:number):void{this.classType.set(Number(value));this.preview.set(null);}
  156. updateDuration(value:number):void{this.durationMinutes.set(Number(value));this.preview.set(null);}
  157. updateStartTime(value:string):void{this.startTime.set(value);this.preview.set(null);}
  158. coachBusyAtSelectedTime(coachId:number):boolean{return scheduleCoachBusy(this.data()?.appointments||[],coachId,`${this.date()}T${this.startTime()}:00+08:00`,this.durationMinutes(),this.editing()?.targetId||'');}
  159. coachCandidateHint(coach:ScheduleCalendarData['coaches'][number]):string{return this.coachBusyAtSelectedTime(coach.userId)?'该时段已有排课':coach.availabilityConfigured?'该时段可继续预检':'未设置可用时间';}
  160. memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
  161. payload():Record<string,unknown>{return{bindingId:this.bindingId(),studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),durationMinutes:this.durationMinutes(),deliveryMethod:this.deliveryMethod(),deliveryMode:this.deliveryMethod()==='线下'?'offline':'online',date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
  162. async preflight():Promise<void>{this.error.set('');if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能排课;请先补齐该会员的迁移标识');return;}try{this.preview.set(await this.operations.request('ops/appointments/preflight',{targetId:this.editing()?.targetId||'',payload:this.payload()}));}catch(error){this.error.set(error instanceof Error?error.message:'排课预检失败');}}
  163. async save():Promise<void>{if(this.saving())return;if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}if(!this.preview()){this.error.set('请先检查冲突并确认逐项预检结果');return;}if(!this.canSavePreview()){this.error.set('当前没有可以提交的排课,请先调整冲突项');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing(),skipConflicts=!editing&&this.previewConflictCount()>0&&this.previewCandidates().length>1;const fingerprint=JSON.stringify([editing?.targetId||'',this.payload(),skipConflicts,this.reason()]);if(this.saveFingerprint!==fingerprint){this.saveFingerprint=fingerprint;this.saveKey=crypto.randomUUID();}const result=await this.operations.mutate<{created?:number;skipped?:number}>(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),{...this.payload(),skipConflicts},editing?.updatedAt||'',this.saveKey);this.saveFingerprint='';this.saveKey='';this.message.set(editing?'排课已更新':result.skipped?`已创建 ${result.created||0} 节排课,跳过 ${result.skipped} 节冲突项`:`已创建 ${result.created||1} 节排课`);this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
  164. async action(item:ScheduleAppointment,operation:string):Promise<void>{if(item.readOnly){this.message.set('实时业务数据暂不支持从新后台修改,请在原业务端完成操作。');return;}const reason=globalThis.prompt(operation.endsWith('restore')?'请输入恢复原因':operation.endsWith('complete')?'请输入确认完成原因':'请输入取消原因');if(!reason?.trim())return;this.saving.set(true);try{await this.operations.mutate(operation,item.targetId,reason,{},item.updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'操作失败');}finally{this.saving.set(false);}}
  165. }