expense_create_page.dart 52 KB

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