import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:go_router/go_router.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../core/utils/responsive.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import 'expense_apply_controller.dart'; import '../../core/i18n/app_localizations.dart'; import 'expense_model.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/data/mock_api_data.dart'; import 'widgets/expense_detail_edit_dialog.dart'; class ExpenseApplyPage extends ConsumerStatefulWidget { final String? editId; const ExpenseApplyPage({super.key, this.editId}); @override ConsumerState createState() => _ExpenseApplyPageState(); } class _ExpenseApplyPageState extends ConsumerState { final _remarkController = TextEditingController(); final _purposeController = TextEditingController(); final _voucherNoController = TextEditingController(); final _bankNameController = TextEditingController(text: '中国银行'); final _accountNameController = TextEditingController(text: '张三'); // 关联管控 String? _selectedProjectName; String? _selectedSubjectName; double _availableBudget = 0; @override void dispose() { _remarkController.dispose(); _purposeController.dispose(); _voucherNoController.dispose(); _bankNameController.dispose(); _accountNameController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final controller = ref.watch(expenseApplyProvider(widget.editId).notifier); final state = ref.watch(expenseApplyProvider(widget.editId)); final r = ResponsiveHelper.of(context); final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: widget.editId != null ? l10n.get('editExpense') : l10n.get('expenseApply'), showBack: true, onBack: () => context.pop(), ), ); final colors = Theme.of(context).extension()!; final bottomInset = MediaQuery.of(context).padding.bottom; return Column( children: [ Expanded( child: Align( alignment: Alignment.topCenter, child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.formMaxWidth), child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 1. 导入事前申请链接 _buildImportLink(), const SizedBox(height: 16), // 2. 基本信息区 _buildBasicInfoSection(controller, state), const SizedBox(height: 16), // 3. 关联管控区 _buildControlSection(controller, state), const SizedBox(height: 16), // 4. 收款账户区 _buildAccountSection(controller, state), const SizedBox(height: 16), // 5. 报销明细区 _buildDetailSection(controller, state), const SizedBox(height: 16), // 6. 附件上传区 _buildInvoiceSection(controller, state), ], ), ), ), ), ), // 7. 底部操作栏 + iOS 横条指示器背景 ColoredBox( color: colors.bgCard, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildBottomButtons(controller, state), if (bottomInset > 0) SizedBox(height: bottomInset), ], ), ), ], ); } // ═══ 1. 导入事前申请链接 ═══ Widget _buildImportLink() { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return GestureDetector( onTap: () { TDToast.showText(l10n.get('expenseApplyImport'), context: context); }, child: Container( height: 44, decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.download, size: 14, color: colors.primary), const SizedBox(width: 8), Text( l10n.get('importApprovedPreApp'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.primary, ), ), ], ), ), ); } // ═══ 2. 基本信息区 ═══ // 报销日期(只读)、报销单号(只读)、币别(TDPicker)、报销人员(只读)、报销部门(只读)、 // 支付方式(TDPicker)、凭证号码、备注 Widget _buildBasicInfoSection( ExpenseApplyController controller, ExpenseApplyState state, ) { final l10n = AppLocalizations.of(context); final expense = state.expense; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ // 报销日期(只读) FormFieldRow( label: l10n.get('expenseDate'), value: expense.applicationDate != null ? '${expense.applicationDate!.year}-${expense.applicationDate!.month.toString().padLeft(2, '0')}-${expense.applicationDate!.day.toString().padLeft(2, '0')}' : _today(), readOnly: true, showArrow: false, ), const SizedBox(height: 16), // 报销单号(只读) FormFieldRow( label: l10n.get('reportNo'), value: expense.reportNo.isNotEmpty ? expense.reportNo : null, hint: l10n.get('autoGenerated'), readOnly: true, showArrow: false, ), const SizedBox(height: 16), // 币别(TDPicker) FormFieldRow( label: l10n.get('currency'), value: expense.currencyCode.isNotEmpty ? expense.currencyCode : 'CNY', hint: l10n.get('selectCurrency'), onTap: () => _showCurrencyPicker(controller, expense.currencyCode), ), const SizedBox(height: 16), // 报销人员(只读) FormFieldRow( label: l10n.get('applicant'), value: expense.applicantName.isNotEmpty ? expense.applicantName : '张三', readOnly: true, showArrow: false, ), const SizedBox(height: 16), // 报销部门(只读) FormFieldRow( label: l10n.get('department'), value: expense.deptName.isNotEmpty ? expense.deptName : '技术部', readOnly: true, showArrow: false, ), const SizedBox(height: 16), // 支付方式(TDPicker) FormFieldRow( label: l10n.get('paymentMethod'), value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : null, hint: l10n.get('selectPaymentMethod'), onTap: () => _showPaymentMethodPicker(controller), ), const SizedBox(height: 16), // 凭证号码 FormFieldRow( label: l10n.get('voucherNo'), value: _voucherNoController.text.isNotEmpty ? _voucherNoController.text : null, hint: l10n.get('enterVoucherNo'), onTap: () => _showTextInput( l10n.get('voucherNo'), l10n.get('enterVoucherNo'), (v) => setState(() { _voucherNoController.text = v; _voucherNoController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }), initialText: _voucherNoController.text, ), ), const SizedBox(height: 16), // 备注 FormFieldRow( label: l10n.get('remark'), value: _remarkController.text.isNotEmpty ? _remarkController.text : null, hint: l10n.get('enterRemark'), onTap: () => _showTextInput( l10n.get('remark'), l10n.get('enterRemark'), (v) => setState(() { _remarkController.text = v; _remarkController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }), initialText: _remarkController.text, ), ), ], ); } // ═══ 3. 关联管控区 ═══ // 报销事由、关联项目&预算科目(级联)、成本中心、可用预算、报销金额合计(只读)、核准金额合计(只读) Widget _buildControlSection( ExpenseApplyController controller, ExpenseApplyState state, ) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); final expense = state.expense; final cascadeLabel = _selectedProjectName != null && _selectedSubjectName != null ? '$_selectedProjectName / $_selectedSubjectName' : _selectedProjectName; return FormSection( title: l10n.get('relatedControl'), leadingIcon: Icons.link_outlined, children: [ // 报销事由 FormFieldRow( label: l10n.get('expenseReason'), value: _purposeController.text.isNotEmpty ? _purposeController.text : null, hint: l10n.get('enterExpenseReason'), onTap: () => _showTextInput( l10n.get('expenseReason'), l10n.get('enterExpenseReason'), (v) { setState(() { _purposeController.text = v; _purposeController.selection = TextSelection.fromPosition( TextPosition(offset: v.length), ); }); controller.updatePurpose(v); }, initialText: _purposeController.text, ), ), const SizedBox(height: 16), // 关联项目 & 预算科目(级联选择器,参考 ExpenseApplicationApplyPage) FormFieldRow( label: l10n.get('relatedProject'), value: cascadeLabel, hint: l10n.get('selectProjectAndSubject'), required: true, onTap: () { FocusScope.of(context).unfocus(); TDCascader.showMultiCascader( context, title: l10n.get('selectProjectAndSubject'), data: _buildCascadeData(), subTitles: [ l10n.get('project'), l10n.get('budgetSubject'), ], onClose: () => Navigator.of(context).pop(), onChange: (selected) { if (selected.length >= 2) { final pId = int.tryParse(selected[0].value ?? ''); final sId = int.tryParse(selected[1].value ?? ''); if (pId != null && sId != null) { setState(() { _selectedProjectName = selected[0].label; _selectedSubjectName = selected[1].label; _availableBudget = getMockBudget(pId, sId); }); } } }, ); }, ), const SizedBox(height: 16), // 可用预算 _buildBudgetRow(l10n, colors), const SizedBox(height: 16), // 成本中心 FormFieldRow( label: l10n.get('costCenter'), value: expense.costCenterId.isNotEmpty ? expense.costCenterId : null, hint: l10n.get('selectCostCenter'), onTap: () { TDToast.showText(l10n.get('costCenterSelection'), context: context); }, ), ], ); } List> _buildCascadeData() { return mockProjects .map( (p) => { 'label': p.name, 'value': p.id.toString(), 'children': mockBudgetSubjects .map( (s) => { 'label': s.name, 'value': s.id.toString(), }, ) .toList(), }, ) .toList(); } Widget _buildBudgetRow(AppLocalizations l10n, AppColorsExtension colors) { final over = _availableBudget <= 0; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('availableBudget'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), Text( '¥${_availableBudget.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: over ? colors.danger : colors.amountPrimary, ), ), ], ); } // ═══ 4. 收款账户区 ═══ // 开户行(bankName)、户名(accountName)、银行账号(bankAccount) Widget _buildAccountSection( ExpenseApplyController controller, ExpenseApplyState state, ) { final l10n = AppLocalizations.of(context); return FormSection( title: l10n.get('receiptAccount'), leadingIcon: Icons.account_balance_outlined, children: [ FormFieldRow( label: l10n.get('bankName'), value: _bankNameController.text.isNotEmpty ? _bankNameController.text : null, hint: l10n.get('selectBank'), onTap: () { TDToast.showText(l10n.get('bankSelection'), context: context); }, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('accountName'), value: _accountNameController.text, readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('bankAccount'), value: state.expense.bankAccount.isNotEmpty ? state.expense.bankAccount : null, hint: l10n.get('enterBankAccount'), onTap: () { TDToast.showText(l10n.get('bankAccountInput'), context: context); }, ), ], ); } // ═══ 5. 报销明细区 ═══ Widget _buildDetailSection( ExpenseApplyController controller, ExpenseApplyState state, ) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); final totalApproved = state.expense.details.fold( 0, (sum, d) => sum + d.approvedAmount, ); return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, showAction: true, actionText: l10n.get('add'), onActionTap: () => _showAddDetailDialog(controller), children: [ if (state.expense.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailHint'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder, ), ), ) else ...state.expense.details.asMap().entries.map((entry) { final d = entry.value; return Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Stack( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 第一行:费用项目 + 金额含税 Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( d.expenseDesc.isNotEmpty ? d.expenseDesc : l10n.get('expenseName'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPrimary, ), ), ), const SizedBox(width: 8), Text( '¥${d.totalAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary, ), ), ], ), const SizedBox(height: 8), // 第二行(小字):金额不含税 / 核准金额 / 税金 / 税率 Text( '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)} ' '${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)} ' '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)} ' '${l10n.get('taxRate')}: ${(d.taxRate * 100).toStringAsFixed(0)}%', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), const SizedBox(height: 6), // 第三行(小字):客户/厂商 / 已充金额 Text( '${l10n.get('customerVendor')}: ${d.customerVendorName.isNotEmpty ? d.customerVendorName : '--'} ' '${l10n.get('offsetAmount')}: ¥${d.offsetAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), const SizedBox(height: 6), // 第四行(小字):费用科目 / 项目代号 / 科目代号 / 项目类别 Text( '${l10n.get('expenseCategory')}: ${d.expenseType.isNotEmpty ? d.expenseType : '--'} ' '${l10n.get('projectCode')}: ${d.projectCode.isNotEmpty ? d.projectCode : '--'} ' '${l10n.get('subjectCode')}: ${d.subjectCode.isNotEmpty ? d.subjectCode : '--'} ' '${l10n.get('projectCategory')}: ${d.projectCategory.isNotEmpty ? d.projectCategory : '--'}', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), // 第五行:摘要 if (d.remark.isNotEmpty) ...[ const SizedBox(height: 6), Text( d.remark, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ], ], ), // 右上角圆形删除按钮 Positioned( right: 0, top: 0, child: GestureDetector( onTap: () { controller.removeDetail(entry.key); controller.recalculateAmount(); }, child: Container( width: 24, height: 24, decoration: BoxDecoration( color: colors.primaryLight, shape: BoxShape.circle, ), child: Icon( Icons.close, size: 14, color: colors.primary700, ), ), ), ), ], ), ); }), // 报销金额合计 & 报销核准金额合计(始终显示) const SizedBox(height: 8), Container( height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('totalExpense'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '¥${state.expense.totalAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), Container( height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('approvedTotal'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '¥${totalApproved.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), ], ); } // ═══ 6. 附件上传区 ═══ Widget _buildInvoiceSection( ExpenseApplyController controller, ExpenseApplyState state, ) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return FormSection( title: l10n.get('invoiceUpload'), leadingIcon: Icons.attach_file_outlined, children: [ Text( l10n.get('maxInvoices'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ GestureDetector( onTap: () { TDToast.showText( l10n.get('expenseApplyImport'), context: context, ); }, child: Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border, width: 1), ), child: Center( child: Icon( Icons.add, size: 24, color: colors.textPlaceholder, ), ), ), ), ], ), ], ); } // ═══ 7. 底部操作栏 ═══ Widget _buildBottomButtons( ExpenseApplyController controller, ExpenseApplyState state, ) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); return Container( height: 72, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration(color: colors.bgCard), child: Row( children: [ // 存草稿 Expanded( child: SizedBox( height: 40, child: Material( color: colors.bgCard, borderRadius: BorderRadius.circular(22), child: InkWell( onTap: state.isSubmitting ? null : () async { final ok = await controller.saveDraft(); if (!mounted) return; if (ok) { TDToast.showText( l10n.get('draftSavedToast'), context: context, ); } }, borderRadius: BorderRadius.circular(22), child: Center( child: Text( l10n.get('saveDraftShort'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textSecondary, ), ), ), ), ), ), ), const SizedBox(width: 12), // 重置 Expanded( child: SizedBox( height: 40, child: Material( color: colors.bgCard, borderRadius: BorderRadius.circular(22), child: InkWell( onTap: () { setState(() { _purposeController.clear(); _remarkController.clear(); _voucherNoController.clear(); }); }, borderRadius: BorderRadius.circular(22), child: Center( child: Text( l10n.get('reset'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textSecondary, ), ), ), ), ), ), ), const SizedBox(width: 12), // 保存 Expanded( child: SizedBox( height: 40, child: Material( color: colors.primary, borderRadius: BorderRadius.circular(22), child: InkWell( onTap: state.isSubmitting ? null : () async { await controller.saveDraft(); if (!mounted) return; context.pop(); }, borderRadius: BorderRadius.circular(22), child: Center( child: Text( l10n.get('save'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.bgCard, ), ), ), ), ), ), ), ], ), ); } // ═══════════════════════════════════════════ // 弹窗 / 选择器方法 // ═══════════════════════════════════════════ /// 添加报销明细对话框(底部滑出,参照 ExpenseDetailDialog 样式) Future _showAddDetailDialog(ExpenseApplyController controller) async { final l10n = AppLocalizations.of(context); final result = await ExpenseDetailEditDialog.show( context, categories: mockCostCategories, l10n: l10n, ); if (result != null && mounted) { final amountExclTax = result.taxRate > 0 ? result.amount / (1 + result.taxRate) : result.amount; final taxAmount = result.amount - amountExclTax; controller.addDetail( ExpenseDetailModel( id: DateTime.now().millisecondsSinceEpoch.toString(), expenseId: '', expenseDate: DateTime.now(), expenseType: result.category, expenseDesc: result.expenseDesc, amount: amountExclTax, taxRate: result.taxRate, taxAmount: taxAmount, totalAmount: result.amount, customerVendorName: result.customerVendorName, offsetAmount: result.offsetAmount, remark: result.remark, baseAmount: result.amount, projectCode: _selectedProjectName ?? '', subjectCode: _selectedSubjectName ?? '', projectCategory: '', ), ); controller.recalculateAmount(); } } /// 币别选择器 void _showCurrencyPicker( ExpenseApplyController controller, String currentCurrency, ) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; const currencies = ['CNY', 'USD', 'EUR', 'JPY', 'HKD', 'GBP']; TDPicker.showMultiPicker( context, title: l10n.get('selectCurrency'), backgroundColor: colors.bgCard, data: [currencies], onConfirm: (selected) { if (selected.isNotEmpty && selected[0] is int) { final idx = selected[0] as int; if (idx >= 0 && idx < currencies.length) { Navigator.of(context).pop(); controller.updateCurrencyCode(currencies[idx]); } } }, ); } /// 支付方式选择器 void _showPaymentMethodPicker(ExpenseApplyController controller) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; const methods = ['bankTransfer', 'cash', 'check', 'alipay', 'wechat']; TDPicker.showMultiPicker( context, title: l10n.get('selectPaymentMethod'), backgroundColor: colors.bgCard, data: [methods], onConfirm: (selected) { if (selected.isNotEmpty && selected[0] is int) { final idx = selected[0] as int; if (idx >= 0 && idx < methods.length) { Navigator.of(context).pop(); // The controller can be extended with updatePaymentMethod if needed TDToast.showText(methods[idx], context: context); } } }, ); } /// 通用文本输入对话框 void _showTextInput( String title, String hint, void Function(String) onConfirm, { String initialText = '', }) { final l10n = AppLocalizations.of(context); final c = TextEditingController(text: initialText); showGeneralDialog( context: context, pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog( textEditingController: c, title: title, hintText: hint, leftBtn: TDDialogButtonOptions( title: l10n.get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: l10n.get('confirm'), action: () { onConfirm(c.text); Navigator.pop(ctx); }, ), ), ); } /// 获取今天的日期字符串 String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } }