|
|
@@ -6,12 +6,8 @@ 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';
|
|
|
@@ -26,62 +22,60 @@ class ExpenseApplyDetailPage extends ConsumerStatefulWidget {
|
|
|
ConsumerState<ExpenseApplyDetailPage> createState() => _ExpenseApplyDetailPageState();
|
|
|
}
|
|
|
|
|
|
-class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage> {
|
|
|
+class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
|
|
|
+ with WidgetsBindingObserver {
|
|
|
bool _loading = true;
|
|
|
String? _error;
|
|
|
ExpenseApplyModel? _data;
|
|
|
List<BillAttachment> _attachments = [];
|
|
|
-
|
|
|
- List<ApprovalRecord> _approvalRecords = [];
|
|
|
- List<String> _approvalChain = [];
|
|
|
- String _currentApproverId = '';
|
|
|
+ bool _attachAvailable = false;
|
|
|
|
|
|
@override
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
+ WidgetsBinding.instance.addObserver(this);
|
|
|
_loadData();
|
|
|
}
|
|
|
|
|
|
+ @override
|
|
|
+ void dispose() {
|
|
|
+ WidgetsBinding.instance.removeObserver(this);
|
|
|
+ super.dispose();
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
|
+ if (state == AppLifecycleState.resumed) {
|
|
|
+ _attachments = [];
|
|
|
+ _attachAvailable = false;
|
|
|
+ _loadData();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
Future<void> _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<ApprovalRecord> records = [];
|
|
|
- List<String> chain = [];
|
|
|
- String currentId = '';
|
|
|
- try {
|
|
|
- final timeline = await api.fetchApprovalTimeline('AE', widget.billNo);
|
|
|
- records = (timeline['records'] as List<dynamic>?)
|
|
|
- ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
|
|
|
- .toList() ??
|
|
|
- [];
|
|
|
- chain = (timeline['chain'] as List<dynamic>?)
|
|
|
- ?.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<BillAttachment> attachments = [];
|
|
|
+ bool attachAvailable = false;
|
|
|
try {
|
|
|
- attachments = await api.getAttachments('AE', widget.billNo);
|
|
|
+ if (mounted) setState(() => _attachAvailable = false);
|
|
|
+ attachAvailable = await api.checkAttachHealth();
|
|
|
+ if (attachAvailable) {
|
|
|
+ attachments = await api.getAttachments('AE', widget.billNo);
|
|
|
+ }
|
|
|
} catch (_) {
|
|
|
- // Attachment load failure is non-fatal
|
|
|
+ attachAvailable = false;
|
|
|
}
|
|
|
|
|
|
if (mounted) {
|
|
|
setState(() {
|
|
|
_data = detail;
|
|
|
- _approvalRecords = records;
|
|
|
- _approvalChain = chain;
|
|
|
- _currentApproverId = currentId;
|
|
|
_attachments = attachments;
|
|
|
+ _attachAvailable = attachAvailable;
|
|
|
_loading = false;
|
|
|
});
|
|
|
}
|
|
|
@@ -92,101 +86,16 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- 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<bool>(
|
|
|
- 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<String?> _showOpinionDialog({
|
|
|
- required String title,
|
|
|
- required String hint,
|
|
|
- }) async {
|
|
|
- final ctrl = TextEditingController();
|
|
|
- return showDialog<String>(
|
|
|
- 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<void> _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<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
- ref
|
|
|
- .read(navBarConfigProvider.notifier)
|
|
|
- .update(
|
|
|
- NavBarConfig(
|
|
|
- title: l10n.get('expenseApplyDetail'),
|
|
|
- showBack: true,
|
|
|
- onBack: () => context.pop(),
|
|
|
- ),
|
|
|
- );
|
|
|
+ setNavBarTitle(context, ref, NavBarConfig(
|
|
|
+ title: l10n.get('expenseApplyDetail'),
|
|
|
+ showBack: true,
|
|
|
+ onBack: () => context.pop(),
|
|
|
+ ));
|
|
|
|
|
|
if (_loading) {
|
|
|
return const Center(
|
|
|
@@ -222,43 +131,22 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
|
|
|
|
|
|
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,
|
|
|
+ return Expanded(
|
|
|
+ child: SingleChildScrollView(
|
|
|
+ physics: const AlwaysScrollableScrollPhysics(),
|
|
|
+ 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: 24),
|
|
|
+ _buildPageFooter(colors),
|
|
|
+ ],
|
|
|
),
|
|
|
- ],
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -268,12 +156,6 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
|
|
|
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,
|
|
|
@@ -284,16 +166,13 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
|
|
|
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),
|
|
|
+ FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(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,
|
|
|
- )),
|
|
|
+ _buildUrgencyChip(app.urgency, l10n, colors),
|
|
|
]),
|
|
|
),
|
|
|
const SizedBox(height: 16),
|
|
|
@@ -420,15 +299,20 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
|
|
|
|
|
|
// ═══ 附件 ═══
|
|
|
Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ final children = <Widget>[];
|
|
|
+ if (!_attachAvailable) {
|
|
|
+ children.add(Text(l10n.get('attachServiceUnavailable'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
|
|
|
+ } else if (_attachments.isEmpty) {
|
|
|
+ children.add(Text(l10n.get('noAttachment'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
|
|
|
+ } else {
|
|
|
+ children.addAll(_attachments.map((a) => _buildAttachmentRow(a, 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)),
|
|
|
- ],
|
|
|
+ children: children,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -461,4 +345,40 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
|
|
|
default: return Icons.insert_drive_file;
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ Widget _buildPageFooter(AppColorsExtension colors) {
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ 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 _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ final (label, color) = switch (urgency) {
|
|
|
+ '3' || 'critical' => (l10n.get('critical'), colors.danger),
|
|
|
+ '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
|
|
|
+ '1' || 'normal' => (l10n.get('normal'), colors.primary),
|
|
|
+ _ => (l10n.get('normal'), colors.primary),
|
|
|
+ };
|
|
|
+ return Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: color.withValues(alpha: 0.1),
|
|
|
+ borderRadius: BorderRadius.circular(4),
|
|
|
+ border: Border.all(color: color, width: 0.5),
|
|
|
+ ),
|
|
|
+ child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
|
|
|
+ );
|
|
|
+ }
|
|
|
}
|