chengc 2 viikkoa sitten
vanhempi
commit
61ded7d9fc

+ 8 - 0
assets/i18n/en.json

@@ -81,6 +81,14 @@
     "statusDisabled": "statusDisabled",
     "statusWaitApprove": "Pending",
     "statusWaitPay": "statusWaitPay",
+    "editApply": "Edit Pre-Application",
+    "editExpense": "Edit Expense Report",
+    "statusTransferred": "Transferred",
+    "statusClosed": "Closed",
+    "statusPendingReview": "Pending Review",
+    "statusReviewing": "Under Review",
+    "statusReviewRejected": "Review Rejected",
+    "statusFinalRejected": "Review Rejected(Final)",
     "filterAll": "filterAll",
     "all": "all",
     "myDrafts": "myDrafts",

+ 23 - 15
assets/i18n/zh_CN.json

@@ -70,7 +70,7 @@
     "returned": "已还车",
     "completed": "已完成",
     "statusPending": "审批中",
-    "statusApproved": "通过",
+    "statusApproved": "审核通过",
     "statusRejected": "已拒绝",
     "statusDraft": "草稿",
     "statusWithdrawn": "已撤回",
@@ -78,6 +78,14 @@
     "statusDisabled": "已禁用",
     "statusWaitApprove": "待审核",
     "statusWaitPay": "待付款",
+    "editApply": "修改报销申请",
+    "editExpense": "修改报销",
+    "statusTransferred": "已转单",
+    "statusClosed": "已结案",
+    "statusPendingReview": "待审核",
+    "statusReviewing": "审核中",
+    "statusReviewRejected": "审核驳回",
+    "statusFinalRejected": "审核不通过",
     "filterAll": "全部",
     "all": "全部",
     "myDrafts": "我的草稿",
@@ -235,8 +243,8 @@
     "monthlyTotalExpense": "本月累计报销",
     "monthlySubmitted": "本月已提单据",
     "pendingDocuments": "待处理单据",
-    "preApplication": "费用申请",
-    "expenseReimbursement": "费用报销",
+    "preApplication": "报销申请",
+    "expenseReimbursement": "报销",
     "vehicleApplication": "用车申请",
     "overtimeApplication": "加班申请",
     "applicationRecords": "申请记录",
@@ -258,18 +266,18 @@
     "scopeSubordinates": "下属审批"
   },
   "expenseApply": {
-    "expenseApplyList": "费用申请列表",
-    "expenseApplyDetail": "费用申请详情",
-    "expenseApplyRequest": "费用申请",
-    "expenseApplyImport": "一键导入已通过的费用申请",
-    "importApprovedPreApp": "一键导入已通过的费用申请",
-    "importExpenseApply": "导入费用申请",
+    "expenseApplyList": "报销申请列表",
+    "expenseApplyDetail": "报销申请详情",
+    "expenseApplyRequest": "报销申请",
+    "expenseApplyImport": "一键导入已通过的报销申请",
+    "importApprovedPreApp": "一键导入已通过的报销申请",
+    "importExpenseApply": "导入报销申请",
     "confirmImport": "确认导入",
     "importSuccess": "导入成功",
     "searchExpenseApply": "搜索申请单号或申请人",
-    "noExpenseApplications": "暂无费用申请",
-    "reportExpenseApply": "费用申请报表",
-    "reportExpenseApplyDetail": "费用申请明细报表",
+    "noExpenseApplications": "暂无报销申请",
+    "reportExpenseApply": "报销申请报表",
+    "reportExpenseApplyDetail": "报销申请明细报表",
     "emergencyLevel": "紧急程度",
     "feeType": "费用类型",
     "feeReason": "费用事由",
@@ -375,11 +383,11 @@
     "expenseList": "报销列表",
     "expenseDetail": "报销详情",
     "editExpense": "编辑报销",
-    "expenseApply": "费用报销",
+    "expenseApply": "报销",
     "searchExpense": "搜索报销单号或报销人",
     "noExpenses": "暂无报销单",
-    "reportExpense": "费用报销报表",
-    "reportExpenseDetail": "费用报销明细报表",
+    "reportExpense": "报销报表",
+    "reportExpenseDetail": "报销明细报表",
     "expenseDate": "报销日期",
     "expenseNo": "报销单号",
     "autoGenerated": "系统自动生成",

+ 19 - 11
assets/i18n/zh_TW.json

@@ -73,7 +73,7 @@
     "returned": "已還車",
     "completed": "已完成",
     "statusPending": "審批中",
-    "statusApproved": "通過",
+    "statusApproved": "審核通過",
     "statusRejected": "已拒絕",
     "statusDraft": "草稿",
     "statusWithdrawn": "已撤回",
@@ -81,6 +81,14 @@
     "statusDisabled": "已禁用",
     "statusWaitApprove": "待審核",
     "statusWaitPay": "待付款",
+    "editApply": "修改報銷申請",
+    "editExpense": "修改報銷",
+    "statusTransferred": "已轉單",
+    "statusClosed": "已結案",
+    "statusPendingReview": "待審核",
+    "statusReviewing": "審核中",
+    "statusReviewRejected": "審核駁回",
+    "statusFinalRejected": "審核不通過",
     "filterAll": "全部",
     "all": "全部",
     "myDrafts": "我的草稿",
@@ -235,7 +243,7 @@
     "monthlyTotalExpense": "本月累計報销",
     "monthlySubmitted": "本月已提單據",
     "pendingDocuments": "待處理單據",
-    "preApplication": "費用申請",
+    "preApplication": "報銷申請",
     "expenseReimbursement": "費用報销",
     "vehicleApplication": "用車申請",
     "overtimeApplication": "加班申請",
@@ -258,18 +266,18 @@
     "scopeSubordinates": "下属審批"
   },
   "expenseApply": {
-    "expenseApplyList": "費用申請列表",
-    "expenseApplyDetail": "費用申請詳情",
-    "expenseApplyRequest": "費用申請",
-    "expenseApplyImport": "一鍵導入已通過的費用申請",
-    "importApprovedPreApp": "一鍵導入已通過的費用申請",
-    "importExpenseApply": "導入費用申請",
+    "expenseApplyList": "報銷申請列表",
+    "expenseApplyDetail": "報銷申請詳情",
+    "expenseApplyRequest": "報銷申請",
+    "expenseApplyImport": "一鍵導入已通過的報銷申請",
+    "importApprovedPreApp": "一鍵導入已通過的報銷申請",
+    "importExpenseApply": "導入報銷申請",
     "confirmImport": "確認導入",
     "importSuccess": "導入成功",
     "searchExpenseApply": "搜索申請單號或申請人",
-    "noExpenseApplications": "暫無費用申請",
-    "reportExpenseApply": "費用申請報表",
-    "reportExpenseApplyDetail": "費用申請明細報表",
+    "noExpenseApplications": "暫無報銷申請",
+    "reportExpenseApply": "報銷申請報表",
+    "reportExpenseApplyDetail": "報銷申請明細報表",
     "emergencyLevel": "紧急程度",
     "feeType": "費用類型",
     "feeReason": "費用事由",

+ 4 - 0
lib/core/router/app_router.dart

@@ -26,6 +26,8 @@ import '../../features/announcement/announcement_create_page.dart';
 import '../../features/expense_apply/expense_apply_list_page.dart';
 import '../../features/expense_apply/expense_apply_detail_page.dart';
 import '../../features/expense_apply/expense_apply_create_page.dart';
+import '../../features/expense_apply/expense_apply_edit_page.dart';
+import '../../features/expense/expense_edit_page.dart';
 import '../../features/report/expense_detail_report_page.dart';
 import '../../features/report/overtime_detail_report_page.dart';
 import '../../features/report/vehicle_detail_report_page.dart';
@@ -62,6 +64,7 @@ GoRouter createAppRouter() => GoRouter(
     GoRoute(path: '/expense/import-apply', pageBuilder: (context, _) => MaterialPage(child: AppScaffold(navConfig: NavBarConfig(showBack: true, onBack: () => _smartBack(context)), body: const ExpenseApplyImportPage(), resizeToAvoidBottomInset: false))),
     GoRoute(path: '/expense/create', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true), body: ExpenseCreatePage(editId: state.uri.queryParameters['id']), resizeToAvoidBottomInset: false))),
     GoRoute(path: '/expense/detail/:billNo', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true, onBack: () => _smartBack(context)), body: ExpenseDetailPage(billNo: state.pathParameters['billNo']!, queryId: int.tryParse(state.uri.queryParameters['queryId'] ?? '0') ?? 0)))),
+    GoRoute(path: '/expense/edit/:billNo', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true, onBack: () => _smartBack(context)), body: ExpenseEditPage(billNo: state.pathParameters['billNo']!), resizeToAvoidBottomInset: false))),
 
     // ── 加班 ──
     GoRoute(path: '/overtime/list', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true, showRight: true, rightWidget: _addButton(context, '/overtime/create'), onBack: _popToNative), body: const OvertimeListPage()))),
@@ -87,6 +90,7 @@ GoRouter createAppRouter() => GoRouter(
     GoRoute(path: '/expense-apply/list', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true, showRight: true, rightWidget: _addButton(context, '/expense-apply/create'), onBack: _popToNative), body: const ExpenseApplyListPage()))),
     GoRoute(path: '/expense-apply/detail/:billNo', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true, onBack: () => _smartBack(context)), body: ExpenseApplyDetailPage(billNo: state.pathParameters['billNo']!, queryId: int.tryParse(state.uri.queryParameters['queryId'] ?? '0') ?? 0)))),
     GoRoute(path: '/expense-apply/create', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true), body: ExpenseApplyCreatePage(id: state.uri.queryParameters['id']), resizeToAvoidBottomInset: false))),
+    GoRoute(path: '/expense-apply/edit/:billNo', pageBuilder: (context, state) => MaterialPage(key: ValueKey(state.uri.toString()), child: AppScaffold(navConfig: NavBarConfig(showBack: true, onBack: () => _smartBack(context)), body: ExpenseApplyEditPage(billNo: state.pathParameters['billNo']!), resizeToAvoidBottomInset: false))),
 
     // ── 报表 ──
     GoRoute(path: '/report/expense-detail', pageBuilder: (context, _) => MaterialPage(child: AppScaffold(navConfig: NavBarConfig(showBack: true, onBack: () => _smartBack(context)), body: const ExpenseDetailReportPage()))),

+ 9 - 0
lib/features/expense/expense_api.dart

@@ -491,6 +491,15 @@ class ExpenseApi {
     );
     return response.data!;
   }
+
+  /// 获取单据状态
+  Future<Map<String, dynamic>> getBillStatus(String billNo) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetBillStatus',
+      queryParameters: {'billId': 'BX', 'billNo': billNo},
+    );
+    return response.data!;
+  }
 }
 
 /// 费用报销审批列表项

