expense_detail_dialog.dart 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841
  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() =>
  120. _ExpenseDetailDialogState();
  121. }
  122. class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
  123. late String _cat;
  124. late TextEditingController _descCtrl;
  125. late TextEditingController _amountCtrl;
  126. CustomerVendor? _selCustomer;
  127. late TextEditingController _approvedAmountCtrl;
  128. late TextEditingController _remarkCtrl;
  129. late TextEditingController _bankNameCtrl;
  130. late TextEditingController _bankAccountNameCtrl;
  131. late TextEditingController _bankAccountCtrl;
  132. double _taxRate = 0;
  133. Project? _selProject;
  134. CostDept? _selDept;
  135. EmployeeItem? _selEmployee;
  136. late final AttachmentPickerController _attachmentCtrl;
  137. final ScrollController _scrollCtrl = ScrollController();
  138. bool _attachAvailable = false;
  139. static const _taxOptions = [0.0, 0.06, 0.09, 0.13];
  140. static const _taxLabels = ['0%', '6%', '9%', '13%'];
  141. List<CostCategory> get _cats => widget.categories;
  142. AppLocalizations get _l10n => widget.l10n;
  143. CostCategory get _selCat => _cats.firstWhere((c) => c.code == _cat);
  144. bool get _isEdit => widget.initialData != null;
  145. @override
  146. void initState() {
  147. super.initState();
  148. final d = widget.initialData;
  149. _cat = d != null
  150. ? (_cats.any((c) => c.code == d.category) ? d.category : _cats.first.code)
  151. : _cats.isNotEmpty ? _cats.first.code : 'other';
  152. _descCtrl = TextEditingController(text: d?.purpose ?? '');
  153. _amountCtrl = TextEditingController(text: d != null && d.amount > 0 ? d.amount.toStringAsFixed(2) : '');
  154. if (d != null && d.customerVendorName.isNotEmpty) {
  155. _selCustomer = CustomerVendor(id: '', name: d.customerVendorName);
  156. }
  157. _approvedAmountCtrl = TextEditingController(text: d != null && d.approvedAmount > 0 ? d.approvedAmount.toStringAsFixed(2) : '');
  158. _remarkCtrl = TextEditingController(text: d?.remark ?? '');
  159. _bankNameCtrl = TextEditingController(text: d?.bankName ?? '');
  160. _bankAccountNameCtrl = TextEditingController(text: d?.bankAccountName ?? '');
  161. _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? '');
  162. _taxRate = d?.taxRate ?? 0;
  163. if (d != null && d.sqMan.isNotEmpty && widget.employees.isNotEmpty) {
  164. final idx = widget.employees.indexWhere((e) => e.salNo == d.sqMan);
  165. if (idx >= 0) _selEmployee = widget.employees[idx];
  166. }
  167. if (d != null) {
  168. if (d.projectId.isNotEmpty && widget.projects.isNotEmpty) {
  169. _selProject = widget.projects.firstWhere((p) => p.id.toString() == d.projectId, orElse: () => widget.projects.first);
  170. }
  171. if (d.costDeptId.isNotEmpty && widget.costDepts.isNotEmpty) {
  172. _selDept = widget.costDepts.firstWhere((dept) => dept.id == d.costDeptId, orElse: () => widget.costDepts.first);
  173. }
  174. if (d.attachmentPaths.isNotEmpty) {
  175. // Restore attachments will be handled after build
  176. WidgetsBinding.instance.addPostFrameCallback((_) {
  177. _attachmentCtrl.restoreFromPaths(d.attachmentPaths);
  178. });
  179. }
  180. }
  181. _attachmentCtrl = AttachmentPickerController(maxCount: 9)
  182. ..addListener(() => setState(() {}));
  183. _checkAttachHealth();
  184. }
  185. Future<void> _checkAttachHealth() async {
  186. if (widget.checkAttachHealth == null) return;
  187. if (mounted) setState(() => _attachAvailable = false);
  188. try {
  189. final ok = await widget.checkAttachHealth!();
  190. if (mounted) setState(() => _attachAvailable = ok);
  191. } catch (_) {
  192. if (mounted) setState(() => _attachAvailable = false);
  193. }
  194. }
  195. @override
  196. void dispose() {
  197. _descCtrl.dispose();
  198. _amountCtrl.dispose();
  199. _approvedAmountCtrl.dispose();
  200. _remarkCtrl.dispose();
  201. _bankNameCtrl.dispose();
  202. _bankAccountNameCtrl.dispose();
  203. _bankAccountCtrl.dispose();
  204. _attachmentCtrl.dispose();
  205. _scrollCtrl.dispose();
  206. super.dispose();
  207. }
  208. void _confirm() {
  209. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  210. if (amount <= 0) {
  211. TDToast.showText(_l10n.get('amountPositive'), context: context);
  212. return;
  213. }
  214. Navigator.pop(
  215. context,
  216. ExpenseDetailInputData(
  217. category: _cat,
  218. categoryName: _l10n.get(_selCat.nameKey),
  219. acctSubjectId: _selCat.acctSubjectId,
  220. acctSubjectName: _selCat.acctSubjectName,
  221. purpose: '',
  222. amount: amount,
  223. taxRate: _taxRate,
  224. projectId: _selProject?.id.toString() ?? '',
  225. projectName: _selProject?.name ?? '',
  226. costDeptId: _selDept?.id ?? '',
  227. costDeptName: _selDept?.name ?? '',
  228. customerVendorId: _selCustomer?.id ?? '',
  229. customerVendorName: _selCustomer?.name ?? '',
  230. approvedAmount: double.tryParse(_approvedAmountCtrl.text) ?? 0,
  231. bankName: _bankNameCtrl.text.trim(),
  232. bankAccountName: _bankAccountNameCtrl.text.trim(),
  233. bankAccount: _bankAccountCtrl.text.trim(),
  234. remark: _remarkCtrl.text.trim(),
  235. attachmentPaths: _attachmentCtrl.toPathList(),
  236. sqMan: _selEmployee?.salNo ?? '',
  237. sqManName: _selEmployee?.name ?? '',
  238. aeNo: widget.initialData?.aeNo ?? '',
  239. aeDd: widget.initialData?.aeDd ?? '',
  240. ),
  241. );
  242. }
  243. double get _amountExclTax => _taxRate > 0
  244. ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate)
  245. : (double.tryParse(_amountCtrl.text) ?? 0);
  246. double get _taxAmount =>
  247. (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax;
  248. @override
  249. Widget build(BuildContext context) {
  250. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  251. return SafeArea(
  252. child: ConstrainedBox(
  253. constraints: BoxConstraints(
  254. maxHeight: MediaQuery.of(context).size.height * 0.8,
  255. ),
  256. child: Container(
  257. decoration: BoxDecoration(
  258. color: colors.bgPage,
  259. borderRadius:
  260. const BorderRadius.vertical(top: Radius.circular(16)),
  261. ),
  262. child: Column(
  263. mainAxisSize: MainAxisSize.min,
  264. crossAxisAlignment: CrossAxisAlignment.stretch,
  265. children: [
  266. _buildHeader(colors),
  267. Flexible(
  268. child: GestureDetector(
  269. onTap: () => FocusScope.of(context).unfocus(),
  270. behavior: HitTestBehavior.translucent,
  271. child: SingleChildScrollView(
  272. controller: _scrollCtrl,
  273. keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
  274. padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
  275. child: Column(
  276. mainAxisSize: MainAxisSize.min,
  277. crossAxisAlignment: CrossAxisAlignment.stretch,
  278. children: [
  279. if (_isEdit && widget.initialData!.aeNo.isNotEmpty) ...[
  280. _buildAeInfoCard(colors),
  281. const SizedBox(height: 12),
  282. ],
  283. _buildCategoryCard(colors),
  284. const SizedBox(height: 12),
  285. _buildAcctSubjectCard(colors),
  286. const SizedBox(height: 12),
  287. _buildAmountCard(),
  288. const SizedBox(height: 12),
  289. _buildTaxRateCard(colors),
  290. if ((double.tryParse(_amountCtrl.text) ?? 0) > 0) ...[
  291. const SizedBox(height: 12),
  292. _buildCalcInfo(colors),
  293. ],
  294. const SizedBox(height: 12),
  295. _buildApprovedAmountCard(),
  296. const SizedBox(height: 12),
  297. _buildProjectCard(colors),
  298. const SizedBox(height: 12),
  299. _buildCostDeptCard(colors),
  300. const SizedBox(height: 12),
  301. _buildCustomerCard(colors),
  302. const SizedBox(height: 12),
  303. _buildEmployeeCard(colors),
  304. const SizedBox(height: 12),
  305. _buildBankInfoCard(colors),
  306. const SizedBox(height: 12),
  307. _buildRemarkInput(colors),
  308. const SizedBox(height: 12),
  309. _buildAttachmentCard(colors),
  310. ],
  311. ),
  312. ),
  313. ),
  314. ),
  315. Container(
  316. padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
  317. decoration: BoxDecoration(
  318. color: colors.bgCard,
  319. border: Border(
  320. top: BorderSide(color: colors.border, width: 0.5),
  321. ),
  322. ),
  323. child: _buildActions(),
  324. ),
  325. ],
  326. ),
  327. ),
  328. ),
  329. );
  330. }
  331. // ── 标题栏 ──
  332. Widget _buildHeader(AppColorsExtension colors) {
  333. return Column(
  334. mainAxisSize: MainAxisSize.min,
  335. children: [
  336. Center(
  337. child: Container(
  338. margin: const EdgeInsets.only(top: 8, bottom: 4),
  339. width: 36,
  340. height: 4,
  341. decoration: BoxDecoration(
  342. color: colors.border,
  343. borderRadius: BorderRadius.circular(2),
  344. ),
  345. ),
  346. ),
  347. Padding(
  348. padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
  349. child: Row(
  350. children: [
  351. const SizedBox(width: 28),
  352. Expanded(
  353. child: Center(
  354. child: Text(
  355. _l10n.get('addExpenseDetail'),
  356. style: TextStyle(
  357. fontSize: AppFontSizes.title,
  358. fontWeight: FontWeight.w600,
  359. color: colors.textPrimary,
  360. ),
  361. ),
  362. ),
  363. ),
  364. GestureDetector(
  365. onTap: () => Navigator.pop(context),
  366. child: Padding(
  367. padding: const EdgeInsets.all(4),
  368. child: Icon(
  369. Icons.close,
  370. size: 20,
  371. color: colors.textSecondary,
  372. ),
  373. ),
  374. ),
  375. ],
  376. ),
  377. ),
  378. ],
  379. );
  380. }
  381. // ── picker 卡片 ──
  382. Widget _pickerCard({
  383. required String label,
  384. required bool required,
  385. required String currentLabel,
  386. required List<String> labels,
  387. required ValueChanged<int> onSelected,
  388. required AppColorsExtension colors,
  389. VoidCallback? onClear,
  390. }) {
  391. final tdTheme = TDTheme.of(context);
  392. final hasValue = onClear != null;
  393. return GestureDetector(
  394. onTap: () {
  395. if (labels.isEmpty) {
  396. TDToast.showText(_l10n.get('noData'), context: context);
  397. return;
  398. }
  399. TDPicker.showMultiPicker(
  400. context,
  401. title: label,
  402. backgroundColor: colors.bgCard,
  403. data: [labels],
  404. onConfirm: (selected) {
  405. if (selected.isNotEmpty && selected[0] is int) {
  406. final idx = selected[0] as int;
  407. if (idx >= 0 && idx < labels.length) {
  408. Navigator.of(context).pop();
  409. onSelected(idx);
  410. }
  411. }
  412. },
  413. );
  414. },
  415. child: Container(
  416. padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
  417. decoration: BoxDecoration(
  418. color: tdTheme.bgColorContainer,
  419. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  420. border: Border.all(color: tdTheme.componentStrokeColor),
  421. ),
  422. child: Row(
  423. children: [
  424. TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
  425. if (required)
  426. Padding(padding: const EdgeInsets.only(left: 4), child: TDText('*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6))),
  427. const SizedBox(width: 12),
  428. Expanded(
  429. child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
  430. Flexible(child: TDText(currentLabel, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end)),
  431. const SizedBox(width: 4),
  432. SizedBox(
  433. width: 18, height: 18,
  434. child: hasValue
  435. ? GestureDetector(onTap: onClear, child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder))
  436. : Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder),
  437. ),
  438. ]),
  439. ),
  440. ],
  441. ),
  442. ),
  443. );
  444. }
  445. // ── 费用类别 ──
  446. Widget _buildCategoryCard(AppColorsExtension colors) {
  447. return _pickerCard(
  448. label: _l10n.get('expenseCategory'),
  449. required: true,
  450. currentLabel: '${_selCat.code}/${_l10n.get(_selCat.nameKey)}',
  451. labels: _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList(),
  452. colors: colors,
  453. onSelected: (idx) => setState(() => _cat = _cats[idx].code),
  454. );
  455. }
  456. // ── 输入卡片(对齐 pickerCard 样式) ──
  457. Widget _inputCard({
  458. required String label,
  459. required bool required,
  460. required TextEditingController controller,
  461. required String hintText,
  462. required AppColorsExtension colors,
  463. TextInputType? keyboardType,
  464. List<TextInputFormatter>? inputFormatters,
  465. }) {
  466. final tdTheme = TDTheme.of(context);
  467. final hasValue = controller.text.isNotEmpty;
  468. return Container(
  469. padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
  470. decoration: BoxDecoration(
  471. color: tdTheme.bgColorContainer,
  472. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  473. border: Border.all(color: tdTheme.componentStrokeColor),
  474. ),
  475. child: Row(
  476. children: [
  477. TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
  478. if (required)
  479. Padding(padding: const EdgeInsets.only(left: 4), child: TDText('*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6))),
  480. const SizedBox(width: 12),
  481. Expanded(
  482. child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
  483. Flexible(
  484. child: TextField(
  485. controller: controller,
  486. textAlign: TextAlign.end,
  487. keyboardType: keyboardType,
  488. inputFormatters: inputFormatters,
  489. style: TextStyle(fontSize: 16, color: colors.textPrimary),
  490. decoration: InputDecoration(
  491. hintText: hintText,
  492. hintStyle: TextStyle(fontSize: 16, color: colors.textPlaceholder),
  493. border: InputBorder.none,
  494. isDense: true,
  495. contentPadding: EdgeInsets.zero,
  496. ),
  497. onChanged: (_) => setState(() {}),
  498. ),
  499. ),
  500. const SizedBox(width: 4),
  501. SizedBox(
  502. width: 18, height: 18,
  503. child: hasValue
  504. ? GestureDetector(
  505. onTap: () { controller.clear(); setState(() {}); },
  506. child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder),
  507. )
  508. : null,
  509. ),
  510. ]),
  511. ),
  512. ],
  513. ),
  514. );
  515. }
  516. // ── 含税金额 ──
  517. Widget _buildAmountCard() {
  518. return _inputCard(
  519. label: _l10n.get('amountInclTax'),
  520. required: true,
  521. controller: _amountCtrl,
  522. hintText: '>0',
  523. colors: Theme.of(context).extension<AppColorsExtension>()!,
  524. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  525. inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
  526. );
  527. }
  528. // ── 税率 ──
  529. Widget _buildTaxRateCard(AppColorsExtension colors) {
  530. final currentLabel =
  531. '${(_taxRate * 100).toStringAsFixed(0)}%';
  532. return _pickerCard(
  533. label: _l10n.get('taxRate'),
  534. required: true,
  535. currentLabel: currentLabel,
  536. labels: _taxLabels.toList(),
  537. colors: colors,
  538. onSelected: (idx) => setState(() {
  539. _taxRate = _taxOptions[idx];
  540. }),
  541. );
  542. }
  543. // ── 计算信息 ──
  544. Widget _buildCalcInfo(AppColorsExtension colors) {
  545. final amount = double.tryParse(_amountCtrl.text) ?? 0;
  546. if (amount <= 0) return const SizedBox.shrink();
  547. return Container(
  548. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  549. decoration: BoxDecoration(
  550. color: colors.primaryLight,
  551. borderRadius: BorderRadius.circular(8),
  552. ),
  553. child: Row(
  554. children: [
  555. Expanded(
  556. child: Text(
  557. '${_l10n.get('amountExcludingTax')}: ¥${_amountExclTax.toStringAsFixed(2)}',
  558. style: TextStyle(
  559. fontSize: AppFontSizes.body,
  560. color: colors.textSecondary,
  561. ),
  562. ),
  563. ),
  564. Text(
  565. '${_l10n.get('taxAmount')}: ¥${_taxAmount.toStringAsFixed(2)}',
  566. style: TextStyle(
  567. fontSize: AppFontSizes.body,
  568. color: colors.textSecondary,
  569. ),
  570. ),
  571. ],
  572. ),
  573. );
  574. }
  575. // ── 导入单据信息 ──
  576. Widget _buildAeInfoCard(AppColorsExtension colors) {
  577. final d = widget.initialData!;
  578. final tdTheme = TDTheme.of(context);
  579. return Container(
  580. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  581. decoration: _cardDecoration(tdTheme),
  582. child: Column(
  583. children: [
  584. _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo),
  585. const SizedBox(height: 8),
  586. _readOnlyRow(tdTheme, _l10n.get('applyDate'), d.aeDd),
  587. ],
  588. ),
  589. );
  590. }
  591. Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) {
  592. return Row(children: [
  593. TDText(label, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
  594. const SizedBox(width: 12),
  595. Expanded(child: TDText(value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end)),
  596. ]);
  597. }
  598. BoxDecoration _cardDecoration(TDThemeData tdTheme) => BoxDecoration(
  599. color: tdTheme.bgColorContainer,
  600. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  601. border: Border.all(color: tdTheme.componentStrokeColor),
  602. );
  603. // ── 申请人 ──
  604. Widget _buildEmployeeCard(AppColorsExtension colors) {
  605. final employees = widget.employees;
  606. return _pickerCard(
  607. label: _l10n.get('applicant'),
  608. required: false,
  609. currentLabel: _selEmployee != null ? '${_selEmployee!.salNo}/${_selEmployee!.name}' : _l10n.get('pleaseSelect'),
  610. labels: employees.map((e) => '${e.salNo}/${e.name}').toList(),
  611. colors: colors,
  612. onSelected: (idx) => setState(() {
  613. _selEmployee = employees[idx];
  614. _bankNameCtrl.text = _selEmployee!.bnkNo;
  615. _bankAccountNameCtrl.text = _selEmployee!.accName;
  616. _bankAccountCtrl.text = _selEmployee!.bnkId;
  617. }),
  618. onClear: _selEmployee != null ? () => setState(() {
  619. _selEmployee = null;
  620. _bankNameCtrl.clear();
  621. _bankAccountNameCtrl.clear();
  622. _bankAccountCtrl.clear();
  623. }) : null,
  624. );
  625. }
  626. // ── 客户/厂商 ──
  627. Widget _buildCustomerCard(AppColorsExtension colors) {
  628. final vendors = widget.customers;
  629. return _pickerCard(
  630. label: _l10n.get('customerVendor'),
  631. required: false,
  632. currentLabel: _selCustomer != null ? '${_selCustomer!.id}/${_selCustomer!.name}' : _l10n.get('pleaseSelect'),
  633. labels: vendors.map((v) => '${v.id}/${v.name}').toList(),
  634. colors: colors,
  635. onSelected: (idx) => setState(() => _selCustomer = vendors[idx]),
  636. onClear: _selCustomer != null ? () => setState(() => _selCustomer = null) : null,
  637. );
  638. }
  639. // ── 已充金额 ──
  640. Widget _buildApprovedAmountCard() {
  641. return _inputCard(
  642. label: _l10n.get('approvedAmount'),
  643. required: false,
  644. controller: _approvedAmountCtrl,
  645. hintText: '0',
  646. colors: Theme.of(context).extension<AppColorsExtension>()!,
  647. keyboardType: const TextInputType.numberWithOptions(decimal: true),
  648. inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
  649. );
  650. }
  651. // ── 备注 ──
  652. Widget _buildRemarkInput(AppColorsExtension colors) {
  653. final tdTheme = TDTheme.of(context);
  654. return TDTextarea(
  655. controller: _remarkCtrl,
  656. label: _l10n.get('remark'),
  657. hintText: _l10n.get('enterRemark'),
  658. maxLines: 3,
  659. minLines: 1,
  660. maxLength: 500,
  661. indicator: true,
  662. decoration: BoxDecoration(
  663. color: tdTheme.bgColorContainer,
  664. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  665. border: Border.all(color: tdTheme.componentStrokeColor),
  666. ),
  667. onChanged: (_) => setState(() {}),
  668. );
  669. }
  670. // ── 操作按钮 ──
  671. Widget _buildAttachmentCard(AppColorsExtension colors) {
  672. final tdTheme = TDTheme.of(context);
  673. return Column(
  674. crossAxisAlignment: CrossAxisAlignment.start,
  675. children: [
  676. Padding(
  677. padding: const EdgeInsets.only(left: 4),
  678. child: TDText(
  679. _l10n.get('attachmentUpload'),
  680. font: tdTheme.fontBodyLarge,
  681. fontWeight: FontWeight.w400,
  682. style: const TextStyle(letterSpacing: 0),
  683. ),
  684. ),
  685. const SizedBox(height: 4),
  686. if (!_attachAvailable)
  687. TDText(_l10n.get('attachServiceUnavailable'),
  688. font: tdTheme.fontBodyMedium, fontWeight: FontWeight.w400,
  689. textColor: tdTheme.textColorPlaceholder)
  690. else
  691. AttachmentPicker(
  692. controller: _attachmentCtrl,
  693. maxImageSizeMB: 10,
  694. maxFileSizeMB: 20,
  695. allowedExtensions: const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
  696. onFileRejected: (file, reason) {
  697. if (context.mounted) TDToast.showText(reason, context: context);
  698. },
  699. ),
  700. ],
  701. );
  702. }
  703. // ── 会计科目(只读,选择类别后自动带出) ──
  704. Widget _buildAcctSubjectCard(AppColorsExtension colors) {
  705. return _readOnlyCard(
  706. label: _l10n.get('acctSubject'),
  707. value: '${_selCat.acctSubjectId}/${_selCat.acctSubjectName}',
  708. colors: colors,
  709. );
  710. }
  711. Widget _readOnlyCard({
  712. required String label,
  713. required String value,
  714. required AppColorsExtension colors,
  715. }) {
  716. final tdTheme = TDTheme.of(context);
  717. return Container(
  718. padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 12),
  719. decoration: BoxDecoration(
  720. color: tdTheme.bgColorContainer,
  721. borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
  722. border: Border.all(color: tdTheme.componentStrokeColor),
  723. ),
  724. child: Row(
  725. children: [
  726. TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
  727. const SizedBox(width: 12),
  728. Expanded(
  729. child: TDText(value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400,
  730. textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end),
  731. ),
  732. ],
  733. ),
  734. );
  735. }
  736. // ── 关联项目 ──
  737. Widget _buildProjectCard(AppColorsExtension colors) {
  738. final projects = widget.projects;
  739. return _pickerCard(
  740. label: _l10n.get('relatedProject'),
  741. required: false,
  742. currentLabel: _selProject != null ? '${_selProject!.id}/${_selProject!.name}' : _l10n.get('pleaseSelect'),
  743. labels: projects.map((p) => '${p.id}/${p.name}').toList(),
  744. colors: colors,
  745. onSelected: (idx) => setState(() => _selProject = projects[idx]),
  746. onClear: _selProject != null ? () => setState(() => _selProject = null) : null,
  747. );
  748. }
  749. // ── 费用承担部门 ──
  750. Widget _buildCostDeptCard(AppColorsExtension colors) {
  751. final depts = widget.costDepts;
  752. return _pickerCard(
  753. label: _l10n.get('costDept'),
  754. required: false,
  755. currentLabel: _selDept != null ? '${_selDept!.id}/${_selDept!.name}' : _l10n.get('pleaseSelect'),
  756. labels: depts.map((d) => '${d.id}/${d.name}').toList(),
  757. colors: colors,
  758. onSelected: (idx) => setState(() => _selDept = depts[idx]),
  759. onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
  760. );
  761. }
  762. // ── 收款银行信息 ──
  763. Widget _buildBankInfoCard(AppColorsExtension colors) {
  764. return Column(
  765. crossAxisAlignment: CrossAxisAlignment.start,
  766. children: [
  767. _inputCard(label: _l10n.get('bankName'), required: false, controller: _bankNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
  768. const SizedBox(height: 12),
  769. _inputCard(label: _l10n.get('bankAccountName'), required: false, controller: _bankAccountNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
  770. const SizedBox(height: 12),
  771. _inputCard(label: _l10n.get('bankAccount'), required: false, controller: _bankAccountCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
  772. ],
  773. );
  774. }
  775. Widget _buildActions() {
  776. return Row(
  777. children: [
  778. Expanded(
  779. child: TDButton(
  780. text: _l10n.get('cancel'),
  781. size: TDButtonSize.large,
  782. type: TDButtonType.outline,
  783. shape: TDButtonShape.rectangle,
  784. theme: TDButtonTheme.defaultTheme,
  785. onTap: () => Navigator.pop(context),
  786. ),
  787. ),
  788. const SizedBox(width: 12),
  789. Expanded(
  790. child: TDButton(
  791. text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'),
  792. size: TDButtonSize.large,
  793. type: TDButtonType.fill,
  794. shape: TDButtonShape.rectangle,
  795. theme: TDButtonTheme.primary,
  796. onTap: _confirm,
  797. ),
  798. ),
  799. ],
  800. );
  801. }
  802. }