expense_create_page.dart 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter/services.dart';
  5. import 'package:flutter_riverpod/flutter_riverpod.dart';
  6. import 'package:tdesign_flutter/tdesign_flutter.dart';
  7. import 'package:go_router/go_router.dart';
  8. import '../../shared/widgets/nav_bar_config.dart';
  9. import '../../core/utils/responsive.dart';
  10. import '../../shared/widgets/form_section.dart';
  11. import '../../shared/widgets/form_field_row.dart';
  12. import '../../shared/widgets/app_skeletons.dart';
  13. import 'expense_api.dart';
  14. import 'expense_create_controller.dart';
  15. import '../../core/i18n/app_localizations.dart';
  16. import 'expense_model.dart';
  17. import '../../core/theme/app_colors.dart';
  18. import '../../core/theme/app_colors_extension.dart';
  19. import '../../core/navigation/host_app_channel.dart';
  20. import '../../core/data/mock_api_data.dart';
  21. import 'widgets/expense_detail_dialog.dart';
  22. import '../../shared/widgets/action_bar.dart';
  23. import '../../shared/widgets/loading_dialog.dart';
  24. import '../../shared/widgets/attachment_picker.dart';
  25. import 'expense_apply_import_page.dart';
  26. class ExpenseCreatePage extends ConsumerStatefulWidget {
  27. final String? editId;
  28. const ExpenseCreatePage({super.key, this.editId});
  29. @override
  30. ConsumerState<ExpenseCreatePage> createState() => _ExpenseCreatePageState();
  31. }
  32. class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
  33. final _purposeController = TextEditingController();
  34. final _purposeFocus = FocusNode();
  35. final _remarkController = TextEditingController();
  36. final _remarkFocus = FocusNode();
  37. final _scrollCtrl = ScrollController();
  38. final _detailsSectionKey = GlobalKey();
  39. late final AttachmentPickerController _attachmentController;
  40. bool _attachAvailable = false;
  41. late Future<bool> _draftFuture;
  42. bool _draftHandled = false;
  43. // ── 参考数据(从 API 加载) ──
  44. List<CostTypeItem> _costTypes = [];
  45. List<ProjectCodeItem> _projects = [];
  46. List<DepartmentItem> _departments = [];
  47. List<CustomerItem> _customers = [];
  48. List<CurrencyItem> _currencies = [];
  49. List<EmployeeItem> _employees = [];
  50. bool _firstBuild = true;
  51. bool _refDataLoading = true;
  52. bool _addingDetail = false;
  53. // ── 报销部门 ──
  54. String _selectedDeptId = '';
  55. String _selectedDeptName = '';
  56. @override
  57. void initState() {
  58. super.initState();
  59. SystemChrome.setSystemUIOverlayStyle(
  60. const SystemUiOverlayStyle(
  61. statusBarColor: Colors.transparent,
  62. statusBarIconBrightness: Brightness.dark,
  63. ),
  64. );
  65. _attachmentController = AttachmentPickerController(maxCount: 9)
  66. ..addListener(() => setState(() {}));
  67. _checkAttachHealth();
  68. _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
  69. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  70. _costTypes = [];
  71. _projects = [];
  72. _departments = [];
  73. _customers = [];
  74. _employees = [];
  75. _refDataLoading = true;
  76. _refDataFuture = null;
  77. _draftFuture = widget.editId == null
  78. ? ExpenseCreateController.hasDraft()
  79. : Future.value(false);
  80. _loadRefData();
  81. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  82. }
  83. void _checkDataReady() {
  84. if (!_refDataLoading && mounted) {
  85. setState(() => _firstBuild = false);
  86. WidgetsBinding.instance.addPostFrameCallback((_) {
  87. if (mounted) setState(() {});
  88. });
  89. } else if (mounted) {
  90. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  91. }
  92. }
  93. Future<void>? _refDataFuture;
  94. Future<void> _loadRefData() async {
  95. if (_refDataFuture != null) return _refDataFuture!;
  96. final completer = Completer<void>();
  97. _refDataFuture = completer.future;
  98. try {
  99. final api = ref.read(expenseApiProvider);
  100. final results = await Future.wait([
  101. api.getCostTypes(),
  102. api.getProjectCodes(),
  103. api.getDepartments(),
  104. api.getCustomers(),
  105. api.getCurrencies(),
  106. api.getEmployees(),
  107. ]);
  108. if (!mounted) {
  109. completer.complete();
  110. return;
  111. }
  112. setState(() {
  113. _costTypes = results[0] as List<CostTypeItem>;
  114. _projects = results[1] as List<ProjectCodeItem>;
  115. _departments = results[2] as List<DepartmentItem>;
  116. _customers = results[3] as List<CustomerItem>;
  117. _currencies = results[4] as List<CurrencyItem>;
  118. _employees = results[5] as List<EmployeeItem>;
  119. _refDataLoading = false;
  120. _autoSelectDept();
  121. });
  122. completer.complete();
  123. } catch (_) {
  124. if (!mounted) {
  125. completer.complete();
  126. return;
  127. }
  128. setState(() => _refDataLoading = false);
  129. completer.complete();
  130. } finally {
  131. _refDataFuture = null;
  132. }
  133. }
  134. void _scrollToDetailSection() {
  135. WidgetsBinding.instance.addPostFrameCallback((_) {
  136. if (_scrollCtrl.hasClients && _detailsSectionKey.currentContext != null) {
  137. Scrollable.ensureVisible(
  138. _detailsSectionKey.currentContext!,
  139. alignment: 0.0,
  140. duration: const Duration(milliseconds: 300),
  141. );
  142. }
  143. });
  144. }
  145. void _ensureVisible(FocusNode node) {
  146. if (!node.hasFocus) return;
  147. WidgetsBinding.instance.addPostFrameCallback((_) {
  148. if (node.hasFocus && _scrollCtrl.hasClients) {
  149. final ctx = node.context;
  150. if (ctx != null) {
  151. Scrollable.ensureVisible(
  152. ctx,
  153. alignment: 0.3,
  154. duration: const Duration(milliseconds: 300),
  155. );
  156. }
  157. }
  158. });
  159. }
  160. @override
  161. void dispose() {
  162. _purposeController.dispose();
  163. _purposeFocus.dispose();
  164. _remarkController.dispose();
  165. _remarkFocus.dispose();
  166. _scrollCtrl.dispose();
  167. _attachmentController.dispose();
  168. super.dispose();
  169. }
  170. @override
  171. Widget build(BuildContext context) {
  172. final controller = ref.watch(expenseCreateProvider(widget.editId).notifier);
  173. final state = ref.watch(expenseCreateProvider(widget.editId));
  174. final r = ResponsiveHelper.of(context);
  175. final l10n = AppLocalizations.of(context);
  176. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  177. final bottomInset = MediaQuery.of(context).padding.bottom;
  178. if (_firstBuild) return const Scaffold(body: SkeletonFormPage(showImportLink: true));
  179. setNavBarTitle(
  180. context,
  181. ref,
  182. NavBarConfig(
  183. title: widget.editId != null
  184. ? l10n.get('editExpense')
  185. : l10n.get('expenseApply'),
  186. showBack: true,
  187. onBack: () => _doPop(),
  188. ),
  189. );
  190. Widget pageContent = PopScope(
  191. canPop: false,
  192. onPopInvokedWithResult: (didPop, _) {
  193. if (didPop) return;
  194. _doPop();
  195. },
  196. child: Column(
  197. children: [
  198. Expanded(
  199. child: Align(
  200. alignment: Alignment.topCenter,
  201. child: ConstrainedBox(
  202. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  203. child: SingleChildScrollView(
  204. controller: _scrollCtrl,
  205. padding: const EdgeInsets.all(16),
  206. child: Column(
  207. crossAxisAlignment: CrossAxisAlignment.start,
  208. children: [
  209. _buildImportLink(),
  210. const SizedBox(height: 16),
  211. _buildBasicInfoSection(controller, state),
  212. const SizedBox(height: 16),
  213. Container(
  214. key: _detailsSectionKey,
  215. child: _buildDetailSection(controller, state),
  216. ),
  217. const SizedBox(height: 16),
  218. _buildAttachmentSection(controller, state),
  219. const SizedBox(height: 24),
  220. _buildPageFooter(),
  221. ],
  222. ),
  223. ),
  224. ),
  225. ),
  226. ),
  227. ColoredBox(
  228. color: colors.bgCard,
  229. child: Column(
  230. mainAxisSize: MainAxisSize.min,
  231. children: [
  232. _buildBottomButtons(controller, state),
  233. if (bottomInset > 0) SizedBox(height: bottomInset),
  234. ],
  235. ),
  236. ),
  237. ],
  238. ),
  239. );
  240. return FutureBuilder<bool>(
  241. future: _draftFuture,
  242. builder: (ctx, snapshot) {
  243. final hasDraft = snapshot.hasData && snapshot.data == true;
  244. if (hasDraft && !_draftHandled) {
  245. _draftHandled = true;
  246. WidgetsBinding.instance.addPostFrameCallback((_) {
  247. if (mounted) _showDraftDialog();
  248. });
  249. }
  250. return pageContent;
  251. },
  252. );
  253. }
  254. void _showDraftDialog() {
  255. final l10n = AppLocalizations.of(context);
  256. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  257. FocusManager.instance.primaryFocus?.unfocus();
  258. showDialog(
  259. context: context,
  260. barrierDismissible: false,
  261. builder: (ctx) => TDAlertDialog(
  262. title: l10n.get('draftFound'),
  263. content: l10n.get('draftRestorePrompt'),
  264. leftBtn: TDDialogButtonOptions(
  265. title: l10n.get('discard'),
  266. titleColor: colors.textSecondary,
  267. action: () {
  268. Navigator.pop(ctx);
  269. ExpenseCreateController.deleteDraft();
  270. },
  271. ),
  272. rightBtn: TDDialogButtonOptions(
  273. title: l10n.get('restore'),
  274. titleColor: colors.primary,
  275. action: () async {
  276. Navigator.pop(ctx);
  277. final draft = await ExpenseCreateController.loadDraft();
  278. if (draft != null && mounted) {
  279. final api = ref.read(expenseApiProvider);
  280. ref
  281. .read(expenseCreateProvider(widget.editId).notifier)
  282. .restoreFromDraft(draft, api);
  283. _purposeController.text = draft.purpose;
  284. _remarkController.text = draft.remark;
  285. if (draft.attachments.isNotEmpty) {
  286. await _attachmentController.restoreFromPaths(draft.attachments);
  287. }
  288. }
  289. },
  290. ),
  291. ),
  292. );
  293. }
  294. // ═══ API 数据 → 弹窗类型转换 ═══
  295. List<CostCategory> get _dialogCategories => _costTypes
  296. .map(
  297. (c) => CostCategory(
  298. code: c.typeNo,
  299. nameKey: c.typeName,
  300. acctSubjectId: c.accNo,
  301. acctSubjectName: c.accName,
  302. ),
  303. )
  304. .toList();
  305. List<Project> get _dialogProjects => _projects
  306. .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
  307. .toList();
  308. List<CostDept> get _dialogCostDepts =>
  309. _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
  310. String _currencyLabel(String code) {
  311. final match = _currencies.where((c) => c.curId == code);
  312. return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code;
  313. }
  314. List<CustomerVendor> get _dialogCustomers =>
  315. _customers.map((c) => CustomerVendor(id: c.cusNo, name: c.name)).toList();
  316. List<EmployeeItem> get _dialogEmployees => _employees;
  317. void _autoSelectDept() {
  318. if (_selectedDeptId.isNotEmpty) return;
  319. final dep = HostAppChannel.dep;
  320. if (dep.isEmpty) return;
  321. final match = _departments.where((d) => d.dep == dep);
  322. if (match.isNotEmpty) {
  323. _selectedDeptId = match.first.dep;
  324. _selectedDeptName = match.first.name;
  325. }
  326. }
  327. void _showDeptPicker() {
  328. if (_departments.isEmpty) {
  329. TDToast.showText(
  330. AppLocalizations.of(context).get('noData'),
  331. context: context,
  332. );
  333. return;
  334. }
  335. final l10n = AppLocalizations.of(context);
  336. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  337. final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
  338. FocusManager.instance.primaryFocus?.unfocus();
  339. TDPicker.showMultiPicker(
  340. context,
  341. title: l10n.get('expenseDept'),
  342. backgroundColor: colors.bgCard,
  343. data: [labels],
  344. onConfirm: (selected) {
  345. if (selected.isNotEmpty && selected[0] is int) {
  346. final idx = selected[0] as int;
  347. if (idx >= 0 && idx < labels.length) {
  348. Navigator.of(context).pop();
  349. setState(() {
  350. _selectedDeptId = _departments[idx].dep;
  351. _selectedDeptName = _departments[idx].name;
  352. });
  353. }
  354. }
  355. },
  356. );
  357. }
  358. Map<String, dynamic> _buildSubmitData(ExpenseCreateState state) {
  359. final expense = state.expense;
  360. return {
  361. 'HeadData': {
  362. 'BX_DD': _today(),
  363. 'DEP': _selectedDeptId,
  364. 'USR_NO': HostAppChannel.usr,
  365. 'PAY_ID': expense.paymentMethod,
  366. //'PRT_SW': 'N',
  367. 'USR': HostAppChannel.usr,
  368. 'REM': expense.remark,
  369. 'CUR_ID': expense.currencyCode,
  370. 'EXC_RTO': 1,
  371. 'REASON': expense.purpose,
  372. 'VOH_ID': expense.isGenerateVoucher ? 'T' : 'F',
  373. },
  374. 'BodyData1': expense.details.asMap().entries.map((e) {
  375. final i = e.key;
  376. final d = e.value;
  377. return {
  378. 'ITM': i + 1,
  379. 'BX_DD': _today(),
  380. 'ACC_NO': d.acctSubjectId,
  381. 'AMT': d.totalAmount,
  382. 'AMTN': d.amount,
  383. 'AMTN_SH': d.approvedAmount > 0 ? d.approvedAmount : d.totalAmount,
  384. 'REM': d.remark,
  385. 'CUST': d.customerVendorId,
  386. 'IDX_NO': d.expenseCategory,
  387. 'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '',
  388. 'TAX': d.taxAmount,
  389. 'TAX_RTO': d.taxRate,
  390. 'DEP': d.costDeptId,
  391. 'AE_NO': d.aeNo,
  392. 'AE_DD': d.aeDd,
  393. 'BNK_NO': d.bankName,
  394. 'BNK_ID': d.bankAccount,
  395. 'ACCNAME': d.bankAccountName,
  396. 'SQ_MAN': d.sqMan.isNotEmpty ? d.sqMan : HostAppChannel.usr,
  397. };
  398. }).toList(),
  399. };
  400. }
  401. Widget _buildImportLink() {
  402. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  403. final l10n = AppLocalizations.of(context);
  404. return GestureDetector(
  405. onTap: () async {
  406. final result = await GoRouter.of(
  407. context,
  408. ).push<List<ImportableItem>>('/expense/import-apply');
  409. if (result == null || result.isEmpty || !mounted) return;
  410. // 将选中的导入数据转换为明细,先移除同单号的旧数据
  411. final controller = ref.read(
  412. expenseCreateProvider(widget.editId).notifier,
  413. );
  414. final aeNos = result.map((e) => e.aeNo).toSet();
  415. for (final aeNo in aeNos) {
  416. controller.removeDetailsByAeNo(aeNo);
  417. }
  418. final now = DateTime.now();
  419. for (final item in result) {
  420. controller.addDetail(
  421. ExpenseDetailModel(
  422. id: '${now.millisecondsSinceEpoch}_${item.itm}',
  423. expenseId: '',
  424. expenseApplyId: '',
  425. expenseApplyNo: item.aeNo,
  426. expenseApplyDate: item.aeDd.isNotEmpty
  427. ? DateTime.tryParse(item.aeDd)
  428. : null,
  429. expenseCategory: item.typeNo,
  430. categoryName: item.typeName,
  431. purpose: item.rem,
  432. priority: item.priority,
  433. projectId: item.objNo,
  434. projectName: item.objName,
  435. costDeptId: item.dep,
  436. costDeptName: item.depName,
  437. acctSubjectId: item.accNo,
  438. acctSubjectName: item.accName,
  439. amount: item.amtnYj,
  440. taxRate: 0,
  441. taxAmount: 0,
  442. totalAmount: item.amtnYj,
  443. currencyCode: '',
  444. exchangeRate: 1.0,
  445. baseAmount: item.amtnYj,
  446. approvedAmount: item.amtnYj,
  447. customerVendorId: '',
  448. customerVendorName: '',
  449. bankName: '',
  450. bankAccountName: '',
  451. bankAccount: '',
  452. remark: item.rem,
  453. sortOrder: item.itm,
  454. attachments: const [],
  455. sqMan: item.sqMan,
  456. sqManName: item.sqName,
  457. aeNo: item.aeNo,
  458. aeDd: item.aeDd,
  459. createTime: now,
  460. updateTime: now,
  461. ),
  462. );
  463. }
  464. controller.recalculateAmount();
  465. TDToast.showSuccess(l10n.get('importSuccess'), context: context);
  466. _scrollToDetailSection();
  467. },
  468. child: Container(
  469. height: 44,
  470. decoration: BoxDecoration(
  471. color: colors.primaryLight,
  472. borderRadius: BorderRadius.circular(8),
  473. ),
  474. child: Row(
  475. mainAxisAlignment: MainAxisAlignment.center,
  476. children: [
  477. Icon(Icons.download, size: 14, color: colors.primary),
  478. const SizedBox(width: 8),
  479. Text(
  480. l10n.get('importApprovedPreApp'),
  481. style: TextStyle(
  482. fontSize: AppFontSizes.body,
  483. color: colors.primary,
  484. ),
  485. ),
  486. ],
  487. ),
  488. ),
  489. );
  490. }
  491. Widget _buildBasicInfoSection(
  492. ExpenseCreateController controller,
  493. ExpenseCreateState state,
  494. ) {
  495. final l10n = AppLocalizations.of(context);
  496. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  497. final expense = state.expense;
  498. return FormSection(
  499. title: l10n.get('basicInfo'),
  500. leadingIcon: Icons.info_outline,
  501. children: [
  502. FormFieldRow(
  503. label: l10n.get('date'),
  504. value: _today(),
  505. readOnly: true,
  506. showArrow: false,
  507. ),
  508. const SizedBox(height: 16),
  509. FormFieldRow(
  510. label: l10n.get('expensePersonnel'),
  511. value:
  512. HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
  513. ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
  514. : '--',
  515. readOnly: true,
  516. showArrow: false,
  517. ),
  518. const SizedBox(height: 16),
  519. FormFieldRow(
  520. label: l10n.get('expenseDept'),
  521. value: _selectedDeptName.isNotEmpty
  522. ? '$_selectedDeptId/$_selectedDeptName'
  523. : null,
  524. hint: l10n.get('pleaseSelect'),
  525. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  526. ),
  527. const SizedBox(height: 16),
  528. _label(l10n.get('expenseReason'), required: true),
  529. const SizedBox(height: 8),
  530. TDTextarea(
  531. controller: _purposeController,
  532. focusNode: _purposeFocus,
  533. hintText: l10n.get('enterExpenseReason'),
  534. maxLines: 4,
  535. minLines: 1,
  536. maxLength: 500,
  537. indicator: true,
  538. padding: EdgeInsets.zero,
  539. bordered: true,
  540. backgroundColor: colors.bgPage,
  541. onChanged: (_) => controller.updatePurpose(_purposeController.text),
  542. ),
  543. const SizedBox(height: 16),
  544. FormFieldRow(
  545. label: l10n.get('paymentMethod'),
  546. value: expense.paymentMethod,
  547. hint: l10n.get('pleaseEnter'),
  548. onTap: () => _showTextInput(
  549. l10n.get('paymentMethod'),
  550. (v) => controller.updatePaymentMethod(v),
  551. initialText: expense.paymentMethod,
  552. ),
  553. onClear: () => controller.updatePaymentMethod(''),
  554. ),
  555. const SizedBox(height: 16),
  556. FormFieldRow(
  557. label: l10n.get('currency'),
  558. value: expense.currencyCode.isNotEmpty
  559. ? _currencyLabel(expense.currencyCode)
  560. : null,
  561. hint: l10n.get('selectCurrency'),
  562. onTap: () => _showCurrencyPicker(controller, expense.currencyCode),
  563. onClear: () => controller.updateCurrencyCode(''),
  564. ),
  565. const SizedBox(height: 16),
  566. Row(
  567. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  568. children: [
  569. Text(
  570. l10n.get('generateVoucher'),
  571. style: TextStyle(
  572. fontSize: AppFontSizes.subtitle,
  573. color: colors.textSecondary,
  574. ),
  575. ),
  576. TDSwitch(
  577. isOn: expense.isGenerateVoucher,
  578. onChanged: (v) {
  579. controller.setGenerateVoucher(v);
  580. return v;
  581. },
  582. ),
  583. ],
  584. ),
  585. const SizedBox(height: 16),
  586. _label(l10n.get('remark')),
  587. const SizedBox(height: 8),
  588. TDTextarea(
  589. controller: _remarkController,
  590. focusNode: _remarkFocus,
  591. hintText: l10n.get('enterRemark'),
  592. maxLines: 3,
  593. minLines: 1,
  594. maxLength: 500,
  595. indicator: true,
  596. padding: EdgeInsets.zero,
  597. bordered: true,
  598. backgroundColor: colors.bgPage,
  599. onChanged: (_) => controller.updateRemark(_remarkController.text),
  600. ),
  601. ],
  602. );
  603. }
  604. Widget _buildDetailSection(
  605. ExpenseCreateController controller,
  606. ExpenseCreateState state,
  607. ) {
  608. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  609. final l10n = AppLocalizations.of(context);
  610. final totalApproved = state.expense.details.fold<double>(
  611. 0,
  612. (sum, d) => sum + d.approvedAmount,
  613. );
  614. return FormSection(
  615. title: l10n.get('expenseDetails'),
  616. leadingIcon: Icons.receipt_long_outlined,
  617. showAction: true,
  618. actionText: l10n.get('add'),
  619. onActionTap: () => _showAddDetailDialog(controller),
  620. children: [
  621. if (state.expense.details.isEmpty)
  622. Padding(
  623. padding: const EdgeInsets.symmetric(vertical: 8),
  624. child: Text(
  625. l10n.get('noDetailHint'),
  626. style: TextStyle(
  627. fontSize: AppFontSizes.subtitle,
  628. color: colors.textPlaceholder,
  629. ),
  630. ),
  631. )
  632. else
  633. ...state.expense.details.asMap().entries.map((entry) {
  634. final d = entry.value;
  635. return GestureDetector(
  636. onTap: () =>
  637. _showAddDetailDialog(controller, editIndex: entry.key),
  638. child: Container(
  639. margin: const EdgeInsets.symmetric(vertical: 6),
  640. padding: const EdgeInsets.all(12),
  641. decoration: BoxDecoration(
  642. color: colors.bgPage,
  643. borderRadius: BorderRadius.circular(8),
  644. ),
  645. child: Row(
  646. children: [
  647. Expanded(
  648. child: Column(
  649. crossAxisAlignment: CrossAxisAlignment.start,
  650. children: [
  651. Row(
  652. children: [
  653. Expanded(
  654. child: Text(
  655. d.categoryName.isNotEmpty
  656. ? '${d.expenseCategory}/${d.categoryName}'
  657. : d.expenseCategory,
  658. style: TextStyle(
  659. fontSize: AppFontSizes.body,
  660. fontWeight: FontWeight.w500,
  661. color: colors.textPrimary,
  662. ),
  663. ),
  664. ),
  665. Column(
  666. crossAxisAlignment: CrossAxisAlignment.end,
  667. children: [
  668. Text(
  669. '¥${d.totalAmount.toStringAsFixed(2)}',
  670. style: TextStyle(
  671. fontSize: AppFontSizes.body,
  672. fontWeight: FontWeight.w600,
  673. color: colors.amountPrimary,
  674. ),
  675. ),
  676. if (d.approvedAmount > 0)
  677. Text(
  678. '¥${d.approvedAmount.toStringAsFixed(2)}',
  679. style: TextStyle(
  680. fontSize: AppFontSizes.body,
  681. fontWeight: FontWeight.w600,
  682. color: colors.success,
  683. ),
  684. ),
  685. ],
  686. ),
  687. ],
  688. ),
  689. const SizedBox(height: 2),
  690. Text(
  691. '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}',
  692. style: TextStyle(
  693. fontSize: AppFontSizes.caption,
  694. color: colors.textSecondary,
  695. ),
  696. ),
  697. if (d.taxAmount > 0)
  698. Text(
  699. '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
  700. style: TextStyle(
  701. fontSize: AppFontSizes.caption,
  702. color: colors.textSecondary,
  703. ),
  704. ),
  705. if (d.acctSubjectName.isNotEmpty)
  706. Text(
  707. '${l10n.get('acctSubject')}: ${d.acctSubjectId}/${d.acctSubjectName}',
  708. maxLines: 1,
  709. overflow: TextOverflow.ellipsis,
  710. style: TextStyle(
  711. fontSize: AppFontSizes.caption,
  712. color: colors.textSecondary,
  713. ),
  714. ),
  715. if (d.aeNo.isNotEmpty)
  716. Text(
  717. '${l10n.get('expenseApplyNo')}: ${d.aeNo}',
  718. maxLines: 1,
  719. overflow: TextOverflow.ellipsis,
  720. style: TextStyle(
  721. fontSize: AppFontSizes.caption,
  722. color: colors.textSecondary,
  723. ),
  724. ),
  725. if (d.aeDd.isNotEmpty)
  726. Text(
  727. '${l10n.get('applyDate')}: ${d.aeDd}',
  728. style: TextStyle(
  729. fontSize: AppFontSizes.caption,
  730. color: colors.textSecondary,
  731. ),
  732. ),
  733. if (d.projectName.isNotEmpty)
  734. Text(
  735. '${l10n.get('project')}: ${d.projectId}/${d.projectName}',
  736. maxLines: 1,
  737. overflow: TextOverflow.ellipsis,
  738. style: TextStyle(
  739. fontSize: AppFontSizes.caption,
  740. color: colors.textSecondary,
  741. ),
  742. ),
  743. if (d.costDeptName.isNotEmpty)
  744. Text(
  745. '${l10n.get('dept')}: ${d.costDeptId}/${d.costDeptName}',
  746. maxLines: 1,
  747. overflow: TextOverflow.ellipsis,
  748. style: TextStyle(
  749. fontSize: AppFontSizes.caption,
  750. color: colors.textSecondary,
  751. ),
  752. ),
  753. if (d.customerVendorName.isNotEmpty)
  754. Text(
  755. '${l10n.get('customerVendor')}: ${d.customerVendorId}/${d.customerVendorName}',
  756. maxLines: 1,
  757. overflow: TextOverflow.ellipsis,
  758. style: TextStyle(
  759. fontSize: AppFontSizes.caption,
  760. color: colors.textSecondary,
  761. ),
  762. ),
  763. if (d.bankAccountName.isNotEmpty)
  764. Text(
  765. '${l10n.get('bankAccountName')}: ${d.bankAccountName}',
  766. maxLines: 1,
  767. overflow: TextOverflow.ellipsis,
  768. style: TextStyle(
  769. fontSize: AppFontSizes.caption,
  770. color: colors.textSecondary,
  771. ),
  772. ),
  773. if (d.bankName.isNotEmpty)
  774. Text(
  775. '${l10n.get('bankName')}: ${d.bankName}',
  776. maxLines: 1,
  777. overflow: TextOverflow.ellipsis,
  778. style: TextStyle(
  779. fontSize: AppFontSizes.caption,
  780. color: colors.textSecondary,
  781. ),
  782. ),
  783. if (d.bankAccount.isNotEmpty)
  784. Text(
  785. '${l10n.get('bankAccount')}: ${d.bankAccount}',
  786. maxLines: 1,
  787. overflow: TextOverflow.ellipsis,
  788. style: TextStyle(
  789. fontSize: AppFontSizes.caption,
  790. color: colors.textSecondary,
  791. ),
  792. ),
  793. if (d.sqManName.isNotEmpty)
  794. Text(
  795. '${l10n.get('applicant')}: ${d.sqManName.isNotEmpty ? d.sqManName : d.sqMan}',
  796. style: TextStyle(
  797. fontSize: AppFontSizes.caption,
  798. color: colors.textSecondary,
  799. ),
  800. ),
  801. if (d.remark.isNotEmpty)
  802. Text(
  803. '${l10n.get('remark')}: ${d.remark}',
  804. maxLines: 2,
  805. overflow: TextOverflow.ellipsis,
  806. style: TextStyle(
  807. fontSize: AppFontSizes.caption,
  808. color: colors.textSecondary,
  809. ),
  810. ),
  811. ],
  812. ),
  813. ),
  814. const SizedBox(width: 8),
  815. GestureDetector(
  816. onTap: () {
  817. controller.removeDetail(entry.key);
  818. controller.recalculateAmount();
  819. },
  820. child: Icon(
  821. Icons.close,
  822. size: 18,
  823. color: colors.textSecondary,
  824. ),
  825. ),
  826. ],
  827. ),
  828. ),
  829. );
  830. }),
  831. const SizedBox(height: 8),
  832. Row(
  833. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  834. children: [
  835. Text(
  836. l10n.get('totalExpense'),
  837. style: TextStyle(
  838. fontSize: AppFontSizes.body,
  839. fontWeight: FontWeight.w600,
  840. color: colors.textPrimary,
  841. ),
  842. ),
  843. Text(
  844. '¥${state.expense.totalAmount.toStringAsFixed(2)}',
  845. style: TextStyle(
  846. fontSize: AppFontSizes.subtitle,
  847. fontWeight: FontWeight.w700,
  848. color: colors.amountPrimary,
  849. ),
  850. ),
  851. ],
  852. ),
  853. const SizedBox(height: 4),
  854. Row(
  855. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  856. children: [
  857. Text(
  858. l10n.get('approvedTotal'),
  859. style: TextStyle(
  860. fontSize: AppFontSizes.body,
  861. fontWeight: FontWeight.w600,
  862. color: colors.textPrimary,
  863. ),
  864. ),
  865. Text(
  866. '¥${totalApproved.toStringAsFixed(2)}',
  867. style: TextStyle(
  868. fontSize: AppFontSizes.subtitle,
  869. fontWeight: FontWeight.w700,
  870. color: totalApproved > 0 ? colors.success : colors.textPrimary,
  871. ),
  872. ),
  873. ],
  874. ),
  875. ],
  876. );
  877. }
  878. Future<void> _checkAttachHealth() async {
  879. if (mounted) setState(() => _attachAvailable = false);
  880. try {
  881. final api = ref.read(expenseApiProvider);
  882. final ok = await api.checkAttachHealth();
  883. if (mounted) setState(() => _attachAvailable = ok);
  884. } catch (_) {
  885. if (mounted) setState(() => _attachAvailable = false);
  886. }
  887. }
  888. Widget _buildAttachmentSection(
  889. ExpenseCreateController controller,
  890. ExpenseCreateState state,
  891. ) {
  892. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  893. final l10n = AppLocalizations.of(context);
  894. final children = <Widget>[];
  895. if (!_attachAvailable) {
  896. children.add(
  897. Text(
  898. l10n.get('attachServiceUnavailable'),
  899. style: TextStyle(
  900. fontSize: AppFontSizes.caption,
  901. color: colors.textPlaceholder,
  902. ),
  903. ),
  904. );
  905. } else {
  906. children.addAll([
  907. Text(
  908. l10n.get('maxAttachment'),
  909. style: TextStyle(
  910. fontSize: AppFontSizes.caption,
  911. color: colors.textPlaceholder,
  912. ),
  913. ),
  914. const SizedBox(height: 8),
  915. ]);
  916. }
  917. return FormSection(
  918. title: l10n.get('attachmentUpload'),
  919. leadingIcon: Icons.attach_file_outlined,
  920. children: [
  921. ...children,
  922. if (_attachAvailable)
  923. AttachmentPicker(
  924. controller: _attachmentController,
  925. maxImageSizeMB: 10,
  926. maxFileSizeMB: 20,
  927. allowedExtensions: const [
  928. 'pdf',
  929. 'doc',
  930. 'docx',
  931. 'xls',
  932. 'xlsx',
  933. 'ppt',
  934. 'pptx',
  935. 'txt',
  936. ],
  937. onFileRejected: (file, reason) {
  938. if (context.mounted) TDToast.showText(reason, context: context);
  939. },
  940. ),
  941. ],
  942. );
  943. }
  944. Widget _buildBottomButtons(
  945. ExpenseCreateController controller,
  946. ExpenseCreateState state,
  947. ) {
  948. final l10n = AppLocalizations.of(context);
  949. return ActionBar(
  950. showLeft: false,
  951. centerLabel: l10n.get('saveDraft'),
  952. rightLabel: l10n.get('submit'),
  953. centerTextOnly: true,
  954. onCenterTap: () async {
  955. if (state.isSubmitting) return;
  956. FocusScope.of(context).unfocus();
  957. controller.updateAttachments(_attachmentController.toPathList());
  958. controller.updateDept(_selectedDeptId, _selectedDeptName);
  959. final ok = await controller.saveDraft();
  960. if (mounted) {
  961. if (ok) {
  962. _forcePop();
  963. } else {
  964. TDToast.showFail(l10n.get('saveFailed'), context: context);
  965. }
  966. }
  967. },
  968. onRightTap: () async {
  969. if (state.isSubmitting) return;
  970. final err = _validate(l10n, state);
  971. if (err.isNotEmpty) {
  972. TDToast.showText(err.first, context: context);
  973. return;
  974. }
  975. FocusScope.of(context).unfocus();
  976. LoadingDialog.show(context, text: l10n.get('submitting'));
  977. try {
  978. final data = _buildSubmitData(state);
  979. final api = ref.read(expenseApiProvider);
  980. final billNo = await api.submit(data);
  981. // 上传附件(billNo 提取失败则跳过,不影响主流程)
  982. if (billNo != null) {
  983. final now = DateTime.now();
  984. final effDd =
  985. '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
  986. '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}.'
  987. '${now.millisecond.toString().padLeft(3, '0')}';
  988. final usr = HostAppChannel.usr;
  989. // 表头附件
  990. for (var i = 0; i < _attachmentController.files.length; i++) {
  991. final file = _attachmentController.files[i];
  992. try {
  993. await api.uploadAttachment(file.path, {
  994. 'BIL_ID': 'BX',
  995. 'BIL_NO': billNo,
  996. 'SRCITM': 0,
  997. 'ITM': i + 1,
  998. 'TAG': 1,
  999. 'EFF_DD': effDd,
  1000. 'USR': usr,
  1001. 'FILENAME': file.name,
  1002. 'EXT': file.name.split('.').last,
  1003. });
  1004. } catch (_) {
  1005. // Attachment upload failure is non-fatal
  1006. }
  1007. }
  1008. // 明细附件
  1009. for (var i = 0; i < state.expense.details.length; i++) {
  1010. final detail = state.expense.details[i];
  1011. if (detail.attachments.isEmpty) continue;
  1012. for (var j = 0; j < detail.attachments.length; j++) {
  1013. final file = File(detail.attachments[j]);
  1014. try {
  1015. if (!await file.exists()) continue;
  1016. final fileName = file.path.split('/').last;
  1017. await api.uploadAttachment(file.path, {
  1018. 'BIL_ID': 'BX',
  1019. 'BIL_NO': billNo,
  1020. 'SRCITM': i + 1,
  1021. 'ITM': j + 1,
  1022. 'TAG': 1,
  1023. 'EFF_DD': effDd,
  1024. 'USR': usr,
  1025. 'FILENAME': fileName,
  1026. 'EXT': fileName.split('.').last,
  1027. });
  1028. } catch (_) {
  1029. // Detail attachment upload failure is non-fatal
  1030. }
  1031. }
  1032. }
  1033. }
  1034. await ExpenseCreateController.deleteDraft();
  1035. if (mounted) {
  1036. LoadingDialog.hide(context);
  1037. TDToast.showSuccess(
  1038. l10n.get('submittedAwaitingApproval'),
  1039. context: context,
  1040. );
  1041. GoRouter.of(context).go('/expense/list');
  1042. }
  1043. } catch (_) {
  1044. if (mounted) {
  1045. LoadingDialog.hide(context);
  1046. TDToast.showFail(l10n.get('submitFailedRetry'), context: context);
  1047. }
  1048. }
  1049. },
  1050. );
  1051. }
  1052. Future<void> _showAddDetailDialog(
  1053. ExpenseCreateController controller, {
  1054. int? editIndex,
  1055. }) async {
  1056. if (_addingDetail) return;
  1057. _addingDetail = true;
  1058. try {
  1059. final l10n = AppLocalizations.of(context);
  1060. if (_costTypes.isEmpty) {
  1061. TDToast.showText(l10n.get('noCostTypeData'), context: context);
  1062. return;
  1063. }
  1064. final state = controller.currentState;
  1065. ExpenseDetailInputData? initialData;
  1066. if (editIndex != null) {
  1067. final d = state.expense.details[editIndex];
  1068. initialData = ExpenseDetailInputData(
  1069. category: d.expenseCategory,
  1070. categoryName: d.categoryName,
  1071. acctSubjectId: d.acctSubjectId,
  1072. acctSubjectName: d.acctSubjectName,
  1073. purpose: d.purpose,
  1074. amount: d.amount,
  1075. taxRate: d.taxRate,
  1076. projectId: d.projectId,
  1077. projectName: d.projectName,
  1078. costDeptId: d.costDeptId,
  1079. costDeptName: d.costDeptName,
  1080. customerVendorId: d.customerVendorId,
  1081. customerVendorName: d.customerVendorName,
  1082. approvedAmount: d.approvedAmount,
  1083. bankName: d.bankName,
  1084. bankAccountName: d.bankAccountName,
  1085. bankAccount: d.bankAccount,
  1086. remark: d.remark,
  1087. attachmentPaths: d.attachments,
  1088. sqMan: d.sqMan,
  1089. sqManName: d.sqManName,
  1090. aeNo: d.aeNo,
  1091. aeDd: d.aeDd,
  1092. );
  1093. }
  1094. FocusManager.instance.primaryFocus?.unfocus();
  1095. final result = await ExpenseDetailDialog.show(
  1096. context,
  1097. categories: _dialogCategories,
  1098. projects: _dialogProjects,
  1099. costDepts: _dialogCostDepts,
  1100. customers: _dialogCustomers,
  1101. employees: _dialogEmployees,
  1102. l10n: l10n,
  1103. initialData: initialData,
  1104. checkAttachHealth: () =>
  1105. ref.read(expenseApiProvider).checkAttachHealth(),
  1106. );
  1107. if (result != null && mounted) {
  1108. final now = DateTime.now();
  1109. final detail = ExpenseDetailModel(
  1110. id: editIndex != null
  1111. ? state.expense.details[editIndex].id
  1112. : now.millisecondsSinceEpoch.toString(),
  1113. expenseId: '',
  1114. expenseCategory: result.category,
  1115. categoryName: result.categoryName,
  1116. purpose: result.purpose,
  1117. amount: result.taxRate > 0
  1118. ? result.amount / (1 + result.taxRate)
  1119. : result.amount,
  1120. taxRate: result.taxRate,
  1121. taxAmount: result.taxRate > 0
  1122. ? result.amount - result.amount / (1 + result.taxRate)
  1123. : 0,
  1124. totalAmount: result.amount,
  1125. projectId: result.projectId,
  1126. projectName: result.projectName,
  1127. costDeptId: result.costDeptId,
  1128. costDeptName: result.costDeptName,
  1129. acctSubjectId: result.acctSubjectId,
  1130. acctSubjectName: result.acctSubjectName,
  1131. customerVendorId: result.customerVendorId,
  1132. customerVendorName: result.customerVendorName,
  1133. approvedAmount: result.approvedAmount,
  1134. bankName: result.bankName,
  1135. bankAccountName: result.bankAccountName,
  1136. bankAccount: result.bankAccount,
  1137. sqMan: result.sqMan,
  1138. sqManName: result.sqManName,
  1139. aeNo: result.aeNo,
  1140. aeDd: result.aeDd,
  1141. remark: result.remark,
  1142. attachments: result.attachmentPaths,
  1143. createTime: now,
  1144. updateTime: now,
  1145. );
  1146. if (editIndex != null) {
  1147. controller.updateDetail(editIndex, detail);
  1148. } else {
  1149. controller.addDetail(detail);
  1150. }
  1151. controller.recalculateAmount();
  1152. }
  1153. } finally {
  1154. _addingDetail = false;
  1155. }
  1156. }
  1157. void _showCurrencyPicker(ExpenseCreateController controller, String cur) {
  1158. if (_currencies.isEmpty) {
  1159. TDToast.showText(
  1160. AppLocalizations.of(context).get('noData'),
  1161. context: context,
  1162. );
  1163. return;
  1164. }
  1165. final l10n = AppLocalizations.of(context);
  1166. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1167. final codes = _currencies.map((c) => c.curId).toList();
  1168. final labels = _currencies.map((c) => '${c.curId}/${c.name}').toList();
  1169. FocusManager.instance.primaryFocus?.unfocus();
  1170. TDPicker.showMultiPicker(
  1171. context,
  1172. title: l10n.get('selectCurrency'),
  1173. backgroundColor: colors.bgCard,
  1174. data: [labels],
  1175. onConfirm: (s) {
  1176. if (s.isNotEmpty && s[0] is int) {
  1177. final i = s[0] as int;
  1178. if (i >= 0 && i < codes.length) {
  1179. Navigator.of(context).pop();
  1180. controller.updateCurrencyCode(codes[i]);
  1181. }
  1182. }
  1183. },
  1184. );
  1185. }
  1186. void _showTextInput(
  1187. String title,
  1188. Function(String) onConfirm, {
  1189. String initialText = '',
  1190. }) {
  1191. FocusScope.of(context).unfocus();
  1192. FocusManager.instance.primaryFocus?.unfocus();
  1193. final l10n = AppLocalizations.of(context);
  1194. final c = TextEditingController(text: initialText);
  1195. showGeneralDialog(
  1196. context: context,
  1197. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  1198. textEditingController: c,
  1199. title: title,
  1200. hintText: l10n.get('pleaseEnter'),
  1201. leftBtn: TDDialogButtonOptions(
  1202. title: l10n.get('cancel'),
  1203. action: () => Navigator.pop(ctx),
  1204. ),
  1205. rightBtn: TDDialogButtonOptions(
  1206. title: l10n.get('confirm'),
  1207. action: () {
  1208. onConfirm(c.text);
  1209. Navigator.pop(ctx);
  1210. },
  1211. ),
  1212. ),
  1213. );
  1214. }
  1215. Widget _label(String t, {bool required = false}) {
  1216. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1217. return Text.rich(
  1218. TextSpan(
  1219. children: [
  1220. TextSpan(
  1221. text: t,
  1222. style: TextStyle(
  1223. fontSize: AppFontSizes.subtitle,
  1224. color: colors.textSecondary,
  1225. ),
  1226. ),
  1227. if (required)
  1228. TextSpan(
  1229. text: ' *',
  1230. style: TextStyle(
  1231. fontSize: AppFontSizes.subtitle,
  1232. color: colors.danger,
  1233. ),
  1234. ),
  1235. ],
  1236. ),
  1237. );
  1238. }
  1239. List<String> _validate(AppLocalizations l10n, ExpenseCreateState state) {
  1240. final e = <String>[];
  1241. if (_purposeController.text.trim().isEmpty) {
  1242. e.add(l10n.get('enterExpenseReason'));
  1243. }
  1244. if (state.expense.details.isEmpty) {
  1245. e.add(l10n.get('addAtLeastOneDetail'));
  1246. }
  1247. return e;
  1248. }
  1249. bool _hasUnsaved(ExpenseCreateState state) =>
  1250. _purposeController.text.isNotEmpty ||
  1251. state.expense.paymentMethod.isNotEmpty ||
  1252. state.expense.currencyCode.isNotEmpty ||
  1253. _remarkController.text.isNotEmpty ||
  1254. state.expense.details.isNotEmpty ||
  1255. _attachmentController.files.isNotEmpty ||
  1256. _selectedDeptId.isNotEmpty;
  1257. void _doPop() {
  1258. final l10n = AppLocalizations.of(context);
  1259. final state = ref.read(expenseCreateProvider(widget.editId));
  1260. if (_hasUnsaved(state)) {
  1261. _showConfirmDialog(
  1262. l10n.get('confirmExit'),
  1263. l10n.get('unsavedContentWarning'),
  1264. l10n.get('continueEditing'),
  1265. l10n.get('discardAndExit'),
  1266. () async {
  1267. try {
  1268. await ExpenseCreateController.deleteDraft();
  1269. } catch (_) {}
  1270. if (!mounted) return;
  1271. setState(() {
  1272. _selectedDeptId = '';
  1273. _selectedDeptName = '';
  1274. });
  1275. ref.read(expenseCreateProvider(widget.editId).notifier).reset();
  1276. _forcePop();
  1277. },
  1278. );
  1279. } else {
  1280. _forcePop();
  1281. }
  1282. }
  1283. void _forcePop() {
  1284. FocusManager.instance.primaryFocus?.unfocus();
  1285. final router = GoRouter.of(context);
  1286. if (router.canPop()) {
  1287. router.pop();
  1288. } else {
  1289. SystemNavigator.pop();
  1290. }
  1291. }
  1292. void _showConfirmDialog(
  1293. String title,
  1294. String content,
  1295. String leftText,
  1296. String rightText,
  1297. VoidCallback onConfirm,
  1298. ) {
  1299. FocusScope.of(context).unfocus();
  1300. FocusManager.instance.primaryFocus?.unfocus();
  1301. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1302. showDialog(
  1303. context: context,
  1304. useRootNavigator: true,
  1305. builder: (ctx) => TDAlertDialog(
  1306. title: title,
  1307. content: content,
  1308. buttonStyle: TDDialogButtonStyle.text,
  1309. leftBtn: TDDialogButtonOptions(
  1310. title: leftText,
  1311. titleColor: colors.primary,
  1312. action: () => Navigator.pop(ctx),
  1313. ),
  1314. rightBtn: TDDialogButtonOptions(
  1315. title: rightText,
  1316. titleColor: colors.danger,
  1317. action: () {
  1318. Navigator.pop(ctx);
  1319. onConfirm();
  1320. },
  1321. ),
  1322. ),
  1323. );
  1324. }
  1325. Widget _buildPageFooter() {
  1326. final l10n = AppLocalizations.of(context);
  1327. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1328. return Center(
  1329. child: Padding(
  1330. padding: const EdgeInsets.only(bottom: 16),
  1331. child: Row(
  1332. mainAxisSize: MainAxisSize.min,
  1333. children: [
  1334. Icon(
  1335. Icons.rocket_launch_outlined,
  1336. size: 16,
  1337. color: colors.textPlaceholder,
  1338. ),
  1339. const SizedBox(width: 6),
  1340. Text(
  1341. l10n.get('pageFooter'),
  1342. style: TextStyle(
  1343. fontSize: AppFontSizes.caption,
  1344. color: colors.textPlaceholder,
  1345. ),
  1346. ),
  1347. ],
  1348. ),
  1349. ),
  1350. );
  1351. }
  1352. String _today() {
  1353. final n = DateTime.now();
  1354. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1355. }
  1356. }