+ 3 - 0
lib/features/expense/expense_apply_import_page.dart

@@ -16,6 +16,7 @@ class ImportableItem {
   final String reason;
   final double headAmtnYj;
   final int itm;
+  final int? preItm;
   final String sqMan;
   final String sqName;
   final String typeNo;
@@ -39,6 +40,7 @@ class ImportableItem {
     required this.reason,
     required this.headAmtnYj,
     required this.itm,
+    this.preItm,
     required this.sqMan,
     required this.sqName,
     required this.typeNo,
@@ -62,6 +64,7 @@ class ImportableItem {
     reason: json['reason'] as String? ?? '',
     headAmtnYj: (json['headAmtnYj'] as num?)?.toDouble() ?? 0,
     itm: json['itm'] as int? ?? 0,
+    preItm: json['preItm'] as int?,
     sqMan: json['sqMan'] as String? ?? '',
     sqName: json['sqName'] as String? ?? '',
     typeNo: json['typeNo'] as String? ?? '',

+ 47 - 12
lib/features/expense/expense_create_page.dart

@@ -53,6 +53,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
   List<CustomerItem> _customers = [];
   List<CurrencyItem> _currencies = [];
   List<EmployeeItem> _employees = [];
+  EmployeeItem? _selEmployee;
   bool _firstBuild = true;
   bool _refDataLoading = true;
   bool _addingDetail = false;
@@ -132,6 +133,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
         _employees = results[5] as List<EmployeeItem>;
         _refDataLoading = false;
         _autoSelectDept();
+        _autoSelectEmployee();
       });
       completer.complete();
     } catch (_) {
@@ -195,7 +197,9 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
 
     if (_firstBuild) return const SkeletonFormPage(showImportLink: true);
 
-    Future.microtask(() => ref.read(pageBackProvider.notifier).state = () => _doPop());
+    Future.microtask(
+      () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
+    );
     Widget pageContent = PopScope(
       canPop: false,
       onPopInvokedWithResult: (didPop, _) {
@@ -346,6 +350,36 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     }
   }
 
+  void _autoSelectEmployee() {
+    if (_selEmployee != null) return;
+    final usr = HostAppChannel.usr;
+    if (usr.isEmpty || _employees.isEmpty) return;
+    final match = _employees.where((e) => e.salNo == usr);
+    if (match.isNotEmpty) {
+      setState(() => _selEmployee = match.first);
+    }
+  }
+
+  void _showEmployeePicker() {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
+    TDPicker.showMultiPicker(
+      context,
+      title: AppLocalizations.of(context).get('expensePersonnel'),
+      backgroundColor: colors.bgCard,
+      data: [labels],
+      onConfirm: (selected) {
+        if (selected.isNotEmpty && selected[0] is int) {
+          final idx = selected[0] as int;
+          if (idx >= 0 && idx < labels.length) {
+            Navigator.of(context).pop();
+            setState(() => _selEmployee = _employees[idx]);
+          }
+        }
+      },
+    );
+  }
+
   void _showDeptPicker() {
     if (_departments.isEmpty) {
       TDToast.showText(
@@ -384,7 +418,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
       'HeadData': {
         'BX_DD': _today(),
         'DEP': _selectedDeptId,
-        'USR_NO': HostAppChannel.usr,
+        'USR_NO': _selEmployee?.salNo ?? '',
         'PAY_ID': expense.paymentMethod,
         //'PRT_SW': 'N',
         'USR': HostAppChannel.usr,
@@ -417,6 +451,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
           'BNK_ID': d.bankAccount,
           'ACCNAME': d.bankAccountName,
           'SQ_MAN': d.sqMan.isNotEmpty ? d.sqMan : HostAppChannel.usr,
+          'EST_ITM': d.aeNo.isNotEmpty ? d.sortOrder : 0,
         };
       }).toList(),
     };
@@ -494,7 +529,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
               bankAccountName: '',
               bankAccount: '',
               remark: item.rem,
-              sortOrder: item.itm,
+              sortOrder: item.preItm ?? item.itm,
               attachments: const [],
               sqMan: item.sqMan,
               sqManName: item.sqName,
@@ -553,12 +588,11 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
         const SizedBox(height: 16),
         FormFieldRow(
           label: l10n.get('expensePersonnel'),
-          value:
-              HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
-              ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
-              : '--',
-          readOnly: true,
-          showArrow: false,
+          value: _selEmployee != null
+              ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
+              : '',
+          hint: l10n.get('pleaseSelect'),
+          onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
@@ -778,7 +812,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
                             ),
                           if (d.aeDd.isNotEmpty)
                             Text(
-                              '${l10n.get('applyDate')}: ${d.aeDd}',
+                              '${l10n.get('applyDate')}: ${d.aeDd.length >= 10 ? d.aeDd.substring(0, 10) : d.aeDd}',
                               style: TextStyle(
                                 fontSize: AppFontSizes.caption,
                                 color: colors.textSecondary,
@@ -1126,7 +1160,8 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
   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;
+      if (e.error is NetworkException)
+        return (e.error as NetworkException).message;
     }
     return null;
   }
@@ -1153,7 +1188,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
           acctSubjectId: d.acctSubjectId,
           acctSubjectName: d.acctSubjectName,
           purpose: d.purpose,
-          amount: d.amount,
+          amount: d.totalAmount,
           taxRate: d.taxRate,
           projectId: d.projectId,
           projectName: d.projectName,

+ 92 - 20
lib/features/expense/expense_detail_page.dart

@@ -2,11 +2,14 @@ import 'dart:typed_data';
 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/loading_dialog.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/app_skeletons.dart';
+import '../../shared/widgets/action_bar.dart';
+import '../../shared/widgets/status_banner.dart';
 import 'expense_model.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../shared/models/bill_attachment.dart';
@@ -35,6 +38,9 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
   bool _attachAvailable = false;
   bool _isLoading = true;
   String? _error;
+  Map<String, dynamic>? _billStatus;
+  bool _statusLoading = false;
+  bool _canEdit = false;
 
   @override
   void initState() {
@@ -71,6 +77,21 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
           _attachments = [];
         }
       }
