// ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../../core/i18n/app_localizations.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors_extension.dart'; import '../../../core/utils/amount_utils.dart'; import '../../../shared/models/bill_file_rights.dart'; import '../expense_api.dart'; import '../../../shared/widgets/attachment_picker.dart'; import '../../../shared/widgets/searchable_picker_sheet.dart'; /// 报销明细输入数据。 class ExpenseDetailInputData { final String category; final String categoryName; final String acctSubjectId; final String acctSubjectName; final String purpose; final double amount; final double taxRate; final String projectId; final String projectName; final String costDeptId; final String costDeptName; final String customerVendorId; final String customerVendorName; final double approvedAmount; final double offsetAmount; final String bankName; final String bankAccountName; final String bankAccount; final String remark; final List attachmentPaths; final String sqMan; final String sqManName; final String aeNo; final String aeDd; const ExpenseDetailInputData({ required this.category, required this.categoryName, required this.acctSubjectId, required this.acctSubjectName, required this.purpose, required this.amount, required this.taxRate, this.projectId = '', this.projectName = '', this.costDeptId = '', this.costDeptName = '', this.customerVendorId = '', this.customerVendorName = '', this.approvedAmount = 0.0, this.offsetAmount = 0.0, this.bankName = '', this.bankAccountName = '', this.bankAccount = '', this.remark = '', this.attachmentPaths = const [], this.sqMan = '', this.sqManName = '', this.aeNo = '', this.aeDd = '', }); } /// 报销明细编辑弹窗。 /// /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。 /// 参照 ExpenseApplyCreatePage 的 ExpenseDetailDialog 样式。 class ExpenseDetailDialog extends StatefulWidget { final ExpenseApi api; final AppLocalizations l10n; final ExpenseDetailInputData? initialData; final Future Function()? checkAttachHealth; final bool showAttachments; final bool canEditApprovedAmount; final BillFileRights? billFileRights; const ExpenseDetailDialog({ super.key, required this.api, required this.l10n, this.initialData, this.checkAttachHealth, this.showAttachments = true, this.canEditApprovedAmount = false, this.billFileRights, }); /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。 static Future show( BuildContext context, { required ExpenseApi api, required AppLocalizations l10n, ExpenseDetailInputData? initialData, Future Function()? checkAttachHealth, bool showAttachments = true, bool canEditApprovedAmount = false, BillFileRights? billFileRights, }) { FocusScope.of(context).unfocus(); return Navigator.push( context, TDSlidePopupRoute( slideTransitionFrom: SlideTransitionFrom.bottom, isDismissible: true, builder: (_) => ExpenseDetailDialog( api: api, l10n: l10n, initialData: initialData, checkAttachHealth: checkAttachHealth, showAttachments: showAttachments, canEditApprovedAmount: canEditApprovedAmount, billFileRights: billFileRights, ), ), ); } @override State createState() => _ExpenseDetailDialogState(); } class _ExpenseDetailDialogState extends State { late CostProjectItem _selCat; late TextEditingController _descCtrl; late TextEditingController _amountCtrl; CustomerItem? _selCustomer; late TextEditingController _approvedAmountCtrl; late TextEditingController _remarkCtrl; late TextEditingController _bankNameCtrl; late TextEditingController _bankAccountNameCtrl; late TextEditingController _bankAccountCtrl; double _taxRate = 0; ProjectCodeItem? _selProject; DepartmentItem? _selDept; EmployeeItem? _selEmployee; late final AttachmentPickerController _attachmentCtrl; final ScrollController _scrollCtrl = ScrollController(); final _remarkFocus = FocusNode(); final _bankNameFocus = FocusNode(); final _bankAccountNameFocus = FocusNode(); final _bankAccountFocus = FocusNode(); bool _attachAvailable = false; void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; _doEnsureVisible(node, 0, -1); } void _doEnsureVisible(FocusNode node, int attempt, double lastInsets) { if (attempt >= 15) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return; final insets = MediaQuery.of(context).viewInsets.bottom; if (insets != lastInsets) { _doEnsureVisible(node, attempt + 1, insets); return; } final ctx = node.context; if (ctx == null) return; Future.delayed(const Duration(milliseconds: 500), () { if (!mounted || !node.hasFocus || !_scrollCtrl.hasClients) return; Scrollable.ensureVisible( ctx, alignment: 0.5, duration: const Duration(milliseconds: 300), ); }); }); } static const _taxOptions = [0, 6, 9, 13]; static const _taxLabels = ['0%', '6%', '9%', '13%']; AppLocalizations get _l10n => widget.l10n; bool get _isEdit => widget.initialData != null; @override void initState() { super.initState(); final d = widget.initialData; _selCat = CostProjectItem( idx1: d?.category ?? '', name: d?.categoryName ?? '', accNo: d?.acctSubjectId ?? '', accName: d?.acctSubjectName ?? '', ); _descCtrl = TextEditingController(text: d?.purpose ?? ''); _amountCtrl = TextEditingController( text: d != null && d.amount > 0 ? formatAmountNumeric(d.amount) : '', ); _selCustomer = (d != null && d.customerVendorId.isNotEmpty) ? CustomerItem(cusNo: '', name: '') : null; _approvedAmountCtrl = TextEditingController( text: d != null && d.approvedAmount > 0 ? formatAmountNumeric(d.approvedAmount) : '', ); _remarkCtrl = TextEditingController(text: d?.remark ?? ''); _bankNameCtrl = TextEditingController(text: d?.bankName ?? ''); _bankAccountNameCtrl = TextEditingController( text: d?.bankAccountName ?? '', ); _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? ''); _taxRate = d?.taxRate ?? 0; _selEmployee = (d != null && d.sqMan.isNotEmpty) ? EmployeeItem(salNo: d.sqMan, name: d.sqManName) : null; _selProject = (d != null && d.projectId.isNotEmpty) ? ProjectCodeItem(objNo: d.projectId, name: d.projectName) : null; _selDept = (d != null && d.costDeptId.isNotEmpty) ? DepartmentItem(dep: d.costDeptId, name: d.costDeptName) : null; if (d != null && d.attachmentPaths.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { _attachmentCtrl.restoreFromPaths(d.attachmentPaths); }); } _attachmentCtrl = AttachmentPickerController(maxCount: 9) ..addListener(() => setState(() {})); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); _bankNameFocus.addListener(() => _ensureVisible(_bankNameFocus)); _bankAccountNameFocus.addListener( () => _ensureVisible(_bankAccountNameFocus), ); _bankAccountFocus.addListener(() => _ensureVisible(_bankAccountFocus)); _checkAttachHealth(); } Future _checkAttachHealth() async { if (widget.checkAttachHealth == null) return; if (mounted) setState(() => _attachAvailable = false); try { final ok = await widget.checkAttachHealth!(); if (mounted) setState(() => _attachAvailable = ok); } catch (_) { if (mounted) setState(() => _attachAvailable = false); } } @override void dispose() { _descCtrl.dispose(); _amountCtrl.dispose(); _approvedAmountCtrl.dispose(); _remarkCtrl.dispose(); _bankNameCtrl.dispose(); _bankAccountNameCtrl.dispose(); _bankAccountCtrl.dispose(); _attachmentCtrl.dispose(); _scrollCtrl.dispose(); _remarkFocus.dispose(); _bankNameFocus.dispose(); _bankAccountNameFocus.dispose(); _bankAccountFocus.dispose(); super.dispose(); } void _confirm() { final amount = double.tryParse(_amountCtrl.text) ?? 0; if (amount <= 0) { TDToast.showText(_l10n.get('amountPositive'), context: context); return; } Navigator.pop( context, ExpenseDetailInputData( category: _selCat.idx1, categoryName: _selCat.name, acctSubjectId: _selCat.accNo, acctSubjectName: _selCat.accName, purpose: '', amount: amount, taxRate: _taxRate, projectId: _selProject?.objNo ?? '', projectName: _selProject?.name ?? '', costDeptId: _selDept?.dep ?? '', costDeptName: _selDept?.name ?? '', customerVendorId: _selCustomer?.cusNo ?? '', customerVendorName: _selCustomer?.name ?? '', approvedAmount: double.tryParse(_approvedAmountCtrl.text) ?? 0, bankName: _bankNameCtrl.text.trim(), bankAccountName: _bankAccountNameCtrl.text.trim(), bankAccount: _bankAccountCtrl.text.trim(), remark: _remarkCtrl.text.trim(), attachmentPaths: _attachmentCtrl.toPathList(), sqMan: _selEmployee?.salNo ?? '', sqManName: _selEmployee?.name ?? '', aeNo: widget.initialData?.aeNo ?? '', aeDd: widget.initialData?.aeDd ?? '', ), ); } double get _amountExclTax => _taxRate > 0 ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate / 100) : (double.tryParse(_amountCtrl.text) ?? 0); double get _taxAmount => (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax; @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return AnimatedPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), duration: const Duration(milliseconds: 200), child: SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.8, ), child: Container( decoration: BoxDecoration( color: colors.bgPage, borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(colors), Flexible( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), behavior: HitTestBehavior.translucent, child: SingleChildScrollView( controller: _scrollCtrl, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.manual, padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_isEdit && widget.initialData!.aeNo.isNotEmpty) ...[ _buildAeInfoCard(colors), const SizedBox(height: 12), ], _buildCategoryCard(colors), const SizedBox(height: 12), _buildAcctSubjectCard(colors), const SizedBox(height: 12), _buildAmountCard(), const SizedBox(height: 12), _buildTaxRateCard(colors), if ((double.tryParse(_amountCtrl.text) ?? 0) > 0) ...[ const SizedBox(height: 12), _buildCalcInfo(colors), ], const SizedBox(height: 12), _buildApprovedAmountCard(), const SizedBox(height: 12), _buildProjectCard(colors), const SizedBox(height: 12), _buildCostDeptCard(colors), const SizedBox(height: 12), _buildCustomerCard(colors), const SizedBox(height: 12), _buildEmployeeCard(colors), const SizedBox(height: 12), _buildBankInfoCard(colors), const SizedBox(height: 12), _buildRemarkInput(colors), if (widget.showAttachments) ...[ const SizedBox(height: 12), _buildAttachmentCard(colors), ], ], ), ), ), ), Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), decoration: BoxDecoration( color: colors.bgCard, border: Border( top: BorderSide(color: colors.border, width: 0.5), ), ), child: _buildActions(), ), ], ), ), ), ), ); } // ── 标题栏 ── Widget _buildHeader(AppColorsExtension colors) { return Column( mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( margin: const EdgeInsets.only(top: 8, bottom: 4), width: 36, height: 4, decoration: BoxDecoration( color: colors.border, borderRadius: BorderRadius.circular(2), ), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 12, 16), child: Row( children: [ const SizedBox(width: 24), Expanded( child: Center( child: Text( _l10n.get('addExpenseDetail'), style: TextStyle( fontSize: AppFontSizes.title, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), ), ), GestureDetector( onTap: () => Navigator.pop(context), child: Padding( padding: const EdgeInsets.all(4), child: Icon( Icons.close, size: 20, color: colors.textSecondary, ), ), ), ], ), ), ], ); } // ── picker 卡片 ── Widget _pickerCard({ required String label, required bool required, required String currentLabel, required bool hasValue, required VoidCallback onTap, VoidCallback? onClear, }) { final tdTheme = TDTheme.of(context); return GestureDetector( onTap: () { FocusManager.instance.primaryFocus?.unfocus(); onTap(); }, child: Container( padding: const EdgeInsets.only( left: 16, right: 10, top: 12, bottom: 12, ), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), if (required) Padding( padding: const EdgeInsets.only(left: 4), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TDText( currentLabel, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: hasValue ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder, textAlign: TextAlign.end, ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: onClear != null ? GestureDetector( onTap: onClear, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : Icon( Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder, ), ), ], ), ), ], ), ), ); } // ── 费用项目 ── Widget _buildCategoryCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('expenseCategory'), required: true, hasValue: _selCat.idx1.isNotEmpty, currentLabel: _selCat.idx1.isNotEmpty ? '${_selCat.idx1}/${_selCat.name}' : _l10n.get('pleaseSelect'), onTap: () async { final result = await showSearchablePicker( context, title: '${_l10n.get('select')}${_l10n.get('expenseCategory')}', searchHint: _l10n.get('search'), loader: (keyword, page) => widget.api .getCostProjects(keyword: keyword, page: page, size: 20) .then((list) => list.cast()), labelBuilder: (c) => '${c.idx1}/${c.name}', onRefresh: () => widget.api.clearRefCache(), ); if (result != null && mounted) { setState(() => _selCat = result); } }, ); } // ── 输入卡片(对齐 pickerCard 样式) ── Widget _inputCard({ required String label, required bool required, required TextEditingController controller, required String hintText, required AppColorsExtension colors, TextInputType? keyboardType, List? inputFormatters, FocusNode? focusNode, }) { final tdTheme = TDTheme.of(context); final hasValue = controller.text.isNotEmpty; return Container( padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), if (required) Padding( padding: const EdgeInsets.only(left: 4), child: TDText( '*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6), ), ), const SizedBox(width: 12), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [ Flexible( child: TextField( controller: controller, focusNode: focusNode, textAlign: TextAlign.end, keyboardType: keyboardType, inputFormatters: inputFormatters, style: TextStyle(fontSize: 16, color: colors.textPrimary), decoration: InputDecoration( hintText: hintText, hintStyle: TextStyle( fontSize: 16, color: colors.textPlaceholder, ), border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, ), onChanged: (_) => setState(() {}), ), ), const SizedBox(width: 4), SizedBox( width: 18, height: 18, child: hasValue ? GestureDetector( onTap: () { controller.clear(); setState(() {}); }, child: Icon( Icons.close, size: 18, color: tdTheme.textColorPlaceholder, ), ) : null, ), ], ), ), ], ), ); } // ── 含税金额 ── Widget _buildAmountCard() { return _inputCard( label: _l10n.get('amountInclTax'), required: true, controller: _amountCtrl, hintText: '>0', colors: Theme.of(context).extension()!, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), ], ); } // ── 税率 ── Widget _buildTaxRateCard(AppColorsExtension colors) { final currentLabel = '${_taxRate.toStringAsFixed(0)}%'; final labels = _taxLabels.toList(); return _pickerCard( label: _l10n.get('taxRate'), required: false, hasValue: true, currentLabel: currentLabel, onTap: () { FocusManager.instance.primaryFocus?.unfocus(); TDPicker.showMultiPicker( context, title: _l10n.get('taxRate'), backgroundColor: colors.bgCard, data: [labels], onConfirm: (selected) { if (selected.isNotEmpty && selected[0] is int) { final idx = selected[0] as int; if (idx >= 0 && idx < labels.length) { Navigator.of(context).pop(); setState(() => _taxRate = _taxOptions[idx].toDouble()); } } }, ); }, ); } // ── 计算信息 ── Widget _buildCalcInfo(AppColorsExtension colors) { final amount = double.tryParse(_amountCtrl.text) ?? 0; if (amount <= 0) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Expanded( child: Text( '${_l10n.get('amountExcludingTax')}: ${formatAmount(_amountExclTax)}', style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), ), Text( '${_l10n.get('taxAmount')}: ${formatAmount(_taxAmount)}', style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), ], ), ); } // ── 导入单据信息 ── Widget _buildAeInfoCard(AppColorsExtension colors) { final d = widget.initialData!; final tdTheme = TDTheme.of(context); final dateStr = d.aeDd.isNotEmpty ? _formatDate(d.aeDd) : d.aeDd; return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: _cardDecoration(tdTheme), child: Column( children: [ _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo), const SizedBox(height: 8), _readOnlyRow(tdTheme, _l10n.get('applyDate'), dateStr), ], ), ); } String _formatDate(String raw) { final dt = DateTime.tryParse(raw); if (dt == null) return raw; return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; } Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) { return Row( children: [ TDText( label, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const SizedBox(width: 12), Expanded( child: TDText( value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), ], ); } BoxDecoration _cardDecoration(TDThemeData tdTheme) => BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ); // ── 申请人 ── Widget _buildEmployeeCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('applicant'), required: false, hasValue: _selEmployee != null, currentLabel: _selEmployee != null ? '${_selEmployee!.salNo}/${_selEmployee!.name}' : _l10n.get('pleaseSelect'), onTap: () async { final result = await showSearchablePicker( context, title: '${_l10n.get('select')}${_l10n.get('applicant')}', searchHint: _l10n.get('search'), loader: (keyword, page) => widget.api.getEmployees(keyword: keyword, page: page, size: 20), labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}', onRefresh: () => widget.api.clearRefCache(), ); if (result != null && mounted) { setState(() { _selEmployee = result; _bankNameCtrl.text = result.bnkNo; _bankAccountNameCtrl.text = result.accName; _bankAccountCtrl.text = result.bnkId; }); } }, onClear: _selEmployee != null ? () => setState(() { _selEmployee = null; _bankNameCtrl.clear(); _bankAccountNameCtrl.clear(); _bankAccountCtrl.clear(); }) : null, ); } // ── 客户/厂商 ── Widget _buildCustomerCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('customerVendor'), required: false, hasValue: _selCustomer != null, currentLabel: _selCustomer != null ? '${_selCustomer!.cusNo}/${_selCustomer!.name}' : _l10n.get('pleaseSelect'), onTap: () async { final result = await showSearchablePicker( context, title: '${_l10n.get('select')}${_l10n.get('customerVendor')}', searchHint: _l10n.get('search'), loader: (keyword, page) => widget.api.getCustomers(keyword: keyword, page: page, size: 20), labelBuilder: (v) => v.name.isEmpty ? v.cusNo : '${v.cusNo} ${v.name}', onRefresh: () => widget.api.clearRefCache(), ); if (result != null && mounted) { setState(() => _selCustomer = result); } }, onClear: _selCustomer != null ? () => setState(() => _selCustomer = null) : null, ); } // ── 核准金额 ── Widget _buildApprovedAmountCard() { if (!widget.canEditApprovedAmount) { final value = _approvedAmountCtrl.text.isNotEmpty ? formatAmount(double.tryParse(_approvedAmountCtrl.text) ?? 0) : '-'; return _readOnlyCard( label: _l10n.get('approvedAmount'), value: value, colors: Theme.of(context).extension()!, ); } return _inputCard( label: _l10n.get('approvedAmount'), required: false, controller: _approvedAmountCtrl, hintText: '0', colors: Theme.of(context).extension()!, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')), ], ); } // ── 备注 ── Widget _buildRemarkInput(AppColorsExtension colors) { final tdTheme = TDTheme.of(context); return TDTextarea( controller: _remarkCtrl, focusNode: _remarkFocus, label: _l10n.get('remark'), hintText: _l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 500, indicator: true, decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), onChanged: (_) => setState(() {}), ); } // ── 操作按钮 ── Widget _buildAttachmentCard(AppColorsExtension colors) { final tdTheme = TDTheme.of(context); Widget? hint; if (!_attachAvailable) { hint = TDText( _l10n.get('attachServiceUnavailable'), font: tdTheme.fontBodySmall, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPlaceholder, ); } else if (widget.billFileRights != null && !widget.billFileRights!.canManageAttachments) { hint = TDText( _l10n.get('noAttachmentPermission'), font: tdTheme.fontBodySmall, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPlaceholder, ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 4), child: TDText( _l10n.get('attachmentUpload'), font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), ), const SizedBox(height: 4), if (hint != null) ...[ Padding(padding: const EdgeInsets.only(left: 4), child: hint), ] else ...[ Padding( padding: const EdgeInsets.only(left: 4, top: 4), child: TDText( _l10n.get('maxAttachment'), font: tdTheme.fontBodySmall, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPlaceholder, ), ), const SizedBox(height: 4), AttachmentPicker( controller: _attachmentCtrl, maxImageSizeMB: 10, maxFileSizeMB: 20, allowedExtensions: const [ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', ], onFileRejected: (file, reason) { if (context.mounted) TDToast.showText(reason, context: context); }, ), ], ], ); } // ── 会计科目(只读,选择类别后自动带出) ── Widget _buildAcctSubjectCard(AppColorsExtension colors) { final id = _selCat.accNo.isNotEmpty ? _selCat.accNo : widget.initialData?.acctSubjectId ?? ''; final name = _selCat.accName.isNotEmpty ? _selCat.accName : widget.initialData?.acctSubjectName ?? ''; return _readOnlyCard( label: _l10n.get('acctSubject'), value: id.isNotEmpty ? '$id${name.isNotEmpty ? '/$name' : ''}' : '', colors: colors, ); } Widget _readOnlyCard({ required String label, required String value, required AppColorsExtension colors, }) { final tdTheme = TDTheme.of(context); return Container( padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 12), decoration: BoxDecoration( color: tdTheme.bgColorContainer, borderRadius: BorderRadius.circular(tdTheme.radiusDefault), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row( children: [ TDText( label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0), ), const SizedBox(width: 12), Expanded( child: TDText( value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end, ), ), ], ), ); } // ── 关联项目 ── Widget _buildProjectCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('relatedProject'), required: false, hasValue: _selProject != null, currentLabel: _selProject != null ? '${_selProject!.objNo}/${_selProject!.name}' : _l10n.get('pleaseSelect'), onTap: () async { final result = await showSearchablePicker( context, title: '${_l10n.get('select')}${_l10n.get('relatedProject')}', searchHint: _l10n.get('search'), loader: (keyword, page) => widget.api .getProjectCodes(keyword: keyword, page: page, size: 20) .then((list) => list.cast()), labelBuilder: (p) => '${p.objNo}/${p.name}', onRefresh: () => widget.api.clearRefCache(), ); if (result != null && mounted) { setState(() => _selProject = result); } }, onClear: _selProject != null ? () => setState(() => _selProject = null) : null, ); } // ── 费用承担部门 ── Widget _buildCostDeptCard(AppColorsExtension colors) { return _pickerCard( label: _l10n.get('costDept'), required: false, hasValue: _selDept != null, currentLabel: _selDept != null ? '${_selDept!.dep}/${_selDept!.name}' : _l10n.get('pleaseSelect'), onTap: () async { final result = await showSearchablePicker( context, title: '${_l10n.get('select')}${_l10n.get('costDept')}', searchHint: _l10n.get('search'), loader: (keyword, page) => widget.api.getDepartments(keyword: keyword, page: page, size: 20), labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}', onRefresh: () => widget.api.clearRefCache(), ); if (result != null && mounted) { setState(() => _selDept = result); } }, onClear: _selDept != null ? () => setState(() => _selDept = null) : null, ); } // ── 收款银行信息 ── Widget _buildBankInfoCard(AppColorsExtension colors) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _inputCard( label: _l10n.get('bankName'), required: false, controller: _bankNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors, focusNode: _bankNameFocus, ), const SizedBox(height: 12), _inputCard( label: _l10n.get('bankAccountName'), required: false, controller: _bankAccountNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors, focusNode: _bankAccountNameFocus, ), const SizedBox(height: 12), _inputCard( label: _l10n.get('bankAccount'), required: false, controller: _bankAccountCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors, focusNode: _bankAccountFocus, ), ], ); } Widget _buildActions() { return Row( children: [ Expanded( child: TDButton( text: _l10n.get('cancel'), size: TDButtonSize.large, type: TDButtonType.outline, shape: TDButtonShape.rectangle, theme: TDButtonTheme.defaultTheme, onTap: () => Navigator.pop(context), ), ), const SizedBox(width: 12), Expanded( child: TDButton( text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'), size: TDButtonSize.large, type: TDButtonType.fill, shape: TDButtonShape.rectangle, theme: TDButtonTheme.primary, onTap: _confirm, ), ), ], ); } }