expense_detail_dialog.dart 35 KB

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