+
+      // 3. 获取单据状态(非致命)
+      setState(() => _statusLoading = true);
+      try {
+        final status = await api.getBillStatus(widget.billNo);
+        if (mounted) {
+          setState(() {
+            _billStatus = status;
+            _canEdit = status['canEdit'] == true;
+            _statusLoading = false;
+          });
+        }
+      } catch (_) {
+        if (mounted) setState(() => _statusLoading = false);
+      }
     } catch (e) {
       setState(() => _error = e.toString());
     } finally {
@@ -112,25 +133,76 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
 
     final expense = _expense!;
 
-    return Expanded(
-      child: SingleChildScrollView(
-        physics: const AlwaysScrollableScrollPhysics(),
-        padding: const EdgeInsets.all(16),
-        child: Column(
-          children: [
-            _buildBasicInfoSection(expense, l10n, colors),
-            const SizedBox(height: 16),
-            _buildExpenseDetailSection(expense, l10n, colors),
-            const SizedBox(height: 16),
-            _buildAttachmentSection(l10n, colors),
-            const SizedBox(height: 24),
-            _buildPageFooter(colors),
-          ],
+    return Column(
+      children: [
+        Expanded(
+          child: SingleChildScrollView(
+            physics: const AlwaysScrollableScrollPhysics(),
+            padding: const EdgeInsets.all(16),
+            child: Column(
+              children: [
+                if (_statusLoading && _billStatus == null) ...[
+                  const Padding(
+                    padding: EdgeInsets.symmetric(vertical: 8),
+                    child: Center(child: TDLoading(size: TDLoadingSize.small, icon: TDLoadingIcon.activity)),
+                  ),
+                  const SizedBox(height: 8),
+                ],
+                if (_billStatus != null) ...[
+                  _buildStatusBadge(l10n),
+                  const SizedBox(height: 16),
+                ],
+                _buildBasicInfoSection(expense, l10n, colors),
+                const SizedBox(height: 16),
+                _buildExpenseDetailSection(expense, l10n, colors),
+                const SizedBox(height: 16),
+                _buildAttachmentSection(l10n, colors),
+                const SizedBox(height: 24),
+                _buildPageFooter(colors),
+              ],
+            ),
+          ),
         ),
-      ),
+        if (_canEdit)
+          ActionBar(
+            showLeft: false,
+            showCenter: false,
+            rightLabel: l10n.get('editExpense'),
+            onRightTap: () async {
+              final result = await GoRouter.of(context).push('/expense/edit/${widget.billNo}');
+              if (result == true && mounted) _loadData();
+            },
+          ),
+      ],
     );
   }
 
+  // ═══ 状态 badge ═══
+  Widget _buildStatusBadge(AppLocalizations l10n) {
+    final isClosed = _billStatus?['isClosed'] == true;
+    final isTransferred = _billStatus?['isTransferred'] == true;
+    final approvalStatus = _billStatus?['approvalStatus'] as String?;
+
+    if (isClosed) {
+      return StatusBanner(icon: Icons.lock_outline, statusText: l10n.get('statusClosed'), subText: '', color: Colors.blueGrey);
+    }
+    if (isTransferred) {
+      return StatusBanner(icon: Icons.swap_horiz, statusText: l10n.get('statusTransferred'), subText: '', color: Colors.blueGrey);
+    }
+    switch (approvalStatus) {
+      case 'SHCOUNT4':
+        return StatusBanner(icon: Icons.check_circle_outline, statusText: l10n.get('statusApproved'), subText: '', color: Colors.green);
+      case 'SHCOUNT5':
+        return StatusBanner(icon: Icons.cancel_outlined, statusText: l10n.get('statusFinalRejected'), subText: '', color: Colors.red);
+      case 'SHCOUNT1':
+        return StatusBanner(icon: Icons.hourglass_empty, statusText: l10n.get('statusPendingReview'), subText: '', color: Colors.blue);
+      case 'SHCOUNT2':
+        return StatusBanner(icon: Icons.block_outlined, statusText: l10n.get('statusReviewRejected'), subText: '', color: Colors.deepOrange);
+      default:
+        return const SizedBox.shrink();
+    }
+  }
+
   // ═══ 基本信息 ═══
   Widget _buildBasicInfoSection(
       ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
@@ -152,17 +224,17 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
         const SizedBox(height: 16),
         FormFieldRow(
             label: l10n.get('expensePersonnel'),
-            value: expense.applicantId.isNotEmpty
+            value: expense.applicantName.isNotEmpty
                 ? '${expense.applicantId}/${expense.applicantName}'
-                : expense.applicantName,
+                : '-',
             readOnly: true,
             showArrow: false),
         const SizedBox(height: 16),
         FormFieldRow(
             label: l10n.get('expenseDept'),
-            value: expense.deptId.isNotEmpty
+            value: expense.deptName.isNotEmpty
                 ? '${expense.deptId}/${expense.deptName}'
-                : expense.deptName,
+                : '-',
             readOnly: true,
             showArrow: false),
         const SizedBox(height: 16),
@@ -294,7 +366,7 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
                               maxLines: 1, overflow: TextOverflow.ellipsis,
                               style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                         if (d.aeDd.isNotEmpty)
-                          Text('${l10n.get('applyDate')}: ${d.aeDd}',
+                          Text('${l10n.get('applyDate')}: ${d.aeDd.length >= 10 ? d.aeDd.substring(0, 10) : d.aeDd}',
                               style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                         if (d.projectName.isNotEmpty)
                           Text('${l10n.get('project')}: ${d.projectId}/${d.projectName}',

+ 1245 - 0
lib/features/expense/expense_edit_page.dart

@@ -0,0 +1,1245 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import 'package:dio/dio.dart';
+import 'package:go_router/go_router.dart';
+import '../../core/network/api_exception.dart';
+import '../../core/utils/responsive.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/app_skeletons.dart';
+import '../../shared/widgets/nav_bar_config.dart';
+import '../../core/data/mock_api_data.dart';
+import 'widgets/expense_detail_dialog.dart';
+import '../../shared/widgets/loading_dialog.dart';
+import '../../shared/widgets/action_bar.dart';
+import 'expense_api.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/navigation/host_app_channel.dart';
+
+/// 费用报销修改页
+///
+/// 完全参考 [ExpenseCreatePage] 创建页,但:
+/// - 无草稿功能
+/// - 无附件上传区
+/// - 无从申请单导入链接
+/// - initState 中加载原单数据并回填
+/// - 基本信息区顶部加单号、日期只读行
+/// - ActionBar 仅含提交按钮
+/// - 提交时 HeadData 加 BX_NO,成功 pop(true) 返回详情页
+class ExpenseEditPage extends ConsumerStatefulWidget {
+  final String billNo;
+  const ExpenseEditPage({super.key, required this.billNo});
+
+  @override
+  ConsumerState<ExpenseEditPage> createState() => _ExpenseEditPageState();
+}
+
+class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
+  final _purposeController = TextEditingController();
+  final _purposeFocus = FocusNode();
+  final _remarkController = TextEditingController();
+  final _remarkFocus = FocusNode();
+  final _scrollCtrl = ScrollController();
+  final _detailsSectionKey = GlobalKey();
+
+  // ── 参考数据(从 API 加载) ──
+  List<CostTypeItem> _costTypes = [];
+  List<ProjectCodeItem> _projects = [];
+  List<DepartmentItem> _departments = [];
+  List<CustomerItem> _customers = [];
+  List<CurrencyItem> _currencies = [];
+  List<EmployeeItem> _employees = [];
+  EmployeeItem? _selEmployee;
+  bool _firstBuild = true;
+  bool _refDataLoading = true;
+  bool _addingDetail = false;
+  bool _isSubmitting = false;
+  String? _loadingError;
+
+  // ── 原单数据 ──
+  ExpenseModel? _expense;
+  String _billNo = '';
+  String _expenseDate = '';
+  // ── 报销部门 ──
+  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));
+    _billNo = widget.billNo;
+    _loadData();
+  }
+
+  Future<void> _loadData() async {
+    try {
+      final api = ref.read(expenseApiProvider);
+      final results = await Future.wait([
+        api.getCostTypes(),
+        api.getProjectCodes(),
+        api.getDepartments(),
+        api.getCustomers(),
+        api.getCurrencies(),
+        api.getEmployees(),
+        api.fetchDetail(widget.billNo),
+      ]);
+      if (!mounted) return;
+      final expense = results[6] as ExpenseModel;
+      setState(() {
+        _costTypes = results[0] as List<CostTypeItem>;
+        _projects = results[1] as List<ProjectCodeItem>;
+        _departments = results[2] as List<DepartmentItem>;
+        _customers = results[3] as List<CustomerItem>;
+        _currencies = results[4] as List<CurrencyItem>;
+        _employees = results[5] as List<EmployeeItem>;
+        _expense = expense;
+        _expenseDate = expense.expenseDate != null
+            ? du.DateUtils.formatDate(expense.expenseDate!)
+            : _today();
+        _selectedDeptId = expense.deptId;
+        _selectedDeptName = expense.deptName;
+        _purposeController.text = expense.purpose;
+        _remarkController.text = expense.remark;
+        _refDataLoading = false;
+        _firstBuild = false;
+        _autoSelectDept();
+        _autoSelectEmployee();
+      });
+    } catch (e) {
+      if (!mounted) return;
+      setState(() {
+        _refDataLoading = false;
+        _firstBuild = false;
+        _loadingError = e.toString();
+      });
+    }
+  }
+
+  void _autoSelectDept() {
+    if (_selectedDeptId.isNotEmpty) return;
+    final dep = HostAppChannel.dep;
+    if (dep.isEmpty) return;
+    final match = _departments.where((d) => d.dep == dep);
+    if (match.isNotEmpty) {
+      _selectedDeptId = match.first.dep;
+      _selectedDeptName = match.first.name;
+    }
+  }
+
+  void _autoSelectEmployee() {
+    if (_selEmployee != null) return;
+    final usr = HostAppChannel.usr;
+    if (usr.isEmpty || _employees.isEmpty) return;
+    final match = _employees.where((e) => e.salNo == usr);
+    if (match.isNotEmpty) {
+      setState(() => _selEmployee = match.first);
+    }
+  }
+
+  void _showEmployeePicker() {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
+    TDPicker.showMultiPicker(
+      context,
+      title: AppLocalizations.of(context).get('expensePersonnel'),
+      backgroundColor: colors.bgCard,
+      data: [labels],
+      onConfirm: (selected) {
+        if (selected.isNotEmpty && selected[0] is int) {
+          final idx = selected[0] as int;
+          if (idx >= 0 && idx < labels.length) {
+            Navigator.of(context).pop();
+            setState(() => _selEmployee = _employees[idx]);
+          }
+        }
+      },
+    );
+  }
+
+  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();
+  }
+
+  // ═══ 状态更新助手(替代 Riverpod controller) ═══
+
+  void _updatePurpose(String purpose) {
+    if (_expense == null) return;
+    setState(() {
+      _expense = _expense!.copyWith(purpose: purpose);
+    });
+  }
+
+  void _updateRemark(String remark) {
+    if (_expense == null) return;
+    setState(() {
+      _expense = _expense!.copyWith(remark: remark);
+    });
+  }
+
+  void _updatePaymentMethod(String method) {
+    if (_expense == null) return;
+    setState(() {
+      _expense = _expense!.copyWith(paymentMethod: method);
+    });
+  }
+
+  void _updateCurrencyCode(String code) {
+    if (_expense == null) return;
+    setState(() {
+      _expense = _expense!.copyWith(currencyCode: code);
+    });
+  }
+
+  void _setGenerateVoucher(bool value) {
+    if (_expense == null) return;
+    setState(() {
+      _expense = _expense!.copyWith(isGenerateVoucher: value);
+    });
+  }
+
+  void _addDetail(ExpenseDetailModel detail) {
+    if (_expense == null) return;
+    setState(() {
+      _expense = _expense!.copyWith(details: [..._expense!.details, detail]);
+    });
+    _recalculateAmount();
+  }
+
+  void _updateDetail(int index, ExpenseDetailModel detail) {
+    if (_expense == null) return;
+    final details = [..._expense!.details];
+    details[index] = detail;
+    setState(() {
+      _expense = _expense!.copyWith(details: details);
+    });
+    _recalculateAmount();
+  }
+
+  void _removeDetail(int index) {
+    if (_expense == null) return;
+    final details = [..._expense!.details]..removeAt(index);
+    setState(() {
+      _expense = _expense!.copyWith(details: details);
+    });
+    _recalculateAmount();
+  }
+
+  void _recalculateAmount() {
+    if (_expense == null) return;
+    var totalAmount = 0.0;
+    var approvedAmount = 0.0;
+    for (final d in _expense!.details) {
+      totalAmount += d.totalAmount;
+      approvedAmount += d.approvedAmount;
+    }
+    setState(() {
+      _expense = _expense!.copyWith(
+        totalAmount: totalAmount,
+        approvedAmount: approvedAmount,
+      );
+    });
+  }
+
+  // ═══ API 数据 → 弹窗类型转换 ═══
+
+  List<CostCategory> get _dialogCategories => _costTypes
+      .map(
+        (c) => CostCategory(
+          code: c.typeNo,
+          nameKey: c.typeName,
+          acctSubjectId: c.accNo,
+          acctSubjectName: c.accName,
+        ),
+      )
+      .toList();
+
+  List<Project> get _dialogProjects => _projects
+      .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
+      .toList();
+
+  List<CostDept> get _dialogCostDepts =>
+      _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
+
+  String _currencyLabel(String code) {
+    final match = _currencies.where((c) => c.curId == code);
+    return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code;
+  }
+
+  List<CustomerVendor> get _dialogCustomers =>
+      _customers.map((c) => CustomerVendor(id: c.cusNo, name: c.name)).toList();
+
+  List<EmployeeItem> get _dialogEmployees => _employees;
+
+  void _showDeptPicker() {
+    if (_departments.isEmpty) {
+      TDToast.showText(
+        AppLocalizations.of(context).get('noData'),
+        context: context,
+      );
+      return;
+    }
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
+    FocusManager.instance.primaryFocus?.unfocus();
+    TDPicker.showMultiPicker(
+      context,
+      title: l10n.get('expenseDept'),
+      backgroundColor: colors.bgCard,
+      data: [labels],
+      onConfirm: (selected) {
+        if (selected.isNotEmpty && selected[0] is int) {
+          final idx = selected[0] as int;
+          if (idx >= 0 && idx < labels.length) {
+            Navigator.of(context).pop();
+            setState(() {
+              _selectedDeptId = _departments[idx].dep;
+              _selectedDeptName = _departments[idx].name;
+            });
+          }
+        }
+      },
+    );
+  }
+
+  Map<String, dynamic> _buildSubmitData() {
+    final expense = _expense!;
+    return {
+      'HeadData': {
+        'BX_NO': _billNo,
+        'BX_DD': _today(),
+        'DEP': _selectedDeptId,
+        'USR_NO': _selEmployee?.salNo ?? '',
+        'PAY_ID': expense.paymentMethod,
+        'USR': HostAppChannel.usr,
+        'REM': expense.remark,
+        'CUR_ID': expense.currencyCode,
+        'EXC_RTO': 1,
+        'REASON': expense.purpose,
+        'VOH_ID': expense.isGenerateVoucher ? 'T' : 'F',
+      },
+      'BodyData1': expense.details.asMap().entries.map((e) {
+        final i = e.key;
+        final d = e.value;
+        return {
+          'ITM': i + 1,
+          'BX_DD': _today(),
+          'ACC_NO': d.acctSubjectId,
+          'AMT': d.totalAmount,
+          'AMTN': d.amount,
+          'AMTN_SH': d.approvedAmount,
+          'REM': d.remark,
+          'CUST': d.customerVendorId,
+          'IDX_NO': d.expenseCategory,
+          'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '',
+          'TAX': d.taxAmount,
+          'TAX_RTO': d.taxRate,
+          'DEP': d.costDeptId,
+          'AE_NO': d.aeNo,
+          'AE_DD': d.aeDd,
+          'BNK_NO': d.bankName,
+          'BNK_ID': d.bankAccount,
+          'ACCNAME': d.bankAccountName,
+          'SQ_MAN': d.sqMan.isNotEmpty ? d.sqMan : HostAppChannel.usr,
+          'EST_ITM': d.aeNo.isNotEmpty ? d.sortOrder : 0,
+        };
+      }).toList(),
+    };
+  }
+
+  // ═══ Build ═══
+
+  @override
+  Widget build(BuildContext context) {
+    final r = ResponsiveHelper.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final l10n = AppLocalizations.of(context);
+    final bottomInset = MediaQuery.of(context).padding.bottom;
+
+    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;
+                  _firstBuild = true;
+                  _refDataLoading = true;
+                });
+                _loadData();
+              },
+            ),
+          ],
+        ),
+      );
+    }
+
+    if (_firstBuild || _expense == null) {
+      return const SkeletonFormPage(showImportLink: false);
+    }
+
+    Future.microtask(
+      () => ref.read(pageBackProvider.notifier).state = () => _doPop(l10n),
+    );
+
+    Widget pageContent = PopScope(
+      canPop: false,
+      onPopInvokedWithResult: (didPop, _) {
+        if (didPop) return;
+        _doPop(l10n);
+      },
+      child: Column(
+        children: [
+          Expanded(
+            child: Align(
+              alignment: Alignment.topCenter,
+              child: ConstrainedBox(
+                constraints: BoxConstraints(maxWidth: r.formMaxWidth),
+                child: SingleChildScrollView(
+                  controller: _scrollCtrl,
+                  padding: const EdgeInsets.all(16),
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      _buildBasicInfoSection(l10n, colors),
+                      const SizedBox(height: 16),
+                      Container(
+                        key: _detailsSectionKey,
+                        child: _buildDetailSection(l10n, colors),
+                      ),
+                      const SizedBox(height: 24),
+                      _buildPageFooter(),
+                    ],
+                  ),
+                ),
+              ),
+            ),
+          ),
+          ColoredBox(
+            color: colors.bgCard,
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                _buildBottomButtons(l10n),
+                if (bottomInset > 0) SizedBox(height: bottomInset),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+
+    return pageContent;
+  }
+
+  Widget _buildBasicInfoSection(
+    AppLocalizations l10n,
+    AppColorsExtension colors,
+  ) {
+    final expense = _expense!;
+    return FormSection(
+      title: l10n.get('basicInfo'),
+      leadingIcon: Icons.info_outline,
+      children: [
+        FormFieldRow(
+          label: l10n.get('expenseNo'),
+          value: _billNo,
+          readOnly: true,
+          showArrow: false,
+        ),
+        const SizedBox(height: 16),
+        FormFieldRow(
+          label: l10n.get('date'),
+          value: _expenseDate,
+          readOnly: true,
+          showArrow: false,
+        ),
+        const SizedBox(height: 16),
+        FormFieldRow(
+          label: l10n.get('expensePersonnel'),
+          value: _selEmployee != null
+              ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
+              : '',
+          hint: l10n.get('pleaseSelect'),
+          onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
+        ),
+        const SizedBox(height: 16),
+        FormFieldRow(
+          label: l10n.get('expenseDept'),
+          value: _selectedDeptName.isNotEmpty
+              ? '$_selectedDeptId/$_selectedDeptName'
+              : null,
+          hint: l10n.get('pleaseSelect'),
+          onTap: _refDataLoading ? null : () => _showDeptPicker(),
+        ),
+        const SizedBox(height: 16),
+        _label(l10n.get('expenseReason'), required: true),
+        const SizedBox(height: 8),
+        TDTextarea(
+          controller: _purposeController,
+          focusNode: _purposeFocus,
+          hintText: l10n.get('enterExpenseReason'),
+          maxLines: 4,
+          minLines: 1,
+          maxLength: 500,
+          indicator: true,
+          padding: EdgeInsets.zero,
+          bordered: true,
+          backgroundColor: colors.bgPage,
+          onChanged: (_) => _updatePurpose(_purposeController.text),
+        ),
+        const SizedBox(height: 16),
+        FormFieldRow(
+          label: l10n.get('paymentMethod'),
+          value: expense.paymentMethod,
+          hint: l10n.get('pleaseEnter'),
+          onTap: () => _showTextInput(
+            l10n.get('paymentMethod'),
+            (v) => _updatePaymentMethod(v),
+            initialText: expense.paymentMethod,
+          ),
+          onClear: () => _updatePaymentMethod(''),
+        ),
+        const SizedBox(height: 16),
+        FormFieldRow(
+          label: l10n.get('currency'),
+          value: expense.currencyCode.isNotEmpty
+              ? _currencyLabel(expense.currencyCode)
+              : null,
+          hint: l10n.get('selectCurrency'),
+          onTap: () => _showCurrencyPicker(expense.currencyCode),
+          onClear: () => _updateCurrencyCode(''),
+        ),
+        const SizedBox(height: 16),
+        Row(
+          mainAxisAlignment: MainAxisAlignment.spaceBetween,
+          children: [
+            Text(
+              l10n.get('generateVoucher'),
+              style: TextStyle(
+                fontSize: AppFontSizes.subtitle,
+                color: colors.textSecondary,
+              ),
+            ),
+            TDSwitch(
+              isOn: expense.isGenerateVoucher,
+              onChanged: (v) {
+                _setGenerateVoucher(v);
+                return v;
+              },
+            ),
+          ],
+        ),
+        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,
+          onChanged: (_) => _updateRemark(_remarkController.text),
+        ),
+      ],
+    );
+  }
+
+  Widget _buildDetailSection(AppLocalizations l10n, AppColorsExtension colors) {
+    final expense = _expense!;
+    final totalApproved = expense.details.fold<double>(
+      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(),
+      children: [
+        if (expense.details.isEmpty)
+          Padding(
+            padding: const EdgeInsets.symmetric(vertical: 8),
+            child: Text(
+              l10n.get('noDetailHint'),
+              style: TextStyle(
+                fontSize: AppFontSizes.subtitle,
+                color: colors.textPlaceholder,
+              ),
+            ),
+          )
+        else
+          ...expense.details.asMap().entries.map((entry) {
+            final d = entry.value;
+            return GestureDetector(
+              onTap: () => _showAddDetailDialog(editIndex: entry.key),
+              child: Container(
+                margin: const EdgeInsets.symmetric(vertical: 6),
+                padding: const EdgeInsets.all(12),
+                decoration: BoxDecoration(
+                  color: colors.bgPage,
+                  borderRadius: BorderRadius.circular(8),
+                ),
+                child: Row(
+                  children: [
+                    Expanded(
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          Row(
+                            children: [
+                              Expanded(
+                                child: Text(
+                                  d.categoryName.isNotEmpty
+                                      ? '${d.expenseCategory}/${d.categoryName}'
+                                      : d.expenseCategory,
+                                  style: TextStyle(
+                                    fontSize: AppFontSizes.body,
+                                    fontWeight: FontWeight.w500,
+                                    color: colors.textPrimary,
+                                  ),
+                                ),
+                              ),
+                              Column(
+                                crossAxisAlignment: CrossAxisAlignment.end,
+                                children: [
+                                  Text(
+                                    '¥${d.totalAmount.toStringAsFixed(2)}',
+                                    style: TextStyle(
+                                      fontSize: AppFontSizes.body,
+                                      fontWeight: FontWeight.w600,
+                                      color: colors.amountPrimary,
+                                    ),
+                                  ),
+                                  if (d.approvedAmount > 0)
+                                    Text(
+                                      '¥${d.approvedAmount.toStringAsFixed(2)}',
+                                      style: TextStyle(
+                                        fontSize: AppFontSizes.body,
+                                        fontWeight: FontWeight.w600,
+                                        color: colors.success,
+                                      ),
+                                    ),
+                                ],
+                              ),
+                            ],
+                          ),
+                          const SizedBox(height: 2),
+                          Text(
+                            '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}',
+                            style: TextStyle(
+                              fontSize: AppFontSizes.caption,
+                              color: colors.textSecondary,
+                            ),
+                          ),
+                          if (d.taxAmount > 0)
+                            Text(
+                              '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.taxRate > 0)
+                            Text(
+                              '${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%',
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.acctSubjectName.isNotEmpty)
+                            Text(
+                              '${l10n.get('acctSubject')}: ${d.acctSubjectId}/${d.acctSubjectName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.aeNo.isNotEmpty)
+                            Text(
+                              '${l10n.get('expenseApplyNo')}: ${d.aeNo}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.aeDd.isNotEmpty)
+                            Text(
+                              '${l10n.get('applyDate')}: ${d.aeDd.length >= 10 ? d.aeDd.substring(0, 10) : d.aeDd}',
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.projectName.isNotEmpty)
+                            Text(
+                              '${l10n.get('project')}: ${d.projectId}/${d.projectName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.costDeptName.isNotEmpty)
+                            Text(
+                              '${l10n.get('costDept')}: ${d.costDeptId}/${d.costDeptName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.customerVendorName.isNotEmpty)
+                            Text(
+                              '${l10n.get('customerVendor')}: ${d.customerVendorId}/${d.customerVendorName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.sqManName.isNotEmpty)
+                            Text(
+                              '${l10n.get('applicant')}: ${d.sqMan}/${d.sqManName}',
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.bankAccountName.isNotEmpty)
+                            Text(
+                              '${l10n.get('bankAccountName')}: ${d.bankAccountName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.bankName.isNotEmpty)
+                            Text(
+                              '${l10n.get('bankName')}: ${d.bankName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.bankAccount.isNotEmpty)
+                            Text(
+                              '${l10n.get('bankAccount')}: ${d.bankAccount}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.remark.isNotEmpty)
+                            Text(
+                              '${l10n.get('remark')}: ${d.remark}',
+                              maxLines: 2,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                        ],
+                      ),
+                    ),
+                    const SizedBox(width: 8),
+                    GestureDetector(
+                      onTap: () => _removeDetail(entry.key),
+                      child: Icon(
+                        Icons.close,
+                        size: 18,
+                        color: colors.textSecondary,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            );
+          }),
+        const SizedBox(height: 8),
+        Row(
+          mainAxisAlignment: MainAxisAlignment.spaceBetween,
+          children: [
+            Text(
+              l10n.get('totalExpense'),
+              style: TextStyle(
+                fontSize: AppFontSizes.body,
+                fontWeight: FontWeight.w600,
+                color: colors.textPrimary,
+              ),
+            ),
+            Text(
+              '¥${expense.totalAmount.toStringAsFixed(2)}',
+              style: TextStyle(
+                fontSize: AppFontSizes.subtitle,
+                fontWeight: FontWeight.w700,
+                color: colors.amountPrimary,
+              ),
+            ),
+          ],
+        ),
+        const SizedBox(height: 4),
+        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: totalApproved > 0 ? colors.success : colors.textPrimary,
+              ),
+            ),
+          ],
+        ),
+      ],
+    );
+  }
+
+  Future<void> _showAddDetailDialog({int? editIndex}) async {
+    if (_addingDetail) return;
+    _addingDetail = true;
+    try {
+      final l10n = AppLocalizations.of(context);
+      if (_costTypes.isEmpty) {
+        TDToast.showText(l10n.get('noCostTypeData'), context: context);
+        return;
+      }
+      final expense = _expense!;
+      ExpenseDetailInputData? initialData;
+      if (editIndex != null) {
+        final d = expense.details[editIndex];
+        initialData = ExpenseDetailInputData(
+          category: d.expenseCategory,
+          categoryName: d.categoryName,
+          acctSubjectId: d.acctSubjectId,
+          acctSubjectName: d.acctSubjectName,
+          purpose: d.purpose,
+          amount: d.totalAmount,
+          taxRate: d.taxRate,
+          projectId: d.projectId,
+          projectName: d.projectName,
+          costDeptId: d.costDeptId,
+          costDeptName: d.costDeptName,
+          customerVendorId: d.customerVendorId,
+          customerVendorName: d.customerVendorName,
+          approvedAmount: d.approvedAmount,
+          bankName: d.bankName,
+          bankAccountName: d.bankAccountName,
+          bankAccount: d.bankAccount,
+          remark: d.remark,
+          attachmentPaths: d.attachments,
+          sqMan: d.sqMan,
+          sqManName: d.sqManName,
+          aeNo: d.aeNo,
+          aeDd: d.aeDd,
+        );
+      }
+      FocusManager.instance.primaryFocus?.unfocus();
+      final result = await ExpenseDetailDialog.show(
+        context,
+        categories: _dialogCategories,
+        projects: _dialogProjects,
+        costDepts: _dialogCostDepts,
+        customers: _dialogCustomers,
+        employees: _dialogEmployees,
+        l10n: l10n,
+        initialData: initialData,
+        checkAttachHealth: () =>
+            ref.read(expenseApiProvider).checkAttachHealth(),
+      );
+      if (result != null && mounted) {
+        final now = DateTime.now();
+        final detail = ExpenseDetailModel(
+          id: editIndex != null
+              ? expense.details[editIndex].id
+              : now.millisecondsSinceEpoch.toString(),
+          expenseId: '',
+          expenseCategory: result.category,
+          categoryName: result.categoryName,
+          purpose: result.purpose,
+          amount: result.taxRate > 0
+              ? result.amount / (1 + result.taxRate / 100)
+              : result.amount,
+          taxRate: result.taxRate,
+          taxAmount: result.taxRate > 0
+              ? result.amount - result.amount / (1 + result.taxRate / 100)
+              : 0,
+          totalAmount: result.amount,
+          projectId: result.projectId,
+          projectName: result.projectName,
+          costDeptId: result.costDeptId,
+          costDeptName: result.costDeptName,
+          acctSubjectId: result.acctSubjectId,
+          acctSubjectName: result.acctSubjectName,
+          customerVendorId: result.customerVendorId,
+          customerVendorName: result.customerVendorName,
+          approvedAmount: result.approvedAmount,
+          bankName: result.bankName,
+          bankAccountName: result.bankAccountName,
+          bankAccount: result.bankAccount,
+          sqMan: result.sqMan,
+          sqManName: result.sqManName,
+          aeNo: result.aeNo,
+          aeDd: result.aeDd,
+          remark: result.remark,
+          attachments: result.attachmentPaths,
+          createTime: now,
+          updateTime: now,
+        );
+        if (editIndex != null) {
+          _updateDetail(editIndex, detail);
+        } else {
+          _addDetail(detail);
+        }
+      }
+    } finally {
+      _addingDetail = false;
+    }
+  }
+
+  void _showCurrencyPicker(String cur) {
+    if (_currencies.isEmpty) {
+      TDToast.showText(
+        AppLocalizations.of(context).get('noData'),
+        context: context,
+      );
+      return;
+    }
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final codes = _currencies.map((c) => c.curId).toList();
+    final labels = _currencies.map((c) => '${c.curId}/${c.name}').toList();
+    FocusManager.instance.primaryFocus?.unfocus();
+    TDPicker.showMultiPicker(
+      context,
+      title: l10n.get('selectCurrency'),
+      backgroundColor: colors.bgCard,
+      data: [labels],
+      onConfirm: (s) {
+        if (s.isNotEmpty && s[0] is int) {
+          final i = s[0] as int;
+          if (i >= 0 && i < codes.length) {
+            Navigator.of(context).pop();
+            _updateCurrencyCode(codes[i]);
+          }
+        }
+      },
+    );
+  }
+
+  void _showTextInput(
+    String title,
+    Function(String) onConfirm, {
+    String initialText = '',
+  }) {
+    FocusScope.of(context).unfocus();
+    FocusManager.instance.primaryFocus?.unfocus();
+    final l10n = AppLocalizations.of(context);
+    final c = TextEditingController(text: initialText);
+    showGeneralDialog(
+      context: context,
+      pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
+        textEditingController: c,
+        title: title,
+        hintText: l10n.get('pleaseEnter'),
+        leftBtn: TDDialogButtonOptions(
+          title: l10n.get('cancel'),
+          action: () => Navigator.pop(ctx),
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: l10n.get('confirm'),
+          action: () {
+            onConfirm(c.text);
+            Navigator.pop(ctx);
+          },
+        ),
+      ),
+    );
+  }
+
+  Widget _label(String t, {bool required = false}) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    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,
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildBottomButtons(AppLocalizations l10n) {
+    return ActionBar(
+      showLeft: false,
+      showCenter: false,
+      rightLabel: l10n.get('submit'),
+      onRightTap: () async {
+        if (_isSubmitting) return;
+        final err = _validate(l10n);
+        if (err.isNotEmpty) {
+          TDToast.showText(err.first, context: context);
+          return;
+        }
+        FocusScope.of(context).unfocus();
+        _isSubmitting = true;
+        LoadingDialog.show(context, text: l10n.get('submitting'));
+        try {
+          final data = _buildSubmitData();
+          final api = ref.read(expenseApiProvider);
+          await api.submit(data);
+          if (mounted) {
+            LoadingDialog.hide(context);
+            TDToast.showSuccess(
+              l10n.get('submittedAwaitingApproval'),
+              context: context,
+            );
+            GoRouter.of(context).pop(true);
+          }
+        } catch (e) {
+          if (mounted) {
+            LoadingDialog.hide(context);
+            WidgetsBinding.instance.addPostFrameCallback((_) {
+              if (mounted) _showSubmitError(e, l10n);
+            });
+          }
+        } finally {
+          _isSubmitting = false;
+        }
+      },
+    );
+  }
+
+  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;
+  }
+
+  List<String> _validate(AppLocalizations l10n) {
+    final e = <String>[];
+    if (_purposeController.text.trim().isEmpty) {
+      e.add(l10n.get('enterExpenseReason'));
+    }
+    if (_expense!.details.isEmpty) {
+      e.add(l10n.get('addAtLeastOneDetail'));
+    }
+    return e;
+  }
+
+  bool _hasUnsaved() =>
+      _purposeController.text.isNotEmpty ||
+      (_expense?.paymentMethod.isNotEmpty ?? false) ||
+      (_expense?.currencyCode.isNotEmpty ?? false) ||
+      _remarkController.text.isNotEmpty ||
+      (_expense?.details.isNotEmpty ?? false);
+
+  void _doPop(AppLocalizations l10n) {
+    if (_hasUnsaved()) {
+      _showConfirmDialog(
+        l10n.get('confirmExit'),
+        l10n.get('unsavedContentWarning'),
+        l10n.get('continueEditing'),
+        l10n.get('discardAndExit'),
+        () {
+          if (!mounted) return;
+          _forcePop();
+        },
+      );
+    } else {
+      _forcePop();
+    }
+  }
+
+  void _forcePop() {
+    FocusManager.instance.primaryFocus?.unfocus();
+    final router = GoRouter.of(context);
+    if (router.canPop()) {
+      router.pop();
+    } else {
+      SystemNavigator.pop();
+    }
+  }
+
+  void _showConfirmDialog(
+    String title,
+    String content,
+    String leftText,
+    String rightText,
+    VoidCallback onConfirm,
+  ) {
+    FocusScope.of(context).unfocus();
+    FocusManager.instance.primaryFocus?.unfocus();
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    showDialog(
+      context: context,
+      useRootNavigator: true,
+      builder: (ctx) => TDAlertDialog(
+        title: title,
+        content: content,
+        buttonStyle: TDDialogButtonStyle.text,
+        leftBtn: TDDialogButtonOptions(
+          title: leftText,
+          titleColor: colors.primary,
+          action: () => Navigator.pop(ctx),
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: rightText,
+          titleColor: colors.danger,
+          action: () {
+            Navigator.pop(ctx);
+            onConfirm();
+          },
+        ),
+      ),
+    );
+  }
+
+  Widget _buildPageFooter() {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    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,
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  String _today() {
+    final n = DateTime.now();
+    return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
+  }
+}

+ 31 - 2
lib/features/expense/expense_list_page.dart

@@ -195,7 +195,9 @@ class _ExpenseListContent extends ConsumerWidget {
         final applicant = item.deptName.isNotEmpty
             ? '${item.applicantName} · ${item.deptName}'
             : item.applicantName;
-        final card = ListCard(cardNo: item.expenseNo, amount: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime), onTap: () { context.push('/expense/detail/${item.expenseNo}'); });
+        final card = ListCard(cardNo: item.expenseNo, amount: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
+          statusTag: _buildStatusTags(item, l10n, context),
+          onTap: () { context.push('/expense/detail/${item.expenseNo}'); });
         return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
       });
     }
@@ -208,9 +210,36 @@ class _ExpenseListContent extends ConsumerWidget {
       final applicant = item.deptName.isNotEmpty
           ? '${item.applicantName} · ${item.deptName}'
           : item.applicantName;
-      final card = ListCard(cardNo: item.expenseNo, amount: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime), onTap: () { context.push('/expense/detail/${item.expenseNo}'); });
+      final card = ListCard(cardNo: item.expenseNo, amount: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
+        statusTag: _buildStatusTags(item, l10n, context),
+        onTap: () { context.push('/expense/detail/${item.expenseNo}'); });
       return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
     });
   }
 
+  /// 构建状态 tag,放在卡片底部日期旁边
+  Widget _buildStatusTags(ExpenseModel item, AppLocalizations l10n, BuildContext context) {
+    final tags = <Widget>[];
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final isDark = Theme.of(context).brightness == Brightness.dark;
+    final tagBg = isDark ? colors.bgSecondaryContainer : Colors.grey.shade200;
+    final tagFg = isDark ? colors.textSecondary : Colors.grey.shade600;
+    if (item.isTransferred == 1) {
+      tags.add(Container(
+        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+        decoration: BoxDecoration(color: tagBg, borderRadius: BorderRadius.circular(4)),
+        child: Text(l10n.get('statusTransferred'), style: TextStyle(fontSize: 11, color: tagFg)),
+      ));
+      tags.add(const SizedBox(width: 6));
+    }
+    if (item.isClosed == 1) {
+      tags.add(Container(
+        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+        decoration: BoxDecoration(color: tagBg, borderRadius: BorderRadius.circular(4)),
+        child: Text(l10n.get('statusClosed'), style: TextStyle(fontSize: 11, color: tagFg)),
+      ));
+    }
+    if (tags.isEmpty) return const SizedBox.shrink();
+    return Row(mainAxisSize: MainAxisSize.min, children: tags);
+  }
 }

