import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/navigation/host_app_channel.dart'; import 'package:dio/dio.dart'; import '../../core/network/api_exception.dart'; import '../../shared/widgets/action_bar.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/app_skeletons.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/constants/enums.dart'; import 'expense_apply_api.dart'; import 'widgets/expense_apply_detail_dialog.dart'; import '../../shared/widgets/searchable_picker_sheet.dart'; class ExpenseApplyEditPage extends ConsumerStatefulWidget { final String billNo; const ExpenseApplyEditPage({super.key, required this.billNo}); @override ConsumerState createState() => _ExpenseApplyEditPageState(); } class _ExpenseApplyEditPageState extends ConsumerState { // ── 原单数据 ── String _billNo = ''; String _applyDate = ''; // ── 基本信息 ── String _urgency = Urgency.normal.value; final _purposeController = TextEditingController(); final _purposeFocus = FocusNode(); final _remarkController = TextEditingController(); final _remarkFocus = FocusNode(); final _scrollCtrl = ScrollController(); // ── 费用明细 ── final List<_DetailItem> _details = []; int _detailIdCounter = 1; // ── 参考数据(从 API 加载) ── bool _firstBuild = true; bool _refDataLoading = true; bool _loadingBill = true; String? _loadingError; bool _addingDetail = false; dynamic _acctTree; // ── 申请部门 ── String _selectedDeptId = ''; String _selectedDeptName = ''; @override void initState() { super.initState(); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, ), ); _purposeFocus.addListener(() => _ensureVisible(_purposeFocus)); _remarkFocus.addListener(() => _ensureVisible(_remarkFocus)); _acctTree = null; _refDataLoading = true; _loadingBill = true; _loadRefData(); _loadBillData(); WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady()); } void _checkDataReady() { if (!_refDataLoading && !_loadingBill && mounted) { setState(() => _firstBuild = false); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); } else if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) => _checkDataReady()); } } Future? _refDataFuture; Future _loadRefData({bool showLoading = false}) async { if (_refDataFuture != null) return _refDataFuture!; final completer = Completer(); _refDataFuture = completer.future; if (showLoading) { LoadingDialog.show( context, text: AppLocalizations.of(context).get('dataLoading'), ); } try { final api = ref.read(expenseApplyApiProvider); final results = await Future.wait([ api.getAcctSubjects(), ]); if (!mounted) return; setState(() { _acctTree = _convertAcctTree(results[0]); _refDataLoading = false; }); completer.complete(); } catch (_) { if (!mounted) { completer.complete(); return; } setState(() => _refDataLoading = false); completer.complete(); } finally { if (showLoading && mounted) LoadingDialog.hide(context); _refDataFuture = null; } } Future _loadBillData() async { try { final api = ref.read(expenseApplyApiProvider); final detail = await api.fetchDetail(widget.billNo); if (!mounted) return; final n = detail.createTime; final applyDate = '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}'; setState(() { _billNo = detail.expenseApplyNo; _applyDate = applyDate; _selectedDeptId = detail.deptId; _selectedDeptName = detail.deptName; // 紧急程度映射(兼容代码值 '1'/'2'/'3' 和文字值) final urg = detail.urgency; if (urg == '3' || urg == 'critical') { _urgency = Urgency.critical.value; } else if (urg == '2' || urg == 'urgent') { _urgency = Urgency.urgent.value; } else { _urgency = Urgency.normal.value; } _purposeController.text = detail.purpose; _remarkController.text = detail.remark; // 费用明细回填 _details.clear(); _detailIdCounter = 1; for (final d in detail.details) { String startDateStr = ''; String endDateStr = ''; if (d.estimatedStartDate != null) { final s = d.estimatedStartDate!; startDateStr = '${s.year}-${s.month.toString().padLeft(2, '0')}-${s.day.toString().padLeft(2, '0')}'; } if (d.estimatedEndDate != null) { final e = d.estimatedEndDate!; endDateStr = '${e.year}-${e.month.toString().padLeft(2, '0')}-${e.day.toString().padLeft(2, '0')}'; } _details.add(_DetailItem( id: _detailIdCounter++, category: d.expenseCategory, categoryName: d.categoryName, acctSubjectId: d.acctSubjectId, acctSubjectName: d.acctSubjectName, purpose: d.purpose, projectId: d.projectId, projectName: d.projectName, costDeptId: d.costDeptId, costDeptName: d.costDeptName, startDate: startDateStr, endDate: endDateStr, estimatedAmount: d.estimatedAmount, remark: d.remark, preItm: d.preItm, sqMan: d.sqMan, sqManName: d.sqName, )); } _loadingBill = false; }); } catch (e) { if (!mounted) return; setState(() { _loadingBill = false; _loadingError = e.toString(); }); } } void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (node.hasFocus && _scrollCtrl.hasClients) { final ctx = node.context; if (ctx != null) { Scrollable.ensureVisible( ctx, alignment: 0.3, duration: const Duration(milliseconds: 300), ); } } }); } @override void dispose() { _purposeController.dispose(); _purposeFocus.dispose(); _remarkController.dispose(); _remarkFocus.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; if (_loadingError != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: colors.danger), const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( _loadingError!, textAlign: TextAlign.center, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textSecondary, ), ), ), const SizedBox(height: 16), TDButton( text: l10n.get('retry'), size: TDButtonSize.medium, onTap: () { setState(() { _loadingError = null; _loadingBill = true; }); _loadBillData(); }, ), ], ), ); } if (_firstBuild) { return const SkeletonFormPage(); } Future.microtask( () => ref.read(pageBackProvider.notifier).state = () => _doPop(), ); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; _doPop(); }, child: Column( children: [ Expanded( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( controller: _scrollCtrl, padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfo(l10n), const SizedBox(height: 16), _buildDetailsSection(l10n), const SizedBox(height: 24), _buildPageFooter(), ], ), ), ), ), _buildBottomBar(l10n), ], ), ); } // ═══ 1. 基本信息 ═══ Widget _buildBasicInfo(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow( label: l10n.get('expenseApplyNo'), value: _billNo, readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('date'), value: _applyDate, readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('applyDept'), value: _selectedDeptId.isNotEmpty ? '$_selectedDeptId/$_selectedDeptName' : '', hint: l10n.get('pleaseSelect'), onTap: _refDataLoading ? null : () => _showDeptPicker(), ), const SizedBox(height: 16), _buildUrgencyRow(l10n), const SizedBox(height: 16), _label(l10n.get('applyReason'), required: true), const SizedBox(height: 8), TDTextarea( controller: _purposeController, focusNode: _purposeFocus, hintText: l10n.get('enterApplyReason'), maxLines: 4, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), const SizedBox(height: 16), _label(l10n.get('remark')), const SizedBox(height: 8), TDTextarea( controller: _remarkController, focusNode: _remarkFocus, hintText: l10n.get('enterRemark'), maxLines: 3, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), ], ); } Widget _buildUrgencyRow(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return Row( children: [ Text.rich( TextSpan( children: [ TextSpan( text: l10n.get('emergencyLevel'), style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), TextSpan( text: ' *', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.danger, ), ), ], ), ), const Spacer(), Row( mainAxisSize: MainAxisSize.min, children: Urgency.values.asMap().entries.map((e) { final sel = _urgency == e.value.value; final isCritical = e.value.value == Urgency.critical.value; final isUrgent = e.value.value == Urgency.urgent.value; final activeColor = isCritical ? colors.danger : isUrgent ? colors.warning : colors.primary; return Padding( padding: EdgeInsets.only(left: e.key > 0 ? 18 : 0), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _urgency = e.value.value), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 18, height: 18, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: sel ? activeColor : colors.textPlaceholder, width: 2, ), ), child: sel ? Center( child: Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: activeColor, ), ), ) : null, ), const SizedBox(width: 5), Text( l10n.get(e.value.labelKey), style: TextStyle( fontSize: AppFontSizes.subtitle, color: sel ? activeColor : colors.textPrimary, ), ), ], ), ), ); }).toList(), ), ], ); } // ═══ 2. 费用明细 ═══ Widget _buildDetailsSection(AppLocalizations l10n) { final colors = Theme.of(context).extension()!; return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, 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: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder, ), ), ) else ..._details.asMap().entries.map((e) { final d = e.value; return GestureDetector( onTap: () => _showDetailDialog(editIndex: e.key), child: Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( '${d.category}${d.categoryName.isNotEmpty ? '/${d.categoryName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textPrimary, ), ), ), const SizedBox(width: 12), Text( '¥${d.estimatedAmount.toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary, ), ), ], ), if (d.acctSubjectId.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], if (d.sqMan.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], if (d.projectId.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], if (d.costDeptId.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], if (d.startDate.isNotEmpty && d.endDate.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${l10n.get('estimatedDate')}: ${d.startDate} ~ ${d.endDate}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], if (d.remark.isNotEmpty) ...[ const SizedBox(height: 4), Text( d.remark, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], ], ), ), const SizedBox(width: 8), GestureDetector( onTap: () => setState(() => _details.removeAt(e.key)), child: Icon( Icons.close, size: 18, color: colors.textSecondary, ), ), ], ), ), ); }), const SizedBox(height: 8), Container( height: 36, padding: const EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( l10n.get('total'), style: TextStyle( fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary, ), ), Text( '¥${_totalAmount().toStringAsFixed(2)}', style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary, ), ), ], ), ), ], ); } double _totalAmount() => _details.fold(0, (s, d) => s + d.estimatedAmount); Future _showDetailDialog({int? editIndex}) async { if (_addingDetail) return; _addingDetail = true; try { final l10n = AppLocalizations.of(context); ExpenseDetailData? initialData; if (editIndex != null) { final d = _details[editIndex]; initialData = ExpenseDetailData( category: d.category, categoryName: d.categoryName, acctSubjectId: d.acctSubjectId, acctSubjectName: d.acctSubjectName, purpose: d.purpose, projectId: d.projectId, projectName: d.projectName, costDeptId: d.costDeptId, costDeptName: d.costDeptName, startDate: d.startDate, endDate: d.endDate, estimatedAmount: d.estimatedAmount, remark: d.remark, sqMan: d.sqMan, sqManName: d.sqManName, ); } FocusManager.instance.primaryFocus?.unfocus(); final result = await ExpenseApplyDetailDialog.show( // ignore: use_build_context_synchronously context, api: ref.read(expenseApplyApiProvider), l10n: l10n, acctTree: _acctTree, initialData: initialData, ); if (result != null && mounted) { setState(() { final item = _DetailItem( id: editIndex != null ? _details[editIndex].id : _detailIdCounter++, category: result.category, categoryName: result.categoryName, acctSubjectId: result.acctSubjectId, acctSubjectName: result.acctSubjectName, purpose: result.purpose, projectId: result.projectId, projectName: result.projectName, costDeptId: result.costDeptId, costDeptName: result.costDeptName, startDate: result.startDate, endDate: result.endDate, estimatedAmount: result.estimatedAmount, remark: result.remark, preItm: editIndex != null ? _details[editIndex].preItm : null, sqMan: result.sqMan, sqManName: result.sqManName, ); if (editIndex != null) { _details[editIndex] = item; } else { _details.add(item); } }); } } finally { _addingDetail = false; } } // ═══ 类型转换 ═══ List> _convertAcctTree(dynamic tree) { if (tree is! List) return []; return tree.map>((e) { final map = Map.from(e as Map); if (map['children'] != null) { map['children'] = _convertAcctTree(map['children']); } return map; }).toList(); } // ═══ 3. 底部操作栏 ═══ Widget _buildBottomBar(AppLocalizations l10n) { return ActionBar( showLeft: false, showCenter: false, rightLabel: l10n.get('submit'), onRightTap: () async { final err = _validate(l10n); if (err.isNotEmpty) { TDToast.showText(err.first, context: context); return; } FocusScope.of(context).unfocus(); LoadingDialog.show(context, text: l10n.get('submitting')); try { final data = _buildSubmitData(); final api = ref.read(expenseApplyApiProvider); await api.submit(data); if (mounted) { LoadingDialog.hide(context); TDToast.showSuccess( l10n.get('submitSuccess'), context: context, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) GoRouter.of(context).pop(true); }); } } catch (e) { if (mounted) { LoadingDialog.hide(context); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showSubmitError(e, l10n); }); } } }, ); } void _showSubmitError(Object e, AppLocalizations l10n) { final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry'); showGeneralDialog( context: context, pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog( title: l10n.get('submitFailed'), content: message, buttonStyle: TDDialogButtonStyle.text, ), ); } String? _extractErrorMessage(Object e) { if (e is DioException) { if (e.error is ApiException) return (e.error as ApiException).message; if (e.error is NetworkException) { return (e.error as NetworkException).message; } } return null; } Map _buildSubmitData() { String priority; switch (_urgency) { case 'urgent': priority = '2'; break; case 'critical': priority = '3'; break; default: priority = '1'; } return { 'HeadData': { 'AE_NO': _billNo, 'AE_DD': _today(), 'PRIORITY': priority, 'AMTN_YJ': _totalAmount(), 'REASON': _purposeController.text.trim(), 'REM': _remarkController.text, 'DEP': _selectedDeptId, 'USR': HostAppChannel.usr, }, 'BodyData1': _details.asMap().entries.map((e) { final d = e.value; final item = { 'AE_NO': _billNo, 'SQ_MAN': d.sqMan, 'TYPE_NO': d.category, 'AMTN_YJ': d.estimatedAmount, 'ACC_NO': d.acctSubjectId, 'DEP': d.costDeptId, 'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '', 'START_DD': d.startDate, 'END_DD': d.endDate, 'REM': d.remark.isNotEmpty ? d.remark : d.purpose, }; if (d.preItm != null) { item['PRE_ITM'] = d.preItm; } return item; }).toList(), }; } List _validate(AppLocalizations l10n) { final e = []; if (_purposeController.text.trim().isEmpty) { e.add(l10n.get('enterApplyReason')); } if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail')); return e; } void _doPop() { _forcePop(); } void _forcePop() { FocusManager.instance.primaryFocus?.unfocus(); final router = GoRouter.of(context); if (router.canPop()) { router.pop(); } else { SystemNavigator.pop(); } } Future _showDeptPicker() async { FocusManager.instance.primaryFocus?.unfocus(); final l10n = AppLocalizations.of(context); final api = ref.read(expenseApplyApiProvider); final result = await showSearchablePicker( context, title: '${l10n.get('select')}${l10n.get('applyDept')}', searchHint: l10n.get('search'), loader: (keyword, page) => api.getDepartments(keyword: keyword, page: page, size: 20), labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}', onRefresh: () => api.clearRefCache(), ); if (result != null && mounted) { setState(() { _selectedDeptId = result.dep; _selectedDeptName = result.name; }); } } Widget _buildPageFooter() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; return Center( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder, ), const SizedBox(width: 6), Text( l10n.get('pageFooter'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ], ), ), ); } Widget _label(String t, {bool required = false}) { final colors = Theme.of(context).extension()!; return Text.rich( TextSpan( children: [ TextSpan( text: t, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), if (required) TextSpan( text: ' *', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.danger, ), ), ], ), ); } 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 String acctSubjectId; final String acctSubjectName; final String purpose; final String projectId; final String projectName; final String costDeptId; final String costDeptName; final String startDate; final String endDate; final double estimatedAmount; final String remark; final int? preItm; final String sqMan; final String sqManName; const _DetailItem({ required this.id, required this.category, required this.categoryName, required this.acctSubjectId, required this.acctSubjectName, required this.purpose, required this.projectId, required this.projectName, required this.costDeptId, required this.costDeptName, required this.startDate, required this.endDate, required this.estimatedAmount, required this.remark, this.preItm, this.sqMan = '', this.sqManName = '', }); }