expense_apply_create_page.dart 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import 'package:tdesign_flutter/tdesign_flutter.dart';
  7. import '../../core/i18n/app_localizations.dart';
  8. import '../../core/navigation/host_app_channel.dart';
  9. import '../../core/storage/draft_storage.dart';
  10. import '../../shared/widgets/action_bar.dart';
  11. import '../../shared/widgets/loading_dialog.dart';
  12. import '../../shared/widgets/form_section.dart';
  13. import '../../shared/widgets/form_field_row.dart';
  14. import '../../shared/widgets/nav_bar_config.dart';
  15. import '../../shared/widgets/attachment_picker.dart';
  16. import '../../core/theme/app_colors.dart';
  17. import '../../core/theme/app_colors_extension.dart';
  18. import '../../core/constants/enums.dart';
  19. import '../../core/data/mock_api_data.dart';
  20. import 'expense_apply_api.dart';
  21. import 'widgets/expense_apply_detail_dialog.dart';
  22. class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
  23. final String? id;
  24. const ExpenseApplyCreatePage({super.key, this.id});
  25. @override
  26. ConsumerState<ExpenseApplyCreatePage> createState() =>
  27. _ExpenseApplyCreatePageState();
  28. }
  29. class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
  30. with WidgetsBindingObserver {
  31. static const _draftKey = 'expense_apply';
  32. // ── 基本信息 ──
  33. String _urgency = Urgency.normal.value;
  34. final _purposeController = TextEditingController();
  35. final _purposeFocus = FocusNode();
  36. String _validUntil = '';
  37. final _referenceNoController = TextEditingController();
  38. final _remarkController = TextEditingController();
  39. final _remarkFocus = FocusNode();
  40. final _scrollCtrl = ScrollController();
  41. // ── 费用明细 ──
  42. final List<_DetailItem> _details = [];
  43. int _detailIdCounter = 1;
  44. // ── 附件 ──
  45. late final AttachmentPickerController _attachmentController;
  46. bool _attachAvailable = false;
  47. // ── 草稿 ──
  48. late Future<bool> _draftFuture;
  49. bool _draftHandled = false;
  50. bool _isPoppingToNative = false;
  51. // ── 参考数据(从 API 加载) ──
  52. List<CostTypeItem> _costTypes = [];
  53. List<ProjectCodeItem> _projects = [];
  54. List<DepartmentItem> _departments = [];
  55. bool _refDataLoading = true;
  56. bool _addingDetail = false;
  57. dynamic _acctTree;
  58. // ── 申请部门 ──
  59. String _selectedDeptId = '';
  60. String _selectedDeptName = '';
  61. @override
  62. void initState() {
  63. super.initState();
  64. WidgetsBinding.instance.addObserver(this);
  65. SystemChrome.setSystemUIOverlayStyle(
  66. const SystemUiOverlayStyle(
  67. statusBarColor: Colors.transparent,
  68. statusBarIconBrightness: Brightness.dark,
  69. ),
  70. );
  71. _attachmentController = AttachmentPickerController(maxCount: 9)
  72. ..addListener(() => setState(() {}));
  73. _checkAttachHealth();
  74. _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
  75. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  76. _costTypes = [];
  77. _projects = [];
  78. _departments = [];
  79. _acctTree = null;
  80. _refDataLoading = true;
  81. _refDataFuture = null;
  82. _draftFuture = DraftStorage.has(_draftKey);
  83. _loadRefData();
  84. }
  85. Future<void>? _refDataFuture;
  86. Future<void> _loadRefData({bool showLoading = false}) async {
  87. if (_refDataFuture != null) return _refDataFuture!;
  88. final completer = Completer<void>();
  89. _refDataFuture = completer.future;
  90. if (showLoading) {
  91. LoadingDialog.show(
  92. context,
  93. text: AppLocalizations.of(context).get('dataLoading'),
  94. );
  95. }
  96. try {
  97. final api = ref.read(expenseApplyApiProvider);
  98. final results = await Future.wait([
  99. api.getCostTypes(),
  100. api.getProjectCodes(),
  101. api.getDepartments(),
  102. api.getAcctSubjects(),
  103. ]);
  104. if (!mounted) return;
  105. setState(() {
  106. _costTypes = results[0] as List<CostTypeItem>;
  107. _projects = results[1] as List<ProjectCodeItem>;
  108. _departments = results[2] as List<DepartmentItem>;
  109. _acctTree = _convertAcctTree(results[3]);
  110. debugPrint('[_loadRefData] _acctTree isNull: ${_acctTree == null}');
  111. _refDataLoading = false;
  112. _autoSelectDept();
  113. });
  114. completer.complete();
  115. } catch (_) {
  116. if (!mounted) {
  117. completer.complete();
  118. return;
  119. }
  120. setState(() => _refDataLoading = false);
  121. completer.complete();
  122. } finally {
  123. if (showLoading && mounted) LoadingDialog.hide(context);
  124. _refDataFuture = null;
  125. }
  126. }
  127. void _autoSelectDept() {
  128. if (_selectedDeptId.isNotEmpty) return; // 已选中则不覆盖
  129. final dep = HostAppChannel.dep;
  130. if (dep.isEmpty) return;
  131. final match = _departments.where((d) => d.dep == dep);
  132. if (match.isNotEmpty) {
  133. _selectedDeptId = match.first.dep;
  134. _selectedDeptName = match.first.name;
  135. }
  136. }
  137. void _ensureVisible(FocusNode node) {
  138. if (!node.hasFocus) return;
  139. WidgetsBinding.instance.addPostFrameCallback((_) {
  140. if (node.hasFocus && _scrollCtrl.hasClients) {
  141. final ctx = node.context;
  142. if (ctx != null) {
  143. Scrollable.ensureVisible(
  144. ctx,
  145. alignment: 0.3,
  146. duration: const Duration(milliseconds: 300),
  147. );
  148. }
  149. }
  150. });
  151. }
  152. @override
  153. void dispose() {
  154. WidgetsBinding.instance.removeObserver(this);
  155. _purposeController.dispose();
  156. _purposeFocus.dispose();
  157. _referenceNoController.dispose();
  158. _remarkController.dispose();
  159. _remarkFocus.dispose();
  160. _attachmentController.dispose();
  161. _scrollCtrl.dispose();
  162. super.dispose();
  163. }
  164. @override
  165. void didChangeAppLifecycleState(AppLifecycleState state) {
  166. if (state == AppLifecycleState.resumed) {
  167. FocusScope.of(context).unfocus();
  168. if (_isPoppingToNative) {
  169. _isPoppingToNative = false;
  170. HostAppChannel.refresh();
  171. // 重置表单数据
  172. _urgency = Urgency.normal.value;
  173. _purposeController.clear();
  174. _validUntil = '';
  175. _referenceNoController.clear();
  176. _remarkController.clear();
  177. _details.clear();
  178. _detailIdCounter = 1;
  179. _attachmentController.clear();
  180. _attachAvailable = false;
  181. _addingDetail = false;
  182. _selectedDeptId = '';
  183. _selectedDeptName = '';
  184. // 重置参考数据
  185. _costTypes = [];
  186. _projects = [];
  187. _departments = [];
  188. _acctTree = null;
  189. _refDataFuture = null;
  190. _refDataLoading = true;
  191. // 重置草稿状态
  192. _draftHandled = false;
  193. _draftFuture = DraftStorage.has(_draftKey);
  194. // 重新加载
  195. _loadRefData();
  196. _checkAttachHealth();
  197. }
  198. }
  199. }
  200. @override
  201. Widget build(BuildContext context) {
  202. final l10n = AppLocalizations.of(context);
  203. setNavBarTitle(
  204. context,
  205. ref,
  206. NavBarConfig(
  207. title: l10n.get('expenseApplyRequest'),
  208. showBack: true,
  209. onBack: () => _doPop(),
  210. ),
  211. );
  212. return FutureBuilder<bool>(
  213. future: _draftFuture,
  214. builder: (ctx, snapshot) {
  215. final hasDraft = snapshot.hasData && snapshot.data == true;
  216. if (hasDraft && !_draftHandled) {
  217. _draftHandled = true;
  218. WidgetsBinding.instance.addPostFrameCallback((_) {
  219. if (mounted) _showDraftDialog();
  220. });
  221. }
  222. return PopScope(
  223. canPop: false,
  224. onPopInvokedWithResult: (didPop, _) {
  225. if (didPop) return;
  226. _doPop();
  227. },
  228. child: Column(
  229. children: [
  230. Expanded(
  231. child: GestureDetector(
  232. onTap: () => FocusScope.of(context).unfocus(),
  233. child: SingleChildScrollView(
  234. controller: _scrollCtrl,
  235. padding: const EdgeInsets.all(16),
  236. child: Column(
  237. children: [
  238. _buildBasicInfo(l10n),
  239. const SizedBox(height: 16),
  240. _buildDetailsSection(l10n),
  241. const SizedBox(height: 16),
  242. _buildAttachmentSection(l10n),
  243. const SizedBox(height: 24),
  244. _buildPageFooter(),
  245. ],
  246. ),
  247. ),
  248. ),
  249. ),
  250. _buildBottomBar(l10n),
  251. ],
  252. ),
  253. );
  254. },
  255. );
  256. }
  257. // ═══ 草稿持久化 ═══
  258. Future<void> _restoreDraft() async {
  259. final data = await DraftStorage.load(_draftKey);
  260. if (data == null) return;
  261. final attData = data['attachments'] as List<dynamic>?;
  262. if (attData != null) {
  263. await _attachmentController.restoreFromPaths(attData.cast<String>());
  264. }
  265. setState(() {
  266. _urgency = data['urgency'] as String? ?? Urgency.normal.value;
  267. _purposeController.text = data['purpose'] as String? ?? '';
  268. _validUntil = data['validUntil'] as String? ?? '';
  269. _referenceNoController.text = data['referenceNo'] as String? ?? '';
  270. _remarkController.text = data['remark'] as String? ?? '';
  271. _selectedDeptId = data['deptId'] as String? ?? '';
  272. _selectedDeptName = data['deptName'] as String? ?? '';
  273. _details.clear();
  274. final detailList = data['details'] as List<dynamic>?;
  275. if (detailList != null) {
  276. for (final d in detailList) {
  277. final m = d as Map<String, dynamic>;
  278. _details.add(
  279. _DetailItem(
  280. id: m['id'] as int? ?? _detailIdCounter++,
  281. category: m['category'] as String? ?? '',
  282. categoryName: m['categoryName'] as String? ?? '',
  283. acctSubjectId: m['acctSubjectId'] as String? ?? '',
  284. acctSubjectName: m['acctSubjectName'] as String? ?? '',
  285. purpose: m['purpose'] as String? ?? '',
  286. projectId: m['projectId'] as int? ?? 0,
  287. projectName: m['projectName'] as String? ?? '',
  288. costDeptId: m['costDeptId'] as String? ?? '',
  289. costDeptName: m['costDeptName'] as String? ?? '',
  290. startDate: m['startDate'] as String? ?? '',
  291. endDate: m['endDate'] as String? ?? '',
  292. estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
  293. remark: m['remark'] as String? ?? '',
  294. ),
  295. );
  296. }
  297. }
  298. _detailIdCounter = _details.isEmpty
  299. ? 1
  300. : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
  301. });
  302. }
  303. Future<void> _saveDraftToStorage() async {
  304. final detailList = _details
  305. .map(
  306. (d) => {
  307. 'id': d.id,
  308. 'category': d.category,
  309. 'categoryName': d.categoryName,
  310. 'acctSubjectId': d.acctSubjectId,
  311. 'acctSubjectName': d.acctSubjectName,
  312. 'purpose': d.purpose,
  313. 'projectId': d.projectId,
  314. 'projectName': d.projectName,
  315. 'costDeptId': d.costDeptId,
  316. 'costDeptName': d.costDeptName,
  317. 'startDate': d.startDate,
  318. 'endDate': d.endDate,
  319. 'estimatedAmount': d.estimatedAmount,
  320. 'remark': d.remark,
  321. },
  322. )
  323. .toList();
  324. await DraftStorage.save(_draftKey, {
  325. 'urgency': _urgency,
  326. 'purpose': _purposeController.text,
  327. 'deptId': _selectedDeptId,
  328. 'deptName': _selectedDeptName,
  329. 'validUntil': _validUntil,
  330. 'referenceNo': _referenceNoController.text,
  331. 'remark': _remarkController.text,
  332. 'attachments': _attachmentController.toPathList(),
  333. 'details': detailList,
  334. });
  335. }
  336. // ═══ 草稿弹窗 ═══
  337. // 使用 showDialog 而非内联渲染,确保 TDAlertDialog 获取正确的主题上下文
  338. void _showDraftDialog() {
  339. final l10n = AppLocalizations.of(context);
  340. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  341. FocusManager.instance.primaryFocus?.unfocus();
  342. showDialog(
  343. context: context,
  344. barrierDismissible: false,
  345. builder: (ctx) => TDAlertDialog(
  346. title: l10n.get('draftFound'),
  347. content: l10n.get('draftRestorePrompt'),
  348. leftBtn: TDDialogButtonOptions(
  349. title: l10n.get('discard'),
  350. titleColor: colors.textSecondary,
  351. action: () {
  352. Navigator.pop(ctx);
  353. DraftStorage.delete(_draftKey);
  354. },
  355. ),
  356. rightBtn: TDDialogButtonOptions(
  357. title: l10n.get('restore'),
  358. titleColor: colors.primary,
  359. action: () {
  360. Navigator.pop(ctx);
  361. _restoreDraft();
  362. },
  363. ),
  364. ),
  365. );
  366. }
  367. // ═══ 1. 基本信息 ═══
  368. Widget _buildBasicInfo(AppLocalizations l10n) {
  369. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  370. return FormSection(
  371. title: l10n.get('basicInfo'),
  372. leadingIcon: Icons.info_outline,
  373. children: [
  374. FormFieldRow(
  375. label: l10n.get('date'),
  376. value: _today(),
  377. readOnly: true,
  378. showArrow: false,
  379. ),
  380. const SizedBox(height: 16),
  381. FormFieldRow(
  382. label: l10n.get('applicant'),
  383. value:
  384. HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
  385. ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
  386. : '--',
  387. readOnly: true,
  388. showArrow: false,
  389. ),
  390. const SizedBox(height: 16),
  391. FormFieldRow(
  392. label: l10n.get('applyDept'),
  393. value: _selectedDeptId.isNotEmpty
  394. ? '$_selectedDeptId/$_selectedDeptName'
  395. : '',
  396. hint: l10n.get('pleaseSelect'),
  397. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  398. ),
  399. const SizedBox(height: 16),
  400. _buildUrgencyRow(l10n),
  401. const SizedBox(height: 16),
  402. _label(l10n.get('applyReason'), required: true),
  403. const SizedBox(height: 8),
  404. TDTextarea(
  405. controller: _purposeController,
  406. focusNode: _purposeFocus,
  407. hintText: l10n.get('enterApplyReason'),
  408. maxLines: 4,
  409. minLines: 1,
  410. maxLength: 500,
  411. indicator: true,
  412. padding: EdgeInsets.zero,
  413. bordered: true,
  414. backgroundColor: colors.bgPage,
  415. ),
  416. // TODO: 暂不支持录入,后续开放
  417. // const SizedBox(height: 16),
  418. // FormFieldRow(
  419. // label: l10n.get('validUntil'),
  420. // value: _validUntil,
  421. // hint: l10n.get('pleaseSelect'),
  422. // onTap: () => _pickDate((d) => setState(() => _validUntil = d)),
  423. // ),
  424. // const SizedBox(height: 16),
  425. // FormFieldRow(
  426. // label: l10n.get('relatedContractNo'),
  427. // value: _referenceNoController.text,
  428. // hint: l10n.get('optional'),
  429. // onTap: () => _showTextInput(
  430. // l10n.get('relatedContractNo'),
  431. // (v) => setState(() {
  432. // _referenceNoController.text = v;
  433. // _referenceNoController.selection = TextSelection.fromPosition(
  434. // TextPosition(offset: v.length),
  435. // );
  436. // }),
  437. // initialText: _referenceNoController.text,
  438. // ),
  439. // ),
  440. const SizedBox(height: 16),
  441. _label(l10n.get('remark')),
  442. const SizedBox(height: 8),
  443. TDTextarea(
  444. controller: _remarkController,
  445. focusNode: _remarkFocus,
  446. hintText: l10n.get('enterRemark'),
  447. maxLines: 3,
  448. minLines: 1,
  449. maxLength: 500,
  450. indicator: true,
  451. padding: EdgeInsets.zero,
  452. bordered: true,
  453. backgroundColor: colors.bgPage,
  454. ),
  455. ],
  456. );
  457. }
  458. Widget _buildUrgencyRow(AppLocalizations l10n) {
  459. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  460. return Row(
  461. children: [
  462. Text.rich(
  463. TextSpan(
  464. children: [
  465. TextSpan(
  466. text: l10n.get('emergencyLevel'),
  467. style: TextStyle(
  468. fontSize: AppFontSizes.subtitle,
  469. color: colors.textSecondary,
  470. ),
  471. ),
  472. TextSpan(
  473. text: ' *',
  474. style: TextStyle(
  475. fontSize: AppFontSizes.subtitle,
  476. color: colors.danger,
  477. ),
  478. ),
  479. ],
  480. ),
  481. ),
  482. const Spacer(),
  483. Row(
  484. mainAxisSize: MainAxisSize.min,
  485. children: Urgency.values.asMap().entries.map((e) {
  486. final sel = _urgency == e.value.value;
  487. final isCritical = e.value.value == Urgency.critical.value;
  488. final isUrgent = e.value.value == Urgency.urgent.value;
  489. final activeColor = isCritical
  490. ? colors.danger
  491. : isUrgent
  492. ? colors.warning
  493. : colors.primary;
  494. return Padding(
  495. padding: EdgeInsets.only(left: e.key > 0 ? 18 : 0),
  496. child: GestureDetector(
  497. behavior: HitTestBehavior.opaque,
  498. onTap: () => setState(() => _urgency = e.value.value),
  499. child: Row(
  500. mainAxisSize: MainAxisSize.min,
  501. children: [
  502. Container(
  503. width: 18,
  504. height: 18,
  505. decoration: BoxDecoration(
  506. shape: BoxShape.circle,
  507. border: Border.all(
  508. color: sel ? activeColor : colors.textPlaceholder,
  509. width: 2,
  510. ),
  511. ),
  512. child: sel
  513. ? Center(
  514. child: Container(
  515. width: 8,
  516. height: 8,
  517. decoration: BoxDecoration(
  518. shape: BoxShape.circle,
  519. color: activeColor,
  520. ),
  521. ),
  522. )
  523. : null,
  524. ),
  525. const SizedBox(width: 5),
  526. Text(
  527. l10n.get(e.value.labelKey),
  528. style: TextStyle(
  529. fontSize: AppFontSizes.subtitle,
  530. color: sel ? activeColor : colors.textPrimary,
  531. ),
  532. ),
  533. ],
  534. ),
  535. ),
  536. );
  537. }).toList(),
  538. ),
  539. ],
  540. );
  541. }
  542. // ═══ 2. 费用明细 ═══
  543. Widget _buildDetailsSection(AppLocalizations l10n) {
  544. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  545. return FormSection(
  546. title: l10n.get('expenseDetails'),
  547. leadingIcon: Icons.receipt_long_outlined,
  548. showAction: true,
  549. actionText: l10n.get('add'),
  550. onActionTap: _showDetailDialog,
  551. children: [
  552. if (_details.isEmpty)
  553. Padding(
  554. padding: const EdgeInsets.symmetric(vertical: 8),
  555. child: Text(
  556. l10n.get('noDetailHint'),
  557. style: TextStyle(
  558. fontSize: AppFontSizes.subtitle,
  559. color: colors.textPlaceholder,
  560. ),
  561. ),
  562. )
  563. else
  564. ..._details.asMap().entries.map((e) {
  565. final d = e.value;
  566. return GestureDetector(
  567. onTap: () => _showDetailDialog(editIndex: e.key),
  568. child: Container(
  569. margin: const EdgeInsets.symmetric(vertical: 8),
  570. padding: const EdgeInsets.all(12),
  571. decoration: BoxDecoration(
  572. color: colors.bgPage,
  573. borderRadius: BorderRadius.circular(8),
  574. ),
  575. child: Row(
  576. crossAxisAlignment: CrossAxisAlignment.center,
  577. children: [
  578. Expanded(
  579. child: Column(
  580. crossAxisAlignment: CrossAxisAlignment.start,
  581. children: [
  582. Row(
  583. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  584. children: [
  585. Expanded(
  586. child: Text(
  587. '${d.category}/${d.categoryName}',
  588. maxLines: 1,
  589. overflow: TextOverflow.ellipsis,
  590. style: TextStyle(
  591. fontSize: AppFontSizes.subtitle,
  592. color: colors.textPrimary,
  593. ),
  594. ),
  595. ),
  596. const SizedBox(width: 12),
  597. Text(
  598. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  599. style: TextStyle(
  600. fontSize: AppFontSizes.caption,
  601. fontWeight: FontWeight.w600,
  602. color: colors.amountPrimary,
  603. ),
  604. ),
  605. ],
  606. ),
  607. if (d.acctSubjectId.isNotEmpty &&
  608. d.acctSubjectName.isNotEmpty) ...[
  609. const SizedBox(height: 4),
  610. Text(
  611. d.acctSubjectName,
  612. maxLines: 1,
  613. overflow: TextOverflow.ellipsis,
  614. style: TextStyle(
  615. fontSize: AppFontSizes.caption,
  616. color: colors.textSecondary,
  617. ),
  618. ),
  619. ],
  620. if (d.projectId > 0 && d.projectName.isNotEmpty) ...[
  621. const SizedBox(height: 4),
  622. Text(
  623. '${d.projectId}/${d.projectName}',
  624. maxLines: 1,
  625. overflow: TextOverflow.ellipsis,
  626. style: TextStyle(
  627. fontSize: AppFontSizes.caption,
  628. color: colors.textSecondary,
  629. ),
  630. ),
  631. ],
  632. if (d.costDeptId.isNotEmpty &&
  633. d.costDeptName.isNotEmpty) ...[
  634. const SizedBox(height: 4),
  635. Text(
  636. '${d.costDeptId}/${d.costDeptName}',
  637. maxLines: 1,
  638. overflow: TextOverflow.ellipsis,
  639. style: TextStyle(
  640. fontSize: AppFontSizes.caption,
  641. color: colors.textSecondary,
  642. ),
  643. ),
  644. ],
  645. if (d.startDate.isNotEmpty &&
  646. d.endDate.isNotEmpty) ...[
  647. const SizedBox(height: 4),
  648. Text(
  649. '${d.startDate} ~ ${d.endDate}',
  650. maxLines: 1,
  651. overflow: TextOverflow.ellipsis,
  652. style: TextStyle(
  653. fontSize: AppFontSizes.caption,
  654. color: colors.textSecondary,
  655. ),
  656. ),
  657. ],
  658. if (d.remark.isNotEmpty) ...[
  659. const SizedBox(height: 4),
  660. Text(
  661. d.remark,
  662. style: TextStyle(
  663. fontSize: AppFontSizes.caption,
  664. color: colors.textSecondary,
  665. ),
  666. ),
  667. ],
  668. ],
  669. ),
  670. ),
  671. const SizedBox(width: 8),
  672. GestureDetector(
  673. onTap: () => setState(() => _details.removeAt(e.key)),
  674. child: Icon(
  675. Icons.close,
  676. size: 18,
  677. color: colors.textSecondary,
  678. ),
  679. ),
  680. ],
  681. ),
  682. ),
  683. );
  684. }),
  685. const SizedBox(height: 8),
  686. Container(
  687. height: 36,
  688. padding: const EdgeInsets.symmetric(vertical: 8),
  689. child: Row(
  690. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  691. children: [
  692. Text(
  693. l10n.get('total'),
  694. style: TextStyle(
  695. fontSize: AppFontSizes.body,
  696. fontWeight: FontWeight.w600,
  697. color: colors.textPrimary,
  698. ),
  699. ),
  700. Text(
  701. '¥${_totalAmount().toStringAsFixed(2)}',
  702. style: TextStyle(
  703. fontSize: AppFontSizes.subtitle,
  704. fontWeight: FontWeight.w700,
  705. color: colors.amountPrimary,
  706. ),
  707. ),
  708. ],
  709. ),
  710. ),
  711. ],
  712. );
  713. }
  714. double _totalAmount() => _details.fold(0, (s, d) => s + d.estimatedAmount);
  715. Future<void> _showDetailDialog({int? editIndex}) async {
  716. if (_addingDetail) return;
  717. _addingDetail = true;
  718. try {
  719. final l10n = AppLocalizations.of(context);
  720. if (_costTypes.isEmpty) {
  721. await _loadRefData(showLoading: true);
  722. if (!mounted) return;
  723. if (_costTypes.isEmpty) {
  724. TDToast.showText(l10n.get('noCostTypeData'), context: context);
  725. return;
  726. }
  727. }
  728. ExpenseDetailData? initialData;
  729. if (editIndex != null) {
  730. final d = _details[editIndex];
  731. initialData = ExpenseDetailData(
  732. category: d.category,
  733. categoryName: d.categoryName,
  734. acctSubjectId: d.acctSubjectId,
  735. acctSubjectName: d.acctSubjectName,
  736. purpose: d.purpose,
  737. projectId: d.projectId,
  738. projectName: d.projectName,
  739. costDeptId: d.costDeptId,
  740. costDeptName: d.costDeptName,
  741. startDate: d.startDate,
  742. endDate: d.endDate,
  743. estimatedAmount: d.estimatedAmount,
  744. remark: d.remark,
  745. );
  746. }
  747. FocusManager.instance.primaryFocus?.unfocus();
  748. final result = await ExpenseApplyDetailDialog.show(
  749. // ignore: use_build_context_synchronously
  750. context,
  751. categories: _dialogCategories,
  752. projects: _dialogProjects,
  753. costDepts: _dialogCostDepts,
  754. l10n: l10n,
  755. acctTree: _acctTree,
  756. initialData: initialData,
  757. );
  758. if (result != null && mounted) {
  759. setState(() {
  760. final item = _DetailItem(
  761. id: editIndex != null ? _details[editIndex].id : _detailIdCounter++,
  762. category: result.category,
  763. categoryName: result.categoryName,
  764. acctSubjectId: result.acctSubjectId,
  765. acctSubjectName: result.acctSubjectName,
  766. purpose: result.purpose,
  767. projectId: result.projectId,
  768. projectName: result.projectName,
  769. costDeptId: result.costDeptId,
  770. costDeptName: result.costDeptName,
  771. startDate: result.startDate,
  772. endDate: result.endDate,
  773. estimatedAmount: result.estimatedAmount,
  774. remark: result.remark,
  775. );
  776. if (editIndex != null) {
  777. _details[editIndex] = item;
  778. } else {
  779. _details.add(item);
  780. }
  781. });
  782. }
  783. } finally {
  784. _addingDetail = false;
  785. }
  786. }
  787. // ═══ 3. 附件上传 ═══
  788. /// 深度转换 JSON 解析的 List<dynamic> 为 List<Map>,确保 TDCascader 类型匹配
  789. List<Map<String, dynamic>> _convertAcctTree(dynamic tree) {
  790. if (tree is! List) return [];
  791. return tree.map<Map<String, dynamic>>((e) {
  792. final map = Map<String, dynamic>.from(e as Map);
  793. if (map['children'] != null) {
  794. map['children'] = _convertAcctTree(map['children']);
  795. }
  796. return map;
  797. }).toList();
  798. }
  799. Future<void> _checkAttachHealth() async {
  800. // 立即设为 false,等待 API 返回后再更新,避免缓存旧值
  801. if (mounted) setState(() => _attachAvailable = false);
  802. try {
  803. final api = ref.read(expenseApplyApiProvider);
  804. final ok = await api.checkAttachHealth();
  805. if (mounted) setState(() => _attachAvailable = ok);
  806. } catch (_) {
  807. if (mounted) setState(() => _attachAvailable = false);
  808. }
  809. }
  810. Widget _buildAttachmentSection(AppLocalizations l10n) {
  811. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  812. final children = <Widget>[];
  813. if (!_attachAvailable) {
  814. children.add(
  815. Text(
  816. l10n.get('attachServiceUnavailable'),
  817. style: TextStyle(
  818. fontSize: AppFontSizes.caption,
  819. color: colors.textPlaceholder,
  820. ),
  821. ),
  822. );
  823. } else {
  824. children.addAll([
  825. Text(
  826. l10n.get('maxAttachment'),
  827. style: TextStyle(
  828. fontSize: AppFontSizes.caption,
  829. color: colors.textPlaceholder,
  830. ),
  831. ),
  832. const SizedBox(height: 8),
  833. ]);
  834. }
  835. return FormSection(
  836. title: l10n.get('attachmentUpload'),
  837. leadingIcon: Icons.attach_file_outlined,
  838. children: [
  839. ...children,
  840. if (_attachAvailable)
  841. AttachmentPicker(
  842. controller: _attachmentController,
  843. maxImageSizeMB: 10,
  844. maxFileSizeMB: 20,
  845. allowedExtensions: const [
  846. 'pdf',
  847. 'doc',
  848. 'docx',
  849. 'xls',
  850. 'xlsx',
  851. 'ppt',
  852. 'pptx',
  853. 'txt',
  854. ],
  855. onFileRejected: (file, reason) {
  856. if (context.mounted) {
  857. TDToast.showText(reason, context: context);
  858. }
  859. },
  860. ),
  861. ],
  862. );
  863. }
  864. void _showDeptPicker() {
  865. if (_departments.isEmpty) {
  866. TDToast.showText(
  867. AppLocalizations.of(context).get('noData'),
  868. context: context,
  869. );
  870. return;
  871. }
  872. final l10n = AppLocalizations.of(context);
  873. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  874. final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
  875. FocusManager.instance.primaryFocus?.unfocus();
  876. TDPicker.showMultiPicker(
  877. context,
  878. title: l10n.get('applyDept'),
  879. backgroundColor: colors.bgCard,
  880. data: [labels],
  881. onConfirm: (selected) {
  882. if (selected.isNotEmpty && selected[0] is int) {
  883. final idx = selected[0] as int;
  884. if (idx >= 0 && idx < labels.length) {
  885. Navigator.of(context).pop();
  886. setState(() {
  887. _selectedDeptId = _departments[idx].dep;
  888. _selectedDeptName = _departments[idx].name;
  889. });
  890. }
  891. }
  892. },
  893. );
  894. }
  895. // ═══ API 数据 → 弹窗类型转换 ═══
  896. List<CostCategory> get _dialogCategories => _costTypes
  897. .map(
  898. (c) => CostCategory(
  899. code: c.typeNo,
  900. nameKey: c.typeName,
  901. acctSubjectId: c.accNo,
  902. acctSubjectName: c.accName,
  903. ),
  904. )
  905. .toList();
  906. List<Project> get _dialogProjects => _projects
  907. .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
  908. .toList();
  909. List<CostDept> get _dialogCostDepts =>
  910. _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
  911. // ═══ 4. 底部操作栏 ═══
  912. Widget _buildBottomBar(AppLocalizations l10n) {
  913. return ActionBar(
  914. showLeft: false,
  915. centerLabel: l10n.get('saveDraft'),
  916. rightLabel: l10n.get('submit'),
  917. centerTextOnly: true,
  918. onCenterTap: () async {
  919. FocusScope.of(context).unfocus();
  920. try {
  921. await _saveDraftToStorage();
  922. if (mounted) _forcePop();
  923. } catch (_) {
  924. if (mounted) {
  925. TDToast.showFail(l10n.get('saveFailed'), context: context);
  926. }
  927. }
  928. },
  929. onRightTap: () async {
  930. final err = _validate(l10n);
  931. if (err.isNotEmpty) {
  932. TDToast.showText(err.first, context: context);
  933. return;
  934. }
  935. FocusScope.of(context).unfocus();
  936. LoadingDialog.show(context, text: l10n.get('submitting'));
  937. try {
  938. final data = _buildSubmitData();
  939. final api = ref.read(expenseApplyApiProvider);
  940. final billNo = await api.submit(data);
  941. // 上传表头附件(billNo 提取失败则跳过,不影响主流程)
  942. if (billNo != null && _attachmentController.files.isNotEmpty) {
  943. final now = DateTime.now();
  944. final effDd =
  945. '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
  946. '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}.'
  947. '${now.millisecond.toString().padLeft(3, '0')}';
  948. final usr = HostAppChannel.usr;
  949. for (var i = 0; i < _attachmentController.files.length; i++) {
  950. final file = _attachmentController.files[i];
  951. try {
  952. await api.uploadAttachment(file.path, {
  953. 'BIL_ID': 'AE',
  954. 'BIL_NO': billNo,
  955. 'SRCITM': 0,
  956. 'ITM': i + 1,
  957. 'TAG': 1,
  958. 'EFF_DD': effDd,
  959. 'USR': usr,
  960. 'FILENAME': file.name,
  961. 'EXT': file.name.split('.').last,
  962. });
  963. } catch (_) {
  964. // Attachment upload failure is non-fatal
  965. }
  966. }
  967. }
  968. await DraftStorage.delete(_draftKey);
  969. if (mounted) {
  970. LoadingDialog.hide(context);
  971. TDToast.showSuccess(
  972. l10n.get('submittedAwaitingApproval'),
  973. context: context,
  974. );
  975. GoRouter.of(context).go('/expense-apply/list');
  976. }
  977. } catch (_) {
  978. if (mounted) {
  979. LoadingDialog.hide(context);
  980. TDToast.showFail(l10n.get('submitFailedRetry'), context: context);
  981. }
  982. }
  983. },
  984. );
  985. }
  986. Map<String, dynamic> _buildSubmitData() {
  987. // 紧急程度映射:normal→1, urgent→2, critical→3
  988. String priority;
  989. switch (_urgency) {
  990. case 'urgent':
  991. priority = '2';
  992. break;
  993. case 'critical':
  994. priority = '3';
  995. break;
  996. default:
  997. priority = '1';
  998. }
  999. return {
  1000. 'HeadData': {
  1001. 'AE_DD': _today(),
  1002. 'PRIORITY': priority,
  1003. 'AMTN_YJ': _totalAmount(),
  1004. 'REASON': _purposeController.text.trim(),
  1005. 'REM': _remarkController.text,
  1006. 'DEP': _selectedDeptId,
  1007. 'USR': HostAppChannel.usr,
  1008. },
  1009. 'BodyData1': _details.asMap().entries.map((e) {
  1010. final i = e.key;
  1011. final d = e.value;
  1012. return {
  1013. 'ITM': i + 1,
  1014. 'SQ_MAN': HostAppChannel.usr,
  1015. 'TYPE_NO': d.category,
  1016. 'AMTN_YJ': d.estimatedAmount,
  1017. 'ACC_NO': d.acctSubjectId,
  1018. //'ACC_NAME': d.acctSubjectName,
  1019. 'DEP': d.costDeptId,
  1020. 'OBJ_NO': d.projectId > 0 ? d.projectId.toString() : '',
  1021. 'START_DD': d.startDate,
  1022. 'END_DD': d.endDate,
  1023. 'REM': d.remark.isNotEmpty ? d.remark : d.purpose,
  1024. };
  1025. }).toList(),
  1026. };
  1027. }
  1028. List<String> _validate(AppLocalizations l10n) {
  1029. final e = <String>[];
  1030. if (_purposeController.text.trim().isEmpty) {
  1031. e.add(l10n.get('enterApplyReason'));
  1032. }
  1033. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
  1034. return e;
  1035. }
  1036. void _doPop() {
  1037. if (_hasUnsaved()) {
  1038. final l10n = AppLocalizations.of(context);
  1039. _showConfirmDialog(
  1040. l10n.get('confirmExit'),
  1041. l10n.get('unsavedContentWarning'),
  1042. l10n.get('continueEditing'),
  1043. l10n.get('discardAndExit'),
  1044. () async {
  1045. await DraftStorage.delete(_draftKey);
  1046. if (!mounted) return;
  1047. setState(() => _clearLocalState());
  1048. _forcePop();
  1049. },
  1050. );
  1051. } else {
  1052. _forcePop();
  1053. }
  1054. }
  1055. void _forcePop() {
  1056. FocusManager.instance.primaryFocus?.unfocus();
  1057. final router = GoRouter.of(context);
  1058. if (router.canPop()) {
  1059. router.pop();
  1060. } else {
  1061. _isPoppingToNative = true;
  1062. SystemNavigator.pop();
  1063. }
  1064. }
  1065. bool _hasUnsaved() =>
  1066. _purposeController.text.isNotEmpty ||
  1067. _details.isNotEmpty ||
  1068. _attachmentController.files.isNotEmpty ||
  1069. _referenceNoController.text.isNotEmpty ||
  1070. _remarkController.text.isNotEmpty ||
  1071. _urgency != Urgency.normal.value ||
  1072. _validUntil.isNotEmpty ||
  1073. _selectedDeptId.isNotEmpty;
  1074. void _clearLocalState() {
  1075. _urgency = Urgency.normal.value;
  1076. _purposeController.clear();
  1077. _validUntil = '';
  1078. _referenceNoController.clear();
  1079. _remarkController.clear();
  1080. _details.clear();
  1081. _detailIdCounter = 1;
  1082. _attachmentController.clear();
  1083. _selectedDeptId = '';
  1084. _selectedDeptName = '';
  1085. }
  1086. void _unfocus() => FocusScope.of(context).unfocus();
  1087. // ═══ 通用弹窗方法 ═══
  1088. void _showConfirmDialog(
  1089. String title,
  1090. String content,
  1091. String leftText,
  1092. String rightText,
  1093. VoidCallback onConfirm,
  1094. ) {
  1095. _unfocus();
  1096. FocusManager.instance.primaryFocus?.unfocus();
  1097. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1098. showDialog(
  1099. context: context,
  1100. useRootNavigator: true,
  1101. builder: (ctx) => TDAlertDialog(
  1102. title: title,
  1103. content: content,
  1104. buttonStyle: TDDialogButtonStyle.text,
  1105. leftBtn: TDDialogButtonOptions(
  1106. title: leftText,
  1107. titleColor: colors.primary,
  1108. action: () => Navigator.pop(ctx),
  1109. ),
  1110. rightBtn: TDDialogButtonOptions(
  1111. title: rightText,
  1112. titleColor: colors.danger,
  1113. action: () {
  1114. Navigator.pop(ctx);
  1115. onConfirm();
  1116. },
  1117. ),
  1118. ),
  1119. );
  1120. }
  1121. // TODO: 有效期至 / 关联合同号 暂不支持,方法暂时注释
  1122. // void _showTextInput(
  1123. // String title,
  1124. // Function(String) onConfirm, {
  1125. // String initialText = '',
  1126. // }) {
  1127. // ...
  1128. // }
  1129. // void _pickDate(Function(String) onPick) {
  1130. // ...
  1131. // }
  1132. Widget _label(String t, {bool required = false}) {
  1133. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1134. return Text.rich(
  1135. TextSpan(
  1136. children: [
  1137. TextSpan(
  1138. text: t,
  1139. style: TextStyle(
  1140. fontSize: AppFontSizes.subtitle,
  1141. color: colors.textSecondary,
  1142. ),
  1143. ),
  1144. if (required)
  1145. TextSpan(
  1146. text: ' *',
  1147. style: TextStyle(
  1148. fontSize: AppFontSizes.subtitle,
  1149. color: colors.danger,
  1150. ),
  1151. ),
  1152. ],
  1153. ),
  1154. );
  1155. }
  1156. Widget _buildPageFooter() {
  1157. final l10n = AppLocalizations.of(context);
  1158. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1159. return Center(
  1160. child: Padding(
  1161. padding: const EdgeInsets.only(bottom: 16),
  1162. child: Row(
  1163. mainAxisSize: MainAxisSize.min,
  1164. children: [
  1165. Icon(
  1166. Icons.rocket_launch_outlined,
  1167. size: 16,
  1168. color: colors.textPlaceholder,
  1169. ),
  1170. const SizedBox(width: 6),
  1171. Text(
  1172. l10n.get('pageFooter'),
  1173. style: TextStyle(
  1174. fontSize: AppFontSizes.caption,
  1175. color: colors.textPlaceholder,
  1176. ),
  1177. ),
  1178. ],
  1179. ),
  1180. ),
  1181. );
  1182. }
  1183. String _today() {
  1184. final n = DateTime.now();
  1185. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1186. }
  1187. }
  1188. class _DetailItem {
  1189. final int id;
  1190. final String category;
  1191. final String categoryName;
  1192. final String acctSubjectId;
  1193. final String acctSubjectName;
  1194. final String purpose;
  1195. final int projectId;
  1196. final String projectName;
  1197. final String costDeptId;
  1198. final String costDeptName;
  1199. final String startDate;
  1200. final String endDate;
  1201. final double estimatedAmount;
  1202. final String remark;
  1203. const _DetailItem({
  1204. required this.id,
  1205. required this.category,
  1206. required this.categoryName,
  1207. required this.acctSubjectId,
  1208. required this.acctSubjectName,
  1209. required this.purpose,
  1210. required this.projectId,
  1211. required this.projectName,
  1212. required this.costDeptId,
  1213. required this.costDeptName,
  1214. required this.startDate,
  1215. required this.endDate,
  1216. required this.estimatedAmount,
  1217. required this.remark,
  1218. });
  1219. }