expense_detail_page.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  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 'expense_model.dart';
  10. import '../../core/i18n/app_localizations.dart';
  11. import '../../shared/widgets/loading_dialog.dart';
  12. import '../../shared/models/bill_attachment.dart';
  13. import '../../core/theme/app_colors.dart';
  14. import '../../core/theme/app_colors_extension.dart';
  15. import 'expense_api.dart';
  16. import '../../shared/widgets/approval_actions.dart';
  17. import '../../shared/widgets/approval_timeline.dart';
  18. import '../../shared/models/approval_status.dart';
  19. class ExpenseDetailPage extends ConsumerStatefulWidget {
  20. final String billNo;
  21. final int queryId;
  22. const ExpenseDetailPage({super.key, required this.billNo, this.queryId = 0});
  23. @override
  24. ConsumerState<ExpenseDetailPage> createState() => _ExpenseDetailPageState();
  25. }
  26. class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
  27. ExpenseModel? _expense;
  28. List<BillAttachment> _attachments = [];
  29. List<ApprovalRecord> _timelineRecords = [];
  30. List<String> _timelineChain = [];
  31. String _timelineCurrentApproverId = '';
  32. bool _isLoading = true;
  33. String? _error;
  34. @override
  35. void initState() {
  36. super.initState();
  37. _loadData();
  38. }
  39. Future<void> _loadData() async {
  40. setState(() {
  41. _isLoading = true;
  42. _error = null;
  43. });
  44. try {
  45. final api = ref.read(expenseApiProvider);
  46. // 1. 加载报销详情(主表 + 明细)
  47. final expense = await api.fetchDetail(widget.billNo);
  48. setState(() => _expense = expense);
  49. // 2. 加载审批时间线(非致命——失败时回退到 expense 模型数据)
  50. try {
  51. final timelineData = await api.fetchApprovalTimeline('BX', widget.billNo);
  52. setState(() {
  53. _timelineRecords = (timelineData['records'] as List<dynamic>?)
  54. ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
  55. .toList() ??
  56. [];
  57. _timelineChain = (timelineData['chain'] as List<dynamic>?)
  58. ?.map((e) => e as String)
  59. .toList() ??
  60. [];
  61. _timelineCurrentApproverId =
  62. (timelineData['currentApproverId'] as String?) ?? '';
  63. });
  64. } catch (_) {
  65. // 回退到 expense 自带的审批数据
  66. setState(() {
  67. _timelineRecords = expense.approvalRecords;
  68. _timelineChain = expense.approvalChain;
  69. _timelineCurrentApproverId = expense.currentApproverId;
  70. });
  71. }
  72. // 3. 加载附件(非致命)
  73. try {
  74. _attachments = await api.getAttachments('BX', widget.billNo);
  75. } catch (_) {
  76. _attachments = [];
  77. }
  78. } catch (e) {
  79. setState(() => _error = e.toString());
  80. } finally {
  81. setState(() => _isLoading = false);
  82. }
  83. }
  84. @override
  85. Widget build(BuildContext context) {
  86. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  87. final l10n = AppLocalizations.of(context);
  88. ref.read(navBarConfigProvider.notifier).update(
  89. NavBarConfig(
  90. title: l10n.get('expenseDetail'),
  91. showBack: true,
  92. onBack: () => context.pop(),
  93. ),
  94. );
  95. if (_isLoading) {
  96. return const Center(
  97. child: TDLoading(
  98. size: TDLoadingSize.large,
  99. icon: TDLoadingIcon.activity,
  100. ),
  101. );
  102. }
  103. if (_error != null) {
  104. return Center(
  105. child: Column(
  106. mainAxisAlignment: MainAxisAlignment.center,
  107. children: [
  108. Icon(Icons.error_outline, size: 48, color: colors.danger),
  109. const SizedBox(height: 12),
  110. Text(
  111. _error!,
  112. style: TextStyle(fontSize: AppFontSizes.body, color: colors.danger),
  113. textAlign: TextAlign.center,
  114. ),
  115. const SizedBox(height: 16),
  116. TDButton(
  117. text: l10n.get('retry'),
  118. theme: TDButtonTheme.primary,
  119. onTap: _loadData,
  120. ),
  121. ],
  122. ),
  123. );
  124. }
  125. final expense = _expense!;
  126. return Column(
  127. children: [
  128. Expanded(
  129. child: SingleChildScrollView(
  130. padding: const EdgeInsets.all(16),
  131. child: Column(
  132. children: [
  133. _buildBasicInfoSection(expense, l10n, colors),
  134. const SizedBox(height: 16),
  135. _buildExpenseDetailSection(expense, l10n, colors),
  136. const SizedBox(height: 16),
  137. _buildAttachmentSection(l10n, colors),
  138. const SizedBox(height: 16),
  139. _buildApprovalSection(l10n, colors),
  140. ],
  141. ),
  142. ),
  143. ),
  144. ApprovalActions(
  145. queryId: widget.queryId,
  146. onApprove: () => _handleApprove(),
  147. onReject: () => _handleReject(),
  148. onReverseAudit: () => _handleReverseAudit(),
  149. ),
  150. ],
  151. );
  152. }
  153. // ── 审核操作 handler ──
  154. void _handleApprove() async {
  155. final l10n = AppLocalizations.of(context);
  156. final rem = await _showOpinionDialog(
  157. title: l10n.get('confirmApprove'),
  158. hint: l10n.get('approvalComment'),
  159. );
  160. if (rem == null || !mounted) return;
  161. await _doAudit('approve', rem: rem);
  162. }
  163. void _handleReject() async {
  164. final l10n = AppLocalizations.of(context);
  165. final rem = await _showOpinionDialog(
  166. title: l10n.get('confirmReject'),
  167. hint: l10n.get('rejectReason'),
  168. );
  169. if (rem == null || !mounted) return;
  170. await _doAudit('reject', rem: rem);
  171. }
  172. void _handleReverseAudit() async {
  173. final l10n = AppLocalizations.of(context);
  174. final confirmed = await showDialog<bool>(
  175. context: context,
  176. builder: (ctx) => TDAlertDialog(
  177. title: l10n.get('withdrawConfirm'),
  178. content: l10n.get('withdrawConfirmTip'),
  179. leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
  180. rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
  181. ),
  182. );
  183. if (confirmed != true || !mounted) return;
  184. final rem = await _showOpinionDialog(
  185. title: l10n.get('withdrawConfirm'),
  186. hint: l10n.get('approvalComment'),
  187. );
  188. if (rem == null || !mounted) return;
  189. await _doAudit('reverseAudit', rem: rem);
  190. }
  191. /// 弹出审批意见输入框,返回 null 表示取消
  192. Future<String?> _showOpinionDialog({
  193. required String title,
  194. required String hint,
  195. }) async {
  196. final ctrl = TextEditingController();
  197. return showDialog<String>(
  198. context: context,
  199. builder: (ctx) => TDAlertDialog(
  200. title: title,
  201. content: hint,
  202. leftBtn: TDDialogButtonOptions(
  203. title: AppLocalizations.of(context).get('cancel'),
  204. action: () => Navigator.pop(ctx),
  205. ),
  206. rightBtn: TDDialogButtonOptions(
  207. title: AppLocalizations.of(context).get('confirm'),
  208. action: () => Navigator.pop(ctx, ctrl.text),
  209. ),
  210. ),
  211. );
  212. }
  213. Future<void> _doAudit(String action, {String rem = ''}) async {
  214. try {
  215. LoadingDialog.show(context);
  216. final api = ref.read(expenseApiProvider);
  217. await api.executeApproval(bilId: 'BX', bilNo: widget.billNo, action: action, rem: rem);
  218. if (mounted) {
  219. LoadingDialog.hide(context);
  220. TDToast.showSuccess(AppLocalizations.of(context).get('submitSuccess'), context: context);
  221. if (mounted) context.pop();
  222. }
  223. } catch (e) {
  224. if (mounted) {
  225. LoadingDialog.hide(context);
  226. TDToast.showFail(e.toString(), context: context);
  227. }
  228. }
  229. }
  230. // ═══ 基本信息 ═══
  231. Widget _buildBasicInfoSection(
  232. ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  233. return FormSection(
  234. title: l10n.get('basicInfo'),
  235. leadingIcon: Icons.info_outline,
  236. children: [
  237. FormFieldRow(
  238. label: l10n.get('expenseNo'),
  239. value: expense.expenseNo,
  240. readOnly: true,
  241. showArrow: false),
  242. const SizedBox(height: 16),
  243. FormFieldRow(
  244. label: l10n.get('voucherNo'),
  245. value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
  246. readOnly: true,
  247. showArrow: false),
  248. const SizedBox(height: 16),
  249. FormFieldRow(
  250. label: l10n.get('expensePersonnel'),
  251. value: expense.applicantId.isNotEmpty
  252. ? '${expense.applicantId}/${expense.applicantName}'
  253. : expense.applicantName,
  254. readOnly: true,
  255. showArrow: false),
  256. const SizedBox(height: 16),
  257. FormFieldRow(
  258. label: l10n.get('expenseDept'),
  259. value: expense.deptId.isNotEmpty
  260. ? '${expense.deptId}/${expense.deptName}'
  261. : expense.deptName,
  262. readOnly: true,
  263. showArrow: false),
  264. const SizedBox(height: 16),
  265. FormFieldRow(
  266. label: l10n.get('date'),
  267. value: du.DateUtils.formatDateTime(expense.createTime),
  268. readOnly: true,
  269. showArrow: false),
  270. const SizedBox(height: 16),
  271. FormFieldRow(
  272. label: l10n.get('currency'),
  273. value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
  274. readOnly: true,
  275. showArrow: false),
  276. const SizedBox(height: 16),
  277. SizedBox(
  278. height: 24,
  279. child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
  280. Text(l10n.get('feeReason'),
  281. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  282. const SizedBox(width: 8),
  283. Expanded(
  284. child: Text(expense.purpose.isNotEmpty ? expense.purpose : '-',
  285. textAlign: TextAlign.end,
  286. maxLines: 2,
  287. overflow: TextOverflow.ellipsis,
  288. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
  289. ),
  290. ]),
  291. ),
  292. const SizedBox(height: 16),
  293. FormFieldRow(
  294. label: l10n.get('paymentMethod'),
  295. value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
  296. readOnly: true,
  297. showArrow: false),
  298. const SizedBox(height: 16),
  299. SizedBox(
  300. height: 24,
  301. child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
  302. Text(l10n.get('remark'),
  303. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  304. const SizedBox(width: 8),
  305. Expanded(
  306. child: Text(expense.remark.isNotEmpty ? expense.remark : '-',
  307. textAlign: TextAlign.end,
  308. maxLines: 2,
  309. overflow: TextOverflow.ellipsis,
  310. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
  311. ),
  312. ]),
  313. ),
  314. ],
  315. );
  316. }
  317. // ═══ 费用明细 ═══
  318. Widget _buildExpenseDetailSection(
  319. ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
  320. final totalAmount = expense.details.fold<double>(
  321. 0,
  322. (sum, d) => sum + d.totalAmount,
  323. );
  324. final totalApproved = expense.details.fold<double>(
  325. 0,
  326. (sum, d) => sum + d.approvedAmount,
  327. );
  328. return FormSection(
  329. title: l10n.get('expenseDetails'),
  330. leadingIcon: Icons.receipt_long_outlined,
  331. children: [
  332. if (expense.details.isEmpty)
  333. Padding(
  334. padding: const EdgeInsets.symmetric(vertical: 8),
  335. child: Text(l10n.get('noDetailData'),
  336. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
  337. )
  338. else
  339. ...expense.details.asMap().entries.map((e) {
  340. final d = e.value;
  341. final title = d.categoryName.isNotEmpty
  342. ? '${d.expenseCategory}/${d.categoryName}'
  343. : (d.remark.isNotEmpty ? d.remark : (d.acctSubjectId.isNotEmpty ? d.acctSubjectId : '--'));
  344. return Container(
  345. margin: const EdgeInsets.symmetric(vertical: 6),
  346. padding: const EdgeInsets.all(12),
  347. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  348. child: Row(
  349. children: [
  350. Expanded(
  351. child: Column(
  352. crossAxisAlignment: CrossAxisAlignment.start,
  353. children: [
  354. Row(
  355. children: [
  356. Expanded(
  357. child: Text(title,
  358. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textPrimary)),
  359. ),
  360. Column(
  361. crossAxisAlignment: CrossAxisAlignment.end,
  362. children: [
  363. Text('¥${d.totalAmount.toStringAsFixed(2)}',
  364. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
  365. if (d.approvedAmount > 0)
  366. Text('¥${d.approvedAmount.toStringAsFixed(2)}',
  367. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.success)),
  368. ],
  369. ),
  370. ],
  371. ),
  372. const SizedBox(height: 2),
  373. Text('${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}',
  374. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  375. if (d.taxAmount > 0)
  376. Text('${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
  377. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  378. if (d.approvedAmount > 0)
  379. Text('${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)}',
  380. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w500, color: colors.success)),
  381. if (d.acctSubjectName.isNotEmpty)
  382. Text('${l10n.get('acctSubject')}: ${d.acctSubjectId}/${d.acctSubjectName}',
  383. maxLines: 1, overflow: TextOverflow.ellipsis,
  384. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  385. if (d.aeNo.isNotEmpty)
  386. Text('${l10n.get('expenseApplyNo')}: ${d.aeNo}',
  387. maxLines: 1, overflow: TextOverflow.ellipsis,
  388. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  389. if (d.aeDd.isNotEmpty)
  390. Text('${l10n.get('applyDate')}: ${d.aeDd}',
  391. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  392. if (d.projectName.isNotEmpty)
  393. Text('${l10n.get('project')}: ${d.projectId}/${d.projectName}',
  394. maxLines: 1, overflow: TextOverflow.ellipsis,
  395. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  396. if (d.costDeptName.isNotEmpty)
  397. Text('${l10n.get('dept')}: ${d.costDeptId}/${d.costDeptName}',
  398. maxLines: 1, overflow: TextOverflow.ellipsis,
  399. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  400. if (d.customerVendorName.isNotEmpty)
  401. Text('${l10n.get('customerVendor')}: ${d.customerVendorName}',
  402. maxLines: 1, overflow: TextOverflow.ellipsis,
  403. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  404. if (d.remark.isNotEmpty)
  405. Text(d.remark,
  406. maxLines: 2, overflow: TextOverflow.ellipsis,
  407. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
  408. ],
  409. ),
  410. ),
  411. ],
  412. ),
  413. );
  414. }),
  415. if (expense.details.isNotEmpty) ...[
  416. const SizedBox(height: 8),
  417. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  418. Text(l10n.get('total'),
  419. style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  420. Column(
  421. crossAxisAlignment: CrossAxisAlignment.end,
  422. children: [
  423. Text('¥${totalAmount.toStringAsFixed(2)}',
  424. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  425. if (totalApproved > 0)
  426. Text('${l10n.get('approvedAmount')} ¥${totalApproved.toStringAsFixed(2)}',
  427. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.success)),
  428. ],
  429. ),
  430. ]),
  431. ],
  432. ],
  433. );
  434. }
  435. // ═══ 附件 ═══
  436. Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
  437. final headerAtts = _attachments.where((a) => a.isHeader).toList();
  438. final bodyGroups = <int, List<BillAttachment>>{};
  439. for (final a in _attachments.where((a) => a.isBody)) {
  440. bodyGroups.putIfAbsent(a.srcItm, () => []).add(a);
  441. }
  442. final children = <Widget>[];
  443. if (_attachments.isEmpty) {
  444. children.add(Text(l10n.get('noAttachment'),
  445. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  446. } else {
  447. // 表头附件
  448. if (headerAtts.isNotEmpty) {
  449. children.add(Padding(
  450. padding: const EdgeInsets.only(bottom: 8),
  451. child: Text(l10n.get('headerAttachments'),
  452. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
  453. ));
  454. for (final a in headerAtts) {
  455. children.add(_buildAttachmentRow(a, colors));
  456. }
  457. }
  458. // 表身附件(按明细行分组)
  459. for (final entry in bodyGroups.entries) {
  460. children.add(const SizedBox(height: 8));
  461. children.add(Padding(
  462. padding: const EdgeInsets.only(bottom: 8),
  463. child: Text('${l10n.get('detailLine')} ${entry.key}',
  464. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
  465. ));
  466. for (final a in entry.value) {
  467. children.add(_buildAttachmentRow(a, colors));
  468. }
  469. }
  470. }
  471. return FormSection(
  472. title: l10n.get('attachments'),
  473. leadingIcon: Icons.attach_file_outlined,
  474. children: children,
  475. );
  476. }
  477. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  478. return Container(
  479. margin: const EdgeInsets.symmetric(vertical: 4),
  480. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  481. decoration: BoxDecoration(
  482. color: colors.bgPage,
  483. borderRadius: BorderRadius.circular(8),
  484. ),
  485. child: Row(children: [
  486. Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary),
  487. const SizedBox(width: 10),
  488. Expanded(
  489. child: Text(a.fileName,
  490. maxLines: 1, overflow: TextOverflow.ellipsis,
  491. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
  492. ),
  493. ]),
  494. );
  495. }
  496. // ═══ 审核流程 — 使用 ApprovalTimeline 组件 ═══
  497. Widget _buildApprovalSection(
  498. AppLocalizations l10n, AppColorsExtension colors) {
  499. return FormSection(
  500. title: l10n.get('approvalFlow'),
  501. leadingIcon: Icons.fact_check_outlined,
  502. children: [
  503. ApprovalTimeline(
  504. records: _timelineRecords,
  505. chain: _timelineChain,
  506. currentApproverId: _timelineCurrentApproverId,
  507. ),
  508. ],
  509. );
  510. }
  511. IconData _fileTypeIcon(String ext) {
  512. switch (ext.toLowerCase()) {
  513. case 'pdf': return Icons.picture_as_pdf;
  514. case 'doc': case 'docx': return Icons.description;
  515. case 'xls': case 'xlsx': return Icons.table_chart;
  516. case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
  517. default: return Icons.insert_drive_file;
  518. }
  519. }
  520. }