+ 13 - 0
lib/features/expense/expense_model.dart

@@ -31,6 +31,8 @@ class ExpenseModel {
   final DateTime createTime;
   final DateTime updateTime;
   final bool isDeleted;
+  final int isTransferred;
+  final int isClosed;
 
   // ── 瞬态字段(API 从 ERP 实时查询,非存储) ──
   final String currentApproverId;
@@ -71,6 +73,8 @@ class ExpenseModel {
     required this.createTime,
     required this.updateTime,
     this.isDeleted = false,
+    this.isTransferred = 0,
+    this.isClosed = 0,
     this.currentApproverId = '',
     this.approvalChain = const [],
     this.approvalRecords = const [],
@@ -117,6 +121,8 @@ class ExpenseModel {
           ? DateTime.parse(json['updateTime'] as String)
           : DateTime.now(),
       isDeleted: json['isDeleted'] as bool? ?? false,
+      isTransferred: json['isTransferred'] as int? ?? 0,
+      isClosed: json['isClosed'] as int? ?? 0,
       currentApproverId: json['currentApproverId'] as String? ?? '',
       approvalChain:
           (json['approvalChain'] as List<dynamic>?)
@@ -173,6 +179,8 @@ class ExpenseModel {
     'createTime': createTime.toIso8601String(),
     'updateTime': updateTime.toIso8601String(),
     'isDeleted': isDeleted,
+    'isTransferred': isTransferred,
+    'isClosed': isClosed,
     'currentApproverId': currentApproverId,
     'approvalChain': approvalChain,
     'approvalRecords': approvalRecords.map((r) => r.toJson()).toList(),
@@ -210,6 +218,8 @@ class ExpenseModel {
     DateTime? createTime,
     DateTime? updateTime,
     bool? isDeleted,
+    int? isTransferred,
+    int? isClosed,
     String? currentApproverId,
     List<String>? approvalChain,
     List<ApprovalRecord>? approvalRecords,
@@ -230,6 +240,7 @@ class ExpenseModel {
       approvedAmount: approvedAmount ?? this.approvedAmount,
       totalAmount: totalAmount ?? this.totalAmount,
       purpose: purpose ?? this.purpose,
+      remark: remark ?? this.remark,
       paymentMethod: paymentMethod ?? this.paymentMethod,
       isInvoiceVerified: isInvoiceVerified ?? this.isInvoiceVerified,
       isTaxIdMatched: isTaxIdMatched ?? this.isTaxIdMatched,
@@ -245,6 +256,8 @@ class ExpenseModel {
       createTime: createTime ?? this.createTime,
       updateTime: updateTime ?? this.updateTime,
       isDeleted: isDeleted ?? this.isDeleted,
+      isTransferred: isTransferred ?? this.isTransferred,
+      isClosed: isClosed ?? this.isClosed,
       currentApproverId: currentApproverId ?? this.currentApproverId,
       approvalChain: approvalChain ?? this.approvalChain,
       approvalRecords: approvalRecords ?? this.approvalRecords,

+ 39 - 0
lib/features/expense_apply/expense_apply_api.dart

@@ -341,4 +341,43 @@ class ExpenseApplyApi {
     );
     return response.data!;
   }
+
+  /// 获取单据状态
+  Future<Map<String, dynamic>> getBillStatus(String billNo) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetBillStatus',
+      queryParameters: {'billId': 'AE', 'billNo': billNo},
+    );
+    return response.data!;
+  }
+
+  /// 员工查询
+  Future<List<EmployeeItem>> getEmployees({
+    String keyword = '',
+    String salNo = '',
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetEmployees',
+      queryParameters: {
+        'keyword': keyword,
+        'salNo': salNo,
+        'page': 1,
+        'size': 100,
+      },
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list
+        .map((e) => EmployeeItem.fromJson(e as Map<String, dynamic>))
+        .toList();
+  }
+}
+
+class EmployeeItem {
+  final String salNo;
+  final String name;
+  const EmployeeItem({required this.salNo, required this.name});
+  factory EmployeeItem.fromJson(Map<String, dynamic> json) => EmployeeItem(
+    salNo: json['salNo'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+  );
 }

+ 56 - 15
lib/features/expense_apply/expense_apply_create_page.dart

@@ -33,7 +33,8 @@ class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
       _ExpenseApplyCreatePageState();
 }
 
-class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage> {
+class _ExpenseApplyCreatePageState
+    extends ConsumerState<ExpenseApplyCreatePage> {
   static const _draftKey = 'expense_apply';
 
   // ── 基本信息 ──
@@ -62,6 +63,8 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
   List<CostTypeItem> _costTypes = [];
   List<ProjectCodeItem> _projects = [];
   List<DepartmentItem> _departments = [];
+  List<EmployeeItem> _employees = [];
+  EmployeeItem? _selEmployee;
   bool _firstBuild = true;
   bool _refDataLoading = true;
   bool _addingDetail = false;
@@ -127,6 +130,7 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
         api.getProjectCodes(),
         api.getDepartments(),
         api.getAcctSubjects(),
+        api.getEmployees(),
       ]);
       if (!mounted) return;
       setState(() {
@@ -134,8 +138,10 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
         _projects = results[1] as List<ProjectCodeItem>;
         _departments = results[2] as List<DepartmentItem>;
         _acctTree = _convertAcctTree(results[3]);
+        _employees = results[4] as List<EmployeeItem>;
         _refDataLoading = false;
         _autoSelectDept();
+        _autoSelectEmployee();
       });
       completer.complete();
     } catch (_) {
@@ -162,6 +168,36 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
     }
   }
 
+  void _autoSelectEmployee() {
+    if (_selEmployee != null) return;
+    final usr = HostAppChannel.usr;
+    if (usr.isEmpty || _employees.isEmpty) return;
+    final match = _employees.where((e) => e.salNo == usr);
+    if (match.isNotEmpty) {
+      setState(() => _selEmployee = match.first);
+    }
+  }
+
+  void _showEmployeePicker() {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
+    TDPicker.showMultiPicker(
+      context,
+      title: AppLocalizations.of(context).get('applicant'),
+      backgroundColor: colors.bgCard,
+      data: [labels],
+      onConfirm: (selected) {
+        if (selected.isNotEmpty && selected[0] is int) {
+          final idx = selected[0] as int;
+          if (idx >= 0 && idx < labels.length) {
+            Navigator.of(context).pop();
+            setState(() => _selEmployee = _employees[idx]);
+          }
+        }
+      },
+    );
+  }
+
   void _ensureVisible(FocusNode node) {
     if (!node.hasFocus) return;
     WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -198,7 +234,9 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
       return const SkeletonFormPage();
     }
 
-    Future.microtask(() => ref.read(pageBackProvider.notifier).state = () => _doPop());
+    Future.microtask(
+      () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
+    );
 
     return FutureBuilder<bool>(
       future: _draftFuture,
@@ -212,7 +250,9 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
           });
         }
 
