chengc преди 1 седмица
родител
ревизия
4418830834

+ 13 - 3
assets/i18n/en.json

@@ -285,6 +285,7 @@
     "feeReason": "Fee Reason",
     "enterFeeReason": "Enter fee reason",
     "applyReason": "Application Reason",
+    "expenseApplyReason": "Purpose",
     "enterApplyReason": "Enter application reason",
     "validUntil": "Valid Until",
     "selectExpiryDate": "Select expiry date",
@@ -702,7 +703,7 @@
     "chartTitle1": "Reimbursement vs Approved Amount (Last 12 Months)",
     "chartTitle2": "Overtime Hours Trend (Last 12 Months)",
     "chartTitle3": "Vehicle Trips vs Total Cost (Last 12 Months)",
-    "chartTitle4": "Applied vs Approved Amount (Last 12 Months)",
+    "chartTitle4": "Monthly Trend",
     "chartTitle5": "Visits vs Avg Rating (Last 12 Months)",
     "chartDesc1": "Dual line comparison chart",
     "chartDesc2": "Bar trend chart",
@@ -716,11 +717,14 @@
     "chartDeptOutingCompare": "Dept Visits vs Avg Rating",
     "statTotalApproved": "Total Reimbursed",
     "statMonthCount": "This Month Count",
+    "statTotalCount": "Total Bills",
     "statPendingApprove": "Pending Approval",
     "statPendingPayment": "Pending Payment",
     "statTotalApply": "Total Applied",
     "statApprovedCount": "Approved Count",
     "statApprovedAmount": "Approved Amount",
+    "statTransferredCount": "Transferred",
+    "statTransferredAmount": "Transferred Amount",
     "statMonthHours": "This Month Net Hours",
     "statMonthTrips": "This Month Trips",
     "statTotalMileage": "Total Mileage",
@@ -742,9 +746,10 @@
     "totalMileage": "Total Mileage",
     "totalCost": "Total Cost",
     "notReturned": "Not Returned",
-    "yearTotalApp": "Total Applied This Year",
+    "yearTotalApp": "Total Amount",
     "approvedCount": "Approved Count",
-    "approvedAmount": "Approved Amount",
+    "approvedAmount": "Transferred Amount",
+    "transferredAmount": "Transferred Amount",
     "monthVisits": "This Month Visits",
     "visitCustomers": "Customers Visited",
     "avgRating": "Avg Rating",
@@ -752,6 +757,11 @@
     "paidTotal": "Total Paid",
     "pendingPaymentTotal": "Total Pending Payment",
     "abnormalReturns": "Abnormal Returns",
