expense_apply_edit_page.dart 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  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 'package:dio/dio.dart';
  10. import '../../core/network/api_exception.dart';
  11. import '../../shared/widgets/action_bar.dart';
  12. import '../../shared/widgets/loading_dialog.dart';
  13. import '../../shared/widgets/form_section.dart';
  14. import '../../shared/widgets/form_field_row.dart';
  15. import '../../shared/widgets/app_skeletons.dart';
  16. import '../../shared/widgets/nav_bar_config.dart';
  17. import '../../core/theme/app_colors.dart';
  18. import '../../core/theme/app_colors_extension.dart';
  19. import '../../core/constants/enums.dart';
  20. import '../../core/data/mock_api_data.dart';
  21. import 'expense_apply_api.dart';
  22. import 'widgets/expense_apply_detail_dialog.dart';
  23. class ExpenseApplyEditPage extends ConsumerStatefulWidget {
  24. final String billNo;
  25. const ExpenseApplyEditPage({super.key, required this.billNo});
  26. @override
  27. ConsumerState<ExpenseApplyEditPage> createState() =>
  28. _ExpenseApplyEditPageState();
  29. }
  30. class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
  31. // ── 原单数据 ──
  32. String _billNo = '';
  33. String _applyDate = '';
  34. // ── 基本信息 ──
  35. String _urgency = Urgency.normal.value;
  36. final _purposeController = TextEditingController();
  37. final _purposeFocus = FocusNode();
  38. final _remarkController = TextEditingController();
  39. final _remarkFocus = FocusNode();
  40. final _scrollCtrl = ScrollController();
  41. // ── 费用明细 ──
  42. final List<_DetailItem> _details = [];
  43. int _detailIdCounter = 1;
  44. // ── 参考数据(从 API 加载) ──
  45. List<CostTypeItem> _costTypes = [];
  46. List<ProjectCodeItem> _projects = [];
  47. List<DepartmentItem> _departments = [];
  48. List<EmployeeItem> _employees = [];
  49. EmployeeItem? _selEmployee;
  50. bool _firstBuild = true;
  51. bool _refDataLoading = true;
  52. bool _loadingBill = true;
  53. String? _loadingError;
  54. bool _addingDetail = false;
  55. dynamic _acctTree;
  56. // ── 申请部门 ──
  57. String _selectedDeptId = '';
  58. String _selectedDeptName = '';
  59. @override
  60. void initState() {
  61. super.initState();
  62. SystemChrome.setSystemUIOverlayStyle(
  63. const SystemUiOverlayStyle(
  64. statusBarColor: Colors.transparent,
  65. statusBarIconBrightness: Brightness.dark,
  66. ),
  67. );
  68. _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
  69. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  70. _costTypes = [];
  71. _projects = [];
  72. _departments = [];
  73. _acctTree = null;
  74. _refDataLoading = true;
  75. _loadingBill = true;
  76. _loadRefData();
  77. _loadBillData();
  78. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  79. }
  80. void _checkDataReady() {
  81. if (!_refDataLoading && !_loadingBill && mounted) {
  82. setState(() => _firstBuild = false);
  83. WidgetsBinding.instance.addPostFrameCallback((_) {
  84. if (mounted) setState(() {});
  85. });
  86. } else if (mounted) {
  87. WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady());
  88. }
  89. }
  90. Future<void>? _refDataFuture;
  91. Future<void> _loadRefData({bool showLoading = false}) async {
  92. if (_refDataFuture != null) return _refDataFuture!;
  93. final completer = Completer<void>();
  94. _refDataFuture = completer.future;
  95. if (showLoading) {
  96. LoadingDialog.show(
  97. context,
  98. text: AppLocalizations.of(context).get('dataLoading'),
  99. );
  100. }
  101. try {
  102. final api = ref.read(expenseApplyApiProvider);
  103. final results = await Future.wait([
  104. api.getCostTypes(),
  105. api.getProjectCodes(),
  106. api.getDepartments(),
  107. api.getAcctSubjects(),
  108. api.getEmployees(),
  109. ]);
  110. if (!mounted) return;
  111. setState(() {
  112. _costTypes = results[0] as List<CostTypeItem>;
  113. _projects = results[1] as List<ProjectCodeItem>;
  114. _departments = results[2] as List<DepartmentItem>;
  115. _acctTree = _convertAcctTree(results[3]);
  116. _employees = results[4] as List<EmployeeItem>;
  117. _refDataLoading = false;
  118. _autoSelectEmployee();
  119. });
  120. completer.complete();
  121. } catch (_) {
  122. if (!mounted) {
  123. completer.complete();
  124. return;
  125. }
  126. setState(() => _refDataLoading = false);
  127. completer.complete();
  128. } finally {
  129. if (showLoading && mounted) LoadingDialog.hide(context);
  130. _refDataFuture = null;
  131. }
  132. }
  133. void _autoSelectEmployee() {
  134. if (_selEmployee != null) return;
  135. final usr = HostAppChannel.usr;
  136. if (usr.isEmpty || _employees.isEmpty) return;
  137. final match = _employees.where((e) => e.salNo == usr);
  138. if (match.isNotEmpty) {
  139. setState(() => _selEmployee = match.first);
  140. }
  141. }
  142. void _showEmployeePicker() {
  143. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  144. final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
  145. TDPicker.showMultiPicker(
  146. context,
  147. title: AppLocalizations.of(context).get('applicant'),
  148. backgroundColor: colors.bgCard,
  149. data: [labels],
  150. onConfirm: (selected) {
  151. if (selected.isNotEmpty && selected[0] is int) {
  152. final idx = selected[0] as int;
  153. if (idx >= 0 && idx < labels.length) {
  154. Navigator.of(context).pop();
  155. setState(() => _selEmployee = _employees[idx]);
  156. }
  157. }
  158. },
  159. );
  160. }
  161. Future<void> _loadBillData() async {
  162. try {
  163. final api = ref.read(expenseApplyApiProvider);
  164. final detail = await api.fetchDetail(widget.billNo);
  165. if (!mounted) return;
  166. final n = detail.createTime;
  167. final applyDate =
  168. '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  169. setState(() {
  170. _billNo = detail.expenseApplyNo;
  171. _applyDate = applyDate;
  172. _selectedDeptId = detail.deptId;
  173. _selectedDeptName = detail.deptName;
  174. // 紧急程度映射(兼容代码值 '1'/'2'/'3' 和文字值)
  175. final urg = detail.urgency;
  176. if (urg == '3' || urg == 'critical') {
  177. _urgency = Urgency.critical.value;
  178. } else if (urg == '2' || urg == 'urgent') {
  179. _urgency = Urgency.urgent.value;
  180. } else {
  181. _urgency = Urgency.normal.value;
  182. }
  183. _purposeController.text = detail.purpose;
  184. _remarkController.text = detail.remark;
  185. // 费用明细回填
  186. _details.clear();
  187. _detailIdCounter = 1;
  188. for (final d in detail.details) {
  189. String startDateStr = '';
  190. String endDateStr = '';
  191. if (d.estimatedStartDate != null) {
  192. final s = d.estimatedStartDate!;
  193. startDateStr =
  194. '${s.year}-${s.month.toString().padLeft(2, '0')}-${s.day.toString().padLeft(2, '0')}';
  195. }
  196. if (d.estimatedEndDate != null) {
  197. final e = d.estimatedEndDate!;
  198. endDateStr =
  199. '${e.year}-${e.month.toString().padLeft(2, '0')}-${e.day.toString().padLeft(2, '0')}';
  200. }
  201. _details.add(_DetailItem(
  202. id: _detailIdCounter++,
  203. category: d.expenseCategory,
  204. categoryName: d.categoryName,
  205. acctSubjectId: d.acctSubjectId,
  206. acctSubjectName: d.acctSubjectName,
  207. purpose: d.purpose,
  208. projectId: d.projectId,
  209. projectName: d.projectName,
  210. costDeptId: d.costDeptId,
  211. costDeptName: d.costDeptName,
  212. startDate: startDateStr,
  213. endDate: endDateStr,
  214. estimatedAmount: d.estimatedAmount,
  215. remark: d.remark,
  216. preItm: d.preItm,
  217. ));
  218. }
  219. _loadingBill = false;
  220. });
  221. } catch (e) {
  222. if (!mounted) return;
  223. setState(() {
  224. _loadingBill = false;
  225. _loadingError = e.toString();
  226. });
  227. }
  228. }
  229. void _ensureVisible(FocusNode node) {
  230. if (!node.hasFocus) return;
  231. WidgetsBinding.instance.addPostFrameCallback((_) {
  232. if (node.hasFocus && _scrollCtrl.hasClients) {
  233. final ctx = node.context;
  234. if (ctx != null) {
  235. Scrollable.ensureVisible(
  236. ctx,
  237. alignment: 0.3,
  238. duration: const Duration(milliseconds: 300),
  239. );
  240. }
  241. }
  242. });
  243. }
  244. @override
  245. void dispose() {
  246. _purposeController.dispose();
  247. _purposeFocus.dispose();
  248. _remarkController.dispose();
  249. _remarkFocus.dispose();
  250. _scrollCtrl.dispose();
  251. super.dispose();
  252. }
  253. @override
  254. Widget build(BuildContext context) {
  255. final l10n = AppLocalizations.of(context);
  256. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  257. if (_loadingError != null) {
  258. return Center(
  259. child: Column(
  260. mainAxisSize: MainAxisSize.min,
  261. children: [
  262. Icon(Icons.error_outline, size: 48, color: colors.danger),
  263. const SizedBox(height: 16),
  264. Padding(
  265. padding: const EdgeInsets.symmetric(horizontal: 32),
  266. child: Text(
  267. _loadingError!,
  268. textAlign: TextAlign.center,
  269. style: TextStyle(
  270. fontSize: AppFontSizes.body,
  271. color: colors.textSecondary,
  272. ),
  273. ),
  274. ),
  275. const SizedBox(height: 16),
  276. TDButton(
  277. text: l10n.get('retry'),
  278. size: TDButtonSize.medium,
  279. onTap: () {
  280. setState(() {
  281. _loadingError = null;
  282. _loadingBill = true;
  283. });
  284. _loadBillData();
  285. },
  286. ),
  287. ],
  288. ),
  289. );
  290. }
  291. if (_firstBuild) {
  292. return const SkeletonFormPage();
  293. }
  294. Future.microtask(
  295. () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
  296. );
  297. return PopScope(
  298. canPop: false,
  299. onPopInvokedWithResult: (didPop, _) {
  300. if (didPop) return;
  301. _doPop();
  302. },
  303. child: Column(
  304. children: [
  305. Expanded(
  306. child: GestureDetector(
  307. onTap: () => FocusScope.of(context).unfocus(),
  308. child: SingleChildScrollView(
  309. controller: _scrollCtrl,
  310. padding: const EdgeInsets.all(16),
  311. child: Column(
  312. children: [
  313. _buildBasicInfo(l10n),
  314. const SizedBox(height: 16),
  315. _buildDetailsSection(l10n),
  316. const SizedBox(height: 24),
  317. _buildPageFooter(),
  318. ],
  319. ),
  320. ),
  321. ),
  322. ),
  323. _buildBottomBar(l10n),
  324. ],
  325. ),
  326. );
  327. }
  328. // ═══ 1. 基本信息 ═══
  329. Widget _buildBasicInfo(AppLocalizations l10n) {
  330. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  331. return FormSection(
  332. title: l10n.get('basicInfo'),
  333. leadingIcon: Icons.info_outline,
  334. children: [
  335. FormFieldRow(
  336. label: l10n.get('expenseApplyNo'),
  337. value: _billNo,
  338. readOnly: true,
  339. showArrow: false,
  340. ),
  341. const SizedBox(height: 16),
  342. FormFieldRow(
  343. label: l10n.get('date'),
  344. value: _applyDate,
  345. readOnly: true,
  346. showArrow: false,
  347. ),
  348. const SizedBox(height: 16),
  349. FormFieldRow(
  350. label: l10n.get('applicant'),
  351. value: _selEmployee != null
  352. ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
  353. : '',
  354. hint: l10n.get('pleaseSelect'),
  355. onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
  356. ),
  357. const SizedBox(height: 16),
  358. FormFieldRow(
  359. label: l10n.get('applyDept'),
  360. value: _selectedDeptId.isNotEmpty
  361. ? '$_selectedDeptId/$_selectedDeptName'
  362. : '',
  363. hint: l10n.get('pleaseSelect'),
  364. onTap: _refDataLoading ? null : () => _showDeptPicker(),
  365. ),
  366. const SizedBox(height: 16),
  367. _buildUrgencyRow(l10n),
  368. const SizedBox(height: 16),
  369. _label(l10n.get('applyReason'), required: true),
  370. const SizedBox(height: 8),
  371. TDTextarea(
  372. controller: _purposeController,
  373. focusNode: _purposeFocus,
  374. hintText: l10n.get('enterApplyReason'),
  375. maxLines: 4,
  376. minLines: 1,
  377. maxLength: 500,
  378. indicator: true,
  379. padding: EdgeInsets.zero,
  380. bordered: true,
  381. backgroundColor: colors.bgPage,
  382. ),
  383. const SizedBox(height: 16),
  384. _label(l10n.get('remark')),
  385. const SizedBox(height: 8),
  386. TDTextarea(
  387. controller: _remarkController,
  388. focusNode: _remarkFocus,
  389. hintText: l10n.get('enterRemark'),
  390. maxLines: 3,
  391. minLines: 1,
  392. maxLength: 500,
  393. indicator: true,
  394. padding: EdgeInsets.zero,
  395. bordered: true,
  396. backgroundColor: colors.bgPage,
  397. ),
  398. ],
  399. );
  400. }
  401. Widget _buildUrgencyRow(AppLocalizations l10n) {
  402. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  403. return Row(
  404. children: [
  405. Text.rich(
  406. TextSpan(
  407. children: [
  408. TextSpan(
  409. text: l10n.get('emergencyLevel'),
  410. style: TextStyle(
  411. fontSize: AppFontSizes.subtitle,
  412. color: colors.textSecondary,
  413. ),
  414. ),
  415. TextSpan(
  416. text: ' *',
  417. style: TextStyle(
  418. fontSize: AppFontSizes.subtitle,
  419. color: colors.danger,
  420. ),
  421. ),
  422. ],
  423. ),
  424. ),
  425. const Spacer(),
  426. Row(
  427. mainAxisSize: MainAxisSize.min,
  428. children: Urgency.values.asMap().entries.map((e) {
  429. final sel = _urgency == e.value.value;
  430. final isCritical = e.value.value == Urgency.critical.value;
  431. final isUrgent = e.value.value == Urgency.urgent.value;
  432. final activeColor = isCritical
  433. ? colors.danger
  434. : isUrgent
  435. ? colors.warning
  436. : colors.primary;
  437. return Padding(
  438. padding: EdgeInsets.only(left: e.key > 0 ? 18 : 0),
  439. child: GestureDetector(
  440. behavior: HitTestBehavior.opaque,
  441. onTap: () => setState(() => _urgency = e.value.value),
  442. child: Row(
  443. mainAxisSize: MainAxisSize.min,
  444. children: [
  445. Container(
  446. width: 18,
  447. height: 18,
  448. decoration: BoxDecoration(
  449. shape: BoxShape.circle,
  450. border: Border.all(
  451. color: sel ? activeColor : colors.textPlaceholder,
  452. width: 2,
  453. ),
  454. ),
  455. child: sel
  456. ? Center(
  457. child: Container(
  458. width: 8,
  459. height: 8,
  460. decoration: BoxDecoration(
  461. shape: BoxShape.circle,
  462. color: activeColor,
  463. ),
  464. ),
  465. )
  466. : null,
  467. ),
  468. const SizedBox(width: 5),
  469. Text(
  470. l10n.get(e.value.labelKey),
  471. style: TextStyle(
  472. fontSize: AppFontSizes.subtitle,
  473. color: sel ? activeColor : colors.textPrimary,
  474. ),
  475. ),
  476. ],
  477. ),
  478. ),
  479. );
  480. }).toList(),
  481. ),
  482. ],
  483. );
  484. }
  485. // ═══ 2. 费用明细 ═══
  486. Widget _buildDetailsSection(AppLocalizations l10n) {
  487. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  488. return FormSection(
  489. title: l10n.get('expenseDetails'),
  490. leadingIcon: Icons.receipt_long_outlined,
  491. showAction: true,
  492. actionText: l10n.get('add'),
  493. onActionTap: _showDetailDialog,
  494. children: [
  495. if (_details.isEmpty)
  496. Padding(
  497. padding: const EdgeInsets.symmetric(vertical: 8),
  498. child: Text(
  499. l10n.get('noDetailHint'),
  500. style: TextStyle(
  501. fontSize: AppFontSizes.subtitle,
  502. color: colors.textPlaceholder,
  503. ),
  504. ),
  505. )
  506. else
  507. ..._details.asMap().entries.map((e) {
  508. final d = e.value;
  509. return GestureDetector(
  510. onTap: () => _showDetailDialog(editIndex: e.key),
  511. child: Container(
  512. margin: const EdgeInsets.symmetric(vertical: 8),
  513. padding: const EdgeInsets.all(12),
  514. decoration: BoxDecoration(
  515. color: colors.bgPage,
  516. borderRadius: BorderRadius.circular(8),
  517. ),
  518. child: Row(
  519. crossAxisAlignment: CrossAxisAlignment.center,
  520. children: [
  521. Expanded(
  522. child: Column(
  523. crossAxisAlignment: CrossAxisAlignment.start,
  524. children: [
  525. Row(
  526. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  527. children: [
  528. Expanded(
  529. child: Text(
  530. '${d.category}${d.categoryName.isNotEmpty ? '/${d.categoryName}' : ''}',
  531. maxLines: 1,
  532. overflow: TextOverflow.ellipsis,
  533. style: TextStyle(
  534. fontSize: AppFontSizes.subtitle,
  535. color: colors.textPrimary,
  536. ),
  537. ),
  538. ),
  539. const SizedBox(width: 12),
  540. Text(
  541. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  542. style: TextStyle(
  543. fontSize: AppFontSizes.caption,
  544. fontWeight: FontWeight.w600,
  545. color: colors.amountPrimary,
  546. ),
  547. ),
  548. ],
  549. ),
  550. if (d.acctSubjectId.isNotEmpty) ...[
  551. const SizedBox(height: 4),
  552. Text(
  553. '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}',
  554. maxLines: 1,
  555. overflow: TextOverflow.ellipsis,
  556. style: TextStyle(
  557. fontSize: AppFontSizes.caption,
  558. color: colors.textSecondary,
  559. ),
  560. ),
  561. ],
  562. if (d.projectId.isNotEmpty) ...[
  563. const SizedBox(height: 4),
  564. Text(
  565. '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}',
  566. maxLines: 1,
  567. overflow: TextOverflow.ellipsis,
  568. style: TextStyle(
  569. fontSize: AppFontSizes.caption,
  570. color: colors.textSecondary,
  571. ),
  572. ),
  573. ],
  574. if (d.costDeptId.isNotEmpty) ...[
  575. const SizedBox(height: 4),
  576. Text(
  577. '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}',
  578. maxLines: 1,
  579. overflow: TextOverflow.ellipsis,
  580. style: TextStyle(
  581. fontSize: AppFontSizes.caption,
  582. color: colors.textSecondary,
  583. ),
  584. ),
  585. ],
  586. if (d.startDate.isNotEmpty &&
  587. d.endDate.isNotEmpty) ...[
  588. const SizedBox(height: 4),
  589. Text(
  590. '${l10n.get('estimatedDate')}: ${d.startDate} ~ ${d.endDate}',
  591. maxLines: 1,
  592. overflow: TextOverflow.ellipsis,
  593. style: TextStyle(
  594. fontSize: AppFontSizes.caption,
  595. color: colors.textSecondary,
  596. ),
  597. ),
  598. ],
  599. if (d.remark.isNotEmpty) ...[
  600. const SizedBox(height: 4),
  601. Text(
  602. d.remark,
  603. style: TextStyle(
  604. fontSize: AppFontSizes.caption,
  605. color: colors.textSecondary,
  606. ),
  607. ),
  608. ],
  609. ],
  610. ),
  611. ),
  612. const SizedBox(width: 8),
  613. GestureDetector(
  614. onTap: () => setState(() => _details.removeAt(e.key)),
  615. child: Icon(
  616. Icons.close,
  617. size: 18,
  618. color: colors.textSecondary,
  619. ),
  620. ),
  621. ],
  622. ),
  623. ),
  624. );
  625. }),
  626. const SizedBox(height: 8),
  627. Container(
  628. height: 36,
  629. padding: const EdgeInsets.symmetric(vertical: 8),
  630. child: Row(
  631. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  632. children: [
  633. Text(
  634. l10n.get('total'),
  635. style: TextStyle(
  636. fontSize: AppFontSizes.body,
  637. fontWeight: FontWeight.w600,
  638. color: colors.textPrimary,
  639. ),
  640. ),
  641. Text(
  642. '¥${_totalAmount().toStringAsFixed(2)}',
  643. style: TextStyle(
  644. fontSize: AppFontSizes.subtitle,
  645. fontWeight: FontWeight.w700,
  646. color: colors.amountPrimary,
  647. ),
  648. ),
  649. ],
  650. ),
  651. ),
  652. ],
  653. );
  654. }
  655. double _totalAmount() => _details.fold(0, (s, d) => s + d.estimatedAmount);
  656. Future<void> _showDetailDialog({int? editIndex}) async {
  657. if (_addingDetail) return;
  658. _addingDetail = true;
  659. try {
  660. final l10n = AppLocalizations.of(context);
  661. if (_costTypes.isEmpty) {
  662. await _loadRefData(showLoading: true);
  663. if (!mounted) return;
  664. if (_costTypes.isEmpty) {
  665. TDToast.showText(l10n.get('noCostTypeData'), context: context);
  666. return;
  667. }
  668. }
  669. ExpenseDetailData? initialData;
  670. if (editIndex != null) {
  671. final d = _details[editIndex];
  672. initialData = ExpenseDetailData(
  673. category: d.category,
  674. categoryName: d.categoryName,
  675. acctSubjectId: d.acctSubjectId,
  676. acctSubjectName: d.acctSubjectName,
  677. purpose: d.purpose,
  678. projectId: d.projectId,
  679. projectName: d.projectName,
  680. costDeptId: d.costDeptId,
  681. costDeptName: d.costDeptName,
  682. startDate: d.startDate,
  683. endDate: d.endDate,
  684. estimatedAmount: d.estimatedAmount,
  685. remark: d.remark,
  686. );
  687. }
  688. FocusManager.instance.primaryFocus?.unfocus();
  689. final result = await ExpenseApplyDetailDialog.show(
  690. // ignore: use_build_context_synchronously
  691. context,
  692. categories: _dialogCategories,
  693. projects: _dialogProjects,
  694. costDepts: _dialogCostDepts,
  695. l10n: l10n,
  696. acctTree: _acctTree,
  697. initialData: initialData,
  698. );
  699. if (result != null && mounted) {
  700. setState(() {
  701. final item = _DetailItem(
  702. id: editIndex != null ? _details[editIndex].id : _detailIdCounter++,
  703. category: result.category,
  704. categoryName: result.categoryName,
  705. acctSubjectId: result.acctSubjectId,
  706. acctSubjectName: result.acctSubjectName,
  707. purpose: result.purpose,
  708. projectId: result.projectId,
  709. projectName: result.projectName,
  710. costDeptId: result.costDeptId,
  711. costDeptName: result.costDeptName,
  712. startDate: result.startDate,
  713. endDate: result.endDate,
  714. estimatedAmount: result.estimatedAmount,
  715. remark: result.remark,
  716. preItm: editIndex != null ? _details[editIndex].preItm : null,
  717. );
  718. if (editIndex != null) {
  719. _details[editIndex] = item;
  720. } else {
  721. _details.add(item);
  722. }
  723. });
  724. }
  725. } finally {
  726. _addingDetail = false;
  727. }
  728. }
  729. // ═══ 类型转换 ═══
  730. List<Map<String, dynamic>> _convertAcctTree(dynamic tree) {
  731. if (tree is! List) return [];
  732. return tree.map<Map<String, dynamic>>((e) {
  733. final map = Map<String, dynamic>.from(e as Map);
  734. if (map['children'] != null) {
  735. map['children'] = _convertAcctTree(map['children']);
  736. }
  737. return map;
  738. }).toList();
  739. }
  740. List<CostCategory> get _dialogCategories => _costTypes
  741. .map(
  742. (c) => CostCategory(
  743. code: c.typeNo,
  744. nameKey: c.typeName,
  745. acctSubjectId: c.accNo,
  746. acctSubjectName: c.accName,
  747. ),
  748. )
  749. .toList();
  750. List<Project> get _dialogProjects => _projects
  751. .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
  752. .toList();
  753. List<CostDept> get _dialogCostDepts =>
  754. _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
  755. // ═══ 3. 底部操作栏 ═══
  756. Widget _buildBottomBar(AppLocalizations l10n) {
  757. return ActionBar(
  758. showLeft: false,
  759. showCenter: false,
  760. rightLabel: l10n.get('submit'),
  761. onRightTap: () async {
  762. final err = _validate(l10n);
  763. if (err.isNotEmpty) {
  764. TDToast.showText(err.first, context: context);
  765. return;
  766. }
  767. FocusScope.of(context).unfocus();
  768. LoadingDialog.show(context, text: l10n.get('submitting'));
  769. try {
  770. final data = _buildSubmitData();
  771. final api = ref.read(expenseApplyApiProvider);
  772. await api.submit(data);
  773. if (mounted) {
  774. LoadingDialog.hide(context);
  775. TDToast.showSuccess(
  776. l10n.get('submitSuccess'),
  777. context: context,
  778. );
  779. WidgetsBinding.instance.addPostFrameCallback((_) {
  780. if (mounted) GoRouter.of(context).pop(true);
  781. });
  782. }
  783. } catch (e) {
  784. if (mounted) {
  785. LoadingDialog.hide(context);
  786. WidgetsBinding.instance.addPostFrameCallback((_) {
  787. if (mounted) _showSubmitError(e, l10n);
  788. });
  789. }
  790. }
  791. },
  792. );
  793. }
  794. void _showSubmitError(Object e, AppLocalizations l10n) {
  795. final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
  796. showGeneralDialog(
  797. context: context,
  798. pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
  799. title: l10n.get('submitFailed'),
  800. content: message,
  801. buttonStyle: TDDialogButtonStyle.text,
  802. ),
  803. );
  804. }
  805. String? _extractErrorMessage(Object e) {
  806. if (e is DioException) {
  807. if (e.error is ApiException) return (e.error as ApiException).message;
  808. if (e.error is NetworkException) {
  809. return (e.error as NetworkException).message;
  810. }
  811. }
  812. return null;
  813. }
  814. Map<String, dynamic> _buildSubmitData() {
  815. String priority;
  816. switch (_urgency) {
  817. case 'urgent':
  818. priority = '2';
  819. break;
  820. case 'critical':
  821. priority = '3';
  822. break;
  823. default:
  824. priority = '1';
  825. }
  826. return {
  827. 'HeadData': {
  828. 'AE_NO': _billNo,
  829. 'AE_DD': _today(),
  830. 'PRIORITY': priority,
  831. 'AMTN_YJ': _totalAmount(),
  832. 'REASON': _purposeController.text.trim(),
  833. 'REM': _remarkController.text,
  834. 'DEP': _selectedDeptId,
  835. 'USR': HostAppChannel.usr,
  836. },
  837. 'BodyData1': _details.asMap().entries.map((e) {
  838. final d = e.value;
  839. final item = <String, dynamic>{
  840. 'AE_NO': _billNo,
  841. 'SQ_MAN': _selEmployee?.salNo ?? '',
  842. 'TYPE_NO': d.category,
  843. 'AMTN_YJ': d.estimatedAmount,
  844. 'ACC_NO': d.acctSubjectId,
  845. 'DEP': d.costDeptId,
  846. 'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '',
  847. 'START_DD': d.startDate,
  848. 'END_DD': d.endDate,
  849. 'REM': d.remark.isNotEmpty ? d.remark : d.purpose,
  850. };
  851. if (d.preItm != null) {
  852. item['PRE_ITM'] = d.preItm;
  853. }
  854. return item;
  855. }).toList(),
  856. };
  857. }
  858. List<String> _validate(AppLocalizations l10n) {
  859. final e = <String>[];
  860. if (_purposeController.text.trim().isEmpty) {
  861. e.add(l10n.get('enterApplyReason'));
  862. }
  863. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
  864. return e;
  865. }
  866. void _doPop() {
  867. _forcePop();
  868. }
  869. void _forcePop() {
  870. FocusManager.instance.primaryFocus?.unfocus();
  871. final router = GoRouter.of(context);
  872. if (router.canPop()) {
  873. router.pop();
  874. } else {
  875. SystemNavigator.pop();
  876. }
  877. }
  878. void _showDeptPicker() {
  879. if (_departments.isEmpty) {
  880. TDToast.showText(
  881. AppLocalizations.of(context).get('noData'),
  882. context: context,
  883. );
  884. return;
  885. }
  886. final l10n = AppLocalizations.of(context);
  887. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  888. final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
  889. FocusManager.instance.primaryFocus?.unfocus();
  890. TDPicker.showMultiPicker(
  891. context,
  892. title: l10n.get('applyDept'),
  893. backgroundColor: colors.bgCard,
  894. data: [labels],
  895. onConfirm: (selected) {
  896. if (selected.isNotEmpty && selected[0] is int) {
  897. final idx = selected[0] as int;
  898. if (idx >= 0 && idx < labels.length) {
  899. Navigator.of(context).pop();
  900. setState(() {
  901. _selectedDeptId = _departments[idx].dep;
  902. _selectedDeptName = _departments[idx].name;
  903. });
  904. }
  905. }
  906. },
  907. );
  908. }
  909. Widget _buildPageFooter() {
  910. final l10n = AppLocalizations.of(context);
  911. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  912. return Center(
  913. child: Padding(
  914. padding: const EdgeInsets.only(bottom: 16),
  915. child: Row(
  916. mainAxisSize: MainAxisSize.min,
  917. children: [
  918. Icon(
  919. Icons.rocket_launch_outlined,
  920. size: 16,
  921. color: colors.textPlaceholder,
  922. ),
  923. const SizedBox(width: 6),
  924. Text(
  925. l10n.get('pageFooter'),
  926. style: TextStyle(
  927. fontSize: AppFontSizes.caption,
  928. color: colors.textPlaceholder,
  929. ),
  930. ),
  931. ],
  932. ),
  933. ),
  934. );
  935. }
  936. Widget _label(String t, {bool required = false}) {
  937. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  938. return Text.rich(
  939. TextSpan(
  940. children: [
  941. TextSpan(
  942. text: t,
  943. style: TextStyle(
  944. fontSize: AppFontSizes.subtitle,
  945. color: colors.textSecondary,
  946. ),
  947. ),
  948. if (required)
  949. TextSpan(
  950. text: ' *',
  951. style: TextStyle(
  952. fontSize: AppFontSizes.subtitle,
  953. color: colors.danger,
  954. ),
  955. ),
  956. ],
  957. ),
  958. );
  959. }
  960. String _today() {
  961. final n = DateTime.now();
  962. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  963. }
  964. }
  965. class _DetailItem {
  966. final int id;
  967. final String category;
  968. final String categoryName;
  969. final String acctSubjectId;
  970. final String acctSubjectName;
  971. final String purpose;
  972. final String projectId;
  973. final String projectName;
  974. final String costDeptId;
  975. final String costDeptName;
  976. final String startDate;
  977. final String endDate;
  978. final double estimatedAmount;
  979. final String remark;
  980. final int? preItm;
  981. const _DetailItem({
  982. required this.id,
  983. required this.category,
  984. required this.categoryName,
  985. required this.acctSubjectId,
  986. required this.acctSubjectName,
  987. required this.purpose,
  988. required this.projectId,
  989. required this.projectName,
  990. required this.costDeptId,
  991. required this.costDeptName,
  992. required this.startDate,
  993. required this.endDate,
  994. required this.estimatedAmount,
  995. required this.remark,
  996. this.preItm,
  997. });
  998. }