operations-schedule.component.html 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. <header class="page-header">
  2. <div><p>SCHEDULING CENTER</p><h1>排课中心</h1><span>按陪练或时间快速排课,并在提交前发现冲突</span></div>
  3. <div class="header-actions">
  4. <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新</button>
  5. <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17" />新增排课</button>
  6. </div>
  7. </header>
  8. <section class="toolbar">
  9. <div class="segmented">
  10. <button type="button" [class.active]="view()==='day'" (click)="view.set('day');load()">日</button>
  11. <button type="button" [class.active]="view()==='week'" (click)="view.set('week');load()">周</button>
  12. <button type="button" [class.active]="view()==='list'" (click)="view.set('list');load()">列表</button>
  13. </div>
  14. <button type="button" aria-label="上一时间段" (click)="move(view()==='week'?-7:-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button>
  15. <label class="date-filter"><span>日期</span><input type="date" [ngModel]="date()" (ngModelChange)="date.set($event);load()" /></label>
  16. <button type="button" aria-label="下一时间段" (click)="move(view()==='week'?7:1)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button>
  17. <label class="coach-filter"><span>陪练筛选</span><select [ngModel]="coachFilter()" (ngModelChange)="coachFilter.set(+$event);load()"><option [ngValue]="0">全部陪练</option>@for(coach of data()?.coaches||[];track coach.userId){<option [ngValue]="coach.userId">{{coach.displayName}}</option>}</select></label>
  18. @if(data()?.dataDateTo){<button class="latest-data" type="button" (click)="showLatest()">最近有数据:{{data()?.dataDateTo}}</button>}
  19. </section>
  20. <section class="schedule-filterbar" aria-label="排课记录筛选">
  21. <label class="schedule-search"><span>搜索排课</span><span class="filter-control"><lucide-icon [img]="icons.Search" [size]="16"/><input type="search" [ngModel]="scheduleSearch()" (ngModelChange)="scheduleSearch.set($event)" placeholder="学员、陪练、课程或业务ID"/></span></label>
  22. <label class="status-filter"><span>业务状态</span><select [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)"><option value="all">全部状态</option><option value="upcoming">待开始</option><option value="live">上课中</option><option value="coach-ended">陪练已结束</option><option value="confirmed">学员已确认</option><option value="completed">已完课</option><option value="archived">已归档</option><option value="overdue">逾期未完成</option><option value="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
  23. <div class="filter-result" role="status"><strong>{{visibleAppointments().length}}</strong><span>条筛选结果</span><small>当前范围共 {{data()?.appointments?.length||0}} 条</small></div>
  24. @if(hasScheduleFilters()){<button type="button" (click)="clearScheduleFilters()">清除筛选</button>}
  25. </section>
  26. @if(message()){<p class="notice" role="status">{{message()}}</p>}
  27. @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
  28. @if(data()&&!data()?.liveSourceAvailable){<p class="notice error" role="status">{{data()?.liveSourceMessage}}</p>}
  29. <section class="schedule-workflow" aria-label="预约到完课业务流程">
  30. <header>
  31. <div><strong>预约到完课流程</strong><span>按原系统真实操作职责梳理</span></div>
  32. <small>学员端负责查看、确认和评价,不直接创建预约</small>
  33. </header>
  34. <ol>
  35. <li><i>1</i><div><strong>运营创建预约</strong><span>选择学员已绑定课程、时间和陪练</span></div><em>状态 0</em></li>
  36. <li><i>2</i><div><strong>陪练开始并结束</strong><span>教师端开始上课,结束后提交课堂结果</span></div><em>10 → 11</em></li>
  37. <li><i>3</i><div><strong>学员确认评价</strong><span>学员端确认完成并提交评分与评价</span></div><em>状态 20</em></li>
  38. <li><i>4</i><div><strong>陪练生成学习报告</strong><span>学员确认后由陪练提交点评;工资仍按上课记录而非预约计算</span></div><em>状态 30</em></li>
  39. </ol>
  40. </section>
  41. <section class="summary-grid">
  42. <article class="summary-card"><span>当前范围课程</span><strong>{{activeAppointments()}}</strong><small>{{range().from}} 至 {{range().to}}</small></article>
  43. <article class="summary-card"><span>待分配</span><strong>{{unassignedCount()}}</strong><small>未分配老师时不能开始</small></article>
  44. <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length||0}}</strong><small>未配置可用时间会提示</small></article>
  45. <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments||0}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
  46. <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>{{ data()?.sync?.label || '实时排课接口读取' }}</small></article>
  47. </section>
  48. <section class="content-card">
  49. <header class="schedule-content-header">
  50. <div><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击课程可改期或换老师'}}</span></div>
  51. <div class="status-legend" aria-label="排课状态图例">
  52. <span class="status-upcoming"><i></i>待开始</span>
  53. <span class="status-live"><i></i>上课中</span>
  54. <span class="status-coach-ended"><i></i>陪练已结束</span>
  55. <span class="status-confirmed"><i></i>学员已确认</span>
  56. <span class="status-completed"><i></i>已完课</span>
  57. <span class="status-archived"><i></i>已归档</span>
  58. <span class="status-overdue"><i></i>逾期未完成</span>
  59. <span class="status-cancelled"><i></i>已取消</span>
  60. </div>
  61. </header>
  62. @if(view()==='list'){
  63. <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>
  64. @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.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){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
  65. @empty{<tr><td colspan="7"><div class="empty">{{hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
  66. </tbody></table></div>
  67. } @else {
  68. <div class="table-scroll"><div class="calendar">
  69. @for(coach of data()?.coaches||[];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){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="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>}
  70. @empty{<div class="empty">暂无陪练资料</div>}
  71. @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>}
  72. </div></div>
  73. }
  74. </section>
  75. @if(drawerOpen()){
  76. <div class="backdrop" (click)="closeDrawer()"></div>
  77. <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">
  78. <header><div><p>SCHEDULE FORM</p><h2>{{editing()?'修改排课':'新增排课'}}</h2><span>搜索并确认学员、课程和陪练,再检查时间冲突</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
  79. <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="setMode('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="setMode('time')">时间优先</button></div>
  80. <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期、课型和陪练,再补充学员及其已绑定课程':'先选学员、课程和上课时间,陪练候选会把当前时段无冲突的老师排在前面'}}</span></p>
  81. <div class="form-grid" [class.coach-first]="mode()==='coach'" [class.time-first]="mode()==='time'">
  82. <label class="search-field member-step">
  83. <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
  84. <span class="search-control" [class.selected]="selectedMemberObjectId()">
  85. <lucide-icon [img]="icons.Search" [size]="17" />
  86. <input type="search" autocomplete="off" placeholder="输入学员姓名或手机号码" [ngModel]="memberSearch()" (ngModelChange)="updateMemberSearch($event)" (focus)="openSearch('member')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='member'" aria-controls="member-search-results" />
  87. @if(selectedMemberObjectId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  88. </span>
  89. @if(activeSearch()==='member'){
  90. <span class="search-results" id="member-search-results" role="listbox">
  91. @for(member of filteredMembers();track member.objectId){
  92. <button type="button" class="search-option" role="option" [class.read-only]="member.readOnly" [attr.aria-disabled]="member.readOnly" [attr.aria-selected]="selectedMemberObjectId()===member.objectId" (click)="chooseMember(member)"><span><strong>{{member.displayName}}</strong><small>{{member.mobileMasked}}@if(member.userId){ · ID {{member.userId}}}@if(member.readOnly){ · 同步中,仅查看}</small></span><em>{{member.learningStatusLabel}}</em></button>
  93. } @empty {<span class="search-empty">没有匹配的学员,请检查姓名或手机号</span>}
  94. </span>
  95. }
  96. </label>
  97. <label class="search-field course-step">
  98. <span>选择课程 <small>仅显示当前学员已绑定课程,支持模糊匹配</small></span>
  99. <span class="search-control" [class.selected]="courseId()">
  100. <lucide-icon [img]="icons.Search" [size]="17" />
  101. <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="studentId()?'输入课程名称关键词':'请先选择学员'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
  102. @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  103. </span>
  104. @if(activeSearch()==='course'){
  105. <span class="search-results" id="course-search-results" role="listbox">
  106. @for(course of filteredCourses();track course.courseId){
  107. <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId" (click)="chooseCourse(course)"><span><strong>{{course.courseName}}</strong><small>课程 ID {{course.courseId}}</small></span></button>
  108. } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
  109. </span>
  110. }
  111. </label>
  112. <label class="type-step">课程类型<select [ngModel]="classType()" (ngModelChange)="updateClassType(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
  113. <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
  114. <label class="date-step">日期<input type="date" [ngModel]="date()" (ngModelChange)="updateDraftDate($event)" /></label>
  115. <label class="time-step">开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="updateStartTime($event)" /></label>
  116. <label class="search-field wide coach-step">
  117. <span>选择陪练老师 <small>可搜索老师姓名或手机号码</small></span>
  118. <span class="search-control" [class.selected]="coachId()">
  119. <lucide-icon [img]="icons.Search" [size]="17" />
  120. <input type="search" autocomplete="off" placeholder="输入陪练老师姓名或手机号码,可暂不分配" [ngModel]="coachSearch()" (ngModelChange)="updateCoachSearch($event)" (focus)="openSearch('coach')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='coach'" aria-controls="coach-search-results" />
  121. @if(coachId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  122. </span>
  123. @if(activeSearch()==='coach'){
  124. <span class="search-results" id="coach-search-results" role="listbox">
  125. <button type="button" class="search-option muted-option" role="option" [attr.aria-selected]="!coachId()" (click)="clearCoach()"><span><strong>暂不分配</strong><small>保存为待分配预约</small></span></button>
  126. @for(coach of filteredCoaches();track coach.userId){
  127. <button type="button" class="search-option" [class.busy-option]="coachBusyAtSelectedTime(coach.userId)" role="option" [attr.aria-selected]="coachId()===coach.userId" (click)="chooseCoach(coach)"><span><strong>{{coach.displayName}}</strong><small>{{coach.mobileMasked||'未登记手机号'}} · ID {{coach.userId}}</small></span><em>{{coachCandidateHint(coach)}}</em></button>
  128. } @empty {<span class="search-empty">没有匹配的陪练老师,请检查姓名或手机号</span>}
  129. </span>
  130. }
  131. </label>
  132. @if(!editing()){
  133. <label class="recurrence-step">重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event);preview.set(null)"><option value="once">单次</option><option value="daily">每天</option><option value="alternate">隔天</option><option value="weekly">每周</option><option value="same_day_30">同日间隔30分钟</option><option value="same_day_60">同日间隔60分钟</option></select></label>
  134. <label class="occurrences-step">排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event);preview.set(null)" /></label>
  135. }
  136. <label class="wide reason-step">操作原因<textarea rows="2" [ngModel]="reason()" (ngModelChange)="reason.set($event)"></textarea></label>
  137. </div>
  138. @if(preview();as result){
  139. <p class="notice" [class.error]="previewConflictCount()>0">预检完成:可排 {{result['availableCount']||0}} 节,冲突 {{result['conflictCount']||0}} 节。@if(result['issueCount']&&result['issueCount']!==result['conflictCount']){共发现 {{result['issueCount']}} 个冲突原因。}{{result['warnings']||''}}</p>
  140. <div class="preflight-results" aria-label="批量排课逐项预检结果">
  141. @for(item of previewCandidates();track item.index){<article [class.conflict]="!item.available"><i>{{item.index+1}}</i><div><strong>{{item.startsAt|date:'yyyy-MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</strong><span>{{item.available?'可以排课':item.reasons.join(';')}}</span></div><em>{{item.available?'可提交':'需调整'}}</em></article>}
  142. </div>
  143. }
  144. <footer><button type="button" (click)="preflight()" [disabled]="saving()">检查冲突</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
  145. </aside>
  146. }