|
|
@@ -1,26 +1,29 @@
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
|
|
|
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
-import { Banknote, Download, Eye, LucideAngularModule, Plus, RefreshCw, Settings2 } from 'lucide-angular';
|
|
|
-import { OperationsPage, PayRate, PayrollDetail, PayrollSummary } from '../admin.models';
|
|
|
+import { Banknote, Download, Eye, FileCheck2, LucideAngularModule, Plus, RefreshCw, 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';
|
|
|
import { OperationsService } from '../operations.service';
|
|
|
|
|
|
const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
|
|
|
|
|
|
-@Component({selector:'app-operations-payroll',standalone:true,imports:[CommonModule,FormsModule,LucideAngularModule],templateUrl:'./operations-payroll.component.html',styleUrl:'./operations-professional.scss',changeDetection:ChangeDetectionStrategy.OnPush})
|
|
|
+@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 page=signal<OperationsPage<PayrollSummary>|null>(null);readonly detail=signal<PayrollDetail|null>(null);readonly rates=signal<PayRate[]>([]);readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly tab=signal<'batches'|'rates'>('batches');readonly month=signal(localDateKey(new Date()).slice(0,7));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 icons={Banknote,Download,Eye,Plus,RefreshCw,Settings2};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 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);
|
|
|
ngOnInit():void{void this.load();}isManager():boolean{return this.sessions.session()?.operationsRole==='ops-manager'||this.sessions.session()?.isSuperAdmin===true;}
|
|
|
- async load():Promise<void>{this.loading.set(true);this.error.set('');try{const [page,rates]=await Promise.all([this.operations.payrolls(),this.operations.payRates()]);this.page.set(page);this.rates.set(rates.items);}catch(error){this.error.set(error instanceof Error?error.message:'工资数据加载失败');}finally{this.loading.set(false);}}
|
|
|
- async preview():Promise<void>{this.loading.set(true);this.error.set('');try{this.detail.set(await this.operations.payrollPreview(this.month()));}catch(error){this.error.set(error instanceof Error?error.message:'工资预览失败');}finally{this.loading.set(false);}}
|
|
|
- async open(row:PayrollSummary):Promise<void>{this.loading.set(true);try{this.detail.set(await this.operations.payrollDetail(row.objectId));}catch(error){this.error.set(error instanceof Error?error.message:'工资单加载失败');}finally{this.loading.set(false);}}
|
|
|
- async create():Promise<void>{this.saving.set(true);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('工资草稿已生成');}catch(error){this.error.set(error instanceof Error?error.message:'生成工资草稿失败');}finally{this.saving.set(false);}}
|
|
|
- async saveRate():Promise<void>{this.saving.set(true);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())return;this.saving.set(true);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('');}catch(error){this.error.set(error instanceof Error?error.message:'调整项保存失败');}finally{this.saving.set(false);}}
|
|
|
- async refreshDraft():Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;this.saving.set(true);this.error.set('');try{this.detail.set(await this.operations.mutate<PayrollDetail>('ops/payroll/refresh',batch.objectId,'根据最新计价规则重新计算工资草稿',{},batch.updatedAt));this.message.set('工资草稿已按最新计价规则重新计算');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'工资草稿重新计算失败');}finally{this.saving.set(false);}}
|
|
|
- async excludeLine(objectId:string):Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;const reason=globalThis.prompt('请输入排除该异常记录的原因');if(!reason?.trim())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 workflow(operation:string):Promise<void>{const batch=this.detail();if(!batch)return;let payload:Record<string,unknown>={};let reason=operation.endsWith('review')?'复核工资明细并锁定':operation.endsWith('reopen')?'发现差异,重新打开工资草稿':'确认工资已线下发放';if(operation.endsWith('mark-paid')){const method=globalThis.prompt('请输入发放方式,例如:银行转账');if(!method)return;const reference=globalThis.prompt('请输入发放凭证编号');if(!reference)return;payload={paymentMethod:method,paymentReference:reference};}this.saving.set(true);try{await this.operations.mutate(operation,batch.objectId,reason,payload,batch.updatedAt);await this.load();await this.open({...batch});}catch(error){this.error.set(error instanceof Error?error.message:'工资流程操作失败');}finally{this.saving.set(false);}}
|
|
|
- exportCsv():void{const batch=this.detail();if(!batch)return;const rows=[['老师','日期','课型','工时','单价','金额','说明'],...batch.lines.map(line=>[line.coachName,line.lessonAt,line.classTypeLabel,String(line.durationHours),String(line.rateAmount),String(line.amount),line.description])];const csv='\ufeff'+rows.map(row=>row.map(cell=>`"${String(cell).replaceAll('"','""')}"`).join(',')).join('\n');const url=URL.createObjectURL(new Blob([csv],{type:'text/csv;charset=utf-8'}));const anchor=document.createElement('a');anchor.href=url;anchor.download=`小树陪练工资_${batch.month}.csv`;anchor.click();URL.revokeObjectURL(url);}
|
|
|
- rateLabel(type:number):string{return({1:'30分钟',2:'60分钟',3:'体验课',4:'陪练'})[type]||`课型${type}`;}
|
|
|
+ async load():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()),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);}}
|
|
|
+ async applyFilters():Promise<void>{await this.load();}
|
|
|
+ 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);}
|
|
|
+ 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);}}
|
|
|
+ async refreshDraft():Promise<void>{const batch=this.detail();if(!batch||batch.status!=='draft')return;this.saving.set(true);this.error.set('');try{this.detail.set(await this.operations.mutate<PayrollDetail>('ops/payroll/refresh',batch.objectId,'刷新实时上课记录并增量重算工资草稿',{},batch.updatedAt));this.message.set('工资草稿已吸收新增上课记录并重新计算');await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'工资草稿刷新失败');}finally{this.saving.set(false);}}
|
|
|
+ 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};}
|
|
|
}
|