Explorar el Código

style: polish payroll operations interface

彭峰 hace 1 mes
padre
commit
3be57a5434

+ 8 - 8
src/app/admin/pages/operations-payroll.component.html

@@ -1,20 +1,20 @@
-<header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按旧系统有效上课记录核算收益,并完成月度审核与发放登记</span></div><div class="header-actions"><button type="button" (click)="refreshLive()" [disabled]="refreshing()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()" />{{refreshing()?'正在刷新':'刷新实时数据'}}</button><button type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button></div></header>
+<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><button class="ui-button export-action" type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button></div></header>
 @if(message()){<p class="notice" role="status">{{message()}}</p>} @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
-<nav class="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()==='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>
-@if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label><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}}</option>}</select></label><label class="search-field"><span>陪练搜索</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="姓名或手机号" (keyup.enter)="applyFilters()" /></label><button type="button" (click)="applyFilters()">查询</button><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'"><i></i>{{overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss'}}</span></section>}
+<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()==='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>
+@if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label><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="姓名或手机号" (keyup.enter)="applyFilters()" /></label><button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'" role="status"><i></i>{{overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss'}}</span></section>}
 @if(tab()==='overview'){
  @if(overview();as data){
   @if(data.sourceStatus.mode==='cache'){<p class="notice error">{{data.sourceStatus.message}}。当前可以查看缓存,但不能生成或审核工资单。</p>}
   <section class="payroll-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.payableAmount|number:'1.2-2'}}</strong><small>生成草稿后可调账</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>生成工资前必须处理</small></article></section>
-  <section class="content-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师可查看每一节课的计薪依据</span></div>@if(isManager()){<button class="primary" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.available||data.exceptionCount>0"><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></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{row.coachName}}</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 class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button type="button" (click)="openCoach(row)" aria-label="查看老师上课明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="11"><div class="empty">当前筛选范围没有有效上课记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
+  <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.available||data.exceptionCount>0"><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></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 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="11"><div class="empty">当前筛选范围没有有效上课记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
  }
 } @else if(tab()==='batches'){
  <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>工时</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.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="10"><div class="empty">尚未生成工资批次。请先在“收益统计”核对实时数据。</div></td></tr>}</tbody></table></div></section>
 } @else {
- <section class="content-card"><header><div><strong>按生效日期管理课酬</strong><span>历史工资保存单价快照,不受后续改价影响</span></div></header>@if(isManager()){<div class="panel"><div class="form-grid"><label>课程类型<select [ngModel]="rateType()" (ngModelChange)="rateType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">课型4(如需启用)</option></select></label><label>每节课酬<input type="number" min="0" step="0.01" [ngModel]="rateAmount()" (ngModelChange)="rateAmount.set(+$event)" /></label><label>计薪工时<input type="number" min="0" step="0.5" [ngModel]="rateHours()" (ngModelChange)="rateHours.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><button class="primary" type="button" (click)="saveRate()" [disabled]="saving()">保存规则</button></div></div>}<div class="table-scroll"><table class="data-table"><thead><tr><th>课型</th><th>每节课酬</th><th>计薪工时</th><th>生效日期</th><th>状态</th></tr></thead><tbody>@for(rate of rates();track rate.objectId){<tr><td>{{rate.classTypeLabel}}</td><td class="money">¥{{rate.amount|number:'1.2-2'}}</td><td>{{rate.durationHours}}h</td><td>@if(rate.isDefault){系统默认}@else { {{rate.effectiveFrom|date:'yyyy-MM-dd'}} }</td><td><span class="badge">{{rate.active?'生效':'停用'}}</span>@if(rate.isDefault){<small>原系统默认规则</small>}</td></tr>}</tbody></table></div><p class="type4-note">课型4当前没有默认规则,保持“只计课次、不计工资、不计工时”。只有明确新增生效规则后才应改变口径。</p></section>
+ <section class="content-card"><header><div><strong>按生效日期管理课酬</strong><span>历史工资保存单价快照,不受后续改价影响</span></div></header>@if(isManager()){<div class="panel"><div class="form-grid"><label>课程类型<select [ngModel]="rateType()" (ngModelChange)="rateType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">课型4(如需启用)</option></select></label><label>每节课酬<input type="number" min="0" step="0.01" [ngModel]="rateAmount()" (ngModelChange)="rateAmount.set(+$event)" /></label><label>计薪工时<input type="number" min="0" step="0.5" [ngModel]="rateHours()" (ngModelChange)="rateHours.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><button class="ui-button primary" type="button" (click)="saveRate()" [disabled]="saving()">保存规则</button></div></div>}<div class="table-scroll"><table class="data-table"><thead><tr><th>课型</th><th>每节课酬</th><th>计薪工时</th><th>生效日期</th><th>状态</th></tr></thead><tbody>@for(rate of rates();track rate.objectId){<tr><td>{{rate.classTypeLabel}}</td><td class="money">¥{{rate.amount|number:'1.2-2'}}</td><td>{{rate.durationHours}}h</td><td>@if(rate.isDefault){系统默认}@else { {{rate.effectiveFrom|date:'yyyy-MM-dd'}} }</td><td><span class="badge">{{rate.active?'生效':'停用'}}</span>@if(rate.isDefault){<small>原系统默认规则</small>}</td></tr>}</tbody></table></div><p class="type4-note">课型4当前没有默认规则,保持“只计课次、不计工资、不计工时”。只有明确新增生效规则后才应改变口径。</p></section>
 }
 @if(detail()||selectedCoachId()){
- <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" 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{ 老师上课明细 }</h2><span>{{coachLines().length}} 条来源记录</span></div><button 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'}}</td><td>{{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.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':line.exception||line.description||'—'}} @if(detail()?.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button type="button" (click)="excludeLine(line.objectId)">排除</button>}</td></tr>}@empty{<tr><td colspan="9"><div class="empty">暂无可查看记录</div></td></tr>}</tbody></table></div>
- @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 overview()?.coachItems||[];track coach.coachId){<option [ngValue]="coach.coachId">{{coach.coachName}} · {{coach.storeName}}</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 type="button" (click)="addAdjustment()">添加调整</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>}}
- <footer><button type="button" (click)="exportExcel()"><lucide-icon [img]="icons.Download" [size]="16" />导出 Excel</button>@if(detail();as batch){@if(isManager()&&batch.status==='draft'){<button type="button" (click)="refreshDraft()" [disabled]="saving()">刷新并增量重算</button><button class="primary" type="button" (click)="review()" [disabled]="batch.exceptionCount>0||saving()">审核并永久锁定</button>}@else if(isManager()&&batch.status==='reviewed'){<button class="primary" type="button" (click)="markPaid()" [disabled]="saving()">登记已发放</button>}}</footer></aside>
+ <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" 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'}}</td><td>{{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.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':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>
+ @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 overview()?.coachItems||[];track coach.coachId){<option [ngValue]="coach.coachId">{{coachLabel(coach.coachName,coach.coachId)}} · {{coach.storeName}}</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()">添加调整</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>}}
+ <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()">刷新并增量重算</button><button class="ui-button primary" type="button" (click)="review()" [disabled]="batch.exceptionCount>0||saving()">审核并永久锁定</button>}@else if(isManager()&&batch.status==='reviewed'){<button class="ui-button primary" type="button" (click)="markPaid()" [disabled]="saving()">登记已发放</button>}}</footer></aside>
 }

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
src/app/admin/pages/operations-payroll.component.scss


