expense_detail_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  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 '../../shared/widgets/approval_timeline.dart';
  12. import 'expense_model.dart';
  13. import '../../core/i18n/app_localizations.dart';
  14. import 'expense_list_controller.dart';
  15. import '../../core/theme/app_colors.dart';
  16. import '../../core/theme/app_colors_extension.dart';
  17. import '../../core/auth/role_provider.dart';
  18. class ExpenseDetailPage extends ConsumerWidget {
  19. final String id;
  20. const ExpenseDetailPage({super.key, required this.id});
  21. @override
  22. Widget build(BuildContext context, WidgetRef ref) {
  23. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  24. final expense = mockExpenses.firstWhere(
  25. (e) => e.id == id,
  26. orElse: () => mockExpenses.first,
  27. );
  28. final l10n = AppLocalizations.of(context);
  29. final isFinance = ref.watch(isFinanceProvider);
  30. final isAdmin = ref.watch(isAdminProvider);
  31. ref
  32. .read(navBarConfigProvider.notifier)
  33. .update(
  34. NavBarConfig(
  35. title: l10n.get('expenseDetail'),
  36. showBack: true,
  37. onBack: () => context.pop(),
  38. ),
  39. );
  40. return Column(
  41. children: [
  42. Expanded(
  43. child: SingleChildScrollView(
  44. padding: const EdgeInsets.all(16),
  45. child: Column(
  46. children: [
  47. _buildStatusBanner(expense, l10n, colors),
  48. const SizedBox(height: 4),
  49. _buildSubmitTime(expense, l10n, colors),
  50. const SizedBox(height: 16),
  51. _buildBasicInfoSection(expense, l10n, colors),
  52. const SizedBox(height: 16),
  53. _buildAccountSection(expense, l10n),
  54. const SizedBox(height: 16),
  55. _buildDetailSection(expense, l10n, colors),
  56. const SizedBox(height: 16),
  57. _buildInvoiceSection(expense, l10n, colors),
  58. const SizedBox(height: 16),
  59. if (isFinance) _buildComplianceSection(expense, l10n, colors),
  60. const SizedBox(height: 16),
  61. if (expense.approvalRecords.isNotEmpty ||
  62. expense.approvalChain.isNotEmpty)
  63. _buildApprovalSection(expense, l10n),
  64. const SizedBox(height: 16),
  65. if (isFinance || isAdmin) _buildArchiveSection(expense, l10n),
  66. ],
  67. ),
  68. ),
  69. ),
  70. _buildBottomBar(context, expense, isFinance: isFinance, isAdmin: isAdmin),
  71. ],
  72. );
  73. }
  74. Widget _buildStatusBanner(
  75. ExpenseModel expense,
  76. AppLocalizations l10n,
  77. AppColorsExtension colors,
  78. ) {
  79. final (icon, color, label) = switch (expense.status) {
  80. 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
  81. 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
  82. 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
  83. _ => (Icons.schedule, colors.warning, l10n.get('pending')),
  84. };
  85. final approverText = switch (expense.status) {
  86. 'approved' when expense.approvalRecords.isNotEmpty =>
  87. '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}',
  88. 'rejected' when expense.approvalRecords.isNotEmpty =>
  89. '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}',
  90. 'pending' when expense.currentApproverId.isNotEmpty =>
  91. '${l10n.get('currentApprover')}:${expense.currentApproverId}',
  92. _ => '',
  93. };
  94. return StatusBanner(
  95. icon: icon,
  96. statusText: label,
  97. subText: approverText,
  98. color: color,
  99. );
  100. }
  101. Widget _buildSubmitTime(
  102. ExpenseModel expense,
  103. AppLocalizations l10n,
  104. AppColorsExtension colors,
  105. ) {
  106. return Padding(
  107. padding: const EdgeInsets.only(left: 4, top: 4),
  108. child: Align(
  109. alignment: Alignment.centerLeft,
  110. child: Text(
  111. '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
  112. style: TextStyle(
  113. fontSize: AppFontSizes.caption,
  114. color: colors.textPlaceholder,
  115. ),
  116. ),
  117. ),
  118. );
  119. }
  120. Widget _buildBasicInfoSection(
  121. ExpenseModel expense,
  122. AppLocalizations l10n,
  123. AppColorsExtension colors,
  124. ) {
  125. return FormSection(
  126. title: l10n.get('basicInfo'),
  127. children: [
  128. FormFieldRow(
  129. label: l10n.get('applicant'),
  130. value: expense.applicantName,
  131. readOnly: true,
  132. showArrow: false,
  133. ),
  134. FormFieldRow(
  135. label: l10n.get('department'),
  136. value: expense.deptName,
  137. readOnly: true,
  138. showArrow: false,
  139. ),
  140. FormFieldRow(
  141. label: l10n.get('expenseType'),
  142. value: expense.expenseType,
  143. readOnly: true,
  144. showArrow: false,
  145. ),
  146. Container(
  147. height: 44,
  148. padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
  149. child: Row(
  150. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  151. children: [
  152. Text(
  153. l10n.get('expenseAmount'),
  154. style: TextStyle(
  155. fontSize: AppFontSizes.body,
  156. color: colors.textSecondary,
  157. ),
  158. ),
  159. Text(
  160. '¥${expense.totalAmount.toStringAsFixed(2)}',
  161. style: TextStyle(
  162. fontSize: AppFontSizes.subtitle,
  163. fontWeight: FontWeight.w700,
  164. color: colors.amountPrimary,
  165. ),
  166. ),
  167. ],
  168. ),
  169. ),
  170. FormFieldRow(
  171. label: l10n.get('relatedProject'),
  172. value: expense.projectName.isNotEmpty ? expense.projectName : null,
  173. hint: '-',
  174. readOnly: true,
  175. showArrow: false,
  176. ),
  177. FormFieldRow(
  178. label: l10n.get('budgetSubject'),
  179. value: expense.budgetSubjectId.isNotEmpty
  180. ? expense.budgetSubjectId
  181. : null,
  182. hint: '-',
  183. readOnly: true,
  184. showArrow: false,
  185. ),
  186. ],
  187. );
  188. }
  189. Widget _buildAccountSection(ExpenseModel expense, AppLocalizations l10n) {
  190. return FormSection(
  191. title: l10n.get('receiptAccount'),
  192. children: [
  193. FormFieldRow(
  194. label: l10n.get('bankName'),
  195. value: expense.accountName.isNotEmpty ? expense.accountName : null,
  196. hint: '-',
  197. readOnly: true,
  198. showArrow: false,
  199. ),
  200. FormFieldRow(
  201. label: l10n.get('accountName'),
  202. value: expense.accountName.isNotEmpty ? expense.accountName : null,
  203. hint: '-',
  204. readOnly: true,
  205. showArrow: false,
  206. ),
  207. FormFieldRow(
  208. label: l10n.get('bankAccount'),
  209. value: expense.accountId.isNotEmpty ? expense.accountId : null,
  210. hint: '-',
  211. readOnly: true,
  212. showArrow: false,
  213. ),
  214. ],
  215. );
  216. }
  217. Widget _buildDetailSection(
  218. ExpenseModel expense,
  219. AppLocalizations l10n,
  220. AppColorsExtension colors,
  221. ) {
  222. return FormSection(
  223. title: l10n.get('expenseDetails'),
  224. children: [
  225. // Table header
  226. Container(
  227. height: 36,
  228. padding: const EdgeInsets.symmetric(horizontal: 8),
  229. decoration: BoxDecoration(
  230. color: colors.bgPage,
  231. borderRadius: BorderRadius.circular(4),
  232. ),
  233. child: Row(
  234. children: [
  235. Expanded(
  236. flex: 3,
  237. child: Text(
  238. l10n.get('expenseProject'),
  239. style: TextStyle(
  240. fontSize: AppFontSizes.caption,
  241. fontWeight: FontWeight.w500,
  242. color: colors.textSecondary,
  243. ),
  244. ),
  245. ),
  246. Expanded(
  247. flex: 2,
  248. child: Text(
  249. l10n.get('amount'),
  250. textAlign: TextAlign.right,
  251. style: TextStyle(
  252. fontSize: AppFontSizes.caption,
  253. fontWeight: FontWeight.w500,
  254. color: colors.textSecondary,
  255. ),
  256. ),
  257. ),
  258. ],
  259. ),
  260. ),
  261. if (expense.details.isEmpty)
  262. Padding(
  263. padding: const EdgeInsets.symmetric(vertical: 8),
  264. child: Text(
  265. l10n.get('noDetailData'),
  266. style: TextStyle(
  267. fontSize: AppFontSizes.body,
  268. color: colors.textPlaceholder,
  269. ),
  270. ),
  271. )
  272. else
  273. ...expense.details.map(
  274. (d) => SizedBox(
  275. height: 28,
  276. child: Row(
  277. children: [
  278. Expanded(
  279. flex: 3,
  280. child: Text(
  281. d.expenseDesc,
  282. style: TextStyle(
  283. fontSize: AppFontSizes.body,
  284. color: colors.textPrimary,
  285. ),
  286. ),
  287. ),
  288. Expanded(
  289. flex: 2,
  290. child: Text(
  291. '¥${d.totalAmount.toStringAsFixed(2)}',
  292. textAlign: TextAlign.right,
  293. style: TextStyle(
  294. fontSize: AppFontSizes.body,
  295. fontWeight: FontWeight.w500,
  296. color: colors.amountPrimary,
  297. ),
  298. ),
  299. ),
  300. ],
  301. ),
  302. ),
  303. ),
  304. if (expense.details.isNotEmpty) ...[
  305. Container(height: 1, color: colors.border),
  306. Container(
  307. height: 36,
  308. padding: const EdgeInsets.symmetric(vertical: 8),
  309. child: Row(
  310. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  311. children: [
  312. Text(
  313. l10n.get('total'),
  314. style: TextStyle(
  315. fontSize: AppFontSizes.body,
  316. fontWeight: FontWeight.w600,
  317. color: colors.textPrimary,
  318. ),
  319. ),
  320. Text(
  321. '¥${expense.totalAmount.toStringAsFixed(2)}',
  322. style: TextStyle(
  323. fontSize: AppFontSizes.subtitle,
  324. fontWeight: FontWeight.w700,
  325. color: colors.amountPrimary,
  326. ),
  327. ),
  328. ],
  329. ),
  330. ),
  331. ],
  332. ],
  333. );
  334. }
  335. Widget _buildInvoiceSection(
  336. ExpenseModel expense,
  337. AppLocalizations l10n,
  338. AppColorsExtension colors,
  339. ) {
  340. final hasInvoices = expense.invoiceImages.isNotEmpty;
  341. return FormSection(
  342. title: l10n.get('invoiceAttachment'),
  343. children: [
  344. if (!hasInvoices)
  345. Padding(
  346. padding: const EdgeInsets.symmetric(vertical: 8),
  347. child: Text(
  348. l10n.get('noInvoice'),
  349. style: TextStyle(
  350. fontSize: AppFontSizes.body,
  351. color: colors.textPlaceholder,
  352. ),
  353. ),
  354. )
  355. else
  356. Wrap(
  357. spacing: 8,
  358. runSpacing: 8,
  359. children: expense.invoiceImages.map((url) {
  360. return Container(
  361. width: 80,
  362. height: 80,
  363. decoration: BoxDecoration(
  364. color: colors.bgPage,
  365. borderRadius: BorderRadius.circular(4),
  366. border: Border.all(
  367. color: colors.border,
  368. strokeAlign: BorderSide.strokeAlignInside,
  369. ),
  370. ),
  371. child: Center(
  372. child: Icon(
  373. Icons.image_outlined,
  374. size: 24,
  375. color: colors.textPlaceholder,
  376. ),
  377. ),
  378. );
  379. }).toList(),
  380. ),
  381. ],
  382. );
  383. }
  384. Widget _buildComplianceSection(
  385. ExpenseModel expense,
  386. AppLocalizations l10n,
  387. AppColorsExtension colors,
  388. ) {
  389. final checks = [
  390. l10n.get('invoiceCheck1'),
  391. l10n.get('invoiceCheck2'),
  392. l10n.get('invoiceCheck3'),
  393. l10n.get('invoiceCheck4'),
  394. ];
  395. return FormSection(
  396. title: l10n.get('invoiceCheck'),
  397. children: checks.map((text) {
  398. return SizedBox(
  399. height: 44,
  400. child: Row(
  401. children: [
  402. Icon(Icons.check_circle, size: 16, color: colors.success),
  403. const SizedBox(width: 8),
  404. Text(
  405. text,
  406. style: TextStyle(
  407. fontSize: AppFontSizes.body,
  408. color: colors.textPrimary,
  409. ),
  410. ),
  411. ],
  412. ),
  413. );
  414. }).toList(),
  415. );
  416. }
  417. Widget _buildApprovalSection(ExpenseModel expense, AppLocalizations l10n) {
  418. return FormSection(
  419. title: l10n.get('approvalFlow'),
  420. children: [
  421. ApprovalTimeline(
  422. records: expense.approvalRecords,
  423. chain: expense.approvalChain,
  424. currentApproverId: expense.currentApproverId,
  425. ),
  426. ],
  427. );
  428. }
  429. Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n) {
  430. return FormSection(
  431. title: l10n.get('financialArchive'),
  432. children: [
  433. FormFieldRow(
  434. label: l10n.get('voucherNo'),
  435. value: expense.voucherNo.isNotEmpty ? expense.voucherNo : null,
  436. hint: '-',
  437. readOnly: true,
  438. showArrow: false,
  439. ),
  440. FormFieldRow(
  441. label: l10n.get('archiveDate'),
  442. value: du.DateUtils.formatDate(expense.updateTime),
  443. readOnly: true,
  444. showArrow: false,
  445. ),
  446. FormFieldRow(
  447. label: l10n.get('archiver'),
  448. value: l10n.get('financeDept'),
  449. readOnly: true,
  450. showArrow: false,
  451. ),
  452. ],
  453. );
  454. }
  455. Widget _buildBottomBar(
  456. BuildContext context,
  457. ExpenseModel expense, {
  458. required bool isFinance,
  459. required bool isAdmin,
  460. }) {
  461. final l10n = AppLocalizations.of(context);
  462. final canWithdraw =
  463. expense.status == 'pending' || expense.status == 'draft';
  464. // 财务角色:已审批 + 未付款 → 显示打款归档按钮
  465. if (isFinance &&
  466. expense.status == 'approved' &&
  467. expense.paymentStatus == 'unpaid') {
  468. return ActionBar(
  469. showLeft: true,
  470. leftLabel: l10n.get('confirmPaymentAndArchive'),
  471. centerLabel: l10n.get('nextPendingPayment'),
  472. rightLabel: l10n.get('confirmPaymentAndArchive'),
  473. onLeftTap: () {
  474. showDialog(
  475. context: context,
  476. builder: (ctx) => TDAlertDialog(
  477. title: l10n.get('confirmPaymentAndArchive'),
  478. content: l10n.get('confirmPaymentAndArchiveTip'),
  479. leftBtn: TDDialogButtonOptions(
  480. title: l10n.get('cancel'),
  481. action: () => Navigator.pop(ctx),
  482. ),
  483. rightBtn: TDDialogButtonOptions(
  484. title: l10n.get('confirm'),
  485. action: () {
  486. Navigator.pop(ctx);
  487. TDToast.showText(
  488. l10n.get('paymentArchiveSuccess'),
  489. context: context,
  490. );
  491. context.pop();
  492. },
  493. ),
  494. ),
  495. );
  496. },
  497. onCenterTap: () {
  498. TDToast.showText(
  499. l10n.get('allPaymentsProcessed'),
  500. context: context,
  501. );
  502. },
  503. );
  504. }
  505. // 非 employee/manager 角色不显示撤回按钮
  506. if (isFinance || isAdmin) {
  507. return const SizedBox.shrink();
  508. }
  509. if (!canWithdraw) {
  510. return const SizedBox.shrink();
  511. }
  512. return ActionBar(
  513. showLeft: false,
  514. centerLabel: l10n.get('withdrawApplication'),
  515. rightLabel: l10n.get('submitApproval'),
  516. onCenterTap: () {
  517. showDialog(
  518. context: context,
  519. builder: (ctx) => TDAlertDialog(
  520. title: l10n.get('withdrawConfirm'),
  521. content: l10n.get('withdrawConfirmTip'),
  522. leftBtn: TDDialogButtonOptions(
  523. title: l10n.get('cancel'),
  524. action: () => Navigator.pop(ctx),
  525. ),
  526. rightBtn: TDDialogButtonOptions(
  527. title: l10n.get('confirm'),
  528. action: () {
  529. Navigator.pop(ctx);
  530. TDToast.showText(l10n.get('withdrawn'), context: context);
  531. context.pop();
  532. },
  533. ),
  534. ),
  535. );
  536. },
  537. onRightTap: null,
  538. );
  539. }
  540. }