expense_detail_dialog.dart 38 KB

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