expense_application_apply_page.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  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/theme/app_colors.dart';
  6. import '../../core/i18n/app_localizations.dart';
  7. import '../../shared/widgets/action_bar.dart';
  8. import '../../shared/widgets/form_section.dart';
  9. import '../../shared/widgets/form_field_row.dart';
  10. import '../shell/nav_bar_config.dart';
  11. class ExpenseApplicationApplyPage extends ConsumerStatefulWidget {
  12. final String? id;
  13. const ExpenseApplicationApplyPage({super.key, this.id});
  14. @override
  15. ConsumerState<ExpenseApplicationApplyPage> createState() =>
  16. _ExpenseApplicationApplyPageState();
  17. }
  18. class _ExpenseApplicationApplyPageState
  19. extends ConsumerState<ExpenseApplicationApplyPage> {
  20. // ── 基本信息 ──
  21. int _urgency = 0; // 0=普通, 1=紧急, 2=特急
  22. static const _urgencyLabels = ['普通', '紧急', '特急'];
  23. final Set<String> _expenseTypes = {};
  24. static const _expenseTypeOptions = [
  25. ('travel', '差旅费'),
  26. ('entertainment', '业务招待费'),
  27. ('procurement', '日常采购'),
  28. ('activity', '活动经费'),
  29. ('office', '办公费'),
  30. ('meeting', '会议费'),
  31. ('training', '培训费'),
  32. ];
  33. bool _isTaxIncluded = false;
  34. final _purposeController = TextEditingController();
  35. // ── 关联管控 ──
  36. String? _selectedProjectName;
  37. int? _selectedProjectId;
  38. String? _selectedSubjectName;
  39. int? _selectedSubjectId;
  40. final _availableBudget = 50000.00;
  41. final _referenceNoController = TextEditingController();
  42. // ── 费用明细 ──
  43. final List<_DetailItem> _details = [];
  44. int _detailIdCounter = 1;
  45. // ── 附件 ──
  46. final List<String> _attachments = []; // mock file names
  47. // ── 专用字段 ──
  48. String _estimatedStartDate = '';
  49. String _estimatedEndDate = '';
  50. String _entertainmentTarget = '';
  51. String _venue = '';
  52. @override
  53. void dispose() {
  54. _purposeController.dispose();
  55. _referenceNoController.dispose();
  56. super.dispose();
  57. }
  58. @override
  59. Widget build(BuildContext context) {
  60. final l10n = AppLocalizations.of(context);
  61. ref.read(navBarConfigProvider.notifier).update(
  62. NavBarConfig(
  63. title: l10n.get('expenseApplyRequest'),
  64. showBack: true,
  65. onBack: () {
  66. if (_hasUnsaved()) {
  67. _showConfirmDialog('确认退出', '当前内容尚未保存,是否退出?', '继续编辑', '放弃并退出', () => context.pop());
  68. } else {
  69. context.pop();
  70. }
  71. },
  72. ),
  73. );
  74. return PopScope(
  75. canPop: false,
  76. onPopInvokedWithResult: (didPop, _) {
  77. if (!didPop) {
  78. if (_hasUnsaved()) {
  79. _showConfirmDialog('确认退出', '当前内容尚未保存,是否退出?', '继续编辑', '放弃并退出', () => context.pop());
  80. } else {
  81. context.pop();
  82. }
  83. }
  84. },
  85. child: Column(
  86. children: [
  87. Expanded(
  88. child: SingleChildScrollView(
  89. padding: const EdgeInsets.all(16),
  90. child: Column(
  91. children: [
  92. _buildBasicInfo(l10n),
  93. const SizedBox(height: 16),
  94. _buildTypeSpecificFields(l10n),
  95. const SizedBox(height: 16),
  96. _buildControlSection(l10n),
  97. const SizedBox(height: 16),
  98. _buildDetailsSection(l10n),
  99. const SizedBox(height: 16),
  100. _buildAttachmentSection(l10n),
  101. const SizedBox(height: 80),
  102. ],
  103. ),
  104. ),
  105. ),
  106. _buildBottomBar(l10n),
  107. ],
  108. ),
  109. );
  110. }
  111. // ═══ 1. 基本信息 ═══
  112. Widget _buildBasicInfo(AppLocalizations l10n) {
  113. return FormSection(
  114. title: l10n.get('basicInfo'),
  115. children: [
  116. FormFieldRow(label: l10n.get('applicant'), value: '张三', readOnly: true, showArrow: false),
  117. FormFieldRow(label: l10n.get('department'), value: '技术部', readOnly: true, showArrow: false),
  118. FormFieldRow(label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false),
  119. const SizedBox(height: 12),
  120. _label(l10n.get('emergencyLevel')),
  121. const SizedBox(height: 6),
  122. _buildUrgencyRadio(),
  123. const SizedBox(height: 12),
  124. _label(l10n.get('expenseType')),
  125. const SizedBox(height: 6),
  126. Wrap(
  127. spacing: 8, runSpacing: 8,
  128. children: _expenseTypeOptions.map((opt) {
  129. final sel = _expenseTypes.contains(opt.$1);
  130. return GestureDetector(
  131. onTap: () => setState(() => sel ? _expenseTypes.remove(opt.$1) : _expenseTypes.add(opt.$1)),
  132. child: TDTag(opt.$2, size: TDTagSize.medium, theme: sel ? TDTagTheme.primary : TDTagTheme.defaultTheme, isOutline: !sel),
  133. );
  134. }).toList(),
  135. ),
  136. const SizedBox(height: 12),
  137. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  138. _label(l10n.get('isTaxIncluded')),
  139. TDSwitch(isOn: _isTaxIncluded, onChanged: (v) { setState(() => _isTaxIncluded = v); return true; }),
  140. ]),
  141. const SizedBox(height: 12),
  142. _label(l10n.get('feeReason')),
  143. const SizedBox(height: 4),
  144. TDTextarea(
  145. controller: _purposeController,
  146. hintText: l10n.get('enterFeeReason'),
  147. maxLength: 200,
  148. backgroundColor: AppColors.bgPage,
  149. ),
  150. const SizedBox(height: 12),
  151. FormFieldRow(label: l10n.get('validUntil'), hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) {})),
  152. ],
  153. );
  154. }
  155. Widget _buildUrgencyRadio() {
  156. return Row(
  157. children: List.generate(3, (i) {
  158. final sel = _urgency == i;
  159. return Padding(
  160. padding: EdgeInsets.only(right: i < 2 ? 24 : 0),
  161. child: GestureDetector(
  162. onTap: () => setState(() => _urgency = i),
  163. child: Row(mainAxisSize: MainAxisSize.min, children: [
  164. Container(
  165. width: 18, height: 18,
  166. decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: sel ? AppColors.primary : AppColors.textPlaceholder, width: 2)),
  167. child: sel ? Center(child: Container(width: 8, height: 8, decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.primary))) : null,
  168. ),
  169. const SizedBox(width: 6),
  170. Text(_urgencyLabels[i], style: TextStyle(fontSize: AppFontSizes.body, color: sel ? AppColors.primary : AppColors.textSecondary)),
  171. ]),
  172. ),
  173. );
  174. }),
  175. );
  176. }
  177. // ═══ 2. 类型专用字段 ═══
  178. Widget _buildTypeSpecificFields(AppLocalizations l10n) {
  179. final ws = <Widget>[];
  180. if (_expenseTypes.contains('travel')) ws.add(_buildTravelFields(l10n));
  181. if (_expenseTypes.contains('entertainment')) { ws.add(const SizedBox(height: 16)); ws.add(_buildEntertainmentFields(l10n)); }
  182. if (_expenseTypes.contains('meeting')) { ws.add(const SizedBox(height: 16)); ws.add(_buildMeetingFields(l10n)); }
  183. return ws.isEmpty ? const SizedBox.shrink() : Column(children: ws);
  184. }
  185. Widget _buildTravelFields(AppLocalizations l10n) {
  186. return FormSection(title: '差旅费专用', children: [
  187. FormFieldRow(label: l10n.get('estimatedStartDate'), value: _estimatedStartDate, hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) => setState(() => _estimatedStartDate = d))),
  188. FormFieldRow(label: l10n.get('estimatedEndDate'), value: _estimatedEndDate, hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) => setState(() => _estimatedEndDate = d))),
  189. const SizedBox(height: 8),
  190. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [_label(l10n.get('isOvernight')), TDSwitch(onChanged: (_) => true)]),
  191. const SizedBox(height: 8),
  192. FormFieldRow(label: l10n.get('transportType'), value: '高铁/动车', onTap: () => _showListPicker('选择交通工具', ['飞机', '高铁/动车', '火车(普速)', '自驾'], (_) {})),
  193. ]);
  194. }
  195. Widget _buildEntertainmentFields(AppLocalizations l10n) {
  196. return FormSection(title: '招待费专用', children: [
  197. FormFieldRow(label: '招待对象单位', value: _entertainmentTarget, hint: '请输入', onTap: () => _showTextInput('招待对象单位', (v) => setState(() => _entertainmentTarget = v))),
  198. FormFieldRow(label: '招待层级', value: '普通', onTap: () => _showListPicker('选择招待层级', ['普通', '重要', 'VIP'], (_) {})),
  199. FormFieldRow(label: '外部人数', value: '3', onTap: () => _showNumberInput('外部人数', (_) {})),
  200. FormFieldRow(label: '内部陪同人数', value: '2', onTap: () => _showNumberInput('内部陪同人数', (_) {})),
  201. FormFieldRow(label: l10n.get('venue'), value: _venue, hint: '请输入地点', onTap: () => _showTextInput(l10n.get('venue'), (v) => setState(() => _venue = v))),
  202. ]);
  203. }
  204. Widget _buildMeetingFields(AppLocalizations l10n) {
  205. return FormSection(title: '会议费专用', children: [
  206. FormFieldRow(label: l10n.get('estimatedStartDate'), hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((_) {})),
  207. FormFieldRow(label: l10n.get('estimatedEndDate'), hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((_) {})),
  208. FormFieldRow(label: l10n.get('venue'), value: _venue, hint: '请输入会议地点', onTap: () => _showTextInput('会议地点', (_) {})),
  209. ]);
  210. }
  211. // ═══ 3. 关联管控 ═══
  212. static const _mockProjects = [('华东市场拓展', 100), ('ERP系统升级', 101), ('新产品研发', 102), ('华南渠道建设', 103)];
  213. static const _mockSubjects = [('差旅费', 5), ('招待费', 6), ('办公费', 7), ('培训费', 8)];
  214. Widget _buildControlSection(AppLocalizations l10n) {
  215. return FormSection(title: l10n.get('relatedControl'), children: [
  216. FormFieldRow(label: l10n.get('relatedProject'), value: _selectedProjectName, hint: l10n.get('selectProject'), onTap: () {
  217. _showListPicker('选择关联项目', _mockProjects.map((p) => p.$1).toList(), (v) {
  218. final p = _mockProjects.firstWhere((x) => x.$1 == v);
  219. setState(() { _selectedProjectId = p.$2; _selectedProjectName = p.$1; _selectedSubjectName = null; _selectedSubjectId = null; });
  220. });
  221. }),
  222. FormFieldRow(
  223. label: l10n.get('budgetSubject'),
  224. value: _selectedSubjectName,
  225. hint: l10n.get('selectSubject'),
  226. onTap: _selectedProjectId != null
  227. ? () {
  228. _showListPicker('选择预算科目', _mockSubjects.map((s) => s.$1).toList(), (v) {
  229. final s = _mockSubjects.firstWhere((x) => x.$1 == v);
  230. setState(() { _selectedSubjectId = s.$2; _selectedSubjectName = s.$1; });
  231. });
  232. }
  233. : null,
  234. ),
  235. _buildBudgetRow(l10n),
  236. const SizedBox(height: 8),
  237. FormFieldRow(label: '关联合同号', value: _referenceNoController.text, hint: '选填', onTap: () => _showTextInput('关联合同号', (v) => setState(() { _referenceNoController.text = v; _referenceNoController.selection = TextSelection.fromPosition(TextPosition(offset: v.length)); }))),
  238. ]);
  239. }
  240. Widget _buildBudgetRow(AppLocalizations l10n) {
  241. final over = _totalAmount() > _availableBudget;
  242. return SizedBox(
  243. height: 44,
  244. child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  245. Text(l10n.get('availableBudget'), style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textSecondary)),
  246. Text('¥${_availableBudget.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: over ? AppColors.danger : AppColors.amountPrimary)),
  247. ]),
  248. );
  249. }
  250. // ═══ 4. 费用明细 ═══
  251. Widget _buildDetailsSection(AppLocalizations l10n) {
  252. return FormSection(title: l10n.get('expenseDetails'), showAction: true, actionText: l10n.get('add'), onActionTap: _showDetailDialog, children: [
  253. if (_details.isEmpty)
  254. Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l10n.get('noDetailHint'), style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textPlaceholder)))
  255. else
  256. ..._details.asMap().entries.map((e) {
  257. final d = e.value;
  258. return Container(
  259. padding: const EdgeInsets.symmetric(vertical: 6),
  260. decoration: BoxDecoration(border: e.key < _details.length - 1 ? const Border(bottom: BorderSide(color: AppColors.border)) : null),
  261. child: Row(children: [
  262. Expanded(flex: 3, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  263. Text(d.categoryName, style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textPrimary)),
  264. if (d.remark.isNotEmpty) Text(d.remark, style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.textPlaceholder)),
  265. ])),
  266. Text('${d.quantity}×¥${d.unitPrice.toStringAsFixed(2)}', style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.textSecondary)),
  267. const SizedBox(width: 8),
  268. Text('¥${d.amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: AppColors.amountPrimary)),
  269. GestureDetector(onTap: () => setState(() => _details.removeAt(e.key)), child: const Icon(Icons.close, size: 16, color: AppColors.textPlaceholder)),
  270. ]),
  271. );
  272. }),
  273. Container(height: 1, color: AppColors.border),
  274. Container(height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  275. Text(l10n.get('total'), style: const TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
  276. Text('¥${_totalAmount().toStringAsFixed(2)}', style: const TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: AppColors.amountPrimary)),
  277. ])),
  278. if (_totalAmount() > _availableBudget)
  279. Padding(padding: const EdgeInsets.only(top: 8), child: Row(children: [
  280. const Icon(Icons.warning_amber, size: 14, color: AppColors.danger), const SizedBox(width: 6),
  281. Expanded(child: Text('您的申请金额已超支,提交后将自动触发高管特批流程', style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.danger))),
  282. ])),
  283. ]);
  284. }
  285. double _totalAmount() => _details.fold(0, (s, d) => s + d.amount);
  286. static const _detailCategories = [('transport', '交通费'), ('hotel', '住宿费'), ('office_supplies', '办公用品'), ('meals', '餐饮费'), ('materials', '材料费'), ('service', '服务费'), ('other', '其他')];
  287. static const _units = ['张', '间', '人', '天', '套', '个'];
  288. void _showDetailDialog() {
  289. String cat = 'transport';
  290. String unit = '张';
  291. final qtyCtrl = TextEditingController(text: '1');
  292. final priceCtrl = TextEditingController();
  293. final remarkCtrl = TextEditingController();
  294. showDialog(
  295. context: context,
  296. builder: (ctx) => StatefulBuilder(
  297. builder: (ctx, setDlg) => TDAlertDialog(
  298. title: '添加费用明细',
  299. contentWidget: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
  300. _label('费用类别'),
  301. const SizedBox(height: 4),
  302. GestureDetector(
  303. onTap: () {
  304. Navigator.pop(ctx);
  305. _showListPicker('选择费用类别', _detailCategories.map((c) => c.$2).toList(), (v) {
  306. cat = _detailCategories.firstWhere((c) => c.$2 == v).$1;
  307. _showDetailDialog();
  308. });
  309. },
  310. child: Container(
  311. height: 44, padding: const EdgeInsets.symmetric(horizontal: 12),
  312. decoration: BoxDecoration(color: AppColors.bgPage, borderRadius: BorderRadius.circular(4)),
  313. child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  314. Text(_detailCategories.firstWhere((c) => c.$1 == cat).$2, style: const TextStyle(fontSize: AppFontSizes.body)),
  315. const Icon(Icons.arrow_drop_down, color: AppColors.textPlaceholder),
  316. ]),
  317. ),
  318. ),
  319. const SizedBox(height: 12),
  320. Row(children: [
  321. Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  322. _label('数量'), const SizedBox(height: 4),
  323. TDInput(controller: qtyCtrl, hintText: '>0'),
  324. ])),
  325. const SizedBox(width: 12),
  326. Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
  327. _label('单位'), const SizedBox(height: 4),
  328. GestureDetector(
  329. onTap: () {
  330. Navigator.pop(ctx);
  331. _showListPicker('选择单位', _units, (v) { unit = v; _showDetailDialog(); });
  332. },
  333. child: Container(
  334. height: 44, padding: const EdgeInsets.symmetric(horizontal: 12),
  335. decoration: BoxDecoration(color: AppColors.bgPage, borderRadius: BorderRadius.circular(4)),
  336. child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  337. Text(unit, style: const TextStyle(fontSize: AppFontSizes.body)),
  338. const Icon(Icons.arrow_drop_down, color: AppColors.textPlaceholder),
  339. ]),
  340. ),
  341. ),
  342. ])),
  343. ]),
  344. const SizedBox(height: 12),
  345. _label('单价'), const SizedBox(height: 4),
  346. TDInput(controller: priceCtrl, hintText: '>0'),
  347. const SizedBox(height: 12),
  348. _label('明细说明'), const SizedBox(height: 4),
  349. TDInput(controller: remarkCtrl, hintText: '选填'),
  350. ]),
  351. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)),
  352. rightBtn: TDDialogButtonOptions(title: '确定', titleColor: AppColors.primary, action: () {
  353. final q = int.tryParse(qtyCtrl.text) ?? 0;
  354. final p = double.tryParse(priceCtrl.text) ?? 0;
  355. if (q <= 0 || p <= 0) { TDToast.showText('数量和单价必须大于0', context: context); return; }
  356. setState(() => _details.add(_DetailItem(id: _detailIdCounter++, category: cat, categoryName: _detailCategories.firstWhere((c) => c.$1 == cat).$2, quantity: q, unit: unit, unitPrice: p, amount: q * p, remark: remarkCtrl.text)));
  357. Navigator.pop(ctx);
  358. }),
  359. ),
  360. ),
  361. );
  362. }
  363. // ═══ 5. 附件上传 ═══
  364. Widget _buildAttachmentSection(AppLocalizations l10n) {
  365. return FormSection(title: l10n.get('attachmentUpload'), children: [
  366. Text(l10n.get('maxAttachment'), style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.textPlaceholder)),
  367. const SizedBox(height: 12),
  368. Wrap(spacing: 8, runSpacing: 8, children: [
  369. ..._attachments.asMap().entries.map((e) => Stack(
  370. clipBehavior: Clip.none,
  371. children: [
  372. Container(width: 80, height: 80, decoration: BoxDecoration(color: AppColors.primaryLight, borderRadius: BorderRadius.circular(4)),
  373. child: const Center(child: Icon(Icons.image, color: AppColors.primary, size: 32)),
  374. ),
  375. Positioned(right: -4, top: -4, child: GestureDetector(
  376. onTap: () => setState(() => _attachments.removeAt(e.key)),
  377. child: Container(width: 20, height: 20, decoration: const BoxDecoration(color: AppColors.danger, shape: BoxShape.circle),
  378. child: const Icon(Icons.close, size: 12, color: Colors.white),
  379. ),
  380. )),
  381. ],
  382. )),
  383. if (_attachments.length < 9)
  384. GestureDetector(
  385. onTap: () {
  386. // Mock: add attachment
  387. setState(() => _attachments.add('附件_${DateTime.now().millisecondsSinceEpoch}.jpg'));
  388. TDToast.showText('已添加附件(mock)', context: context);
  389. },
  390. child: Container(width: 80, height: 80, decoration: BoxDecoration(color: AppColors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: AppColors.border)),
  391. child: const Center(child: Icon(Icons.add, size: 24, color: AppColors.textPlaceholder)),
  392. ),
  393. ),
  394. ]),
  395. ]);
  396. }
  397. // ═══ 6. 底部操作栏 ═══
  398. Widget _buildBottomBar(AppLocalizations l10n) {
  399. final isDraft = widget.id != null;
  400. return ActionBar(
  401. leftLabel: isDraft ? l10n.get('reset') : null,
  402. centerLabel: l10n.get('saveDraft'),
  403. rightLabel: l10n.get('submitApproval'),
  404. showLeft: isDraft,
  405. onLeftTap: isDraft ? () => _showConfirmDialog('确认重置', '将清空所有已填内容,此操作不可撤销', '取消', '确认重置', _resetAll) : null,
  406. onCenterTap: () {
  407. TDToast.showSuccess('已保存为草稿', context: context);
  408. context.pop();
  409. },
  410. onRightTap: () {
  411. final err = _validate();
  412. if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; }
  413. TDToast.showSuccess('已提交,等待审批', context: context);
  414. context.pop();
  415. },
  416. );
  417. }
  418. List<String> _validate() {
  419. final e = <String>[];
  420. if (_expenseTypes.isEmpty) e.add('请至少选择一项费用类型');
  421. if (_purposeController.text.trim().isEmpty) e.add('请填写费用事由');
  422. if (_selectedProjectId == null) e.add('请选择关联项目');
  423. if (_selectedSubjectId == null) e.add('请选择预算科目');
  424. if (_details.isEmpty) e.add('请至少添加一行费用明细');
  425. if (_expenseTypes.contains('travel')) {
  426. if (_estimatedStartDate.isEmpty) e.add('请选择预计开始日期');
  427. if (_estimatedEndDate.isEmpty) e.add('请选择预计结束日期');
  428. }
  429. return e;
  430. }
  431. void _resetAll() => setState(() {
  432. _purposeController.clear(); _expenseTypes.clear(); _urgency = 0; _isTaxIncluded = false;
  433. _selectedProjectId = null; _selectedProjectName = null; _selectedSubjectId = null; _selectedSubjectName = null;
  434. _referenceNoController.clear(); _details.clear(); _attachments.clear();
  435. _estimatedStartDate = ''; _estimatedEndDate = ''; _entertainmentTarget = ''; _venue = '';
  436. });
  437. bool _hasUnsaved() => _purposeController.text.isNotEmpty || _expenseTypes.isNotEmpty || _details.isNotEmpty || _attachments.isNotEmpty || _selectedProjectId != null;
  438. // ═══ 通用弹窗方法 ═══
  439. void _showConfirmDialog(String title, String content, String leftText, String rightText, VoidCallback onConfirm) {
  440. showDialog(
  441. context: context,
  442. builder: (ctx) => TDAlertDialog(
  443. title: title,
  444. content: content,
  445. leftBtn: TDDialogButtonOptions(title: leftText, titleColor: AppColors.primary, action: () => Navigator.pop(ctx)),
  446. rightBtn: TDDialogButtonOptions(title: rightText, titleColor: AppColors.danger, action: () { Navigator.pop(ctx); onConfirm(); }),
  447. ),
  448. );
  449. }
  450. void _showListPicker(String title, List<String> items, Function(String) onPick) {
  451. showDialog(
  452. context: context,
  453. builder: (ctx) => TDAlertDialog(
  454. title: title,
  455. contentWidget: SizedBox(width: double.maxFinite, child: ListView(shrinkWrap: true, children: items.map((item) => ListTile(title: Text(item), onTap: () { onPick(item); Navigator.pop(ctx); })).toList())),
  456. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)),
  457. ),
  458. );
  459. }
  460. void _showTextInput(String title, Function(String) onConfirm) {
  461. final c = TextEditingController();
  462. showDialog(
  463. context: context,
  464. builder: (ctx) => TDAlertDialog(
  465. title: title,
  466. contentWidget: TDInput(controller: c, hintText: '请输入'),
  467. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)),
  468. rightBtn: TDDialogButtonOptions(title: '确定', titleColor: AppColors.primary, action: () { onConfirm(c.text); Navigator.pop(ctx); }),
  469. ),
  470. );
  471. }
  472. void _showNumberInput(String title, Function(int) onConfirm) {
  473. final c = TextEditingController();
  474. showDialog(
  475. context: context,
  476. builder: (ctx) => TDAlertDialog(
  477. title: title,
  478. contentWidget: TextField(controller: c, keyboardType: TextInputType.number, decoration: const InputDecoration(hintText: '请输入数字')),
  479. leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)),
  480. rightBtn: TDDialogButtonOptions(title: '确定', titleColor: AppColors.primary, action: () { onConfirm(int.tryParse(c.text) ?? 0); Navigator.pop(ctx); }),
  481. ),
  482. );
  483. }
  484. void _pickDate(Function(String) onPick) {
  485. showDatePicker(context: context, initialDate: DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2028), locale: const Locale('zh'))
  486. .then((d) { if (d != null) onPick('${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'); });
  487. }
  488. Widget _label(String t) => Text(t, style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textSecondary));
  489. String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; }
  490. }
  491. class _DetailItem {
  492. final int id; final String category; final String categoryName; final int quantity;
  493. final String unit; final double unitPrice; final double amount; final String remark;
  494. const _DetailItem({required this.id, required this.category, required this.categoryName, required this.quantity, required this.unit, required this.unitPrice, required this.amount, required this.remark});
  495. }