expense_detail_dialog.dart 38 KB

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