import 'package:flutter/material.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../core/utils/date_utils.dart' as du; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/approval_actions.dart'; import '../../shared/widgets/approval_timeline.dart'; import '../../shared/models/approval_status.dart'; import 'expense_apply_model.dart'; import '../../core/i18n/app_localizations.dart'; import '../../shared/widgets/loading_dialog.dart'; import '../../shared/models/bill_attachment.dart'; import 'expense_apply_api.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; class ExpenseApplyDetailPage extends ConsumerStatefulWidget { final String billNo; final int queryId; const ExpenseApplyDetailPage({super.key, required this.billNo, this.queryId = 0}); @override ConsumerState createState() => _ExpenseApplyDetailPageState(); } class _ExpenseApplyDetailPageState extends ConsumerState { bool _loading = true; String? _error; ExpenseApplyModel? _data; List _attachments = []; List _approvalRecords = []; List _approvalChain = []; String _currentApproverId = ''; @override void initState() { super.initState(); _loadData(); } Future _loadData() async { setState(() { _loading = true; _error = null; }); try { final api = ref.read(expenseApplyApiProvider); final detail = await api.fetchDetail(widget.billNo); // Load approval timeline (non-critical, best-effort) List records = []; List chain = []; String currentId = ''; try { final timeline = await api.fetchApprovalTimeline('AE', widget.billNo); records = (timeline['records'] as List?) ?.map((e) => ApprovalRecord.fromJson(e as Map)) .toList() ?? []; chain = (timeline['chain'] as List?) ?.map((e) => e as String) .toList() ?? []; currentId = timeline['currentApproverId'] as String? ?? ''; } catch (_) { // Timeline load failure is non-fatal } // Load attachments (non-critical, best-effort) List attachments = []; try { attachments = await api.getAttachments('AE', widget.billNo); } catch (_) { // Attachment load failure is non-fatal } if (mounted) { setState(() { _data = detail; _approvalRecords = records; _approvalChain = chain; _currentApproverId = currentId; _attachments = attachments; _loading = false; }); } } catch (e) { if (mounted) { setState(() { _error = e.toString(); _loading = false; }); } } } void _handleApprove() async { final l10n = AppLocalizations.of(context); final rem = await _showOpinionDialog( title: l10n.get('confirmApprove'), hint: l10n.get('approvalComment'), ); if (rem == null || !mounted) return; await _doAudit('approve', rem: rem); } void _handleReject() async { final l10n = AppLocalizations.of(context); final rem = await _showOpinionDialog( title: l10n.get('confirmReject'), hint: l10n.get('rejectReason'), ); if (rem == null || !mounted) return; await _doAudit('reject', rem: rem); } void _handleReverseAudit() async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (ctx) => TDAlertDialog( title: l10n.get('withdrawConfirm'), content: l10n.get('withdrawConfirmTip'), leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)), rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)), ), ); if (confirmed != true || !mounted) return; final rem = await _showOpinionDialog( title: l10n.get('withdrawConfirm'), hint: l10n.get('approvalComment'), ); if (rem == null || !mounted) return; await _doAudit('reverseAudit', rem: rem); } /// 弹出审批意见输入框,返回 null 表示取消 Future _showOpinionDialog({ required String title, required String hint, }) async { final ctrl = TextEditingController(); return showDialog( context: context, builder: (ctx) => TDAlertDialog( title: title, content: hint, leftBtn: TDDialogButtonOptions( title: AppLocalizations.of(context).get('cancel'), action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: AppLocalizations.of(context).get('confirm'), action: () => Navigator.pop(ctx, ctrl.text), ), ), ); } Future _doAudit(String action, {String rem = ''}) async { try { LoadingDialog.show(context); final api = ref.read(expenseApplyApiProvider); await api.executeApproval(bilId: 'AE', bilNo: widget.billNo, action: action, rem: rem); if (mounted) { LoadingDialog.hide(context); TDToast.showSuccess(AppLocalizations.of(context).get('submitSuccess'), context: context); if (mounted) context.pop(); } } catch (e) { if (mounted) { LoadingDialog.hide(context); TDToast.showFail(e.toString(), context: context); } } } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('expenseApplyDetail'), showBack: true, onBack: () => context.pop(), ), ); if (_loading) { return const Center( child: TDLoading(size: TDLoadingSize.large, icon: TDLoadingIcon.activity), ); } if (_error != 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( _error!, textAlign: TextAlign.center, style: TextStyle(fontSize: AppFontSizes.body, color: colors.textSecondary), ), ), const SizedBox(height: 16), TDButton( text: l10n.get('retry'), size: TDButtonSize.medium, onTap: _loadData, ), ], ), ); } final app = _data!; return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ _buildBasicInfoSection(app, l10n, colors), const SizedBox(height: 16), _buildExpenseDetailSection(app, l10n, colors), const SizedBox(height: 16), _buildAttachmentSection(l10n, colors), const SizedBox(height: 16), if (_approvalChain.isNotEmpty) Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(8), ), child: ApprovalTimeline( records: _approvalRecords, chain: _approvalChain, currentApproverId: _currentApproverId, ), ), ], ), ), ), ApprovalActions( queryId: widget.queryId, onApprove: _handleApprove, onReject: _handleReject, onReverseAudit: _handleReverseAudit, ), ], ); } // ═══ 基本信息 ═══ Widget _buildBasicInfoSection( ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors, ) { String urgencyLabel = switch (app.urgency) { 'urgent' => l10n.get('urgent'), 'normal' => l10n.get('normal'), 'critical' => l10n.get('critical'), _ => app.urgency, }; return FormSection( title: l10n.get('basicInfo'), leadingIcon: Icons.info_outline, children: [ FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('applicant'), value: app.applicantName, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(app.createTime), readOnly: true, showArrow: false), const SizedBox(height: 16), SizedBox( height: 24, child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)), Text(urgencyLabel, style: TextStyle( fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: app.urgency == 'urgent' || app.urgency == 'critical' ? colors.danger : colors.textPrimary, )), ]), ), const SizedBox(height: 16), FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false), const SizedBox(height: 16), FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false), ], ); } // ═══ 费用明细 ═══ Widget _buildExpenseDetailSection( ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors, ) { return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, children: [ if (app.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)), ) else ...app.details.asMap().entries.map((e) { final d = e.value; final catLabel = d.categoryName.isNotEmpty ? '${d.expenseCategory}/${d.categoryName}' : d.expenseCategory; return 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( catLabel, 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 && d.acctSubjectName.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${d.acctSubjectId}/${d.acctSubjectName}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.projectId.isNotEmpty && d.projectName.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${d.projectId}/${d.projectName}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty) ...[ const SizedBox(height: 4), Text( '${d.costDeptId}/${d.costDeptName}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.estimatedStartDate != null) ...[ const SizedBox(height: 4), Text( '${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], if (d.remark.isNotEmpty) ...[ const SizedBox(height: 4), Text( d.remark, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary), ), ], ], ), ), ], ), ); }), if (app.details.isNotEmpty) ...[ const SizedBox(height: 8), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)), Text('¥${app.estimatedAmount.toStringAsFixed(2)}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)), ]), ], ], ); } // ═══ 附件 ═══ Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('attachments'), leadingIcon: Icons.attach_file_outlined, children: [ if (_attachments.isEmpty) Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)) else ..._attachments.map((a) => _buildAttachmentRow(a, colors)), ], ); } Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) { return Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(8), ), child: Row(children: [ Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary), const SizedBox(width: 10), Expanded( child: Text(a.fileName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)), ), ]), ); } IconData _fileTypeIcon(String ext) { switch (ext.toLowerCase()) { case 'pdf': return Icons.picture_as_pdf; case 'doc': case 'docx': return Icons.description; case 'xls': case 'xlsx': return Icons.table_chart; case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined; default: return Icons.insert_drive_file; } } }