Bläddra i källkod

doing #19407 优化费用报销单相关页面

chengc 2 veckor sedan
förälder
incheckning
f28e926f28

+ 1 - 0
assets/i18n/en.json

@@ -566,6 +566,7 @@
     "amountPositive": "amountPositive",
     "taxAmount": "taxAmount",
     "taxRate": "taxRate",
+    "exchangeRate": "Exchange Rate",
     "approvedAmount": "Approved Amount",
     "expenseName": "expenseName",
     "enterExpenseName": "enterExpenseName",

+ 1 - 0
assets/i18n/zh_CN.json

@@ -416,6 +416,7 @@
     "amountPositive": "金额必须大于0",
     "taxAmount": "税金",
     "taxRate": "税率",
+    "exchangeRate": "汇率",
     "approvedAmount": "核准金额",
     "expenseName": "费用名称",
     "enterExpenseName": "请输入费用项目名称",

+ 1 - 0
assets/i18n/zh_TW.json

@@ -416,6 +416,7 @@
     "amountPositive": "金額必须大于0",
     "taxAmount": "稅金",
     "taxRate": "稅率",
+    "exchangeRate": "匯率",
     "approvedAmount": "核准金額",
     "expenseName": "費用名称",
     "enterExpenseName": "請輸入費用項目名称",

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

@@ -398,7 +398,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
           'ACC_NO': d.acctSubjectId,
           'AMT': d.totalAmount,
           'AMTN': d.amount,
-          'AMTN_SH': d.approvedAmount > 0 ? d.approvedAmount : d.totalAmount,
+          'AMTN_SH': d.approvedAmount,
           'REM': d.remark,
           'CUST': d.customerVendorId,
           'IDX_NO': d.expenseCategory,
@@ -723,6 +723,14 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
                                 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}',
@@ -763,7 +771,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
                             ),
                           if (d.costDeptName.isNotEmpty)
                             Text(
-                              '${l10n.get('dept')}: ${d.costDeptId}/${d.costDeptName}',
+                              '${l10n.get('costDept')}: ${d.costDeptId}/${d.costDeptName}',
                               maxLines: 1,
                               overflow: TextOverflow.ellipsis,
                               style: TextStyle(
@@ -781,6 +789,14 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
                                 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}',
@@ -811,14 +827,6 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
                                 color: colors.textSecondary,
                               ),
                             ),
-                          if (d.sqManName.isNotEmpty)
-                            Text(
-                              '${l10n.get('applicant')}: ${d.sqManName.isNotEmpty ? d.sqManName : d.sqMan}',
-                              style: TextStyle(
-                                fontSize: AppFontSizes.caption,
-                                color: colors.textSecondary,
-                              ),
-                            ),
                           if (d.remark.isNotEmpty)
                             Text(
                               '${l10n.get('remark')}: ${d.remark}',
@@ -1142,11 +1150,11 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
           categoryName: result.categoryName,
           purpose: result.purpose,
           amount: result.taxRate > 0
-              ? result.amount / (1 + result.taxRate)
+              ? result.amount / (1 + result.taxRate / 100)
               : result.amount,
           taxRate: result.taxRate,
           taxAmount: result.taxRate > 0
-              ? result.amount - result.amount / (1 + result.taxRate)
+              ? result.amount - result.amount / (1 + result.taxRate / 100)
               : 0,
           totalAmount: result.amount,
           projectId: result.projectId,

+ 31 - 8
lib/features/expense/expense_detail_page.dart

@@ -17,6 +17,7 @@ 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 'widgets/expense_detail_view_dialog.dart';
 
 class ExpenseDetailPage extends ConsumerStatefulWidget {
   final String billNo;
@@ -244,8 +245,10 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
             final d = e.value;
             final title = d.categoryName.isNotEmpty
                 ? '${d.expenseCategory}/${d.categoryName}'
-                : (d.remark.isNotEmpty ? d.remark : (d.acctSubjectId.isNotEmpty ? d.acctSubjectId : '--'));
-            return Container(
+                : d.expenseCategory;
+            return GestureDetector(
+              onTap: () => _showExpenseDetailDialog(context, d),
+              child: Container(
               margin: const EdgeInsets.symmetric(vertical: 6),
               padding: const EdgeInsets.all(12),
               decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
@@ -279,9 +282,9 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
                         if (d.taxAmount > 0)
                           Text('${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
                               style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                        if (d.approvedAmount > 0)
-                          Text('${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)}',
-                              style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w500, color: colors.success)),
+                        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,
@@ -298,15 +301,30 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
                               maxLines: 1, overflow: TextOverflow.ellipsis,
                               style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                         if (d.costDeptName.isNotEmpty)
-                          Text('${l10n.get('dept')}: ${d.costDeptId}/${d.costDeptName}',
+                          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.customerVendorName}',
+                          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(d.remark,
+                          Text('${l10n.get('remark')}: ${d.remark}',
                               maxLines: 2, overflow: TextOverflow.ellipsis,
                               style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                       ],
@@ -314,6 +332,7 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
                   ),
                 ],
               ),
+              ),
             );
           }),
         if (expense.details.isNotEmpty) ...[
@@ -337,6 +356,10 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
     );
   }
 