+ 6 - 5
src/app/admin/pages/operations-payroll.component.ts

@@ -1,7 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { Banknote, Download, Eye, FileCheck2, LucideAngularModule, Plus, RefreshCw, Settings2, TrendingUp, X } from 'lucide-angular';
+import { Banknote, Download, Eye, FileCheck2, LucideAngularModule, Plus, RefreshCw, Search, Settings2, TrendingUp, X } from 'lucide-angular';
 import { utils, writeFile } from 'xlsx';
 import { OperationsPage, PayRate, PayrollCoachSummary, PayrollDetail, PayrollLine, PayrollOverview, PayrollSummary } from '../admin.models';
 import { AdminSessionService } from '../admin-session.service';
@@ -11,13 +11,14 @@ const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.g
 
 @Component({selector:'app-operations-payroll',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-payroll.component.html',styleUrls:['./operations-professional.scss','./operations-payroll.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsPayrollComponent implements OnInit{
- readonly overview=signal<PayrollOverview|null>(null);readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'overview'|'batches'|'rates'>('overview');readonly month=signal(localDateKey(new Date()).slice(0,7));readonly storeId=signal(0);readonly search=signal('');readonly selectedCoachId=signal(0);readonly rateType=signal(1);readonly rateAmount=signal(20);readonly rateHours=signal(.5);readonly effectiveFrom=signal(localDateKey(new Date()));readonly adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly paymentMethod=signal('银行转账');readonly paymentReference=signal('');readonly icons={Banknote,Download,Eye,FileCheck2,Plus,RefreshCw,Settings2,TrendingUp,X};readonly coachLines=computed(()=>{const coachId=this.selectedCoachId(),source=this.detail()?.lines||this.overview()?.lines||[];return coachId?source.filter((line)=>line.coachId===coachId):source;});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
+ readonly overview=signal<PayrollOverview|null>(null);readonly page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'overview'|'batches'|'rates'>('overview');readonly month=signal(localDateKey(new Date()).slice(0,7));readonly storeId=signal(0);readonly search=signal('');readonly selectedCoachId=signal(0);readonly selectedCoachName=signal('');readonly rateType=signal(1);readonly rateAmount=signal(20);readonly rateHours=signal(.5);readonly effectiveFrom=signal(localDateKey(new Date()));readonly adjustmentCoachId=signal(0);readonly adjustmentAmount=signal(0);readonly adjustmentReason=signal('');readonly paymentMethod=signal('银行转账');readonly paymentReference=signal('');readonly icons={Banknote,Download,Eye,FileCheck2,Plus,RefreshCw,Search,Settings2,TrendingUp,X};readonly coachLines=computed(()=>{const coachId=this.selectedCoachId(),coachName=this.selectedCoachName(),source=this.detail()?.lines||this.overview()?.lines||[];return coachId?source.filter((line)=>line.coachId===coachId).map((line)=>({...line,coachName:coachName||line.coachName})):source;});private readonly operations=inject(OperationsService);private readonly sessions=inject(AdminSessionService);
  ngOnInit():void{void this.load(true);}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
  async load(refreshAfterLoad=false):Promise<void>{this.loading.set(true);this.error.set('');try{const [overview,page,rates]=await Promise.all([this.operations.payrollOverview(this.month(),this.storeId(),this.search(),false),this.operations.payrolls(),this.operations.payRates()]);this.overview.set(overview);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}if(refreshAfterLoad)void this.refreshLive();}
  async refreshLive():Promise<void>{if(this.refreshing())return;this.refreshing.set(true);this.error.set('');try{this.overview.set(await this.operations.payrollOverview(this.month(),this.storeId(),this.search(),true));this.message.set('实时上课记录已刷新');}catch(error){this.error.set(error instanceof Error?error.message:'实时工资数据刷新失败,当前继续显示缓存');}finally{this.refreshing.set(false);}}
  async applyFilters():Promise<void>{await this.load(true);}
  async open(row:PayrollSummary):Promise<void>{this.loading.set(true);this.error.set('');try{this.selectedCoachId.set(0);this.detail.set(await this.operations.payrollDetail(row.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{this.loading.set(false);}}
- openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.detail.set(null);}closeDrawer():void{this.detail.set(null);this.selectedCoachId.set(0);}
+ coachLabel(name:string,coachId:number):string{const value=String(name||'').trim();return !value||value===String(coachId)?`陪练 ${coachId}`:value;}
+ openCoach(row:PayrollCoachSummary):void{this.selectedCoachId.set(row.coachId);this.selectedCoachName.set(this.coachLabel(row.coachName,row.coachId));this.detail.set(null);}closeDrawer():void{this.detail.set(null);this.selectedCoachId.set(0);this.selectedCoachName.set('');}
  async create():Promise<void>{if(!this.overview()?.sourceStatus.available){this.error.set('实时上课记录不可用,不能生成工资草稿');return;}this.saving.set(true);this.error.set('');try{const result=await this.operations.mutate<PayrollSummary>('ops/payroll/create','',`生成 ${this.month()} 月全公司工资草稿`,{month:this.month()});await this.load();await this.open(result);this.message.set('全公司工资草稿已生成,尚未审核或发放');this.tab.set('batches');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
  async saveRate():Promise<void>{this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/pay-rates/save','',`调整课型 ${this.rateType()} 的计价规则`,{classType:this.rateType(),amount:this.rateAmount(),durationHours:this.rateHours(),effectiveFrom:this.effectiveFrom(),active:true});this.message.set('计价规则已保存');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'计价规则保存失败');}finally{this.saving.set(false);}}
  async addAdjustment():Promise<void>{const batch=this.detail();if(!batch||!this.adjustmentCoachId()||!this.adjustmentAmount()||!this.adjustmentReason().trim()){this.error.set('请选择陪练并填写非零调整金额和原因');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/add-adjustment',batch.objectId,this.adjustmentReason(),{coachId:this.adjustmentCoachId(),amount:this.adjustmentAmount(),description:this.adjustmentReason()},batch.updatedAt);await this.open(batch);this.adjustmentAmount.set(0);this.adjustmentReason.set('');this.message.set('调整项已保存');}catch(error){this.error.set(error instanceof Error?error.message:'调整项保存失败');}finally{this.saving.set(false);}}
@@ -25,6 +26,6 @@ export class OperationsPayrollComponent implements OnInit{
  async excludeLine(objectId:string):Promise<void>{const batch=this.detail(),reason=this.adjustmentReason().trim();if(!batch||batch.status!=='draft')return;if(!reason){this.error.set('请先在调整原因中填写排除该异常记录的原因');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/exclude-line',objectId,reason,{},'');await this.open(batch);this.message.set('异常记录已排除并保留审计原因');}catch(error){this.error.set(error instanceof Error?error.message:'异常记录排除失败');}finally{this.saving.set(false);}}
  async review():Promise<void>{const batch=this.detail();if(!batch)return;this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/review',batch.objectId,'实时刷新完成,复核工资明细并永久锁定',{},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资单已审核锁定');}catch(error){this.error.set(error instanceof Error?error.message:'工资审核失败');}finally{this.saving.set(false);}}
  async markPaid():Promise<void>{const batch=this.detail();if(!batch||!this.paymentMethod().trim()||!this.paymentReference().trim()){this.error.set('请填写发放方式和凭证编号');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/payroll/mark-paid',batch.objectId,'确认工资已线下发放',{paymentMethod:this.paymentMethod(),paymentReference:this.paymentReference()},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资发放信息已登记');}catch(error){this.error.set(error instanceof Error?error.message:'发放登记失败');}finally{this.saving.set(false);}}
- async exportExcel():Promise<void>{this.saving.set(true);this.error.set('');try{const batch=this.detail(),data=await this.operations.payrollExport(this.month(),batch?.objectId||'',this.storeId(),this.search());const summary=data.coachItems.map((row)=>({'陪练老师':row.coachName,'门店':row.storeName,'30分钟课':row.type30Count,'60分钟课':row.type60Count,'体验课':row.trialCount,'课型4':row.type4Count,'总课次':row.lessonCount,'工时':row.totalHours,'基础工资':row.baseAmount,'调整':row.adjustmentAmount,'应发金额':row.payableAmount}));const details=data.lines.map((line)=>this.exportLine(line)),exceptions=data.exceptions.map((line)=>({...this.exportLine(line),'异常原因':line.exception}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(summary),'老师汇总');utils.book_append_sheet(book,utils.json_to_sheet(details),'上课明细');utils.book_append_sheet(book,utils.json_to_sheet(exceptions),'异常记录');writeFile(book,data.fileName);}catch(error){this.error.set(error instanceof Error?error.message:'工资 Excel 导出失败');}finally{this.saving.set(false);}}
- private exportLine(line:PayrollLine):Record<string,unknown>{return{'来源记录ID':line.legacyGeneralId,'上课日期':line.lessonAt,'结算月份':line.settlementMonth,'原上课月份':line.originalMonth,'陪练老师':line.coachName,'门店':line.storeName,'学员':line.studentName,'课程':line.courseName,'课型':line.classTypeLabel,'工时':line.durationHours,'单价':line.rateAmount,'金额':line.amount,'说明':line.description};}
+ async exportExcel():Promise<void>{this.saving.set(true);this.error.set('');try{const batch=this.detail(),data=await this.operations.payrollExport(this.month(),batch?.objectId||'',this.storeId(),this.search());const summary=data.coachItems.map((row)=>({'陪练老师':this.coachLabel(row.coachName,row.coachId),'门店':row.storeName,'30分钟课':row.type30Count,'60分钟课':row.type60Count,'体验课':row.trialCount,'课型4':row.type4Count,'总课次':row.lessonCount,'工时':row.totalHours,'基础工资':row.baseAmount,'调整':row.adjustmentAmount,'应发金额':row.payableAmount}));const details=data.lines.map((line)=>this.exportLine(line)),exceptions=data.exceptions.map((line)=>({...this.exportLine(line),'异常原因':line.exception}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(summary),'老师汇总');utils.book_append_sheet(book,utils.json_to_sheet(details),'上课明细');utils.book_append_sheet(book,utils.json_to_sheet(exceptions),'异常记录');writeFile(book,data.fileName);}catch(error){this.error.set(error instanceof Error?error.message:'工资 Excel 导出失败');}finally{this.saving.set(false);}}
+ private exportLine(line:PayrollLine):Record<string,unknown>{return{'来源记录ID':line.legacyGeneralId,'上课日期':line.lessonAt,'结算月份':line.settlementMonth,'原上课月份':line.originalMonth,'陪练老师':this.coachLabel(line.coachName,line.coachId),'门店':line.storeName,'学员':line.studentName,'课程':line.courseName,'课型':line.classTypeLabel,'工时':line.durationHours,'单价':line.rateAmount,'金额':line.amount,'说明':line.description};}
 }

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio