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 '../../shared/widgets/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 '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: 8), _buildSubmitTime(expense, l10n, colors), const SizedBox(height: 16), _buildBasicInfoSection(expense, l10n, colors), const SizedBox(height: 16), _buildExpenseDetailSection(expense, l10n, colors), const SizedBox(height: 16), _buildAttachmentSection(expense, l10n, colors), if (isFinance) ...[ const SizedBox(height: 16), _buildComplianceSection(expense, l10n, colors), ], const SizedBox(height: 16), _buildApprovalSection(l10n, colors), if (isFinance || isAdmin) ...[ const SizedBox(height: 16), _buildArchiveSection(expense, l10n, colors), ], ], ), ), ), _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), child: Text('${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ); } // ═══ 基本信息 + 收款账户 — 对应 create 页 basicInfo + 数据库 Expense 字段 ═══ Widget _buildBasicInfoSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) { var pms = expense.paymentMethod; if (pms == 'bankTransfer') { pms = l10n.get('bankTransfer'); } else if (pms == 'cash') { pms = l10n.get('cash'); } else if (pms == 'alipay') { pms = l10n.get('alipay'); } else if (pms == 'wechat') { pms = l10n.get('wechat'); } return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow(label: l10n.get('expenseNo'), value: expense.expenseNo, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('applicant'), value: expense.applicantName, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('department'), value: expense.deptName, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(expense.createTime), readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('currency'), value: expense.currencyCode, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('feeReason'), value: expense.purpose, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('expenseAmount'), value: '¥${expense.totalAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false), if (expense.approvedAmount > 0) ...[ const SizedBox(height: 16), FormFieldRow(label: l10n.get('approvedAmount'), value: '¥${expense.approvedAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false), ], const SizedBox(height: 16), FormFieldRow(label: l10n.get('paymentMethod'), value: pms.isNotEmpty ? pms : '-', readOnly: true, showArrow: false), ], ); } // ═══ 费用明细 — 对应 create 页 detailSection + 数据库 ExpenseDetail ═══ Widget _buildExpenseDetailSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, children: [ 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.asMap().entries.map((e) { final d = e.value; return Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)), ), const SizedBox(width: 16), Text('¥${d.totalAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)), ]), const SizedBox(height: 4), Text('¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)}${d.bankName.isNotEmpty ? ' | ${d.bankName}' : ''}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), if (d.projectName.isNotEmpty) ...[ const SizedBox(height: 4), Text('${l10n.get('relatedProject')}:${d.projectName} | ${l10n.get('budgetSubject')}:${d.acctSubjectName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], if (d.costDeptName.isNotEmpty) ...[ const SizedBox(height: 4), Text('${l10n.get('costDept')}:${d.costDeptName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], if (d.customerVendorName.isNotEmpty) ...[ const SizedBox(height: 4), Text('${l10n.get('customerVendor')}:${d.customerVendorName}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], if (d.approvedAmount > 0) ...[ const SizedBox(height: 4), Text('${l10n.get('approvedAmount')}:¥${d.approvedAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.success)), ], if (d.remark.isNotEmpty) ...[ const SizedBox(height: 4), Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)), ], if (d.attachments.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: d.attachments.map((path) { final name = path.split('/').last.split('\\').last; return Container( width: 60, height: 60, decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(4), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(_fileTypeIcon(path), size: 24, color: colors.primary), const SizedBox(height: 2), Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 9, color: colors.textSecondary)), ), ], ), ); }).toList(), ), ], ]), ); }), if (expense.details.isNotEmpty) ...[ const SizedBox(height: 8), 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 _buildAttachmentSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('attachments'), leadingIcon: Icons.attach_file_outlined, children: [ if (expense.attachments.isEmpty) Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)) else Wrap(spacing: 8, runSpacing: 8, children: expense.attachments.map((path) { final name = path.split('/').last.split('\\').last; return SizedBox( width: 80, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border), ), child: Center( child: Icon( _fileTypeIcon(path), size: 28, color: colors.primary, ), ), ), const SizedBox(height: 4), Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], ), ); }).toList()), ], ); } // ═══ 财务合规查验 ═══ Widget _buildComplianceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) { final checks = [ (expense.isInvoiceVerified, l10n.get('invoiceCheck1')), (expense.isTaxIdMatched, l10n.get('invoiceCheck2')), (expense.isCategoryCompliant, l10n.get('invoiceCheck3')), ]; return FormSection( title: l10n.get('invoiceCheck'), leadingIcon: Icons.verified_outlined, children: checks.asMap().entries.map((e) { final (passed, text) = e.value; return Padding( padding: EdgeInsets.only(top: e.key > 0 ? 12 : 0), child: SizedBox(height: 24, child: Row(children: [ Icon(passed ? Icons.check_circle : Icons.radio_button_unchecked, size: 16, color: passed ? colors.success : colors.textPlaceholder), const SizedBox(width: 8), Text(text, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary)), ])), ); }).toList(), ); } // ═══ 审核流程 — 时间线组件 ═══ Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) { final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[ (title: l10n.get('approvalStepSubmitted'), desc: l10n.get('approvalDescSubmitted'), time: '2026-06-29 09:15', icon: Icons.check_circle, iconColor: colors.success), (title: l10n.get('approvalStepApproved'), desc: l10n.get('approvalDescApproved'), time: '2026-06-29 14:30', icon: Icons.check_circle, iconColor: colors.success), (title: l10n.get('approvalStepInvoice'), desc: l10n.get('approvalDescInvoice'), time: null, icon: Icons.schedule, iconColor: colors.warning), (title: l10n.get('approvalStepPayment'), desc: l10n.get('approvalStepPaymentDesc'), time: null, icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder), ]; return FormSection( title: l10n.get('approvalFlow'), leadingIcon: Icons.fact_check_outlined, children: [ ...steps.asMap().entries.map((e) { final s = e.value; final isLast = e.key == steps.length - 1; final isActive = e.key <= 2; final iconColor = isActive ? s.iconColor : colors.textPlaceholder; final textColor = isActive ? colors.textPrimary : colors.textPlaceholder; final subColor = isActive ? colors.textSecondary : colors.textPlaceholder; return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 24, child: Column( children: [ Container( width: 24, height: 24, decoration: BoxDecoration( color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled, shape: BoxShape.circle, ), child: Icon(s.icon, size: 14, color: iconColor), ), if (!isLast) Expanded( child: Container( width: 2, margin: const EdgeInsets.symmetric(vertical: 4), color: isActive ? s.iconColor.withAlpha(60) : colors.border, ), ), ], ), ), const SizedBox(width: 12), Expanded( child: Padding( padding: EdgeInsets.only(bottom: isLast ? 0 : 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)), const SizedBox(height: 4), Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)), if (s.time != null) ...[ const SizedBox(height: 2), Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)), ], ], ), ), ), ], ), ); }), ], ); } // ═══ 财务归档 ═══ Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('financialArchive'), leadingIcon: Icons.archive_outlined, children: [ FormFieldRow(label: l10n.get('voucherNo'), value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-', readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('bankTransferNo'), value: expense.bankTransferNo.isNotEmpty ? expense.bankTransferNo : '-', readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('paymentStatus'), value: expense.paymentStatus == 'paid' ? l10n.get('paid') : l10n.get('unpaid'), readOnly: true, showArrow: false), ], ); } Widget _buildBottomBar(BuildContext context, ExpenseModel expense, {required bool isFinance, required bool isAdmin}) { final l10n = AppLocalizations.of(context); 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), ); } if (isFinance || isAdmin) return const SizedBox.shrink(); if (expense.status != 'pending' && expense.status != 'draft') 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, ); } IconData _fileTypeIcon(String path) { final ext = path.split('.').last.toLowerCase(); switch (ext) { case 'pdf': return Icons.picture_as_pdf; case 'doc': case 'docx': return Icons.description; case 'xls': case 'xlsx': return Icons.table_chart; case 'ppt': case 'pptx': return Icons.slideshow; default: return Icons.insert_drive_file; } } }