expense_detail_page.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. import 'package:flutter/material.dart';
  2. import 'package:tdesign_flutter/tdesign_flutter.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../shared/widgets/nav_bar_config.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../shared/widgets/status_banner.dart';
  10. import '../../shared/widgets/action_bar.dart';
  11. import 'expense_model.dart';
  12. import '../../core/i18n/app_localizations.dart';
  13. import 'expense_list_controller.dart';
  14. import '../../core/theme/app_colors.dart';
  15. import '../../core/theme/app_colors_extension.dart';
  16. import '../../core/auth/role_provider.dart';
  17. class ExpenseDetailPage extends ConsumerWidget {
  18. final String id;
  19. const ExpenseDetailPage({super.key, required this.id});
  20. @override
  21. Widget build(BuildContext context, WidgetRef ref) {
  22. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  23. final expense = mockExpenses.firstWhere(
  24. (e) => e.id == id,
  25. orElse: () => mockExpenses.first,
  26. );
  27. final l10n = AppLocalizations.of(context);
  28. final isFinance = ref.watch(isFinanceProvider);
  29. final isAdmin = ref.watch(isAdminProvider);
  30. ref
  31. .read(navBarConfigProvider.notifier)
  32. .update(
  33. NavBarConfig(
  34. title: l10n.get('expenseDetail'),
  35. showBack: true,
  36. onBack: () => context.pop(),
  37. ),
  38. );
  39. return Column(
  40. children: [
  41. Expanded(
  42. child: SingleChildScrollView(
  43. padding: const EdgeInsets.all(16),
  44. child: Column(
  45. children: [
  46. _buildStatusBanner(expense, l10n, colors),
  47. const SizedBox(height: 8),
  48. _buildSubmitTime(expense, l10n, colors),
  49. const SizedBox(height: 16),
  50. _buildBasicInfoSection(expense, l10n, colors),
  51. const SizedBox(height: 16),
  52. _buildExpenseDetailSection(expense, l10n, colors),
  53. const SizedBox(height: 16),
  54. _buildInvoiceSection(expense, l10n, colors),
  55. if (isFinance) ...[
  56. const SizedBox(height: 16),
  57. _buildComplianceSection(expense, l10n, colors),
  58. ],
  59. const SizedBox(height: 16),
  60. _buildApprovalSection(l10n, colors),
  61. if (isFinance || isAdmin) ...[
  62. const SizedBox(height: 16),
  63. _buildArchiveSection(expense, l10n, colors),
  64. ],
  65. ],
  66. ),
  67. ),
  68. ),
  69. _buildBottomBar(context, expense, isFinance: isFinance, isAdmin: isAdmin),
  70. ],
  71. );
  72. }
  73. Widget _buildStatusBanner(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  74. final (icon, color, label) = switch (expense.status) {
  75. 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
  76. 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
  77. 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
  78. _ => (Icons.schedule, colors.warning, l10n.get('pending')),
  79. };
  80. final approverText = switch (expense.status) {
  81. 'approved' when expense.approvalRecords.isNotEmpty => '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}',
  82. 'rejected' when expense.approvalRecords.isNotEmpty => '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}',
  83. 'pending' when expense.currentApproverId.isNotEmpty => '${l10n.get('currentApprover')}:${expense.currentApproverId}',
  84. _ => '',
  85. };
  86. return StatusBanner(icon: icon, statusText: label, subText: approverText, color: color);
  87. }
  88. Widget _buildSubmitTime(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  89. return Padding(
  90. padding: const EdgeInsets.only(left: 4),
  91. child: Text('${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
  92. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  93. );
  94. }
  95. // ═══ 基本信息 + 收款账户 — 对应 create 页 basicInfo + 数据库 Expense 字段 ═══
  96. Widget _buildBasicInfoSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  97. var pms = expense.paymentMethod;
  98. if (pms == 'bankTransfer') pms = l10n.get('bankTransfer');
  99. else if (pms == 'cash') pms = l10n.get('cash');
  100. else if (pms == 'alipay') pms = l10n.get('alipay');
  101. else if (pms == 'wechat') pms = l10n.get('wechat');
  102. return FormSection(
  103. title: l10n.get('basicInfo'),
  104. leadingIcon: Icons.info_outline,
  105. children: [
  106. FormFieldRow(label: l10n.get('expenseNo'), value: expense.expenseNo, readOnly: true, showArrow: false),
  107. const SizedBox(height: 16),
  108. FormFieldRow(label: l10n.get('applicant'), value: expense.applicantName, readOnly: true, showArrow: false),
  109. const SizedBox(height: 16),
  110. FormFieldRow(label: l10n.get('department'), value: expense.deptName, readOnly: true, showArrow: false),
  111. const SizedBox(height: 16),
  112. FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(expense.createTime), readOnly: true, showArrow: false),
  113. const SizedBox(height: 16),
  114. FormFieldRow(label: l10n.get('currency'), value: expense.currencyCode, readOnly: true, showArrow: false),
  115. const SizedBox(height: 16),
  116. FormFieldRow(label: l10n.get('feeReason'), value: expense.purpose, readOnly: true, showArrow: false),
  117. const SizedBox(height: 16),
  118. FormFieldRow(label: l10n.get('expenseAmount'), value: '¥${expense.totalAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false),
  119. if (expense.approvedAmount > 0) ...[
  120. const SizedBox(height: 16),
  121. FormFieldRow(label: l10n.get('approvedAmount'), value: '¥${expense.approvedAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false),
  122. ],
  123. const SizedBox(height: 16),
  124. FormFieldRow(label: l10n.get('paymentMethod'), value: pms.isNotEmpty ? pms : '-', readOnly: true, showArrow: false),
  125. if (expense.details.any((d) => d.bankName.isNotEmpty)) ...[
  126. const SizedBox(height: 16),
  127. FormSection(title: l10n.get('receiptAccount'), leadingIcon: Icons.account_balance_outlined, children: [
  128. for (final d in expense.details.where((d) => d.bankName.isNotEmpty))
  129. Padding(
  130. padding: const EdgeInsets.only(bottom: 12),
  131. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  132. Text('${d.bankName}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  133. const SizedBox(height: 4),
  134. Text('${d.bankAccountName} ${d.bankAccount}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  135. ]),
  136. ),
  137. ]),
  138. ],
  139. ],
  140. );
  141. }
  142. // ═══ 费用明细 — 对应 create 页 detailSection + 数据库 ExpenseDetail ═══
  143. Widget _buildExpenseDetailSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  144. return FormSection(
  145. title: l10n.get('expenseDetails'),
  146. leadingIcon: Icons.receipt_long_outlined,
  147. children: [
  148. if (expense.details.isEmpty)
  149. Padding(
  150. padding: const EdgeInsets.symmetric(vertical: 8),
  151. child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
  152. )
  153. else
  154. ...expense.details.asMap().entries.map((e) {
  155. final d = e.value;
  156. return Container(
  157. margin: const EdgeInsets.symmetric(vertical: 8),
  158. padding: const EdgeInsets.all(12),
  159. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  160. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  161. Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
  162. Expanded(
  163. child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
  164. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  165. ),
  166. const SizedBox(width: 16),
  167. Text('¥${d.totalAmount.toStringAsFixed(2)}',
  168. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
  169. ]),
  170. const SizedBox(height: 4),
  171. Text('¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)}${d.bankName.isNotEmpty ? ' | ${d.bankName}' : ''}',
  172. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  173. if (d.projectName.isNotEmpty) ...[
  174. const SizedBox(height: 4),
  175. Text('${l10n.get('relatedProject')}:${d.projectName} | ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
  176. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  177. ],
  178. if (d.costDeptName.isNotEmpty) ...[
  179. const SizedBox(height: 4),
  180. Text('${l10n.get('costDept')}:${d.costDeptName}',
  181. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  182. ],
  183. if (d.customerVendorName.isNotEmpty) ...[
  184. const SizedBox(height: 4),
  185. Text('${l10n.get('customerVendor')}:${d.customerVendorName}',
  186. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  187. ],
  188. if (d.approvedAmount > 0) ...[
  189. const SizedBox(height: 4),
  190. Text('${l10n.get('approvedAmount')}:¥${d.approvedAmount.toStringAsFixed(2)}',
  191. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.success)),
  192. ],
  193. if (d.remark.isNotEmpty) ...[
  194. const SizedBox(height: 4),
  195. Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis,
  196. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  197. ],
  198. ]),
  199. );
  200. }),
  201. if (expense.details.isNotEmpty) ...[
  202. const SizedBox(height: 8),
  203. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  204. Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  205. Text('¥${expense.totalAmount.toStringAsFixed(2)}',
  206. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  207. ]),
  208. ],
  209. ],
  210. );
  211. }
  212. // ═══ 发票附件 ═══
  213. Widget _buildInvoiceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  214. return FormSection(
  215. title: l10n.get('invoiceAttachment'),
  216. leadingIcon: Icons.attach_file_outlined,
  217. children: [
  218. if (expense.attachments.isEmpty)
  219. Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
  220. else
  221. Wrap(spacing: 8, runSpacing: 8, children: expense.attachments.map((url) {
  222. return Container(width: 80, height: 80,
  223. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
  224. child: Center(child: Icon(Icons.image_outlined, size: 24, color: colors.textPlaceholder)));
  225. }).toList()),
  226. ],
  227. );
  228. }
  229. // ═══ 财务合规查验 ═══
  230. Widget _buildComplianceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  231. final checks = [
  232. (expense.isInvoiceVerified, l10n.get('invoiceCheck1')),
  233. (expense.isTaxIdMatched, l10n.get('invoiceCheck2')),
  234. (expense.isCategoryCompliant, l10n.get('invoiceCheck3')),
  235. ];
  236. return FormSection(
  237. title: l10n.get('invoiceCheck'),
  238. leadingIcon: Icons.verified_outlined,
  239. children: checks.asMap().entries.map((e) {
  240. final (passed, text) = e.value;
  241. return Padding(
  242. padding: EdgeInsets.only(top: e.key > 0 ? 12 : 0),
  243. child: SizedBox(height: 24, child: Row(children: [
  244. Icon(passed ? Icons.check_circle : Icons.radio_button_unchecked, size: 16, color: passed ? colors.success : colors.textPlaceholder),
  245. const SizedBox(width: 8),
  246. Text(text, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary)),
  247. ])),
  248. );
  249. }).toList(),
  250. );
  251. }
  252. // ═══ 审核流程 — 时间线组件 ═══
  253. Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) {
  254. final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[
  255. (title: l10n.get('approvalStepSubmitted'), desc: l10n.get('approvalDescSubmitted'), time: '2026-06-29 09:15', icon: Icons.check_circle, iconColor: colors.success),
  256. (title: l10n.get('approvalStepApproved'), desc: l10n.get('approvalDescApproved'), time: '2026-06-29 14:30', icon: Icons.check_circle, iconColor: colors.success),
  257. (title: l10n.get('approvalStepInvoice'), desc: l10n.get('approvalDescInvoice'), time: null, icon: Icons.schedule, iconColor: colors.warning),
  258. (title: l10n.get('approvalStepPayment'), desc: l10n.get('approvalStepPaymentDesc'), time: null, icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder),
  259. ];
  260. return FormSection(
  261. title: l10n.get('approvalFlow'),
  262. leadingIcon: Icons.fact_check_outlined,
  263. children: [
  264. ...steps.asMap().entries.map((e) {
  265. final s = e.value;
  266. final isLast = e.key == steps.length - 1;
  267. final isActive = e.key <= 2;
  268. final iconColor = isActive ? s.iconColor : colors.textPlaceholder;
  269. final textColor = isActive ? colors.textPrimary : colors.textPlaceholder;
  270. final subColor = isActive ? colors.textSecondary : colors.textPlaceholder;
  271. return IntrinsicHeight(
  272. child: Row(
  273. crossAxisAlignment: CrossAxisAlignment.start,
  274. children: [
  275. SizedBox(
  276. width: 24,
  277. child: Column(
  278. children: [
  279. Container(
  280. width: 24, height: 24,
  281. decoration: BoxDecoration(
  282. color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled,
  283. shape: BoxShape.circle,
  284. ),
  285. child: Icon(s.icon, size: 14, color: iconColor),
  286. ),
  287. if (!isLast)
  288. Expanded(
  289. child: Container(
  290. width: 2,
  291. margin: const EdgeInsets.symmetric(vertical: 4),
  292. color: isActive ? s.iconColor.withAlpha(60) : colors.border,
  293. ),
  294. ),
  295. ],
  296. ),
  297. ),
  298. const SizedBox(width: 12),
  299. Expanded(
  300. child: Padding(
  301. padding: EdgeInsets.only(bottom: isLast ? 0 : 16),
  302. child: Column(
  303. crossAxisAlignment: CrossAxisAlignment.start,
  304. children: [
  305. Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)),
  306. const SizedBox(height: 4),
  307. Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)),
  308. if (s.time != null) ...[
  309. const SizedBox(height: 2),
  310. Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  311. ],
  312. ],
  313. ),
  314. ),
  315. ),
  316. ],
  317. ),
  318. );
  319. }),
  320. ],
  321. );
  322. }
  323. // ═══ 财务归档 ═══
  324. Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  325. return FormSection(
  326. title: l10n.get('financialArchive'),
  327. leadingIcon: Icons.archive_outlined,
  328. children: [
  329. FormFieldRow(label: l10n.get('voucherNo'), value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-', readOnly: true, showArrow: false),
  330. const SizedBox(height: 16),
  331. FormFieldRow(label: l10n.get('bankTransferNo'), value: expense.bankTransferNo.isNotEmpty ? expense.bankTransferNo : '-', readOnly: true, showArrow: false),
  332. const SizedBox(height: 16),
  333. FormFieldRow(label: l10n.get('paymentStatus'), value: expense.paymentStatus == 'paid' ? l10n.get('paid') : l10n.get('unpaid'), readOnly: true, showArrow: false),
  334. ],
  335. );
  336. }
  337. Widget _buildBottomBar(BuildContext context, ExpenseModel expense, {required bool isFinance, required bool isAdmin}) {
  338. final l10n = AppLocalizations.of(context);
  339. if (isFinance && expense.status == 'approved' && expense.paymentStatus == 'unpaid') {
  340. return ActionBar(
  341. showLeft: true, leftLabel: l10n.get('confirmPaymentAndArchive'),
  342. centerLabel: l10n.get('nextPendingPayment'), rightLabel: l10n.get('confirmPaymentAndArchive'),
  343. onLeftTap: () {
  344. showDialog(context: context, builder: (ctx) => TDAlertDialog(
  345. title: l10n.get('confirmPaymentAndArchive'), content: l10n.get('confirmPaymentAndArchiveTip'),
  346. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
  347. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('paymentArchiveSuccess'), context: context); context.pop(); }),
  348. ));
  349. },
  350. onCenterTap: () => TDToast.showText(l10n.get('allPaymentsProcessed'), context: context),
  351. );
  352. }
  353. if (isFinance || isAdmin) return const SizedBox.shrink();
  354. if (expense.status != 'pending' && expense.status != 'draft') return const SizedBox.shrink();
  355. return ActionBar(
  356. showLeft: false, centerLabel: l10n.get('withdrawApplication'), rightLabel: l10n.get('submitApproval'),
  357. onCenterTap: () {
  358. showDialog(context: context, builder: (ctx) => TDAlertDialog(
  359. title: l10n.get('withdrawConfirm'), content: l10n.get('withdrawConfirmTip'),
  360. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
  361. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); }),
  362. ));
  363. },
  364. onRightTap: null,
  365. );
  366. }
  367. }