+  void _showExpenseDetailDialog(BuildContext context, ExpenseDetailModel d) {
+    ExpenseDetailViewDialog.show(context, d);
+  }
+
   // ═══ 附件 ═══
   Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
     if (!_attachAvailable) {

+ 4 - 4
lib/features/expense/expense_list_controller.dart

@@ -24,7 +24,7 @@ final mockExpenses = <ExpenseModel>[
           expenseCategory: 'transport', categoryName: '交通费', priority: '2', purpose: '往返机票',
           projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
           acctSubjectId: '550201', acctSubjectName: '差旅费-交通',
-          amount: 1200, taxRate: 0.09, taxAmount: 108, totalAmount: 1308, currencyCode: 'CNY',
+          amount: 1200, taxRate: 9, taxAmount: 108, totalAmount: 1308, currencyCode: 'CNY',
           exchangeRate: 1.0, baseAmount: 1308, approvedAmount: 1308,
           bankName: '招商银行', bankAccountName: '张三', bankAccount: '6222 0212 3456 7890',
           remark: '经济舱', createTime: now, updateTime: now),
@@ -32,7 +32,7 @@ final mockExpenses = <ExpenseModel>[
           expenseCategory: 'hotel', categoryName: '住宿费', priority: '2', purpose: '酒店住宿',
           projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
           acctSubjectId: '550202', acctSubjectName: '差旅费-住宿',
-          amount: 984, taxRate: 0.09, taxAmount: 88, totalAmount: 1072, currencyCode: 'CNY',
+          amount: 984, taxRate: 9, taxAmount: 88, totalAmount: 1072, currencyCode: 'CNY',
           exchangeRate: 1.0, baseAmount: 1072, approvedAmount: 1072,
           bankName: '招商银行', bankAccountName: '张三', bankAccount: '6222 0212 3456 7890',
           remark: '3晚', createTime: now, updateTime: now),
@@ -54,7 +54,7 @@ final mockExpenses = <ExpenseModel>[
           expenseCategory: 'office', categoryName: '办公费', priority: '1', purpose: 'A4打印纸+墨盒',
           costDeptId: 'D001', costDeptName: '销售部',
           acctSubjectId: '550601', acctSubjectName: '办公费',
-          amount: 156, taxRate: 0.13, taxAmount: 20, totalAmount: 176, currencyCode: 'CNY',
+          amount: 156, taxRate: 13, taxAmount: 20, totalAmount: 176, currencyCode: 'CNY',
           exchangeRate: 1.0, baseAmount: 176, approvedAmount: 156,
           bankName: '支付宝', bankAccountName: '张三', bankAccount: 'zhangsan@alipay',
           createTime: now, updateTime: now),
@@ -74,7 +74,7 @@ final mockExpenses = <ExpenseModel>[
           expenseCategory: 'equipment', categoryName: '设备费', priority: '3', purpose: 'SSD硬盘×2',
           projectId: 'P002', projectName: '智能硬件研发', costDeptId: 'D002', costDeptName: '技术部',
           acctSubjectId: '550301', acctSubjectName: '研发费用-设备',
-          amount: 890, taxRate: 0.13, taxAmount: 116, totalAmount: 1006, currencyCode: 'CNY',
+          amount: 890, taxRate: 13, taxAmount: 116, totalAmount: 1006, currencyCode: 'CNY',
           exchangeRate: 1.0, baseAmount: 1006, approvedAmount: 0,
           customerVendorName: 'XX电子科技有限公司',
           bankName: '工商银行', bankAccountName: '李四', bankAccount: '6212 3456 7890 1234',

+ 7 - 7
lib/features/expense/expense_model.dart

@@ -313,7 +313,7 @@ class ExpenseDetailModel {
     this.acctSubjectId = '',
     this.acctSubjectName = '',
     required this.amount,
-    this.taxRate = 0.0,
+    this.taxRate = 0,
     this.taxAmount = 0.0,
     required this.totalAmount,
     this.currencyCode = '',
@@ -357,16 +357,16 @@ class ExpenseDetailModel {
       costDeptName: json['depName'] as String? ?? json['dep'] as String? ?? '',
       acctSubjectId: json['accNo'] as String? ?? '',
       acctSubjectName: json['accName'] as String? ?? '',
-      amount: (json['amt'] as num?)?.toDouble() ?? 0.0,
-      taxRate: (json['taxRto'] as num?)?.toDouble() ?? 0.0,
-      taxAmount: (json['tax'] as num?)?.toDouble() ?? 0.0,
-      totalAmount: (json['amtn'] as num?)?.toDouble() ?? 0.0,
+      amount: (json['amount'] as num?)?.toDouble() ?? (json['amtn'] as num?)?.toDouble() ?? 0.0,
+      taxRate: (json['taxRate'] as num?)?.toDouble() ?? (json['taxRto'] as num?)?.toDouble() ?? 0.0,
+      taxAmount: (json['taxAmount'] as num?)?.toDouble() ?? (json['tax'] as num?)?.toDouble() ?? 0.0,
+      totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? (json['amt'] as num?)?.toDouble() ?? 0.0,
       currencyCode: json['currencyCode'] as String? ?? '',
       exchangeRate: (json['exchangeRate'] as num?)?.toDouble() ?? 1.0,
       baseAmount: (json['baseAmount'] as num?)?.toDouble() ?? 0.0,
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
-      customerVendorId: json['customerVendorId'] as String? ?? '',
-      customerVendorName: json['cust'] as String? ?? '',
+      customerVendorId: json['cust'] as String? ?? '',
+      customerVendorName: json['custName'] as String? ?? '',
       offsetAmount: (json['offsetAmount'] as num?)?.toDouble() ?? 0.0,
       bankName: json['bankName'] as String? ?? '',
       bankAccountName: json['bankAccountName'] as String? ?? '',

+ 4 - 4
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -176,7 +176,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     });
   }
 
-  static const _taxOptions = [0.0, 0.06, 0.09, 0.13];
+  static const _taxOptions = [0, 6, 9, 13];
   static const _taxLabels = ['0%', '6%', '9%', '13%'];
 
   List<CostCategory> get _cats => widget.categories;
@@ -317,7 +317,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   }
 
   double get _amountExclTax => _taxRate > 0
