expense_apply_create_page.dart 43 KB

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