-        Future.microtask(() => ref.read(pageBackProvider.notifier).state = () => _doPop());
+        Future.microtask(
+          () => ref.read(pageBackProvider.notifier).state = () => _doPop(),
+        );
         return PopScope(
           canPop: false,
           onPopInvokedWithResult: (didPop, _) {
@@ -279,7 +319,7 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
               acctSubjectId: m['acctSubjectId'] as String? ?? '',
               acctSubjectName: m['acctSubjectName'] as String? ?? '',
               purpose: m['purpose'] as String? ?? '',
-              projectId: m['projectId'] as int? ?? 0,
+              projectId: m['projectId'] as String? ?? '',
               projectName: m['projectName'] as String? ?? '',
               costDeptId: m['costDeptId'] as String? ?? '',
               costDeptName: m['costDeptName'] as String? ?? '',
@@ -379,12 +419,11 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
         const SizedBox(height: 16),
         FormFieldRow(
           label: l10n.get('applicant'),
-          value:
-              HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
-              ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
-              : '--',
-          readOnly: true,
-          showArrow: false,
+          value: _selEmployee != null
+              ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
+              : '',
+          hint: l10n.get('pleaseSelect'),
+          onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
@@ -618,7 +657,8 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
                               ),
                             ),
                           ],
-                          if (d.projectId > 0 && d.projectName.isNotEmpty) ...[
+                          if (d.projectId.isNotEmpty &&
+                              d.projectName.isNotEmpty) ...[
                             const SizedBox(height: 4),
                             Text(
                               '${l10n.get('project')}: ${d.projectId}/${d.projectName}',
@@ -1014,7 +1054,8 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
   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;
+      if (e.error is NetworkException)
+        return (e.error as NetworkException).message;
     }
     return null;
   }
@@ -1048,13 +1089,13 @@ class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
         final d = e.value;
         return {
           'ITM': i + 1,
-          'SQ_MAN': HostAppChannel.usr,
+          'SQ_MAN': _selEmployee?.salNo ?? '',
           'TYPE_NO': d.category,
           'AMTN_YJ': d.estimatedAmount,
           'ACC_NO': d.acctSubjectId,
           //'ACC_NAME': d.acctSubjectName,
           'DEP': d.costDeptId,
-          'OBJ_NO': d.projectId > 0 ? d.projectId.toString() : '',
+          'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '',
           'START_DD': d.startDate,
           'END_DD': d.endDate,
           'REM': d.remark.isNotEmpty ? d.remark : d.purpose,
@@ -1240,7 +1281,7 @@ class _DetailItem {
   final String acctSubjectId;
   final String acctSubjectName;
   final String purpose;
-  final int projectId;
+  final String projectId;
   final String projectName;
   final String costDeptId;
   final String costDeptName;

+ 90 - 20
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -3,6 +3,7 @@ import 'dart:io';
 import 'package:flutter/material.dart';
 import 'package:path_provider/path_provider.dart';
 import 'package:open_filex/open_filex.dart';
+import 'package:go_router/go_router.dart';
 import 'widgets/attachment_preview_page.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -11,6 +12,8 @@ import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/app_skeletons.dart';
+import '../../shared/widgets/action_bar.dart';
+import '../../shared/widgets/status_banner.dart';
 import 'expense_apply_model.dart';
 import 'widgets/expense_apply_detail_view_dialog.dart';
 import '../../core/i18n/app_localizations.dart';
@@ -35,6 +38,9 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
   ExpenseApplyModel? _data;
   List<BillAttachment> _attachments = [];
   bool _attachAvailable = false;
+  Map<String, dynamic>? _billStatus;
+  bool _statusLoading = false;
+  bool _canEdit = false;
 
   @override
   void initState() {
@@ -83,6 +89,21 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
           _loading = false;
         });
       }
+
+      // 单据状态(非关键,尽力加载)
+      setState(() => _statusLoading = true);
+      try {
+        final status = await api.getBillStatus(widget.billNo);
+        if (mounted) {
+          setState(() {
+            _billStatus = status;
+            _canEdit = status['canEdit'] == true;
+            _statusLoading = false;
+          });
+        }
+      } catch (_) {
+        if (mounted) setState(() => _statusLoading = false);
+      }
     } catch (e) {
       if (mounted) {
         setState(() { _error = e.toString(); _loading = false; });
@@ -127,25 +148,74 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
 
     final app = _data!;
 
-    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),
-          ],
+    return Column(
+      children: [
+        Expanded(
+          child: SingleChildScrollView(
+            physics: const AlwaysScrollableScrollPhysics(),
+            padding: const EdgeInsets.all(16),
+            child: Column(
+              children: [
+                if (_statusLoading && _billStatus == null) ...[
+                  const Padding(
+                    padding: EdgeInsets.symmetric(vertical: 8),
+                    child: Center(child: TDLoading(size: TDLoadingSize.small, icon: TDLoadingIcon.activity)),
+                  ),
+                  const SizedBox(height: 8),
+                ],
+                if (_billStatus != null) ...[
+                  _buildStatusBadge(l10n),
+                  const SizedBox(height: 16),
+                ],
+                _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),
+              ],
+            ),
+          ),
         ),
-      ),
+        if (_canEdit)
+          ActionBar(
+            showLeft: false,
+            showCenter: false,
+            rightLabel: l10n.get('editApply'),
+            onRightTap: () async {
+              final result = await GoRouter.of(context).push(
+                '/expense-apply/edit/${widget.billNo}',
+              );
+              if (result == true && mounted) _loadData();
+            },
+          ),
+      ],
     );
   }
 
+  // ═══ 状态徽章 ═══
+  Widget _buildStatusBadge(AppLocalizations l10n) {
+    final isTransferred = _billStatus?['isTransferred'] == true;
+    final approvalStatus = _billStatus?['approvalStatus'] as String?;
+
+    if (isTransferred) {
+      return StatusBanner(icon: Icons.swap_horiz, statusText: l10n.get('statusTransferred'), subText: '', color: Colors.blueGrey);
+    }
+    switch (approvalStatus) {
+      case 'SHCOUNT4':
+        return StatusBanner(icon: Icons.check_circle_outline, statusText: l10n.get('statusApproved'), subText: '', color: Colors.green);
+      case 'SHCOUNT5':
+        return StatusBanner(icon: Icons.cancel_outlined, statusText: l10n.get('statusFinalRejected'), subText: '', color: Colors.red);
+      case 'SHCOUNT1':
+        return StatusBanner(icon: Icons.hourglass_empty, statusText: l10n.get('statusPendingReview'), subText: '', color: Colors.blue);
+      case 'SHCOUNT2':
+        return StatusBanner(icon: Icons.block_outlined, statusText: l10n.get('statusReviewRejected'), subText: '', color: Colors.deepOrange);
+      default:
+        return const SizedBox.shrink();
+    }
+  }
+
   // ═══ 基本信息 ═══
   Widget _buildBasicInfoSection(
     ExpenseApplyModel app,
@@ -160,9 +230,9 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('applicant'), value: app.applicantId.isNotEmpty ? '${app.applicantId}/${app.applicantName}' : app.applicantName, readOnly: true, showArrow: false),
+        FormFieldRow(label: l10n.get('applicant'), value: app.applicantName.isNotEmpty ? '${app.applicantId}/${app.applicantName}' : '-', readOnly: true, showArrow: false),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('department'), value: app.deptId.isNotEmpty ? '${app.deptId}/${app.deptName}' : app.deptName, readOnly: true, showArrow: false),
+        FormFieldRow(label: l10n.get('department'), value: app.deptName.isNotEmpty ? '${app.deptId}/${app.deptName}' : '-', readOnly: true, showArrow: false),
         const SizedBox(height: 16),
         SizedBox(
           height: 24,
@@ -172,7 +242,7 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
           ]),
         ),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('applyReason'), value: app.purpose, readOnly: true, showArrow: false, bold: true),
