operations-schedule.component.html 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  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><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="grow"><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 type="button" (click)="showLatest()">最近有数据:{{data()?.dataDateTo}}</button>}
  19. </section>
  20. @if(message()){<p class="notice" role="status">{{message()}}</p>}
  21. @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
  22. <section class="summary-grid">
  23. <article class="summary-card"><span>当前范围课程</span><strong>{{activeAppointments()}}</strong><small>{{range().from}} 至 {{range().to}}</small></article>
  24. <article class="summary-card"><span>待分配</span><strong>{{unassignedCount()}}</strong><small>未分配老师时不能开始</small></article>
  25. <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length||0}}</strong><small>未配置可用时间会提示</small></article>
  26. <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments||0}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
  27. <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>服务端实时读取</small></article>
  28. </section>
  29. <section class="content-card">
  30. <header><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击课程可改期或换老师'}}</span></header>
  31. @if(view()==='list'){
  32. <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>
  33. @for(item of data()?.appointments||[];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}}</td><td><span class="badge" [class.muted]="item.cancelled">{{item.statusLabel}}</span></td><td><div class="row-actions"><button type="button" (click)="openEdit(item)" [disabled]="item.statusCode>0||item.cancelled">修改</button>@if(item.cancelled){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@else{<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button><button type="button" (click)="action(item,'ops/appointments/complete')">完成</button>}</div></td></tr>}
  34. @empty{<tr><td colspan="7"><div class="empty">当前范围暂无排课;历史数据范围为 {{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</div></td></tr>}
  35. </tbody></table></div>
  36. } @else {
  37. <div class="table-scroll"><div class="calendar">
  38. @for(coach of data()?.coaches||[];track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>今日 {{coach.todayAppointments}} 节</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of data()?.appointments||[];track item.targetId){@if(item.coachId===coach.userId){<button type="button" class="appointment" [class.cancelled]="item.cancelled" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{item.statusLabel}}</small></button>}}</div></div>}
  39. @empty{<div class="empty">暂无陪练资料</div>}
  40. @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of data()?.unassigned||[];track item.targetId){<button type="button" class="appointment" [class.cancelled]="item.cancelled" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{item.statusLabel}}</small></button>}</div></div>}
  41. </div></div>
  42. }
  43. </section>
  44. @if(drawerOpen()){
  45. <div class="backdrop" (click)="closeDrawer()"></div>
  46. <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">
  47. <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>
  48. <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="mode.set('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="mode.set('time')">时间优先</button></div>
  49. <div class="form-grid">
  50. <label class="search-field">
  51. <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
  52. <span class="search-control" [class.selected]="selectedMemberObjectId()">
  53. <lucide-icon [img]="icons.Search" [size]="17" />
  54. <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" />
  55. @if(selectedMemberObjectId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  56. </span>
  57. @if(activeSearch()==='member'){
  58. <span class="search-results" id="member-search-results" role="listbox">
  59. @for(member of filteredMembers();track member.objectId){
  60. <button type="button" class="search-option" role="option" [attr.aria-selected]="selectedMemberObjectId()===member.objectId" (click)="chooseMember(member)"><span><strong>{{member.displayName}}</strong><small>{{member.mobileMasked}}@if(member.userId){ · ID {{member.userId}}}</small></span><em>{{member.learningStatusLabel}}</em></button>
  61. } @empty {<span class="search-empty">没有匹配的学员,请检查姓名或手机号</span>}
  62. </span>
  63. }
  64. </label>
  65. <label class="search-field">
  66. <span>选择课程 <small>支持课程名称模糊匹配</small></span>
  67. <span class="search-control" [class.selected]="courseId()">
  68. <lucide-icon [img]="icons.Search" [size]="17" />
  69. <input type="search" autocomplete="off" placeholder="输入课程名称关键词" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
  70. @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  71. </span>
  72. @if(activeSearch()==='course'){
  73. <span class="search-results" id="course-search-results" role="listbox">
  74. @for(course of filteredCourses();track course.courseId){
  75. <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>
  76. } @empty {<span class="search-empty">没有匹配的课程,请更换关键词</span>}
  77. </span>
  78. }
  79. </label>
  80. <label>课程类型<select [ngModel]="classType()" (ngModelChange)="classType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
  81. <label>交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event)"><option>线上</option><option>线下</option></select></label>
  82. <label>日期<input type="date" [ngModel]="date()" (ngModelChange)="date.set($event)" /></label>
  83. <label>开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="startTime.set($event)" /></label>
  84. <label class="search-field wide">
  85. <span>选择陪练老师 <small>可搜索老师姓名或手机号码</small></span>
  86. <span class="search-control" [class.selected]="coachId()">
  87. <lucide-icon [img]="icons.Search" [size]="17" />
  88. <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" />
  89. @if(coachId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  90. </span>
  91. @if(activeSearch()==='coach'){
  92. <span class="search-results" id="coach-search-results" role="listbox">
  93. <button type="button" class="search-option muted-option" role="option" [attr.aria-selected]="!coachId()" (click)="clearCoach()"><span><strong>暂不分配</strong><small>保存为待分配预约</small></span></button>
  94. @for(coach of filteredCoaches();track coach.userId){
  95. <button type="button" class="search-option" 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>{{coach.availabilityConfigured?'已设置时间':'未设置时间'}}</em></button>
  96. } @empty {<span class="search-empty">没有匹配的陪练老师,请检查姓名或手机号</span>}
  97. </span>
  98. }
  99. </label>
  100. @if(!editing()){
  101. <label>重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event)"><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>
  102. <label>排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event)" /></label>
  103. }
  104. <label class="wide">操作原因<textarea rows="2" [ngModel]="reason()" (ngModelChange)="reason.set($event)"></textarea></label>
  105. </div>
  106. @if(preview();as result){<p class="notice" [class.error]="previewConflictCount()>0">预检完成:可排 {{result['availableCount']||0}} 节,冲突 {{result['conflictCount']||0}} 节。{{result['warnings']||''}}</p>}
  107. <footer><button type="button" (click)="preflight()">检查冲突</button><button class="primary" type="button" (click)="save()" [disabled]="saving()">{{saving()?'保存中…':'确认保存'}}</button></footer>
  108. </aside>
  109. }