expense_detail_dialog.dart 30 KB

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