expense_apply_detail_page.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  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_apply_model.dart';
  12. import '../../core/i18n/app_localizations.dart';
  13. import 'expense_apply_list_controller.dart';
  14. import '../../core/theme/app_colors.dart';
  15. import '../../core/theme/app_colors_extension.dart';
  16. class ExpenseApplyDetailPage extends ConsumerWidget {
  17. final String id;
  18. const ExpenseApplyDetailPage({super.key, required this.id});
  19. @override
  20. Widget build(BuildContext context, WidgetRef ref) {
  21. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  22. final app = mockExpenseApplies.firstWhere(
  23. (e) => e.id == id,
  24. orElse: () => mockExpenseApplies.first,
  25. );
  26. final l10n = AppLocalizations.of(context);
  27. ref
  28. .read(navBarConfigProvider.notifier)
  29. .update(
  30. NavBarConfig(
  31. title: l10n.get('expenseApplyDetail'),
  32. showBack: true,
  33. onBack: () => context.pop(),
  34. ),
  35. );
  36. return Column(
  37. children: [
  38. Expanded(
  39. child: SingleChildScrollView(
  40. padding: const EdgeInsets.all(16),
  41. child: Column(
  42. children: [
  43. _buildStatusBanner(context, app, colors),
  44. const SizedBox(height: 8),
  45. _buildSubmitTime(context, app, colors),
  46. const SizedBox(height: 16),
  47. _buildBasicInfoSection(app, l10n, colors),
  48. const SizedBox(height: 16),
  49. _buildExpenseDetailSection(app, l10n, colors),
  50. const SizedBox(height: 16),
  51. _buildAttachmentSection(app, l10n, colors),
  52. const SizedBox(height: 16),
  53. _buildApprovalSection(l10n, colors),
  54. ],
  55. ),
  56. ),
  57. ),
  58. _buildBottomBar(context, app),
  59. ],
  60. );
  61. }
  62. Widget _buildStatusBanner(
  63. BuildContext context,
  64. ExpenseApplyModel app,
  65. AppColorsExtension colors,
  66. ) {
  67. final l10n = AppLocalizations.of(context);
  68. final (icon, color, label) = switch (app.status) {
  69. 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
  70. 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
  71. 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
  72. _ => (Icons.schedule, colors.warning, l10n.get('pending')),
  73. };
  74. final approverText = switch (app.status) {
  75. 'approved' when app.approvalRecords.isNotEmpty =>
  76. '${l10n.get('approver')}:${app.approvalRecords.last.approverName}',
  77. 'rejected' when app.approvalRecords.isNotEmpty =>
  78. '${l10n.get('rejecter')}:${app.approvalRecords.last.approverName}',
  79. 'pending' when app.currentApproverId.isNotEmpty =>
  80. '${l10n.get('currentApprover')}:${app.currentApproverId}',
  81. _ => '',
  82. };
  83. return StatusBanner(
  84. icon: icon,
  85. statusText: label,
  86. subText: approverText,
  87. color: color,
  88. );
  89. }
  90. Widget _buildSubmitTime(
  91. BuildContext context,
  92. ExpenseApplyModel app,
  93. AppColorsExtension colors,
  94. ) {
  95. final l10n = AppLocalizations.of(context);
  96. return Padding(
  97. padding: const EdgeInsets.only(left: 4),
  98. child: Text(
  99. '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(app.createTime)}',
  100. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  101. ),
  102. );
  103. }
  104. // ═══ 基本信息 ═══
  105. Widget _buildBasicInfoSection(
  106. ExpenseApplyModel app,
  107. AppLocalizations l10n,
  108. AppColorsExtension colors,
  109. ) {
  110. String urgencyLabel = switch (app.urgency) {
  111. 'urgent' => l10n.get('urgent'),
  112. 'normal' => l10n.get('normal'),
  113. 'critical' => l10n.get('critical'),
  114. _ => app.urgency,
  115. };
  116. String usageLabel = switch (app.usageStatus) {
  117. 'unused' => l10n.get('unused'),
  118. 'partially_used' => l10n.get('partiallyUsed'),
  119. 'fully_used' => l10n.get('fullyUsed'),
  120. _ => app.usageStatus,
  121. };
  122. return FormSection(
  123. title: l10n.get('basicInfo'),
  124. leadingIcon: Icons.info_outline,
  125. children: [
  126. FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false),
  127. const SizedBox(height: 16),
  128. FormFieldRow(label: l10n.get('applicant'), value: app.applicantName, readOnly: true, showArrow: false),
  129. const SizedBox(height: 16),
  130. FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false),
  131. const SizedBox(height: 16),
  132. FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(app.createTime), readOnly: true, showArrow: false),
  133. const SizedBox(height: 16),
  134. SizedBox(
  135. height: 24,
  136. child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  137. Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  138. Text(urgencyLabel, style: TextStyle(
  139. fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600,
  140. color: app.urgency == 'urgent' || app.urgency == 'critical' ? colors.danger : colors.textPrimary,
  141. )),
  142. ]),
  143. ),
  144. const SizedBox(height: 16),
  145. FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false),
  146. const SizedBox(height: 16),
  147. FormFieldRow(label: l10n.get('validUntil'), value: app.validUntil != null ? du.DateUtils.formatDate(app.validUntil!) : '-', readOnly: true, showArrow: false),
  148. const SizedBox(height: 16),
  149. FormFieldRow(label: l10n.get('relatedContractNo'), value: app.referenceNo.isNotEmpty ? app.referenceNo : '-', readOnly: true, showArrow: false),
  150. const SizedBox(height: 16),
  151. FormFieldRow(label: l10n.get('usageStatus'), value: usageLabel, readOnly: true, showArrow: false),
  152. const SizedBox(height: 16),
  153. FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
  154. ],
  155. );
  156. }
  157. // ═══ 费用明细 — 对应 create 页 _buildDetailsSection ═══
  158. Widget _buildExpenseDetailSection(
  159. ExpenseApplyModel app,
  160. AppLocalizations l10n,
  161. AppColorsExtension colors,
  162. ) {
  163. return FormSection(
  164. title: l10n.get('expenseDetails'),
  165. leadingIcon: Icons.receipt_long_outlined,
  166. children: [
  167. if (app.details.isEmpty)
  168. Padding(
  169. padding: const EdgeInsets.symmetric(vertical: 8),
  170. child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
  171. )
  172. else
  173. ...app.details.asMap().entries.map((e) {
  174. final d = e.value;
  175. return Container(
  176. margin: const EdgeInsets.symmetric(vertical: 8),
  177. padding: const EdgeInsets.all(12),
  178. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  179. child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  180. Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
  181. Expanded(
  182. child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
  183. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  184. ),
  185. const SizedBox(width: 16),
  186. Text('¥${d.estimatedAmount.toStringAsFixed(2)}',
  187. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
  188. ]),
  189. if (d.expenseCategory.isNotEmpty && d.purpose != d.expenseCategory) ...[
  190. const SizedBox(height: 4),
  191. Text(d.expenseCategory, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  192. ],
  193. if (d.projectName.isNotEmpty) ...[
  194. const SizedBox(height: 4),
  195. Text('${l10n.get('relatedProject')}:${d.projectName} | ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
  196. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  197. ],
  198. if (d.costDeptName.isNotEmpty) ...[
  199. const SizedBox(height: 4),
  200. Text('${l10n.get('costDept')}:${d.costDeptName}',
  201. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  202. ],
  203. if (d.estimatedStartDate != null) ...[
  204. const SizedBox(height: 4),
  205. Text('${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}',
  206. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  207. ],
  208. if (d.remark.isNotEmpty) ...[
  209. const SizedBox(height: 4),
  210. Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis,
  211. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  212. ],
  213. ]),
  214. );
  215. }),
  216. if (app.details.isNotEmpty) ...[
  217. const SizedBox(height: 8),
  218. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  219. Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  220. Text('¥${app.estimatedAmount.toStringAsFixed(2)}',
  221. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  222. ]),
  223. ],
  224. ],
  225. );
  226. }
  227. // ═══ 附件 ═══
  228. Widget _buildAttachmentSection(ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors) {
  229. return FormSection(
  230. title: l10n.get('attachments'),
  231. leadingIcon: Icons.attach_file_outlined,
  232. children: [
  233. if (app.attachments.isEmpty)
  234. Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
  235. else
  236. Wrap(spacing: 8, runSpacing: 8, children: app.attachments.map((a) {
  237. return Container(width: 80, height: 80,
  238. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
  239. child: Center(child: Icon(Icons.image_outlined, size: 24, color: colors.textPlaceholder)));
  240. }).toList()),
  241. ],
  242. );
  243. }
  244. // ═══ 审核流程 — 时间线组件 ═══
  245. Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) {
  246. final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[
  247. (title: l10n.get('approvalStepSubmitted'), desc: l10n.get('approvalDescSubmitted'), time: '2026-06-29 09:15', icon: Icons.check_circle, iconColor: colors.success),
  248. (title: l10n.get('approvalStepApproved'), desc: l10n.get('approvalDescApproved'), time: '2026-06-29 14:30', icon: Icons.check_circle, iconColor: colors.success),
  249. (title: l10n.get('approvalStepFinanceReview'), desc: l10n.get('approvalDescFinanceReview'), time: null, icon: Icons.schedule, iconColor: colors.warning),
  250. (title: l10n.get('approvalStepArchive'), desc: l10n.get('approvalStepArchiveDesc'), time: null, icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder),
  251. ];
  252. return FormSection(
  253. title: l10n.get('approvalFlow'),
  254. leadingIcon: Icons.fact_check_outlined,
  255. children: [
  256. ...steps.asMap().entries.map((e) {
  257. final s = e.value;
  258. final isLast = e.key == steps.length - 1;
  259. final isActive = e.key <= 2;
  260. final iconColor = isActive ? s.iconColor : colors.textPlaceholder;
  261. final textColor = isActive ? colors.textPrimary : colors.textPlaceholder;
  262. final subColor = isActive ? colors.textSecondary : colors.textPlaceholder;
  263. return IntrinsicHeight(
  264. child: Row(
  265. crossAxisAlignment: CrossAxisAlignment.start,
  266. children: [
  267. SizedBox(
  268. width: 24,
  269. child: Column(
  270. children: [
  271. Container(
  272. width: 24, height: 24,
  273. decoration: BoxDecoration(
  274. color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled,
  275. shape: BoxShape.circle,
  276. ),
  277. child: Icon(s.icon, size: 14, color: iconColor),
  278. ),
  279. if (!isLast)
  280. Expanded(
  281. child: Container(
  282. width: 2,
  283. margin: const EdgeInsets.symmetric(vertical: 4),
  284. color: isActive ? s.iconColor.withAlpha(60) : colors.border,
  285. ),
  286. ),
  287. ],
  288. ),
  289. ),
  290. const SizedBox(width: 12),
  291. Expanded(
  292. child: Padding(
  293. padding: EdgeInsets.only(bottom: isLast ? 0 : 16),
  294. child: Column(
  295. crossAxisAlignment: CrossAxisAlignment.start,
  296. children: [
  297. Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)),
  298. const SizedBox(height: 4),
  299. Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)),
  300. if (s.time != null) ...[
  301. const SizedBox(height: 2),
  302. Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  303. ],
  304. ],
  305. ),
  306. ),
  307. ),
  308. ],
  309. ),
  310. );
  311. }),
  312. ],
  313. );
  314. }
  315. Widget _buildBottomBar(BuildContext context, ExpenseApplyModel app) {
  316. final l10n = AppLocalizations.of(context);
  317. if (app.status != 'pending' && app.status != 'draft') return const SizedBox.shrink();
  318. return ActionBar(
  319. showLeft: false,
  320. centerLabel: l10n.get('withdrawApplication'),
  321. rightLabel: l10n.get('submitApproval'),
  322. onCenterTap: () { TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); },
  323. onRightTap: null,
  324. );
  325. }
  326. }