expense_detail_dialog.dart 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import '../../../core/i18n/app_localizations.dart';
  5. import '../../../core/theme/app_colors.dart';
  6. import '../../../core/theme/app_colors_extension.dart';
  7. import '../../../core/data/mock_api_data.dart';
  8. import '../expense_api.dart';
  9. import '../../../shared/widgets/attachment_picker.dart';
  10. /// 报销明细输入数据。
  11. class ExpenseDetailInputData {
  12. final String category;
  13. final String categoryName;
  14. final String acctSubjectId;
  15. final String acctSubjectName;
  16. final String purpose;
  17. final double amount; // 含税金额
  18. final double taxRate;
  19. final String projectId;
  20. final String projectName;
  21. final String costDeptId;
  22. final String costDeptName;
  23. final String customerVendorId;
  24. final String customerVendorName;
  25. final double approvedAmount;
  26. final double offsetAmount;
  27. final String bankName;
  28. final String bankAccountName;
  29. final String bankAccount;
  30. final String remark;
  31. final List<String> attachmentPaths;
  32. final String sqMan;
  33. final String sqManName;
  34. final String aeNo;
  35. final String aeDd;
  36. const ExpenseDetailInputData({
  37. required this.category,
  38. required this.categoryName,
  39. required this.acctSubjectId,
  40. required this.acctSubjectName,
  41. required this.purpose,
  42. required this.amount,
  43. required this.taxRate,
  44. this.projectId = '',
  45. this.projectName = '',
  46. this.costDeptId = '',
  47. this.costDeptName = '',
  48. this.customerVendorId = '',
  49. this.customerVendorName = '',
  50. this.approvedAmount = 0.0,
  51. this.offsetAmount = 0.0,
  52. this.bankName = '',
  53. this.bankAccountName = '',
  54. this.bankAccount = '',
  55. this.remark = '',
  56. this.attachmentPaths = const [],
  57. this.sqMan = '',
  58. this.sqManName = '',
  59. this.aeNo = '',
  60. this.aeDd = '',
  61. });
  62. }
  63. /// 报销明细编辑弹窗。
  64. ///
  65. /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
  66. /// 参照 ExpenseApplyCreatePage 的 ExpenseDetailDialog 样式。
  67. class ExpenseDetailDialog extends StatefulWidget {
  68. final List<CostCategory> categories;
  69. final List<Project> projects;
  70. final List<CostDept> costDepts;
  71. final List<CustomerVendor> customers;
  72. final List<EmployeeItem> employees;
  73. final AppLocalizations l10n;
  74. final ExpenseDetailInputData? initialData;
  75. final Future<bool> Function()? checkAttachHealth;
  76. const ExpenseDetailDialog({
  77. super.key,
  78. required this.categories,
  79. required this.projects,
  80. required this.costDepts,
  81. required this.customers,
  82. required this.employees,
  83. required this.l10n,
  84. this.initialData,
  85. this.checkAttachHealth,
  86. });
  87. /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
  88. static Future<ExpenseDetailInputData?> show(
  89. BuildContext context, {
  90. required List<CostCategory> categories,
  91. required List<Project> projects,
  92. required List<CostDept> costDepts,
  93. required List<CustomerVendor> customers,
  94. required List<EmployeeItem> employees,
  95. required AppLocalizations l10n,
  96. ExpenseDetailInputData? initialData,
  97. Future<bool> Function()? checkAttachHealth,
  98. }) {
  99. FocusScope.of(context).unfocus();
  100. return Navigator.push<ExpenseDetailInputData>(
  101. context,
  102. TDSlidePopupRoute<ExpenseDetailInputData>(
  103. slideTransitionFrom: SlideTransitionFrom.bottom,
  104. isDismissible: true,
  105. builder: (_) => ExpenseDetailDialog(
  106. categories: categories,
  107. projects: projects,
  108. costDepts: costDepts,
  109. customers: customers,
  110. employees: employees,
  111. l10n: l10n,
  112. initialData: initialData,
  113. checkAttachHealth: checkAttachHealth,
  114. ),
  115. ),
  116. );
  117. }
  118. @override
  119. State<ExpenseDetailDialog> createState() => _ExpenseDetailDialogState();
  120. }
  121. class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
  122. late String _cat;
  123. late TextEditingController _descCtrl;
  124. late TextEditingController _amountCtrl;
  125. CustomerVendor? _selCustomer;
  126. late TextEditingController _approvedAmountCtrl;
  127. late TextEditingController _remarkCtrl;
  128. late TextEditingController _bankNameCtrl;
  129. late TextEditingController _bankAccountNameCtrl;
  130. late TextEditingController _bankAccountCtrl;
  131. double _taxRate = 0;
  132. Project? _selProject;
  133. CostDept? _selDept;
  134. EmployeeItem? _selEmployee;
  135. late final AttachmentPickerController _attachmentCtrl;
  136. final ScrollController _scrollCtrl = ScrollController();
  137. final _remarkFocus = FocusNode();
  138. final _bankNameFocus = FocusNode();
  139. final _bankAccountNameFocus = FocusNode();
  140. final _bankAccountFocus = FocusNode();
  141. bool _attachAvailable = false;
  142. void _ensureVisible(FocusNode node) {
  143. if (!node.hasFocus) return;
  144. _doEnsureVisible(node, 0, -1);
  145. }
  146. void _doEnsureVisible(FocusNode node, int attempt, double lastInsets) {
  147. if (attempt >= 15) return;
  148. WidgetsBinding.instance.addPostFrameCallback((_) {
  149. if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
  150. final insets = MediaQuery.of(context).viewInsets.bottom;
  151. if (insets != lastInsets) {
  152. _doEnsureVisible(node, attempt + 1, insets);
  153. return;
  154. }
  155. Future.delayed(const Duration(milliseconds: 500), () {
  156. if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return;
  157. final ctx = node.context;
  158. if (ctx == null) return;
  159. Scrollable.ensureVisible(
  160. ctx,
  161. alignment: 0.5,
  162. duration: const Duration(milliseconds: 300),
  163. );
  164. });
  165. });
  166. }
  167. static const _taxOptions = [0.0, 0.06, 0.09, 0.13];
  168. static const _taxLabels = ['0%', '6%', '9%', '13%'];
  169. List<CostCategory> get _cats => widget.categories;
  170. AppLocalizations get _l10n => widget.l10n;
  171. CostCategory get _selCat => _cats.firstWhere((c) => c.code == _cat);
  172. bool get _isEdit => widget.initialData != null;
  173. @override
  174. void initState() {
  175. super.initState();
  176. final d = widget.initialData;
  177. _cat = d != null
  178. ? (_cats.any((c) => c.code == d.category)
  179. ? d.category
  180. : _cats.first.code)
  181. : _cats.isNotEmpty
  182. ? _cats.first.code
  183. : 'other';
  184. _descCtrl = TextEditingController(text: d?.purpose ?? '');
  185. _amountCtrl = TextEditingController(
  186. text: d != null && d.amount > 0 ? d.amount.toStringAsFixed(2) : '',
  187. );
  188. if (d != null && d.customerVendorName.isNotEmpty) {
  189. _selCustomer = CustomerVendor(id: '', name: d.customerVendorName);
  190. }
  191. _approvedAmountCtrl = TextEditingController(
  192. text: d != null && d.approvedAmount > 0
  193. ? d.approvedAmount.toStringAsFixed(2)
  194. : '',
  195. );
  196. _remarkCtrl = TextEditingController(text: d?.remark ?? '');
  197. _bankNameCtrl = TextEditingController(text: d?.bankName ?? '');
  198. _bankAccountNameCtrl = TextEditingController(
  199. text: d?.bankAccountName ?? '',
  200. );
  201. _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? '');
  202. _taxRate = d?.taxRate ?? 0;
  203. if (d != null && d.sqMan.isNotEmpty && widget.employees.isNotEmpty) {
  204. final idx = widget.employees.indexWhere((e) => e.salNo == d.sqMan);
  205. if (idx >= 0) _selEmployee = widget.employees[idx];
  206. }
  207. if (d != null) {
  208. if (d.projectId.isNotEmpty && widget.projects.isNotEmpty) {
  209. _selProject = widget.projects.firstWhere(
  210. (p) => p.id.toString() == d.projectId,
  211. orElse: () => widget.projects.first,
  212. );
  213. }
  214. if (d.costDeptId.isNotEmpty && widget.costDepts.isNotEmpty) {
  215. _selDept = widget.costDepts.firstWhere(
  216. (dept) => dept.id == d.costDeptId,
  217. orElse: () => widget.costDepts.first,
  218. );
  219. }
  220. if (d.attachmentPaths.isNotEmpty) {
  221. // Restore attachments will be handled after build
  222. WidgetsBinding.instance.addPostFrameCallback((_) {
  223. _attachmentCtrl.restoreFromPaths(d.attachmentPaths);
  224. });
  225. }
  226. }
  227. _attachmentCtrl = AttachmentPickerController(maxCount: 9)
  228. ..addListener(() => setState(() {}));
  229. _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
  230. _bankNameFocus.addListener(() => _ensureVisible(_bankNameFocus));
  231. _bankAccountNameFocus.addListener(
  232. () => _ensureVisible(_bankAccountNameFocus),
  233. );
  234. _bankAccountFocus.addListener(() => _ensureVisible(_bankAccountFocus));
  235. _checkAttachHealth();
  236. }
  237. Future<void> _checkAttachHealth() async {
  238. if (widget.checkAttachHealth == null) return;
  239. if (mounted) setState(() => _attachAvailable = false);
  240. try {
  241. final ok = await widget.checkAttachHealth!();
  242. if (mounted) setState(() => _attachAvailable = ok);
  243. } catch (_) {
  244. if (mounted) setState(() => _attachAvailable = false);
  245. }
  246. }
  247. @override
  248. void dispose() {
  249. _descCtrl.dispose();
  250. _amountCtrl.dispose();
  251. _approvedAmountCtrl.dispose();
  252. _remarkCtrl.dispose();
  253. _bankNameCtrl.dispose();
  254. _bankAccountNameCtrl.dispose();
  255. _bankAccountCtrl.dispose();
  256. _attachmentCtrl.dispose();
  257. _scrollCtrl.dispose();
  258. _remarkFocus.dispose();
  259. _bankNameFocus.dispose();
  260. _bankAccountNameFocus.dispose();
  261. _bankAccountFocus.dispose();
  262. super.dispose();
  263. }
  264. void _confirm() {
  265. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  266. if (amount <= 0) {
  267. TDToast.showText(_l10n.get('amountPositive'), context: context);
  268. return;
  269. }
  270. Navigator.pop(
  271. context,
  272. ExpenseDetailInputData(
  273. category: _cat,
  274. categoryName: _l10n.get(_selCat.nameKey),
  275. acctSubjectId: _selCat.acctSubjectId,
  276. acctSubjectName: _selCat.acctSubjectName,
  277. purpose: '',
  278. amount: amount,
  279. taxRate: _taxRate,
  280. projectId: _selProject?.id.toString() ?? '',
  281. projectName: _selProject?.name ?? '',
  282. costDeptId: _selDept?.id ?? '',
  283. costDeptName: _selDept?.name ?? '',
  284. customerVendorId: _selCustomer?.id ?? '',
  285. customerVendorName: _selCustomer?.name ?? '',
  286. approvedAmount: double.tryParse(_approvedAmountCtrl.text) ?? 0,
  287. bankName: _bankNameCtrl.text.trim(),
  288. bankAccountName: _bankAccountNameCtrl.text.trim(),
  289. bankAccount: _bankAccountCtrl.text.trim(),
  290. remark: _remarkCtrl.text.trim(),
  291. attachmentPaths: _attachmentCtrl.toPathList(),
  292. sqMan: _selEmployee?.salNo ?? '',
  293. sqManName: _selEmployee?.name ?? '',
  294. aeNo: widget.initialData?.aeNo ?? '',
  295. aeDd: widget.initialData?.aeDd ?? '',
  296. ),
  297. );
  298. }
  299. double get _amountExclTax => _taxRate > 0
  300. ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate)
  301. : (double.tryParse(_amountCtrl.text) ?? 0);
  302. double get _taxAmount =>
  303. (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax;
  304. @override
  305. Widget build(BuildContext context) {
  306. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  307. return AnimatedPadding(
  308. padding: EdgeInsets.only(
  309. bottom: MediaQuery.of(context).viewInsets.bottom,
  310. ),
  311. duration: const Duration(milliseconds: 200),
  312. child: SafeArea(
  313. child: ConstrainedBox(
  314. constraints: BoxConstraints(
  315. maxHeight: MediaQuery.of(context).size.height * 0.8,
  316. ),
  317. child: Container(
  318. decoration: BoxDecoration(
  319. color: colors.bgPage,
  320. borderRadius: const BorderRadius.vertical(
  321. top: Radius.circular(16),
  322. ),
  323. ),
  324. child: Column(
  325. mainAxisSize: MainAxisSize.min,
  326. crossAxisAlignment: CrossAxisAlignment.stretch,
  327. children: [
  328. _buildHeader(colors),
  329. Flexible(
  330. child: GestureDetector(
  331. onTap: () => FocusScope.of(context).unfocus(),
  332. behavior: HitTestBehavior.translucent,
  333. child: SingleChildScrollView(
  334. controller: _scrollCtrl,
  335. keyboardDismissBehavior:
  336. ScrollViewKeyboardDismissBehavior.manual,
  337. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  338. child: Column(
  339. mainAxisSize: MainAxisSize.min,
  340. crossAxisAlignment: CrossAxisAlignment.stretch,
  341. children: [
  342. if (_isEdit &&
  343. widget.initialData!.aeNo.isNotEmpty) ...[
  344. _buildAeInfoCard(colors),
  345. const SizedBox(height: 12),
  346. ],
  347. _buildCategoryCard(colors),
  348. const SizedBox(height: 12),
  349. _buildAcctSubjectCard(colors),
  350. const SizedBox(height: 12),
  351. _buildAmountCard(),
  352. const SizedBox(height: 12),
  353. _buildTaxRateCard(colors),
  354. if ((double.tryParse(_amountCtrl.text) ?? 0) > 0) ...[
  355. const SizedBox(height: 12),
  356. _buildCalcInfo(colors),
  357. ],
  358. const SizedBox(height: 12),
  359. _buildApprovedAmountCard(),
  360. const SizedBox(height: 12),
  361. _buildProjectCard(colors),
  362. const SizedBox(height: 12),
  363. _buildCostDeptCard(colors),
  364. const SizedBox(height: 12),
  365. _buildCustomerCard(colors),
  366. const SizedBox(height: 12),
  367. _buildEmployeeCard(colors),
  368. const SizedBox(height: 12),
  369. _buildBankInfoCard(colors),
  370. const SizedBox(height: 12),
  371. _buildRemarkInput(colors),
  372. const SizedBox(height: 12),
  373. _buildAttachmentCard(colors),
  374. ],
  375. ),
  376. ),
  377. ),
  378. ),
  379. Container(
  380. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  381. decoration: BoxDecoration(
  382. color: colors.bgCard,
  383. border: Border(
  384. top: BorderSide(color: colors.border, width: 0.5),
  385. ),
  386. ),
  387. child: _buildActions(),
  388. ),
  389. ],
  390. ),
  391. ),
  392. ),
  393. ),
  394. );
  395. }
  396. // ── 标题栏 ──
  397. Widget _buildHeader(AppColorsExtension colors) {
  398. return Column(
  399. mainAxisSize: MainAxisSize.min,
  400. children: [
  401. Center(
  402. child: Container(
  403. margin: const EdgeInsets.only(top: 8, bottom: 4),
  404. width: 36,
  405. height: 4,
  406. decoration: BoxDecoration(
  407. color: colors.border,
  408. borderRadius: BorderRadius.circular(2),
  409. ),
  410. ),
  411. ),
  412. Padding(
  413. padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
  414. child: Row(
  415. children: [
  416. const SizedBox(width: 28),
  417. Expanded(
  418. child: Center(
  419. child: Text(
  420. _l10n.get('addExpenseDetail'),
  421. style: TextStyle(
  422. fontSize: AppFontSizes.title,
  423. fontWeight: FontWeight.w600,
  424. color: colors.textPrimary,
  425. ),
  426. ),
  427. ),
  428. ),
  429. GestureDetector(
  430. onTap: () => Navigator.pop(context),
  431. child: Padding(
  432. padding: const EdgeInsets.all(4),
  433. child: Icon(
  434. Icons.close,
  435. size: 20,
  436. color: colors.textSecondary,
  437. ),
  438. ),
  439. ),
  440. ],
  441. ),
  442. ),
  443. ],
  444. );
  445. }
  446. // ── picker 卡片 ──
  447. Widget _pickerCard({
  448. required String label,
  449. required bool required,
  450. required String currentLabel,
  451. required List<String> labels,
  452. required ValueChanged<int> onSelected,
  453. required AppColorsExtension colors,
  454. VoidCallback? onClear,
  455. }) {
  456. final tdTheme = TDTheme.of(context);
  457. final hasValue = onClear != null;
  458. return GestureDetector(
  459. onTap: () {
  460. if (labels.isEmpty) {
  461. TDToast.showText(_l10n.get('noData'), context: context);
  462. return;
  463. }
  464. FocusManager.instance.primaryFocus?.unfocus();
  465. TDPicker.showMultiPicker(
  466. context,
  467. title: label,
  468. backgroundColor: colors.bgCard,
  469. data: [labels],
  470. onConfirm: (selected) {
  471. if (selected.isNotEmpty && selected[0] is int) {
  472. final idx = selected[0] as int;
  473. if (idx >= 0 && idx < labels.length) {
  474. Navigator.of(context).pop();
  475. onSelected(idx);
  476. }
  477. }
  478. },
  479. );
  480. },
  481. child: Container(
  482. padding: const EdgeInsets.only(
  483. left: 16,
  484. right: 10,
  485. top: 12,
  486. bottom: 12,
  487. ),
  488. decoration: BoxDecoration(
  489. color: tdTheme.bgColorContainer,
  490. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  491. border: Border.all(color: tdTheme.componentStrokeColor),
  492. ),
  493. child: Row(
  494. children: [
  495. TDText(
  496. label,
  497. maxLines: 1,
  498. overflow: TextOverflow.visible,
  499. font: tdTheme.fontBodyLarge,
  500. fontWeight: FontWeight.w400,
  501. style: const TextStyle(letterSpacing: 0),
  502. ),
  503. if (required)
  504. Padding(
  505. padding: const EdgeInsets.only(left: 4),
  506. child: TDText(
  507. '*',
  508. font: tdTheme.fontBodyLarge,
  509. fontWeight: FontWeight.w400,
  510. style: TextStyle(color: tdTheme.errorColor6),
  511. ),
  512. ),
  513. const SizedBox(width: 12),
  514. Expanded(
  515. child: Row(
  516. mainAxisAlignment: MainAxisAlignment.end,
  517. mainAxisSize: MainAxisSize.max,
  518. children: [
  519. Flexible(
  520. child: TDText(
  521. currentLabel,
  522. maxLines: 1,
  523. overflow: TextOverflow.ellipsis,
  524. font: tdTheme.fontBodyLarge,
  525. fontWeight: FontWeight.w400,
  526. textColor: tdTheme.textColorPrimary,
  527. textAlign: TextAlign.end,
  528. ),
  529. ),
  530. const SizedBox(width: 4),
  531. SizedBox(
  532. width: 18,
  533. height: 18,
  534. child: hasValue
  535. ? GestureDetector(
  536. onTap: onClear,
  537. child: Icon(
  538. Icons.close,
  539. size: 18,
  540. color: tdTheme.textColorPlaceholder,
  541. ),
  542. )
  543. : Icon(
  544. Icons.chevron_right,
  545. size: 18,
  546. color: tdTheme.textColorPlaceholder,
  547. ),
  548. ),
  549. ],
  550. ),
  551. ),
  552. ],
  553. ),
  554. ),
  555. );
  556. }
  557. // ── 费用类别 ──
  558. Widget _buildCategoryCard(AppColorsExtension colors) {
  559. return _pickerCard(
  560. label: _l10n.get('expenseCategory'),
  561. required: true,
  562. currentLabel: '${_selCat.code}/${_l10n.get(_selCat.nameKey)}',
  563. labels: _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList(),
  564. colors: colors,
  565. onSelected: (idx) => setState(() => _cat = _cats[idx].code),
  566. );
  567. }
  568. // ── 输入卡片(对齐 pickerCard 样式) ──
  569. Widget _inputCard({
  570. required String label,
  571. required bool required,
  572. required TextEditingController controller,
  573. required String hintText,
  574. required AppColorsExtension colors,
  575. TextInputType? keyboardType,
  576. List<TextInputFormatter>? inputFormatters,
  577. FocusNode? focusNode,
  578. }) {
  579. final tdTheme = TDTheme.of(context);
  580. final hasValue = controller.text.isNotEmpty;
  581. return Container(
  582. padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
  583. decoration: BoxDecoration(
  584. color: tdTheme.bgColorContainer,
  585. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  586. border: Border.all(color: tdTheme.componentStrokeColor),
  587. ),
  588. child: Row(
  589. children: [
  590. TDText(
  591. label,
  592. maxLines: 1,
  593. overflow: TextOverflow.visible,
  594. font: tdTheme.fontBodyLarge,
  595. fontWeight: FontWeight.w400,
  596. style: const TextStyle(letterSpacing: 0),
  597. ),
  598. if (required)
  599. Padding(
  600. padding: const EdgeInsets.only(left: 4),
  601. child: TDText(
  602. '*',
  603. font: tdTheme.fontBodyLarge,
  604. fontWeight: FontWeight.w400,
  605. style: TextStyle(color: tdTheme.errorColor6),
  606. ),
  607. ),
  608. const SizedBox(width: 12),
  609. Expanded(
  610. child: Row(
  611. mainAxisAlignment: MainAxisAlignment.end,
  612. mainAxisSize: MainAxisSize.max,
  613. children: [
  614. Flexible(
  615. child: TextField(
  616. controller: controller,
  617. focusNode: focusNode,
  618. textAlign: TextAlign.end,
  619. keyboardType: keyboardType,
  620. inputFormatters: inputFormatters,
  621. style: TextStyle(fontSize: 16, color: colors.textPrimary),
  622. decoration: InputDecoration(
  623. hintText: hintText,
  624. hintStyle: TextStyle(
  625. fontSize: 16,
  626. color: colors.textPlaceholder,
  627. ),
  628. border: InputBorder.none,
  629. isDense: true,
  630. contentPadding: EdgeInsets.zero,
  631. ),
  632. onChanged: (_) => setState(() {}),
  633. ),
  634. ),
  635. const SizedBox(width: 4),
  636. SizedBox(
  637. width: 18,
  638. height: 18,
  639. child: hasValue
  640. ? GestureDetector(
  641. onTap: () {
  642. controller.clear();
  643. setState(() {});
  644. },
  645. child: Icon(
  646. Icons.close,
  647. size: 18,
  648. color: tdTheme.textColorPlaceholder,
  649. ),
  650. )
  651. : null,
  652. ),
  653. ],
  654. ),
  655. ),
  656. ],
  657. ),
  658. );
  659. }
  660. // ── 含税金额 ──
  661. Widget _buildAmountCard() {
  662. return _inputCard(
  663. label: _l10n.get('amountInclTax'),
  664. required: true,
  665. controller: _amountCtrl,
  666. hintText: '>0',
  667. colors: Theme.of(context).extension<AppColorsExtension>()!,
  668. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  669. inputFormatters: [
  670. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
  671. ],
  672. );
  673. }
  674. // ── 税率 ──
  675. Widget _buildTaxRateCard(AppColorsExtension colors) {
  676. final currentLabel = '${(_taxRate * 100).toStringAsFixed(0)}%';
  677. return _pickerCard(
  678. label: _l10n.get('taxRate'),
  679. required: false,
  680. currentLabel: currentLabel,
  681. labels: _taxLabels.toList(),
  682. colors: colors,
  683. onSelected: (idx) => setState(() {
  684. _taxRate = _taxOptions[idx];
  685. }),
  686. );
  687. }
  688. // ── 计算信息 ──
  689. Widget _buildCalcInfo(AppColorsExtension colors) {
  690. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  691. if (amount <= 0) return const SizedBox.shrink();
  692. return Container(
  693. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  694. decoration: BoxDecoration(
  695. color: colors.primaryLight,
  696. borderRadius: BorderRadius.circular(8),
  697. ),
  698. child: Row(
  699. children: [
  700. Expanded(
  701. child: Text(
  702. '${_l10n.get('amountExcludingTax')}: ¥${_amountExclTax.toStringAsFixed(2)}',
  703. style: TextStyle(
  704. fontSize: AppFontSizes.body,
  705. color: colors.textSecondary,
  706. ),
  707. ),
  708. ),
  709. Text(
  710. '${_l10n.get('taxAmount')}: ¥${_taxAmount.toStringAsFixed(2)}',
  711. style: TextStyle(
  712. fontSize: AppFontSizes.body,
  713. color: colors.textSecondary,
  714. ),
  715. ),
  716. ],
  717. ),
  718. );
  719. }
  720. // ── 导入单据信息 ──
  721. Widget _buildAeInfoCard(AppColorsExtension colors) {
  722. final d = widget.initialData!;
  723. final tdTheme = TDTheme.of(context);
  724. return Container(
  725. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  726. decoration: _cardDecoration(tdTheme),
  727. child: Column(
  728. children: [
  729. _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo),
  730. const SizedBox(height: 8),
  731. _readOnlyRow(tdTheme, _l10n.get('applyDate'), d.aeDd),
  732. ],
  733. ),
  734. );
  735. }
  736. Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) {
  737. return Row(
  738. children: [
  739. TDText(
  740. label,
  741. font: tdTheme.fontBodyLarge,
  742. fontWeight: FontWeight.w400,
  743. style: const TextStyle(letterSpacing: 0),
  744. ),
  745. const SizedBox(width: 12),
  746. Expanded(
  747. child: TDText(
  748. value,
  749. maxLines: 1,
  750. overflow: TextOverflow.ellipsis,
  751. font: tdTheme.fontBodyLarge,
  752. fontWeight: FontWeight.w400,
  753. textColor: tdTheme.textColorPrimary,
  754. textAlign: TextAlign.end,
  755. ),
  756. ),
  757. ],
  758. );
  759. }
  760. BoxDecoration _cardDecoration(TDThemeData tdTheme) => BoxDecoration(
  761. color: tdTheme.bgColorContainer,
  762. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  763. border: Border.all(color: tdTheme.componentStrokeColor),
  764. );
  765. // ── 申请人 ──
  766. Widget _buildEmployeeCard(AppColorsExtension colors) {
  767. final employees = widget.employees;
  768. return _pickerCard(
  769. label: _l10n.get('applicant'),
  770. required: false,
  771. currentLabel: _selEmployee != null
  772. ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
  773. : _l10n.get('pleaseSelect'),
  774. labels: employees.map((e) => '${e.salNo}/${e.name}').toList(),
  775. colors: colors,
  776. onSelected: (idx) => setState(() {
  777. _selEmployee = employees[idx];
  778. _bankNameCtrl.text = _selEmployee!.bnkNo;
  779. _bankAccountNameCtrl.text = _selEmployee!.accName;
  780. _bankAccountCtrl.text = _selEmployee!.bnkId;
  781. }),
  782. onClear: _selEmployee != null
  783. ? () => setState(() {
  784. _selEmployee = null;
  785. _bankNameCtrl.clear();
  786. _bankAccountNameCtrl.clear();
  787. _bankAccountCtrl.clear();
  788. })
  789. : null,
  790. );
  791. }
  792. // ── 客户/厂商 ──
  793. Widget _buildCustomerCard(AppColorsExtension colors) {
  794. final vendors = widget.customers;
  795. return _pickerCard(
  796. label: _l10n.get('customerVendor'),
  797. required: false,
  798. currentLabel: _selCustomer != null
  799. ? '${_selCustomer!.id}/${_selCustomer!.name}'
  800. : _l10n.get('pleaseSelect'),
  801. labels: vendors.map((v) => '${v.id}/${v.name}').toList(),
  802. colors: colors,
  803. onSelected: (idx) => setState(() => _selCustomer = vendors[idx]),
  804. onClear: _selCustomer != null
  805. ? () => setState(() => _selCustomer = null)
  806. : null,
  807. );
  808. }
  809. // ── 已充金额 ──
  810. Widget _buildApprovedAmountCard() {
  811. return _inputCard(
  812. label: _l10n.get('approvedAmount'),
  813. required: false,
  814. controller: _approvedAmountCtrl,
  815. hintText: '0',
  816. colors: Theme.of(context).extension<AppColorsExtension>()!,
  817. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  818. inputFormatters: [
  819. FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
  820. ],
  821. );
  822. }
  823. // ── 备注 ──
  824. Widget _buildRemarkInput(AppColorsExtension colors) {
  825. final tdTheme = TDTheme.of(context);
  826. return TDTextarea(
  827. controller: _remarkCtrl,
  828. focusNode: _remarkFocus,
  829. label: _l10n.get('remark'),
  830. hintText: _l10n.get('enterRemark'),
  831. maxLines: 3,
  832. minLines: 1,
  833. maxLength: 500,
  834. indicator: true,
  835. decoration: BoxDecoration(
  836. color: tdTheme.bgColorContainer,
  837. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  838. border: Border.all(color: tdTheme.componentStrokeColor),
  839. ),
  840. onChanged: (_) => setState(() {}),
  841. );
  842. }
  843. // ── 操作按钮 ──
  844. Widget _buildAttachmentCard(AppColorsExtension colors) {
  845. final tdTheme = TDTheme.of(context);
  846. return Column(
  847. crossAxisAlignment: CrossAxisAlignment.start,
  848. children: [
  849. Padding(
  850. padding: const EdgeInsets.only(left: 4),
  851. child: TDText(
  852. _l10n.get('attachmentUpload'),
  853. font: tdTheme.fontBodyLarge,
  854. fontWeight: FontWeight.w400,
  855. style: const TextStyle(letterSpacing: 0),
  856. ),
  857. ),
  858. Padding(
  859. padding: const EdgeInsets.only(left: 4, top: 4),
  860. child: TDText(
  861. _l10n.get('maxAttachment'),
  862. font: tdTheme.fontBodySmall,
  863. fontWeight: FontWeight.w400,
  864. textColor: tdTheme.textColorPlaceholder,
  865. ),
  866. ),
  867. const SizedBox(height: 4),
  868. if (!_attachAvailable)
  869. TDText(
  870. _l10n.get('attachServiceUnavailable'),
  871. font: tdTheme.fontBodyMedium,
  872. fontWeight: FontWeight.w400,
  873. textColor: tdTheme.textColorPlaceholder,
  874. )
  875. else
  876. AttachmentPicker(
  877. controller: _attachmentCtrl,
  878. maxImageSizeMB: 10,
  879. maxFileSizeMB: 20,
  880. allowedExtensions: const [
  881. 'pdf',
  882. 'doc',
  883. 'docx',
  884. 'xls',
  885. 'xlsx',
  886. 'ppt',
  887. 'pptx',
  888. 'txt',
  889. ],
  890. onFileRejected: (file, reason) {
  891. if (context.mounted) TDToast.showText(reason, context: context);
  892. },
  893. ),
  894. ],
  895. );
  896. }
  897. // ── 会计科目(只读,选择类别后自动带出) ──
  898. Widget _buildAcctSubjectCard(AppColorsExtension colors) {
  899. return _readOnlyCard(
  900. label: _l10n.get('acctSubject'),
  901. value: '${_selCat.acctSubjectId}/${_selCat.acctSubjectName}',
  902. colors: colors,
  903. );
  904. }
  905. Widget _readOnlyCard({
  906. required String label,
  907. required String value,
  908. required AppColorsExtension colors,
  909. }) {
  910. final tdTheme = TDTheme.of(context);
  911. return Container(
  912. padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 12),
  913. decoration: BoxDecoration(
  914. color: tdTheme.bgColorContainer,
  915. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  916. border: Border.all(color: tdTheme.componentStrokeColor),
  917. ),
  918. child: Row(
  919. children: [
  920. TDText(
  921. label,
  922. maxLines: 1,
  923. overflow: TextOverflow.visible,
  924. font: tdTheme.fontBodyLarge,
  925. fontWeight: FontWeight.w400,
  926. style: const TextStyle(letterSpacing: 0),
  927. ),
  928. const SizedBox(width: 12),
  929. Expanded(
  930. child: TDText(
  931. value,
  932. maxLines: 1,
  933. overflow: TextOverflow.ellipsis,
  934. font: tdTheme.fontBodyLarge,
  935. fontWeight: FontWeight.w400,
  936. textColor: tdTheme.textColorPrimary,
  937. textAlign: TextAlign.end,
  938. ),
  939. ),
  940. ],
  941. ),
  942. );
  943. }
  944. // ── 关联项目 ──
  945. Widget _buildProjectCard(AppColorsExtension colors) {
  946. final projects = widget.projects;
  947. return _pickerCard(
  948. label: _l10n.get('relatedProject'),
  949. required: false,
  950. currentLabel: _selProject != null
  951. ? '${_selProject!.id}/${_selProject!.name}'
  952. : _l10n.get('pleaseSelect'),
  953. labels: projects.map((p) => '${p.id}/${p.name}').toList(),
  954. colors: colors,
  955. onSelected: (idx) => setState(() => _selProject = projects[idx]),
  956. onClear: _selProject != null
  957. ? () => setState(() => _selProject = null)
  958. : null,
  959. );
  960. }
  961. // ── 费用承担部门 ──
  962. Widget _buildCostDeptCard(AppColorsExtension colors) {
  963. final depts = widget.costDepts;
  964. return _pickerCard(
  965. label: _l10n.get('costDept'),
  966. required: false,
  967. currentLabel: _selDept != null
  968. ? '${_selDept!.id}/${_selDept!.name}'
  969. : _l10n.get('pleaseSelect'),
  970. labels: depts.map((d) => '${d.id}/${d.name}').toList(),
  971. colors: colors,
  972. onSelected: (idx) => setState(() => _selDept = depts[idx]),
  973. onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
  974. );
  975. }
  976. // ── 收款银行信息 ──
  977. Widget _buildBankInfoCard(AppColorsExtension colors) {
  978. return Column(
  979. crossAxisAlignment: CrossAxisAlignment.start,
  980. children: [
  981. _inputCard(
  982. label: _l10n.get('bankName'),
  983. required: false,
  984. controller: _bankNameCtrl,
  985. hintText: _l10n.get('pleaseEnter'),
  986. colors: colors,
  987. focusNode: _bankNameFocus,
  988. ),
  989. const SizedBox(height: 12),
  990. _inputCard(
  991. label: _l10n.get('bankAccountName'),
  992. required: false,
  993. controller: _bankAccountNameCtrl,
  994. hintText: _l10n.get('pleaseEnter'),
  995. colors: colors,
  996. focusNode: _bankAccountNameFocus,
  997. ),
  998. const SizedBox(height: 12),
  999. _inputCard(
  1000. label: _l10n.get('bankAccount'),
  1001. required: false,
  1002. controller: _bankAccountCtrl,
  1003. hintText: _l10n.get('pleaseEnter'),
  1004. colors: colors,
  1005. focusNode: _bankAccountFocus,
  1006. ),
  1007. ],
  1008. );
  1009. }
  1010. Widget _buildActions() {
  1011. return Row(
  1012. children: [
  1013. Expanded(
  1014. child: TDButton(
  1015. text: _l10n.get('cancel'),
  1016. size: TDButtonSize.large,
  1017. type: TDButtonType.outline,
  1018. shape: TDButtonShape.rectangle,
  1019. theme: TDButtonTheme.defaultTheme,
  1020. onTap: () => Navigator.pop(context),
  1021. ),
  1022. ),
  1023. const SizedBox(width: 12),
  1024. Expanded(
  1025. child: TDButton(
  1026. text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'),
  1027. size: TDButtonSize.large,
  1028. type: TDButtonType.fill,
  1029. shape: TDButtonShape.rectangle,
  1030. theme: TDButtonTheme.primary,
  1031. onTap: _confirm,
  1032. ),
  1033. ),
  1034. ],
  1035. );
  1036. }
  1037. }