expense_detail_page.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  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. _buildAttachmentSection(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') {
  99. pms = l10n.get('bankTransfer');
  100. } else if (pms == 'cash') {
  101. pms = l10n.get('cash');
  102. } else if (pms == 'alipay') {
  103. pms = l10n.get('alipay');
  104. } else if (pms == 'wechat') {
  105. pms = l10n.get('wechat');
  106. }
  107. return FormSection(
  108. title: l10n.get('basicInfo'),
  109. leadingIcon: Icons.info_outline,
  110. children: [
  111. FormFieldRow(label: l10n.get('expenseNo'), value: expense.expenseNo, readOnly: true, showArrow: false),
  112. const SizedBox(height: 16),
  113. FormFieldRow(label: l10n.get('applicant'), value: expense.applicantName, readOnly: true, showArrow: false),
  114. const SizedBox(height: 16),
  115. FormFieldRow(label: l10n.get('department'), value: expense.deptName, readOnly: true, showArrow: false),
  116. const SizedBox(height: 16),
  117. FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(expense.createTime), readOnly: true, showArrow: false),
  118. const SizedBox(height: 16),
  119. FormFieldRow(label: l10n.get('currency'), value: expense.currencyCode, readOnly: true, showArrow: false),
  120. const SizedBox(height: 16),
  121. FormFieldRow(label: l10n.get('feeReason'), value: expense.purpose, readOnly: true, showArrow: false),
  122. const SizedBox(height: 16),
  123. FormFieldRow(label: l10n.get('expenseAmount'), value: '¥${expense.totalAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false),
  124. if (expense.approvedAmount > 0) ...[
  125. const SizedBox(height: 16),
  126. FormFieldRow(label: l10n.get('approvedAmount'), value: '¥${expense.approvedAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false),
  127. ],
  128. const SizedBox(height: 16),
  129. FormFieldRow(label: l10n.get('paymentMethod'), value: pms.isNotEmpty ? pms : '-', readOnly: true, showArrow: false),
  130. ],
  131. );
  132. }
  133. // ═══ 费用明细 — 对应 create 页 detailSection + 数据库 ExpenseDetail ═══
  134. Widget _buildExpenseDetailSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  135. return FormSection(
  136. title: l10n.get('expenseDetails'),
  137. leadingIcon: Icons.receipt_long_outlined,
  138. children: [
  139. if (expense.details.isEmpty)
  140. Padding(
  141. padding: const EdgeInsets.symmetric(vertical: 8),
  142. child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
  143. )
  144. else
  145. ...expense.details.asMap().entries.map((e) {
  146. final d = e.value;
  147. return Container(
  148. margin: const EdgeInsets.symmetric(vertical: 8),
  149. padding: const EdgeInsets.all(12),
  150. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  151. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  152. Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
  153. Expanded(
  154. child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
  155. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  156. ),
  157. const SizedBox(width: 16),
  158. Text('¥${d.totalAmount.toStringAsFixed(2)}',
  159. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
  160. ]),
  161. const SizedBox(height: 4),
  162. Text('¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)}${d.bankName.isNotEmpty ? ' | ${d.bankName}' : ''}',
  163. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  164. if (d.projectName.isNotEmpty) ...[
  165. const SizedBox(height: 4),
  166. Text('${l10n.get('relatedProject')}:${d.projectName} | ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
  167. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  168. ],
  169. if (d.costDeptName.isNotEmpty) ...[
  170. const SizedBox(height: 4),
  171. Text('${l10n.get('costDept')}:${d.costDeptName}',
  172. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  173. ],
  174. if (d.customerVendorName.isNotEmpty) ...[
  175. const SizedBox(height: 4),
  176. Text('${l10n.get('customerVendor')}:${d.customerVendorName}',
  177. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  178. ],
  179. if (d.approvedAmount > 0) ...[
  180. const SizedBox(height: 4),
  181. Text('${l10n.get('approvedAmount')}:¥${d.approvedAmount.toStringAsFixed(2)}',
  182. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.success)),
  183. ],
  184. if (d.remark.isNotEmpty) ...[
  185. const SizedBox(height: 4),
  186. Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis,
  187. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  188. ],
  189. if (d.attachments.isNotEmpty) ...[
  190. const SizedBox(height: 8),
  191. Wrap(
  192. spacing: 6,
  193. runSpacing: 6,
  194. children: d.attachments.map((path) {
  195. final name = path.split('/').last.split('\\').last;
  196. return Container(
  197. width: 60, height: 60,
  198. decoration: BoxDecoration(
  199. color: colors.primaryLight,
  200. borderRadius: BorderRadius.circular(4),
  201. ),
  202. child: Column(
  203. mainAxisAlignment: MainAxisAlignment.center,
  204. children: [
  205. Icon(_fileTypeIcon(path), size: 24, color: colors.primary),
  206. const SizedBox(height: 2),
  207. Padding(
  208. padding: const EdgeInsets.symmetric(horizontal: 2),
  209. child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis,
  210. style: TextStyle(fontSize: 9, color: colors.textSecondary)),
  211. ),
  212. ],
  213. ),
  214. );
  215. }).toList(),
  216. ),
  217. ],
  218. ]),
  219. );
  220. }),
  221. if (expense.details.isNotEmpty) ...[
  222. const SizedBox(height: 8),
  223. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  224. Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  225. Text('¥${expense.totalAmount.toStringAsFixed(2)}',
  226. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  227. ]),
  228. ],
  229. ],
  230. );
  231. }
  232. // ═══ 附件 ═══
  233. Widget _buildAttachmentSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  234. return FormSection(
  235. title: l10n.get('attachments'),
  236. leadingIcon: Icons.attach_file_outlined,
  237. children: [
  238. if (expense.attachments.isEmpty)
  239. Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
  240. else
  241. Wrap(spacing: 8, runSpacing: 8, children: expense.attachments.map((path) {
  242. final name = path.split('/').last.split('\\').last;
  243. return SizedBox(
  244. width: 80,
  245. child: Column(
  246. mainAxisSize: MainAxisSize.min,
  247. children: [
  248. Container(
  249. width: 80, height: 80,
  250. decoration: BoxDecoration(
  251. color: colors.bgPage, borderRadius: BorderRadius.circular(4),
  252. border: Border.all(color: colors.border),
  253. ),
  254. child: Center(
  255. child: Icon(
  256. _fileTypeIcon(path),
  257. size: 28,
  258. color: colors.primary,
  259. ),
  260. ),
  261. ),
  262. const SizedBox(height: 4),
  263. Text(
  264. name,
  265. maxLines: 1,
  266. overflow: TextOverflow.ellipsis,
  267. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  268. ),
  269. ],
  270. ),
  271. );
  272. }).toList()),
  273. ],
  274. );
  275. }
  276. // ═══ 财务合规查验 ═══
  277. Widget _buildComplianceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  278. final checks = [
  279. (expense.isInvoiceVerified, l10n.get('invoiceCheck1')),
  280. (expense.isTaxIdMatched, l10n.get('invoiceCheck2')),
  281. (expense.isCategoryCompliant, l10n.get('invoiceCheck3')),
  282. ];
  283. return FormSection(
  284. title: l10n.get('invoiceCheck'),
  285. leadingIcon: Icons.verified_outlined,
  286. children: checks.asMap().entries.map((e) {
  287. final (passed, text) = e.value;
  288. return Padding(
  289. padding: EdgeInsets.only(top: e.key > 0 ? 12 : 0),
  290. child: SizedBox(height: 24, child: Row(children: [
  291. Icon(passed ? Icons.check_circle : Icons.radio_button_unchecked, size: 16, color: passed ? colors.success : colors.textPlaceholder),
  292. const SizedBox(width: 8),
  293. Text(text, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary)),
  294. ])),
  295. );
  296. }).toList(),
  297. );
  298. }
  299. // ═══ 审核流程 — 时间线组件 ═══
  300. Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) {
  301. final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[
  302. (title: l10n.get('approvalStepSubmitted'), desc: l10n.get('approvalDescSubmitted'), time: '2026-06-29 09:15', icon: Icons.check_circle, iconColor: colors.success),
  303. (title: l10n.get('approvalStepApproved'), desc: l10n.get('approvalDescApproved'), time: '2026-06-29 14:30', icon: Icons.check_circle, iconColor: colors.success),
  304. (title: l10n.get('approvalStepInvoice'), desc: l10n.get('approvalDescInvoice'), time: null, icon: Icons.schedule, iconColor: colors.warning),
  305. (title: l10n.get('approvalStepPayment'), desc: l10n.get('approvalStepPaymentDesc'), time: null, icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder),
  306. ];
  307. return FormSection(
  308. title: l10n.get('approvalFlow'),
  309. leadingIcon: Icons.fact_check_outlined,
  310. children: [
  311. ...steps.asMap().entries.map((e) {
  312. final s = e.value;
  313. final isLast = e.key == steps.length - 1;
  314. final isActive = e.key <= 2;
  315. final iconColor = isActive ? s.iconColor : colors.textPlaceholder;
  316. final textColor = isActive ? colors.textPrimary : colors.textPlaceholder;
  317. final subColor = isActive ? colors.textSecondary : colors.textPlaceholder;
  318. return IntrinsicHeight(
  319. child: Row(
  320. crossAxisAlignment: CrossAxisAlignment.start,
  321. children: [
  322. SizedBox(
  323. width: 24,
  324. child: Column(
  325. children: [
  326. Container(
  327. width: 24, height: 24,
  328. decoration: BoxDecoration(
  329. color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled,
  330. shape: BoxShape.circle,
  331. ),
  332. child: Icon(s.icon, size: 14, color: iconColor),
  333. ),
  334. if (!isLast)
  335. Expanded(
  336. child: Container(
  337. width: 2,
  338. margin: const EdgeInsets.symmetric(vertical: 4),
  339. color: isActive ? s.iconColor.withAlpha(60) : colors.border,
  340. ),
  341. ),
  342. ],
  343. ),
  344. ),
  345. const SizedBox(width: 12),
  346. Expanded(
  347. child: Padding(
  348. padding: EdgeInsets.only(bottom: isLast ? 0 : 16),
  349. child: Column(
  350. crossAxisAlignment: CrossAxisAlignment.start,
  351. children: [
  352. Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)),
  353. const SizedBox(height: 4),
  354. Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)),
  355. if (s.time != null) ...[
  356. const SizedBox(height: 2),
  357. Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  358. ],
  359. ],
  360. ),
  361. ),
  362. ),
  363. ],
  364. ),
  365. );
  366. }),
  367. ],
  368. );
  369. }
  370. // ═══ 财务归档 ═══
  371. Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  372. return FormSection(
  373. title: l10n.get('financialArchive'),
  374. leadingIcon: Icons.archive_outlined,
  375. children: [
  376. FormFieldRow(label: l10n.get('voucherNo'), value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-', readOnly: true, showArrow: false),
  377. const SizedBox(height: 16),
  378. FormFieldRow(label: l10n.get('bankTransferNo'), value: expense.bankTransferNo.isNotEmpty ? expense.bankTransferNo : '-', readOnly: true, showArrow: false),
  379. const SizedBox(height: 16),
  380. FormFieldRow(label: l10n.get('paymentStatus'), value: expense.paymentStatus == 'paid' ? l10n.get('paid') : l10n.get('unpaid'), readOnly: true, showArrow: false),
  381. ],
  382. );
  383. }
  384. Widget _buildBottomBar(BuildContext context, ExpenseModel expense, {required bool isFinance, required bool isAdmin}) {
  385. final l10n = AppLocalizations.of(context);
  386. if (isFinance && expense.status == 'approved' && expense.paymentStatus == 'unpaid') {
  387. return ActionBar(
  388. showLeft: true, leftLabel: l10n.get('confirmPaymentAndArchive'),
  389. centerLabel: l10n.get('nextPendingPayment'), rightLabel: l10n.get('confirmPaymentAndArchive'),
  390. onLeftTap: () {
  391. showDialog(context: context, builder: (ctx) => TDAlertDialog(
  392. title: l10n.get('confirmPaymentAndArchive'), content: l10n.get('confirmPaymentAndArchiveTip'),
  393. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
  394. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('paymentArchiveSuccess'), context: context); context.pop(); }),
  395. ));
  396. },
  397. onCenterTap: () => TDToast.showText(l10n.get('allPaymentsProcessed'), context: context),
  398. );
  399. }
  400. if (isFinance || isAdmin) return const SizedBox.shrink();
  401. if (expense.status != 'pending' && expense.status != 'draft') return const SizedBox.shrink();
  402. return ActionBar(
  403. showLeft: false, centerLabel: l10n.get('withdrawApplication'), rightLabel: l10n.get('submitApproval'),
  404. onCenterTap: () {
  405. showDialog(context: context, builder: (ctx) => TDAlertDialog(
  406. title: l10n.get('withdrawConfirm'), content: l10n.get('withdrawConfirmTip'),
  407. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
  408. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); }),
  409. ));
  410. },
  411. onRightTap: null,
  412. );
  413. }
  414. IconData _fileTypeIcon(String path) {
  415. final ext = path.split('.').last.toLowerCase();
  416. switch (ext) {
  417. case 'pdf':
  418. return Icons.picture_as_pdf;
  419. case 'doc':
  420. case 'docx':
  421. return Icons.description;
  422. case 'xls':
  423. case 'xlsx':
  424. return Icons.table_chart;
  425. case 'ppt':
  426. case 'pptx':
  427. return Icons.slideshow;
  428. default:
  429. return Icons.insert_drive_file;
  430. }
  431. }
  432. }