+        FormFieldRow(label: l10n.get('applyReason'), value: app.purpose.isNotEmpty ? app.purpose : '-', readOnly: true, showArrow: false, bold: true),
         const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
       ],
@@ -270,7 +340,7 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
                         if (d.bxNo.isNotEmpty) ...[
                           const SizedBox(height: 4),
                           Text(
-                            '${l10n.get('expenseNo')}: $d.bxNo',
+                            '${l10n.get('expenseNo')}: ${d.bxNo}',
                             maxLines: 1,
                             overflow: TextOverflow.ellipsis,
                             style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
@@ -297,7 +367,7 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
           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)}',
+            Text('¥${app.details.fold<double>(0, (sum, d) => sum + d.estimatedAmount).toStringAsFixed(2)}',
                 style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
           ]),
         ],

+ 1043 - 0
lib/features/expense_apply/expense_apply_edit_page.dart

@@ -0,0 +1,1043 @@
+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 '../../core/data/mock_api_data.dart';
+import 'expense_apply_api.dart';
+import 'widgets/expense_apply_detail_dialog.dart';
+
+class ExpenseApplyEditPage extends ConsumerStatefulWidget {
+  final String billNo;
+  const ExpenseApplyEditPage({super.key, required this.billNo});
+
+  @override
+  ConsumerState<ExpenseApplyEditPage> createState() =>
+      _ExpenseApplyEditPageState();
+}
+
+class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
+  // ── 原单数据 ──
+  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 加载) ──
+  List<CostTypeItem> _costTypes = [];
+  List<ProjectCodeItem> _projects = [];
+  List<DepartmentItem> _departments = [];
+  List<EmployeeItem> _employees = [];
+  EmployeeItem? _selEmployee;
+  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));
+    _costTypes = [];
+    _projects = [];
+    _departments = [];
+    _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<void>? _refDataFuture;
+
+  Future<void> _loadRefData({bool showLoading = false}) async {
+    if (_refDataFuture != null) return _refDataFuture!;
+    final completer = Completer<void>();
+    _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.getCostTypes(),
+        api.getProjectCodes(),
+        api.getDepartments(),
+        api.getAcctSubjects(),
+        api.getEmployees(),
+      ]);
+      if (!mounted) return;
+      setState(() {
+        _costTypes = results[0] as List<CostTypeItem>;
+        _projects = results[1] as List<ProjectCodeItem>;
+        _departments = results[2] as List<DepartmentItem>;
+        _acctTree = _convertAcctTree(results[3]);
+        _employees = results[4] as List<EmployeeItem>;
+        _refDataLoading = false;
+        _autoSelectEmployee();
+      });
+      completer.complete();
+    } catch (_) {
+      if (!mounted) {
+        completer.complete();
+        return;
+      }
+      setState(() => _refDataLoading = false);
+      completer.complete();
+    } finally {
+      if (showLoading && mounted) LoadingDialog.hide(context);
+      _refDataFuture = null;
+    }
+  }
+
+  void _autoSelectEmployee() {
+    if (_selEmployee != null) return;
+    final usr = HostAppChannel.usr;
+    if (usr.isEmpty || _employees.isEmpty) return;
+    final match = _employees.where((e) => e.salNo == usr);
+    if (match.isNotEmpty) {
+      setState(() => _selEmployee = match.first);
+    }
+  }
+
+  void _showEmployeePicker() {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
+    TDPicker.showMultiPicker(
+      context,
+      title: AppLocalizations.of(context).get('applicant'),
+      backgroundColor: colors.bgCard,
+      data: [labels],
+      onConfirm: (selected) {
+        if (selected.isNotEmpty && selected[0] is int) {
+          final idx = selected[0] as int;
+          if (idx >= 0 && idx < labels.length) {
+            Navigator.of(context).pop();
+            setState(() => _selEmployee = _employees[idx]);
+          }
+        }
+      },
+    );
+  }
+
+  Future<void> _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,
+          ));
+        }
+
+        _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<AppColorsExtension>()!;
+
+    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<AppColorsExtension>()!;
+    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('applicant'),
+          value: _selEmployee != null
+              ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
+              : '',
+          hint: l10n.get('pleaseSelect'),
+          onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
+        ),
+        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<AppColorsExtension>()!;
+    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<AppColorsExtension>()!;
+    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}',
+                                  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(
+                              '${l10n.get('acctSubject')}: ${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(
+                              '${l10n.get('project')}: ${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(
+                              '${l10n.get('costDept')}: ${d.costDeptId}/${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<void> _showDetailDialog({int? editIndex}) async {
+    if (_addingDetail) return;
+    _addingDetail = true;
+    try {
+      final l10n = AppLocalizations.of(context);
+      if (_costTypes.isEmpty) {
+        await _loadRefData(showLoading: true);
+        if (!mounted) return;
+        if (_costTypes.isEmpty) {
+          TDToast.showText(l10n.get('noCostTypeData'), context: context);
+          return;
+        }
+      }
+      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,
+        );
+      }
+      FocusManager.instance.primaryFocus?.unfocus();
+      final result = await ExpenseApplyDetailDialog.show(
+        // ignore: use_build_context_synchronously
+        context,
+        categories: _dialogCategories,
+        projects: _dialogProjects,
+        costDepts: _dialogCostDepts,
+        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,
+          );
+          if (editIndex != null) {
+            _details[editIndex] = item;
+          } else {
+            _details.add(item);
+          }
+        });
+      }
+    } finally {
+      _addingDetail = false;
+    }
+  }
+
+  // ═══ 类型转换 ═══
+
+  List<Map<String, dynamic>> _convertAcctTree(dynamic tree) {
+    if (tree is! List) return [];
+    return tree.map<Map<String, dynamic>>((e) {
+      final map = Map<String, dynamic>.from(e as Map);
+      if (map['children'] != null) {
+        map['children'] = _convertAcctTree(map['children']);
+      }
+      return map;
+    }).toList();
+  }
+
+  List<CostCategory> get _dialogCategories => _costTypes
+      .map(
+        (c) => CostCategory(
+          code: c.typeNo,
+          nameKey: c.typeName,
+          acctSubjectId: c.accNo,
+          acctSubjectName: c.accName,
+        ),
+      )
+      .toList();
+
+  List<Project> get _dialogProjects => _projects
+      .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
+      .toList();
+
+  List<CostDept> get _dialogCostDepts =>
+      _departments.map((d) => CostDept(id: d.dep, name: d.name)).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('submittedAwaitingApproval'),
+              context: context,
+            );
+            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<String, dynamic> _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 i = e.key;
+        final d = e.value;
+        return {
+          'ITM': i + 1,
+          'SQ_MAN': _selEmployee?.salNo ?? '',
+          '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,
+        };
+      }).toList(),
+    };
+  }
+
+  List<String> _validate(AppLocalizations l10n) {
+    final e = <String>[];
+    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();
+    }
+  }
+
+  void _showDeptPicker() {
+    if (_departments.isEmpty) {
+      TDToast.showText(
+        AppLocalizations.of(context).get('noData'),
+        context: context,
+      );
+      return;
+    }
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
+    FocusManager.instance.primaryFocus?.unfocus();
+    TDPicker.showMultiPicker(
+      context,
+      title: l10n.get('applyDept'),
+      backgroundColor: colors.bgCard,
+      data: [labels],
+      onConfirm: (selected) {
+        if (selected.isNotEmpty && selected[0] is int) {
+          final idx = selected[0] as int;
+          if (idx >= 0 && idx < labels.length) {
+            Navigator.of(context).pop();
+            setState(() {
+              _selectedDeptId = _departments[idx].dep;
+              _selectedDeptName = _departments[idx].name;
+            });
+          }
+        }
+      },
+    );
+  }
+
+  Widget _buildPageFooter() {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    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<AppColorsExtension>()!;
+    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;
+  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,
+  });
+}

