operations-schedule.component.ts 11 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. import { CommonModule } from '@angular/common';
  2. import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
  3. import { FormsModule } from '@angular/forms';
  4. import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LucideAngularModule, Plus, RefreshCw, Search, X } from 'lucide-angular';
  5. import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
  6. import { OperationsService } from '../operations.service';
  7. const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
  8. type SearchField='member'|'course'|'coach';
  9. export type ScheduleStatusTone='status-upcoming'|'status-live'|'status-coach-ended'|'status-confirmed'|'status-completed'|'status-overdue'|'status-cancelled'|'status-neutral';
  10. const scheduleStatusLabels:Partial<Record<number,string>>={0:'待开始',10:'上课中',11:'陪练已结束',20:'学员已确认',30:'已完课'};
  11. export function scheduleStatusTone(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'endsAt'>,now=Date.now()):ScheduleStatusTone{
  12. if(item.cancelled)return 'status-cancelled';
  13. const endTime=Date.parse(item.endsAt);
  14. if((item.statusCode===0||item.statusCode===10)&&Number.isFinite(endTime)&&endTime<now)return 'status-overdue';
  15. if(item.statusCode===0)return 'status-upcoming';
  16. if(item.statusCode===10)return 'status-live';
  17. if(item.statusCode===11)return 'status-coach-ended';
  18. if(item.statusCode===20)return 'status-confirmed';
  19. if(item.statusCode===30)return 'status-completed';
  20. return 'status-neutral';
  21. }
  22. export function scheduleStatusLabel(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'statusLabel'|'endsAt'>,now=Date.now()):string{
  23. const tone=scheduleStatusTone(item,now);
  24. if(tone==='status-cancelled')return '已取消';
  25. if(tone==='status-overdue')return '逾期未完成';
  26. return scheduleStatusLabels[item.statusCode]||item.statusLabel||`状态 ${item.statusCode}`;
  27. }
  28. export function matchesScheduleSearch(query:string,values:Array<string|number|undefined|null>):boolean{
  29. const normalizedQuery=query.trim().toLocaleLowerCase();
  30. if(!normalizedQuery)return true;
  31. const normalizedValues=values.map(value=>String(value??'').trim().toLocaleLowerCase()).filter(Boolean);
  32. if(normalizedValues.some(value=>value.includes(normalizedQuery)))return true;
  33. const queryDigits=normalizedQuery.replace(/\D/g,'');
  34. if(!queryDigits)return false;
  35. return normalizedValues.some(value=>{
  36. const valueDigits=value.replace(/\D/g,'');
  37. if(valueDigits.includes(queryDigits))return true;
  38. return value.includes('*')&&queryDigits.length>=7&&valueDigits.startsWith(queryDigits.slice(0,3))&&valueDigits.endsWith(queryDigits.slice(-4));
  39. });
  40. }
  41. @Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
  42. export class OperationsScheduleComponent implements OnInit {
  43. 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 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);
  44. readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); 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);
  45. readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null);
  46. readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,Plus,RefreshCw,Search,X}; private readonly operations=inject(OperationsService);
  47. readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0); readonly unassignedCount=computed(()=>this.data()?.unassigned.length||0);
  48. readonly filteredMembers=computed(()=>(this.data()?.members||[]).filter(member=>matchesScheduleSearch(this.memberSearch(),[member.displayName,member.mobileMasked,member.userId])).slice(0,12));
  49. readonly filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>matchesScheduleSearch(this.courseSearch(),[course.courseName,course.courseId])).slice(0,12));
  50. readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).slice(0,12));
  51. private initialRange=true;
  52. previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
  53. statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
  54. statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
  55. ngOnInit():void{void this.load();}
  56. 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)};}
  57. 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);}}
  58. 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();}
  59. showLatest():void{const latest=this.data()?.dataDateTo;if(!latest)return;this.date.set(latest);void this.load();}
  60. openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.coachId.set(0);this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
  61. 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.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.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);}
  62. closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
  63. openSearch(field:SearchField):void{this.activeSearch.set(field);}
  64. updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.openSearch('member');}
  65. updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.openSearch('course');}
  66. updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
  67. chooseMember(member:ScheduleCalendarData['members'][number]):void{this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.activeSearch.set(null);this.preview.set(null);}
  68. chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.courseSearch.set(course.courseName);this.activeSearch.set(null);this.preview.set(null);}
  69. 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);}
  70. clearCoach():void{this.coachId.set(0);this.coachSearch.set('');this.activeSearch.set(null);this.preview.set(null);}
  71. memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
  72. payload():Record<string,unknown>{return{studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),deliveryMethod:this.deliveryMethod(),date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
  73. 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:'排课预检失败');}}
  74. async save():Promise<void>{if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing();await this.operations.mutate(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),this.payload(),editing?.updatedAt||'');this.message.set(editing?'排课已更新':'排课已创建');this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
  75. 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);}}
  76. }