expense_apply_edit_page.dart 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import 'package:tdesign_flutter/tdesign_flutter.dart';
  7. import '../../core/i18n/app_localizations.dart';
  8. import '../../core/navigation/host_app_channel.dart';
  9. import '../../shared/widgets/action_bar.dart';
  10. import '../../shared/widgets/loading_dialog.dart';
  11. import '../../shared/widgets/form_section.dart';
  12. import '../../shared/widgets/form_field_row.dart';
  13. import '../../shared/widgets/app_skeletons.dart';
  14. import '../../shared/widgets/nav_bar_config.dart';
  15. import '../../core/theme/app_colors.dart';
  16. import '../../core/theme/app_colors_extension.dart';
  17. import '../../core/constants/enums.dart';
  18. import 'expense_apply_api.dart';
  19. import 'widgets/expense_apply_detail_dialog.dart';
  20. import '../../shared/widgets/searchable_picker_sheet.dart';
  21. import '../../shared/helpers/audit_flow_helper.dart';
  22. import '../../core/utils/amount_utils.dart';
  23. class ExpenseApplyEditPage extends ConsumerStatefulWidget {
  24. final String billNo;
  25. const ExpenseApplyEditPage({super.key, required this.billNo});
  26. @override
  27. ConsumerState<ExpenseApplyEditPage> createState() =>
  28. _ExpenseApplyEditPageState();
  29. }
  30. class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
  31. // ── 原单数据 ──
  32. String _billNo = '';
  33. String _applyDate = '';
  34. // ── 基本信息 ──
  35. String _urgency = Urgency.normal.value;
  36. final _purposeController = TextEditingController();
  37. final _purposeFocus = FocusNode();
  38. final _remarkController = TextEditingController();
  39. final _remarkFocus = FocusNode();
  40. final _scrollCtrl = ScrollController();
  41. // ── 费用明细 ──
  42. final List<_DetailItem> _details = [];
  43. int _detailIdCounter = 1;
  44. // ── 参考数据(从 API 加载) ──
  45. bool _firstBuild = true;
  46. bool _refDataLoading = true;
  47. bool _loadingBill = true;
  48. String? _loadingError;
  49. bool _addingDetail = false;
  50. dynamic _acctTree;
  51. EmployeeItem? _currentUserAsEmployee;
  52. // ── 原始数据(用于未保存修改检测) ──
  53. String _originalPurpose = '';
  54. String _originalRemark = '';
  55. List<_DetailItem> _originalDetails = [];
  56. // ── 申请部门 ──
  57. String _selectedDeptId = '';
  58. String _selectedDeptName = '';
  59. @override
  60. void initState() {
  61. super.initState();
  62. SystemChrome.setSystemUIOverlayStyle(
  63. const SystemUiOverlayStyle(
  64. statusBarColor: Colors.transparent,
  65. statusBarIconBrightness: Brightness.dark,
  66. ),
  67. );
  68. _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
  69. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  70. _acctTree = null;
  71. _refDataLoading = true;
  72. _loadingBill = true;
  73. _loadRefData();
  74. _loadBillData();
  75. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  76. }
  77. void _checkDataReady() {
  78. if (!_refDataLoading && !_loadingBill && mounted) {
  79. setState(() => _firstBuild = false);
  80. WidgetsBinding.instance.addPostFrameCallback((_) {
  81. if (mounted) setState(() {});
  82. });
  83. } else if (mounted) {
  84. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  85. }
  86. }
  87. Future<void>? _refDataFuture;
  88. Future<void> _loadRefData({bool showLoading = false}) async {
  89. if (_refDataFuture != null) return _refDataFuture!;
  90. final completer = Completer<void>();
  91. _refDataFuture = completer.future;
  92. if (showLoading) {
  93. LoadingDialog.show(
  94. context,
  95. text: AppLocalizations.of(context).get('dataLoading'),
  96. );
  97. }
  98. try {
  99. final api = ref.read(expenseApplyApiProvider);
  100. final results = await Future.wait([
  101. api.getAcctSubjects(),
  102. api.getEmployees(salNo: HostAppChannel.usr, size: 1),
  103. ]);
  104. if (!mounted) return;
  105. final employeeCheck = results[1] as List<EmployeeItem>;
  106. setState(() {
  107. _acctTree = _convertAcctTree(results[0]);
  108. _currentUserAsEmployee = employeeCheck.isNotEmpty
  109. ? employeeCheck.first
  110. : null;
  111. _refDataLoading = false;
  112. });
  113. completer.complete();
  114. } catch (_) {
  115. if (!mounted) {
  116. completer.complete();
  117. return;
  118. }
  119. setState(() => _refDataLoading = false);
  120. completer.complete();
  121. } finally {
  122. if (showLoading && mounted) LoadingDialog.hide(context);
  123. _refDataFuture = null;
  124. }
  125. }
  126. Future<void> _loadBillData() async {
  127. try {
  128. final api = ref.read(expenseApplyApiProvider);
  129. final detail = await api.fetchDetail(widget.billNo);
  130. if (!mounted) return;
  131. final n = detail.createTime;
  132. final applyDate =
  133. '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  134. setState(() {
  135. _billNo = detail.expenseApplyNo;
  136. _applyDate = applyDate;
  137. _selectedDeptId = detail.deptId;
  138. _selectedDeptName = detail.deptName;
  139. // 紧急程度映射(兼容代码值 '1'/'2'/'3' 和文字值)
  140. final urg = detail.urgency;
  141. if (urg == '3' || urg == 'critical') {
  142. _urgency = Urgency.critical.value;
  143. } else if (urg == '2' || urg == 'urgent') {
  144. _urgency = Urgency.urgent.value;
  145. } else {
  146. _urgency = Urgency.normal.value;
  147. }
  148. _purposeController.text = detail.purpose;
  149. _remarkController.text = detail.remark;
  150. // 费用明细回填
  151. _details.clear();
  152. _detailIdCounter = 1;
  153. for (final d in detail.details) {
  154. String startDateStr = '';
  155. String endDateStr = '';
  156. if (d.estimatedStartDate != null) {
  157. final s = d.estimatedStartDate!;
  158. startDateStr =
  159. '${s.year}-${s.month.toString().padLeft(2, '0')}-${s.day.toString().padLeft(2, '0')}';
  160. }
  161. if (d.estimatedEndDate != null) {
  162. final e = d.estimatedEndDate!;
  163. endDateStr =
  164. '${e.year}-${e.month.toString().padLeft(2, '0')}-${e.day.toString().padLeft(2, '0')}';
  165. }
  166. _details.add(
  167. _DetailItem(
  168. id: _detailIdCounter++,
  169. category: d.expenseCategory,
  170. categoryName: d.categoryName,
  171. acctSubjectId: d.acctSubjectId,
  172. acctSubjectName: d.acctSubjectName,
  173. purpose: d.purpose,
  174. projectId: d.projectId,
  175. projectName: d.projectName,
  176. costDeptId: d.costDeptId,
  177. costDeptName: d.costDeptName,
  178. startDate: startDateStr,
  179. endDate: endDateStr,
  180. estimatedAmount: d.estimatedAmount,
  181. remark: d.remark,
  182. preItm: d.preItm,
  183. sqMan: d.sqMan,
  184. sqManName: d.sqName,
  185. ),
  186. );
  187. }
  188. _loadingBill = false;
  189. // 保存原始值用于检测未保存修改
  190. _originalPurpose = detail.purpose;
  191. _originalRemark = detail.remark;
  192. _originalDetails = List<_DetailItem>.from(_details);
  193. });
  194. } catch (e) {
  195. if (!mounted) return;
  196. setState(() {
  197. _loadingBill = false;
  198. _loadingError = e.toString();
  199. });
  200. }
  201. }
  202. void _ensureVisible(FocusNode node) {
  203. if (!node.hasFocus) return;
  204. WidgetsBinding.instance.addPostFrameCallback((_) {
  205. if (node.hasFocus && _scrollCtrl.hasClients) {
  206. final ctx = node.context;
  207. if (ctx != null) {
  208. Scrollable.ensureVisible(
  209. ctx,
  210. alignment: 0.3,
  211. duration: const Duration(milliseconds: 300),
  212. );
  213. }
  214. }
  215. });
  216. }
  217. @override
  218. void dispose() {
  219. _purposeController.dispose();
  220. _purposeFocus.dispose();
  221. _remarkController.dispose();
  222. _remarkFocus.dispose();
  223. _scrollCtrl.dispose();
  224. super.dispose();
  225. }
  226. @override
  227. Widget build(BuildContext context) {
  228. final l10n = AppLocalizations.of(context);
  229. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  230. if (_loadingError != null) {
  231. return Center(
  232. child: Column(
  233. mainAxisSize: MainAxisSize.min,
  234. children: [
  235. Icon(Icons.error_outline, size: 48, color: colors.danger),
  236. const SizedBox(height: 16),
  237. Padding(
  238. padding: const EdgeInsets.symmetric(horizontal: 32),
  239. child: Text(
  240. _loadingError!,
  241. textAlign: TextAlign.center,
  242. style: TextStyle(
  243. fontSize: AppFontSizes.body,
  244. color: colors.textSecondary,
  245. ),
  246. ),
  247. ),
  248. const SizedBox(height: 16),
  249. TDButton(
  250. text: l10n.get('retry'),
  251. size: TDButtonSize.medium,
  252. onTap: () {
  253. setState(() {
  254. _loadingError = null;
  255. _loadingBill = true;
  256. });
  257. _loadBillData();
  258. },
  259. ),
  260. ],
  261. ),
  262. );
  263. }
  264. if (_firstBuild) {
  265. return const SkeletonFormPage();
  266. }
  267. Future.microtask(
  268. () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
  269. );
  270. return PopScope(
  271. canPop: false,
  272. onPopInvokedWithResult: (didPop, _) {
  273. if (didPop) return;
  274. _doPop();
  275. },
  276. child: Column(
  277. children: [
  278. Expanded(
  279. child: GestureDetector(
  280. onTap: () => FocusScope.of(context).unfocus(),
  281. child: SingleChildScrollView(
  282. controller: _scrollCtrl,
  283. padding: const EdgeInsets.all(16),
  284. child: Column(
  285. children: [
  286. _buildBasicInfo(l10n),
  287. const SizedBox(height: 16),
  288. _buildDetailsSection(l10n),
  289. const SizedBox(height: 24),
  290. _buildPageFooter(),
  291. ],
  292. ),
  293. ),
  294. ),
  295. ),
  296. _buildBottomBar(l10n),
  297. ],
  298. ),
  299. );
  300. }
  301. // ═══ 1. 基本信息 ═══
  302. Widget _buildBasicInfo(AppLocalizations l10n) {
  303. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  304. return FormSection(
  305. title: l10n.get('basicInfo'),
  306. leadingIcon: Icons.info_outline,
  307. children: [
  308. FormFieldRow(
  309. label: l10n.get('expenseApplyNo'),
  310. value: _billNo,
  311. readOnly: true,
  312. showArrow: false,
  313. ),
  314. const SizedBox(height: 16),
  315. FormFieldRow(
  316. label: l10n.get('date'),
  317. value: _applyDate,
  318. readOnly: true,
  319. showArrow: false,
  320. ),
  321. const SizedBox(height: 16),
  322. FormFieldRow(
  323. label: l10n.get('applyDept'),
  324. value: _selectedDeptId.isNotEmpty
  325. ? '$_selectedDeptId/$_selectedDeptName'
  326. : '',
  327. hint: l10n.get('pleaseSelect'),
  328. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  329. ),
  330. const SizedBox(height: 16),
  331. _buildUrgencyRow(l10n),
  332. const SizedBox(height: 16),
  333. _label(l10n.get('applyReason'), required: true),
  334. const SizedBox(height: 8),
  335. TDTextarea(
  336. controller: _purposeController,
  337. focusNode: _purposeFocus,
  338. hintText: l10n.get('enterApplyReason'),
  339. maxLines: 4,
  340. minLines: 1,
  341. maxLength: 500,
  342. indicator: true,
  343. padding: EdgeInsets.zero,
  344. bordered: true,
  345. backgroundColor: colors.bgPage,
  346. ),
  347. const SizedBox(height: 16),
  348. _label(l10n.get('remark')),
  349. const SizedBox(height: 8),
  350. TDTextarea(
  351. controller: _remarkController,
  352. focusNode: _remarkFocus,
  353. hintText: l10n.get('enterRemark'),
  354. maxLines: 3,
  355. minLines: 1,
  356. maxLength: 500,
  357. indicator: true,
  358. padding: EdgeInsets.zero,
  359. bordered: true,
  360. backgroundColor: colors.bgPage,
  361. ),
  362. ],
  363. );
  364. }
  365. Widget _buildUrgencyRow(AppLocalizations l10n) {
  366. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  367. return Row(
  368. children: [
  369. Text.rich(
  370. TextSpan(
  371. children: [
  372. TextSpan(
  373. text: l10n.get('emergencyLevel'),
  374. style: TextStyle(
  375. fontSize: AppFontSizes.subtitle,
  376. color: colors.textSecondary,
  377. ),
  378. ),
  379. TextSpan(
  380. text: ' *',
  381. style: TextStyle(
  382. fontSize: AppFontSizes.subtitle,
  383. color: colors.danger,
  384. ),
  385. ),
  386. ],
  387. ),
  388. ),
  389. const SizedBox(width: 8),
  390. Expanded(
  391. child: Wrap(
  392. alignment: WrapAlignment.end,
  393. crossAxisAlignment: WrapCrossAlignment.center,
  394. spacing: 18,
  395. runSpacing: 8,
  396. children: Urgency.values.asMap().entries.map((e) {
  397. final sel = _urgency == e.value.value;
  398. final isCritical = e.value.value == Urgency.critical.value;
  399. final isUrgent = e.value.value == Urgency.urgent.value;
  400. final activeColor = isCritical
  401. ? colors.danger
  402. : isUrgent
  403. ? colors.warning
  404. : colors.primary;
  405. return GestureDetector(
  406. behavior: HitTestBehavior.opaque,
  407. onTap: () => setState(() => _urgency = e.value.value),
  408. child: Row(
  409. mainAxisSize: MainAxisSize.min,
  410. children: [
  411. Container(
  412. width: 18,
  413. height: 18,
  414. decoration: BoxDecoration(
  415. shape: BoxShape.circle,
  416. border: Border.all(
  417. color: sel ? activeColor : colors.textPlaceholder,
  418. width: 2,
  419. ),
  420. ),
  421. child: sel
  422. ? Center(
  423. child: Container(
  424. width: 8,
  425. height: 8,
  426. decoration: BoxDecoration(
  427. shape: BoxShape.circle,
  428. color: activeColor,
  429. ),
  430. ),
  431. )
  432. : null,
  433. ),
  434. const SizedBox(width: 5),
  435. Text(
  436. l10n.get(e.value.labelKey),
  437. style: TextStyle(
  438. fontSize: AppFontSizes.subtitle,
  439. color: sel ? activeColor : colors.textPrimary,
  440. ),
  441. ),
  442. ],
  443. ),
  444. );
  445. }).toList(),
  446. ),
  447. ),
  448. ],
  449. );
  450. }
  451. // ═══ 2. 费用明细 ═══
  452. Widget _buildDetailsSection(AppLocalizations l10n) {
  453. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  454. return FormSection(
  455. title: l10n.get('expenseDetails'),
  456. leadingIcon: Icons.receipt_long_outlined,
  457. showAction: true,
  458. actionText: l10n.get('add'),
  459. onActionTap: _showDetailDialog,
  460. children: [
  461. if (_details.isEmpty)
  462. Padding(
  463. padding: const EdgeInsets.symmetric(vertical: 8),
  464. child: Text(
  465. l10n.get('noDetailHint'),
  466. style: TextStyle(
  467. fontSize: AppFontSizes.subtitle,
  468. color: colors.textPlaceholder,
  469. ),
  470. ),
  471. )
  472. else
  473. ..._details.asMap().entries.map((e) {
  474. final d = e.value;
  475. return GestureDetector(
  476. onTap: () => _showDetailDialog(editIndex: e.key),
  477. child: Container(
  478. margin: const EdgeInsets.symmetric(vertical: 8),
  479. padding: const EdgeInsets.all(12),
  480. decoration: BoxDecoration(
  481. color: colors.bgPage,
  482. borderRadius: BorderRadius.circular(8),
  483. ),
  484. child: Row(
  485. crossAxisAlignment: CrossAxisAlignment.center,
  486. children: [
  487. Expanded(
  488. child: Column(
  489. crossAxisAlignment: CrossAxisAlignment.start,
  490. children: [
  491. Row(
  492. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  493. children: [
  494. Expanded(
  495. child: Text(
  496. '${d.category}${d.categoryName.isNotEmpty ? '/${d.categoryName}' : ''}',
  497. maxLines: 1,
  498. overflow: TextOverflow.ellipsis,
  499. style: TextStyle(
  500. fontSize: AppFontSizes.subtitle,
  501. color: colors.textPrimary,
  502. ),
  503. ),
  504. ),
  505. const SizedBox(width: 12),
  506. Text(
  507. formatAmount(d.estimatedAmount),
  508. style: TextStyle(
  509. fontSize: AppFontSizes.caption,
  510. fontWeight: FontWeight.w600,
  511. color: colors.amountPrimary,
  512. ),
  513. ),
  514. ],
  515. ),
  516. if (d.acctSubjectId.isNotEmpty) ...[
  517. const SizedBox(height: 4),
  518. Text(
  519. '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}',
  520. maxLines: 1,
  521. overflow: TextOverflow.ellipsis,
  522. style: TextStyle(
  523. fontSize: AppFontSizes.caption,
  524. color: colors.textSecondary,
  525. ),
  526. ),
  527. ],
  528. if (d.sqMan.isNotEmpty) ...[
  529. const SizedBox(height: 4),
  530. Text(
  531. '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}',
  532. maxLines: 1,
  533. overflow: TextOverflow.ellipsis,
  534. style: TextStyle(
  535. fontSize: AppFontSizes.caption,
  536. color: colors.textSecondary,
  537. ),
  538. ),
  539. ],
  540. if (d.projectId.isNotEmpty) ...[
  541. const SizedBox(height: 4),
  542. Text(
  543. '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}',
  544. maxLines: 1,
  545. overflow: TextOverflow.ellipsis,
  546. style: TextStyle(
  547. fontSize: AppFontSizes.caption,
  548. color: colors.textSecondary,
  549. ),
  550. ),
  551. ],
  552. if (d.costDeptId.isNotEmpty) ...[
  553. const SizedBox(height: 4),
  554. Text(
  555. '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}',
  556. maxLines: 1,
  557. overflow: TextOverflow.ellipsis,
  558. style: TextStyle(
  559. fontSize: AppFontSizes.caption,
  560. color: colors.textSecondary,
  561. ),
  562. ),
  563. ],
  564. if (d.startDate.isNotEmpty &&
  565. d.endDate.isNotEmpty) ...[
  566. const SizedBox(height: 4),
  567. Text(
  568. '${l10n.get('estimatedDate')}: ${d.startDate} ~ ${d.endDate}',
  569. maxLines: 1,
  570. overflow: TextOverflow.ellipsis,
  571. style: TextStyle(
  572. fontSize: AppFontSizes.caption,
  573. color: colors.textSecondary,
  574. ),
  575. ),
  576. ],
  577. if (d.remark.isNotEmpty) ...[
  578. const SizedBox(height: 4),
  579. Text(
  580. d.remark,
  581. style: TextStyle(
  582. fontSize: AppFontSizes.caption,
  583. color: colors.textSecondary,
  584. ),
  585. ),
  586. ],
  587. ],
  588. ),
  589. ),
  590. const SizedBox(width: 8),
  591. GestureDetector(
  592. onTap: () => setState(() => _details.removeAt(e.key)),
  593. child: Icon(
  594. Icons.close,
  595. size: 18,
  596. color: colors.textSecondary,
  597. ),
  598. ),
  599. ],
  600. ),
  601. ),
  602. );
  603. }),
  604. const SizedBox(height: 8),
  605. Container(
  606. padding: const EdgeInsets.symmetric(vertical: 8),
  607. child: Row(
  608. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  609. children: [
  610. Text(
  611. l10n.get('total'),
  612. style: TextStyle(
  613. fontSize: AppFontSizes.body,
  614. fontWeight: FontWeight.w600,
  615. color: colors.textPrimary,
  616. ),
  617. ),
  618. Text(
  619. formatAmount(_totalAmount()),
  620. style: TextStyle(
  621. fontSize: AppFontSizes.subtitle,
  622. fontWeight: FontWeight.w700,
  623. color: colors.amountPrimary,
  624. ),
  625. ),
  626. ],
  627. ),
  628. ),
  629. ],
  630. );
  631. }
  632. double _totalAmount() => _details.fold(0, (s, d) => s + d.estimatedAmount);
  633. Future<void> _showDetailDialog({int? editIndex}) async {
  634. if (_addingDetail) return;
  635. _addingDetail = true;
  636. try {
  637. final l10n = AppLocalizations.of(context);
  638. ExpenseDetailData? initialData;
  639. if (editIndex != null) {
  640. final d = _details[editIndex];
  641. initialData = ExpenseDetailData(
  642. category: d.category,
  643. categoryName: d.categoryName,
  644. acctSubjectId: d.acctSubjectId,
  645. acctSubjectName: d.acctSubjectName,
  646. purpose: d.purpose,
  647. projectId: d.projectId,
  648. projectName: d.projectName,
  649. costDeptId: d.costDeptId,
  650. costDeptName: d.costDeptName,
  651. startDate: d.startDate,
  652. endDate: d.endDate,
  653. estimatedAmount: d.estimatedAmount,
  654. remark: d.remark,
  655. sqMan: d.sqMan,
  656. sqManName: d.sqManName,
  657. );
  658. }
  659. FocusManager.instance.primaryFocus?.unfocus();
  660. final result = await ExpenseApplyDetailDialog.show(
  661. // ignore: use_build_context_synchronously
  662. context,
  663. api: ref.read(expenseApplyApiProvider),
  664. l10n: l10n,
  665. acctTree: _acctTree,
  666. initialData: initialData,
  667. defaultSqMan: _currentUserAsEmployee?.salNo,
  668. defaultSqManName: _currentUserAsEmployee?.name,
  669. );
  670. if (result != null && mounted) {
  671. setState(() {
  672. final item = _DetailItem(
  673. id: editIndex != null ? _details[editIndex].id : _detailIdCounter++,
  674. category: result.category,
  675. categoryName: result.categoryName,
  676. acctSubjectId: result.acctSubjectId,
  677. acctSubjectName: result.acctSubjectName,
  678. purpose: result.purpose,
  679. projectId: result.projectId,
  680. projectName: result.projectName,
  681. costDeptId: result.costDeptId,
  682. costDeptName: result.costDeptName,
  683. startDate: result.startDate,
  684. endDate: result.endDate,
  685. estimatedAmount: result.estimatedAmount,
  686. remark: result.remark,
  687. preItm: editIndex != null ? _details[editIndex].preItm : null,
  688. sqMan: result.sqMan,
  689. sqManName: result.sqManName,
  690. );
  691. if (editIndex != null) {
  692. _details[editIndex] = item;
  693. } else {
  694. _details.add(item);
  695. }
  696. });
  697. }
  698. } finally {
  699. _addingDetail = false;
  700. }
  701. }
  702. // ═══ 类型转换 ═══
  703. List<Map<String, dynamic>> _convertAcctTree(dynamic tree) {
  704. if (tree is! List) return [];
  705. return tree.map<Map<String, dynamic>>((e) {
  706. final map = Map<String, dynamic>.from(e as Map);
  707. if (map['children'] != null) {
  708. map['children'] = _convertAcctTree(map['children']);
  709. }
  710. return map;
  711. }).toList();
  712. }
  713. // ═══ 3. 底部操作栏 ═══
  714. Widget _buildBottomBar(AppLocalizations l10n) {
  715. return ActionBar(
  716. showLeft: false,
  717. showCenter: false,
  718. rightLabel: l10n.get('submit'),
  719. onRightTap: () async {
  720. final err = _validate(l10n);
  721. if (err.isNotEmpty) {
  722. TDToast.showText(err.first, context: context);
  723. return;
  724. }
  725. FocusScope.of(context).unfocus();
  726. LoadingDialog.show(context, text: l10n.get('submitting'));
  727. try {
  728. final data = _buildSubmitData();
  729. final api = ref.read(expenseApplyApiProvider);
  730. final billNo = await api.submit(data);
  731. if (!mounted) return;
  732. LoadingDialog.hide(context);
  733. if (billNo != null) {
  734. final dd = data['HeadData']['AE_DD']?.toString() ?? '';
  735. await AuditFlowHelper.handle(
  736. context: context,
  737. l10n: l10n,
  738. getConfig: () => api.getBillAuditConfig('AE'),
  739. onShSubmit: () => api.shSubmit(bilNo: billNo, bilDd: dd),
  740. skipOnAutoSubmit: true,
  741. );
  742. }
  743. if (mounted) {
  744. TDToast.showSuccess(l10n.get('billModified'), context: context);
  745. GoRouter.of(context).go('/expense-apply/list');
  746. }
  747. } catch (e) {
  748. if (mounted) LoadingDialog.hide(context);
  749. }
  750. },
  751. );
  752. }
  753. Map<String, dynamic> _buildSubmitData() {
  754. String priority;
  755. switch (_urgency) {
  756. case 'urgent':
  757. priority = '2';
  758. break;
  759. case 'critical':
  760. priority = '3';
  761. break;
  762. default:
  763. priority = '1';
  764. }
  765. return {
  766. 'HeadData': {
  767. 'AE_NO': _billNo,
  768. 'AE_DD': _today(),
  769. 'PRIORITY': priority,
  770. 'AMTN_YJ': _totalAmount(),
  771. 'REASON': _purposeController.text.trim(),
  772. 'REM': _remarkController.text,
  773. 'DEP': _selectedDeptId,
  774. 'USR': HostAppChannel.usr,
  775. },
  776. 'BodyData1': _details.asMap().entries.map((e) {
  777. final d = e.value;
  778. final item = <String, dynamic>{
  779. 'AE_NO': _billNo,
  780. 'SQ_MAN': d.sqMan,
  781. 'TYPE_NO': d.category,
  782. 'AMTN_YJ': d.estimatedAmount,
  783. 'ACC_NO': d.acctSubjectId,
  784. 'DEP': d.costDeptId,
  785. 'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '',
  786. 'START_DD': d.startDate,
  787. 'END_DD': d.endDate,
  788. 'REM': d.remark.isNotEmpty ? d.remark : d.purpose,
  789. };
  790. if (d.preItm != null) {
  791. item['PRE_ITM'] = d.preItm;
  792. }
  793. return item;
  794. }).toList(),
  795. };
  796. }
  797. List<String> _validate(AppLocalizations l10n) {
  798. final e = <String>[];
  799. if (_purposeController.text.trim().isEmpty) {
  800. e.add(l10n.get('enterApplyReason'));
  801. }
  802. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
  803. return e;
  804. }
  805. void _doPop() {
  806. if (_hasUnsaved()) {
  807. final l10n = AppLocalizations.of(context);
  808. _showConfirmDialog(
  809. l10n.get('confirmExit'),
  810. l10n.get('unsavedContentWarning'),
  811. l10n.get('continueEditing'),
  812. l10n.get('discardAndExit'),
  813. () {
  814. if (!mounted) return;
  815. _forcePop();
  816. },
  817. );
  818. } else {
  819. _forcePop();
  820. }
  821. }
  822. void _forcePop() {
  823. FocusManager.instance.primaryFocus?.unfocus();
  824. final router = GoRouter.of(context);
  825. if (router.canPop()) {
  826. router.pop();
  827. } else {
  828. SystemNavigator.pop();
  829. }
  830. }
  831. bool _hasUnsaved() {
  832. if (_purposeController.text != _originalPurpose) return true;
  833. if (_remarkController.text != _originalRemark) return true;
  834. if (_details.length != _originalDetails.length) return true;
  835. for (var i = 0; i < _details.length; i++) {
  836. if (_details[i] != _originalDetails[i]) return true;
  837. }
  838. return false;
  839. }
  840. void _unfocus() => FocusScope.of(context).unfocus();
  841. void _showConfirmDialog(
  842. String title,
  843. String content,
  844. String leftText,
  845. String rightText,
  846. VoidCallback onConfirm,
  847. ) {
  848. _unfocus();
  849. FocusManager.instance.primaryFocus?.unfocus();
  850. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  851. showDialog(
  852. context: context,
  853. useRootNavigator: true,
  854. builder: (ctx) => TDAlertDialog(
  855. title: title,
  856. content: content,
  857. buttonStyle: TDDialogButtonStyle.text,
  858. leftBtn: TDDialogButtonOptions(
  859. title: leftText,
  860. titleColor: colors.primary,
  861. action: () => Navigator.pop(ctx),
  862. ),
  863. rightBtn: TDDialogButtonOptions(
  864. title: rightText,
  865. titleColor: colors.danger,
  866. action: () {
  867. Navigator.pop(ctx);
  868. onConfirm();
  869. },
  870. ),
  871. ),
  872. );
  873. }
  874. Future<void> _showDeptPicker() async {
  875. FocusManager.instance.primaryFocus?.unfocus();
  876. final l10n = AppLocalizations.of(context);
  877. final api = ref.read(expenseApplyApiProvider);
  878. final result = await showSearchablePicker<DepartmentItem>(
  879. context,
  880. title: '${l10n.get('select')}${l10n.get('applyDept')}',
  881. searchHint: l10n.get('search'),
  882. loader: (keyword, page) =>
  883. api.getDepartments(keyword: keyword, page: page, size: 20),
  884. labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
  885. onRefresh: () => api.clearRefCache(),
  886. );
  887. if (result != null && mounted) {
  888. setState(() {
  889. _selectedDeptId = result.dep;
  890. _selectedDeptName = result.name;
  891. });
  892. }
  893. }
  894. Widget _buildPageFooter() {
  895. final l10n = AppLocalizations.of(context);
  896. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  897. return Center(
  898. child: Padding(
  899. padding: const EdgeInsets.only(bottom: 16),
  900. child: Row(
  901. mainAxisSize: MainAxisSize.min,
  902. children: [
  903. Icon(
  904. Icons.rocket_launch_outlined,
  905. size: 16,
  906. color: colors.textPlaceholder,
  907. ),
  908. const SizedBox(width: 6),
  909. Text(
  910. l10n.get('pageFooter'),
  911. style: TextStyle(
  912. fontSize: AppFontSizes.caption,
  913. color: colors.textPlaceholder,
  914. ),
  915. ),
  916. ],
  917. ),
  918. ),
  919. );
  920. }
  921. Widget _label(String t, {bool required = false}) {
  922. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  923. return Text.rich(
  924. TextSpan(
  925. children: [
  926. TextSpan(
  927. text: t,
  928. style: TextStyle(
  929. fontSize: AppFontSizes.subtitle,
  930. color: colors.textSecondary,
  931. ),
  932. ),
  933. if (required)
  934. TextSpan(
  935. text: ' *',
  936. style: TextStyle(
  937. fontSize: AppFontSizes.subtitle,
  938. color: colors.danger,
  939. ),
  940. ),
  941. ],
  942. ),
  943. );
  944. }
  945. String _today() {
  946. final n = DateTime.now();
  947. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  948. }
  949. }
  950. class _DetailItem {
  951. final int id;
  952. final String category;
  953. final String categoryName;
  954. final String acctSubjectId;
  955. final String acctSubjectName;
  956. final String purpose;
  957. final String projectId;
  958. final String projectName;
  959. final String costDeptId;
  960. final String costDeptName;
  961. final String startDate;
  962. final String endDate;
  963. final double estimatedAmount;
  964. final String remark;
  965. final int? preItm;
  966. final String sqMan;
  967. final String sqManName;
  968. const _DetailItem({
  969. required this.id,
  970. required this.category,
  971. required this.categoryName,
  972. required this.acctSubjectId,
  973. required this.acctSubjectName,
  974. required this.purpose,
  975. required this.projectId,
  976. required this.projectName,
  977. required this.costDeptId,
  978. required this.costDeptName,
  979. required this.startDate,
  980. required this.endDate,
  981. required this.estimatedAmount,
  982. required this.remark,
  983. this.preItm,
  984. this.sqMan = '',
  985. this.sqManName = '',
  986. });
  987. @override
  988. bool operator ==(Object other) =>
  989. identical(this, other) ||
  990. other is _DetailItem &&
  991. runtimeType == other.runtimeType &&
  992. id == other.id &&
  993. category == other.category &&
  994. categoryName == other.categoryName &&
  995. acctSubjectId == other.acctSubjectId &&
  996. acctSubjectName == other.acctSubjectName &&
  997. purpose == other.purpose &&
  998. projectId == other.projectId &&
  999. projectName == other.projectName &&
  1000. costDeptId == other.costDeptId &&
  1001. costDeptName == other.costDeptName &&
  1002. startDate == other.startDate &&
  1003. endDate == other.endDate &&
  1004. estimatedAmount == other.estimatedAmount &&
  1005. remark == other.remark &&
  1006. preItm == other.preItm &&
  1007. sqMan == other.sqMan &&
  1008. sqManName == other.sqManName;
  1009. @override
  1010. int get hashCode => Object.hash(
  1011. id,
  1012. category,
  1013. categoryName,
  1014. acctSubjectId,
  1015. acctSubjectName,
  1016. purpose,
  1017. projectId,
  1018. projectName,
  1019. costDeptId,
  1020. costDeptName,
  1021. startDate,
  1022. endDate,
  1023. estimatedAmount,
  1024. remark,
  1025. preItm,
  1026. sqMan,
  1027. sqManName,
  1028. );
  1029. }