import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../core/theme/app_colors.dart'; import '../../core/i18n/app_localizations.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../shell/nav_bar_config.dart'; class ExpenseApplicationApplyPage extends ConsumerStatefulWidget { final String? id; const ExpenseApplicationApplyPage({super.key, this.id}); @override ConsumerState createState() => _ExpenseApplicationApplyPageState(); } class _ExpenseApplicationApplyPageState extends ConsumerState { // ── 基本信息 ── int _urgency = 0; // 0=普通, 1=紧急, 2=特急 static const _urgencyLabels = ['普通', '紧急', '特急']; final Set _expenseTypes = {}; static const _expenseTypeOptions = [ ('travel', '差旅费'), ('entertainment', '业务招待费'), ('procurement', '日常采购'), ('activity', '活动经费'), ('office', '办公费'), ('meeting', '会议费'), ('training', '培训费'), ]; bool _isTaxIncluded = false; final _purposeController = TextEditingController(); // ── 关联管控 ── String? _selectedProjectName; int? _selectedProjectId; String? _selectedSubjectName; int? _selectedSubjectId; final _availableBudget = 50000.00; final _referenceNoController = TextEditingController(); // ── 费用明细 ── final List<_DetailItem> _details = []; int _detailIdCounter = 1; // ── 附件 ── final List _attachments = []; // mock file names // ── 专用字段 ── String _estimatedStartDate = ''; String _estimatedEndDate = ''; String _entertainmentTarget = ''; String _venue = ''; @override void dispose() { _purposeController.dispose(); _referenceNoController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); ref.read(navBarConfigProvider.notifier).update( NavBarConfig( title: l10n.get('expenseApplyRequest'), showBack: true, onBack: () { if (_hasUnsaved()) { _showConfirmDialog('确认退出', '当前内容尚未保存,是否退出?', '继续编辑', '放弃并退出', () => context.pop()); } else { context.pop(); } }, ), ); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) { if (_hasUnsaved()) { _showConfirmDialog('确认退出', '当前内容尚未保存,是否退出?', '继续编辑', '放弃并退出', () => context.pop()); } else { context.pop(); } } }, child: Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfo(l10n), const SizedBox(height: 16), _buildTypeSpecificFields(l10n), const SizedBox(height: 16), _buildControlSection(l10n), const SizedBox(height: 16), _buildDetailsSection(l10n), const SizedBox(height: 16), _buildAttachmentSection(l10n), const SizedBox(height: 80), ], ), ), ), _buildBottomBar(l10n), ], ), ); } // ═══ 1. 基本信息 ═══ Widget _buildBasicInfo(AppLocalizations l10n) { return FormSection( title: l10n.get('basicInfo'), children: [ FormFieldRow(label: l10n.get('applicant'), value: '张三', readOnly: true, showArrow: false), FormFieldRow(label: l10n.get('department'), value: '技术部', readOnly: true, showArrow: false), FormFieldRow(label: l10n.get('date'), value: _today(), readOnly: true, showArrow: false), const SizedBox(height: 12), _label(l10n.get('emergencyLevel')), const SizedBox(height: 6), _buildUrgencyRadio(), const SizedBox(height: 12), _label(l10n.get('expenseType')), const SizedBox(height: 6), Wrap( spacing: 8, runSpacing: 8, children: _expenseTypeOptions.map((opt) { final sel = _expenseTypes.contains(opt.$1); return GestureDetector( onTap: () => setState(() => sel ? _expenseTypes.remove(opt.$1) : _expenseTypes.add(opt.$1)), child: TDTag(opt.$2, size: TDTagSize.medium, theme: sel ? TDTagTheme.primary : TDTagTheme.defaultTheme, isOutline: !sel), ); }).toList(), ), const SizedBox(height: 12), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _label(l10n.get('isTaxIncluded')), TDSwitch(isOn: _isTaxIncluded, onChanged: (v) { setState(() => _isTaxIncluded = v); return true; }), ]), const SizedBox(height: 12), _label(l10n.get('feeReason')), const SizedBox(height: 4), TDTextarea( controller: _purposeController, hintText: l10n.get('enterFeeReason'), maxLength: 200, backgroundColor: AppColors.bgPage, ), const SizedBox(height: 12), FormFieldRow(label: l10n.get('validUntil'), hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) {})), ], ); } Widget _buildUrgencyRadio() { return Row( children: List.generate(3, (i) { final sel = _urgency == i; return Padding( padding: EdgeInsets.only(right: i < 2 ? 24 : 0), child: GestureDetector( onTap: () => setState(() => _urgency = i), child: Row(mainAxisSize: MainAxisSize.min, children: [ Container( width: 18, height: 18, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: sel ? AppColors.primary : AppColors.textPlaceholder, width: 2)), child: sel ? Center(child: Container(width: 8, height: 8, decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.primary))) : null, ), const SizedBox(width: 6), Text(_urgencyLabels[i], style: TextStyle(fontSize: AppFontSizes.body, color: sel ? AppColors.primary : AppColors.textSecondary)), ]), ), ); }), ); } // ═══ 2. 类型专用字段 ═══ Widget _buildTypeSpecificFields(AppLocalizations l10n) { final ws = []; if (_expenseTypes.contains('travel')) ws.add(_buildTravelFields(l10n)); if (_expenseTypes.contains('entertainment')) { ws.add(const SizedBox(height: 16)); ws.add(_buildEntertainmentFields(l10n)); } if (_expenseTypes.contains('meeting')) { ws.add(const SizedBox(height: 16)); ws.add(_buildMeetingFields(l10n)); } return ws.isEmpty ? const SizedBox.shrink() : Column(children: ws); } Widget _buildTravelFields(AppLocalizations l10n) { return FormSection(title: '差旅费专用', children: [ FormFieldRow(label: l10n.get('estimatedStartDate'), value: _estimatedStartDate, hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) => setState(() => _estimatedStartDate = d))), FormFieldRow(label: l10n.get('estimatedEndDate'), value: _estimatedEndDate, hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((d) => setState(() => _estimatedEndDate = d))), const SizedBox(height: 8), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [_label(l10n.get('isOvernight')), TDSwitch(onChanged: (_) => true)]), const SizedBox(height: 8), FormFieldRow(label: l10n.get('transportType'), value: '高铁/动车', onTap: () => _showListPicker('选择交通工具', ['飞机', '高铁/动车', '火车(普速)', '自驾'], (_) {})), ]); } Widget _buildEntertainmentFields(AppLocalizations l10n) { return FormSection(title: '招待费专用', children: [ FormFieldRow(label: '招待对象单位', value: _entertainmentTarget, hint: '请输入', onTap: () => _showTextInput('招待对象单位', (v) => setState(() => _entertainmentTarget = v))), FormFieldRow(label: '招待层级', value: '普通', onTap: () => _showListPicker('选择招待层级', ['普通', '重要', 'VIP'], (_) {})), FormFieldRow(label: '外部人数', value: '3', onTap: () => _showNumberInput('外部人数', (_) {})), FormFieldRow(label: '内部陪同人数', value: '2', onTap: () => _showNumberInput('内部陪同人数', (_) {})), FormFieldRow(label: l10n.get('venue'), value: _venue, hint: '请输入地点', onTap: () => _showTextInput(l10n.get('venue'), (v) => setState(() => _venue = v))), ]); } Widget _buildMeetingFields(AppLocalizations l10n) { return FormSection(title: '会议费专用', children: [ FormFieldRow(label: l10n.get('estimatedStartDate'), hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((_) {})), FormFieldRow(label: l10n.get('estimatedEndDate'), hint: l10n.get('pleaseSelect'), onTap: () => _pickDate((_) {})), FormFieldRow(label: l10n.get('venue'), value: _venue, hint: '请输入会议地点', onTap: () => _showTextInput('会议地点', (_) {})), ]); } // ═══ 3. 关联管控 ═══ static const _mockProjects = [('华东市场拓展', 100), ('ERP系统升级', 101), ('新产品研发', 102), ('华南渠道建设', 103)]; static const _mockSubjects = [('差旅费', 5), ('招待费', 6), ('办公费', 7), ('培训费', 8)]; Widget _buildControlSection(AppLocalizations l10n) { return FormSection(title: l10n.get('relatedControl'), children: [ FormFieldRow(label: l10n.get('relatedProject'), value: _selectedProjectName, hint: l10n.get('selectProject'), onTap: () { _showListPicker('选择关联项目', _mockProjects.map((p) => p.$1).toList(), (v) { final p = _mockProjects.firstWhere((x) => x.$1 == v); setState(() { _selectedProjectId = p.$2; _selectedProjectName = p.$1; _selectedSubjectName = null; _selectedSubjectId = null; }); }); }), FormFieldRow( label: l10n.get('budgetSubject'), value: _selectedSubjectName, hint: l10n.get('selectSubject'), onTap: _selectedProjectId != null ? () { _showListPicker('选择预算科目', _mockSubjects.map((s) => s.$1).toList(), (v) { final s = _mockSubjects.firstWhere((x) => x.$1 == v); setState(() { _selectedSubjectId = s.$2; _selectedSubjectName = s.$1; }); }); } : null, ), _buildBudgetRow(l10n), const SizedBox(height: 8), FormFieldRow(label: '关联合同号', value: _referenceNoController.text, hint: '选填', onTap: () => _showTextInput('关联合同号', (v) => setState(() { _referenceNoController.text = v; _referenceNoController.selection = TextSelection.fromPosition(TextPosition(offset: v.length)); }))), ]); } Widget _buildBudgetRow(AppLocalizations l10n) { final over = _totalAmount() > _availableBudget; return SizedBox( height: 44, child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('availableBudget'), style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textSecondary)), Text('¥${_availableBudget.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: over ? AppColors.danger : AppColors.amountPrimary)), ]), ); } // ═══ 4. 费用明细 ═══ Widget _buildDetailsSection(AppLocalizations l10n) { return FormSection(title: l10n.get('expenseDetails'), showAction: true, actionText: l10n.get('add'), onActionTap: _showDetailDialog, children: [ if (_details.isEmpty) Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l10n.get('noDetailHint'), style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textPlaceholder))) else ..._details.asMap().entries.map((e) { final d = e.value; return Container( padding: const EdgeInsets.symmetric(vertical: 6), decoration: BoxDecoration(border: e.key < _details.length - 1 ? const Border(bottom: BorderSide(color: AppColors.border)) : null), child: Row(children: [ Expanded(flex: 3, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(d.categoryName, style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textPrimary)), if (d.remark.isNotEmpty) Text(d.remark, style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.textPlaceholder)), ])), Text('${d.quantity}×¥${d.unitPrice.toStringAsFixed(2)}', style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.textSecondary)), const SizedBox(width: 8), Text('¥${d.amount.toStringAsFixed(2)}', style: const TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: AppColors.amountPrimary)), GestureDetector(onTap: () => setState(() => _details.removeAt(e.key)), child: const Icon(Icons.close, size: 16, color: AppColors.textPlaceholder)), ]), ); }), Container(height: 1, color: AppColors.border), Container(height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('total'), style: const TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), Text('¥${_totalAmount().toStringAsFixed(2)}', style: const TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: AppColors.amountPrimary)), ])), if (_totalAmount() > _availableBudget) Padding(padding: const EdgeInsets.only(top: 8), child: Row(children: [ const Icon(Icons.warning_amber, size: 14, color: AppColors.danger), const SizedBox(width: 6), Expanded(child: Text('您的申请金额已超支,提交后将自动触发高管特批流程', style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.danger))), ])), ]); } double _totalAmount() => _details.fold(0, (s, d) => s + d.amount); static const _detailCategories = [('transport', '交通费'), ('hotel', '住宿费'), ('office_supplies', '办公用品'), ('meals', '餐饮费'), ('materials', '材料费'), ('service', '服务费'), ('other', '其他')]; static const _units = ['张', '间', '人', '天', '套', '个']; void _showDetailDialog() { String cat = 'transport'; String unit = '张'; final qtyCtrl = TextEditingController(text: '1'); final priceCtrl = TextEditingController(); final remarkCtrl = TextEditingController(); showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDlg) => TDAlertDialog( title: '添加费用明细', contentWidget: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _label('费用类别'), const SizedBox(height: 4), GestureDetector( onTap: () { Navigator.pop(ctx); _showListPicker('选择费用类别', _detailCategories.map((c) => c.$2).toList(), (v) { cat = _detailCategories.firstWhere((c) => c.$2 == v).$1; _showDetailDialog(); }); }, child: Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration(color: AppColors.bgPage, borderRadius: BorderRadius.circular(4)), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(_detailCategories.firstWhere((c) => c.$1 == cat).$2, style: const TextStyle(fontSize: AppFontSizes.body)), const Icon(Icons.arrow_drop_down, color: AppColors.textPlaceholder), ]), ), ), const SizedBox(height: 12), Row(children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _label('数量'), const SizedBox(height: 4), TDInput(controller: qtyCtrl, hintText: '>0'), ])), const SizedBox(width: 12), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _label('单位'), const SizedBox(height: 4), GestureDetector( onTap: () { Navigator.pop(ctx); _showListPicker('选择单位', _units, (v) { unit = v; _showDetailDialog(); }); }, child: Container( height: 44, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration(color: AppColors.bgPage, borderRadius: BorderRadius.circular(4)), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(unit, style: const TextStyle(fontSize: AppFontSizes.body)), const Icon(Icons.arrow_drop_down, color: AppColors.textPlaceholder), ]), ), ), ])), ]), const SizedBox(height: 12), _label('单价'), const SizedBox(height: 4), TDInput(controller: priceCtrl, hintText: '>0'), const SizedBox(height: 12), _label('明细说明'), const SizedBox(height: 4), TDInput(controller: remarkCtrl, hintText: '选填'), ]), leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)), rightBtn: TDDialogButtonOptions(title: '确定', titleColor: AppColors.primary, action: () { final q = int.tryParse(qtyCtrl.text) ?? 0; final p = double.tryParse(priceCtrl.text) ?? 0; if (q <= 0 || p <= 0) { TDToast.showText('数量和单价必须大于0', context: context); return; } setState(() => _details.add(_DetailItem(id: _detailIdCounter++, category: cat, categoryName: _detailCategories.firstWhere((c) => c.$1 == cat).$2, quantity: q, unit: unit, unitPrice: p, amount: q * p, remark: remarkCtrl.text))); Navigator.pop(ctx); }), ), ), ); } // ═══ 5. 附件上传 ═══ Widget _buildAttachmentSection(AppLocalizations l10n) { return FormSection(title: l10n.get('attachmentUpload'), children: [ Text(l10n.get('maxAttachment'), style: const TextStyle(fontSize: AppFontSizes.caption, color: AppColors.textPlaceholder)), const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: [ ..._attachments.asMap().entries.map((e) => Stack( clipBehavior: Clip.none, children: [ Container(width: 80, height: 80, decoration: BoxDecoration(color: AppColors.primaryLight, borderRadius: BorderRadius.circular(4)), child: const Center(child: Icon(Icons.image, color: AppColors.primary, size: 32)), ), Positioned(right: -4, top: -4, child: GestureDetector( onTap: () => setState(() => _attachments.removeAt(e.key)), child: Container(width: 20, height: 20, decoration: const BoxDecoration(color: AppColors.danger, shape: BoxShape.circle), child: const Icon(Icons.close, size: 12, color: Colors.white), ), )), ], )), if (_attachments.length < 9) GestureDetector( onTap: () { // Mock: add attachment setState(() => _attachments.add('附件_${DateTime.now().millisecondsSinceEpoch}.jpg')); TDToast.showText('已添加附件(mock)', context: context); }, child: Container(width: 80, height: 80, decoration: BoxDecoration(color: AppColors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: AppColors.border)), child: const Center(child: Icon(Icons.add, size: 24, color: AppColors.textPlaceholder)), ), ), ]), ]); } // ═══ 6. 底部操作栏 ═══ Widget _buildBottomBar(AppLocalizations l10n) { final isDraft = widget.id != null; return ActionBar( leftLabel: isDraft ? l10n.get('reset') : null, centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submitApproval'), showLeft: isDraft, onLeftTap: isDraft ? () => _showConfirmDialog('确认重置', '将清空所有已填内容,此操作不可撤销', '取消', '确认重置', _resetAll) : null, onCenterTap: () { TDToast.showSuccess('已保存为草稿', context: context); context.pop(); }, onRightTap: () { final err = _validate(); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } TDToast.showSuccess('已提交,等待审批', context: context); context.pop(); }, ); } List _validate() { final e = []; if (_expenseTypes.isEmpty) e.add('请至少选择一项费用类型'); if (_purposeController.text.trim().isEmpty) e.add('请填写费用事由'); if (_selectedProjectId == null) e.add('请选择关联项目'); if (_selectedSubjectId == null) e.add('请选择预算科目'); if (_details.isEmpty) e.add('请至少添加一行费用明细'); if (_expenseTypes.contains('travel')) { if (_estimatedStartDate.isEmpty) e.add('请选择预计开始日期'); if (_estimatedEndDate.isEmpty) e.add('请选择预计结束日期'); } return e; } void _resetAll() => setState(() { _purposeController.clear(); _expenseTypes.clear(); _urgency = 0; _isTaxIncluded = false; _selectedProjectId = null; _selectedProjectName = null; _selectedSubjectId = null; _selectedSubjectName = null; _referenceNoController.clear(); _details.clear(); _attachments.clear(); _estimatedStartDate = ''; _estimatedEndDate = ''; _entertainmentTarget = ''; _venue = ''; }); bool _hasUnsaved() => _purposeController.text.isNotEmpty || _expenseTypes.isNotEmpty || _details.isNotEmpty || _attachments.isNotEmpty || _selectedProjectId != null; // ═══ 通用弹窗方法 ═══ void _showConfirmDialog(String title, String content, String leftText, String rightText, VoidCallback onConfirm) { showDialog( context: context, builder: (ctx) => TDAlertDialog( title: title, content: content, leftBtn: TDDialogButtonOptions(title: leftText, titleColor: AppColors.primary, action: () => Navigator.pop(ctx)), rightBtn: TDDialogButtonOptions(title: rightText, titleColor: AppColors.danger, action: () { Navigator.pop(ctx); onConfirm(); }), ), ); } void _showListPicker(String title, List items, Function(String) onPick) { showDialog( context: context, builder: (ctx) => TDAlertDialog( title: title, contentWidget: SizedBox(width: double.maxFinite, child: ListView(shrinkWrap: true, children: items.map((item) => ListTile(title: Text(item), onTap: () { onPick(item); Navigator.pop(ctx); })).toList())), leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)), ), ); } void _showTextInput(String title, Function(String) onConfirm) { final c = TextEditingController(); showDialog( context: context, builder: (ctx) => TDAlertDialog( title: title, contentWidget: TDInput(controller: c, hintText: '请输入'), leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)), rightBtn: TDDialogButtonOptions(title: '确定', titleColor: AppColors.primary, action: () { onConfirm(c.text); Navigator.pop(ctx); }), ), ); } void _showNumberInput(String title, Function(int) onConfirm) { final c = TextEditingController(); showDialog( context: context, builder: (ctx) => TDAlertDialog( title: title, contentWidget: TextField(controller: c, keyboardType: TextInputType.number, decoration: const InputDecoration(hintText: '请输入数字')), leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.pop(ctx)), rightBtn: TDDialogButtonOptions(title: '确定', titleColor: AppColors.primary, action: () { onConfirm(int.tryParse(c.text) ?? 0); Navigator.pop(ctx); }), ), ); } void _pickDate(Function(String) onPick) { showDatePicker(context: context, initialDate: DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2028), locale: const Locale('zh')) .then((d) { if (d != null) onPick('${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'); }); } Widget _label(String t) => Text(t, style: const TextStyle(fontSize: AppFontSizes.body, color: AppColors.textSecondary)); String _today() { final n = DateTime.now(); return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; } } class _DetailItem { final int id; final String category; final String categoryName; final int quantity; final String unit; final double unitPrice; final double amount; final String remark; const _DetailItem({required this.id, required this.category, required this.categoryName, required this.quantity, required this.unit, required this.unitPrice, required this.amount, required this.remark}); }