expense_application_apply_page.dart 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128
  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. '确认退出',
  72. '当前内容尚未保存,是否退出?',
  73. '继续编辑',
  74. '放弃并退出',
  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. '确认退出',
  90. '当前内容尚未保存,是否退出?',
  91. '继续编辑',
  92. '放弃并退出',
  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: '差旅费专用',
  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('选择交通工具', [
  303. '飞机',
  304. '高铁/动车',
  305. '火车(普速)',
  306. '自驾',
  307. ], (_) {}),
  308. ),
  309. ],
  310. );
  311. }
  312. Widget _buildEntertainmentFields(AppLocalizations l10n) {
  313. return FormSection(
  314. title: '招待费专用',
  315. children: [
  316. FormFieldRow(
  317. label: '招待对象单位',
  318. value: _entertainmentTarget,
  319. hint: '请输入',
  320. onTap: () => _showTextInput(
  321. '招待对象单位',
  322. (v) => setState(() => _entertainmentTarget = v),
  323. ),
  324. ),
  325. FormFieldRow(
  326. label: '招待层级',
  327. value: '普通',
  328. onTap: () => _showListPicker('选择招待层级', ['普通', '重要', 'VIP'], (_) {}),
  329. ),
  330. FormFieldRow(
  331. label: '外部人数',
  332. value: '3',
  333. onTap: () => _showNumberInput('外部人数', (_) {}),
  334. ),
  335. FormFieldRow(
  336. label: '内部陪同人数',
  337. value: '2',
  338. onTap: () => _showNumberInput('内部陪同人数', (_) {}),
  339. ),
  340. FormFieldRow(
  341. label: l10n.get('venue'),
  342. value: _venue,
  343. hint: '请输入地点',
  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: '会议费专用',
  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: '请输入会议地点',
  370. onTap: () => _showTextInput('会议地点', (_) {}),
  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('选择关联项目', _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. '选择预算科目',
  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: '关联合同号',
  429. value: _referenceNoController.text,
  430. hint: '选填',
  431. onTap: () => _showTextInput(
  432. '关联合同号',
  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. '您的申请金额已超支,提交后将自动触发高管特批流程',
  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 colors = Theme.of(context).extension<AppColorsExtension>()!;
  615. String cat = 'transport';
  616. String unit = '张';
  617. final qtyCtrl = TextEditingController(text: '1');
  618. final priceCtrl = TextEditingController();
  619. final remarkCtrl = TextEditingController();
  620. showDialog(
  621. context: context,
  622. builder: (ctx) => StatefulBuilder(
  623. builder: (ctx, setDlg) => TDAlertDialog(
  624. title: '添加费用明细',
  625. contentWidget: Column(
  626. mainAxisSize: MainAxisSize.min,
  627. crossAxisAlignment: CrossAxisAlignment.start,
  628. children: [
  629. _label('费用类别'),
  630. const SizedBox(height: 4),
  631. GestureDetector(
  632. onTap: () {
  633. Navigator.pop(ctx);
  634. _showListPicker(
  635. '选择费用类别',
  636. _detailCategories.map((c) => c.$2).toList(),
  637. (v) {
  638. cat = _detailCategories.firstWhere((c) => c.$2 == v).$1;
  639. _showDetailDialog();
  640. },
  641. );
  642. },
  643. child: Container(
  644. height: 44,
  645. padding: const EdgeInsets.symmetric(horizontal: 12),
  646. decoration: BoxDecoration(
  647. color: colors.bgPage,
  648. borderRadius: BorderRadius.circular(4),
  649. ),
  650. child: Row(
  651. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  652. children: [
  653. Text(
  654. _detailCategories.firstWhere((c) => c.$1 == cat).$2,
  655. style: const TextStyle(fontSize: AppFontSizes.body),
  656. ),
  657. Icon(
  658. Icons.arrow_drop_down,
  659. color: colors.textPlaceholder,
  660. ),
  661. ],
  662. ),
  663. ),
  664. ),
  665. const SizedBox(height: 12),
  666. Row(
  667. children: [
  668. Expanded(
  669. child: Column(
  670. crossAxisAlignment: CrossAxisAlignment.start,
  671. children: [
  672. _label('数量'),
  673. const SizedBox(height: 4),
  674. TDInput(controller: qtyCtrl, hintText: '>0'),
  675. ],
  676. ),
  677. ),
  678. const SizedBox(width: 12),
  679. Expanded(
  680. child: Column(
  681. crossAxisAlignment: CrossAxisAlignment.start,
  682. children: [
  683. _label('单位'),
  684. const SizedBox(height: 4),
  685. GestureDetector(
  686. onTap: () {
  687. Navigator.pop(ctx);
  688. _showListPicker('选择单位', _units, (v) {
  689. unit = v;
  690. _showDetailDialog();
  691. });
  692. },
  693. child: Container(
  694. height: 44,
  695. padding: const EdgeInsets.symmetric(horizontal: 12),
  696. decoration: BoxDecoration(
  697. color: colors.bgPage,
  698. borderRadius: BorderRadius.circular(4),
  699. ),
  700. child: Row(
  701. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  702. children: [
  703. Text(
  704. unit,
  705. style: const TextStyle(
  706. fontSize: AppFontSizes.body,
  707. ),
  708. ),
  709. Icon(
  710. Icons.arrow_drop_down,
  711. color: colors.textPlaceholder,
  712. ),
  713. ],
  714. ),
  715. ),
  716. ),
  717. ],
  718. ),
  719. ),
  720. ],
  721. ),
  722. const SizedBox(height: 12),
  723. _label('单价'),
  724. const SizedBox(height: 4),
  725. TDInput(controller: priceCtrl, hintText: '>0'),
  726. const SizedBox(height: 12),
  727. _label('明细说明'),
  728. const SizedBox(height: 4),
  729. TDInput(controller: remarkCtrl, hintText: '选填'),
  730. ],
  731. ),
  732. leftBtn: TDDialogButtonOptions(
  733. title: '取消',
  734. action: () => Navigator.pop(ctx),
  735. ),
  736. rightBtn: TDDialogButtonOptions(
  737. title: '确定',
  738. titleColor: colors.primary,
  739. action: () {
  740. final q = int.tryParse(qtyCtrl.text) ?? 0;
  741. final p = double.tryParse(priceCtrl.text) ?? 0;
  742. if (q <= 0 || p <= 0) {
  743. TDToast.showText('数量和单价必须大于0', context: context);
  744. return;
  745. }
  746. setState(
  747. () => _details.add(
  748. _DetailItem(
  749. id: _detailIdCounter++,
  750. category: cat,
  751. categoryName: _detailCategories
  752. .firstWhere((c) => c.$1 == cat)
  753. .$2,
  754. quantity: q,
  755. unit: unit,
  756. unitPrice: p,
  757. amount: q * p,
  758. remark: remarkCtrl.text,
  759. ),
  760. ),
  761. );
  762. Navigator.pop(ctx);
  763. },
  764. ),
  765. ),
  766. ),
  767. );
  768. }
  769. // ═══ 5. 附件上传 ═══
  770. Widget _buildAttachmentSection(AppLocalizations l10n) {
  771. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  772. return FormSection(
  773. title: l10n.get('attachmentUpload'),
  774. children: [
  775. Text(
  776. l10n.get('maxAttachment'),
  777. style: TextStyle(
  778. fontSize: AppFontSizes.caption,
  779. color: colors.textPlaceholder,
  780. ),
  781. ),
  782. const SizedBox(height: 12),
  783. Wrap(
  784. spacing: 8,
  785. runSpacing: 8,
  786. children: [
  787. ..._attachments.asMap().entries.map(
  788. (e) => Stack(
  789. clipBehavior: Clip.none,
  790. children: [
  791. Container(
  792. width: 80,
  793. height: 80,
  794. decoration: BoxDecoration(
  795. color: colors.primaryLight,
  796. borderRadius: BorderRadius.circular(4),
  797. ),
  798. child: Center(
  799. child: Icon(Icons.image, color: colors.primary, size: 32),
  800. ),
  801. ),
  802. Positioned(
  803. right: -4,
  804. top: -4,
  805. child: GestureDetector(
  806. onTap: () => setState(() => _attachments.removeAt(e.key)),
  807. child: Container(
  808. width: 20,
  809. height: 20,
  810. decoration: BoxDecoration(
  811. color: colors.danger,
  812. shape: BoxShape.circle,
  813. ),
  814. child: const Icon(
  815. Icons.close,
  816. size: 12,
  817. color: Colors.white,
  818. ),
  819. ),
  820. ),
  821. ),
  822. ],
  823. ),
  824. ),
  825. if (_attachments.length < 9)
  826. GestureDetector(
  827. onTap: () {
  828. // Mock: add attachment
  829. setState(
  830. () => _attachments.add(
  831. '附件_${DateTime.now().millisecondsSinceEpoch}.jpg',
  832. ),
  833. );
  834. TDToast.showText('已添加附件(mock)', context: context);
  835. },
  836. child: Container(
  837. width: 80,
  838. height: 80,
  839. decoration: BoxDecoration(
  840. color: colors.bgPage,
  841. borderRadius: BorderRadius.circular(4),
  842. border: Border.all(color: colors.border),
  843. ),
  844. child: Center(
  845. child: Icon(
  846. Icons.add,
  847. size: 24,
  848. color: colors.textPlaceholder,
  849. ),
  850. ),
  851. ),
  852. ),
  853. ],
  854. ),
  855. ],
  856. );
  857. }
  858. // ═══ 6. 底部操作栏 ═══
  859. Widget _buildBottomBar(AppLocalizations l10n) {
  860. final isDraft = widget.id != null;
  861. return ActionBar(
  862. leftLabel: isDraft ? l10n.get('reset') : null,
  863. centerLabel: l10n.get('saveDraft'),
  864. rightLabel: l10n.get('submitApproval'),
  865. showLeft: isDraft,
  866. onLeftTap: isDraft
  867. ? () => _showConfirmDialog(
  868. '确认重置',
  869. '将清空所有已填内容,此操作不可撤销',
  870. '取消',
  871. '确认重置',
  872. _resetAll,
  873. )
  874. : null,
  875. onCenterTap: () {
  876. TDToast.showSuccess('已保存为草稿', context: context);
  877. context.pop();
  878. },
  879. onRightTap: () {
  880. final err = _validate();
  881. if (err.isNotEmpty) {
  882. TDToast.showText(err.first, context: context);
  883. return;
  884. }
  885. TDToast.showSuccess('已提交,等待审批', context: context);
  886. context.pop();
  887. },
  888. );
  889. }
  890. List<String> _validate() {
  891. final e = <String>[];
  892. if (_expenseTypes.isEmpty) e.add('请至少选择一项费用类型');
  893. if (_purposeController.text.trim().isEmpty) e.add('请填写费用事由');
  894. if (_selectedProjectId == null) e.add('请选择关联项目');
  895. if (_selectedSubjectId == null) e.add('请选择预算科目');
  896. if (_details.isEmpty) e.add('请至少添加一行费用明细');
  897. if (_expenseTypes.contains('travel')) {
  898. if (_estimatedStartDate.isEmpty) e.add('请选择预计开始日期');
  899. if (_estimatedEndDate.isEmpty) e.add('请选择预计结束日期');
  900. }
  901. return e;
  902. }
  903. void _resetAll() => setState(() {
  904. _purposeController.clear();
  905. _expenseTypes.clear();
  906. _urgency = 0;
  907. _isTaxIncluded = false;
  908. _selectedProjectId = null;
  909. _selectedProjectName = null;
  910. _selectedSubjectId = null;
  911. _selectedSubjectName = null;
  912. _referenceNoController.clear();
  913. _details.clear();
  914. _attachments.clear();
  915. _estimatedStartDate = '';
  916. _estimatedEndDate = '';
  917. _entertainmentTarget = '';
  918. _venue = '';
  919. });
  920. bool _hasUnsaved() =>
  921. _purposeController.text.isNotEmpty ||
  922. _expenseTypes.isNotEmpty ||
  923. _details.isNotEmpty ||
  924. _attachments.isNotEmpty ||
  925. _selectedProjectId != null;
  926. // ═══ 通用弹窗方法 ═══
  927. void _showConfirmDialog(
  928. String title,
  929. String content,
  930. String leftText,
  931. String rightText,
  932. VoidCallback onConfirm,
  933. ) {
  934. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  935. showDialog(
  936. context: context,
  937. builder: (ctx) => TDAlertDialog(
  938. title: title,
  939. content: content,
  940. leftBtn: TDDialogButtonOptions(
  941. title: leftText,
  942. titleColor: colors.primary,
  943. action: () => Navigator.pop(ctx),
  944. ),
  945. rightBtn: TDDialogButtonOptions(
  946. title: rightText,
  947. titleColor: colors.danger,
  948. action: () {
  949. Navigator.pop(ctx);
  950. onConfirm();
  951. },
  952. ),
  953. ),
  954. );
  955. }
  956. void _showListPicker(
  957. String title,
  958. List<String> items,
  959. Function(String) onPick,
  960. ) {
  961. showDialog(
  962. context: context,
  963. builder: (ctx) => TDAlertDialog(
  964. title: title,
  965. contentWidget: SizedBox(
  966. width: double.maxFinite,
  967. child: ListView(
  968. shrinkWrap: true,
  969. children: items
  970. .map(
  971. (item) => TDCell(
  972. title: item,
  973. onClick: (_) {
  974. onPick(item);
  975. Navigator.pop(ctx);
  976. },
  977. ),
  978. )
  979. .toList(),
  980. ),
  981. ),
  982. leftBtn: TDDialogButtonOptions(
  983. title: '取消',
  984. action: () => Navigator.pop(ctx),
  985. ),
  986. ),
  987. );
  988. }
  989. void _showTextInput(String title, Function(String) onConfirm) {
  990. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  991. final c = TextEditingController();
  992. showDialog(
  993. context: context,
  994. builder: (ctx) => TDAlertDialog(
  995. title: title,
  996. contentWidget: TDInput(controller: c, hintText: '请输入'),
  997. leftBtn: TDDialogButtonOptions(
  998. title: '取消',
  999. action: () => Navigator.pop(ctx),
  1000. ),
  1001. rightBtn: TDDialogButtonOptions(
  1002. title: '确定',
  1003. titleColor: colors.primary,
  1004. action: () {
  1005. onConfirm(c.text);
  1006. Navigator.pop(ctx);
  1007. },
  1008. ),
  1009. ),
  1010. );
  1011. }
  1012. void _showNumberInput(String title, Function(int) onConfirm) {
  1013. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1014. final c = TextEditingController();
  1015. showDialog(
  1016. context: context,
  1017. builder: (ctx) => TDAlertDialog(
  1018. title: title,
  1019. contentWidget: TDInput(
  1020. controller: c,
  1021. inputType: TextInputType.number,
  1022. hintText: '请输入数字',
  1023. ),
  1024. leftBtn: TDDialogButtonOptions(
  1025. title: '取消',
  1026. action: () => Navigator.pop(ctx),
  1027. ),
  1028. rightBtn: TDDialogButtonOptions(
  1029. title: '确定',
  1030. titleColor: colors.primary,
  1031. action: () {
  1032. onConfirm(int.tryParse(c.text) ?? 0);
  1033. Navigator.pop(ctx);
  1034. },
  1035. ),
  1036. ),
  1037. );
  1038. }
  1039. void _pickDate(Function(String) onPick) {
  1040. final now = DateTime.now();
  1041. TDPicker.showDatePicker(
  1042. context,
  1043. title: '选择日期',
  1044. useYear: true,
  1045. useMonth: true,
  1046. useDay: true,
  1047. initialDate: [now.year, now.month, now.day],
  1048. onConfirm: (selected) {
  1049. onPick(
  1050. '${selected['year']}-${selected['month']!.toString().padLeft(2, '0')}-${selected['day']!.toString().padLeft(2, '0')}',
  1051. );
  1052. },
  1053. );
  1054. }
  1055. Widget _label(String t) {
  1056. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  1057. return Text(
  1058. t,
  1059. style: TextStyle(
  1060. fontSize: AppFontSizes.body,
  1061. color: colors.textSecondary,
  1062. ),
  1063. );
  1064. }
  1065. String _today() {
  1066. final n = DateTime.now();
  1067. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  1068. }
  1069. }
  1070. class _DetailItem {
  1071. final int id;
  1072. final String category;
  1073. final String categoryName;
  1074. final int quantity;
  1075. final String unit;
  1076. final double unitPrice;
  1077. final double amount;
  1078. final String remark;
  1079. const _DetailItem({
  1080. required this.id,
  1081. required this.category,
  1082. required this.categoryName,
  1083. required this.quantity,
  1084. required this.unit,
  1085. required this.unitPrice,
  1086. required this.amount,
  1087. required this.remark,
  1088. });
  1089. }