expense_application_apply_page.dart 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../shared/widgets/action_bar.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../shared/widgets/nav_bar_config.dart';
  10. import '../../core/theme/app_colors.dart';
  11. import '../../core/theme/app_colors_extension.dart';
  12. import '../../core/constants/enums.dart';
  13. import '../../core/data/mock_api_data.dart';
  14. import 'widgets/expense_detail_dialog.dart';
  15. class ExpenseApplicationApplyPage extends ConsumerStatefulWidget {
  16. final String? id;
  17. const ExpenseApplicationApplyPage({super.key, this.id});
  18. @override
  19. ConsumerState<ExpenseApplicationApplyPage> createState() =>
  20. _ExpenseApplicationApplyPageState();
  21. }
  22. class _ExpenseApplicationApplyPageState
  23. extends ConsumerState<ExpenseApplicationApplyPage> {
  24. // ── 基本信息 ──
  25. String _urgency = Urgency.normal.value;
  26. final Set<String> _expenseTypes = {};
  27. bool _isTaxIncluded = false;
  28. final _purposeController = TextEditingController();
  29. String _validUntil = '';
  30. // ── 关联管控 ──
  31. String? _selectedProjectName;
  32. int? _selectedProjectId;
  33. String? _selectedSubjectName;
  34. int? _selectedSubjectId;
  35. double _availableBudget = 0;
  36. final _referenceNoController = TextEditingController();
  37. // ── 费用明细 ──
  38. final List<_DetailItem> _details = [];
  39. int _detailIdCounter = 1;
  40. // ── 附件 ──
  41. final List<String> _attachments = [];
  42. // ── 专用字段 ──
  43. String _estimatedStartDate = '';
  44. String _estimatedEndDate = '';
  45. bool _isOvernight = false;
  46. String? _transportType;
  47. String? _entertainmentTarget;
  48. String? _entertainmentLevel;
  49. int _guestCount = 1;
  50. int _companionCount = 0;
  51. String _entertainmentVenue = '';
  52. String _meetingStartDate = '';
  53. String _meetingEndDate = '';
  54. String _meetingVenue = '';
  55. @override
  56. void dispose() {
  57. _purposeController.dispose();
  58. _referenceNoController.dispose();
  59. super.dispose();
  60. }
  61. @override
  62. Widget build(BuildContext context) {
  63. final l10n = AppLocalizations.of(context);
  64. ref
  65. .read(navBarConfigProvider.notifier)
  66. .update(
  67. NavBarConfig(
  68. title: l10n.get('expenseApplyRequest'),
  69. showBack: true,
  70. onBack: () {
  71. if (_hasUnsaved()) {
  72. _showConfirmDialog(
  73. l10n.get('confirmExit'),
  74. l10n.get('unsavedContentWarning'),
  75. l10n.get('continueEditing'),
  76. l10n.get('discardAndExit'),
  77. () => context.pop(),
  78. );
  79. } else {
  80. context.pop();
  81. }
  82. },
  83. ),
  84. );
  85. return PopScope(
  86. canPop: false,
  87. onPopInvokedWithResult: (didPop, _) {
  88. if (!didPop) {
  89. if (_hasUnsaved()) {
  90. _showConfirmDialog(
  91. l10n.get('confirmExit'),
  92. l10n.get('unsavedContentWarning'),
  93. l10n.get('continueEditing'),
  94. l10n.get('discardAndExit'),
  95. () => context.pop(),
  96. );
  97. } else {
  98. context.pop();
  99. }
  100. }
  101. },
  102. child: Column(
  103. children: [
  104. Expanded(
  105. child: GestureDetector(
  106. onTap: () => FocusScope.of(context).unfocus(),
  107. child: SingleChildScrollView(
  108. padding: const EdgeInsets.all(16),
  109. child: Column(
  110. children: [
  111. _buildBasicInfo(l10n),
  112. _buildTypeSpecificFields(l10n),
  113. _buildControlSection(l10n),
  114. const SizedBox(height: 16),
  115. _buildDetailsSection(l10n),
  116. const SizedBox(height: 16),
  117. _buildAttachmentSection(l10n),
  118. const SizedBox(height: 80),
  119. ],
  120. ),
  121. ),
  122. ),
  123. ),
  124. _buildBottomBar(l10n),
  125. ],
  126. ),
  127. );
  128. }
  129. // ═══ 1. 基本信息 ═══
  130. Widget _buildBasicInfo(AppLocalizations l10n) {
  131. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  132. return FormSection(
  133. title: l10n.get('basicInfo'),
  134. leadingIcon: Icons.info_outline,
  135. children: [
  136. FormFieldRow(
  137. label: l10n.get('applicant'),
  138. value: '张三',
  139. readOnly: true,
  140. showArrow: false,
  141. ),
  142. const SizedBox(height: 16),
  143. FormFieldRow(
  144. label: l10n.get('department'),
  145. value: '技术部',
  146. readOnly: true,
  147. showArrow: false,
  148. ),
  149. const SizedBox(height: 16),
  150. FormFieldRow(
  151. label: l10n.get('date'),
  152. value: _today(),
  153. readOnly: true,
  154. showArrow: false,
  155. ),
  156. const SizedBox(height: 16),
  157. _label(l10n.get('emergencyLevel'), required: true),
  158. const SizedBox(height: 8),
  159. _buildUrgencyRadio(l10n),
  160. const SizedBox(height: 16),
  161. _label(l10n.get('expenseType'), required: true),
  162. const SizedBox(height: 8),
  163. Wrap(
  164. spacing: 8,
  165. runSpacing: 8,
  166. children: ExpenseType.values.map((opt) {
  167. final sel = _expenseTypes.contains(opt.value);
  168. return GestureDetector(
  169. onTap: () => setState(() {
  170. if (sel) {
  171. _expenseTypes.remove(opt.value);
  172. } else {
  173. _expenseTypes.add(opt.value);
  174. final hints = {
  175. 'travel': 'hintTravelFields',
  176. 'entertainment': 'hintEntertainmentFields',
  177. 'meeting': 'hintMeetingFields',
  178. };
  179. final hintKey = hints[opt.value];
  180. if (hintKey != null) {
  181. TDMessage.showMessage(
  182. context: context,
  183. content: l10n.get(hintKey),
  184. theme: MessageTheme.info,
  185. icon: true,
  186. marquee: MessageMarquee(speed: 3000, loop: 1, delay: 300),
  187. duration: 3000,
  188. );
  189. }
  190. }
  191. }),
  192. child: TDTag(
  193. l10n.get(opt.labelKey),
  194. size: TDTagSize.large,
  195. theme: sel ? TDTagTheme.primary : TDTagTheme.defaultTheme,
  196. isOutline: !sel,
  197. ),
  198. );
  199. }).toList(),
  200. ),
  201. const SizedBox(height: 16),
  202. _label(l10n.get('feeReason'), required: true),
  203. const SizedBox(height: 8),
  204. TDTextarea(
  205. controller: _purposeController,
  206. hintText: l10n.get('enterFeeReason'),
  207. maxLines: 4,
  208. minLines: 1,
  209. maxLength: 500,
  210. indicator: true,
  211. padding: EdgeInsets.zero,
  212. bordered: true,
  213. backgroundColor: colors.bgPage,
  214. ),
  215. const SizedBox(height: 16),
  216. FormFieldRow(
  217. label: l10n.get('validUntil'),
  218. value: _validUntil,
  219. hint: l10n.get('pleaseSelect'),
  220. onTap: () => _pickDate((d) => setState(() => _validUntil = d)),
  221. ),
  222. ],
  223. );
  224. }
  225. Widget _buildUrgencyRadio(AppLocalizations l10n) {
  226. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  227. return Row(
  228. children: Urgency.values.asMap().entries.map((e) {
  229. final sel = _urgency == e.value.value;
  230. final isCritical = e.value.value == Urgency.critical.value;
  231. final activeColor = isCritical ? colors.danger : colors.primary;
  232. return Padding(
  233. padding: EdgeInsets.only(right: e.key < 2 ? 24 : 0),
  234. child: GestureDetector(
  235. behavior: HitTestBehavior.opaque,
  236. onTap: () => setState(() => _urgency = e.value.value),
  237. child: Row(
  238. mainAxisSize: MainAxisSize.min,
  239. children: [
  240. Container(
  241. width: 18,
  242. height: 18,
  243. decoration: BoxDecoration(
  244. shape: BoxShape.circle,
  245. border: Border.all(
  246. color: sel ? activeColor : colors.textPlaceholder,
  247. width: 2,
  248. ),
  249. ),
  250. child: sel
  251. ? Center(
  252. child: Container(
  253. width: 8,
  254. height: 8,
  255. decoration: BoxDecoration(
  256. shape: BoxShape.circle,
  257. color: activeColor,
  258. ),
  259. ),
  260. )
  261. : null,
  262. ),
  263. const SizedBox(width: 6),
  264. Text(
  265. l10n.get(e.value.labelKey),
  266. style: TextStyle(
  267. fontSize: AppFontSizes.subtitle,
  268. color: sel ? activeColor : colors.textPrimary,
  269. ),
  270. ),
  271. ],
  272. ),
  273. ),
  274. );
  275. }).toList(),
  276. );
  277. }
  278. // ═══ 2. 类型专用字段 ═══
  279. Widget _buildTypeSpecificFields(AppLocalizations l10n) {
  280. final ws = <Widget>[];
  281. if (_expenseTypes.contains('travel')) ws.add(_buildTravelFields(l10n));
  282. if (_expenseTypes.contains('entertainment')) {
  283. if (ws.isNotEmpty) ws.add(const SizedBox(height: 16));
  284. ws.add(_buildEntertainmentFields(l10n));
  285. }
  286. if (_expenseTypes.contains('meeting')) {
  287. if (ws.isNotEmpty) ws.add(const SizedBox(height: 16));
  288. ws.add(_buildMeetingFields(l10n));
  289. }
  290. if (ws.isEmpty) return const SizedBox(height: 16);
  291. return Column(
  292. children: [const SizedBox(height: 16), ...ws, const SizedBox(height: 16)],
  293. );
  294. }
  295. Widget _buildTravelFields(AppLocalizations l10n) {
  296. return FormSection(
  297. title: l10n.get('travelExpense'),
  298. leadingIcon: Icons.flight_outlined,
  299. children: [
  300. FormFieldRow(
  301. label: l10n.get('estimatedStartDate'),
  302. value: _estimatedStartDate,
  303. hint: l10n.get('pleaseSelect'),
  304. required: true,
  305. onTap: () =>
  306. _pickDate((d) => setState(() => _estimatedStartDate = d)),
  307. ),
  308. const SizedBox(height: 16),
  309. FormFieldRow(
  310. label: l10n.get('estimatedEndDate'),
  311. value: _estimatedEndDate,
  312. hint: l10n.get('pleaseSelect'),
  313. required: true,
  314. onTap: () => _pickDate((d) {
  315. if (_estimatedStartDate.isNotEmpty &&
  316. _estimatedStartDate.compareTo(d) > 0) {
  317. TDToast.showText(
  318. l10n.get('startDateNotAfterEndDate'),
  319. context: context,
  320. );
  321. return;
  322. }
  323. setState(() => _estimatedEndDate = d);
  324. }),
  325. ),
  326. const SizedBox(height: 16),
  327. Row(
  328. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  329. children: [
  330. _label(l10n.get('isOvernight')),
  331. TDSwitch(
  332. isOn: _isOvernight,
  333. onChanged: (v) {
  334. setState(() => _isOvernight = v);
  335. return true;
  336. },
  337. ),
  338. ],
  339. ),
  340. const SizedBox(height: 16),
  341. FormFieldRow(
  342. label: l10n.get('transportType'),
  343. value: _transportType != null
  344. ? l10n.get(
  345. TransportType.values
  346. .firstWhere((t) => t.value == _transportType)
  347. .labelKey,
  348. )
  349. : null,
  350. hint: l10n.get('pleaseSelect'),
  351. onTap: () => _showEnumPicker(
  352. l10n.get('selectTransport'),
  353. TransportType.values,
  354. (v) => setState(() => _transportType = v),
  355. ),
  356. ),
  357. ],
  358. );
  359. }
  360. Widget _buildEntertainmentFields(AppLocalizations l10n) {
  361. return FormSection(
  362. title: l10n.get('entertainmentExpense'),
  363. leadingIcon: Icons.people_outline,
  364. children: [
  365. FormFieldRow(
  366. label: l10n.get('entertainmentTargetUnit'),
  367. value: _entertainmentTarget,
  368. hint: l10n.get('pleaseEnter'),
  369. required: true,
  370. onTap: () => _showTextInput(
  371. l10n.get('entertainmentTargetUnit'),
  372. (v) => setState(() => _entertainmentTarget = v),
  373. initialText: _entertainmentTarget ?? '',
  374. ),
  375. ),
  376. const SizedBox(height: 16),
  377. FormFieldRow(
  378. label: l10n.get('entertainmentLevel'),
  379. value: _entertainmentLevel != null
  380. ? l10n.get(
  381. EntertainmentLevel.values
  382. .firstWhere((e) => e.value == _entertainmentLevel)
  383. .labelKey,
  384. )
  385. : null,
  386. hint: l10n.get('pleaseSelect'),
  387. onTap: () => _showEnumPicker(
  388. l10n.get('selectEntertainmentLevel'),
  389. EntertainmentLevel.values,
  390. (v) => setState(() => _entertainmentLevel = v),
  391. ),
  392. ),
  393. const SizedBox(height: 16),
  394. FormFieldRow(
  395. label: l10n.get('externalCount'),
  396. value: '$_guestCount',
  397. onTap: () => _showNumberInput(
  398. l10n.get('externalCount'),
  399. (v) => setState(() => _guestCount = v),
  400. initialValue: _guestCount,
  401. ),
  402. ),
  403. const SizedBox(height: 16),
  404. FormFieldRow(
  405. label: l10n.get('internalCount'),
  406. value: '$_companionCount',
  407. onTap: () => _showNumberInput(
  408. l10n.get('internalCount'),
  409. (v) => setState(() => _companionCount = v),
  410. initialValue: _companionCount,
  411. ),
  412. ),
  413. const SizedBox(height: 16),
  414. FormFieldRow(
  415. label: l10n.get('venue'),
  416. value: _entertainmentVenue,
  417. hint: l10n.get('pleaseEnterLocation'),
  418. onTap: () => _showTextInput(
  419. l10n.get('venue'),
  420. (v) => setState(() => _entertainmentVenue = v),
  421. initialText: _entertainmentVenue,
  422. ),
  423. ),
  424. ],
  425. );
  426. }
  427. Widget _buildMeetingFields(AppLocalizations l10n) {
  428. return FormSection(
  429. title: l10n.get('meetingExpense'),
  430. leadingIcon: Icons.meeting_room_outlined,
  431. children: [
  432. FormFieldRow(
  433. label: l10n.get('estimatedStartDate'),
  434. value: _meetingStartDate,
  435. hint: l10n.get('pleaseSelect'),
  436. required: true,
  437. onTap: () => _pickDate((d) => setState(() => _meetingStartDate = d)),
  438. ),
  439. const SizedBox(height: 16),
  440. FormFieldRow(
  441. label: l10n.get('estimatedEndDate'),
  442. value: _meetingEndDate,
  443. hint: l10n.get('pleaseSelect'),
  444. required: true,
  445. onTap: () => _pickDate((d) {
  446. if (_meetingStartDate.isNotEmpty &&
  447. _meetingStartDate.compareTo(d) > 0) {
  448. TDToast.showText(
  449. l10n.get('startDateNotAfterEndDate'),
  450. context: context,
  451. );
  452. return;
  453. }
  454. setState(() => _meetingEndDate = d);
  455. }),
  456. ),
  457. const SizedBox(height: 16),
  458. FormFieldRow(
  459. label: l10n.get('venue'),
  460. value: _meetingVenue,
  461. hint: l10n.get('pleaseEnterMeetingLocation'),
  462. onTap: () => _showTextInput(
  463. l10n.get('meetingLocation'),
  464. (v) => setState(() => _meetingVenue = v),
  465. initialText: _meetingVenue,
  466. ),
  467. ),
  468. ],
  469. );
  470. }
  471. // ═══ 3. 关联管控 ═══
  472. List<Map<String, dynamic>> _buildCascadeData() {
  473. return mockProjects
  474. .map(
  475. (p) => <String, dynamic>{
  476. 'label': p.name,
  477. 'value': p.id.toString(),
  478. 'children': mockBudgetSubjects
  479. .map(
  480. (s) => <String, dynamic>{
  481. 'label': s.name,
  482. 'value': s.id.toString(),
  483. },
  484. )
  485. .toList(),
  486. },
  487. )
  488. .toList();
  489. }
  490. Widget _buildControlSection(AppLocalizations l10n) {
  491. final cascadeLabel = _selectedProjectName != null &&
  492. _selectedSubjectName != null
  493. ? '$_selectedProjectName / $_selectedSubjectName'
  494. : _selectedProjectName;
  495. return FormSection(
  496. title: l10n.get('relatedControl'),
  497. leadingIcon: Icons.link_outlined,
  498. children: [
  499. FormFieldRow(
  500. label: l10n.get('relatedProject'),
  501. value: cascadeLabel,
  502. hint: l10n.get('selectProjectAndSubject'),
  503. required: true,
  504. onTap: () {
  505. _unfocus();
  506. TDCascader.showMultiCascader(
  507. context,
  508. title: l10n.get('selectProjectAndSubject'),
  509. data: _buildCascadeData(),
  510. subTitles: [
  511. l10n.get('project'),
  512. l10n.get('budgetSubject'),
  513. ],
  514. onClose: () => Navigator.of(context).pop(),
  515. onChange: (selected) {
  516. if (selected.length >= 2) {
  517. final pId =
  518. int.tryParse(selected[0].value ?? '');
  519. final sId =
  520. int.tryParse(selected[1].value ?? '');
  521. if (pId != null && sId != null) {
  522. setState(() {
  523. _selectedProjectId = pId;
  524. _selectedProjectName =
  525. selected[0].label;
  526. _selectedSubjectId = sId;
  527. _selectedSubjectName =
  528. selected[1].label;
  529. _availableBudget =
  530. getMockBudget(pId, sId);
  531. });
  532. }
  533. }
  534. },
  535. );
  536. },
  537. ),
  538. const SizedBox(height: 16),
  539. _buildBudgetRow(l10n),
  540. const SizedBox(height: 16),
  541. FormFieldRow(
  542. label: l10n.get('relatedContractNo'),
  543. value: _referenceNoController.text,
  544. hint: l10n.get('optional'),
  545. onTap: () => _showTextInput(
  546. l10n.get('relatedContractNo'),
  547. (v) => setState(() {
  548. _referenceNoController.text = v;
  549. _referenceNoController.selection = TextSelection.fromPosition(
  550. TextPosition(offset: v.length),
  551. );
  552. }),
  553. initialText: _referenceNoController.text,
  554. ),
  555. ),
  556. ],
  557. );
  558. }
  559. Widget _buildBudgetRow(AppLocalizations l10n) {
  560. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  561. final over = _availableBudget <= 0;
  562. return Row(
  563. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  564. children: [
  565. Text(
  566. l10n.get('availableBudget'),
  567. style: TextStyle(
  568. fontSize: AppFontSizes.subtitle,
  569. color: colors.textSecondary,
  570. ),
  571. ),
  572. Text(
  573. '¥${_availableBudget.toStringAsFixed(2)}',
  574. style: TextStyle(
  575. fontSize: AppFontSizes.subtitle,
  576. fontWeight: FontWeight.w700,
  577. color: over ? colors.danger : colors.amountPrimary,
  578. ),
  579. ),
  580. ],
  581. );
  582. }
  583. // ═══ 4. 费用明细 ═══
  584. Widget _buildDetailsSection(AppLocalizations l10n) {
  585. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  586. return FormSection(
  587. title: l10n.get('expenseDetails'),
  588. leadingIcon: Icons.receipt_long_outlined,
  589. showAction: true,
  590. actionText: l10n.get('add'),
  591. onActionTap: _showDetailDialog,
  592. children: [
  593. Padding(
  594. padding: const EdgeInsets.only(bottom: 12),
  595. child: Row(
  596. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  597. children: [
  598. _label(l10n.get('isTaxIncluded')),
  599. TDSwitch(
  600. isOn: _isTaxIncluded,
  601. onChanged: (v) {
  602. setState(() => _isTaxIncluded = v);
  603. return true;
  604. },
  605. ),
  606. ],
  607. ),
  608. ),
  609. if (_details.isEmpty)
  610. Padding(
  611. padding: const EdgeInsets.symmetric(vertical: 8),
  612. child: Text(
  613. l10n.get('noDetailHint'),
  614. style: TextStyle(
  615. fontSize: AppFontSizes.subtitle,
  616. color: colors.textPlaceholder,
  617. ),
  618. ),
  619. )
  620. else
  621. ..._details.asMap().entries.map((e) {
  622. final d = e.value;
  623. return Container(
  624. margin: const EdgeInsets.symmetric(vertical: 8),
  625. padding: const EdgeInsets.all(12),
  626. decoration: BoxDecoration(
  627. color: colors.bgPage,
  628. borderRadius: BorderRadius.circular(8),
  629. ),
  630. child: Row(
  631. children: [
  632. Expanded(
  633. flex: 3,
  634. child: Column(
  635. crossAxisAlignment: CrossAxisAlignment.start,
  636. children: [
  637. Text(
  638. d.categoryName,
  639. style: TextStyle(
  640. fontSize: AppFontSizes.subtitle,
  641. color: colors.textPrimary,
  642. ),
  643. ),
  644. if (d.remark.isNotEmpty)
  645. Text(
  646. d.remark,
  647. maxLines: 2,
  648. overflow: TextOverflow.ellipsis,
  649. style: TextStyle(
  650. fontSize: AppFontSizes.caption,
  651. color: colors.textSecondary,
  652. ),
  653. ),
  654. ],
  655. ),
  656. ),
  657. Text(
  658. '${d.quantity}×¥${d.unitPrice.toStringAsFixed(2)}',
  659. style: TextStyle(
  660. fontSize: AppFontSizes.body,
  661. color: colors.textSecondary,
  662. ),
  663. ),
  664. const SizedBox(width: 8),
  665. Text(
  666. '¥${d.amount.toStringAsFixed(2)}',
  667. style: TextStyle(
  668. fontSize: AppFontSizes.subtitle,
  669. fontWeight: FontWeight.w600,
  670. color: colors.amountPrimary,
  671. ),
  672. ),
  673. const SizedBox(width: 8),
  674. GestureDetector(
  675. onTap: () => setState(() => _details.removeAt(e.key)),
  676. child: Container(
  677. width: 24,
  678. height: 24,
  679. decoration: BoxDecoration(
  680. color: colors.primaryLight,
  681. shape: BoxShape.circle,
  682. ),
  683. child: Icon(
  684. Icons.close,
  685. size: 14,
  686. color: colors.primary700,
  687. ),
  688. ),
  689. ),
  690. ],
  691. ),
  692. );
  693. }),
  694. const SizedBox(height: 8),
  695. Container(
  696. height: 36,
  697. padding: const EdgeInsets.symmetric(vertical: 8),
  698. child: Row(
  699. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  700. children: [
  701. Text(
  702. l10n.get('total'),
  703. style: TextStyle(
  704. fontSize: AppFontSizes.body,
  705. fontWeight: FontWeight.w600,
  706. color: colors.textPrimary,
  707. ),
  708. ),
  709. Text(
  710. '¥${_totalAmount().toStringAsFixed(2)}',
  711. style: TextStyle(
  712. fontSize: AppFontSizes.subtitle,
  713. fontWeight: FontWeight.w700,
  714. color: colors.amountPrimary,
  715. ),
  716. ),
  717. ],
  718. ),
  719. ),
  720. if (_totalAmount() > _availableBudget)
  721. Padding(
  722. padding: const EdgeInsets.only(top: 8),
  723. child: Row(
  724. children: [
  725. Icon(Icons.warning_amber, size: 14, color: colors.danger),
  726. const SizedBox(width: 6),
  727. Expanded(
  728. child: Text(
  729. l10n.get('overBudgetTriggerApproval'),
  730. style: TextStyle(
  731. fontSize: AppFontSizes.caption,
  732. color: colors.danger,
  733. ),
  734. ),
  735. ),
  736. ],
  737. ),
  738. ),
  739. ],
  740. );
  741. }
  742. double _totalAmount() => _details.fold(0, (s, d) => s + d.amount);
  743. List<CostCategory> get _availableDetailCategories {
  744. if (_expenseTypes.isEmpty) return mockCostCategories;
  745. final codes = _expenseTypes
  746. .expand((et) => expenseTypeCategories[et] ?? <String>[])
  747. .toSet();
  748. return mockCostCategories.where((c) => codes.contains(c.code)).toList();
  749. }
  750. Future<void> _showDetailDialog() async {
  751. final l10n = AppLocalizations.of(context);
  752. final result = await ExpenseDetailDialog.show(
  753. context,
  754. categories: _availableDetailCategories,
  755. unitKeys: unitOptions,
  756. l10n: l10n,
  757. );
  758. if (result != null && mounted) {
  759. setState(
  760. () => _details.add(
  761. _DetailItem(
  762. id: _detailIdCounter++,
  763. category: result.category,
  764. categoryName: result.categoryName,
  765. quantity: result.quantity,
  766. unit: result.unit,
  767. unitPrice: result.unitPrice,
  768. amount: result.quantity * result.unitPrice,
  769. remark: result.remark,
  770. ),
  771. ),
  772. );
  773. }
  774. }
  775. // ═══ 5. 附件上传 ═══
  776. Widget _buildAttachmentSection(AppLocalizations l10n) {
  777. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  778. return FormSection(
  779. title: l10n.get('attachmentUpload'),
  780. leadingIcon: Icons.attach_file_outlined,
  781. children: [
  782. Text(
  783. l10n.get('maxAttachment'),
  784. style: TextStyle(
  785. fontSize: AppFontSizes.caption,
  786. color: colors.textPlaceholder,
  787. ),
  788. ),
  789. const SizedBox(height: 8),
  790. Wrap(
  791. spacing: 8,
  792. runSpacing: 8,
  793. children: [
  794. ..._attachments.asMap().entries.map(
  795. (e) => Stack(
  796. clipBehavior: Clip.none,
  797. children: [
  798. Container(
  799. width: 80,
  800. height: 80,
  801. decoration: BoxDecoration(
  802. color: colors.primaryLight,
  803. borderRadius: BorderRadius.circular(4),
  804. ),
  805. child: Center(
  806. child: Icon(Icons.image, color: colors.primary, size: 32),
  807. ),
  808. ),
  809. Positioned(
  810. right: -4,
  811. top: -4,
  812. child: GestureDetector(
  813. onTap: () => setState(() => _attachments.removeAt(e.key)),
  814. child: Container(
  815. width: 20,
  816. height: 20,
  817. decoration: BoxDecoration(
  818. color: colors.danger,
  819. shape: BoxShape.circle,
  820. ),
  821. child: const Icon(
  822. Icons.close,
  823. size: 12,
  824. color: Colors.white,
  825. ),
  826. ),
  827. ),
  828. ),
  829. ],
  830. ),
  831. ),
  832. if (_attachments.length < 9)
  833. GestureDetector(
  834. onTap: () {
  835. // Mock: add attachment (支持图片/PDF/Word/Excel)
  836. final exts = ['.jpg', '.png', '.pdf', '.docx', '.xlsx'];
  837. setState(
  838. () => _attachments.add(
  839. '附件_${DateTime.now().millisecondsSinceEpoch}${exts[_attachments.length % exts.length]}',
  840. ),
  841. );
  842. TDToast.showText(
  843. l10n.get('mockAttachmentAdded'),
  844. context: context,
  845. );
  846. },
  847. child: Container(
  848. width: 80,
  849. height: 80,
  850. decoration: BoxDecoration(
  851. color: colors.bgPage,
  852. borderRadius: BorderRadius.circular(4),
  853. border: Border.all(color: colors.border, width: 1),
  854. ),
  855. child: Center(
  856. child: Icon(
  857. Icons.add,
  858. size: 24,
  859. color: colors.textPlaceholder,
  860. ),
  861. ),
  862. ),
  863. ),
  864. ],
  865. ),
  866. ],
  867. );
  868. }
  869. // ═══ 6. 底部操作栏 ═══
  870. Widget _buildBottomBar(AppLocalizations l10n) {
  871. final isDraft = widget.id != null;
  872. return ActionBar(
  873. leftLabel: isDraft ? l10n.get('reset') : null,
  874. centerLabel: l10n.get('saveDraft'),
  875. rightLabel: l10n.get('submitApproval'),
  876. showLeft: isDraft,
  877. onLeftTap: isDraft
  878. ? () => _showConfirmDialog(
  879. l10n.get('confirmReset'),
  880. l10n.get('resetWarning'),
  881. l10n.get('cancel'),
  882. l10n.get('confirmReset'),
  883. _resetAll,
  884. )
  885. : null,
  886. onCenterTap: () {
  887. TDToast.showSuccess(l10n.get('draftSavedToast'), context: context);
  888. context.pop();
  889. },
  890. onRightTap: () {
  891. final err = _validate(l10n);
  892. if (err.isNotEmpty) {
  893. TDToast.showText(err.first, context: context);
  894. return;
  895. }
  896. TDToast.showSuccess(
  897. l10n.get('submittedAwaitingApproval'),
  898. context: context,
  899. );
  900. context.pop();
  901. },
  902. );
  903. }
  904. List<String> _validate(AppLocalizations l10n) {
  905. final e = <String>[];
  906. if (_expenseTypes.isEmpty) e.add(l10n.get('selectAtLeastOneExpenseType'));
  907. if (_purposeController.text.trim().isEmpty) {
  908. e.add(l10n.get('enterFeeReason'));
  909. }
  910. if (_selectedProjectId == null) e.add(l10n.get('selectProject'));
  911. if (_selectedSubjectId == null) e.add(l10n.get('selectSubject'));
  912. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
  913. if (_expenseTypes.contains('travel')) {
  914. if (_estimatedStartDate.isEmpty) {
  915. e.add(l10n.get('selectEstimatedStartDate'));
  916. }
  917. if (_estimatedEndDate.isEmpty) {
  918. e.add(l10n.get('selectEstimatedEndDate'));
  919. }
  920. if (_estimatedStartDate.isNotEmpty &&
  921. _estimatedEndDate.isNotEmpty &&
  922. _estimatedStartDate.compareTo(_estimatedEndDate) > 0) {
  923. e.add(l10n.get('startDateNotAfterEndDate'));
  924. }
  925. }
  926. if (_expenseTypes.contains('entertainment')) {
  927. if (_entertainmentTarget == null || _entertainmentTarget!.isEmpty) {
  928. e.add(l10n.get('entertainmentTargetUnit'));
  929. }
  930. if (_companionCount > _guestCount) {
  931. e.add(l10n.get('companionNotExceedGuest'));
  932. }
  933. }
  934. if (_expenseTypes.contains('meeting')) {
  935. if (_meetingStartDate.isEmpty) {
  936. e.add(l10n.get('selectEstimatedStartDate'));
  937. }
  938. if (_meetingEndDate.isEmpty) {
  939. e.add(l10n.get('selectEstimatedEndDate'));
  940. }
  941. if (_meetingStartDate.isNotEmpty &&
  942. _meetingEndDate.isNotEmpty &&
  943. _meetingStartDate.compareTo(_meetingEndDate) > 0) {
  944. e.add(l10n.get('startDateNotAfterEndDate'));
  945. }
  946. }
  947. return e;
  948. }
  949. void _resetAll() => setState(() {
  950. _purposeController.clear();
  951. _expenseTypes.clear();
  952. _urgency = Urgency.normal.value;
  953. _isTaxIncluded = false;
  954. _validUntil = '';
  955. _selectedProjectId = null;
  956. _selectedProjectName = null;
  957. _selectedSubjectId = null;
  958. _selectedSubjectName = null;
  959. _availableBudget = 0;
  960. _referenceNoController.clear();
  961. _details.clear();
  962. _attachments.clear();
  963. _estimatedStartDate = '';
  964. _estimatedEndDate = '';
  965. _isOvernight = false;
  966. _transportType = null;
  967. _entertainmentTarget = null;
  968. _entertainmentLevel = null;
  969. _guestCount = 1;
  970. _companionCount = 0;
  971. _entertainmentVenue = '';
  972. _meetingStartDate = '';
  973. _meetingEndDate = '';
  974. _meetingVenue = '';
  975. });
  976. bool _hasUnsaved() =>
  977. _purposeController.text.isNotEmpty ||
  978. _expenseTypes.isNotEmpty ||
  979. _details.isNotEmpty ||
  980. _attachments.isNotEmpty ||
  981. _selectedProjectId != null ||
  982. _estimatedStartDate.isNotEmpty ||
  983. _estimatedEndDate.isNotEmpty ||
  984. _entertainmentTarget != null ||
  985. _meetingStartDate.isNotEmpty;
  986. void _unfocus() => FocusScope.of(context).unfocus();
  987. // ═══ 通用弹窗方法 ═══
  988. void _showConfirmDialog(
  989. String title,
  990. String content,
  991. String leftText,
  992. String rightText,
  993. VoidCallback onConfirm,
  994. ) {
  995. _unfocus();
  996. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  997. showDialog(
  998. context: context,
  999. builder: (ctx) => TDAlertDialog(
  1000. title: title,
  1001. content: content,
  1002. buttonStyle: TDDialogButtonStyle.text,
  1003. leftBtn: TDDialogButtonOptions(
  1004. title: leftText,
  1005. titleColor: colors.primary,
  1006. action: () => Navigator.pop(ctx),
  1007. ),
  1008. rightBtn: TDDialogButtonOptions(
  1009. title: rightText,
  1010. titleColor: colors.danger,
  1011. action: () {
  1012. Navigator.pop(ctx);
  1013. onConfirm();
  1014. },
  1015. ),
  1016. ),
  1017. );
  1018. }
  1019. void _showEnumPicker(
  1020. String title,
  1021. List<EnumEntry> entries,
  1022. Function(String) onPick,
  1023. ) {
  1024. _showListPicker(
  1025. title,
  1026. entries.map((e) => AppLocalizations.of(context).get(e.labelKey)).toList(),
  1027. (label) {
  1028. final entry = entries.firstWhere(
  1029. (e) => AppLocalizations.of(context).get(e.labelKey) == label,
  1030. );
  1031. onPick(entry.value);
  1032. },
  1033. );
  1034. }
  1035. void _showListPicker(
  1036. String title,
  1037. List<String> items,
  1038. Function(String) onPick,
  1039. ) {
  1040. _unfocus();
  1041. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1042. TDPicker.showMultiPicker(
  1043. context,
  1044. title: title,
  1045. backgroundColor: colors.bgCard,
  1046. data: [items],
  1047. onConfirm: (selected) {
  1048. if (selected.isNotEmpty && selected[0] is int) {
  1049. final idx = selected[0] as int;
  1050. if (idx >= 0 && idx < items.length) {
  1051. Navigator.of(context).pop();
  1052. onPick(items[idx]);
  1053. }
  1054. }
  1055. },
  1056. );
  1057. }
  1058. void _showTextInput(
  1059. String title,
  1060. Function(String) onConfirm, {
  1061. String initialText = '',
  1062. }) {
  1063. _unfocus();
  1064. final l10n = AppLocalizations.of(context);
  1065. final c = TextEditingController(text: initialText);
  1066. showGeneralDialog(
  1067. context: context,
  1068. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  1069. textEditingController: c,
  1070. title: title,
  1071. hintText: l10n.get('pleaseEnter'),
  1072. leftBtn: TDDialogButtonOptions(
  1073. title: l10n.get('cancel'),
  1074. action: () => Navigator.pop(ctx),
  1075. ),
  1076. rightBtn: TDDialogButtonOptions(
  1077. title: l10n.get('confirm'),
  1078. action: () {
  1079. onConfirm(c.text);
  1080. Navigator.pop(ctx);
  1081. },
  1082. ),
  1083. ),
  1084. );
  1085. }
  1086. void _showNumberInput(
  1087. String title,
  1088. Function(int) onConfirm, {
  1089. int initialValue = 0,
  1090. }) {
  1091. _unfocus();
  1092. final l10n = AppLocalizations.of(context);
  1093. final c = TextEditingController(
  1094. text: initialValue > 0 ? '$initialValue' : '',
  1095. );
  1096. showGeneralDialog(
  1097. context: context,
  1098. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  1099. textEditingController: c,
  1100. title: title,
  1101. hintText: l10n.get('enterNumber'),
  1102. leftBtn: TDDialogButtonOptions(
  1103. title: l10n.get('cancel'),
  1104. action: () => Navigator.pop(ctx),
  1105. ),
  1106. rightBtn: TDDialogButtonOptions(
  1107. title: l10n.get('confirm'),
  1108. action: () {
  1109. onConfirm(int.tryParse(c.text) ?? 0);
  1110. Navigator.pop(ctx);
  1111. },
  1112. ),
  1113. ),
  1114. );
  1115. }
  1116. void _pickDate(Function(String) onPick) {
  1117. _unfocus();
  1118. final l10n = AppLocalizations.of(context);
  1119. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1120. final theme = Theme.of(context);
  1121. final now = DateTime.now();
  1122. showModalBottomSheet(
  1123. context: context,
  1124. backgroundColor: Colors.transparent,
  1125. builder: (ctx) => Theme(
  1126. data: theme,
  1127. child: TDDatePicker(
  1128. title: l10n.get('selectDate'),
  1129. backgroundColor: colors.bgCard,
  1130. model: DatePickerModel(
  1131. useYear: true,
  1132. useMonth: true,
  1133. useDay: true,
  1134. useHour: false,
  1135. useMinute: false,
  1136. useSecond: false,
  1137. useWeekDay: false,
  1138. dateStart: [2020, 1, 1],
  1139. dateEnd: [now.year + 1, 12, 31],
  1140. dateInitial: [now.year, now.month, now.day],
  1141. ),
  1142. onConfirm: (selected) {
  1143. onPick(
  1144. '${selected['year']}-${selected['month']!.toString().padLeft(2, '0')}-${selected['day']!.toString().padLeft(2, '0')}',
  1145. );
  1146. Navigator.of(ctx).pop();
  1147. },
  1148. onCancel: (_) => Navigator.of(ctx).pop(),
  1149. ),
  1150. ),
  1151. );
  1152. }
  1153. Widget _label(String t, {bool required = false}) {
  1154. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1155. return Text.rich(
  1156. TextSpan(
  1157. children: [
  1158. TextSpan(
  1159. text: t,
  1160. style: TextStyle(
  1161. fontSize: AppFontSizes.subtitle,
  1162. color: colors.textSecondary,
  1163. ),
  1164. ),
  1165. if (required)
  1166. TextSpan(
  1167. text: ' *',
  1168. style: TextStyle(
  1169. fontSize: AppFontSizes.subtitle,
  1170. color: colors.danger,
  1171. ),
  1172. ),
  1173. ],
  1174. ),
  1175. );
  1176. }
  1177. String _today() {
  1178. final n = DateTime.now();
  1179. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1180. }
  1181. }
  1182. class _DetailItem {
  1183. final int id;
  1184. final String category;
  1185. final String categoryName;
  1186. final double quantity;
  1187. final String unit;
  1188. final double unitPrice;
  1189. final double amount;
  1190. final String remark;
  1191. const _DetailItem({
  1192. required this.id,
  1193. required this.category,
  1194. required this.categoryName,
  1195. required this.quantity,
  1196. required this.unit,
  1197. required this.unitPrice,
  1198. required this.amount,
  1199. required this.remark,
  1200. });
  1201. }