operations-payroll.component.html 27 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
  1. <header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按有效上课记录与抗遗忘工资核算工资,完成月度审核与发放登记</span></div><div class="header-actions"><button class="ui-button refresh-action" type="button" (click)="refreshLive()" [disabled]="refreshing()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()" />{{refreshing()?'正在刷新':'刷新统计'}}</button>@if(tab()!=='rewards'){<button class="ui-button export-action" type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button>}</div></header>
  2. @if(message()){<p class="notice" role="status">{{message()}}</p>} @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
  3. <nav class="tabs payroll-tabs" aria-label="工资结算功能"><button type="button" [class.active]="tab()==='overview'" (click)="tab.set('overview')"><lucide-icon [img]="icons.TrendingUp" [size]="17" />收益统计</button><button type="button" [class.active]="tab()==='rewards'" (click)="openRewards()"><lucide-icon [img]="icons.Brain" [size]="17" />抗遗忘工资</button><button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />工资批次</button><button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />排课与工资标准</button></nav>
  4. @if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><div class="month-picker-field"><span>统计月份</span><button class="month-picker-trigger" type="button" aria-label="选择统计月份" aria-haspopup="dialog" [attr.aria-expanded]="monthPickerOpen()" [class.active]="monthPickerOpen()" (click)="toggleMonthPicker($event)"><lucide-icon [img]="icons.CalendarDays" [size]="17"/><strong>{{monthLabel()}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="16"/></button>@if(monthPickerOpen()){<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]="month()===monthOptionValue($index)" [attr.aria-pressed]="month()===monthOptionValue($index)" (click)="selectMonth($index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth()">回到本月</button></footer></div>}</div><label><span>门店</span><select [ngModel]="storeId()" (ngModelChange)="storeId.set(+$event)"><option [ngValue]="0">全部门店</option>@for(store of overview()?.stores||[];track store.storeId){<option [ngValue]="store.storeId">{{store.storeName==='未绑定门店'?'未绑定门店(ID '+store.storeId+')':store.storeName}}</option>}</select></label><label class="search-field"><span>陪练搜索</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" [placeholder]="tab()==='rewards'?'老师姓名或 ID':'姓名或手机号'" (keyup.enter)="applyFilters()" /></label><div class="filter-actions"><button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button><button class="ui-button secondary reset-action" type="button" (click)="resetFilters()">重置</button></div><span class="source-chip" [class.cache]="!overview()?.sourceStatus?.sync?.settlementReady" role="status"><i></i>{{overview()?.sourceStatus?.mode==='parse'?'新数据库':overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · 查询于 {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss':'+0800'}}</span></section>}
  5. @if(tab()==='overview'||tab()==='batches'){<app-payroll-cutoff [month]="month()" [canConfirm]="isManager()" (changed)="load(false)" />}
  6. @if(tab()==='overview'){
  7. @if(overview();as data){
  8. @if(data.sourceStatus.sync;as sync){<div class="sync-status" [class.delayed]="!sync.settlementReady" role="status"><strong>{{sync.settlementReady?'工资截止来源已确认':sync.state==='batch_verified'?'旧接口已同步 · 结算待核对':'结算暂不可用'}}</strong><span>来源同步:{{sync.lastSuccessAt?(sync.lastSuccessAt|date:'yyyy-MM-dd HH:mm:ss':'+0800'):'尚无成功记录'}} · 延迟 {{sync.lagSeconds==null?'未知':sync.lagSeconds+' 秒'}}</span><small>{{sync.message}}</small></div>}@else{<p class="notice error">无法核验旧系统同步状态,当前统计仅供查看,不能结算。</p>}
  9. <section class="payroll-metrics overview-metrics" aria-label="工资收益摘要"><article><span>参与陪练</span><strong>{{data.coachCount}}</strong><small>本月有课酬或抗遗忘工资</small></article><article><span>有效课次</span><strong>{{data.lessonCount}}</strong><small>含课型4 {{data.type4Count||0}} 节</small></article><article><span>总工时</span><strong>{{data.totalHours|number:'1.1-1'}}h</strong><small>课型4不计工时</small></article><article><span>基础工资</span><strong>¥{{data.baseAmount|number:'1.2-2'}}</strong><small>优先使用已保存的工资快照</small></article><article><span>抗遗忘工资</span><strong>¥{{data.reviewAmount||0|number:'1.2-2'}}</strong><small>有效复习 {{data.reviewCount||0}} 次</small></article><article><span>当月收入</span><strong>¥{{data.payableAmount|number:'1.2-2'}}</strong><small>按收入月份汇总,结算调整另列</small></article><article><span>批次结算应发</span><strong>{{data.settlementAmount==null?'未生成':'¥'+(data.settlementAmount|number:'1.2-2')}}</strong><small>调整 ¥{{data.settlementAdjustmentAmount||0|number:'1.2-2'}} · 补结 ¥{{data.settlementCarryAmount||0|number:'1.2-2'}}</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>审核工资前必须处理</small></article></section>
  10. <section class="content-card payroll-table-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师查看课酬与抗遗忘工资明细</span></div>@if(isManager()){<button class="ui-button primary create-payroll-action" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.sync?.settlementReady"><lucide-icon [img]="icons.Plus" [size]="17" />生成全公司工资草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验课</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础工资</th><th>复习次数</th><th>抗遗忘工资</th><th>当月收入</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{coachLabel(row.coachName,row.coachId)}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button class="icon-action view-action" type="button" (click)="openCoach(row)" aria-label="查看老师上课明细" title="查看明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="13"><div class="empty">当前筛选范围没有有效计薪记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
  11. }
  12. } @else if(tab()==='rewards'){
  13. @if(rewardCoachId()){<p class="notice">当前查看老师 ID {{rewardCoachId()}} <button class="ui-button secondary compact" (click)="clearRewardCoach()">查看全部老师</button></p>}
  14. <app-review-rewards [filters]="rewardFilters()" [canManage]="isManager()" />
  15. } @else if(tab()==='batches'){
  16. <section class="content-card"><header><div><strong>{{allBatchMonths()?'全部月份':month()}} 全公司工资批次</strong><span>每月仅一张;门店筛选不会拆分工资单</span><button class="ui-button secondary compact" (click)="allBatchMonths.set(true);loadBatchPage(1)">查看全部月份</button></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>月份</th><th>状态</th><th>陪练</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>复习次数</th><th>抗遗忘工资</th><th>调整</th><th>应发</th><th>异常</th><th>操作</th></tr></thead><tbody>@for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.status==='draft'">{{row.statusLabel}}</span></td><td>{{row.coachCount}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td [class.exception]="row.exceptionCount">{{row.exceptionCount}}</td><td><button type="button" aria-label="查看工资单" (click)="open(row)"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="12"><div class="empty">尚未生成工资批次。请先在“收益统计”核对实时数据。</div></td></tr>}</tbody></table></div></section>
  17. @if(page();as batches){<div class="filter-actions"><span>共 {{batches.total}} 个批次 · 第 {{batches.page}} 页</span><button class="ui-button secondary" (click)="loadBatchPage(batches.page-1)" [disabled]="batches.page<=1">上一页</button><button class="ui-button secondary" (click)="loadBatchPage(batches.page+1)" [disabled]="batches.page*batches.pageSize>=batches.total">下一页</button></div>}
  18. } @else {
  19. @if(standards();as config){
  20. <section class="standards-summary overview-metrics"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
  21. <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button><button type="button" [class.active]="standardsSection()==='students'" (click)="loadWritingMappings()">按学员核对语法写作</button></nav>
  22. @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>包含待开始预约及所选工资月份的已完课记录。按学员确认分类并填写依据后,工资统计会刷新。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据 <span class="required-mark">*</span></th><th>操作</th></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.assessmentReason||(row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据')}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><div class="evidence-field"><input [(ngModel)]="row.evidence" placeholder="如:学生档案为小学五年级" /><small>必填,至少 2 个字</small></div></td><td>@if(isManager()){<button class="ui-button secondary" type="button" (click)="saveWritingMapping(row)" [disabled]="saving()" [attr.title]="!row.categoryKey?'请先选择对应分类':row.evidence.trim().length<2?'请填写核对依据(至少 2 个字)':'确认此预约分类'">{{saving()?'确认中…':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
  23. @if(standardsSection()==='rules'){
  24. <section class="content-card"><header><div><strong>当前课程工资标准</strong><span>按最新价目表统一为课程分类、时长、线上和线下单价;新版本从生效日期起计算,历史工资仍使用原规则</span></div></header>
  25. @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="updateRuleCategory($event)">@for(category of activeCategories();track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)">@for(duration of ruleDurations();track duration){<option [ngValue]="duration">{{duration}} 分钟</option>}</select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><app-operations-date-filter class="effective-date-filter" label="生效日期" [value]="effectiveFrom()" (valueChange)="effectiveFrom.set($event)" /><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}
  26. <div class="table-scroll"><table class="data-table standards-table rate-matrix"><thead><tr><th>课程分类</th><th>课程时长</th><th>线上单价</th><th>线下单价</th><th>课时扣减</th><th>当前版本</th><th>操作</th></tr></thead><tbody>@for(row of standardMatrix();track row.categoryKey+':'+row.durationMinutes){<tr><td><strong>{{row.categoryName}}</strong></td><td>{{row.durationMinutes}}分钟</td><td class="money">¥{{row.online?.teacherPay|number:'1.2-2'}}</td><td class="money offline-price">¥{{row.offline?.teacherPay|number:'1.2-2'}}</td><td>{{row.online?creditSummary(row.online):row.offline?creditSummary(row.offline):'—'}}</td><td>{{row.online?.effectiveFrom||row.offline?.effectiveFrom}}<small>{{row.online?.isDefault&&row.offline?.isDefault?'系统价目':'管理员版本'}}</small></td><td>@if(isManager()){<span class="matrix-actions"><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'online')">编辑线上</button><button class="ui-button secondary compact" type="button" (click)="editMatrixRule(row,'offline')">编辑线下</button></span>}@else{<span class="muted-text">—</span>}</td></tr>}</tbody></table></div></section>
  27. <details class="content-card"><summary>工资规则修订记录</summary><div class="table-scroll"><table class="data-table"><thead><tr><th>分类</th><th>时长 / 方式</th><th>生效日期</th><th>工资</th><th>状态</th></tr></thead><tbody>@for(rule of standards()?.rules||[];track rule.objectId){@if(!rule.isDefault){<tr><td>{{rule.categoryName}}</td><td>{{rule.durationMinutes}}分钟 · {{rule.deliveryLabel}}</td><td>{{rule.effectiveFrom}}</td><td>¥{{rule.teacherPay|number:'1.2-2'}}</td><td>{{rule['superseded']?'历史版本':rule.active?'当前版本':'已停用'}}</td></tr>}}</tbody></table></div></details>
  28. } @else if(standardsSection()==='mappings'){
  29. <section class="content-card"><header><div><strong>历史课程映射</strong><span>优先读取课程目录;旧预约保留的课程也可确认分类,语法写作须对应学段</span></div><label class="mapping-search"><span class="sr-only">搜索课程</span><input [ngModel]="mappingSearch()" (ngModelChange)="mappingSearch.set($event)" placeholder="搜索课程、学段、分类或 ID" /></label></header><div class="table-scroll"><table class="data-table mapping-table"><thead><tr><th>历史课程</th><th>系统建议</th><th>数据库大类</th><th>课程分类</th><th>状态</th><th>操作</th></tr></thead><tbody>@for(row of filteredMappings();track row.courseId){<tr><td><strong>{{row.courseName}}</strong><small>ID {{row.courseId}}</small></td><td>{{row.suggestedCategoryName||'需按学段确认'}}</td><td><span class="stage-badge" [class.missing]="!row.stageConfirmed">{{row.stageName||'数据库未归类'}}</span></td><td><select [ngModel]="mappingSelections()[row.courseId]" (ngModelChange)="setMapping(row.courseId,$event)" [disabled]="!mappingCategories(row).length">@if(mappingCategoryUnavailable(row)){<option [value]="row.categoryKey" disabled>{{row.categoryName||'旧分类'}}(不再使用)</option>}@for(category of mappingCategories(row);track category.key){<option [value]="category.key">{{category.name}}</option>}</select></td><td><span class="badge" [class.warn]="!row.confirmed">{{row.confirmed?'已确认':!row.stageConfirmed?'数据库大类缺失':'待确认'}}</span></td><td>@if(isManager()){<button class="ui-button secondary compact" type="button" (click)="confirmMapping(row)" [disabled]="saving()||!mappingCategories(row).length">{{savingMappingId()===row.courseId?'确认中…':row.confirmed?'更新':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6"><div class="empty">没有匹配的课程</div></td></tr>}</tbody></table></div></section>
  30. } @else if(standardsSection()==='categories') {
  31. <section class="content-card"><header><div><strong>标准课程分类</strong><span>分类名称与价目表完全一致,不再显示或使用旧分类</span></div></header><div class="stage-overview" aria-label="数据库课程大类">@for(stage of stageOptions;track stage.key){<span>{{stage.name}}·数据库大类</span>}</div><div class="category-grid">@for(category of activeCategories();track category.key){<article><span>{{category.key}}</span><strong>{{category.name}}</strong><small>固定分类</small></article>}</div></section>
  32. } @else if(standardsSection()==='reservations') {
  33. <section class="content-card reconciliation-card"><header><div><strong>历史未来预约课时预留</strong><span>按预约时间顺序核对现有余额;确认后仅补建预留,不扣减实际课时</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReservations()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reservationReconciliation()?.summary?.total){<button class="ui-button primary" type="button" (click)="commitReservations()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认补建预留</button>}</div></header>
  34. @if(reservationReconciliation();as result){<div class="reconciliation-summary overview-metrics"><article><span>待核对预约</span><strong>{{result.summary.total}}</strong></article><article><span>可补建预留</span><strong>{{result.summary.ready}}</strong></article><article><span>待补课时</span><strong>{{result.summary.insufficient}}</strong></article><article><span>映射/数据异常</span><strong>{{result.summary.exceptions}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员</th><th>课程</th><th>预约时间</th><th>标准</th><th>课时预留</th><th>处理结果</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentName}}<small>ID {{row.studentId}}</small></td><td>{{row.courseName}}</td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.categoryName||'未映射'}}<small>{{row.durationMinutes}}分钟 · {{row.deliveryLabel}}</small></td><td>@for(cost of row.creditCosts;track cost.account){<span>{{cost.label}} {{cost.amount}}</span>}</td><td><span class="badge" [class.warn]="row.status!=='ready'">{{row.statusLabel}}</span><small>{{row.reason}}</small></td></tr>}@empty{<tr><td colspan="7"><div class="empty">所有未来预约均已建立课时预留</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对历史未来预约……</div>}
  35. </section>
  36. } @else {
  37. <section class="content-card reconciliation-card"><header><div><strong>2026 年 9 月起复习课课时差异</strong><span>只预览新规则生效后的已完课记录;确认前不会修改余额</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reconciliation()?.summary?.differenceCount){<button class="ui-button primary" type="button" (click)="commitReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认退回差额</button>}</div></header>
  38. @if(reconciliation();as result){<div class="reconciliation-summary three-column overview-metrics"><article><span>已完成复习课</span><strong>{{result.summary.completedReviewCount}}</strong></article><article><span>待处理差异</span><strong>{{result.summary.differenceCount}}</strong></article><article><span>应退陪练时长</span><strong>{{result.summary.refundTotal|number:'1.1-2'}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员ID</th><th>上课时间</th><th>时长</th><th>规则应扣</th><th>实际已扣</th><th>应退</th><th>状态</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentId}}</td><td>{{row.lessonAt}}</td><td>{{row.durationMinutes}}分钟</td><td>{{row.expected}}</td><td>{{row.charged}}</td><td [class.money]="row.adjustment">{{row.adjustment}}</td><td><span class="badge" [class.warn]="row.adjustment">{{row.status}}</span></td></tr>}@empty{<tr><td colspan="8"><div class="empty">暂无已完成的复习课记录</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对复习课课时……</div>}
  39. </section>
  40. }
  41. }
  42. }
  43. @if(detail()||selectedCoachId()){
  44. <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" appAccessibleDialog (dialogEscape)="closeDrawer()" role="dialog" aria-modal="true" aria-label="工资明细"><header><div><p>{{detail()?.objectId?'PAYROLL BATCH':'COACH LESSONS'}}</p><h2>@if(detail();as batch){ {{batch.month}} · {{batch.statusLabel}} }@else{ {{selectedCoachName()||'老师上课明细'}} }</h2><span>{{coachLines().length}} 条来源记录</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>@if(detail()?.exceptionCount){<p class="notice error">存在 {{detail()?.exceptionCount}} 条异常,处理完成前不能审核。</p>}<div class="table-scroll"><table class="data-table"><thead><tr><th>陪练</th><th>学员 / 课程</th><th>日期</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th><th>说明</th></tr></thead><tbody>@for(line of coachLines();track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}<small>{{line.storeName}}</small></td><td>{{line.studentName}}<small>{{line.courseName}}</small></td><td>{{line.lessonAt|date:'MM-dd HH:mm':'+0800'}}</td><td>{{line.kind==='review_reward'?'抗遗忘工资':line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.reviewTaskId||line.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除:'+(line['exclusionReason']||'未填写原因'):line.exception||line.description||'—'}} @if(detail()?.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button class="ui-button danger compact" type="button" (click)="excludeLine(line.objectId)">排除</button>}</td></tr>}@empty{<tr><td colspan="9"><div class="empty">暂无可查看记录</div></td></tr>}</tbody></table></div>
  45. @if(detail();as batch){@if(batch.status==='draft'&&isManager()){<section class="panel"><h3>新增工资调整</h3><div class="form-grid"><label>陪练老师<select [ngModel]="adjustmentCoachId()" (ngModelChange)="adjustmentCoachId.set(+$event)"><option [ngValue]="0">请选择陪练</option>@for(coach of adjustmentCoaches();track coach.coachId){<option [ngValue]="coach.coachId">{{coachLabel(coach.coachName,coach.coachId)}}</option>}</select></label><label>调整金额<input type="number" step="0.01" [ngModel]="adjustmentAmount()" (ngModelChange)="adjustmentAmount.set(+$event)" placeholder="负数表示扣减" /></label><label class="wide">调整 / 排除原因<input [ngModel]="adjustmentReason()" (ngModelChange)="adjustmentReason.set($event)" placeholder="必填,将写入审计日志" /></label><button class="ui-button secondary" type="button" (click)="addAdjustment()" [disabled]="saving()">添加调整</button></div></section>}@if(batch.status==='reviewed'&&isManager()){<section class="panel"><h3>登记线下发放</h3><div class="form-grid"><label>发放方式<input [ngModel]="paymentMethod()" (ngModelChange)="paymentMethod.set($event)" /></label><label>凭证编号<input [ngModel]="paymentReference()" (ngModelChange)="paymentReference.set($event)" placeholder="银行流水或内部凭证号" /></label></div></section>}}
  46. <footer><button class="ui-button secondary" type="button" (click)="exportExcel()"><lucide-icon [img]="icons.Download" [size]="16" />导出 Excel</button>@if(detail();as batch){@if(isManager()&&batch.status==='draft'){<button class="ui-button secondary" type="button" (click)="refreshDraft()" [disabled]="saving()||!overview()?.sourceStatus?.sync?.settlementReady">刷新并增量重算</button><button class="ui-button primary" type="button" (click)="review()" [disabled]="batch.exceptionCount>0||saving()||!overview()?.sourceStatus?.sync?.settlementReady">审核并永久锁定</button>}@else if(isManager()&&batch.status==='reviewed'){<button class="ui-button primary" type="button" (click)="markPaid()" [disabled]="saving()">登记已发放</button>}}</footer></aside>
  47. }