expense_create_page.dart 50 KB

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