|
|
@@ -5,11 +5,13 @@ import { DropdownDirective } from '../shared/dropdown.directive';
|
|
|
import { skip, Subscription } from 'rxjs';
|
|
|
import { ActivatedRoute } from '@angular/router';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
-import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
|
|
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, LucideAngularModule, Plus, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
|
|
|
import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
|
|
|
import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
|
|
|
+import { MemberEnrollmentComponent } from '../components/member-enrollment.component';
|
|
|
+import { SCHEDULE_COURSE_STANDARDS } from './schedule-course-standards';
|
|
|
import { OperationsService } from '../operations.service';
|
|
|
import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
|
|
|
import { ScheduleStatusFilter, ScheduleStatusTone, scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
|
|
|
@@ -128,7 +130,7 @@ export function scheduleCoachBusy(
|
|
|
});
|
|
|
}
|
|
|
|
|
|
-@Component({ selector:'app-operations-schedule', host:{class:'schedule-ui'}, standalone:true, imports: [LessonRecordDetailComponent,AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
|
|
|
+@Component({ selector:'app-operations-schedule', host:{class:'schedule-ui'}, standalone:true, imports: [MemberEnrollmentComponent,LessonRecordDetailComponent,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 lessonAppointmentId=signal(''); 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);
|
|
|
@@ -141,14 +143,22 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
|
|
|
readonly hasScheduleFilters=computed(()=>Boolean(this.scheduleSearch().trim())||this.statusFilter()!=='all');
|
|
|
readonly filteredMembers=computed(()=>(this.data()?.members||[]).filter(member=>matchesScheduleSearch(this.memberSearch(),[member.displayName,member.mobileMasked,member.userId])).slice(0,12));
|
|
|
readonly studentCourses=computed(()=>scheduleCoursesForStudent(this.data()?.courses||[],this.studentId()));
|
|
|
- readonly filteredCourses=computed(()=>this.studentCourses().filter(course=>matchesScheduleSearch(this.courseSearch(),[course.courseName,course.categoryName,course.courseId])).slice(0,12));
|
|
|
+ readonly courseStandards=SCHEDULE_COURSE_STANDARDS;
|
|
|
+ readonly categoryKey=signal('word');
|
|
|
+ readonly standardKey=computed(()=>`${this.categoryKey()}:${this.durationMinutes()}`);
|
|
|
+ readonly standardLabel=computed(()=>this.courseStandards.find(row=>row.key===this.standardKey())?.label||'当前课型');
|
|
|
+ readonly categoryCourses=computed(()=>this.studentCourses().filter(course=>course.categoryKey===this.categoryKey()));
|
|
|
+ readonly filteredCourses=computed(()=>this.categoryCourses().filter(course=>matchesScheduleSearch(this.courseId()?'':this.courseSearch(),[course.courseName,course.categoryName,course.courseId])));
|
|
|
+ readonly enrollmentOpen=signal(false);
|
|
|
+ @ViewChild(MemberEnrollmentComponent) enrollment?:MemberEnrollmentComponent;
|
|
|
readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
|
|
|
+ readonly courseSelectionValid=computed(()=>{const course=this.selectedCourse();return Boolean(course?.mappingConfirmed&&course.categoryKey===this.categoryKey()&&course.supportedDurations?.includes(this.durationMinutes()));});
|
|
|
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));
|
|
|
readonly previewCandidates=computed(()=>schedulePreflightCandidates(this.preview()));
|
|
|
private preflightRequest=0; private previewFingerprint=''; private initialRange=true; private saveKey=''; private saveFingerprint=''; private stopLiveRefresh?:()=>void; private loadRequest=0;
|
|
|
previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
|
|
|
previewAvailableCount():number{return Number(this.preview()?.['availableCount']||0);}
|
|
|
- canSavePreview():boolean{return !this.checking()&&this.previewFingerprint===JSON.stringify([this.editing()?.targetId||'',this.payload()])&&Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
|
|
|
+ canSavePreview():boolean{return this.courseSelectionValid()&&!this.enrollmentOpen()&&!this.checking()&&this.previewFingerprint===JSON.stringify([this.editing()?.targetId||'',this.payload()])&&Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
|
|
|
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()?'确认修改':'确认保存';}
|
|
|
statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
|
|
|
statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
|
|
|
@@ -221,9 +231,9 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
|
|
|
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);}
|
|
|
clearScheduleFilters():void{this.scheduleSearchInput.set('');this.statusFilterInput.set('all');this.scheduleSearch.set('');this.statusFilter.set('all');this.searching.set(false);}
|
|
|
visibleCoachCount(coachId:number):number{return this.visibleAppointments().filter(item=>item.coachId===coachId).length;}
|
|
|
- openCreate():void{++this.preflightRequest;this.checking.set(false);this.error.set('');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);}
|
|
|
- openEdit(item:ScheduleAppointment):void{++this.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);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));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
|
|
|
- closeDrawer():void{++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
|
|
|
+ openCreate():void{++this.preflightRequest;this.checking.set(false);this.error.set('');this.editing.set(null);this.enrollmentOpen.set(false);this.categoryKey.set('word');this.recurrence.set('once');this.occurrences.set(1);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);}
|
|
|
+ openEdit(item:ScheduleAppointment):void{++this.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.enrollmentOpen.set(false);this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);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.categoryKey.set(item.courseCategoryKey||this.selectedCourse()?.categoryKey||'');this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
|
|
|
+ closeDrawer():void{if(this.saving()||this.enrollment?.saving()||this.loading())return;this.enrollmentOpen.set(false);++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
|
|
|
async viewFeedback(item:ScheduleAppointment):Promise<void>{this.lessonAppointmentId.set(String(item.generalId));}
|
|
|
async viewWorkflowIssue(targetId:string):Promise<void>{this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'异常详情加载失败');}}
|
|
|
closeFeedback():void{this.feedback.set(null);}
|
|
|
@@ -232,8 +242,37 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
|
|
|
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');}
|
|
|
updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.bindingId.set('');this.preview.set(null);this.openSearch('course');}
|
|
|
updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.preview.set(null);this.openSearch('coach');}
|
|
|
- chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');const courses=scheduleCoursesForStudent(this.data()?.courses||[],member.userId);if(courses.length===1){this.chooseCourse(courses[0]);if(courses[0].mappingConfirmed)this.message.set(`已自动选择该学员唯一的已开课程:${courses[0].courseName}`);}else{this.activeSearch.set(courses.length?'course':null);}this.preview.set(null);}
|
|
|
- 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('该开课分类缺少有效课时规则,请在排课与工资标准中配置。');}
|
|
|
+ chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');const courses=scheduleCoursesForStudent(this.data()?.courses||[],member.userId);if(courses.length===1){this.chooseCourse(courses[0]);if(courses[0].mappingConfirmed)this.message.set(`已自动选择该学员唯一的已开课程:${courses[0].courseName}`);}else{this.selectStandard(this.standardKey());}this.preview.set(null);}
|
|
|
+ chooseCourse(course:ScheduleCalendarData['courses'][number]):void{
|
|
|
+ if(!scheduleCourseAvailable(course,this.studentId()))return;
|
|
|
+ const sameCategory=course.categoryKey===this.categoryKey();
|
|
|
+ this.categoryKey.set(course.categoryKey||'');this.courseId.set(course.courseId);this.bindingId.set(course.bindingId||'');this.courseSearch.set(course.courseName);
|
|
|
+ if(!sameCategory&&!course.supportedDurations?.includes(this.durationMinutes()))this.durationMinutes.set(course.supportedDurations?.[0]||30);
|
|
|
+ this.activeSearch.set(null);this.preview.set(null);
|
|
|
+ }
|
|
|
+ standardAvailability(categoryKey:string):string{
|
|
|
+ if(!this.studentId())return '';
|
|
|
+ const count=this.studentCourses().filter(course=>course.categoryKey===categoryKey).length;
|
|
|
+ return count?`已开 ${count} 门教材`:'待开课';
|
|
|
+ }
|
|
|
+ selectStandard(key:string):void{
|
|
|
+ const standard=this.courseStandards.find(row=>row.key===key);if(!standard)return;
|
|
|
+ const previous=this.selectedCourse();
|
|
|
+ this.categoryKey.set(standard.categoryKey);this.durationMinutes.set(standard.duration);this.error.set('');this.preview.set(null);
|
|
|
+ const courses=this.categoryCourses();
|
|
|
+ const course=courses.find(row=>previous&&row.bindingId===previous.bindingId&&row.courseId===previous.courseId)||(courses.length===1?courses[0]:null);
|
|
|
+ if(course)this.chooseCourse(course);
|
|
|
+ else{this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');this.activeSearch.set(null);}
|
|
|
+ }
|
|
|
+ openEnrollment():void{if(!this.studentId())return;this.preview.set(null);this.activeSearch.set(null);this.enrollmentOpen.set(true);}
|
|
|
+ closeEnrollment():void{if(this.enrollment?.saving()||this.loading())return;this.enrollmentOpen.set(false);}
|
|
|
+ async enrollmentChanged():Promise<void>{
|
|
|
+ const category=this.enrollment?.category||this.categoryKey();
|
|
|
+ await this.load();if(this.error())return;
|
|
|
+ const standard=this.courseStandards.find(row=>row.categoryKey===category&&row.duration===this.durationMinutes())||this.courseStandards.find(row=>row.categoryKey===category);
|
|
|
+ if(standard)this.selectStandard(standard.key);
|
|
|
+ this.enrollmentOpen.set(false);this.message.set('开课资料已更新,请选择教材并检查排课冲突');
|
|
|
+ }
|
|
|
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);}
|
|
|
clearCoach():void{this.coachId.set(0);this.coachSearch.set('');this.activeSearch.set(null);this.preview.set(null);}
|
|
|
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();}
|
|
|
@@ -245,10 +284,11 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
|
|
|
memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
|
|
|
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()};}
|
|
|
async preflight():Promise<void>{
|
|
|
- if(this.checking()||this.saving())return;
|
|
|
+ if(this.checking()||this.saving()||this.enrollmentOpen())return;
|
|
|
this.error.set('');this.preview.set(null);this.previewFingerprint='';
|
|
|
if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}
|
|
|
if(!this.studentId()){this.error.set('该会员缺少排课编号,暂时不能排课');return;}
|
|
|
+ if(!this.courseSelectionValid()){this.error.set('请选择该课型下的有效已开教材,并确认所选时长有可用规则');return;}
|
|
|
const request=++this.preflightRequest,targetId=this.editing()?.targetId||'',payload=this.payload(),fingerprint=JSON.stringify([targetId,payload]);
|
|
|
this.checking.set(true);
|
|
|
try{
|