expense_detail_page.dart 14 KB

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