+    "statExpenseTotal": "Total Expense",
+    "statExpenseTotalCount": "Total Bills",
+    "statTransferredToPmt": "Transferred to Pmt",
+    "statTransferredToPmtAmount": "Transferred Amount",
+    "approvedAmountLabel": "Approved Amount",
     "exportPlaceholder": "Export (placeholder)"
   },
   "messages": {

+ 13 - 3
assets/i18n/zh_CN.json

@@ -285,6 +285,7 @@
     "feeReason": "费用事由",
     "enterFeeReason": "请输入费用事由",
     "applyReason": "申请事由",
+    "expenseApplyReason": "申请事由",
     "enterApplyReason": "请输入申请事由",
     "validUntil": "有效期至",
     "selectExpiryDate": "选择过期日期",
@@ -716,11 +717,14 @@
     "chartDeptOutingCompare": "部门拜访次数 vs 平均评分对比",
     "statTotalApproved": "累计核销总额",
     "statMonthCount": "当月笔数",
+    "statTotalCount": "总笔数",
     "statPendingApprove": "待审批笔数",
     "statPendingPayment": "待付款笔数",
     "statTotalApply": "累计申请总额",
     "statApprovedCount": "已通过笔数",
-    "statApprovedAmount": "已通过金额",
+    "statApprovedAmount": "核准总金额",
+    "statTransferredCount": "已转报销笔数",
+    "statTransferredAmount": "已转报销金额",
     "statMonthHours": "当月净工时",
     "statMonthTrips": "当月次数",
     "statTotalMileage": "累计里程",
@@ -742,9 +746,10 @@
     "totalMileage": "累计里程",
     "totalCost": "费用合计",
     "notReturned": "未还车",
-    "yearTotalApp": "本年累计申请",
+    "yearTotalApp": "总申请金额",
     "approvedCount": "已通过笔数",
-    "approvedAmount": "核准金额",
+    "approvedAmount": "已转报销金额",
+    "transferredAmount": "已转报销金额",
     "monthVisits": "本月拜访",
     "visitCustomers": "拜访客户",
     "avgRating": "平均评分",
@@ -752,6 +757,11 @@
     "paidTotal": "已支付总额",
     "pendingPaymentTotal": "待付款总额",
     "abnormalReturns": "异常退回",
+    "statExpenseTotal": "报销总金额",
+    "statExpenseTotalCount": "总笔数",
+    "statTransferredToPmt": "已转收支单笔数",
+    "statTransferredToPmtAmount": "已转收支单金额",
+    "approvedAmountLabel": "核准金额",
     "exportPlaceholder": "导出功能(占位)"
   },
   "messages": {

+ 11 - 1
assets/i18n/zh_TW.json

@@ -284,6 +284,7 @@
     "feeType": "費用類型",
     "feeReason": "費用事由",
     "applyReason": "申請事由",
+    "expenseApplyReason": "申請事由",
     "enterApplyReason": "請輸入申請事由",
     "enterFeeReason": "請輸入費用事由",
     "validUntil": "有效期至",
@@ -716,11 +717,14 @@
     "chartDeptOutingCompare": "部門拜訪次數 vs 平均評分对比",
     "statTotalApproved": "累計核销總額",
     "statMonthCount": "当月笔數",
+    "statTotalCount": "總筆數",
     "statPendingApprove": "待審批笔數",
     "statPendingPayment": "待付款笔數",
     "statTotalApply": "累計申請總額",
     "statApprovedCount": "已通過笔數",
-    "statApprovedAmount": "已通過金額",
+    "statApprovedAmount": "核准總金額",
+    "statTransferredCount": "已轉報銷筆數",
+    "statTransferredAmount": "已轉報銷金額",
     "statMonthHours": "当月净工時",
     "statMonthTrips": "当月次數",
     "statTotalMileage": "累計里程",
@@ -745,6 +749,7 @@
     "yearTotalApp": "本年累計申請",
     "approvedCount": "已通過笔數",
     "approvedAmount": "核准金額",
+    "transferredAmount": "已轉報銷金額",
     "monthVisits": "本月拜訪",
     "visitCustomers": "拜訪客戶",
     "avgRating": "平均評分",
@@ -752,6 +757,11 @@
     "paidTotal": "已支付總額",
     "pendingPaymentTotal": "待付款總額",
     "abnormalReturns": "异常退回",
+    "statExpenseTotal": "報銷總金額",
+    "statExpenseTotalCount": "總筆數",
+    "statTransferredToPmt": "已轉收支單筆數",
+    "statTransferredToPmtAmount": "已轉收支單金額",
+    "approvedAmountLabel": "核准金額",
     "exportPlaceholder": "導出功能(占位)"
   },
   "messages": {

+ 1 - 1
lib/features/expense/expense_detail_page.dart

@@ -21,7 +21,7 @@ import 'expense_api.dart';
 import 'dart:io';
 import 'package:path_provider/path_provider.dart';
 import 'package:open_filex/open_filex.dart';
-import '../expense_apply/widgets/attachment_preview_page.dart';
+import '../../shared/widgets/attachment_preview_page.dart';
 import 'widgets/expense_detail_view_dialog.dart';
 
 class ExpenseDetailPage extends ConsumerStatefulWidget {

+ 1 - 1
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -4,7 +4,7 @@ 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 '../../shared/widgets/attachment_preview_page.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../shared/widgets/loading_dialog.dart';

+ 14 - 5
lib/features/expense_apply/report_model.dart

@@ -2,13 +2,15 @@
 class ReportSummary {
   final double totalAmount;
   final int totalCount;
-  final int approvedCount;
-  final double approvedAmount;
+  final int transferredCount; // 已转报销笔数
+  final double transferredAmount; // 已转报销金额
+  final double approvedAmount; // 核准金额
 
   const ReportSummary({
     this.totalAmount = 0,
     this.totalCount = 0,
-    this.approvedCount = 0,
+    this.transferredCount = 0,
+    this.transferredAmount = 0,
     this.approvedAmount = 0,
   });
 
@@ -16,7 +18,8 @@ class ReportSummary {
     return ReportSummary(
       totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0,
       totalCount: json['totalCount'] as int? ?? 0,
-      approvedCount: json['approvedCount'] as int? ?? 0,
+      transferredCount: json['transferredCount'] as int? ?? 0,
+      transferredAmount: (json['transferredAmount'] as num?)?.toDouble() ?? 0,
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0,
     );
   }
@@ -26,12 +29,14 @@ class ReportSummary {
 class ReportMonthlyItem {
   final int month;
   final double amount;
-  final double approvedAmount;
+  final double transferredAmount; // 当月已转报销金额
+  final double approvedAmount; // 当月核准金额
   final int count;
 
   const ReportMonthlyItem({
     this.month = 0,
     this.amount = 0,
+    this.transferredAmount = 0,
     this.approvedAmount = 0,
     this.count = 0,
   });
@@ -40,6 +45,7 @@ class ReportMonthlyItem {
     return ReportMonthlyItem(
       month: json['month'] as int? ?? 0,
       amount: (json['amount'] as num?)?.toDouble() ?? 0,
+      transferredAmount: (json['transferredAmount'] as num?)?.toDouble() ?? 0,
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0,
       count: json['count'] as int? ?? 0,
     );
@@ -51,6 +57,7 @@ class ReportDetailItem {
   final String billNo;
   final String? billDate;
   final double amount;
+  final double approvedAmount; // 核准金额
   final bool isTransferred;
   final String reason;
 
@@ -58,6 +65,7 @@ class ReportDetailItem {
     this.billNo = '',
     this.billDate,
     this.amount = 0,
+    this.approvedAmount = 0,
     this.isTransferred = false,
     this.reason = '',
   });
@@ -67,6 +75,7 @@ class ReportDetailItem {
       billNo: json['billNo'] as String? ?? '',
       billDate: json['billDate'] as String?,
       amount: (json['amount'] as num?)?.toDouble() ?? 0,
+      approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0,
       isTransferred: json['isTransferred'] as bool? ?? false,
       reason: json['reason'] as String? ?? '',
     );

+ 167 - 197
lib/features/report/expense_apply_detail_report_page.dart

@@ -307,7 +307,7 @@ class _ExpenseApplyDetailReportPageState
     );
   }
 
-  // ── 4 张数值卡片 ──
+  // ── 4 张统计卡片(2×2)──
   Widget _buildStatCards() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
@@ -320,16 +320,16 @@ class _ExpenseApplyDetailReportPageState
             children: [
               Expanded(
                 child: _statCard(
-                  l10n.get('statTotalApply'),
-                  '¥${summary.totalAmount.toStringAsFixed(2)}',
-                  colors.amountPrimary,
+                  l10n.get('statTotalCount'),
+                  '${summary.totalCount} 笔',
+                  colors.textPrimary,
                 ),
               ),
               const SizedBox(width: 8),
               Expanded(
                 child: _statCard(
-                  l10n.get('statMonthCount'),
-                  '${summary.totalCount} 笔',
+                  l10n.get('statTransferredCount'),
+                  '${summary.transferredCount} 笔',
                   colors.textPrimary,
                 ),
               ),
@@ -340,16 +340,16 @@ class _ExpenseApplyDetailReportPageState
             children: [
               Expanded(
                 child: _statCard(
-                  l10n.get('statApprovedCount'),
-                  '${summary.approvedCount} 笔',
-                  colors.textPrimary,
+                  l10n.get('statTotalApply'),
+                  '¥${summary.totalAmount.toStringAsFixed(2)}',
+                  colors.amountPrimary,
                 ),
               ),
               const SizedBox(width: 8),
               Expanded(
                 child: _statCard(
-                  l10n.get('statApprovedAmount'),
-                  '¥${summary.approvedAmount.toStringAsFixed(2)}',
+                  l10n.get('statTransferredAmount'),
+                  '¥${summary.transferredAmount.toStringAsFixed(2)}',
                   colors.amountPrimary,
                 ),
               ),
@@ -431,7 +431,7 @@ class _ExpenseApplyDetailReportPageState
               children: [
                 _legendDot(colors.primary, l10n.get('yearTotalApp')),
                 const SizedBox(width: 16),
-                _legendDot(colors.success, l10n.get('approvedAmount')),
+                _legendDot(colors.success, l10n.get('transferredAmount')),
               ],
             ),
             const SizedBox(height: 12),
@@ -474,12 +474,12 @@ class _ExpenseApplyDetailReportPageState
     final amountData = List.generate(12, (i) {
       return monthlyMap[i + 1]?.amount ?? 0;
     });
-    final approvedData = List.generate(12, (i) {
-      return monthlyMap[i + 1]?.approvedAmount ?? 0;
+    final transferredData = List.generate(12, (i) {
+      return monthlyMap[i + 1]?.transferredAmount ?? 0;
     });
 
     // 计算 Y 轴最大值
-    final allValues = [...amountData, ...approvedData];
+    final allValues = [...amountData, ...transferredData];
     final maxVal = allValues.reduce((a, b) => a > b ? a : b);
     final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
 
@@ -575,7 +575,7 @@ class _ExpenseApplyDetailReportPageState
           LineChartBarData(
             spots: List.generate(
               12,
-              (i) => FlSpot(i.toDouble(), approvedData[i]),
+              (i) => FlSpot(i.toDouble(), transferredData[i]),
             ),
             isCurved: true,
             color: colors.success,
@@ -640,17 +640,19 @@ class _ExpenseApplyDetailReportPageState
         ),
       );
     }
-    final headerStyle = TextStyle(
-      fontSize: 12,
-      fontWeight: FontWeight.w600,
-      color: colors.textSecondary,
-    );
-    final rowStyle = TextStyle(fontSize: 13, color: colors.textPrimary);
-    final amountStyle = TextStyle(
-      fontSize: 13,
-      color: colors.amountPrimary,
-      fontWeight: FontWeight.w600,
-    );
+
+    final tableData = _details.map((d) => {
+      'billNo': d.billNo,
+      'billDate': d.billDate != null && d.billDate!.length >= 10
+          ? d.billDate!.substring(0, 10)
+          : '-',
+      'amount': d.amount,
+      'reason': d.reason,
+      'isTransferred': d.isTransferred,
+    }).toList();
+
+    final totalPages = (_detailTotal / 20).ceil();
+
     return Padding(
       padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
       child: Container(
@@ -676,184 +678,152 @@ class _ExpenseApplyDetailReportPageState
                 ],
               ),
             ),
-            Container(
-              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
-              color: colors.bgDisabled,
-              child: Row(
-                children: [
-                  Expanded(
-                    flex: 3,
-                    child: Text(l10n.get('expenseApplyNo'), style: headerStyle),
-                  ),
-                  const SizedBox(width: 8),
-                  SizedBox(
-                    width: 80,
-                    child: Text(l10n.get('date'), style: headerStyle),
-                  ),
-                  const SizedBox(width: 8),
-                  SizedBox(
-                    width: 80,
-                    child: Text(
-                      l10n.get('estimatedAmount'),
-                      textAlign: TextAlign.right,
-                      style: headerStyle,
-                    ),
-                  ),
-                  const SizedBox(width: 8),
-                  SizedBox(
-                    width: 60,
-                    child: Text(
-                      l10n.get('transferStatus'),
-                      textAlign: TextAlign.center,
-                      style: headerStyle,
-                    ),
-                  ),
-                ],
-              ),
-            ),
-            ..._details.map((d) {
-              final transferred = d.isTransferred;
-              return Padding(
-                padding: const EdgeInsets.symmetric(
-                  horizontal: 16,
-                  vertical: 8,
+            TDTable(
+              bordered: true,
+              stripe: true,
+              columns: [
+                TDTableCol(
+                  title: l10n.get('expenseApplyNo'),
+                  colKey: 'billNo',
+                  width: 130,
+                  ellipsis: true,
                 ),
-                child: Row(
-                  crossAxisAlignment: CrossAxisAlignment.center,
-                  children: [
-                    Expanded(
-                      flex: 3,
-                      child: Text(
-                        d.billNo,
-                        style: rowStyle,
-                        maxLines: 2,
-                        overflow: TextOverflow.ellipsis,
-                      ),
-                    ),
-                    const SizedBox(width: 8),
-                    SizedBox(
-                      width: 80,
-                      child: Text(
-                        d.billDate != null && d.billDate!.length >= 10
-                            ? d.billDate!.substring(0, 10)
-                            : '-',
-                        style: rowStyle,
-                        maxLines: 2,
-                      ),
-                    ),
-                    const SizedBox(width: 8),
-                    SizedBox(
-                      width: 80,
-                      child: Text(
-                        '¥${d.amount.toStringAsFixed(2)}',
-                        textAlign: TextAlign.right,
-                        style: amountStyle,
-                        maxLines: 1,
-                        overflow: TextOverflow.ellipsis,
+                TDTableCol(
+                  title: l10n.get('date'),
+                  colKey: 'billDate',
+                  width: 90,
+                ),
+                TDTableCol(
+                  title: l10n.get('estimatedAmount'),
+                  colKey: 'amount',
+                  width: 90,
+                  align: TDTableColAlign.right,
+                  cellBuilder: (context, rowIndex) {
+                    final amount = tableData[rowIndex]['amount'] as double;
+                    return Text(
+                      '¥${amount.toStringAsFixed(2)}',
+                      style: TextStyle(
+                        fontSize: 13,
+                        color: colors.amountPrimary,
+                        fontWeight: FontWeight.w600,
                       ),
-                    ),
-                    const SizedBox(width: 8),
-                    SizedBox(
-                      width: 60,
-                      child: transferred
-                          ? Container(
-                              padding: const EdgeInsets.symmetric(
-                                horizontal: 6,
-                                vertical: 2,
-                              ),
-                              decoration: BoxDecoration(
-                                color: colors.success.withValues(alpha: 0.1),
-                                borderRadius: BorderRadius.circular(10),
-                                border: Border.all(
-                                    color: colors.success, width: 0.5),
-                              ),
-                              child: Text(
-                                l10n.get('statusConvertedToExpense'),
-                                textAlign: TextAlign.center,
-                                style: TextStyle(
-                                  fontSize: 11,
-                                  color: colors.success,
-                                  fontWeight: FontWeight.w500,
-                                ),
-                              ),
-                            )
-                          : const SizedBox.shrink(),
-                    ),
-                  ],
+                    );
+                  },
                 ),
-              );
-            }),
-            // 分页栏
-            if (_detailTotal > 0)
-              Container(
-                padding: const EdgeInsets.symmetric(
-                  horizontal: 16,
-                  vertical: 10,
+                TDTableCol(
+                  title: l10n.get('applyReason'),
+                  colKey: 'reason',
+                  width: 120,
+                  ellipsis: true,
                 ),
-                decoration: BoxDecoration(
-                  border: Border(
-                    top: BorderSide(color: colors.border, width: 0.5),
-                  ),
+                TDTableCol(
+                  title: l10n.get('transferStatus'),
+                  colKey: 'isTransferred',
+                  width: 70,
+                  align: TDTableColAlign.center,
+                  cellBuilder: (context, rowIndex) {
+                    final transferred = tableData[rowIndex]['isTransferred'] as bool;
+                    return transferred
+                        ? Container(
+                            padding: const EdgeInsets.symmetric(
+                              horizontal: 6,
+                              vertical: 2,
+                            ),
+                            decoration: BoxDecoration(
+                              color: colors.success.withValues(alpha: 0.1),
+                              borderRadius: BorderRadius.circular(10),
+                              border: Border.all(
+                                  color: colors.success, width: 0.5),
+                            ),
+                            child: Text(
+                              l10n.get('statusConvertedToExpense'),
+                              style: TextStyle(
+                                fontSize: 11,
+                                color: colors.success,
+                                fontWeight: FontWeight.w500,
+                              ),
+                            ),
+                          )
+                        : const SizedBox.shrink();
+                  },
                 ),
-                child: Row(
-                  mainAxisAlignment: MainAxisAlignment.center,
-                  children: [
-                    GestureDetector(
-                      onTap: _detailPage > 1
-                          ? () {
-                              _detailPage--;
-                              _loadDetails();
-                            }
-                          : null,
-                      child: Icon(
-                        Icons.chevron_left,
-                        size: 20,
-                        color: _detailPage > 1
-                            ? colors.textPrimary
-                            : colors.textPlaceholder,
-                      ),
-                    ),
-                    const SizedBox(width: 16),
-                    Text(
-                      '$_detailPage',
-                      style: TextStyle(fontSize: 13, color: colors.textPrimary),
-                    ),
-                    const SizedBox(width: 4),
-                    Text(
-                      '/ ${(_detailTotal / 20).ceil()}',
-                      style: TextStyle(
-                        fontSize: 13,
-                        color: colors.textSecondary,
+              ],
+              data: tableData,
+              empty: TDTableEmpty(text: l10n.get('noDetailData')),
+              footerWidget: _detailTotal > 0
+                  ? Container(
+                      padding: const EdgeInsets.symmetric(
+                        horizontal: 16,
+                        vertical: 10,
                       ),
-                    ),
-                    const SizedBox(width: 16),
-                    GestureDetector(
-                      onTap: _detailPage * 20 < _detailTotal
-                          ? () {
-                              _detailPage++;
-                              _loadDetails();
-                            }
-                          : null,
-                      child: Icon(
-                        Icons.chevron_right,
-                        size: 20,
-                        color: _detailPage * 20 < _detailTotal
-                            ? colors.textPrimary
-                            : colors.textPlaceholder,
+                      decoration: BoxDecoration(
+                        border: Border(
+                          top: BorderSide(color: colors.border, width: 0.5),
+                        ),
                       ),
-                    ),
-                    const SizedBox(width: 16),
-                    Text(
-                      '${l10n.get('total')} $_detailTotal ${l10n.get('items')}',
-                      style: TextStyle(
-                        fontSize: 12,
-                        color: colors.textSecondary,
+                      child: Row(
+                        mainAxisAlignment: MainAxisAlignment.center,
+                        children: [
+                          GestureDetector(
+                            onTap: _detailPage > 1
+                                ? () {
+                                    _detailPage--;
+                                    _loadDetails();
+                                  }
+                                : null,
+                            child: Icon(
+                              Icons.chevron_left,
+                              size: 20,
+                              color: _detailPage > 1
+                                  ? colors.textPrimary
+                                  : colors.textPlaceholder,
+                            ),
+                          ),
+                          const SizedBox(width: 16),
+                          Text(
+                            '$_detailPage',
+                            style: TextStyle(
+                              fontSize: 13,
+                              color: colors.textPrimary,
+                            ),
+                          ),
+                          const SizedBox(width: 4),
+                          Text(
+                            '/ $totalPages',
+                            style: TextStyle(
+                              fontSize: 13,
+                              color: colors.textSecondary,
+                            ),
+                          ),
+                          const SizedBox(width: 16),
+                          GestureDetector(
+                            onTap: _detailPage < totalPages
+                                ? () {
+                                    _detailPage++;
+                                    _loadDetails();
+                                  }
+                                : null,
+                            child: Icon(
+                              Icons.chevron_right,
+                              size: 20,
+                              color: _detailPage < totalPages
+                                  ? colors.textPrimary
+                                  : colors.textPlaceholder,
+                            ),
+                          ),
+                          const SizedBox(width: 16),
+                          Text(
+                            '${l10n.get('total')} $_detailTotal ${l10n.get('items')}',
+                            style: TextStyle(
+                              fontSize: 12,
+                              color: colors.textSecondary,
+                            ),
+                          ),
+                        ],
                       ),
-                    ),
-                  ],
-                ),
-              ),
-            const SizedBox(height: 8),
+                    )
+                  : null,
+            ),
           ],
         ),
       ),

+ 260 - 248
lib/features/report/expense_detail_report_page.dart

@@ -245,12 +245,11 @@ class _ExpenseDetailReportPageState
     );
   }
 
-  // ── 4 张统计卡片 ──
+  // ── 5 张统计卡片(2+2+1)──
   Widget _buildStatCards() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final summary = _data?.summary ?? const ReportSummary();
-    final pendingCount = summary.totalCount - summary.approvedCount;
     return Padding(
       padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
       child: Column(
@@ -259,16 +258,16 @@ class _ExpenseDetailReportPageState
             children: [
               Expanded(
                 child: _statCard(
-                  l10n.get('statTotalApproved'),
-                  '¥${summary.totalAmount.toStringAsFixed(2)}',
-                  colors.amountPrimary,
+                  l10n.get('statExpenseTotalCount'),
+                  '${summary.totalCount} 笔',
+                  colors.textPrimary,
                 ),
               ),
               const SizedBox(width: 8),
               Expanded(
                 child: _statCard(
-                  l10n.get('statMonthCount'),
-                  '${summary.totalCount} 笔',
+                  l10n.get('statTransferredToPmt'),
+                  '${summary.transferredCount} 笔',
                   colors.textPrimary,
                 ),
               ),
@@ -279,17 +278,29 @@ class _ExpenseDetailReportPageState
             children: [
               Expanded(
                 child: _statCard(
-                  l10n.get('statPendingApprove'),
-                  '$pendingCount 笔',
-                  colors.textPrimary,
+                  l10n.get('statExpenseTotal'),
+                  '¥${summary.totalAmount.toStringAsFixed(2)}',
+                  colors.amountPrimary,
                 ),
               ),
               const SizedBox(width: 8),
               Expanded(
                 child: _statCard(
-                  l10n.get('statPendingPayment'),
-                  '0 笔',
-                  colors.textPrimary,
+                  l10n.get('statTransferredToPmtAmount'),
+                  '¥${summary.transferredAmount.toStringAsFixed(2)}',
+                  colors.amountPrimary,
+                ),
+              ),
+            ],
+          ),
+          const SizedBox(height: 8),
+          Row(
+            children: [
+              Expanded(
+                child: _statCard(
+                  l10n.get('statApprovedAmount'),
+                  '¥${summary.approvedAmount.toStringAsFixed(2)}',
+                  colors.amountPrimary,
                 ),
               ),
             ],
@@ -370,11 +381,13 @@ class _ExpenseDetailReportPageState
               children: [
                 _legendDot(colors.primary, l10n.get('expenseAmount')),
                 const SizedBox(width: 16),
-                _legendDot(colors.success, l10n.get('approvedAmount')),
+                _legendDot(colors.warning, l10n.get('approvedAmountLabel')),
+                const SizedBox(width: 16),
+                _legendDot(colors.success, l10n.get('transferredAmount')),
               ],
             ),
             const SizedBox(height: 12),
-            SizedBox(height: 200, child: _buildDualLineChart()),
+            SizedBox(height: 200, child: _buildTripleLineChart()),
           ],
         ),
       ),
@@ -400,51 +413,37 @@ class _ExpenseDetailReportPageState
     );
   }
 
-  Widget _buildDualLineChart() {
+  // 三折线图
+  Widget _buildTripleLineChart() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final monthly = _data?.monthly ?? [];
-    final months = [
-      '1月',
-      '2月',
-      '3月',
-      '4月',
-      '5月',
-      '6月',
-      '7月',
-      '8月',
-      '9月',
-      '10月',
-      '11月',
-      '12月',
-    ];
 
-    // 构建 12 个月的点,API 未返回的月份填 0
-    final amountSpots = List.generate(12, (i) {
-      final idx = monthly.indexWhere((m) => m.month == i + 1);
-      return FlSpot(i.toDouble(), idx >= 0 ? monthly[idx].amount : 0);
+    // 从 monthly 构建 12 个月的数据,无数据的月份默认为 0
+    final monthlyMap = <int, ReportMonthlyItem>{};
+    for (final item in (_data?.monthly ?? [])) {
+      monthlyMap[item.month] = item;
+    }
+    final monthLabels = List.generate(12, (i) => '${i + 1}月');
+    final amountData = List.generate(12, (i) {
+      return monthlyMap[i + 1]?.amount ?? 0;
+    });
+    final approvedData = List.generate(12, (i) {
+      return monthlyMap[i + 1]?.approvedAmount ?? 0;
     });
-    final approvedSpots = List.generate(12, (i) {
-      final idx = monthly.indexWhere((m) => m.month == i + 1);
-      return FlSpot(i.toDouble(), idx >= 0 ? monthly[idx].approvedAmount : 0);
+    final transferredData = List.generate(12, (i) {
+      return monthlyMap[i + 1]?.transferredAmount ?? 0;
     });
 
-    // 计算合理的 Y 轴最大值
-    final allValues = <double>[];
-    for (final m in monthly) {
-      allValues.add(m.amount);
-      allValues.add(m.approvedAmount);
-    }
-    final maxVal = allValues.isEmpty
-        ? 100.0
-        : allValues.reduce((a, b) => a > b ? a : b);
-    final maxY = maxVal * 1.2;
+    // 计算 Y 轴最大值
+    final allValues = [...amountData, ...approvedData, ...transferredData];
+    final maxVal = allValues.reduce((a, b) => a > b ? a : b);
+    final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
 
     return LineChart(
       LineChartData(
         gridData: FlGridData(
           show: true,
           drawVerticalLine: false,
-          horizontalInterval: maxY > 0 ? maxY / 5 : 20,
+          horizontalInterval: maxY / 5,
           getDrawingHorizontalLine: (v) =>
               FlLine(color: colors.border, strokeWidth: 0.5),
         ),
@@ -452,14 +451,11 @@ class _ExpenseDetailReportPageState
           leftTitles: AxisTitles(
             sideTitles: SideTitles(
               showTitles: true,
-              reservedSize: 50,
-              getTitlesWidget: (v, meta) {
-                final val = v.toInt();
-                return Text(
-                  '¥$val',
-                  style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
-                );
-              },
+              reservedSize: 48,
+              getTitlesWidget: (v, meta) => Text(
+                '¥${v.toInt()}',
+                style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
+              ),
             ),
           ),
           bottomTitles: AxisTitles(
@@ -469,15 +465,12 @@ class _ExpenseDetailReportPageState
               reservedSize: 26,
               getTitlesWidget: (v, meta) {
                 final i = v.toInt();
-                if (i < 0 || i >= months.length) return const SizedBox();
+                if (i < 0 || i >= monthLabels.length) return const SizedBox();
                 return Padding(
                   padding: const EdgeInsets.only(top: 4),
                   child: Text(
-                    months[i],
-                    style: TextStyle(
-                      fontSize: 9,
-                      color: colors.textPlaceholder,
-                    ),
+                    monthLabels[i],
+                    style: TextStyle(fontSize: 9, color: colors.textPlaceholder),
                   ),
                 );
               },
@@ -492,14 +485,13 @@ class _ExpenseDetailReportPageState
         ),
         borderData: FlBorderData(show: false),
         minY: 0,
-        maxY: maxY > 0 ? maxY : 100,
+        maxY: maxY,
         lineTouchData: LineTouchData(
           enabled: true,
           touchTooltipData: LineTouchTooltipData(
             getTooltipItems: (spots) => spots.map((s) {
-              final mi = s.spotIndex;
               return LineTooltipItem(
-                '${months[mi]}\n¥${s.y.toStringAsFixed(0)}',
+                '${monthLabels[s.spotIndex]}\n¥${s.y.toStringAsFixed(0)}',
                 const TextStyle(
                   color: Colors.white,
                   fontSize: 12,
@@ -510,8 +502,12 @@ class _ExpenseDetailReportPageState
           ),
         ),
         lineBarsData: [
+          // 报销金额 - primary/蓝
           LineChartBarData(
-            spots: amountSpots,
+            spots: List.generate(
+              12,
+              (i) => FlSpot(i.toDouble(), amountData[i]),
+            ),
             isCurved: true,
             color: colors.primary,
             barWidth: 2.5,
@@ -529,8 +525,35 @@ class _ExpenseDetailReportPageState
               color: colors.primary.withValues(alpha: 0.08),
             ),
           ),
+          // 核准金额 - warning/橙
           LineChartBarData(
-            spots: approvedSpots,
+            spots: List.generate(
+              12,
+              (i) => FlSpot(i.toDouble(), approvedData[i]),
+            ),
+            isCurved: true,
+            color: colors.warning,
+            barWidth: 2.5,
+            dotData: FlDotData(
+              show: true,
+              getDotPainter: (spot, percent, barData, index) =>
+                  FlDotCirclePainter(
+                    radius: 3,
+                    color: colors.warning,
+                    strokeWidth: 0,
+                  ),
+            ),
+            belowBarData: BarAreaData(
+              show: true,
+              color: colors.warning.withValues(alpha: 0.08),
+            ),
+          ),
+          // 已转收支单金额 - success/绿
+          LineChartBarData(
+            spots: List.generate(
+              12,
+              (i) => FlSpot(i.toDouble(), transferredData[i]),
+            ),
             isCurved: true,
             color: colors.success,
             barWidth: 2.5,
@@ -633,6 +656,7 @@ class _ExpenseDetailReportPageState
     );
   }
 
+  // ── 明细列表(TDTable)──
   Widget _buildDetailList() {
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
@@ -673,17 +697,20 @@ class _ExpenseDetailReportPageState
         ),
       );
     }
-    final headerStyle = TextStyle(
-      fontSize: 12,
-      fontWeight: FontWeight.w600,
-      color: colors.textSecondary,
-    );
-    final rowStyle = TextStyle(fontSize: 13, color: colors.textPrimary);
-    final amountStyle = TextStyle(
-      fontSize: 13,
-      color: colors.amountPrimary,
-      fontWeight: FontWeight.w600,
-    );
+
+    final tableData = _details.map((d) => {
+      'billNo': d.billNo,
+      'billDate': d.billDate != null && d.billDate!.length >= 10
+          ? d.billDate!.substring(0, 10)
+          : '-',
+      'amount': d.amount,
+      'approvedAmount': d.approvedAmount,
+      'reason': d.reason,
+      'isTransferred': d.isTransferred,
+    }).toList();
+
+    final totalPages = (_detailTotal / 20).ceil();
+
     return Padding(
       padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
       child: Container(
@@ -709,184 +736,169 @@ class _ExpenseDetailReportPageState
                 ],
               ),
             ),
-            Container(
-              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
-              color: colors.bgDisabled,
-              child: Row(
-                children: [
-                  Expanded(
-                    flex: 3,
-                    child: Text(l10n.get('expenseNo'), style: headerStyle),
-                  ),
-                  const SizedBox(width: 8),
-                  SizedBox(
-                    width: 80,
-                    child: Text(l10n.get('date'), style: headerStyle),
-                  ),
-                  const SizedBox(width: 8),
-                  SizedBox(
-                    width: 80,
-                    child: Text(
-                      l10n.get('expenseAmount'),
-                      textAlign: TextAlign.right,
-                      style: headerStyle,
-                    ),
-                  ),
-                  const SizedBox(width: 8),
-                  SizedBox(
-                    width: 60,
-                    child: Text(
-                      l10n.get('transferStatus'),
-                      textAlign: TextAlign.center,
-                      style: headerStyle,
-                    ),
-                  ),
-                ],
-              ),
-            ),
-            ..._details.map((d) {
-              final transferred = d.isTransferred;
-              return Padding(
-                padding: const EdgeInsets.symmetric(
-                  horizontal: 16,
-                  vertical: 8,
+            TDTable(
+              bordered: true,
+              stripe: true,
+              columns: [
+                TDTableCol(
+                  title: l10n.get('expenseNo'),
+                  colKey: 'billNo',
+                  width: 120,
+                  ellipsis: true,
                 ),
-                child: Row(
-                  crossAxisAlignment: CrossAxisAlignment.center,
-                  children: [
-                    Expanded(
-                      flex: 3,
-                      child: Text(
-                        d.billNo,
-                        style: rowStyle,
-                        maxLines: 2,
-                        overflow: TextOverflow.ellipsis,
-                      ),
-                    ),
-                    const SizedBox(width: 8),
-                    SizedBox(
-                      width: 80,
-                      child: Text(
-                        d.billDate != null && d.billDate!.length >= 10
-                            ? d.billDate!.substring(0, 10)
-                            : '-',
-                        style: rowStyle,
-                        maxLines: 2,
+                TDTableCol(
+                  title: l10n.get('date'),
+                  colKey: 'billDate',
+                  width: 85,
+                ),
+                TDTableCol(
+                  title: l10n.get('expenseAmount'),
+                  colKey: 'amount',
+                  width: 85,
+                  align: TDTableColAlign.right,
+                  cellBuilder: (context, rowIndex) {
+                    final amount = tableData[rowIndex]['amount'] as double;
+                    return Text(
+                      '¥${amount.toStringAsFixed(2)}',
+                      style: TextStyle(
+                        fontSize: 13,
+                        color: colors.amountPrimary,
+                        fontWeight: FontWeight.w600,
                       ),
-                    ),
-                    const SizedBox(width: 8),
-                    SizedBox(
-                      width: 80,
-                      child: Text(
-                        '¥${d.amount.toStringAsFixed(2)}',
-                        textAlign: TextAlign.right,
-                        style: amountStyle,
-                        maxLines: 1,
-                        overflow: TextOverflow.ellipsis,
+                    );
+                  },
+                ),
+                TDTableCol(
+                  title: l10n.get('approvedAmountLabel'),
+                  colKey: 'approvedAmount',
+                  width: 85,
+                  align: TDTableColAlign.right,
+                  cellBuilder: (context, rowIndex) {
+                    final amount = tableData[rowIndex]['approvedAmount'] as double;
+                    return Text(
+                      '¥${amount.toStringAsFixed(2)}',
+                      style: TextStyle(
+                        fontSize: 13,
+                        color: colors.amountPrimary,
+                        fontWeight: FontWeight.w600,
                       ),
-                    ),
-                    const SizedBox(width: 8),
-                    SizedBox(
-                      width: 60,
-                      child: transferred
-                          ? Container(
-                              padding: const EdgeInsets.symmetric(
-                                horizontal: 6,
-                                vertical: 2,
-                              ),
-                              decoration: BoxDecoration(
-                                color: colors.success.withValues(alpha: 0.1),
-                                borderRadius: BorderRadius.circular(10),
-                                border: Border.all(
-                                    color: colors.success, width: 0.5),
-                              ),
-                              child: Text(
-                                l10n.get('statusTransferred'),
-                                textAlign: TextAlign.center,
-                                style: TextStyle(
-                                  fontSize: 11,
-                                  color: colors.success,
-                                  fontWeight: FontWeight.w500,
-                                ),
-                              ),
-                            )
-                          : const SizedBox.shrink(),
-                    ),
-                  ],
+                    );
+                  },
                 ),
-              );
-            }),
-            // 分页栏
-            if (_detailTotal > 0)
-              Container(
-                padding: const EdgeInsets.symmetric(
-                  horizontal: 16,
-                  vertical: 10,
+                TDTableCol(
+                  title: l10n.get('expenseApplyReason'),
+                  colKey: 'reason',
+                  width: 110,
+                  ellipsis: true,
                 ),
-                decoration: BoxDecoration(
-                  border: Border(
-                    top: BorderSide(color: colors.border, width: 0.5),
-                  ),
+                TDTableCol(
+                  title: l10n.get('transferStatus'),
+                  colKey: 'isTransferred',
+                  width: 70,
+                  align: TDTableColAlign.center,
+                  cellBuilder: (context, rowIndex) {
+                    final transferred = tableData[rowIndex]['isTransferred'] as bool;
+                    return transferred
+                        ? Container(
+                            padding: const EdgeInsets.symmetric(
+                              horizontal: 6,
+                              vertical: 2,
+                            ),
+                            decoration: BoxDecoration(
+                              color: colors.success.withValues(alpha: 0.1),
+                              borderRadius: BorderRadius.circular(10),
+                              border: Border.all(
+                                  color: colors.success, width: 0.5),
+                            ),
+                            child: Text(
+                              l10n.get('statusTransferred'),
+                              style: TextStyle(
+                                fontSize: 11,
+                                color: colors.success,
+                                fontWeight: FontWeight.w500,
+                              ),
+                            ),
+                          )
+                        : const SizedBox.shrink();
+                  },
                 ),
-                child: Row(
-                  mainAxisAlignment: MainAxisAlignment.center,
-                  children: [
-                    GestureDetector(
-                      onTap: _detailPage > 1
-                          ? () {
-                              _detailPage--;
-                              _loadDetails();
-                            }
-                          : null,
-                      child: Icon(
-                        Icons.chevron_left,
-                        size: 20,
-                        color: _detailPage > 1
-                            ? colors.textPrimary
-                            : colors.textPlaceholder,
-                      ),
-                    ),
-                    const SizedBox(width: 16),
-                    Text(
-                      '$_detailPage',
-                      style: TextStyle(fontSize: 13, color: colors.textPrimary),
-                    ),
-                    const SizedBox(width: 4),
-                    Text(
-                      '/ ${(_detailTotal / 20).ceil()}',
-                      style: TextStyle(
-                        fontSize: 13,
-                        color: colors.textSecondary,
+              ],
+              data: tableData,
+              empty: TDTableEmpty(text: l10n.get('noDetailData')),
+              footerWidget: _detailTotal > 0
+                  ? Container(
+                      padding: const EdgeInsets.symmetric(
+                        horizontal: 16,
+                        vertical: 10,
                       ),
-                    ),
-                    const SizedBox(width: 16),
-                    GestureDetector(
-                      onTap: _detailPage * 20 < _detailTotal
-                          ? () {
-                              _detailPage++;
-                              _loadDetails();
-                            }
-                          : null,
-                      child: Icon(
-                        Icons.chevron_right,
-                        size: 20,
-                        color: _detailPage * 20 < _detailTotal
-                            ? colors.textPrimary
-                            : colors.textPlaceholder,
+                      decoration: BoxDecoration(
+                        border: Border(
+                          top: BorderSide(color: colors.border, width: 0.5),
+                        ),
                       ),
-                    ),
-                    const SizedBox(width: 16),
-                    Text(
-                      '${l10n.get('total')} $_detailTotal ${l10n.get('items')}',
-                      style: TextStyle(
-                        fontSize: 12,
-                        color: colors.textSecondary,
+                      child: Row(
+                        mainAxisAlignment: MainAxisAlignment.center,
+                        children: [
+                          GestureDetector(
+                            onTap: _detailPage > 1
+                                ? () {
+                                    _detailPage--;
+                                    _loadDetails();
+                                  }
+                                : null,
+                            child: Icon(
+                              Icons.chevron_left,
+                              size: 20,
+                              color: _detailPage > 1
+                                  ? colors.textPrimary
+                                  : colors.textPlaceholder,
+                            ),
+                          ),
+                          const SizedBox(width: 16),
+                          Text(
+                            '$_detailPage',
+                            style: TextStyle(
+                              fontSize: 13,
+                              color: colors.textPrimary,
+                            ),
+                          ),
+                          const SizedBox(width: 4),
+                          Text(
+                            '/ $totalPages',
+                            style: TextStyle(
+                              fontSize: 13,
+                              color: colors.textSecondary,
+                            ),
+                          ),
+                          const SizedBox(width: 16),
+                          GestureDetector(
+                            onTap: _detailPage < totalPages
+                                ? () {
+                                    _detailPage++;
+                                    _loadDetails();
+                                  }
+                                : null,
+                            child: Icon(
+                              Icons.chevron_right,
+                              size: 20,
+                              color: _detailPage < totalPages
+                                  ? colors.textPrimary
+                                  : colors.textPlaceholder,
+                            ),
+                          ),
+                          const SizedBox(width: 16),
+                          Text(
+                            '${l10n.get('total')} $_detailTotal ${l10n.get('items')}',
+                            style: TextStyle(
+                              fontSize: 12,
+                              color: colors.textSecondary,
+                            ),
+                          ),
+                        ],
                       ),
-                    ),
-                  ],
-                ),
-              ),
-            const SizedBox(height: 8),
+                    )
+                  : null,
+            ),
           ],
         ),
       ),

+ 0 - 0
lib/features/expense_apply/widgets/attachment_preview_page.dart → lib/shared/widgets/attachment_preview_page.dart