expense_create_page.dart 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523
  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('submittedAwaitingApproval'),
  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. return null;
  1156. }
  1157. Future<void> _showAddDetailDialog(
  1158. ExpenseCreateController controller, {
  1159. int? editIndex,
  1160. }) async {
  1161. if (_addingDetail) return;
  1162. _addingDetail = true;
  1163. try {
  1164. final l10n = AppLocalizations.of(context);
  1165. if (_costTypes.isEmpty) {
  1166. TDToast.showText(l10n.get('noCostTypeData'), context: context);
  1167. return;
  1168. }
  1169. final state = controller.currentState;
  1170. ExpenseDetailInputData? initialData;
  1171. if (editIndex != null) {
  1172. final d = state.expense.details[editIndex];
  1173. initialData = ExpenseDetailInputData(
  1174. category: d.expenseCategory,
  1175. categoryName: d.categoryName,
  1176. acctSubjectId: d.acctSubjectId,
  1177. acctSubjectName: d.acctSubjectName,
  1178. purpose: d.purpose,
  1179. amount: d.totalAmount,
  1180. taxRate: d.taxRate,
  1181. projectId: d.projectId,
  1182. projectName: d.projectName,
  1183. costDeptId: d.costDeptId,
  1184. costDeptName: d.costDeptName,
  1185. customerVendorId: d.customerVendorId,
  1186. customerVendorName: d.customerVendorName,
  1187. approvedAmount: d.approvedAmount,
  1188. bankName: d.bankName,
  1189. bankAccountName: d.bankAccountName,
  1190. bankAccount: d.bankAccount,
  1191. remark: d.remark,
  1192. attachmentPaths: d.attachments,
  1193. sqMan: d.sqMan,
  1194. sqManName: d.sqManName,
  1195. aeNo: d.aeNo,
  1196. aeDd: d.aeDd,
  1197. );
  1198. }
  1199. FocusManager.instance.primaryFocus?.unfocus();
  1200. final result = await ExpenseDetailDialog.show(
  1201. context,
  1202. categories: _dialogCategories,
  1203. projects: _dialogProjects,
  1204. costDepts: _dialogCostDepts,
  1205. customers: _dialogCustomers,
  1206. employees: _dialogEmployees,
  1207. l10n: l10n,
  1208. initialData: initialData,
  1209. checkAttachHealth: () =>
  1210. ref.read(expenseApiProvider).checkAttachHealth(),
  1211. canEditApprovedAmount: _canEditApprovedAmount,
  1212. billFileRights: _billFileRights,
  1213. );
  1214. if (result != null && mounted) {
  1215. final now = DateTime.now();
  1216. final detail = ExpenseDetailModel(
  1217. id: editIndex != null
  1218. ? state.expense.details[editIndex].id
  1219. : now.millisecondsSinceEpoch.toString(),
  1220. expenseId: '',
  1221. expenseCategory: result.category,
  1222. categoryName: result.categoryName,
  1223. purpose: result.purpose,
  1224. amount: result.taxRate > 0
  1225. ? result.amount / (1 + result.taxRate / 100)
  1226. : result.amount,
  1227. taxRate: result.taxRate,
  1228. taxAmount: result.taxRate > 0
  1229. ? result.amount - result.amount / (1 + result.taxRate / 100)
  1230. : 0,
  1231. totalAmount: result.amount,
  1232. projectId: result.projectId,
  1233. projectName: result.projectName,
  1234. costDeptId: result.costDeptId,
  1235. costDeptName: result.costDeptName,
  1236. acctSubjectId: result.acctSubjectId,
  1237. acctSubjectName: result.acctSubjectName,
  1238. customerVendorId: result.customerVendorId,
  1239. customerVendorName: result.customerVendorName,
  1240. approvedAmount: result.approvedAmount,
  1241. bankName: result.bankName,
  1242. bankAccountName: result.bankAccountName,
  1243. bankAccount: result.bankAccount,
  1244. sqMan: result.sqMan,
  1245. sqManName: result.sqManName,
  1246. aeNo: result.aeNo,
  1247. aeDd: result.aeDd,
  1248. remark: result.remark,
  1249. sortOrder: editIndex != null
  1250. ? state.expense.details[editIndex].sortOrder
  1251. : 1,
  1252. preItm: editIndex != null
  1253. ? state.expense.details[editIndex].preItm
  1254. : null,
  1255. attachments: result.attachmentPaths,
  1256. createTime: now,
  1257. updateTime: now,
  1258. );
  1259. if (editIndex != null) {
  1260. controller.updateDetail(editIndex, detail);
  1261. } else {
  1262. controller.addDetail(detail);
  1263. }
  1264. controller.recalculateAmount();
  1265. }
  1266. } finally {
  1267. _addingDetail = false;
  1268. }
  1269. }
  1270. void _showCurrencyPicker(ExpenseCreateController controller, String cur) {
  1271. if (_currencies.isEmpty) {
  1272. TDToast.showText(
  1273. AppLocalizations.of(context).get('noData'),
  1274. context: context,
  1275. );
  1276. return;
  1277. }
  1278. final l10n = AppLocalizations.of(context);
  1279. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1280. final codes = _currencies.map((c) => c.curId).toList();
  1281. final labels = _currencies.map((c) => '${c.curId}/${c.name}').toList();
  1282. FocusManager.instance.primaryFocus?.unfocus();
  1283. TDPicker.showMultiPicker(
  1284. context,
  1285. title: l10n.get('selectCurrency'),
  1286. backgroundColor: colors.bgCard,
  1287. data: [labels],
  1288. onConfirm: (s) {
  1289. if (s.isNotEmpty && s[0] is int) {
  1290. final i = s[0] as int;
  1291. if (i >= 0 && i < codes.length) {
  1292. Navigator.of(context).pop();
  1293. controller.updateCurrencyCode(codes[i]);
  1294. }
  1295. }
  1296. },
  1297. );
  1298. }
  1299. void _showTextInput(
  1300. String title,
  1301. Function(String) onConfirm, {
  1302. String initialText = '',
  1303. }) {
  1304. FocusScope.of(context).unfocus();
  1305. FocusManager.instance.primaryFocus?.unfocus();
  1306. final l10n = AppLocalizations.of(context);
  1307. final c = TextEditingController(text: initialText);
  1308. showGeneralDialog(
  1309. context: context,
  1310. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  1311. textEditingController: c,
  1312. title: title,
  1313. hintText: l10n.get('pleaseEnter'),
  1314. leftBtn: TDDialogButtonOptions(
  1315. title: l10n.get('cancel'),
  1316. action: () => Navigator.pop(ctx),
  1317. ),
  1318. rightBtn: TDDialogButtonOptions(
  1319. title: l10n.get('confirm'),
  1320. action: () {
  1321. onConfirm(c.text);
  1322. Navigator.pop(ctx);
  1323. },
  1324. ),
  1325. ),
  1326. );
  1327. }
  1328. Widget _label(String t, {bool required = false}) {
  1329. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1330. return Text.rich(
  1331. TextSpan(
  1332. children: [
  1333. TextSpan(
  1334. text: t,
  1335. style: TextStyle(
  1336. fontSize: AppFontSizes.subtitle,
  1337. color: colors.textSecondary,
  1338. ),
  1339. ),
  1340. if (required)
  1341. TextSpan(
  1342. text: ' *',
  1343. style: TextStyle(
  1344. fontSize: AppFontSizes.subtitle,
  1345. color: colors.danger,
  1346. ),
  1347. ),
  1348. ],
  1349. ),
  1350. );
  1351. }
  1352. List<String> _validate(AppLocalizations l10n, ExpenseCreateState state) {
  1353. final e = <String>[];
  1354. if (_purposeController.text.trim().isEmpty) {
  1355. e.add(l10n.get('enterExpenseReason'));
  1356. }
  1357. if (state.expense.details.isEmpty) {
  1358. e.add(l10n.get('addAtLeastOneDetail'));
  1359. }
  1360. return e;
  1361. }
  1362. bool _hasUnsaved(ExpenseCreateState state) =>
  1363. _purposeController.text.isNotEmpty ||
  1364. state.expense.paymentMethod.isNotEmpty ||
  1365. state.expense.currencyCode.isNotEmpty ||
  1366. _remarkController.text.isNotEmpty ||
  1367. state.expense.details.isNotEmpty ||
  1368. _attachmentController.files.isNotEmpty;
  1369. void _doPop() {
  1370. final l10n = AppLocalizations.of(context);
  1371. final state = ref.read(expenseCreateProvider(widget.editId));
  1372. if (_hasUnsaved(state)) {
  1373. _showConfirmDialog(
  1374. l10n.get('confirmExit'),
  1375. l10n.get('unsavedContentWarning'),
  1376. l10n.get('continueEditing'),
  1377. l10n.get('discardAndExit'),
  1378. () async {
  1379. try {
  1380. await ExpenseCreateController.deleteDraft();
  1381. } catch (_) {}
  1382. if (!mounted) return;
  1383. setState(() {
  1384. _selectedDeptId = '';
  1385. _selectedDeptName = '';
  1386. });
  1387. ref.read(expenseCreateProvider(widget.editId).notifier).reset();
  1388. _forcePop();
  1389. },
  1390. );
  1391. } else {
  1392. _forcePop();
  1393. }
  1394. }
  1395. void _forcePop() {
  1396. FocusManager.instance.primaryFocus?.unfocus();
  1397. final router = GoRouter.of(context);
  1398. if (router.canPop()) {
  1399. router.pop();
  1400. } else {
  1401. SystemNavigator.pop();
  1402. }
  1403. }
  1404. void _showConfirmDialog(
  1405. String title,
  1406. String content,
  1407. String leftText,
  1408. String rightText,
  1409. VoidCallback onConfirm,
  1410. ) {
  1411. FocusScope.of(context).unfocus();
  1412. FocusManager.instance.primaryFocus?.unfocus();
  1413. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1414. showDialog(
  1415. context: context,
  1416. useRootNavigator: true,
  1417. builder: (ctx) => TDAlertDialog(
  1418. title: title,
  1419. content: content,
  1420. buttonStyle: TDDialogButtonStyle.text,
  1421. leftBtn: TDDialogButtonOptions(
  1422. title: leftText,
  1423. titleColor: colors.primary,
  1424. action: () => Navigator.pop(ctx),
  1425. ),
  1426. rightBtn: TDDialogButtonOptions(
  1427. title: rightText,
  1428. titleColor: colors.danger,
  1429. action: () {
  1430. Navigator.pop(ctx);
  1431. onConfirm();
  1432. },
  1433. ),
  1434. ),
  1435. );
  1436. }
  1437. Widget _buildPageFooter() {
  1438. final l10n = AppLocalizations.of(context);
  1439. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1440. return Center(
  1441. child: Padding(
  1442. padding: const EdgeInsets.only(bottom: 16),
  1443. child: Row(
  1444. mainAxisSize: MainAxisSize.min,
  1445. children: [
  1446. Icon(
  1447. Icons.rocket_launch_outlined,
  1448. size: 16,
  1449. color: colors.textPlaceholder,
  1450. ),
  1451. const SizedBox(width: 6),
  1452. Text(
  1453. l10n.get('pageFooter'),
  1454. style: TextStyle(
  1455. fontSize: AppFontSizes.caption,
  1456. color: colors.textPlaceholder,
  1457. ),
  1458. ),
  1459. ],
  1460. ),
  1461. ),
  1462. );
  1463. }
  1464. String _today() {
  1465. final n = DateTime.now();
  1466. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1467. }
  1468. }