+ 23 - 2
lib/features/expense_apply/expense_apply_list_page.dart

@@ -184,6 +184,9 @@ class _ExpenseApplyListContent extends ConsumerWidget {
   Widget _buildContent(AsyncValue<List<ExpenseApplyModel>> itemsAsync, BuildContext context, WidgetRef ref) {
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final isDark = Theme.of(context).brightness == Brightness.dark;
+    final tagBg = isDark ? colors.bgSecondaryContainer : Colors.grey.shade200;
+    final tagFg = isDark ? colors.textSecondary : Colors.grey.shade600;
     if (itemsAsync.isReloading) {
       final oldItems = itemsAsync.valueOrNull ?? [];
       if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenseApplications'))]);
@@ -194,7 +197,16 @@ class _ExpenseApplyListContent extends ConsumerWidget {
           applicant: m.deptName.isNotEmpty ? '${m.applicantName} · ${m.deptName}' : m.applicantName,
           description: m.remark.isNotEmpty ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}' : m.purpose,
           date: du.DateUtils.formatDate(m.createTime),
-          statusTag: _buildUrgencyChip(m.urgency, l10n, colors),
+          statusTag: Row(mainAxisSize: MainAxisSize.min, children: [
+            if (m.isTransferred == 1)
+              Container(
+                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+                decoration: BoxDecoration(color: tagBg, borderRadius: BorderRadius.circular(4)),
+                child: Text(l10n.get('statusTransferred'), style: TextStyle(fontSize: 11, color: tagFg)),
+              ),
+            if (m.isTransferred == 1) const SizedBox(width: 6),
+            _buildUrgencyChip(m.urgency, l10n, colors),
+          ]),
           onTap: () { context.push('/expense-apply/detail/${m.expenseApplyNo}'); },
         );
         return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
@@ -211,7 +223,16 @@ class _ExpenseApplyListContent extends ConsumerWidget {
         applicant: m.deptName.isNotEmpty ? '${m.applicantName} · ${m.deptName}' : m.applicantName,
         description: m.remark.isNotEmpty ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}' : m.purpose,
         date: du.DateUtils.formatDate(m.createTime),
-        statusTag: _buildUrgencyChip(m.urgency, l10n, colors),
+        statusTag: Row(mainAxisSize: MainAxisSize.min, children: [
+          if (m.isTransferred == 1)
+            Container(
+              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+              decoration: BoxDecoration(color: tagBg, borderRadius: BorderRadius.circular(4)),
+              child: Text(l10n.get('statusTransferred'), style: TextStyle(fontSize: 11, color: tagFg)),
+            ),
+          if (m.isTransferred == 1) const SizedBox(width: 6),
+          _buildUrgencyChip(m.urgency, l10n, colors),
+        ]),
         onTap: () { context.push('/expense-apply/detail/${m.expenseApplyNo}'); },
       );
       return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);

+ 6 - 0
lib/features/expense_apply/expense_apply_model.dart

@@ -25,6 +25,7 @@ class ExpenseApplyModel {
   final DateTime createTime;
   final DateTime updateTime;
   final bool isDeleted;
+  final int isTransferred;
 
   // ── 瞬态字段(API 从 ERP 实时查询,非存储) ──
   final String currentApproverId;
@@ -59,6 +60,7 @@ class ExpenseApplyModel {
     required this.createTime,
     required this.updateTime,
     this.isDeleted = false,
+    this.isTransferred = 0,
     this.currentApproverId = '',
     this.approvalChain = const [],
     this.approvalRecords = const [],
@@ -101,6 +103,7 @@ class ExpenseApplyModel {
           ? DateTime.parse(json['updateTime'] as String)
           : DateTime.now(),
       isDeleted: json['isDeleted'] as bool? ?? false,
+      isTransferred: json['isTransferred'] as int? ?? 0,
       currentApproverId: json['currentApproverId'] as String? ?? '',
       approvalChain:
           (json['approvalChain'] as List<dynamic>?)
@@ -152,6 +155,7 @@ class ExpenseApplyModel {
     'createTime': createTime.toIso8601String(),
     'updateTime': updateTime.toIso8601String(),
     'isDeleted': isDeleted,
+    'isTransferred': isTransferred,
     'currentApproverId': currentApproverId,
     'approvalChain': approvalChain,
     'approvalRecords': approvalRecords.map((r) => r.toJson()).toList(),
@@ -183,6 +187,7 @@ class ExpenseApplyModel {
     DateTime? createTime,
     DateTime? updateTime,
     bool? isDeleted,
+    int? isTransferred,
     String? currentApproverId,
     List<String>? approvalChain,
     List<ApprovalRecord>? approvalRecords,
@@ -213,6 +218,7 @@ class ExpenseApplyModel {
       createTime: createTime ?? this.createTime,
       updateTime: updateTime ?? this.updateTime,
       isDeleted: isDeleted ?? this.isDeleted,
+      isTransferred: isTransferred ?? this.isTransferred,
       currentApproverId: currentApproverId ?? this.currentApproverId,
       approvalChain: approvalChain ?? this.approvalChain,
       approvalRecords: approvalRecords ?? this.approvalRecords,

+ 6 - 5
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -15,7 +15,7 @@ class ExpenseDetailData {
   final String acctSubjectId;
   final String acctSubjectName;
   final String purpose;
-  final int projectId;
+  final String projectId;
   final String projectName;
   final String costDeptId;
   final String costDeptName;
@@ -159,9 +159,9 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
         acctSubjectName: d.acctSubjectName,
       );
     }
-    _selProject = (d != null && d.projectId != 0 && _projects.isNotEmpty)
+    _selProject = (d != null && d.projectId.isNotEmpty && _projects.isNotEmpty)
         ? _projects.firstWhere(
-            (p) => p.id == d.projectId,
+            (p) => p.id.toString() == d.projectId,
             orElse: () => _projects.first,
           )
         : null;
@@ -247,7 +247,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
         acctSubjectId: _selCat.acctSubjectId,
         acctSubjectName: _selCat.acctSubjectName,
         purpose: _purposeCtrl.text.trim(),
-        projectId: _selProject?.id ?? 0,
+        projectId: _selProject != null ? _selProject!.id.toString() : '',
         projectName: _selProject?.name ?? '',
         costDeptId: _selDept?.id ?? '',
         costDeptName: _selDept?.name ?? '',
@@ -487,13 +487,14 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
           context,
           title: _l10n.get('acctSubject'),
           data: widget.acctTree as List<Map>,
+          theme: 'step',
             onChange: (List<MultiCascaderListModel> selected) {
               setState(() {
                 _selCat = CostCategory(
                   code: _selCat.code,
                   nameKey: _selCat.nameKey,
                   acctSubjectId: (selected.last.value ?? '').replaceFirst('self:', ''),
-                  acctSubjectName: (selected.last.label ?? '').replaceFirst(RegExp(r'^> '), ''),
+                  acctSubjectName: (selected.last.label ?? '').replaceFirst(RegExp(r'^> '), '').replaceFirst('${selected.last.value ?? ''}/', ''),
                 );
               });
             },

+ 3 - 1
lib/shared/widgets/app_scaffold.dart

@@ -36,9 +36,11 @@ class AppScaffold extends ConsumerWidget {
     if (path.startsWith('/expense/list')) return l10n.get('expenseList');
     if (path.startsWith('/expense/import-apply')) return l10n.get('importExpenseApply');
     if (path.startsWith('/expense/detail')) return l10n.get('expenseDetail');
-    if (path.startsWith('/expense/create') || path.startsWith('/expense/edit')) return l10n.get('expenseApply');
+    if (path.startsWith('/expense/edit')) return l10n.get('editExpense');
+    if (path.startsWith('/expense/create')) return l10n.get('expenseApply');
     if (path.startsWith('/expense-apply/list')) return l10n.get('expenseApplyList');
     if (path.startsWith('/expense-apply/detail')) return l10n.get('expenseApplyDetail');
+    if (path.startsWith('/expense-apply/edit')) return l10n.get('editApply');
     if (path.startsWith('/expense-apply/create')) return l10n.get('expenseApplyRequest');
     if (path.startsWith('/overtime/list')) return l10n.get('overtimeList');
     if (path.startsWith('/overtime/detail')) return l10n.get('overtimeDetail');