expense_detail_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  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 '../shell/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. 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. ref
  29. .read(navBarConfigProvider.notifier)
  30. .update(
  31. NavBarConfig(
  32. title: l10n.get('expenseDetail'),
  33. showBack: true,
  34. onBack: () => context.pop(),
  35. ),
  36. );
  37. return Column(
  38. children: [
  39. Expanded(
  40. child: SingleChildScrollView(
  41. padding: const EdgeInsets.all(16),
  42. child: Column(
  43. children: [
  44. _buildStatusBanner(expense, l10n, colors),
  45. const SizedBox(height: 4),
  46. _buildSubmitTime(expense, l10n, colors),
  47. const SizedBox(height: 16),
  48. _buildBasicInfoSection(expense, l10n, colors),
  49. const SizedBox(height: 16),
  50. _buildAccountSection(expense, l10n),
  51. const SizedBox(height: 16),
  52. _buildDetailSection(expense, l10n, colors),
  53. const SizedBox(height: 16),
  54. _buildInvoiceSection(expense, l10n, colors),
  55. const SizedBox(height: 16),
  56. _buildComplianceSection(expense, l10n, colors),
  57. const SizedBox(height: 16),
  58. if (expense.approvalRecords.isNotEmpty ||
  59. expense.approvalChain.isNotEmpty)
  60. _buildApprovalSection(expense, l10n),
  61. const SizedBox(height: 16),
  62. _buildArchiveSection(expense, l10n),
  63. ],
  64. ),
  65. ),
  66. ),
  67. _buildBottomBar(context, expense),
  68. ],
  69. );
  70. }
  71. Widget _buildStatusBanner(
  72. ExpenseModel expense,
  73. AppLocalizations l10n,
  74. AppColorsExtension colors,
  75. ) {
  76. final (icon, color, label) = switch (expense.status) {
  77. 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
  78. 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
  79. 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
  80. _ => (Icons.schedule, colors.warning, l10n.get('pending')),
  81. };
  82. final approverText = switch (expense.status) {
  83. 'approved' when expense.approvalRecords.isNotEmpty =>
  84. '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}',
  85. 'rejected' when expense.approvalRecords.isNotEmpty =>
  86. '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}',
  87. 'pending' when expense.currentApproverId.isNotEmpty =>
  88. '${l10n.get('currentApprover')}:${expense.currentApproverId}',
  89. _ => '',
  90. };
  91. return StatusBanner(
  92. icon: icon,
  93. statusText: label,
  94. subText: approverText,
  95. color: color,
  96. );
  97. }
  98. Widget _buildSubmitTime(
  99. ExpenseModel expense,
  100. AppLocalizations l10n,
  101. AppColorsExtension colors,
  102. ) {
  103. return Padding(
  104. padding: const EdgeInsets.only(left: 4, top: 4),
  105. child: Align(
  106. alignment: Alignment.centerLeft,
  107. child: Text(
  108. '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
  109. style: TextStyle(
  110. fontSize: AppFontSizes.caption,
  111. color: colors.textPlaceholder,
  112. ),
  113. ),
  114. ),
  115. );
  116. }
  117. Widget _buildBasicInfoSection(
  118. ExpenseModel expense,
  119. AppLocalizations l10n,
  120. AppColorsExtension colors,
  121. ) {
  122. return FormSection(
  123. title: l10n.get('basicInfo'),
  124. children: [
  125. FormFieldRow(
  126. label: l10n.get('applicant'),
  127. value: expense.applicantName,
  128. readOnly: true,
  129. showArrow: false,
  130. ),
  131. FormFieldRow(
  132. label: l10n.get('department'),
  133. value: expense.deptName,
  134. readOnly: true,
  135. showArrow: false,
  136. ),
  137. FormFieldRow(
  138. label: l10n.get('expenseType'),
  139. value: expense.expenseType,
  140. readOnly: true,
  141. showArrow: false,
  142. ),
  143. Container(
  144. height: 44,
  145. padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
  146. child: Row(
  147. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  148. children: [
  149. Text(
  150. l10n.get('expenseAmount'),
  151. style: TextStyle(
  152. fontSize: AppFontSizes.body,
  153. color: colors.textSecondary,
  154. ),
  155. ),
  156. Text(
  157. '¥${expense.totalAmount.toStringAsFixed(2)}',
  158. style: TextStyle(
  159. fontSize: AppFontSizes.subtitle,
  160. fontWeight: FontWeight.w700,
  161. color: colors.amountPrimary,
  162. ),
  163. ),
  164. ],
  165. ),
  166. ),
  167. FormFieldRow(
  168. label: l10n.get('relatedProject'),
  169. value: expense.projectName.isNotEmpty ? expense.projectName : null,
  170. hint: '-',
  171. readOnly: true,
  172. showArrow: false,
  173. ),
  174. FormFieldRow(
  175. label: l10n.get('budgetSubject'),
  176. value: expense.budgetSubjectId.isNotEmpty
  177. ? expense.budgetSubjectId
  178. : null,
  179. hint: '-',
  180. readOnly: true,
  181. showArrow: false,
  182. ),
  183. ],
  184. );
  185. }
  186. Widget _buildAccountSection(ExpenseModel expense, AppLocalizations l10n) {
  187. return FormSection(
  188. title: l10n.get('receiptAccount'),
  189. children: [
  190. FormFieldRow(
  191. label: l10n.get('bankName'),
  192. value: expense.accountName.isNotEmpty ? expense.accountName : null,
  193. hint: '-',
  194. readOnly: true,
  195. showArrow: false,
  196. ),
  197. FormFieldRow(
  198. label: l10n.get('accountName'),
  199. value: expense.accountName.isNotEmpty ? expense.accountName : null,
  200. hint: '-',
  201. readOnly: true,
  202. showArrow: false,
  203. ),
  204. FormFieldRow(
  205. label: l10n.get('bankAccount'),
  206. value: expense.accountId.isNotEmpty ? expense.accountId : null,
  207. hint: '-',
  208. readOnly: true,
  209. showArrow: false,
  210. ),
  211. ],
  212. );
  213. }
  214. Widget _buildDetailSection(
  215. ExpenseModel expense,
  216. AppLocalizations l10n,
  217. AppColorsExtension colors,
  218. ) {
  219. return FormSection(
  220. title: l10n.get('expenseDetails'),
  221. children: [
  222. // Table header
  223. Container(
  224. height: 36,
  225. padding: const EdgeInsets.symmetric(horizontal: 8),
  226. decoration: BoxDecoration(
  227. color: colors.bgPage,
  228. borderRadius: BorderRadius.circular(4),
  229. ),
  230. child: Row(
  231. children: [
  232. Expanded(
  233. flex: 3,
  234. child: Text(
  235. l10n.get('expenseProject'),
  236. style: TextStyle(
  237. fontSize: AppFontSizes.caption,
  238. fontWeight: FontWeight.w500,
  239. color: colors.textSecondary,
  240. ),
  241. ),
  242. ),
  243. Expanded(
  244. flex: 2,
  245. child: Text(
  246. l10n.get('amount'),
  247. textAlign: TextAlign.right,
  248. style: TextStyle(
  249. fontSize: AppFontSizes.caption,
  250. fontWeight: FontWeight.w500,
  251. color: colors.textSecondary,
  252. ),
  253. ),
  254. ),
  255. ],
  256. ),
  257. ),
  258. if (expense.details.isEmpty)
  259. Padding(
  260. padding: const EdgeInsets.symmetric(vertical: 8),
  261. child: Text(
  262. l10n.get('noDetailData'),
  263. style: TextStyle(
  264. fontSize: AppFontSizes.body,
  265. color: colors.textPlaceholder,
  266. ),
  267. ),
  268. )
  269. else
  270. ...expense.details.map(
  271. (d) => SizedBox(
  272. height: 28,
  273. child: Row(
  274. children: [
  275. Expanded(
  276. flex: 3,
  277. child: Text(
  278. d.expenseDesc,
  279. style: TextStyle(
  280. fontSize: AppFontSizes.body,
  281. color: colors.textPrimary,
  282. ),
  283. ),
  284. ),
  285. Expanded(
  286. flex: 2,
  287. child: Text(
  288. '¥${d.totalAmount.toStringAsFixed(2)}',
  289. textAlign: TextAlign.right,
  290. style: TextStyle(
  291. fontSize: AppFontSizes.body,
  292. fontWeight: FontWeight.w500,
  293. color: colors.amountPrimary,
  294. ),
  295. ),
  296. ),
  297. ],
  298. ),
  299. ),
  300. ),
  301. if (expense.details.isNotEmpty) ...[
  302. Container(height: 1, color: colors.border),
  303. Container(
  304. height: 36,
  305. padding: const EdgeInsets.symmetric(vertical: 8),
  306. child: Row(
  307. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  308. children: [
  309. Text(
  310. l10n.get('total'),
  311. style: TextStyle(
  312. fontSize: AppFontSizes.body,
  313. fontWeight: FontWeight.w600,
  314. color: colors.textPrimary,
  315. ),
  316. ),
  317. Text(
  318. '¥${expense.totalAmount.toStringAsFixed(2)}',
  319. style: TextStyle(
  320. fontSize: AppFontSizes.subtitle,
  321. fontWeight: FontWeight.w700,
  322. color: colors.amountPrimary,
  323. ),
  324. ),
  325. ],
  326. ),
  327. ),
  328. ],
  329. ],
  330. );
  331. }
  332. Widget _buildInvoiceSection(
  333. ExpenseModel expense,
  334. AppLocalizations l10n,
  335. AppColorsExtension colors,
  336. ) {
  337. final hasInvoices = expense.invoiceImages.isNotEmpty;
  338. return FormSection(
  339. title: l10n.get('invoiceAttachment'),
  340. children: [
  341. if (!hasInvoices)
  342. Padding(
  343. padding: const EdgeInsets.symmetric(vertical: 8),
  344. child: Text(
  345. l10n.get('noInvoice'),
  346. style: TextStyle(
  347. fontSize: AppFontSizes.body,
  348. color: colors.textPlaceholder,
  349. ),
  350. ),
  351. )
  352. else
  353. Wrap(
  354. spacing: 8,
  355. runSpacing: 8,
  356. children: expense.invoiceImages.map((url) {
  357. return Container(
  358. width: 80,
  359. height: 80,
  360. decoration: BoxDecoration(
  361. color: colors.bgPage,
  362. borderRadius: BorderRadius.circular(4),
  363. border: Border.all(
  364. color: colors.border,
  365. strokeAlign: BorderSide.strokeAlignInside,
  366. ),
  367. ),
  368. child: Center(
  369. child: Icon(
  370. Icons.image_outlined,
  371. size: 24,
  372. color: colors.textPlaceholder,
  373. ),
  374. ),
  375. );
  376. }).toList(),
  377. ),
  378. ],
  379. );
  380. }
  381. Widget _buildComplianceSection(
  382. ExpenseModel expense,
  383. AppLocalizations l10n,
  384. AppColorsExtension colors,
  385. ) {
  386. final checks = [
  387. l10n.get('invoiceCheck1'),
  388. l10n.get('invoiceCheck2'),
  389. l10n.get('invoiceCheck3'),
  390. l10n.get('invoiceCheck4'),
  391. ];
  392. return FormSection(
  393. title: l10n.get('invoiceCheck'),
  394. children: checks.map((text) {
  395. return SizedBox(
  396. height: 44,
  397. child: Row(
  398. children: [
  399. Icon(Icons.check_circle, size: 16, color: colors.success),
  400. const SizedBox(width: 8),
  401. Text(
  402. text,
  403. style: TextStyle(
  404. fontSize: AppFontSizes.body,
  405. color: colors.textPrimary,
  406. ),
  407. ),
  408. ],
  409. ),
  410. );
  411. }).toList(),
  412. );
  413. }
  414. Widget _buildApprovalSection(ExpenseModel expense, AppLocalizations l10n) {
  415. return FormSection(
  416. title: l10n.get('approvalFlow'),
  417. children: [
  418. ApprovalTimeline(
  419. records: expense.approvalRecords,
  420. chain: expense.approvalChain,
  421. currentApproverId: expense.currentApproverId,
  422. ),
  423. ],
  424. );
  425. }
  426. Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n) {
  427. return FormSection(
  428. title: l10n.get('financialArchive'),
  429. children: [
  430. FormFieldRow(
  431. label: l10n.get('voucherNo'),
  432. value: expense.voucherNo.isNotEmpty ? expense.voucherNo : null,
  433. hint: '-',
  434. readOnly: true,
  435. showArrow: false,
  436. ),
  437. FormFieldRow(
  438. label: l10n.get('archiveDate'),
  439. value: du.DateUtils.formatDate(expense.updateTime),
  440. readOnly: true,
  441. showArrow: false,
  442. ),
  443. FormFieldRow(
  444. label: l10n.get('archiver'),
  445. value: l10n.get('financeDept'),
  446. readOnly: true,
  447. showArrow: false,
  448. ),
  449. ],
  450. );
  451. }
  452. Widget _buildBottomBar(BuildContext context, ExpenseModel expense) {
  453. final l10n = AppLocalizations.of(context);
  454. final canWithdraw =
  455. expense.status == 'pending' || expense.status == 'draft';
  456. if (!canWithdraw) {
  457. return const SizedBox.shrink();
  458. }
  459. return ActionBar(
  460. showLeft: false,
  461. centerLabel: l10n.get('withdrawApplication'),
  462. rightLabel: l10n.get('submitApproval'),
  463. onCenterTap: () {
  464. TDToast.showText(l10n.get('withdrawn'), context: context);
  465. context.pop();
  466. },
  467. onRightTap: null,
  468. );
  469. }
  470. }