expense_apply_create_page.dart 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_riverpod/flutter_riverpod.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:tdesign_flutter/tdesign_flutter.dart';
  6. import '../../core/i18n/app_localizations.dart';
  7. import '../../core/navigation/host_app_channel.dart';
  8. import '../../core/storage/draft_storage.dart';
  9. import '../../shared/widgets/action_bar.dart';
  10. import '../../shared/widgets/submitting_dialog.dart';
  11. import '../../shared/widgets/form_section.dart';
  12. import '../../shared/widgets/form_field_row.dart';
  13. import '../../shared/widgets/nav_bar_config.dart';
  14. import '../../shared/widgets/attachment_picker.dart';
  15. import '../../core/theme/app_colors.dart';
  16. import '../../core/theme/app_colors_extension.dart';
  17. import '../../core/constants/enums.dart';
  18. import '../../core/data/mock_api_data.dart';
  19. import 'expense_apply_api.dart';
  20. import 'widgets/expense_apply_detail_dialog.dart';
  21. class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
  22. final String? id;
  23. const ExpenseApplyCreatePage({super.key, this.id});
  24. @override
  25. ConsumerState<ExpenseApplyCreatePage> createState() =>
  26. _ExpenseApplyCreatePageState();
  27. }
  28. class _ExpenseApplyCreatePageState
  29. 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. // ── 草稿 ──
  47. late Future<bool> _draftFuture;
  48. bool _draftHandled = false;
  49. bool _isPoppingToNative = false;
  50. // ── 参考数据(从 API 加载) ──
  51. List<CostTypeItem> _costTypes = [];
  52. List<ProjectCodeItem> _projects = [];
  53. List<DepartmentItem> _departments = [];
  54. bool _refDataLoading = true;
  55. // ── 申请部门 ──
  56. String _selectedDeptId = '';
  57. String _selectedDeptName = '';
  58. @override
  59. void initState() {
  60. super.initState();
  61. WidgetsBinding.instance.addObserver(this);
  62. SystemChrome.setSystemUIOverlayStyle(
  63. const SystemUiOverlayStyle(
  64. statusBarColor: Colors.transparent,
  65. statusBarIconBrightness: Brightness.dark,
  66. ),
  67. );
  68. _attachmentController = AttachmentPickerController(maxCount: 9)
  69. ..addListener(() => setState(() {}));
  70. _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
  71. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  72. _draftFuture = DraftStorage.has(_draftKey);
  73. _loadRefData();
  74. }
  75. Future<void> _loadRefData() async {
  76. try {
  77. final api = ref.read(expenseApplyApiProvider);
  78. final results = await Future.wait([
  79. api.getCostTypes(),
  80. api.getProjectCodes(),
  81. api.getDepartments(),
  82. ]);
  83. if (!mounted) return;
  84. setState(() {
  85. _costTypes = results[0] as List<CostTypeItem>;
  86. _projects = results[1] as List<ProjectCodeItem>;
  87. _departments = results[2] as List<DepartmentItem>;
  88. _refDataLoading = false;
  89. // 自动匹配当前用户的部门
  90. _autoSelectDept();
  91. });
  92. } catch (_) {
  93. if (!mounted) return;
  94. setState(() => _refDataLoading = false);
  95. }
  96. }
  97. void _autoSelectDept() {
  98. if (_selectedDeptId.isNotEmpty) return; // 已选中则不覆盖
  99. final dep = HostAppChannel.dep;
  100. if (dep.isEmpty) return;
  101. final match = _departments.where((d) => d.dep == dep);
  102. if (match.isNotEmpty) {
  103. _selectedDeptId = match.first.dep;
  104. _selectedDeptName = match.first.name;
  105. }
  106. }
  107. void _ensureVisible(FocusNode node) {
  108. if (!node.hasFocus) return;
  109. WidgetsBinding.instance.addPostFrameCallback((_) {
  110. if (node.hasFocus && _scrollCtrl.hasClients) {
  111. final ctx = node.context;
  112. if (ctx != null) {
  113. Scrollable.ensureVisible(
  114. ctx,
  115. alignment: 0.3,
  116. duration: const Duration(milliseconds: 300),
  117. );
  118. }
  119. }
  120. });
  121. }
  122. @override
  123. void dispose() {
  124. WidgetsBinding.instance.removeObserver(this);
  125. _purposeController.dispose();
  126. _purposeFocus.dispose();
  127. _referenceNoController.dispose();
  128. _remarkController.dispose();
  129. _remarkFocus.dispose();
  130. _attachmentController.dispose();
  131. _scrollCtrl.dispose();
  132. super.dispose();
  133. }
  134. @override
  135. void didChangeAppLifecycleState(AppLifecycleState state) {
  136. if (state == AppLifecycleState.resumed && _isPoppingToNative) {
  137. _isPoppingToNative = false;
  138. HostAppChannel.refresh();
  139. setState(() {
  140. _draftHandled = false;
  141. _draftFuture = DraftStorage.has(_draftKey);
  142. _refDataLoading = true;
  143. });
  144. _loadRefData();
  145. }
  146. }
  147. @override
  148. Widget build(BuildContext context) {
  149. final l10n = AppLocalizations.of(context);
  150. ref
  151. .read(navBarConfigProvider.notifier)
  152. .update(
  153. NavBarConfig(
  154. title: l10n.get('expenseApplyRequest'),
  155. showBack: true,
  156. onBack: () => _doPop(),
  157. ),
  158. );
  159. return FutureBuilder<bool>(
  160. future: _draftFuture,
  161. builder: (ctx, snapshot) {
  162. final hasDraft = snapshot.hasData && snapshot.data == true;
  163. if (hasDraft && !_draftHandled) {
  164. _draftHandled = true;
  165. WidgetsBinding.instance.addPostFrameCallback((_) {
  166. if (mounted) _showDraftDialog();
  167. });
  168. }
  169. return PopScope(
  170. canPop: false,
  171. onPopInvokedWithResult: (didPop, _) {
  172. if (didPop) return;
  173. _doPop();
  174. },
  175. child: Column(
  176. children: [
  177. Expanded(
  178. child: GestureDetector(
  179. onTap: () => FocusScope.of(context).unfocus(),
  180. child: SingleChildScrollView(
  181. controller: _scrollCtrl,
  182. padding: const EdgeInsets.all(16),
  183. child: Column(
  184. children: [
  185. _buildBasicInfo(l10n),
  186. const SizedBox(height: 16),
  187. _buildDetailsSection(l10n),
  188. const SizedBox(height: 16),
  189. _buildAttachmentSection(l10n),
  190. const SizedBox(height: 80),
  191. ],
  192. ),
  193. ),
  194. ),
  195. ),
  196. _buildBottomBar(l10n),
  197. ],
  198. ),
  199. );
  200. },
  201. );
  202. }
  203. // ═══ 草稿持久化 ═══
  204. Future<void> _restoreDraft() async {
  205. final data = await DraftStorage.load(_draftKey);
  206. if (data == null) return;
  207. final attData = data['attachments'] as List<dynamic>?;
  208. if (attData != null) {
  209. await _attachmentController.restoreFromPaths(attData.cast<String>());
  210. }
  211. setState(() {
  212. _urgency = data['urgency'] as String? ?? Urgency.normal.value;
  213. _purposeController.text = data['purpose'] as String? ?? '';
  214. _validUntil = data['validUntil'] as String? ?? '';
  215. _referenceNoController.text = data['referenceNo'] as String? ?? '';
  216. _remarkController.text = data['remark'] as String? ?? '';
  217. _selectedDeptId = data['deptId'] as String? ?? '';
  218. _selectedDeptName = data['deptName'] as String? ?? '';
  219. _details.clear();
  220. final detailList = data['details'] as List<dynamic>?;
  221. if (detailList != null) {
  222. for (final d in detailList) {
  223. final m = d as Map<String, dynamic>;
  224. _details.add(_DetailItem(
  225. id: m['id'] as int? ?? _detailIdCounter++,
  226. category: m['category'] as String? ?? '',
  227. categoryName: m['categoryName'] as String? ?? '',
  228. acctSubjectId: m['acctSubjectId'] as String? ?? '',
  229. acctSubjectName: m['acctSubjectName'] as String? ?? '',
  230. purpose: m['purpose'] as String? ?? '',
  231. projectId: m['projectId'] as int? ?? 0,
  232. projectName: m['projectName'] as String? ?? '',
  233. costDeptId: m['costDeptId'] as String? ?? '',
  234. costDeptName: m['costDeptName'] as String? ?? '',
  235. startDate: m['startDate'] as String? ?? '',
  236. endDate: m['endDate'] as String? ?? '',
  237. estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
  238. remark: m['remark'] as String? ?? '',
  239. ));
  240. }
  241. }
  242. _detailIdCounter = _details.isEmpty ? 1 : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
  243. });
  244. }
  245. Future<void> _saveDraftToStorage() async {
  246. final detailList = _details.map((d) => {
  247. 'id': d.id,
  248. 'category': d.category,
  249. 'categoryName': d.categoryName,
  250. 'acctSubjectId': d.acctSubjectId,
  251. 'acctSubjectName': d.acctSubjectName,
  252. 'purpose': d.purpose,
  253. 'projectId': d.projectId,
  254. 'projectName': d.projectName,
  255. 'costDeptId': d.costDeptId,
  256. 'costDeptName': d.costDeptName,
  257. 'startDate': d.startDate,
  258. 'endDate': d.endDate,
  259. 'estimatedAmount': d.estimatedAmount,
  260. 'remark': d.remark,
  261. }).toList();
  262. await DraftStorage.save(_draftKey, {
  263. 'urgency': _urgency,
  264. 'purpose': _purposeController.text,
  265. 'deptId': _selectedDeptId,
  266. 'deptName': _selectedDeptName,
  267. 'validUntil': _validUntil,
  268. 'referenceNo': _referenceNoController.text,
  269. 'remark': _remarkController.text,
  270. 'attachments': _attachmentController.toPathList(),
  271. 'details': detailList,
  272. });
  273. }
  274. // ═══ 草稿弹窗 ═══
  275. // 使用 showDialog 而非内联渲染,确保 TDAlertDialog 获取正确的主题上下文
  276. void _showDraftDialog() {
  277. final l10n = AppLocalizations.of(context);
  278. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  279. showDialog(
  280. context: context,
  281. barrierDismissible: false,
  282. builder: (ctx) => TDAlertDialog(
  283. title: l10n.get('draftFound'),
  284. content: l10n.get('draftRestorePrompt'),
  285. leftBtn: TDDialogButtonOptions(
  286. title: l10n.get('discard'),
  287. titleColor: colors.textSecondary,
  288. action: () {
  289. Navigator.pop(ctx);
  290. DraftStorage.delete(_draftKey);
  291. },
  292. ),
  293. rightBtn: TDDialogButtonOptions(
  294. title: l10n.get('restore'),
  295. titleColor: colors.primary,
  296. action: () {
  297. Navigator.pop(ctx);
  298. _restoreDraft();
  299. },
  300. ),
  301. ),
  302. );
  303. }
  304. // ═══ 1. 基本信息 ═══
  305. Widget _buildBasicInfo(AppLocalizations l10n) {
  306. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  307. return FormSection(
  308. title: l10n.get('basicInfo'),
  309. leadingIcon: Icons.info_outline,
  310. children: [
  311. FormFieldRow(
  312. label: l10n.get('date'),
  313. value: _today(),
  314. readOnly: true,
  315. showArrow: false,
  316. ),
  317. const SizedBox(height: 16),
  318. FormFieldRow(
  319. label: l10n.get('applicant'),
  320. value: HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
  321. ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
  322. : '--',
  323. readOnly: true,
  324. showArrow: false,
  325. ),
  326. const SizedBox(height: 16),
  327. FormFieldRow(
  328. label: l10n.get('applyDept'),
  329. value: _selectedDeptName,
  330. hint: l10n.get('pleaseSelect'),
  331. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  332. ),
  333. const SizedBox(height: 16),
  334. _label(l10n.get('emergencyLevel'), required: true),
  335. const SizedBox(height: 8),
  336. _buildUrgencyRadio(l10n),
  337. const SizedBox(height: 16),
  338. _label(l10n.get('applyReason'), required: true),
  339. const SizedBox(height: 8),
  340. TDTextarea(
  341. controller: _purposeController,
  342. focusNode: _purposeFocus,
  343. hintText: l10n.get('enterApplyReason'),
  344. maxLines: 4,
  345. minLines: 1,
  346. maxLength: 500,
  347. indicator: true,
  348. padding: EdgeInsets.zero,
  349. bordered: true,
  350. backgroundColor: colors.bgPage,
  351. ),
  352. // TODO: 暂不支持录入,后续开放
  353. // const SizedBox(height: 16),
  354. // FormFieldRow(
  355. // label: l10n.get('validUntil'),
  356. // value: _validUntil,
  357. // hint: l10n.get('pleaseSelect'),
  358. // onTap: () => _pickDate((d) => setState(() => _validUntil = d)),
  359. // ),
  360. // const SizedBox(height: 16),
  361. // FormFieldRow(
  362. // label: l10n.get('relatedContractNo'),
  363. // value: _referenceNoController.text,
  364. // hint: l10n.get('optional'),
  365. // onTap: () => _showTextInput(
  366. // l10n.get('relatedContractNo'),
  367. // (v) => setState(() {
  368. // _referenceNoController.text = v;
  369. // _referenceNoController.selection = TextSelection.fromPosition(
  370. // TextPosition(offset: v.length),
  371. // );
  372. // }),
  373. // initialText: _referenceNoController.text,
  374. // ),
  375. // ),
  376. const SizedBox(height: 16),
  377. _label(l10n.get('remark')),
  378. const SizedBox(height: 8),
  379. TDTextarea(
  380. controller: _remarkController,
  381. focusNode: _remarkFocus,
  382. hintText: l10n.get('enterRemark'),
  383. maxLines: 3,
  384. minLines: 1,
  385. maxLength: 500,
  386. indicator: true,
  387. padding: EdgeInsets.zero,
  388. bordered: true,
  389. backgroundColor: colors.bgPage,
  390. ),
  391. ],
  392. );
  393. }
  394. Widget _buildUrgencyRadio(AppLocalizations l10n) {
  395. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  396. return Row(
  397. children: Urgency.values.asMap().entries.map((e) {
  398. final sel = _urgency == e.value.value;
  399. final isCritical = e.value.value == Urgency.critical.value;
  400. final activeColor = isCritical ? colors.danger : colors.primary;
  401. return Padding(
  402. padding: EdgeInsets.only(right: e.key < 2 ? 24 : 0),
  403. child: GestureDetector(
  404. behavior: HitTestBehavior.opaque,
  405. onTap: () => setState(() => _urgency = e.value.value),
  406. child: Row(
  407. mainAxisSize: MainAxisSize.min,
  408. children: [
  409. Container(
  410. width: 18,
  411. height: 18,
  412. decoration: BoxDecoration(
  413. shape: BoxShape.circle,
  414. border: Border.all(
  415. color: sel ? activeColor : colors.textPlaceholder,
  416. width: 2,
  417. ),
  418. ),
  419. child: sel
  420. ? Center(
  421. child: Container(
  422. width: 8,
  423. height: 8,
  424. decoration: BoxDecoration(
  425. shape: BoxShape.circle,
  426. color: activeColor,
  427. ),
  428. ),
  429. )
  430. : null,
  431. ),
  432. const SizedBox(width: 6),
  433. Text(
  434. l10n.get(e.value.labelKey),
  435. style: TextStyle(
  436. fontSize: AppFontSizes.subtitle,
  437. color: sel ? activeColor : colors.textPrimary,
  438. ),
  439. ),
  440. ],
  441. ),
  442. ),
  443. );
  444. }).toList(),
  445. );
  446. }
  447. // ═══ 2. 费用明细 ═══
  448. Widget _buildDetailsSection(AppLocalizations l10n) {
  449. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  450. return FormSection(
  451. title: l10n.get('expenseDetails'),
  452. leadingIcon: Icons.receipt_long_outlined,
  453. showAction: !_refDataLoading,
  454. actionText: l10n.get('add'),
  455. onActionTap: _showDetailDialog,
  456. children: [
  457. if (_details.isEmpty)
  458. Padding(
  459. padding: const EdgeInsets.symmetric(vertical: 8),
  460. child: Text(
  461. l10n.get('noDetailHint'),
  462. style: TextStyle(
  463. fontSize: AppFontSizes.subtitle,
  464. color: colors.textPlaceholder,
  465. ),
  466. ),
  467. )
  468. else
  469. ..._details.asMap().entries.map((e) {
  470. final d = e.value;
  471. return GestureDetector(
  472. onTap: () => _showDetailDialog(editIndex: e.key),
  473. child: Container(
  474. margin: const EdgeInsets.symmetric(vertical: 8),
  475. padding: const EdgeInsets.all(12),
  476. decoration: BoxDecoration(
  477. color: colors.bgPage,
  478. borderRadius: BorderRadius.circular(8),
  479. ),
  480. child: Row(
  481. children: [
  482. Expanded(
  483. flex: 3,
  484. child: Column(
  485. crossAxisAlignment: CrossAxisAlignment.start,
  486. children: [
  487. Text(
  488. d.categoryName,
  489. style: TextStyle(
  490. fontSize: AppFontSizes.subtitle,
  491. color: colors.textPrimary,
  492. ),
  493. ),
  494. if (d.purpose.isNotEmpty)
  495. Text(
  496. d.purpose,
  497. maxLines: 2,
  498. overflow: TextOverflow.ellipsis,
  499. style: TextStyle(
  500. fontSize: AppFontSizes.caption,
  501. color: colors.textPrimary,
  502. ),
  503. ),
  504. const SizedBox(height: 4),
  505. Text(
  506. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  507. style: TextStyle(
  508. fontSize: AppFontSizes.caption,
  509. fontWeight: FontWeight.w600,
  510. color: colors.amountPrimary,
  511. ),
  512. ),
  513. if (d.projectName.isNotEmpty)
  514. Text(
  515. '${d.projectName} | ${d.acctSubjectName}',
  516. style: TextStyle(
  517. fontSize: AppFontSizes.caption,
  518. color: colors.textSecondary,
  519. ),
  520. ),
  521. if (d.remark.isNotEmpty)
  522. Text(
  523. d.remark,
  524. maxLines: 1,
  525. overflow: TextOverflow.ellipsis,
  526. style: TextStyle(
  527. fontSize: AppFontSizes.caption,
  528. color: colors.textPlaceholder,
  529. ),
  530. ),
  531. ],
  532. ),
  533. ),
  534. GestureDetector(
  535. onTap: () => setState(() => _details.removeAt(e.key)),
  536. child: Container(
  537. width: 24,
  538. height: 24,
  539. decoration: BoxDecoration(
  540. color: colors.primaryLight,
  541. shape: BoxShape.circle,
  542. ),
  543. child: Icon(
  544. Icons.close,
  545. size: 14,
  546. color: colors.primary700,
  547. ),
  548. ),
  549. ),
  550. ],
  551. ),
  552. ),
  553. );
  554. }),
  555. const SizedBox(height: 8),
  556. Container(
  557. height: 36,
  558. padding: const EdgeInsets.symmetric(vertical: 8),
  559. child: Row(
  560. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  561. children: [
  562. Text(
  563. l10n.get('total'),
  564. style: TextStyle(
  565. fontSize: AppFontSizes.body,
  566. fontWeight: FontWeight.w600,
  567. color: colors.textPrimary,
  568. ),
  569. ),
  570. Text(
  571. '¥${_totalAmount().toStringAsFixed(2)}',
  572. style: TextStyle(
  573. fontSize: AppFontSizes.subtitle,
  574. fontWeight: FontWeight.w700,
  575. color: colors.amountPrimary,
  576. ),
  577. ),
  578. ],
  579. ),
  580. ),
  581. ],
  582. );
  583. }
  584. double _totalAmount() =>
  585. _details.fold(0, (s, d) => s + d.estimatedAmount);
  586. Future<void> _showDetailDialog({int? editIndex}) async {
  587. final l10n = AppLocalizations.of(context);
  588. if (_costTypes.isEmpty) {
  589. TDToast.showText(l10n.get('noCostTypeData'), context: context);
  590. return;
  591. }
  592. ExpenseDetailData? initialData;
  593. if (editIndex != null) {
  594. final d = _details[editIndex];
  595. initialData = ExpenseDetailData(
  596. category: d.category,
  597. categoryName: d.categoryName,
  598. acctSubjectId: d.acctSubjectId,
  599. acctSubjectName: d.acctSubjectName,
  600. purpose: d.purpose,
  601. projectId: d.projectId,
  602. projectName: d.projectName,
  603. costDeptId: d.costDeptId,
  604. costDeptName: d.costDeptName,
  605. startDate: d.startDate,
  606. endDate: d.endDate,
  607. estimatedAmount: d.estimatedAmount,
  608. remark: d.remark,
  609. );
  610. }
  611. final result = await ExpenseApplyDetailDialog.show(
  612. context,
  613. categories: _dialogCategories,
  614. projects: _dialogProjects,
  615. costDepts: _dialogCostDepts,
  616. l10n: l10n,
  617. initialData: initialData,
  618. );
  619. if (result != null && mounted) {
  620. setState(() {
  621. final item = _DetailItem(
  622. id: editIndex != null ? _details[editIndex].id : _detailIdCounter++,
  623. category: result.category,
  624. categoryName: result.categoryName,
  625. acctSubjectId: result.acctSubjectId,
  626. acctSubjectName: result.acctSubjectName,
  627. purpose: result.purpose,
  628. projectId: result.projectId,
  629. projectName: result.projectName,
  630. costDeptId: result.costDeptId,
  631. costDeptName: result.costDeptName,
  632. startDate: result.startDate,
  633. endDate: result.endDate,
  634. estimatedAmount: result.estimatedAmount,
  635. remark: result.remark,
  636. );
  637. if (editIndex != null) {
  638. _details[editIndex] = item;
  639. } else {
  640. _details.add(item);
  641. }
  642. });
  643. }
  644. }
  645. // ═══ 3. 附件上传 ═══
  646. Widget _buildAttachmentSection(AppLocalizations l10n) {
  647. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  648. return FormSection(
  649. title: l10n.get('attachmentUpload'),
  650. leadingIcon: Icons.attach_file_outlined,
  651. children: [
  652. Text(
  653. l10n.get('maxAttachment'),
  654. style: TextStyle(
  655. fontSize: AppFontSizes.caption,
  656. color: colors.textPlaceholder,
  657. ),
  658. ),
  659. const SizedBox(height: 8),
  660. AttachmentPicker(
  661. controller: _attachmentController,
  662. maxImageSizeMB: 10,
  663. maxFileSizeMB: 20,
  664. allowedExtensions: const [
  665. 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt',
  666. ],
  667. onFileRejected: (file, reason) {
  668. if (context.mounted) {
  669. TDToast.showText(reason, context: context);
  670. }
  671. },
  672. ),
  673. ],
  674. );
  675. }
  676. void _showDeptPicker() {
  677. if (_departments.isEmpty) {
  678. TDToast.showText(AppLocalizations.of(context).get('noData'), context: context);
  679. return;
  680. }
  681. final l10n = AppLocalizations.of(context);
  682. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  683. final labels = _departments.map((d) => d.name).toList();
  684. TDPicker.showMultiPicker(
  685. context,
  686. title: l10n.get('applyDept'),
  687. backgroundColor: colors.bgCard,
  688. data: [labels],
  689. onConfirm: (selected) {
  690. if (selected.isNotEmpty && selected[0] is int) {
  691. final idx = selected[0] as int;
  692. if (idx >= 0 && idx < labels.length) {
  693. Navigator.of(context).pop();
  694. setState(() {
  695. _selectedDeptId = _departments[idx].dep;
  696. _selectedDeptName = _departments[idx].name;
  697. });
  698. }
  699. }
  700. },
  701. );
  702. }
  703. // ═══ API 数据 → 弹窗类型转换 ═══
  704. List<CostCategory> get _dialogCategories => _costTypes
  705. .map((c) => CostCategory(
  706. code: c.typeNo,
  707. nameKey: c.typeName,
  708. acctSubjectId: c.accNo,
  709. acctSubjectName: c.accName,
  710. ))
  711. .toList();
  712. List<Project> get _dialogProjects => _projects
  713. .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
  714. .toList();
  715. List<CostDept> get _dialogCostDepts => _departments
  716. .map((d) => CostDept(id: d.dep, name: d.name))
  717. .toList();
  718. // ═══ 4. 底部操作栏 ═══
  719. Widget _buildBottomBar(AppLocalizations l10n) {
  720. return ActionBar(
  721. showLeft: false,
  722. centerLabel: l10n.get('saveDraft'),
  723. rightLabel: l10n.get('submit'),
  724. centerTextOnly: true,
  725. onCenterTap: () async {
  726. try {
  727. await _saveDraftToStorage();
  728. if (mounted) _forcePop();
  729. } catch (_) {
  730. if (mounted) {
  731. TDToast.showFail(l10n.get('saveFailed'), context: context);
  732. }
  733. }
  734. },
  735. onRightTap: () async {
  736. final err = _validate(l10n);
  737. if (err.isNotEmpty) {
  738. TDToast.showText(err.first, context: context);
  739. return;
  740. }
  741. SubmittingDialog.show(context);
  742. try {
  743. final data = _buildSubmitData();
  744. await ref.read(expenseApplyApiProvider).submit(data);
  745. await DraftStorage.delete(_draftKey);
  746. if (mounted) {
  747. SubmittingDialog.hide(context);
  748. TDToast.showSuccess(l10n.get('submittedAwaitingApproval'), context: context);
  749. GoRouter.of(context).go('/expense-apply/list');
  750. }
  751. } catch (_) {
  752. if (mounted) {
  753. SubmittingDialog.hide(context);
  754. TDToast.showFail(l10n.get('submitFailedRetry'), context: context);
  755. }
  756. }
  757. },
  758. );
  759. }
  760. Map<String, dynamic> _buildSubmitData() {
  761. // 紧急程度映射:normal→1, urgent→2, critical→3
  762. String priority;
  763. switch (_urgency) {
  764. case 'urgent':
  765. priority = '2';
  766. break;
  767. case 'critical':
  768. priority = '3';
  769. break;
  770. default:
  771. priority = '1';
  772. }
  773. return {
  774. 'HeadData': {
  775. 'AE_DD': _today(),
  776. 'PRIORITY': priority,
  777. 'AMTN_YJ': _totalAmount(),
  778. 'REASON': _purposeController.text.trim(),
  779. 'REM': _remarkController.text,
  780. 'DEP': _selectedDeptId,
  781. 'USR': HostAppChannel.usr,
  782. },
  783. 'BodyData1': _details.asMap().entries.map((e) {
  784. final i = e.key;
  785. final d = e.value;
  786. return {
  787. 'ITM': i + 1,
  788. 'SQ_MAN': HostAppChannel.usr,
  789. 'TYPE_NO': d.category,
  790. 'AMTN_YJ': d.estimatedAmount,
  791. 'ACC_NO': d.acctSubjectId,
  792. 'ACC_NAME': d.acctSubjectName,
  793. 'DEP': d.costDeptId,
  794. 'OBJ_NO': d.projectId > 0 ? d.projectId.toString() : '',
  795. 'START_DD': d.startDate,
  796. 'END_DD': d.endDate,
  797. 'REM': d.remark.isNotEmpty ? d.remark : d.purpose,
  798. };
  799. }).toList(),
  800. };
  801. }
  802. List<String> _validate(AppLocalizations l10n) {
  803. final e = <String>[];
  804. if (_purposeController.text.trim().isEmpty) {
  805. e.add(l10n.get('enterApplyReason'));
  806. }
  807. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
  808. return e;
  809. }
  810. void _doPop() {
  811. if (_hasUnsaved()) {
  812. final l10n = AppLocalizations.of(context);
  813. _showConfirmDialog(
  814. l10n.get('confirmExit'),
  815. l10n.get('unsavedContentWarning'),
  816. l10n.get('continueEditing'),
  817. l10n.get('discardAndExit'),
  818. () async {
  819. await DraftStorage.delete(_draftKey);
  820. if (!mounted) return;
  821. setState(() => _clearLocalState());
  822. _forcePop();
  823. },
  824. );
  825. } else {
  826. _forcePop();
  827. }
  828. }
  829. void _forcePop() {
  830. _isPoppingToNative = true;
  831. SystemNavigator.pop();
  832. }
  833. bool _hasUnsaved() =>
  834. _purposeController.text.isNotEmpty ||
  835. _details.isNotEmpty ||
  836. _attachmentController.files.isNotEmpty ||
  837. _referenceNoController.text.isNotEmpty ||
  838. _remarkController.text.isNotEmpty ||
  839. _urgency != Urgency.normal.value ||
  840. _validUntil.isNotEmpty ||
  841. _selectedDeptId.isNotEmpty;
  842. void _clearLocalState() {
  843. _urgency = Urgency.normal.value;
  844. _purposeController.clear();
  845. _validUntil = '';
  846. _referenceNoController.clear();
  847. _remarkController.clear();
  848. _details.clear();
  849. _detailIdCounter = 1;
  850. _attachmentController.clear();
  851. _selectedDeptId = '';
  852. _selectedDeptName = '';
  853. }
  854. void _unfocus() => FocusScope.of(context).unfocus();
  855. // ═══ 通用弹窗方法 ═══
  856. void _showConfirmDialog(
  857. String title,
  858. String content,
  859. String leftText,
  860. String rightText,
  861. VoidCallback onConfirm,
  862. ) {
  863. _unfocus();
  864. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  865. showDialog(
  866. context: context,
  867. useRootNavigator: true,
  868. builder: (ctx) => TDAlertDialog(
  869. title: title,
  870. content: content,
  871. buttonStyle: TDDialogButtonStyle.text,
  872. leftBtn: TDDialogButtonOptions(
  873. title: leftText,
  874. titleColor: colors.primary,
  875. action: () => Navigator.pop(ctx),
  876. ),
  877. rightBtn: TDDialogButtonOptions(
  878. title: rightText,
  879. titleColor: colors.danger,
  880. action: () {
  881. Navigator.pop(ctx);
  882. onConfirm();
  883. },
  884. ),
  885. ),
  886. );
  887. }
  888. // TODO: 有效期至 / 关联合同号 暂不支持,方法暂时注释
  889. // void _showTextInput(
  890. // String title,
  891. // Function(String) onConfirm, {
  892. // String initialText = '',
  893. // }) {
  894. // ...
  895. // }
  896. // void _pickDate(Function(String) onPick) {
  897. // ...
  898. // }
  899. Widget _label(String t, {bool required = false}) {
  900. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  901. return Text.rich(
  902. TextSpan(
  903. children: [
  904. TextSpan(
  905. text: t,
  906. style: TextStyle(
  907. fontSize: AppFontSizes.subtitle,
  908. color: colors.textSecondary,
  909. ),
  910. ),
  911. if (required)
  912. TextSpan(
  913. text: ' *',
  914. style: TextStyle(
  915. fontSize: AppFontSizes.subtitle,
  916. color: colors.danger,
  917. ),
  918. ),
  919. ],
  920. ),
  921. );
  922. }
  923. String _today() {
  924. final n = DateTime.now();
  925. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  926. }
  927. }
  928. class _DetailItem {
  929. final int id;
  930. final String category;
  931. final String categoryName;
  932. final String acctSubjectId;
  933. final String acctSubjectName;
  934. final String purpose;
  935. final int projectId;
  936. final String projectName;
  937. final String costDeptId;
  938. final String costDeptName;
  939. final String startDate;
  940. final String endDate;
  941. final double estimatedAmount;
  942. final String remark;
  943. const _DetailItem({
  944. required this.id,
  945. required this.category,
  946. required this.categoryName,
  947. required this.acctSubjectId,
  948. required this.acctSubjectName,
  949. required this.purpose,
  950. required this.projectId,
  951. required this.projectName,
  952. required this.costDeptId,
  953. required this.costDeptName,
  954. required this.startDate,
  955. required this.endDate,
  956. required this.estimatedAmount,
  957. required this.remark,
  958. });
  959. }