Просмотр исходного кода

style: unify coach detail month picker

彭峰 1 месяц назад
Родитель
Сommit
c8ee857994

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.html

@@ -54,13 +54,13 @@
     </nav>
 
     @if(tab()==='schedule'){
-      <div class="section-heading"><div><strong>{{scheduleMonth()}} 老师排课记录</strong><span>只筛选排课记录,不影响上课明细和工资记录</span></div><label class="section-month-filter"><span>排课月份</span><input data-testid="schedule-month" type="month" [ngModel]="scheduleMonth()" (ngModelChange)="changeSectionMonth('schedule',$event)" [disabled]="loading()||isSectionLoading('schedule')"/><small>{{isSectionLoading('schedule')?'正在读取…':(detail.appointments.length+' 条排课')}}</small></label></div>
+      <div class="section-heading"><div><strong>{{scheduleMonth()}} 老师排课记录</strong><span>只筛选排课记录,不影响上课明细和工资记录</span></div><div class="section-month-filter"><span>排课月份</span><button class="month-picker-trigger" data-testid="schedule-month" type="button" aria-label="选择排课月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='schedule'" [class.active]="activeMonthPicker()==='schedule'" (click)="toggleMonthPicker('schedule',$event)" [disabled]="loading()||isSectionLoading('schedule')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(scheduleMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('schedule')?'正在读取…':(detail.appointments.length+' 条排课')}}</small>@if(activeMonthPicker()==='schedule'){<div class="month-calendar-popover" role="dialog" aria-label="选择排课月份" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一年" (click)="shiftMonthPickerYear(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><strong>{{monthPickerYear()}}年</strong><button type="button" aria-label="下一年" (click)="shiftMonthPickerYear(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></header><div class="month-grid">@for(name of monthNames;track $index){<button type="button" [class.selected]="scheduleMonth()===monthOptionValue($index)" [attr.aria-pressed]="scheduleMonth()===monthOptionValue($index)" (click)="selectSectionMonth('schedule',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('schedule')">回到本月</button></footer></div>}</div></div>
       <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>上课时间</th><th>学员</th><th>课程</th><th>课型</th><th>状态</th><th>数据来源</th></tr></thead><tbody>
         @for(row of detail.appointments;track row.targetId){<tr><td><strong>{{row.startsAt|date:'yyyy-MM-dd HH:mm'}}</strong><small>{{row.endsAt|date:'HH:mm'}} 结束</small></td><td>{{row.studentName}}</td><td>{{row.courseName}}</td><td>{{row.classTypeLabel}}</td><td><span [class]="'appointment-status '+statusTone(row)">{{statusDisplayLabel(row)}}</span></td><td><small class="source-pill">{{row.sourceLabel}}</small></td></tr>}
         @empty{<tr><td colspan="6"><div class="empty">{{isSectionLoading('schedule')?'正在读取排课记录…':'该月没有可展示的排课记录'}}</div></td></tr>}
       </tbody></table></div>
     }@else if(tab()==='lessons'){
-      <div class="section-heading"><div><strong>{{lessonsMonth()}} 上课与计薪明细</strong><span>只筛选上课明细,不影响排课和工资记录,最多展示最近 300 条</span></div><div class="section-tools"><span class="total-chip">合计 ¥{{(lessonsPayroll()?.payableAmount||0)|number:'1.2-2'}}</span><label class="section-month-filter"><span>上课月份</span><input data-testid="lessons-month" type="month" [ngModel]="lessonsMonth()" (ngModelChange)="changeSectionMonth('lessons',$event)" [disabled]="loading()||isSectionLoading('lessons')"/><small>{{isSectionLoading('lessons')?'正在读取…':(detail.lessons.length+' 条明细')}}</small></label></div></div>
+      <div class="section-heading"><div><strong>{{lessonsMonth()}} 上课与计薪明细</strong><span>只筛选上课明细,不影响排课和工资记录,最多展示最近 300 条</span></div><div class="section-tools"><span class="total-chip">合计 ¥{{(lessonsPayroll()?.payableAmount||0)|number:'1.2-2'}}</span><div class="section-month-filter"><span>上课月份</span><button class="month-picker-trigger" data-testid="lessons-month" type="button" aria-label="选择上课月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='lessons'" [class.active]="activeMonthPicker()==='lessons'" (click)="toggleMonthPicker('lessons',$event)" [disabled]="loading()||isSectionLoading('lessons')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(lessonsMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('lessons')?'正在读取…':(detail.lessons.length+' 条明细')}}</small>@if(activeMonthPicker()==='lessons'){<div class="month-calendar-popover" role="dialog" aria-label="选择上课月份" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一年" (click)="shiftMonthPickerYear(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><strong>{{monthPickerYear()}}年</strong><button type="button" aria-label="下一年" (click)="shiftMonthPickerYear(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></header><div class="month-grid">@for(name of monthNames;track $index){<button type="button" [class.selected]="lessonsMonth()===monthOptionValue($index)" [attr.aria-pressed]="lessonsMonth()===monthOptionValue($index)" (click)="selectSectionMonth('lessons',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('lessons')">回到本月</button></footer></div>}</div></div></div>
       <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>上课时间</th><th>学员</th><th>课程</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th></tr></thead><tbody>
         @for(line of detail.lessons;track line.objectId){<tr><td>{{line.lessonAt|date:'yyyy-MM-dd HH:mm'}}</td><td>{{line.studentName}}</td><td>{{line.courseName}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours|number:'1.1-1'}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.legacyGeneralId||'—'}}</td></tr>}
         @empty{<tr><td colspan="8"><div class="empty">{{isSectionLoading('lessons')?'正在读取上课明细…':'该月没有有效上课记录'}}</div></td></tr>}
@@ -76,7 +76,7 @@
       <div class="availability-form form-grid"><label><span>规则类型</span><select [ngModel]="ruleType()" (ngModelChange)="ruleType.set($event)"><option value="weekly">每周规则</option><option value="date">指定日期</option></select></label>@if(ruleType()==='weekly'){<label><span>星期</span><select [ngModel]="weekday()" (ngModelChange)="weekday.set(+$event)">@for(day of ['日','一','二','三','四','五','六'];track $index){<option [ngValue]="$index">星期{{day}}</option>}</select></label>}@else{<label><span>日期</span><input type="date" [ngModel]="ruleDate()" (ngModelChange)="ruleDate.set($event)"/></label>}<label><span>开始时间</span><input type="time" [ngModel]="startTime()" (ngModelChange)="startTime.set($event)"/></label><label><span>结束时间</span><input type="time" [ngModel]="endTime()" (ngModelChange)="endTime.set($event)"/></label><label><span>状态</span><select [ngModel]="available()" (ngModelChange)="available.set($event)"><option [ngValue]="true">可排课</option><option [ngValue]="false">休息/请假</option></select></label><label><span>原因</span><input [ngModel]="ruleReason()" (ngModelChange)="ruleReason.set($event)"/></label><button class="ui-button primary" type="button" (click)="saveAvailability()" [disabled]="saving()"><lucide-icon [img]="icons.Plus" [size]="16"/>添加规则</button></div>
       <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>规则</th><th>日期/星期</th><th>时段</th><th>状态</th><th>原因</th><th>操作</th></tr></thead><tbody>@for(rule of detail.availability;track rule.objectId){<tr><td>{{rule.ruleType==='weekly'?'每周':'指定日期'}}</td><td>{{rule.ruleType==='weekly'?('星期'+['日','一','二','三','四','五','六'][rule.weekday]):rule.date}}</td><td>{{rule.startTime}}–{{rule.endTime}}</td><td><span class="badge">{{rule.available?'可排课':'休息/请假'}}</span></td><td>{{rule.reason}}</td><td><button class="icon-action danger" type="button" aria-label="删除可用时间规则" (click)="removeAvailability(rule.objectId,rule.updatedAt)"><lucide-icon [img]="icons.Trash2" [size]="16"/></button></td></tr>}@empty{<tr><td colspan="6"><div class="empty">尚未配置;排课时允许选择,但会提示未配置可用时间</div></td></tr>}</tbody></table></div>
     }@else{
-      <div class="section-heading"><div><strong>{{payrollMonth()}} 老师工资记录</strong><span>只筛选工资预估和已生成工资批次,不影响排课和上课明细</span></div><label class="section-month-filter"><span>工资月份</span><input data-testid="payroll-month" type="month" [ngModel]="payrollMonth()" (ngModelChange)="changeSectionMonth('payroll',$event)" [disabled]="loading()||isSectionLoading('payroll')"/><small>{{isSectionLoading('payroll')?'正在读取…':(detail.payrolls.length+' 条工资记录')}}</small></label></div>
+      <div class="section-heading"><div><strong>{{payrollMonth()}} 老师工资记录</strong><span>只筛选工资预估和已生成工资批次,不影响排课和上课明细</span></div><div class="section-month-filter"><span>工资月份</span><button class="month-picker-trigger" data-testid="payroll-month" type="button" aria-label="选择工资月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='payroll'" [class.active]="activeMonthPicker()==='payroll'" (click)="toggleMonthPicker('payroll',$event)" [disabled]="loading()||isSectionLoading('payroll')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(payrollMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('payroll')?'正在读取…':(detail.payrolls.length+' 条工资记录')}}</small>@if(activeMonthPicker()==='payroll'){<div class="month-calendar-popover" role="dialog" aria-label="选择工资月份" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一年" (click)="shiftMonthPickerYear(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17"/></button><strong>{{monthPickerYear()}}年</strong><button type="button" aria-label="下一年" (click)="shiftMonthPickerYear(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17"/></button></header><div class="month-grid">@for(name of monthNames;track $index){<button type="button" [class.selected]="payrollMonth()===monthOptionValue($index)" [attr.aria-pressed]="payrollMonth()===monthOptionValue($index)" (click)="selectSectionMonth('payroll',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('payroll')">回到本月</button></footer></div>}</div></div>
       <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>月份</th><th>状态</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>调整</th><th>应发金额</th></tr></thead><tbody>
         @for(row of detail.payrolls;track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.objectId.startsWith('preview:')">{{row.statusLabel}}</span></td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td></tr>}
         @empty{<tr><td colspan="7"><div class="empty">{{isSectionLoading('payroll')?'正在读取工资记录…':'该月暂无工资记录'}}</div></td></tr>}

+ 152 - 14
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.scss

@@ -183,14 +183,17 @@
 }
 
 .coach-detail-content {
-  overflow: hidden;
+  overflow: visible;
 }
 
 .coach-tabs {
   overflow-x: auto;
-  padding: 8px;
+  gap: 4px;
+  margin-bottom: 0;
+  padding: 8px 10px;
   border-bottom: 1px solid #e8efec;
-  background: #f8faf9;
+  border-radius: 18px 18px 0 0;
+  background: #f5f8f7;
 }
 
 .coach-tabs button {
@@ -199,6 +202,24 @@
   align-items: center;
   gap: 7px;
   min-height: 40px;
+  padding: 8px 11px;
+  border: 1px solid transparent;
+  border-radius: 11px;
+  color: #51665f;
+  font-weight: 750;
+  transition: background .18s ease, border-color .18s ease, box-shadow .18s ease, color .18s ease;
+}
+
+.coach-tabs button:hover {
+  color: #176c55;
+  background: #edf5f2;
+}
+
+.coach-tabs button.active {
+  border-color: #cfe3db;
+  color: #176c55;
+  background: #fff;
+  box-shadow: 0 5px 16px rgba(30, 79, 62, .09);
 }
 
 .coach-tabs em {
@@ -241,10 +262,12 @@
   align-items: center;
   justify-content: space-between;
   gap: 14px;
-  padding: 20px 22px 14px;
+  padding: 18px 22px;
+  border-bottom: 1px solid #edf2f0;
+  background: linear-gradient(180deg, #fff, #fbfdfc);
 }
 
-.section-heading div {
+.section-heading > div:first-child {
   display: grid;
   gap: 4px;
 }
@@ -266,12 +289,13 @@
 }
 
 .section-month-filter {
+  position: relative;
   display: grid;
-  grid-template-columns: auto 154px;
+  grid-template-columns: auto 166px;
   align-items: center;
   gap: 4px 10px;
-  min-width: 238px;
-  padding: 8px 10px 8px 12px;
+  min-width: 252px;
+  padding: 7px 8px 7px 12px;
   border: 1px solid #dce8e3;
   border-radius: 13px;
   color: #60746d;
@@ -287,25 +311,135 @@
   font-weight: 650;
 }
 
-.section-month-filter input {
+.month-picker-trigger {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
   width: 100%;
   height: 38px;
+  align-items: center;
+  gap: 7px;
   padding: 0 10px;
   border: 1px solid #d6e2de;
   border-radius: 10px;
   outline: none;
-  color: #253c34;
+  color: #527067;
   background: #fff;
   font: inherit;
+  text-align: left;
+}
+
+.month-picker-trigger strong {
+  overflow: hidden;
+  color: #253c34;
   font-size: 13px;
-  font-weight: 750;
+  font-weight: 800;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.section-month-filter input:focus {
-  border-color: #2b9b75;
+.month-picker-trigger:hover,
+.month-picker-trigger.active,
+.month-picker-trigger:focus-visible {
+  border-color: #7fae9f;
+  background: #fbfefd;
   box-shadow: 0 0 0 3px rgba(43, 155, 117, .13);
 }
 
+.month-picker-trigger:disabled {
+  cursor: not-allowed;
+  opacity: .55;
+}
+
+.picker-chevron {
+  color: #6c8079;
+  transition: transform .18s ease;
+}
+
+.month-picker-trigger.active .picker-chevron {
+  transform: rotate(180deg);
+}
+
+.month-calendar-popover {
+  position: absolute;
+  z-index: 85;
+  top: calc(100% + 8px);
+  right: 0;
+  width: 304px;
+  padding: 14px;
+  border: 1px solid #d9e5e0;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 20px 55px rgba(24, 62, 49, .2);
+}
+
+.month-calendar-popover header {
+  display: grid;
+  grid-template-columns: 34px 1fr 34px;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.month-calendar-popover header strong {
+  color: #263b34;
+  font-size: 14px;
+  text-align: center;
+}
+
+.month-calendar-popover button {
+  border: 0;
+  border-radius: 9px;
+  color: #415a51;
+  background: transparent;
+}
+
+.month-calendar-popover button:hover,
+.month-calendar-popover button:focus-visible {
+  outline: 0;
+  color: #176c55;
+  background: #edf7f3;
+}
+
+.month-calendar-popover header button {
+  display: grid;
+  width: 34px;
+  height: 34px;
+  place-items: center;
+}
+
+.month-calendar-popover .month-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 7px;
+}
+
+.month-calendar-popover .month-grid button {
+  min-height: 39px;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.month-calendar-popover .month-grid button.selected {
+  color: #fff;
+  background: #1f8066;
+  box-shadow: 0 6px 14px rgba(31, 128, 102, .2);
+}
+
+.month-calendar-popover footer {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid #edf2f0;
+}
+
+.month-calendar-popover footer button {
+  min-height: 32px;
+  padding: 6px 10px;
+  color: #176c55;
+  font-size: 12px;
+  font-weight: 800;
+}
+
 .appointment-status {
   display: inline-flex;
   align-items: center;
@@ -539,7 +673,11 @@
   .section-month-filter {
     width: 100%;
     min-width: 0;
-    grid-template-columns: auto minmax(140px, 1fr);
+    grid-template-columns: auto minmax(150px, 1fr);
+  }
+
+  .month-calendar-popover {
+    width: min(304px, calc(100vw - 44px));
   }
 
   .availability-form {

+ 24 - 3
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.spec.ts

@@ -96,7 +96,7 @@ describe('OperationsCoachDetailComponent month filters', () => {
     const element: HTMLElement = fixture.nativeElement;
 
     fixture.detectChanges();
-    expect(element.querySelector<HTMLInputElement>('[data-testid="schedule-month"]')?.value).toBe(component.scheduleMonth());
+    expect(element.querySelector<HTMLButtonElement>('[data-testid="schedule-month"]')?.textContent).toContain(component.monthLabel(component.scheduleMonth()));
     expect(element.querySelector('[data-testid="lessons-month"]')).toBeNull();
     expect(element.querySelector('[data-testid="payroll-month"]')).toBeNull();
 
@@ -104,17 +104,38 @@ describe('OperationsCoachDetailComponent month filters', () => {
     fixture.detectChanges();
     await fixture.whenStable();
     fixture.detectChanges();
-    expect(element.querySelector<HTMLInputElement>('[data-testid="lessons-month"]')?.value).toBe(component.lessonsMonth());
+    expect(element.querySelector<HTMLButtonElement>('[data-testid="lessons-month"]')?.textContent).toContain(component.monthLabel(component.lessonsMonth()));
     expect(element.querySelector('[data-testid="schedule-month"]')).toBeNull();
 
     component.tab.set('payroll');
     fixture.detectChanges();
     await fixture.whenStable();
     fixture.detectChanges();
-    expect(element.querySelector<HTMLInputElement>('[data-testid="payroll-month"]')?.value).toBe(component.payrollMonth());
+    expect(element.querySelector<HTMLButtonElement>('[data-testid="payroll-month"]')?.textContent).toContain(component.monthLabel(component.payrollMonth()));
     expect(element.querySelector('[data-testid="lessons-month"]')).toBeNull();
   });
 
+  it('opens the branded month calendar and loads the selected month', async () => {
+    const component = fixture.componentInstance;
+    const element: HTMLElement = fixture.nativeElement;
+    element.querySelector<HTMLButtonElement>('[data-testid="schedule-month"]')?.click();
+    fixture.detectChanges();
+
+    const popover = element.querySelector<HTMLElement>('.month-calendar-popover');
+    expect(popover).not.toBeNull();
+    expect(popover?.querySelectorAll('.month-grid button').length).toBe(12);
+    expect(popover?.querySelector<HTMLButtonElement>('.month-grid button.selected')?.textContent?.trim()).toBe('9月');
+
+    popover?.querySelectorAll<HTMLButtonElement>('.month-grid button')[7].click();
+    await fixture.whenStable();
+    fixture.detectChanges();
+
+    expect(component.scheduleMonth()).toBe('2026-08');
+    expect(component.activeMonthPicker()).toBeNull();
+    expect(element.querySelector('.month-calendar-popover')).toBeNull();
+    expect(operations.coachDetail).toHaveBeenCalledWith('legacy-payroll-coach:1810', '2026-08', false);
+  });
+
   it('uses the same overdue and archive status rules as the scheduling center', () => {
     const component = fixture.componentInstance;
     const expired = {

+ 12 - 4
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.ts

@@ -1,8 +1,8 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, HostListener, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
-import { ArrowLeft, Building2, CalendarDays, Clock3, GraduationCap, LucideAngularModule, Phone, Plus, RefreshCw, Star, Trash2, UserRound, WalletCards } from 'lucide-angular';
+import { ArrowLeft, Building2, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Clock3, GraduationCap, LucideAngularModule, Phone, Plus, RefreshCw, Star, Trash2, UserRound, WalletCards } from 'lucide-angular';
 import { CoachDetailData, ScheduleAppointment } from '../admin.models';
 import { OperationsService } from '../operations.service';
 import { ScheduleStatusTone, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
@@ -12,15 +12,23 @@ const CURRENT_MONTH = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/S
 
 @Component({selector:'app-operations-coach-detail',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coach-detail.component.html',styleUrls:['./operations-professional.scss','./operations-coach-detail.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsCoachDetailComponent implements OnInit{
- readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly sectionLoading=signal<MonthSection|null>(null);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'lessons'|'reviews'|'availability'|'payroll'>('schedule');readonly summaryMonth=signal(CURRENT_MONTH);readonly scheduleMonth=signal(CURRENT_MONTH);readonly lessonsMonth=signal(CURRENT_MONTH);readonly payrollMonth=signal(CURRENT_MONTH);readonly lessonsPayroll=signal<CoachDetailData['currentPayroll']|null>(null);readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly upcomingCount=computed(()=>this.data()?.appointments.filter((row)=>!row.cancelled&&row.statusCode<30&&new Date(row.endsAt).getTime()>=Date.now()).length||0);readonly icons={ArrowLeft,Building2,CalendarDays,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';
+ readonly data=signal<CoachDetailData|null>(null);readonly loading=signal(true);readonly sectionLoading=signal<MonthSection|null>(null);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'schedule'|'lessons'|'reviews'|'availability'|'payroll'>('schedule');readonly currentMonth=CURRENT_MONTH;readonly summaryMonth=signal(CURRENT_MONTH);readonly scheduleMonth=signal(CURRENT_MONTH);readonly lessonsMonth=signal(CURRENT_MONTH);readonly payrollMonth=signal(CURRENT_MONTH);readonly lessonsPayroll=signal<CoachDetailData['currentPayroll']|null>(null);readonly activeMonthPicker=signal<MonthSection|null>(null);readonly monthPickerYear=signal(new Date().getFullYear());readonly monthNames=['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];readonly ruleType=signal<'weekly'|'date'>('weekly');readonly weekday=signal(1);readonly ruleDate=signal('');readonly startTime=signal('09:00');readonly endTime=signal('21:00');readonly available=signal(true);readonly ruleReason=signal('常规工作时间');readonly upcomingCount=computed(()=>this.data()?.appointments.filter((row)=>!row.cancelled&&row.statusCode<30&&new Date(row.endsAt).getTime()>=Date.now()).length||0);readonly icons={ArrowLeft,Building2,CalendarDays,ChevronDown,ChevronLeft,ChevronRight,Clock3,GraduationCap,Phone,Plus,RefreshCw,Star,Trash2,UserRound,WalletCards};private readonly operations=inject(OperationsService);private readonly objectId=inject(ActivatedRoute).snapshot.paramMap.get('objectId')||'';
  ngOnInit():void{void this.load();}
  async load(refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,this.summaryMonth(),refresh),current=this.data();if(!current){this.data.set(detail);this.scheduleMonth.set(detail.payrollMonth);this.lessonsMonth.set(detail.payrollMonth);this.payrollMonth.set(detail.payrollMonth);this.lessonsPayroll.set(detail.currentPayroll);}else{this.data.set({...detail,appointments:this.scheduleMonth()===detail.payrollMonth?detail.appointments:current.appointments,lessons:this.lessonsMonth()===detail.payrollMonth?detail.lessons:current.lessons,payrolls:this.payrollMonth()===detail.payrollMonth?detail.payrolls:current.payrolls});if(this.lessonsMonth()===detail.payrollMonth)this.lessonsPayroll.set(detail.currentPayroll);}this.summaryMonth.set(detail.payrollMonth);}catch(error){this.error.set(error instanceof Error?error.message:'陪练详情加载失败');}finally{this.loading.set(false);}}
  async changeSectionMonth(section:MonthSection,value:string):Promise<void>{if(!/^\d{4}-\d{2}$/.test(value)||value===this.sectionMonth(section))return;const previous=this.sectionMonth(section);this.setSectionMonth(section,value);this.sectionLoading.set(section);this.error.set('');try{const detail=await this.operations.coachDetail(this.objectId,value,false);this.data.update((current)=>{if(!current)return detail;if(section==='schedule')return{...current,appointments:detail.appointments};if(section==='lessons')return{...current,lessons:detail.lessons,reviews:detail.reviews};return{...current,payrolls:detail.payrolls};});if(section==='lessons')this.lessonsPayroll.set(detail.currentPayroll);}catch(error){this.setSectionMonth(section,previous);this.error.set(error instanceof Error?error.message:'月份数据加载失败');}finally{this.sectionLoading.set(null);}}
+ @HostListener('document:click') closeMonthPicker():void{this.activeMonthPicker.set(null);}
+ @HostListener('document:keydown.escape') closeMonthPickerWithKeyboard():void{this.closeMonthPicker();}
+ toggleMonthPicker(section:MonthSection,event:Event):void{event.stopPropagation();if(this.loading()||this.isSectionLoading(section))return;if(this.activeMonthPicker()===section){this.activeMonthPicker.set(null);return;}const year=Number(this.sectionMonth(section).slice(0,4));this.monthPickerYear.set(year||new Date().getFullYear());this.activeMonthPicker.set(section);}
+ shiftMonthPickerYear(delta:number,event:Event):void{event.stopPropagation();this.monthPickerYear.update((year)=>year+delta);}
+ async selectSectionMonth(section:MonthSection,monthIndex:number):Promise<void>{const value=this.monthOptionValue(monthIndex);this.activeMonthPicker.set(null);await this.changeSectionMonth(section,value);}
+ async selectCurrentMonth(section:MonthSection):Promise<void>{this.activeMonthPicker.set(null);await this.changeSectionMonth(section,this.currentMonth);}
+ monthLabel(value:string):string{const [year,month]=value.split('-');return year&&month?`${year}年${Number(month)}月`:'选择月份';}
+ monthOptionValue(monthIndex:number):string{return `${this.monthPickerYear()}-${String(monthIndex+1).padStart(2,'0')}`;}
  isSectionLoading(section:MonthSection):boolean{return this.sectionLoading()===section;}
  statusTone(row:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(row);}
  statusDisplayLabel(row:ScheduleAppointment):string{return scheduleStatusLabel(row);}
  async saveAvailability():Promise<void>{const coach=this.data()?.coach;if(!coach)return;this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/save','',this.ruleReason(),{coachId:coach.userId,ruleType:this.ruleType(),weekday:this.weekday(),date:this.ruleDate(),startTime:this.startTime(),endTime:this.endTime(),available:this.available()});this.message.set('可用时间已保存');await this.load(true);}catch(error){this.error.set(error instanceof Error?error.message:'可用时间保存失败');}finally{this.saving.set(false);}}
  async removeAvailability(objectId:string,updatedAt:string):Promise<void>{this.saving.set(true);try{await this.operations.mutate('ops/coach-availability/delete',objectId,'删除错误的可用时间规则',{},updatedAt);await this.load(true);}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
- private sectionMonth(section:MonthSection):string{return section==='schedule'?this.scheduleMonth():section==='lessons'?this.lessonsMonth():this.payrollMonth();}
+ sectionMonth(section:MonthSection):string{return section==='schedule'?this.scheduleMonth():section==='lessons'?this.lessonsMonth():this.payrollMonth();}
  private setSectionMonth(section:MonthSection,value:string):void{if(section==='schedule')this.scheduleMonth.set(value);else if(section==='lessons')this.lessonsMonth.set(value);else this.payrollMonth.set(value);}
 }