expense_application_apply_page.dart 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133
  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 '../shell/nav_bar_config.dart';
  10. import '../../core/theme/app_colors.dart';
  11. import '../../core/theme/app_colors_extension.dart';
  12. class ExpenseApplicationApplyPage extends ConsumerStatefulWidget {
  13. final String? id;
  14. const ExpenseApplicationApplyPage({super.key, this.id});
  15. @override
  16. ConsumerState<ExpenseApplicationApplyPage> createState() =>
  17. _ExpenseApplicationApplyPageState();
  18. }
  19. class _ExpenseApplicationApplyPageState
  20. extends ConsumerState<ExpenseApplicationApplyPage> {
  21. // ── 基本信息 ──
  22. int _urgency = 0; // 0=普通, 1=紧急, 2=特急
  23. static const _urgencyLabels = ['普通', '紧急', '特急'];
  24. final Set<String> _expenseTypes = {};
  25. static const _expenseTypeOptions = [
  26. ('travel', '差旅费'),
  27. ('entertainment', '业务招待费'),
  28. ('procurement', '日常采购'),
  29. ('activity', '活动经费'),
  30. ('office', '办公费'),
  31. ('meeting', '会议费'),
  32. ('training', '培训费'),
  33. ];
  34. bool _isTaxIncluded = false;
  35. final _purposeController = TextEditingController();
  36. // ── 关联管控 ──
  37. String? _selectedProjectName;
  38. int? _selectedProjectId;
  39. String? _selectedSubjectName;
  40. int? _selectedSubjectId;
  41. final _availableBudget = 50000.00;
  42. final _referenceNoController = TextEditingController();
  43. // ── 费用明细 ──
  44. final List<_DetailItem> _details = [];
  45. int _detailIdCounter = 1;
  46. // ── 附件 ──
  47. final List<String> _attachments = []; // mock file names
  48. // ── 专用字段 ──
  49. String _estimatedStartDate = '';
  50. String _estimatedEndDate = '';
  51. String _entertainmentTarget = '';
  52. String _venue = '';
  53. @override
  54. void dispose() {
  55. _purposeController.dispose();
  56. _referenceNoController.dispose();
  57. super.dispose();
  58. }
  59. @override
  60. Widget build(BuildContext context) {
  61. final l10n = AppLocalizations.of(context);
  62. ref
  63. .read(navBarConfigProvider.notifier)
  64. .update(
  65. NavBarConfig(
  66. title: l10n.get('expenseApplyRequest'),
  67. showBack: true,
  68. onBack: () {
  69. if (_hasUnsaved()) {
  70. _showConfirmDialog(
  71. l10n.get('confirmExit'),
  72. l10n.get('unsavedContentWarning'),
  73. l10n.get('continueEditing'),
  74. l10n.get('discardAndExit'),
  75. () => context.pop(),
  76. );
  77. } else {
  78. context.pop();
  79. }
  80. },
  81. ),
  82. );
  83. return PopScope(
  84. canPop: false,
  85. onPopInvokedWithResult: (didPop, _) {
  86. if (!didPop) {
  87. if (_hasUnsaved()) {
  88. _showConfirmDialog(
  89. l10n.get('confirmExit'),
  90. l10n.get('unsavedContentWarning'),
  91. l10n.get('continueEditing'),
  92. l10n.get('discardAndExit'),
  93. () => context.pop(),
  94. );
  95. } else {
  96. context.pop();
  97. }
  98. }
  99. },
  100. child: Column(
  101. children: [
  102. Expanded(
  103. child: SingleChildScrollView(
  104. padding: const EdgeInsets.all(16),
  105. child: Column(
  106. children: [
  107. _buildBasicInfo(l10n),
  108. const SizedBox(height: 16),
  109. _buildTypeSpecificFields(l10n),
  110. const SizedBox(height: 16),
  111. _buildControlSection(l10n),
  112. const SizedBox(height: 16),
  113. _buildDetailsSection(l10n),
  114. const SizedBox(height: 16),
  115. _buildAttachmentSection(l10n),
  116. const SizedBox(height: 80),
  117. ],
  118. ),
  119. ),
  120. ),
  121. _buildBottomBar(l10n),
  122. ],
  123. ),
  124. );
  125. }
  126. // ═══ 1. 基本信息 ═══
  127. Widget _buildBasicInfo(AppLocalizations l10n) {
  128. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  129. return FormSection(
  130. title: l10n.get('basicInfo'),
  131. children: [
  132. FormFieldRow(
  133. label: l10n.get('applicant'),
  134. value: '张三',
  135. readOnly: true,
  136. showArrow: false,
  137. ),
  138. FormFieldRow(
  139. label: l10n.get('department'),
  140. value: '技术部',
  141. readOnly: true,
  142. showArrow: false,
  143. ),
  144. FormFieldRow(
  145. label: l10n.get('date'),
  146. value: _today(),
  147. readOnly: true,
  148. showArrow: false,
  149. ),
  150. const SizedBox(height: 12),
  151. _label(l10n.get('emergencyLevel')),
  152. const SizedBox(height: 6),
  153. _buildUrgencyRadio(),
  154. const SizedBox(height: 12),
  155. _label(l10n.get('expenseType')),
  156. const SizedBox(height: 6),
  157. Wrap(
  158. spacing: 8,
  159. runSpacing: 8,
  160. children: _expenseTypeOptions.map((opt) {
  161. final sel = _expenseTypes.contains(opt.$1);
  162. return GestureDetector(
  163. onTap: () => setState(
  164. () => sel
  165. ? _expenseTypes.remove(opt.$1)
  166. : _expenseTypes.add(opt.$1),
  167. ),
  168. child: TDTag(
  169. opt.$2,
  170. size: TDTagSize.medium,
  171. theme: sel ? TDTagTheme.primary : TDTagTheme.defaultTheme,
  172. isOutline: !sel,
  173. ),
  174. );
  175. }).toList(),
  176. ),
  177. const SizedBox(height: 12),
  178. Row(
  179. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  180. children: [
  181. _label(l10n.get('isTaxIncluded')),
  182. TDSwitch(
  183. isOn: _isTaxIncluded,
  184. onChanged: (v) {
  185. setState(() => _isTaxIncluded = v);
  186. return true;
  187. },
  188. ),
  189. ],
  190. ),
  191. const SizedBox(height: 12),
  192. _label(l10n.get('feeReason')),
  193. const SizedBox(height: 4),
  194. TDTextarea(
  195. controller: _purposeController,
  196. hintText: l10n.get('enterFeeReason'),
  197. maxLength: 200,
  198. backgroundColor: colors.bgPage,
  199. ),
  200. const SizedBox(height: 12),
  201. FormFieldRow(
  202. label: l10n.get('validUntil'),
  203. hint: l10n.get('pleaseSelect'),
  204. onTap: () => _pickDate((d) {}),
  205. ),
  206. ],
  207. );
  208. }
  209. Widget _buildUrgencyRadio() {
  210. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  211. return Row(
  212. children: List.generate(3, (i) {
  213. final sel = _urgency == i;
  214. return Padding(
  215. padding: EdgeInsets.only(right: i < 2 ? 24 : 0),
  216. child: GestureDetector(
  217. onTap: () => setState(() => _urgency = i),
  218. child: Row(
  219. mainAxisSize: MainAxisSize.min,
  220. children: [
  221. Container(
  222. width: 18,
  223. height: 18,
  224. decoration: BoxDecoration(
  225. shape: BoxShape.circle,
  226. border: Border.all(
  227. color: sel ? colors.primary : colors.textPlaceholder,
  228. width: 2,
  229. ),
  230. ),
  231. child: sel
  232. ? Center(
  233. child: Container(
  234. width: 8,
  235. height: 8,
  236. decoration: BoxDecoration(
  237. shape: BoxShape.circle,
  238. color: colors.primary,
  239. ),
  240. ),
  241. )
  242. : null,
  243. ),
  244. const SizedBox(width: 6),
  245. Text(
  246. _urgencyLabels[i],
  247. style: TextStyle(
  248. fontSize: AppFontSizes.body,
  249. color: sel ? colors.primary : colors.textSecondary,
  250. ),
  251. ),
  252. ],
  253. ),
  254. ),
  255. );
  256. }),
  257. );
  258. }
  259. // ═══ 2. 类型专用字段 ═══
  260. Widget _buildTypeSpecificFields(AppLocalizations l10n) {
  261. final ws = <Widget>[];
  262. if (_expenseTypes.contains('travel')) ws.add(_buildTravelFields(l10n));
  263. if (_expenseTypes.contains('entertainment')) {
  264. ws.add(const SizedBox(height: 16));
  265. ws.add(_buildEntertainmentFields(l10n));
  266. }
  267. if (_expenseTypes.contains('meeting')) {
  268. ws.add(const SizedBox(height: 16));
  269. ws.add(_buildMeetingFields(l10n));
  270. }
  271. return ws.isEmpty ? const SizedBox.shrink() : Column(children: ws);
  272. }
  273. Widget _buildTravelFields(AppLocalizations l10n) {
  274. return FormSection(
  275. title: l10n.get('travelExpense'),
  276. children: [
  277. FormFieldRow(
  278. label: l10n.get('estimatedStartDate'),
  279. value: _estimatedStartDate,
  280. hint: l10n.get('pleaseSelect'),
  281. onTap: () =>
  282. _pickDate((d) => setState(() => _estimatedStartDate = d)),
  283. ),
  284. FormFieldRow(
  285. label: l10n.get('estimatedEndDate'),
  286. value: _estimatedEndDate,
  287. hint: l10n.get('pleaseSelect'),
  288. onTap: () => _pickDate((d) => setState(() => _estimatedEndDate = d)),
  289. ),
  290. const SizedBox(height: 8),
  291. Row(
  292. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  293. children: [
  294. _label(l10n.get('isOvernight')),
  295. TDSwitch(onChanged: (_) => true),
  296. ],
  297. ),
  298. const SizedBox(height: 8),
  299. FormFieldRow(
  300. label: l10n.get('transportType'),
  301. value: '高铁/动车',
  302. onTap: () => _showListPicker(l10n.get('selectTransport'), [
  303. '飞机',
  304. '高铁/动车',
  305. '火车(普速)',
  306. '自驾',
  307. ], (_) {}),
  308. ),
  309. ],
  310. );
  311. }
  312. Widget _buildEntertainmentFields(AppLocalizations l10n) {
  313. return FormSection(
  314. title: l10n.get('entertainmentExpense'),
  315. children: [
  316. FormFieldRow(
  317. label: l10n.get('entertainmentTargetUnit'),
  318. value: _entertainmentTarget,
  319. hint: l10n.get('pleaseEnter'),
  320. onTap: () => _showTextInput(
  321. l10n.get('entertainmentTargetUnit'),
  322. (v) => setState(() => _entertainmentTarget = v),
  323. ),
  324. ),
  325. FormFieldRow(
  326. label: l10n.get('entertainmentLevel'),
  327. value: l10n.get('normal'),
  328. onTap: () => _showListPicker(l10n.get('selectEntertainmentLevel'), [l10n.get('normal'), l10n.get('important'), 'VIP'], (_) {}),
  329. ),
  330. FormFieldRow(
  331. label: l10n.get('externalCount'),
  332. value: '3',
  333. onTap: () => _showNumberInput(l10n.get('externalCount'), (_) {}),
  334. ),
  335. FormFieldRow(
  336. label: l10n.get('internalCount'),
  337. value: '2',
  338. onTap: () => _showNumberInput(l10n.get('internalCount'), (_) {}),
  339. ),
  340. FormFieldRow(
  341. label: l10n.get('venue'),
  342. value: _venue,
  343. hint: l10n.get('pleaseEnterLocation'),
  344. onTap: () => _showTextInput(
  345. l10n.get('venue'),
  346. (v) => setState(() => _venue = v),
  347. ),
  348. ),
  349. ],
  350. );
  351. }
  352. Widget _buildMeetingFields(AppLocalizations l10n) {
  353. return FormSection(
  354. title: l10n.get('meetingExpense'),
  355. children: [
  356. FormFieldRow(
  357. label: l10n.get('estimatedStartDate'),
  358. hint: l10n.get('pleaseSelect'),
  359. onTap: () => _pickDate((_) {}),
  360. ),
  361. FormFieldRow(
  362. label: l10n.get('estimatedEndDate'),
  363. hint: l10n.get('pleaseSelect'),
  364. onTap: () => _pickDate((_) {}),
  365. ),
  366. FormFieldRow(
  367. label: l10n.get('venue'),
  368. value: _venue,
  369. hint: l10n.get('pleaseEnterMeetingLocation'),
  370. onTap: () => _showTextInput(l10n.get('meetingLocation'), (_) {}),
  371. ),
  372. ],
  373. );
  374. }
  375. // ═══ 3. 关联管控 ═══
  376. static const _mockProjects = [
  377. ('华东市场拓展', 100),
  378. ('ERP系统升级', 101),
  379. ('新产品研发', 102),
  380. ('华南渠道建设', 103),
  381. ];
  382. static const _mockSubjects = [('差旅费', 5), ('招待费', 6), ('办公费', 7), ('培训费', 8)];
  383. Widget _buildControlSection(AppLocalizations l10n) {
  384. return FormSection(
  385. title: l10n.get('relatedControl'),
  386. children: [
  387. FormFieldRow(
  388. label: l10n.get('relatedProject'),
  389. value: _selectedProjectName,
  390. hint: l10n.get('selectProject'),
  391. onTap: () {
  392. _showListPicker(l10n.get('selectProject'), _mockProjects.map((p) => p.$1).toList(), (
  393. v,
  394. ) {
  395. final p = _mockProjects.firstWhere((x) => x.$1 == v);
  396. setState(() {
  397. _selectedProjectId = p.$2;
  398. _selectedProjectName = p.$1;
  399. _selectedSubjectName = null;
  400. _selectedSubjectId = null;
  401. });
  402. });
  403. },
  404. ),
  405. FormFieldRow(
  406. label: l10n.get('budgetSubject'),
  407. value: _selectedSubjectName,
  408. hint: l10n.get('selectSubject'),
  409. onTap: _selectedProjectId != null
  410. ? () {
  411. _showListPicker(
  412. l10n.get('selectSubject'),
  413. _mockSubjects.map((s) => s.$1).toList(),
  414. (v) {
  415. final s = _mockSubjects.firstWhere((x) => x.$1 == v);
  416. setState(() {
  417. _selectedSubjectId = s.$2;
  418. _selectedSubjectName = s.$1;
  419. });
  420. },
  421. );
  422. }
  423. : null,
  424. ),
  425. _buildBudgetRow(l10n),
  426. const SizedBox(height: 8),
  427. FormFieldRow(
  428. label: l10n.get('relatedContractNo'),
  429. value: _referenceNoController.text,
  430. hint: l10n.get('optional'),
  431. onTap: () => _showTextInput(
  432. l10n.get('relatedContractNo'),
  433. (v) => setState(() {
  434. _referenceNoController.text = v;
  435. _referenceNoController.selection = TextSelection.fromPosition(
  436. TextPosition(offset: v.length),
  437. );
  438. }),
  439. ),
  440. ),
  441. ],
  442. );
  443. }
  444. Widget _buildBudgetRow(AppLocalizations l10n) {
  445. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  446. final over = _totalAmount() > _availableBudget;
  447. return SizedBox(
  448. height: 44,
  449. child: Row(
  450. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  451. children: [
  452. Text(
  453. l10n.get('availableBudget'),
  454. style: TextStyle(
  455. fontSize: AppFontSizes.body,
  456. color: colors.textSecondary,
  457. ),
  458. ),
  459. Text(
  460. '¥${_availableBudget.toStringAsFixed(2)}',
  461. style: TextStyle(
  462. fontSize: AppFontSizes.subtitle,
  463. fontWeight: FontWeight.w700,
  464. color: over ? colors.danger : colors.amountPrimary,
  465. ),
  466. ),
  467. ],
  468. ),
  469. );
  470. }
  471. // ═══ 4. 费用明细 ═══
  472. Widget _buildDetailsSection(AppLocalizations l10n) {
  473. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  474. return FormSection(
  475. title: l10n.get('expenseDetails'),
  476. showAction: true,
  477. actionText: l10n.get('add'),
  478. onActionTap: _showDetailDialog,
  479. children: [
  480. if (_details.isEmpty)
  481. Padding(
  482. padding: const EdgeInsets.symmetric(vertical: 8),
  483. child: Text(
  484. l10n.get('noDetailHint'),
  485. style: TextStyle(
  486. fontSize: AppFontSizes.body,
  487. color: colors.textPlaceholder,
  488. ),
  489. ),
  490. )
  491. else
  492. ..._details.asMap().entries.map((e) {
  493. final d = e.value;
  494. return Container(
  495. padding: const EdgeInsets.symmetric(vertical: 6),
  496. decoration: BoxDecoration(
  497. border: e.key < _details.length - 1
  498. ? Border(bottom: BorderSide(color: colors.border))
  499. : null,
  500. ),
  501. child: Row(
  502. children: [
  503. Expanded(
  504. flex: 3,
  505. child: Column(
  506. crossAxisAlignment: CrossAxisAlignment.start,
  507. children: [
  508. Text(
  509. d.categoryName,
  510. style: TextStyle(
  511. fontSize: AppFontSizes.body,
  512. color: colors.textPrimary,
  513. ),
  514. ),
  515. if (d.remark.isNotEmpty)
  516. Text(
  517. d.remark,
  518. style: TextStyle(
  519. fontSize: AppFontSizes.caption,
  520. color: colors.textPlaceholder,
  521. ),
  522. ),
  523. ],
  524. ),
  525. ),
  526. Text(
  527. '${d.quantity}×¥${d.unitPrice.toStringAsFixed(2)}',
  528. style: TextStyle(
  529. fontSize: AppFontSizes.caption,
  530. color: colors.textSecondary,
  531. ),
  532. ),
  533. const SizedBox(width: 8),
  534. Text(
  535. '¥${d.amount.toStringAsFixed(2)}',
  536. style: TextStyle(
  537. fontSize: AppFontSizes.body,
  538. fontWeight: FontWeight.w600,
  539. color: colors.amountPrimary,
  540. ),
  541. ),
  542. GestureDetector(
  543. onTap: () => setState(() => _details.removeAt(e.key)),
  544. child: Icon(
  545. Icons.close,
  546. size: 16,
  547. color: colors.textPlaceholder,
  548. ),
  549. ),
  550. ],
  551. ),
  552. );
  553. }),
  554. Container(height: 1, color: colors.border),
  555. Container(
  556. height: 36,
  557. padding: const EdgeInsets.symmetric(vertical: 8),
  558. child: Row(
  559. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  560. children: [
  561. Text(
  562. l10n.get('total'),
  563. style: TextStyle(
  564. fontSize: AppFontSizes.body,
  565. fontWeight: FontWeight.w600,
  566. color: colors.textPrimary,
  567. ),
  568. ),
  569. Text(
  570. '¥${_totalAmount().toStringAsFixed(2)}',
  571. style: TextStyle(
  572. fontSize: AppFontSizes.subtitle,
  573. fontWeight: FontWeight.w700,
  574. color: colors.amountPrimary,
  575. ),
  576. ),
  577. ],
  578. ),
  579. ),
  580. if (_totalAmount() > _availableBudget)
  581. Padding(
  582. padding: const EdgeInsets.only(top: 8),
  583. child: Row(
  584. children: [
  585. Icon(Icons.warning_amber, size: 14, color: colors.danger),
  586. const SizedBox(width: 6),
  587. Expanded(
  588. child: Text(
  589. l10n.get('overBudgetTriggerApproval'),
  590. style: TextStyle(
  591. fontSize: AppFontSizes.caption,
  592. color: colors.danger,
  593. ),
  594. ),
  595. ),
  596. ],
  597. ),
  598. ),
  599. ],
  600. );
  601. }
  602. double _totalAmount() => _details.fold(0, (s, d) => s + d.amount);
  603. static const _detailCategories = [
  604. ('transport', '交通费'),
  605. ('hotel', '住宿费'),
  606. ('office_supplies', '办公用品'),
  607. ('meals', '餐饮费'),
  608. ('materials', '材料费'),
  609. ('service', '服务费'),
  610. ('other', '其他'),
  611. ];
  612. static const _units = ['张', '间', '人', '天', '套', '个'];
  613. void _showDetailDialog() {
  614. final l10n = AppLocalizations.of(context);
  615. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  616. String cat = 'transport';
  617. String unit = '张';
  618. final qtyCtrl = TextEditingController(text: '1');
  619. final priceCtrl = TextEditingController();
  620. final remarkCtrl = TextEditingController();
  621. showDialog(
  622. context: context,
  623. builder: (ctx) => StatefulBuilder(
  624. builder: (ctx, setDlg) => TDAlertDialog(
  625. title: l10n.get('addExpenseDetail'),
  626. contentWidget: Column(
  627. mainAxisSize: MainAxisSize.min,
  628. crossAxisAlignment: CrossAxisAlignment.start,
  629. children: [
  630. _label(l10n.get('expenseCategory')),
  631. const SizedBox(height: 4),
  632. GestureDetector(
  633. onTap: () {
  634. Navigator.pop(ctx);
  635. _showListPicker(
  636. l10n.get('selectExpenseCategory'),
  637. _detailCategories.map((c) => c.$2).toList(),
  638. (v) {
  639. cat = _detailCategories.firstWhere((c) => c.$2 == v).$1;
  640. _showDetailDialog();
  641. },
  642. );
  643. },
  644. child: Container(
  645. height: 44,
  646. padding: const EdgeInsets.symmetric(horizontal: 12),
  647. decoration: BoxDecoration(
  648. color: colors.bgPage,
  649. borderRadius: BorderRadius.circular(4),
  650. ),
  651. child: Row(
  652. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  653. children: [
  654. Text(
  655. _detailCategories.firstWhere((c) => c.$1 == cat).$2,
  656. style: const TextStyle(fontSize: AppFontSizes.body),
  657. ),
  658. Icon(
  659. Icons.arrow_drop_down,
  660. color: colors.textPlaceholder,
  661. ),
  662. ],
  663. ),
  664. ),
  665. ),
  666. const SizedBox(height: 12),
  667. Row(
  668. children: [
  669. Expanded(
  670. child: Column(
  671. crossAxisAlignment: CrossAxisAlignment.start,
  672. children: [
  673. _label(l10n.get('quantity')),
  674. const SizedBox(height: 4),
  675. TDInput(controller: qtyCtrl, hintText: '>0'),
  676. ],
  677. ),
  678. ),
  679. const SizedBox(width: 12),
  680. Expanded(
  681. child: Column(
  682. crossAxisAlignment: CrossAxisAlignment.start,
  683. children: [
  684. _label(l10n.get('unit')),
  685. const SizedBox(height: 4),
  686. GestureDetector(
  687. onTap: () {
  688. Navigator.pop(ctx);
  689. _showListPicker(l10n.get('selectUnit'), _units, (v) {
  690. unit = v;
  691. _showDetailDialog();
  692. });
  693. },
  694. child: Container(
  695. height: 44,
  696. padding: const EdgeInsets.symmetric(horizontal: 12),
  697. decoration: BoxDecoration(
  698. color: colors.bgPage,
  699. borderRadius: BorderRadius.circular(4),
  700. ),
  701. child: Row(
  702. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  703. children: [
  704. Text(
  705. unit,
  706. style: const TextStyle(
  707. fontSize: AppFontSizes.body,
  708. ),
  709. ),
  710. Icon(
  711. Icons.arrow_drop_down,
  712. color: colors.textPlaceholder,
  713. ),
  714. ],
  715. ),
  716. ),
  717. ),
  718. ],
  719. ),
  720. ),
  721. ],
  722. ),
  723. const SizedBox(height: 12),
  724. _label(l10n.get('unitPrice')),
  725. const SizedBox(height: 4),
  726. TDInput(controller: priceCtrl, hintText: '>0'),
  727. const SizedBox(height: 12),
  728. _label(l10n.get('detailRemark')),
  729. const SizedBox(height: 4),
  730. TDInput(controller: remarkCtrl, hintText: l10n.get('optional')),
  731. ],
  732. ),
  733. leftBtn: TDDialogButtonOptions(
  734. title: l10n.get('cancel'),
  735. action: () => Navigator.pop(ctx),
  736. ),
  737. rightBtn: TDDialogButtonOptions(
  738. title: l10n.get('confirm'),
  739. titleColor: colors.primary,
  740. action: () {
  741. final q = int.tryParse(qtyCtrl.text) ?? 0;
  742. final p = double.tryParse(priceCtrl.text) ?? 0;
  743. if (q <= 0 || p <= 0) {
  744. TDToast.showText(l10n.get('quantityPricePositive'), context: context);
  745. return;
  746. }
  747. setState(
  748. () => _details.add(
  749. _DetailItem(
  750. id: _detailIdCounter++,
  751. category: cat,
  752. categoryName: _detailCategories
  753. .firstWhere((c) => c.$1 == cat)
  754. .$2,
  755. quantity: q,
  756. unit: unit,
  757. unitPrice: p,
  758. amount: q * p,
  759. remark: remarkCtrl.text,
  760. ),
  761. ),
  762. );
  763. Navigator.pop(ctx);
  764. },
  765. ),
  766. ),
  767. ),
  768. );
  769. }
  770. // ═══ 5. 附件上传 ═══
  771. Widget _buildAttachmentSection(AppLocalizations l10n) {
  772. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  773. return FormSection(
  774. title: l10n.get('attachmentUpload'),
  775. children: [
  776. Text(
  777. l10n.get('maxAttachment'),
  778. style: TextStyle(
  779. fontSize: AppFontSizes.caption,
  780. color: colors.textPlaceholder,
  781. ),
  782. ),
  783. const SizedBox(height: 12),
  784. Wrap(
  785. spacing: 8,
  786. runSpacing: 8,
  787. children: [
  788. ..._attachments.asMap().entries.map(
  789. (e) => Stack(
  790. clipBehavior: Clip.none,
  791. children: [
  792. Container(
  793. width: 80,
  794. height: 80,
  795. decoration: BoxDecoration(
  796. color: colors.primaryLight,
  797. borderRadius: BorderRadius.circular(4),
  798. ),
  799. child: Center(
  800. child: Icon(Icons.image, color: colors.primary, size: 32),
  801. ),
  802. ),
  803. Positioned(
  804. right: -4,
  805. top: -4,
  806. child: GestureDetector(
  807. onTap: () => setState(() => _attachments.removeAt(e.key)),
  808. child: Container(
  809. width: 20,
  810. height: 20,
  811. decoration: BoxDecoration(
  812. color: colors.danger,
  813. shape: BoxShape.circle,
  814. ),
  815. child: const Icon(
  816. Icons.close,
  817. size: 12,
  818. color: Colors.white,
  819. ),
  820. ),
  821. ),
  822. ),
  823. ],
  824. ),
  825. ),
  826. if (_attachments.length < 9)
  827. GestureDetector(
  828. onTap: () {
  829. // Mock: add attachment
  830. setState(
  831. () => _attachments.add(
  832. '附件_${DateTime.now().millisecondsSinceEpoch}.jpg',
  833. ),
  834. );
  835. TDToast.showText(l10n.get('mockAttachmentAdded'), context: context);
  836. },
  837. child: Container(
  838. width: 80,
  839. height: 80,
  840. decoration: BoxDecoration(
  841. color: colors.bgPage,
  842. borderRadius: BorderRadius.circular(4),
  843. border: Border.all(color: colors.border),
  844. ),
  845. child: Center(
  846. child: Icon(
  847. Icons.add,
  848. size: 24,
  849. color: colors.textPlaceholder,
  850. ),
  851. ),
  852. ),
  853. ),
  854. ],
  855. ),
  856. ],
  857. );
  858. }
  859. // ═══ 6. 底部操作栏 ═══
  860. Widget _buildBottomBar(AppLocalizations l10n) {
  861. final isDraft = widget.id != null;
  862. return ActionBar(
  863. leftLabel: isDraft ? l10n.get('reset') : null,
  864. centerLabel: l10n.get('saveDraft'),
  865. rightLabel: l10n.get('submitApproval'),
  866. showLeft: isDraft,
  867. onLeftTap: isDraft
  868. ? () => _showConfirmDialog(
  869. l10n.get('confirmReset'),
  870. l10n.get('resetWarning'),
  871. l10n.get('cancel'),
  872. l10n.get('confirmReset'),
  873. _resetAll,
  874. )
  875. : null,
  876. onCenterTap: () {
  877. TDToast.showSuccess(l10n.get('draftSavedToast'), context: context);
  878. context.pop();
  879. },
  880. onRightTap: () {
  881. final err = _validate(l10n);
  882. if (err.isNotEmpty) {
  883. TDToast.showText(err.first, context: context);
  884. return;
  885. }
  886. TDToast.showSuccess(l10n.get('submittedAwaitingApproval'), context: context);
  887. context.pop();
  888. },
  889. );
  890. }
  891. List<String> _validate(AppLocalizations l10n) {
  892. final e = <String>[];
  893. if (_expenseTypes.isEmpty) e.add(l10n.get('selectAtLeastOneExpenseType'));
  894. if (_purposeController.text.trim().isEmpty) e.add(l10n.get('enterFeeReason'));
  895. if (_selectedProjectId == null) e.add(l10n.get('selectSubject'));
  896. if (_selectedSubjectId == null) e.add(l10n.get('selectSubject'));
  897. if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
  898. if (_expenseTypes.contains('travel')) {
  899. if (_estimatedStartDate.isEmpty) e.add(l10n.get('selectEstimatedStartDate'));
  900. if (_estimatedEndDate.isEmpty) e.add(l10n.get('selectEstimatedEndDate'));
  901. }
  902. return e;
  903. }
  904. void _resetAll() => setState(() {
  905. _purposeController.clear();
  906. _expenseTypes.clear();
  907. _urgency = 0;
  908. _isTaxIncluded = false;
  909. _selectedProjectId = null;
  910. _selectedProjectName = null;
  911. _selectedSubjectId = null;
  912. _selectedSubjectName = null;
  913. _referenceNoController.clear();
  914. _details.clear();
  915. _attachments.clear();
  916. _estimatedStartDate = '';
  917. _estimatedEndDate = '';
  918. _entertainmentTarget = '';
  919. _venue = '';
  920. });
  921. bool _hasUnsaved() =>
  922. _purposeController.text.isNotEmpty ||
  923. _expenseTypes.isNotEmpty ||
  924. _details.isNotEmpty ||
  925. _attachments.isNotEmpty ||
  926. _selectedProjectId != null;
  927. // ═══ 通用弹窗方法 ═══
  928. void _showConfirmDialog(
  929. String title,
  930. String content,
  931. String leftText,
  932. String rightText,
  933. VoidCallback onConfirm,
  934. ) {
  935. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  936. showDialog(
  937. context: context,
  938. builder: (ctx) => TDAlertDialog(
  939. title: title,
  940. content: content,
  941. leftBtn: TDDialogButtonOptions(
  942. title: leftText,
  943. titleColor: colors.primary,
  944. action: () => Navigator.pop(ctx),
  945. ),
  946. rightBtn: TDDialogButtonOptions(
  947. title: rightText,
  948. titleColor: colors.danger,
  949. action: () {
  950. Navigator.pop(ctx);
  951. onConfirm();
  952. },
  953. ),
  954. ),
  955. );
  956. }
  957. void _showListPicker(
  958. String title,
  959. List<String> items,
  960. Function(String) onPick,
  961. ) {
  962. final l10n = AppLocalizations.of(context);
  963. showDialog(
  964. context: context,
  965. builder: (ctx) => TDAlertDialog(
  966. title: title,
  967. contentWidget: SizedBox(
  968. width: double.maxFinite,
  969. child: ListView(
  970. shrinkWrap: true,
  971. children: items
  972. .map(
  973. (item) => TDCell(
  974. title: item,
  975. onClick: (_) {
  976. onPick(item);
  977. Navigator.pop(ctx);
  978. },
  979. ),
  980. )
  981. .toList(),
  982. ),
  983. ),
  984. leftBtn: TDDialogButtonOptions(
  985. title: l10n.get('cancel'),
  986. action: () => Navigator.pop(ctx),
  987. ),
  988. ),
  989. );
  990. }
  991. void _showTextInput(String title, Function(String) onConfirm) {
  992. final l10n = AppLocalizations.of(context);
  993. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  994. final c = TextEditingController();
  995. showDialog(
  996. context: context,
  997. builder: (ctx) => TDAlertDialog(
  998. title: title,
  999. contentWidget: TDInput(controller: c, hintText: l10n.get('pleaseEnter')),
  1000. leftBtn: TDDialogButtonOptions(
  1001. title: l10n.get('cancel'),
  1002. action: () => Navigator.pop(ctx),
  1003. ),
  1004. rightBtn: TDDialogButtonOptions(
  1005. title: l10n.get('confirm'),
  1006. titleColor: colors.primary,
  1007. action: () {
  1008. onConfirm(c.text);
  1009. Navigator.pop(ctx);
  1010. },
  1011. ),
  1012. ),
  1013. );
  1014. }
  1015. void _showNumberInput(String title, Function(int) onConfirm) {
  1016. final l10n = AppLocalizations.of(context);
  1017. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1018. final c = TextEditingController();
  1019. showDialog(
  1020. context: context,
  1021. builder: (ctx) => TDAlertDialog(
  1022. title: title,
  1023. contentWidget: TDInput(
  1024. controller: c,
  1025. inputType: TextInputType.number,
  1026. hintText: l10n.get('enterNumber'),
  1027. ),
  1028. leftBtn: TDDialogButtonOptions(
  1029. title: l10n.get('cancel'),
  1030. action: () => Navigator.pop(ctx),
  1031. ),
  1032. rightBtn: TDDialogButtonOptions(
  1033. title: l10n.get('confirm'),
  1034. titleColor: colors.primary,
  1035. action: () {
  1036. onConfirm(int.tryParse(c.text) ?? 0);
  1037. Navigator.pop(ctx);
  1038. },
  1039. ),
  1040. ),
  1041. );
  1042. }
  1043. void _pickDate(Function(String) onPick) {
  1044. final l10n = AppLocalizations.of(context);
  1045. final now = DateTime.now();
  1046. TDPicker.showDatePicker(
  1047. context,
  1048. title: l10n.get('selectDate'),
  1049. useYear: true,
  1050. useMonth: true,
  1051. useDay: true,
  1052. initialDate: [now.year, now.month, now.day],
  1053. onConfirm: (selected) {
  1054. onPick(
  1055. '${selected['year']}-${selected['month']!.toString().padLeft(2, '0')}-${selected['day']!.toString().padLeft(2, '0')}',
  1056. );
  1057. },
  1058. );
  1059. }
  1060. Widget _label(String t) {
  1061. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1062. return Text(
  1063. t,
  1064. style: TextStyle(
  1065. fontSize: AppFontSizes.body,
  1066. color: colors.textSecondary,
  1067. ),
  1068. );
  1069. }
  1070. String _today() {
  1071. final n = DateTime.now();
  1072. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1073. }
  1074. }
  1075. class _DetailItem {
  1076. final int id;
  1077. final String category;
  1078. final String categoryName;
  1079. final int quantity;
  1080. final String unit;
  1081. final double unitPrice;
  1082. final double amount;
  1083. final String remark;
  1084. const _DetailItem({
  1085. required this.id,
  1086. required this.category,
  1087. required this.categoryName,
  1088. required this.quantity,
  1089. required this.unit,
  1090. required this.unitPrice,
  1091. required this.amount,
  1092. required this.remark,
  1093. });
  1094. }