import 'package:flutter/material.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../shell/nav_bar_config.dart'; import '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/status_banner.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/approval_timeline.dart'; import 'expense_model.dart'; import '../../core/i18n/app_localizations.dart'; import 'expense_list_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/auth/role_provider.dart'; class ExpenseDetailPage extends ConsumerWidget { final String id; const ExpenseDetailPage({super.key, required this.id}); @override Widget build(BuildContext context, WidgetRef ref) { final colors = Theme.of(context).extension()!; final expense = mockExpenses.firstWhere( (e) => e.id == id, orElse: () => mockExpenses.first, ); final l10n = AppLocalizations.of(context); final isFinance = ref.watch(isFinanceProvider); final isAdmin = ref.watch(isAdminProvider); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('expenseDetail'), showBack: true, onBack: () => context.pop(), ), ); return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ _buildStatusBanner(expense, l10n, colors), const SizedBox(height: 4), _buildSubmitTime(expense, l10n, colors), const SizedBox(height: 16), _buildBasicInfoSection(expense, l10n, colors), const SizedBox(height: 16), _buildAccountSection(expense, l10n), const SizedBox(height: 16), _buildDetailSection(expense, l10n, colors), const SizedBox(height: 16), _buildInvoiceSection(expense, l10n, colors), const SizedBox(height: 16), if (isFinance) _buildComplianceSection(expense, l10n, colors), const SizedBox(height: 16), if (expense.approvalRecords.isNotEmpty || expense.approvalChain.isNotEmpty) _buildApprovalSection(expense, l10n), const SizedBox(height: 16), if (isFinance || isAdmin) _buildArchiveSection(expense, l10n), ], ), ), ), _buildBottomBar(context, expense, isFinance: isFinance, isAdmin: isAdmin), ], ); } Widget _buildStatusBanner( ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors, ) { final (icon, color, label) = switch (expense.status) { 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')), 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')), 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')), _ => (Icons.schedule, colors.warning, l10n.get('pending')), }; final approverText = switch (expense.status) { 'approved' when expense.approvalRecords.isNotEmpty => '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}', 'rejected' when expense.approvalRecords.isNotEmpty => '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}', 'pending' when expense.currentApproverId.isNotEmpty => '${l10n.get('currentApprover')}:${expense.currentApproverId}', _ => '', }; return StatusBanner( icon: icon, statusText: label, subText: approverText, color: color, ); } Widget _buildSubmitTime( ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors, ) { return Padding( padding: const EdgeInsets.only(left: 4, top: 4), child: Align( alignment: Alignment.centerLeft, child: Text( '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ), ); } Widget _buildBasicInfoSection( ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors, ) { return FormSection( title: l10n.get('basicInfo'), children: [ FormFieldRow( label: l10n.get('applicant'), value: expense.applicantName, readOnly: true, showArrow: false, ), FormFieldRow( label: l10n.get('department'), value: expense.deptName, readOnly: true, showArrow: false, ), FormFieldRow( label: l10n.get('expenseType'), value: expense.expenseType, readOnly: true, showArrow: false, ), Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('expenseAmount'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), Text( '¥${expense.totalAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), FormFieldRow( label: l10n.get('relatedProject'), value: expense.projectName.isNotEmpty ? expense.projectName : null, hint: '-', readOnly: true, showArrow: false, ), FormFieldRow( label: l10n.get('budgetSubject'), value: expense.budgetSubjectId.isNotEmpty ? expense.budgetSubjectId : null, hint: '-', readOnly: true, showArrow: false, ), ], ); } Widget _buildAccountSection(ExpenseModel expense, AppLocalizations l10n) { return FormSection( title: l10n.get('receiptAccount'), children: [ FormFieldRow( label: l10n.get('bankName'), value: expense.accountName.isNotEmpty ? expense.accountName : null, hint: '-', readOnly: true, showArrow: false, ), FormFieldRow( label: l10n.get('accountName'), value: expense.accountName.isNotEmpty ? expense.accountName : null, hint: '-', readOnly: true, showArrow: false, ), FormFieldRow( label: l10n.get('bankAccount'), value: expense.accountId.isNotEmpty ? expense.accountId : null, hint: '-', readOnly: true, showArrow: false, ), ], ); } Widget _buildDetailSection( ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors, ) { return FormSection( title: l10n.get('expenseDetails'), children: [ // Table header Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), ), child: Row( children: [ Expanded( flex: 3, child: Text( l10n.get('expenseProject'), style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w500, color: colors.textSecondary, ), ), ), Expanded( flex: 2, child: Text( l10n.get('amount'), textAlign: TextAlign.right, style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w500, color: colors.textSecondary, ), ), ), ], ), ), if (expense.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailData'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPlaceholder, ), ), ) else ...expense.details.map( (d) => SizedBox( height: 28, child: Row( children: [ Expanded( flex: 3, child: Text( d.expenseDesc, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPrimary, ), ), ), Expanded( flex: 2, child: Text( '¥${d.totalAmount.toStringAsFixed(2)}', textAlign: TextAlign.right, style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.amountPrimary, ), ), ), ], ), ), ), if (expense.details.isNotEmpty) ...[ Container(height: 1, color: colors.border), Container( height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('total'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '¥${expense.totalAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), ], ], ); } Widget _buildInvoiceSection( ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors, ) { final hasInvoices = expense.invoiceImages.isNotEmpty; return FormSection( title: l10n.get('invoiceAttachment'), children: [ if (!hasInvoices) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noInvoice'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPlaceholder, ), ), ) else Wrap( spacing: 8, runSpacing: 8, children: expense.invoiceImages.map((url) { return Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all( color: colors.border, strokeAlign: BorderSide.strokeAlignInside, ), ), child: Center( child: Icon( Icons.image_outlined, size: 24, color: colors.textPlaceholder, ), ), ); }).toList(), ), ], ); } Widget _buildComplianceSection( ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors, ) { final checks = [ l10n.get('invoiceCheck1'), l10n.get('invoiceCheck2'), l10n.get('invoiceCheck3'), l10n.get('invoiceCheck4'), ]; return FormSection( title: l10n.get('invoiceCheck'), children: checks.map((text) { return SizedBox( height: 44, child: Row( children: [ Icon(Icons.check_circle, size: 16, color: colors.success), const SizedBox(width: 8), Text( text, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPrimary, ), ), ], ), ); }).toList(), ); } Widget _buildApprovalSection(ExpenseModel expense, AppLocalizations l10n) { return FormSection( title: l10n.get('approvalFlow'), children: [ ApprovalTimeline( records: expense.approvalRecords, chain: expense.approvalChain, currentApproverId: expense.currentApproverId, ), ], ); } Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n) { return FormSection( title: l10n.get('financialArchive'), children: [ FormFieldRow( label: l10n.get('voucherNo'), value: expense.voucherNo.isNotEmpty ? expense.voucherNo : null, hint: '-', readOnly: true, showArrow: false, ), FormFieldRow( label: l10n.get('archiveDate'), value: du.DateUtils.formatDate(expense.updateTime), readOnly: true, showArrow: false, ), FormFieldRow( label: l10n.get('archiver'), value: l10n.get('financeDept'), readOnly: true, showArrow: false, ), ], ); } Widget _buildBottomBar( BuildContext context, ExpenseModel expense, { required bool isFinance, required bool isAdmin, }) { final l10n = AppLocalizations.of(context); final canWithdraw = expense.status == 'pending' || expense.status == 'draft'; // 财务角色:已审批 + 未付款 → 显示打款归档按钮 if (isFinance && expense.status == 'approved' && expense.paymentStatus == 'unpaid') { return ActionBar( showLeft: true, leftLabel: l10n.get('confirmPaymentAndArchive'), centerLabel: l10n.get('nextPendingPayment'), rightLabel: l10n.get('confirmPaymentAndArchive'), onLeftTap: () { showDialog( context: context, builder: (ctx) => TDAlertDialog( title: l10n.get('confirmPaymentAndArchive'), content: l10n.get('confirmPaymentAndArchiveTip'), leftBtn: TDDialogButtonOptions( title: l10n.get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText( l10n.get('paymentArchiveSuccess'), context: context, ); context.pop(); }, ), ), ); }, onCenterTap: () { TDToast.showText( l10n.get('allPaymentsProcessed'), context: context, ); }, ); } // 非 employee/manager 角色不显示撤回按钮 if (isFinance || isAdmin) { return const SizedBox.shrink(); } if (!canWithdraw) { return const SizedBox.shrink(); } return ActionBar( showLeft: false, centerLabel: l10n.get('withdrawApplication'), rightLabel: l10n.get('submitApproval'), onCenterTap: () { showDialog( context: context, builder: (ctx) => TDAlertDialog( title: l10n.get('withdrawConfirm'), content: l10n.get('withdrawConfirmTip'), leftBtn: TDDialogButtonOptions( title: l10n.get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); }, ), ), ); }, onRightTap: null, ); } }