expense_apply_page.dart 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:go_router/go_router.dart';
  5. import '../../shared/widgets/nav_bar_config.dart';
  6. import '../../core/utils/responsive.dart';
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import 'expense_apply_controller.dart';
  10. import '../../core/i18n/app_localizations.dart';
  11. import 'expense_model.dart';
  12. import '../../core/theme/app_colors.dart';
  13. import '../../core/theme/app_colors_extension.dart';
  14. import '../../core/data/mock_api_data.dart';
  15. import 'widgets/expense_detail_edit_dialog.dart';
  16. class ExpenseApplyPage extends ConsumerStatefulWidget {
  17. final String? editId;
  18. const ExpenseApplyPage({super.key, this.editId});
  19. @override
  20. ConsumerState<ExpenseApplyPage> createState() => _ExpenseApplyPageState();
  21. }
  22. class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
  23. final _remarkController = TextEditingController();
  24. final _purposeController = TextEditingController();
  25. final _voucherNoController = TextEditingController();
  26. final _bankNameController = TextEditingController(text: '中国银行');
  27. final _accountNameController = TextEditingController(text: '张三');
  28. // 关联管控
  29. String? _selectedProjectName;
  30. String? _selectedSubjectName;
  31. double _availableBudget = 0;
  32. @override
  33. void dispose() {
  34. _remarkController.dispose();
  35. _purposeController.dispose();
  36. _voucherNoController.dispose();
  37. _bankNameController.dispose();
  38. _accountNameController.dispose();
  39. super.dispose();
  40. }
  41. @override
  42. Widget build(BuildContext context) {
  43. final controller = ref.watch(expenseApplyProvider(widget.editId).notifier);
  44. final state = ref.watch(expenseApplyProvider(widget.editId));
  45. final r = ResponsiveHelper.of(context);
  46. final l10n = AppLocalizations.of(context);
  47. ref
  48. .read(navBarConfigProvider.notifier)
  49. .update(
  50. NavBarConfig(
  51. title: widget.editId != null
  52. ? l10n.get('editExpense')
  53. : l10n.get('expenseApply'),
  54. showBack: true,
  55. onBack: () => context.pop(),
  56. ),
  57. );
  58. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  59. final bottomInset = MediaQuery.of(context).padding.bottom;
  60. return Column(
  61. children: [
  62. Expanded(
  63. child: Align(
  64. alignment: Alignment.topCenter,
  65. child: ConstrainedBox(
  66. constraints: BoxConstraints(maxWidth: r.formMaxWidth),
  67. child: SingleChildScrollView(
  68. padding: const EdgeInsets.all(16),
  69. child: Column(
  70. crossAxisAlignment: CrossAxisAlignment.start,
  71. children: [
  72. // 1. 导入事前申请链接
  73. _buildImportLink(),
  74. const SizedBox(height: 16),
  75. // 2. 基本信息区
  76. _buildBasicInfoSection(controller, state),
  77. const SizedBox(height: 16),
  78. // 3. 关联管控区
  79. _buildControlSection(controller, state),
  80. const SizedBox(height: 16),
  81. // 4. 收款账户区
  82. _buildAccountSection(controller, state),
  83. const SizedBox(height: 16),
  84. // 5. 报销明细区
  85. _buildDetailSection(controller, state),
  86. const SizedBox(height: 16),
  87. // 6. 附件上传区
  88. _buildInvoiceSection(controller, state),
  89. ],
  90. ),
  91. ),
  92. ),
  93. ),
  94. ),
  95. // 7. 底部操作栏 + iOS 横条指示器背景
  96. ColoredBox(
  97. color: colors.bgCard,
  98. child: Column(
  99. mainAxisSize: MainAxisSize.min,
  100. children: [
  101. _buildBottomButtons(controller, state),
  102. if (bottomInset > 0) SizedBox(height: bottomInset),
  103. ],
  104. ),
  105. ),
  106. ],
  107. );
  108. }
  109. // ═══ 1. 导入事前申请链接 ═══
  110. Widget _buildImportLink() {
  111. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  112. final l10n = AppLocalizations.of(context);
  113. return GestureDetector(
  114. onTap: () {
  115. TDToast.showText(l10n.get('expenseApplyImport'), context: context);
  116. },
  117. child: Container(
  118. height: 44,
  119. decoration: BoxDecoration(
  120. color: colors.primaryLight,
  121. borderRadius: BorderRadius.circular(8),
  122. ),
  123. child: Row(
  124. mainAxisAlignment: MainAxisAlignment.center,
  125. children: [
  126. Icon(Icons.download, size: 14, color: colors.primary),
  127. const SizedBox(width: 8),
  128. Text(
  129. l10n.get('importApprovedPreApp'),
  130. style: TextStyle(
  131. fontSize: AppFontSizes.body,
  132. color: colors.primary,
  133. ),
  134. ),
  135. ],
  136. ),
  137. ),
  138. );
  139. }
  140. // ═══ 2. 基本信息区 ═══
  141. // 报销日期(只读)、报销单号(只读)、币别(TDPicker)、报销人员(只读)、报销部门(只读)、
  142. // 支付方式(TDPicker)、凭证号码、备注
  143. Widget _buildBasicInfoSection(
  144. ExpenseApplyController controller,
  145. ExpenseApplyState state,
  146. ) {
  147. final l10n = AppLocalizations.of(context);
  148. final expense = state.expense;
  149. return FormSection(
  150. title: l10n.get('basicInfo'),
  151. leadingIcon: Icons.info_outline,
  152. children: [
  153. // 报销日期(只读)
  154. FormFieldRow(
  155. label: l10n.get('expenseDate'),
  156. value: expense.applicationDate != null
  157. ? '${expense.applicationDate!.year}-${expense.applicationDate!.month.toString().padLeft(2, '0')}-${expense.applicationDate!.day.toString().padLeft(2, '0')}'
  158. : _today(),
  159. readOnly: true,
  160. showArrow: false,
  161. ),
  162. const SizedBox(height: 16),
  163. // 报销单号(只读)
  164. FormFieldRow(
  165. label: l10n.get('reportNo'),
  166. value: expense.reportNo.isNotEmpty ? expense.reportNo : null,
  167. hint: l10n.get('autoGenerated'),
  168. readOnly: true,
  169. showArrow: false,
  170. ),
  171. const SizedBox(height: 16),
  172. // 币别(TDPicker)
  173. FormFieldRow(
  174. label: l10n.get('currency'),
  175. value: expense.currencyCode.isNotEmpty ? expense.currencyCode : 'CNY',
  176. hint: l10n.get('selectCurrency'),
  177. onTap: () => _showCurrencyPicker(controller, expense.currencyCode),
  178. ),
  179. const SizedBox(height: 16),
  180. // 报销人员(只读)
  181. FormFieldRow(
  182. label: l10n.get('applicant'),
  183. value: expense.applicantName.isNotEmpty
  184. ? expense.applicantName
  185. : '张三',
  186. readOnly: true,
  187. showArrow: false,
  188. ),
  189. const SizedBox(height: 16),
  190. // 报销部门(只读)
  191. FormFieldRow(
  192. label: l10n.get('department'),
  193. value: expense.deptName.isNotEmpty ? expense.deptName : '技术部',
  194. readOnly: true,
  195. showArrow: false,
  196. ),
  197. const SizedBox(height: 16),
  198. // 支付方式(TDPicker)
  199. FormFieldRow(
  200. label: l10n.get('paymentMethod'),
  201. value: expense.paymentMethod.isNotEmpty
  202. ? expense.paymentMethod
  203. : null,
  204. hint: l10n.get('selectPaymentMethod'),
  205. onTap: () => _showPaymentMethodPicker(controller),
  206. ),
  207. const SizedBox(height: 16),
  208. // 凭证号码
  209. FormFieldRow(
  210. label: l10n.get('voucherNo'),
  211. value: _voucherNoController.text.isNotEmpty
  212. ? _voucherNoController.text
  213. : null,
  214. hint: l10n.get('enterVoucherNo'),
  215. onTap: () => _showTextInput(
  216. l10n.get('voucherNo'),
  217. l10n.get('enterVoucherNo'),
  218. (v) => setState(() {
  219. _voucherNoController.text = v;
  220. _voucherNoController.selection = TextSelection.fromPosition(
  221. TextPosition(offset: v.length),
  222. );
  223. }),
  224. initialText: _voucherNoController.text,
  225. ),
  226. ),
  227. const SizedBox(height: 16),
  228. // 备注
  229. FormFieldRow(
  230. label: l10n.get('remark'),
  231. value: _remarkController.text.isNotEmpty
  232. ? _remarkController.text
  233. : null,
  234. hint: l10n.get('enterRemark'),
  235. onTap: () => _showTextInput(
  236. l10n.get('remark'),
  237. l10n.get('enterRemark'),
  238. (v) => setState(() {
  239. _remarkController.text = v;
  240. _remarkController.selection = TextSelection.fromPosition(
  241. TextPosition(offset: v.length),
  242. );
  243. }),
  244. initialText: _remarkController.text,
  245. ),
  246. ),
  247. ],
  248. );
  249. }
  250. // ═══ 3. 关联管控区 ═══
  251. // 报销事由、关联项目&预算科目(级联)、成本中心、可用预算、报销金额合计(只读)、核准金额合计(只读)
  252. Widget _buildControlSection(
  253. ExpenseApplyController controller,
  254. ExpenseApplyState state,
  255. ) {
  256. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  257. final l10n = AppLocalizations.of(context);
  258. final expense = state.expense;
  259. final cascadeLabel = _selectedProjectName != null &&
  260. _selectedSubjectName != null
  261. ? '$_selectedProjectName / $_selectedSubjectName'
  262. : _selectedProjectName;
  263. return FormSection(
  264. title: l10n.get('relatedControl'),
  265. leadingIcon: Icons.link_outlined,
  266. children: [
  267. // 报销事由
  268. FormFieldRow(
  269. label: l10n.get('expenseReason'),
  270. value: _purposeController.text.isNotEmpty
  271. ? _purposeController.text
  272. : null,
  273. hint: l10n.get('enterExpenseReason'),
  274. onTap: () => _showTextInput(
  275. l10n.get('expenseReason'),
  276. l10n.get('enterExpenseReason'),
  277. (v) {
  278. setState(() {
  279. _purposeController.text = v;
  280. _purposeController.selection = TextSelection.fromPosition(
  281. TextPosition(offset: v.length),
  282. );
  283. });
  284. controller.updatePurpose(v);
  285. },
  286. initialText: _purposeController.text,
  287. ),
  288. ),
  289. const SizedBox(height: 16),
  290. // 关联项目 & 预算科目(级联选择器,参考 ExpenseApplicationApplyPage)
  291. FormFieldRow(
  292. label: l10n.get('relatedProject'),
  293. value: cascadeLabel,
  294. hint: l10n.get('selectProjectAndSubject'),
  295. required: true,
  296. onTap: () {
  297. FocusScope.of(context).unfocus();
  298. TDCascader.showMultiCascader(
  299. context,
  300. title: l10n.get('selectProjectAndSubject'),
  301. data: _buildCascadeData(),
  302. subTitles: [
  303. l10n.get('project'),
  304. l10n.get('budgetSubject'),
  305. ],
  306. onClose: () => Navigator.of(context).pop(),
  307. onChange: (selected) {
  308. if (selected.length >= 2) {
  309. final pId = int.tryParse(selected[0].value ?? '');
  310. final sId = int.tryParse(selected[1].value ?? '');
  311. if (pId != null && sId != null) {
  312. setState(() {
  313. _selectedProjectName = selected[0].label;
  314. _selectedSubjectName = selected[1].label;
  315. _availableBudget = getMockBudget(pId, sId);
  316. });
  317. }
  318. }
  319. },
  320. );
  321. },
  322. ),
  323. const SizedBox(height: 16),
  324. // 可用预算
  325. _buildBudgetRow(l10n, colors),
  326. const SizedBox(height: 16),
  327. // 成本中心
  328. FormFieldRow(
  329. label: l10n.get('costCenter'),
  330. value: expense.costCenterId.isNotEmpty ? expense.costCenterId : null,
  331. hint: l10n.get('selectCostCenter'),
  332. onTap: () {
  333. TDToast.showText(l10n.get('costCenterSelection'), context: context);
  334. },
  335. ),
  336. ],
  337. );
  338. }
  339. List<Map<String, dynamic>> _buildCascadeData() {
  340. return mockProjects
  341. .map(
  342. (p) => <String, dynamic>{
  343. 'label': p.name,
  344. 'value': p.id.toString(),
  345. 'children': mockBudgetSubjects
  346. .map(
  347. (s) => <String, dynamic>{
  348. 'label': s.name,
  349. 'value': s.id.toString(),
  350. },
  351. )
  352. .toList(),
  353. },
  354. )
  355. .toList();
  356. }
  357. Widget _buildBudgetRow(AppLocalizations l10n, AppColorsExtension colors) {
  358. final over = _availableBudget <= 0;
  359. return Row(
  360. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  361. children: [
  362. Text(
  363. l10n.get('availableBudget'),
  364. style: TextStyle(
  365. fontSize: AppFontSizes.subtitle,
  366. color: colors.textSecondary,
  367. ),
  368. ),
  369. Text(
  370. '¥${_availableBudget.toStringAsFixed(2)}',
  371. style: TextStyle(
  372. fontSize: AppFontSizes.subtitle,
  373. fontWeight: FontWeight.w700,
  374. color: over ? colors.danger : colors.amountPrimary,
  375. ),
  376. ),
  377. ],
  378. );
  379. }
  380. // ═══ 4. 收款账户区 ═══
  381. // 开户行(bankName)、户名(accountName)、银行账号(bankAccount)
  382. Widget _buildAccountSection(
  383. ExpenseApplyController controller,
  384. ExpenseApplyState state,
  385. ) {
  386. final l10n = AppLocalizations.of(context);
  387. return FormSection(
  388. title: l10n.get('receiptAccount'),
  389. leadingIcon: Icons.account_balance_outlined,
  390. children: [
  391. FormFieldRow(
  392. label: l10n.get('bankName'),
  393. value: _bankNameController.text.isNotEmpty
  394. ? _bankNameController.text
  395. : null,
  396. hint: l10n.get('selectBank'),
  397. onTap: () {
  398. TDToast.showText(l10n.get('bankSelection'), context: context);
  399. },
  400. ),
  401. const SizedBox(height: 16),
  402. FormFieldRow(
  403. label: l10n.get('accountName'),
  404. value: _accountNameController.text,
  405. readOnly: true,
  406. showArrow: false,
  407. ),
  408. const SizedBox(height: 16),
  409. FormFieldRow(
  410. label: l10n.get('bankAccount'),
  411. value: state.expense.bankAccount.isNotEmpty
  412. ? state.expense.bankAccount
  413. : null,
  414. hint: l10n.get('enterBankAccount'),
  415. onTap: () {
  416. TDToast.showText(l10n.get('bankAccountInput'), context: context);
  417. },
  418. ),
  419. ],
  420. );
  421. }
  422. // ═══ 5. 报销明细区 ═══
  423. Widget _buildDetailSection(
  424. ExpenseApplyController controller,
  425. ExpenseApplyState state,
  426. ) {
  427. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  428. final l10n = AppLocalizations.of(context);
  429. final totalApproved = state.expense.details.fold<double>(
  430. 0,
  431. (sum, d) => sum + d.approvedAmount,
  432. );
  433. return FormSection(
  434. title: l10n.get('expenseDetails'),
  435. leadingIcon: Icons.receipt_long_outlined,
  436. showAction: true,
  437. actionText: l10n.get('add'),
  438. onActionTap: () => _showAddDetailDialog(controller),
  439. children: [
  440. if (state.expense.details.isEmpty)
  441. Padding(
  442. padding: const EdgeInsets.symmetric(vertical: 8),
  443. child: Text(
  444. l10n.get('noDetailHint'),
  445. style: TextStyle(
  446. fontSize: AppFontSizes.subtitle,
  447. color: colors.textPlaceholder,
  448. ),
  449. ),
  450. )
  451. else
  452. ...state.expense.details.asMap().entries.map((entry) {
  453. final d = entry.value;
  454. return Container(
  455. margin: const EdgeInsets.symmetric(vertical: 8),
  456. padding: const EdgeInsets.all(12),
  457. decoration: BoxDecoration(
  458. color: colors.bgPage,
  459. borderRadius: BorderRadius.circular(8),
  460. ),
  461. child: Stack(
  462. children: [
  463. Column(
  464. crossAxisAlignment: CrossAxisAlignment.start,
  465. children: [
  466. // 第一行:费用项目 + 金额含税
  467. Row(
  468. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  469. crossAxisAlignment: CrossAxisAlignment.start,
  470. children: [
  471. Expanded(
  472. child: Text(
  473. d.expenseDesc.isNotEmpty
  474. ? d.expenseDesc
  475. : l10n.get('expenseName'),
  476. style: TextStyle(
  477. fontSize: AppFontSizes.subtitle,
  478. color: colors.textPrimary,
  479. ),
  480. ),
  481. ),
  482. const SizedBox(width: 8),
  483. Text(
  484. '¥${d.totalAmount.toStringAsFixed(2)}',
  485. style: TextStyle(
  486. fontSize: AppFontSizes.subtitle,
  487. fontWeight: FontWeight.w600,
  488. color: colors.amountPrimary,
  489. ),
  490. ),
  491. ],
  492. ),
  493. const SizedBox(height: 8),
  494. // 第二行(小字):金额不含税 / 核准金额 / 税金 / 税率
  495. Text(
  496. '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)} '
  497. '${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)} '
  498. '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)} '
  499. '${l10n.get('taxRate')}: ${(d.taxRate * 100).toStringAsFixed(0)}%',
  500. style: TextStyle(
  501. fontSize: AppFontSizes.caption,
  502. color: colors.textSecondary,
  503. ),
  504. ),
  505. const SizedBox(height: 6),
  506. // 第三行(小字):客户/厂商 / 已充金额
  507. Text(
  508. '${l10n.get('customerVendor')}: ${d.customerVendorName.isNotEmpty ? d.customerVendorName : '--'} '
  509. '${l10n.get('offsetAmount')}: ¥${d.offsetAmount.toStringAsFixed(2)}',
  510. style: TextStyle(
  511. fontSize: AppFontSizes.caption,
  512. color: colors.textSecondary,
  513. ),
  514. ),
  515. const SizedBox(height: 6),
  516. // 第四行(小字):费用科目 / 项目代号 / 科目代号 / 项目类别
  517. Text(
  518. '${l10n.get('expenseCategory')}: ${d.expenseType.isNotEmpty ? d.expenseType : '--'} '
  519. '${l10n.get('projectCode')}: ${d.projectCode.isNotEmpty ? d.projectCode : '--'} '
  520. '${l10n.get('subjectCode')}: ${d.subjectCode.isNotEmpty ? d.subjectCode : '--'} '
  521. '${l10n.get('projectCategory')}: ${d.projectCategory.isNotEmpty ? d.projectCategory : '--'}',
  522. style: TextStyle(
  523. fontSize: AppFontSizes.caption,
  524. color: colors.textSecondary,
  525. ),
  526. ),
  527. // 第五行:摘要
  528. if (d.remark.isNotEmpty) ...[
  529. const SizedBox(height: 6),
  530. Text(
  531. d.remark,
  532. maxLines: 2,
  533. overflow: TextOverflow.ellipsis,
  534. style: TextStyle(
  535. fontSize: AppFontSizes.caption,
  536. color: colors.textPlaceholder,
  537. ),
  538. ),
  539. ],
  540. ],
  541. ),
  542. // 右上角圆形删除按钮
  543. Positioned(
  544. right: 0,
  545. top: 0,
  546. child: GestureDetector(
  547. onTap: () {
  548. controller.removeDetail(entry.key);
  549. controller.recalculateAmount();
  550. },
  551. child: Container(
  552. width: 24,
  553. height: 24,
  554. decoration: BoxDecoration(
  555. color: colors.primaryLight,
  556. shape: BoxShape.circle,
  557. ),
  558. child: Icon(
  559. Icons.close,
  560. size: 14,
  561. color: colors.primary700,
  562. ),
  563. ),
  564. ),
  565. ),
  566. ],
  567. ),
  568. );
  569. }),
  570. // 报销金额合计 & 报销核准金额合计(始终显示)
  571. const SizedBox(height: 8),
  572. Container(
  573. height: 36,
  574. padding: const EdgeInsets.symmetric(vertical: 8),
  575. child: Row(
  576. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  577. children: [
  578. Text(
  579. l10n.get('totalExpense'),
  580. style: TextStyle(
  581. fontSize: AppFontSizes.body,
  582. fontWeight: FontWeight.w600,
  583. color: colors.textPrimary,
  584. ),
  585. ),
  586. Text(
  587. '¥${state.expense.totalAmount.toStringAsFixed(2)}',
  588. style: TextStyle(
  589. fontSize: AppFontSizes.subtitle,
  590. fontWeight: FontWeight.w700,
  591. color: colors.amountPrimary,
  592. ),
  593. ),
  594. ],
  595. ),
  596. ),
  597. Container(
  598. height: 36,
  599. padding: const EdgeInsets.symmetric(vertical: 8),
  600. child: Row(
  601. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  602. children: [
  603. Text(
  604. l10n.get('approvedTotal'),
  605. style: TextStyle(
  606. fontSize: AppFontSizes.body,
  607. fontWeight: FontWeight.w600,
  608. color: colors.textPrimary,
  609. ),
  610. ),
  611. Text(
  612. '¥${totalApproved.toStringAsFixed(2)}',
  613. style: TextStyle(
  614. fontSize: AppFontSizes.subtitle,
  615. fontWeight: FontWeight.w700,
  616. color: colors.amountPrimary,
  617. ),
  618. ),
  619. ],
  620. ),
  621. ),
  622. ],
  623. );
  624. }
  625. // ═══ 6. 附件上传区 ═══
  626. Widget _buildInvoiceSection(
  627. ExpenseApplyController controller,
  628. ExpenseApplyState state,
  629. ) {
  630. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  631. final l10n = AppLocalizations.of(context);
  632. return FormSection(
  633. title: l10n.get('invoiceUpload'),
  634. leadingIcon: Icons.attach_file_outlined,
  635. children: [
  636. Text(
  637. l10n.get('maxInvoices'),
  638. style: TextStyle(
  639. fontSize: AppFontSizes.caption,
  640. color: colors.textPlaceholder,
  641. ),
  642. ),
  643. const SizedBox(height: 8),
  644. Wrap(
  645. spacing: 8,
  646. runSpacing: 8,
  647. children: [
  648. GestureDetector(
  649. onTap: () {
  650. TDToast.showText(
  651. l10n.get('expenseApplyImport'),
  652. context: context,
  653. );
  654. },
  655. child: Container(
  656. width: 80,
  657. height: 80,
  658. decoration: BoxDecoration(
  659. color: colors.bgPage,
  660. borderRadius: BorderRadius.circular(4),
  661. border: Border.all(color: colors.border, width: 1),
  662. ),
  663. child: Center(
  664. child: Icon(
  665. Icons.add,
  666. size: 24,
  667. color: colors.textPlaceholder,
  668. ),
  669. ),
  670. ),
  671. ),
  672. ],
  673. ),
  674. ],
  675. );
  676. }
  677. // ═══ 7. 底部操作栏 ═══
  678. Widget _buildBottomButtons(
  679. ExpenseApplyController controller,
  680. ExpenseApplyState state,
  681. ) {
  682. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  683. final l10n = AppLocalizations.of(context);
  684. return Container(
  685. height: 72,
  686. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
  687. decoration: BoxDecoration(color: colors.bgCard),
  688. child: Row(
  689. children: [
  690. // 存草稿
  691. Expanded(
  692. child: SizedBox(
  693. height: 40,
  694. child: Material(
  695. color: colors.bgCard,
  696. borderRadius: BorderRadius.circular(22),
  697. child: InkWell(
  698. onTap: state.isSubmitting
  699. ? null
  700. : () async {
  701. final ok = await controller.saveDraft();
  702. if (!mounted) return;
  703. if (ok) {
  704. TDToast.showText(
  705. l10n.get('draftSavedToast'),
  706. context: context,
  707. );
  708. }
  709. },
  710. borderRadius: BorderRadius.circular(22),
  711. child: Center(
  712. child: Text(
  713. l10n.get('saveDraftShort'),
  714. style: TextStyle(
  715. fontSize: AppFontSizes.body,
  716. fontWeight: FontWeight.w500,
  717. color: colors.textSecondary,
  718. ),
  719. ),
  720. ),
  721. ),
  722. ),
  723. ),
  724. ),
  725. const SizedBox(width: 12),
  726. // 重置
  727. Expanded(
  728. child: SizedBox(
  729. height: 40,
  730. child: Material(
  731. color: colors.bgCard,
  732. borderRadius: BorderRadius.circular(22),
  733. child: InkWell(
  734. onTap: () {
  735. setState(() {
  736. _purposeController.clear();
  737. _remarkController.clear();
  738. _voucherNoController.clear();
  739. });
  740. },
  741. borderRadius: BorderRadius.circular(22),
  742. child: Center(
  743. child: Text(
  744. l10n.get('reset'),
  745. style: TextStyle(
  746. fontSize: AppFontSizes.body,
  747. fontWeight: FontWeight.w500,
  748. color: colors.textSecondary,
  749. ),
  750. ),
  751. ),
  752. ),
  753. ),
  754. ),
  755. ),
  756. const SizedBox(width: 12),
  757. // 保存
  758. Expanded(
  759. child: SizedBox(
  760. height: 40,
  761. child: Material(
  762. color: colors.primary,
  763. borderRadius: BorderRadius.circular(22),
  764. child: InkWell(
  765. onTap: state.isSubmitting
  766. ? null
  767. : () async {
  768. await controller.saveDraft();
  769. if (!mounted) return;
  770. context.pop();
  771. },
  772. borderRadius: BorderRadius.circular(22),
  773. child: Center(
  774. child: Text(
  775. l10n.get('save'),
  776. style: TextStyle(
  777. fontSize: AppFontSizes.body,
  778. fontWeight: FontWeight.w500,
  779. color: colors.bgCard,
  780. ),
  781. ),
  782. ),
  783. ),
  784. ),
  785. ),
  786. ),
  787. ],
  788. ),
  789. );
  790. }
  791. // ═══════════════════════════════════════════
  792. // 弹窗 / 选择器方法
  793. // ═══════════════════════════════════════════
  794. /// 添加报销明细对话框(底部滑出,参照 ExpenseDetailDialog 样式)
  795. Future<void> _showAddDetailDialog(ExpenseApplyController controller) async {
  796. final l10n = AppLocalizations.of(context);
  797. final result = await ExpenseDetailEditDialog.show(
  798. context,
  799. categories: mockCostCategories,
  800. l10n: l10n,
  801. );
  802. if (result != null && mounted) {
  803. final amountExclTax = result.taxRate > 0
  804. ? result.amount / (1 + result.taxRate)
  805. : result.amount;
  806. final taxAmount = result.amount - amountExclTax;
  807. controller.addDetail(
  808. ExpenseDetailModel(
  809. id: DateTime.now().millisecondsSinceEpoch.toString(),
  810. expenseId: '',
  811. expenseDate: DateTime.now(),
  812. expenseType: result.category,
  813. expenseDesc: result.expenseDesc,
  814. amount: amountExclTax,
  815. taxRate: result.taxRate,
  816. taxAmount: taxAmount,
  817. totalAmount: result.amount,
  818. customerVendorName: result.customerVendorName,
  819. offsetAmount: result.offsetAmount,
  820. remark: result.remark,
  821. baseAmount: result.amount,
  822. projectCode: _selectedProjectName ?? '',
  823. subjectCode: _selectedSubjectName ?? '',
  824. projectCategory: '',
  825. ),
  826. );
  827. controller.recalculateAmount();
  828. }
  829. }
  830. /// 币别选择器
  831. void _showCurrencyPicker(
  832. ExpenseApplyController controller,
  833. String currentCurrency,
  834. ) {
  835. final l10n = AppLocalizations.of(context);
  836. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  837. const currencies = ['CNY', 'USD', 'EUR', 'JPY', 'HKD', 'GBP'];
  838. TDPicker.showMultiPicker(
  839. context,
  840. title: l10n.get('selectCurrency'),
  841. backgroundColor: colors.bgCard,
  842. data: [currencies],
  843. onConfirm: (selected) {
  844. if (selected.isNotEmpty && selected[0] is int) {
  845. final idx = selected[0] as int;
  846. if (idx >= 0 && idx < currencies.length) {
  847. Navigator.of(context).pop();
  848. controller.updateCurrencyCode(currencies[idx]);
  849. }
  850. }
  851. },
  852. );
  853. }
  854. /// 支付方式选择器
  855. void _showPaymentMethodPicker(ExpenseApplyController controller) {
  856. final l10n = AppLocalizations.of(context);
  857. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  858. const methods = ['bankTransfer', 'cash', 'check', 'alipay', 'wechat'];
  859. TDPicker.showMultiPicker(
  860. context,
  861. title: l10n.get('selectPaymentMethod'),
  862. backgroundColor: colors.bgCard,
  863. data: [methods],
  864. onConfirm: (selected) {
  865. if (selected.isNotEmpty && selected[0] is int) {
  866. final idx = selected[0] as int;
  867. if (idx >= 0 && idx < methods.length) {
  868. Navigator.of(context).pop();
  869. // The controller can be extended with updatePaymentMethod if needed
  870. TDToast.showText(methods[idx], context: context);
  871. }
  872. }
  873. },
  874. );
  875. }
  876. /// 通用文本输入对话框
  877. void _showTextInput(
  878. String title,
  879. String hint,
  880. void Function(String) onConfirm, {
  881. String initialText = '',
  882. }) {
  883. final l10n = AppLocalizations.of(context);
  884. final c = TextEditingController(text: initialText);
  885. showGeneralDialog(
  886. context: context,
  887. pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
  888. textEditingController: c,
  889. title: title,
  890. hintText: hint,
  891. leftBtn: TDDialogButtonOptions(
  892. title: l10n.get('cancel'),
  893. action: () => Navigator.pop(ctx),
  894. ),
  895. rightBtn: TDDialogButtonOptions(
  896. title: l10n.get('confirm'),
  897. action: () {
  898. onConfirm(c.text);
  899. Navigator.pop(ctx);
  900. },
  901. ),
  902. ),
  903. );
  904. }
  905. /// 获取今天的日期字符串
  906. String _today() {
  907. final n = DateTime.now();
  908. return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
  909. }
  910. }