expense_application_detail_page.dart 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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_application_model.dart';
  13. import '../../core/i18n/app_localizations.dart';
  14. import 'expense_application_list_controller.dart';
  15. import '../../core/theme/app_colors.dart';
  16. import '../../core/theme/app_colors_extension.dart';
  17. class ExpenseApplicationDetailPage extends ConsumerWidget {
  18. final String id;
  19. const ExpenseApplicationDetailPage({super.key, required this.id});
  20. @override
  21. Widget build(BuildContext context, WidgetRef ref) {
  22. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  23. final app = mockExpenseApplications.firstWhere(
  24. (e) => e.id == id,
  25. orElse: () => mockExpenseApplications.first,
  26. );
  27. final l10n = AppLocalizations.of(context);
  28. ref
  29. .read(navBarConfigProvider.notifier)
  30. .update(
  31. NavBarConfig(
  32. title: l10n.get('expenseApplyDetail'),
  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(app, colors),
  45. const SizedBox(height: 4),
  46. _buildSubmitTime(app, colors),
  47. const SizedBox(height: 16),
  48. _buildBasicInfoSection(app, l10n, colors),
  49. const SizedBox(height: 16),
  50. _buildExpenseDetailSection(app, l10n, colors),
  51. const SizedBox(height: 16),
  52. _buildAttachmentSection(l10n, colors),
  53. const SizedBox(height: 16),
  54. if (app.approvalRecords.isNotEmpty ||
  55. app.approvalChain.isNotEmpty)
  56. _buildApprovalSection(l10n, app),
  57. const SizedBox(height: 16),
  58. ],
  59. ),
  60. ),
  61. ),
  62. _buildBottomBar(context, app),
  63. ],
  64. );
  65. }
  66. Widget _buildStatusBanner(
  67. ExpenseApplicationModel app,
  68. AppColorsExtension colors,
  69. ) {
  70. final (icon, color, label) = switch (app.status) {
  71. 'approved' => (Icons.check_circle, colors.success, '已通过'),
  72. 'rejected' => (Icons.cancel, colors.danger, '已拒绝'),
  73. 'draft' => (Icons.edit, colors.statusGray, '草稿'),
  74. _ => (Icons.schedule, colors.warning, '审批中'),
  75. };
  76. final approverText = switch (app.status) {
  77. 'approved' when app.approvalRecords.isNotEmpty =>
  78. '审批人:${app.approvalRecords.last.approverName}',
  79. 'rejected' when app.approvalRecords.isNotEmpty =>
  80. '拒绝人:${app.approvalRecords.last.approverName}',
  81. 'pending' when app.currentApproverId.isNotEmpty =>
  82. '当前审批人:${app.currentApproverId}',
  83. _ => '',
  84. };
  85. return StatusBanner(
  86. icon: icon,
  87. statusText: label,
  88. subText: approverText,
  89. color: color,
  90. );
  91. }
  92. Widget _buildSubmitTime(
  93. ExpenseApplicationModel app,
  94. AppColorsExtension colors,
  95. ) {
  96. return Padding(
  97. padding: const EdgeInsets.only(left: 4, top: 4),
  98. child: Align(
  99. alignment: Alignment.centerLeft,
  100. child: Text(
  101. '提交时间:${du.DateUtils.formatDateTime(app.createTime)}',
  102. style: TextStyle(
  103. fontSize: AppFontSizes.caption,
  104. color: colors.textPlaceholder,
  105. ),
  106. ),
  107. ),
  108. );
  109. }
  110. Widget _buildBasicInfoSection(
  111. ExpenseApplicationModel app,
  112. AppLocalizations l10n,
  113. AppColorsExtension colors,
  114. ) {
  115. String urgencyLabel = switch (app.urgency) {
  116. 'urgent' => '紧急',
  117. 'normal' => '普通',
  118. _ => app.urgency,
  119. };
  120. return FormSection(
  121. title: l10n.get('basicInfo'),
  122. children: [
  123. FormFieldRow(
  124. label: l10n.get('applicant'),
  125. value: app.applicantName,
  126. readOnly: true,
  127. showArrow: false,
  128. ),
  129. FormFieldRow(
  130. label: l10n.get('department'),
  131. value: app.deptName,
  132. readOnly: true,
  133. showArrow: false,
  134. ),
  135. FormFieldRow(
  136. label: '费用类型',
  137. value: app.expenseType,
  138. readOnly: true,
  139. showArrow: false,
  140. ),
  141. Container(
  142. height: 44,
  143. padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
  144. child: Row(
  145. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  146. children: [
  147. Text(
  148. '申请金额',
  149. style: TextStyle(
  150. fontSize: AppFontSizes.body,
  151. color: colors.textSecondary,
  152. ),
  153. ),
  154. Text(
  155. '¥${app.estimatedAmount.toStringAsFixed(2)}',
  156. style: TextStyle(
  157. fontSize: AppFontSizes.subtitle,
  158. fontWeight: FontWeight.w700,
  159. color: colors.amountPrimary,
  160. ),
  161. ),
  162. ],
  163. ),
  164. ),
  165. FormFieldRow(
  166. label: '关联项目',
  167. value: app.projectName.isNotEmpty ? app.projectName : null,
  168. hint: '-',
  169. readOnly: true,
  170. showArrow: false,
  171. ),
  172. FormFieldRow(
  173. label: '预算科目',
  174. value: app.budgetSubjectId.isNotEmpty ? app.budgetSubjectId : null,
  175. hint: '-',
  176. readOnly: true,
  177. showArrow: false,
  178. ),
  179. FormFieldRow(
  180. label: '紧急程度',
  181. value: urgencyLabel,
  182. readOnly: true,
  183. showArrow: false,
  184. ),
  185. ],
  186. );
  187. }
  188. Widget _buildExpenseDetailSection(
  189. ExpenseApplicationModel app,
  190. AppLocalizations l10n,
  191. AppColorsExtension colors,
  192. ) {
  193. return FormSection(
  194. title: l10n.get('expenseDetails'),
  195. children: [
  196. // Table header
  197. Container(
  198. height: 36,
  199. padding: const EdgeInsets.symmetric(horizontal: 8),
  200. decoration: BoxDecoration(
  201. color: colors.bgPage,
  202. borderRadius: BorderRadius.circular(4),
  203. ),
  204. child: Row(
  205. children: [
  206. Expanded(
  207. flex: 3,
  208. child: Text(
  209. '费用项目',
  210. style: TextStyle(
  211. fontSize: AppFontSizes.caption,
  212. fontWeight: FontWeight.w500,
  213. color: colors.textSecondary,
  214. ),
  215. ),
  216. ),
  217. Expanded(
  218. flex: 2,
  219. child: Text(
  220. '金额',
  221. textAlign: TextAlign.right,
  222. style: TextStyle(
  223. fontSize: AppFontSizes.caption,
  224. fontWeight: FontWeight.w500,
  225. color: colors.textSecondary,
  226. ),
  227. ),
  228. ),
  229. ],
  230. ),
  231. ),
  232. if (app.details.isEmpty)
  233. Padding(
  234. padding: EdgeInsets.symmetric(vertical: 8),
  235. child: Text(
  236. '暂无明细数据',
  237. style: TextStyle(
  238. fontSize: AppFontSizes.body,
  239. color: colors.textPlaceholder,
  240. ),
  241. ),
  242. )
  243. else
  244. ...app.details.map(
  245. (d) => SizedBox(
  246. height: 28,
  247. child: Row(
  248. children: [
  249. Expanded(
  250. flex: 3,
  251. child: Text(
  252. d.itemName,
  253. style: TextStyle(
  254. fontSize: AppFontSizes.body,
  255. color: colors.textPrimary,
  256. ),
  257. ),
  258. ),
  259. Expanded(
  260. flex: 2,
  261. child: Text(
  262. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  263. textAlign: TextAlign.right,
  264. style: TextStyle(
  265. fontSize: AppFontSizes.body,
  266. fontWeight: FontWeight.w500,
  267. color: colors.amountPrimary,
  268. ),
  269. ),
  270. ),
  271. ],
  272. ),
  273. ),
  274. ),
  275. ],
  276. );
  277. }
  278. Widget _buildAttachmentSection(
  279. AppLocalizations l10n,
  280. AppColorsExtension colors,
  281. ) {
  282. return FormSection(
  283. title: l10n.get('attachments'),
  284. children: [
  285. Wrap(
  286. spacing: 8,
  287. runSpacing: 8,
  288. children: List.generate(3, (i) {
  289. return Container(
  290. width: 80,
  291. height: 80,
  292. decoration: BoxDecoration(
  293. color: colors.bgPage,
  294. borderRadius: BorderRadius.circular(4),
  295. border: Border.all(
  296. color: colors.border,
  297. strokeAlign: BorderSide.strokeAlignInside,
  298. ),
  299. ),
  300. child: Center(
  301. child: Icon(
  302. Icons.image_outlined,
  303. size: 24,
  304. color: colors.textPlaceholder,
  305. ),
  306. ),
  307. );
  308. }),
  309. ),
  310. ],
  311. );
  312. }
  313. Widget _buildApprovalSection(
  314. AppLocalizations l10n,
  315. ExpenseApplicationModel app,
  316. ) {
  317. return FormSection(
  318. title: l10n.get('approvalFlow'),
  319. children: [
  320. ApprovalTimeline(
  321. records: app.approvalRecords,
  322. chain: app.approvalChain,
  323. currentApproverId: app.currentApproverId,
  324. ),
  325. ],
  326. );
  327. }
  328. Widget _buildBottomBar(BuildContext context, ExpenseApplicationModel app) {
  329. final canWithdraw = app.status == 'pending' || app.status == 'draft';
  330. if (!canWithdraw) {
  331. return const SizedBox.shrink();
  332. }
  333. return ActionBar(
  334. showLeft: false,
  335. centerLabel: '撤回申请',
  336. rightLabel: '提交审批',
  337. onCenterTap: () {
  338. TDToast.showText('已撤回', context: context);
  339. context.pop();
  340. },
  341. onRightTap: null,
  342. );
  343. }
  344. }