expense_detail_dialog.dart 35 KB

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