-      ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate)
+      ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate / 100)
       : (double.tryParse(_amountCtrl.text) ?? 0);
   double get _taxAmount =>
       (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax;
@@ -701,7 +701,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
 
   // ── 税率 ──
   Widget _buildTaxRateCard(AppColorsExtension colors) {
-    final currentLabel = '${(_taxRate * 100).toStringAsFixed(0)}%';
+    final currentLabel = '${_taxRate.toStringAsFixed(0)}%';
     return _pickerCard(
       label: _l10n.get('taxRate'),
       required: false,
@@ -709,7 +709,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
       labels: _taxLabels.toList(),
       colors: colors,
       onSelected: (idx) => setState(() {
-        _taxRate = _taxOptions[idx];
+        _taxRate = _taxOptions[idx].toDouble();
       }),
     );
   }

+ 119 - 0
lib/features/expense/widgets/expense_detail_view_dialog.dart

@@ -0,0 +1,119 @@
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../../core/i18n/app_localizations.dart';
+import '../../../core/theme/app_colors_extension.dart';
+import '../expense_model.dart';
+
+/// 查看费用报销明细弹窗(只读)。
+///
+/// 使用 [TDSlidePopupRoute] 从底部滑出,展示报销明细的所有字段。
+class ExpenseDetailViewDialog extends StatelessWidget {
+  final ExpenseDetailModel detail;
+  final AppLocalizations l10n;
+
+  const ExpenseDetailViewDialog({
+    super.key,
+    required this.detail,
+    required this.l10n,
+  });
+
+  /// 显示弹窗。
+  static void show(BuildContext context, ExpenseDetailModel detail) {
+    final l10n = AppLocalizations.of(context);
+    FocusScope.of(context).unfocus();
+    Navigator.push(
+      context,
+      TDSlidePopupRoute<void>(
+        slideTransitionFrom: SlideTransitionFrom.bottom,
+        isDismissible: true,
+        builder: (_) => ExpenseDetailViewDialog(detail: detail, l10n: l10n),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final d = detail;
+
+    final catLabel = d.categoryName.isNotEmpty
+        ? '${d.expenseCategory}/${d.categoryName}'
+        : d.expenseCategory;
+
+    return Container(
+      decoration: BoxDecoration(
+        color: colors.bgCard,
+        borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+      ),
+      child: SafeArea(
+        top: false,
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            // 标题栏
+            Padding(
+              padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
+              child: Row(
+                children: [
+                  Expanded(child: Center(child: Text(l10n.get('expenseDetails'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)))),
+                  GestureDetector(
+                    onTap: () => Navigator.pop(context),
+                    child: Icon(Icons.close, color: colors.textSecondary),
+                  ),
+                ],
+              ),
+            ),
+            // 内容
+            Flexible(
+              child: SingleChildScrollView(
+                padding: const EdgeInsets.all(20),
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    _row(l10n.get('expenseCategory'), catLabel, colors),
+                    _row(l10n.get('amountInclTax'), '¥${d.totalAmount.toStringAsFixed(2)}', colors),
+                    if (d.approvedAmount > 0)
+                      _row(l10n.get('approvedAmount'), '¥${d.approvedAmount.toStringAsFixed(2)}', colors),
+                    _row(l10n.get('amountExcludingTax'), '¥${d.amount.toStringAsFixed(2)}', colors),
+                    if (d.taxAmount > 0)
+                      _row(l10n.get('taxAmount'), '¥${d.taxAmount.toStringAsFixed(2)}', colors),
+                    if (d.taxRate > 0)
+                      _row(l10n.get('taxRate'), '${d.taxRate.toStringAsFixed(0)}%', colors),
+                    _row(l10n.get('acctSubject'), d.acctSubjectId.isNotEmpty ? '${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}' : '-', colors),
+                    _row(l10n.get('project'), d.projectId.isNotEmpty && d.projectName.isNotEmpty ? '${d.projectId}/${d.projectName}' : '-', colors),
+                    _row(l10n.get('costDept'), d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty ? '${d.costDeptId}/${d.costDeptName}' : '-', colors),
+                    _row(l10n.get('customerVendor'), d.customerVendorName.isNotEmpty ? '${d.customerVendorId}/${d.customerVendorName}' : '-', colors),
+                    _row(l10n.get('expenseApplyNo'), d.aeNo.isNotEmpty ? d.aeNo : '-', colors),
+                    _row(l10n.get('applyDate'), d.aeDd.isNotEmpty ? d.aeDd : '-', colors),
+                    _row(l10n.get('applicant'), d.sqManName.isNotEmpty ? '${d.sqMan}/${d.sqManName}' : '-', colors),
+                    _row(l10n.get('bankName'), d.bankName.isNotEmpty ? d.bankName : '-', colors),
+                    _row(l10n.get('bankAccountName'), d.bankAccountName.isNotEmpty ? d.bankAccountName : '-', colors),
+                    _row(l10n.get('bankAccount'), d.bankAccount.isNotEmpty ? d.bankAccount : '-', colors),
+                    _row(l10n.get('remark'), d.remark.isNotEmpty ? d.remark : '-', colors),
+                  ],
+                ),
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _row(String label, String value, AppColorsExtension colors) {
+    return Padding(
+      padding: const EdgeInsets.only(bottom: 16),
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          SizedBox(
+            width: 100,
+            child: Text(label, style: TextStyle(fontSize: 14, color: colors.textSecondary)),
+          ),
+          const SizedBox(width: 12),
+          Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),
+        ],
+      ),
+    );
+  }
+}