|
|
@@ -6,13 +6,12 @@ import '../../shared/widgets/nav_bar_config.dart';
|
|
|
import '../../core/utils/date_utils.dart' as du;
|
|
|
import '../../shared/widgets/form_section.dart';
|
|
|
import '../../shared/widgets/form_field_row.dart';
|
|
|
-import '../../shared/widgets/status_banner.dart';
|
|
|
import 'expense_model.dart';
|
|
|
import '../../core/i18n/app_localizations.dart';
|
|
|
import '../../shared/widgets/loading_dialog.dart';
|
|
|
+import '../../shared/models/bill_attachment.dart';
|
|
|
import '../../core/theme/app_colors.dart';
|
|
|
import '../../core/theme/app_colors_extension.dart';
|
|
|
-import '../../core/auth/role_provider.dart';
|
|
|
import 'expense_api.dart';
|
|
|
import '../../shared/widgets/approval_actions.dart';
|
|
|
import '../../shared/widgets/approval_timeline.dart';
|
|
|
@@ -29,6 +28,7 @@ class ExpenseDetailPage extends ConsumerStatefulWidget {
|
|
|
|
|
|
class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
ExpenseModel? _expense;
|
|
|
+ List<BillAttachment> _attachments = [];
|
|
|
List<ApprovalRecord> _timelineRecords = [];
|
|
|
List<String> _timelineChain = [];
|
|
|
String _timelineCurrentApproverId = '';
|
|
|
@@ -75,6 +75,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
_timelineCurrentApproverId = expense.currentApproverId;
|
|
|
});
|
|
|
}
|
|
|
+
|
|
|
+ // 3. 加载附件(非致命)
|
|
|
+ try {
|
|
|
+ _attachments = await api.getAttachments('BX', widget.billNo);
|
|
|
+ } catch (_) {
|
|
|
+ _attachments = [];
|
|
|
+ }
|
|
|
} catch (e) {
|
|
|
setState(() => _error = e.toString());
|
|
|
} finally {
|
|
|
@@ -86,8 +93,6 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
Widget build(BuildContext context) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final isFinance = ref.watch(isFinanceProvider);
|
|
|
- final isAdmin = ref.watch(isAdminProvider);
|
|
|
|
|
|
ref.read(navBarConfigProvider.notifier).update(
|
|
|
NavBarConfig(
|
|
|
@@ -138,25 +143,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
child: Column(
|
|
|
children: [
|
|
|
- _buildStatusBanner(expense, l10n, colors),
|
|
|
- const SizedBox(height: 8),
|
|
|
- _buildSubmitTime(expense, l10n, colors),
|
|
|
- const SizedBox(height: 16),
|
|
|
_buildBasicInfoSection(expense, l10n, colors),
|
|
|
const SizedBox(height: 16),
|
|
|
_buildExpenseDetailSection(expense, l10n, colors),
|
|
|
const SizedBox(height: 16),
|
|
|
- _buildAttachmentSection(expense, l10n, colors),
|
|
|
- if (isFinance) ...[
|
|
|
- const SizedBox(height: 16),
|
|
|
- _buildComplianceSection(expense, l10n, colors),
|
|
|
- ],
|
|
|
+ _buildAttachmentSection(l10n, colors),
|
|
|
const SizedBox(height: 16),
|
|
|
_buildApprovalSection(l10n, colors),
|
|
|
- if (isFinance || isAdmin) ...[
|
|
|
- const SizedBox(height: 16),
|
|
|
- _buildArchiveSection(expense, l10n, colors),
|
|
|
- ],
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
@@ -175,45 +168,22 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
|
|
|
void _handleApprove() async {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final confirmed = await showDialog<bool>(
|
|
|
- context: context,
|
|
|
- builder: (ctx) => TDAlertDialog(
|
|
|
- title: l10n.get('confirmApprove'),
|
|
|
- content: l10n.get('approvalComment'),
|
|
|
- leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
|
|
|
- rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
|
|
|
- ),
|
|
|
+ final rem = await _showOpinionDialog(
|
|
|
+ title: l10n.get('confirmApprove'),
|
|
|
+ hint: l10n.get('approvalComment'),
|
|
|
);
|
|
|
- if (confirmed != true || !mounted) return;
|
|
|
- await _doAudit('approve');
|
|
|
+ if (rem == null || !mounted) return;
|
|
|
+ await _doAudit('approve', rem: rem);
|
|
|
}
|
|
|
|
|
|
void _handleReject() async {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final confirmed = await showDialog<bool>(
|
|
|
- context: context,
|
|
|
- builder: (ctx) => TDAlertDialog(
|
|
|
- title: l10n.get('confirmReject'),
|
|
|
- content: l10n.get('approvalComment'),
|
|
|
- leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
|
|
|
- rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
|
|
|
- ),
|
|
|
- );
|
|
|
- if (confirmed != true || !mounted) return;
|
|
|
- // Open reason input
|
|
|
- final remResult = await showDialog<String>(
|
|
|
- context: context,
|
|
|
- builder: (ctx) {
|
|
|
- final ctrl = TextEditingController();
|
|
|
- return TDAlertDialog(
|
|
|
- title: l10n.get('rejectReason'),
|
|
|
- content: l10n.get('pleaseEnter'),
|
|
|
- leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
|
|
|
- rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, ctrl.text)),
|
|
|
- );
|
|
|
- },
|
|
|
+ final rem = await _showOpinionDialog(
|
|
|
+ title: l10n.get('confirmReject'),
|
|
|
+ hint: l10n.get('rejectReason'),
|
|
|
);
|
|
|
- await _doAudit('reject', rem: remResult ?? '');
|
|
|
+ if (rem == null || !mounted) return;
|
|
|
+ await _doAudit('reject', rem: rem);
|
|
|
}
|
|
|
|
|
|
void _handleReverseAudit() async {
|
|
|
@@ -228,7 +198,35 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
),
|
|
|
);
|
|
|
if (confirmed != true || !mounted) return;
|
|
|
- await _doAudit('reverseAudit');
|
|
|
+ final rem = await _showOpinionDialog(
|
|
|
+ title: l10n.get('withdrawConfirm'),
|
|
|
+ hint: l10n.get('approvalComment'),
|
|
|
+ );
|
|
|
+ if (rem == null || !mounted) return;
|
|
|
+ await _doAudit('reverseAudit', rem: rem);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 弹出审批意见输入框,返回 null 表示取消
|
|
|
+ Future<String?> _showOpinionDialog({
|
|
|
+ required String title,
|
|
|
+ required String hint,
|
|
|
+ }) async {
|
|
|
+ final ctrl = TextEditingController();
|
|
|
+ return showDialog<String>(
|
|
|
+ context: context,
|
|
|
+ builder: (ctx) => TDAlertDialog(
|
|
|
+ title: title,
|
|
|
+ content: hint,
|
|
|
+ leftBtn: TDDialogButtonOptions(
|
|
|
+ title: AppLocalizations.of(context).get('cancel'),
|
|
|
+ action: () => Navigator.pop(ctx),
|
|
|
+ ),
|
|
|
+ rightBtn: TDDialogButtonOptions(
|
|
|
+ title: AppLocalizations.of(context).get('confirm'),
|
|
|
+ action: () => Navigator.pop(ctx, ctrl.text),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
Future<void> _doAudit(String action, {String rem = ''}) async {
|
|
|
@@ -249,56 +247,9 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // ════════════════════════════════════════════════════════════════
|
|
|
- // 以下为展示组件,结构与原版一致,数据来源改为 _expense
|
|
|
- // ════════════════════════════════════════════════════════════════
|
|
|
-
|
|
|
- Widget _buildStatusBanner(
|
|
|
- ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
- final (icon, color, label) = switch (expense.status) {
|
|
|
- 'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
|
|
|
- 'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
|
|
|
- 'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
|
|
|
- _ => (Icons.schedule, colors.warning, l10n.get('pending')),
|
|
|
- };
|
|
|
- final approverText = switch (expense.status) {
|
|
|
- 'approved' when expense.approvalRecords.isNotEmpty =>
|
|
|
- '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}',
|
|
|
- 'rejected' when expense.approvalRecords.isNotEmpty =>
|
|
|
- '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}',
|
|
|
- 'pending' when expense.currentApproverId.isNotEmpty =>
|
|
|
- '${l10n.get('currentApprover')}:${expense.currentApproverId}',
|
|
|
- _ => '',
|
|
|
- };
|
|
|
- return StatusBanner(
|
|
|
- icon: icon, statusText: label, subText: approverText, color: color);
|
|
|
- }
|
|
|
-
|
|
|
- Widget _buildSubmitTime(
|
|
|
- ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
- return Padding(
|
|
|
- padding: const EdgeInsets.only(left: 4),
|
|
|
- child: Text(
|
|
|
- '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
|
|
|
- style:
|
|
|
- TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- // ═══ 基本信息 + 收款账户 — 对应 create 页 basicInfo + 数据库 Expense 字段 ═══
|
|
|
+ // ═══ 基本信息 ═══
|
|
|
Widget _buildBasicInfoSection(
|
|
|
ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
- var pms = expense.paymentMethod;
|
|
|
- if (pms == 'bankTransfer') {
|
|
|
- pms = l10n.get('bankTransfer');
|
|
|
- } else if (pms == 'cash') {
|
|
|
- pms = l10n.get('cash');
|
|
|
- } else if (pms == 'alipay') {
|
|
|
- pms = l10n.get('alipay');
|
|
|
- } else if (pms == 'wechat') {
|
|
|
- pms = l10n.get('wechat');
|
|
|
- }
|
|
|
return FormSection(
|
|
|
title: l10n.get('basicInfo'),
|
|
|
leadingIcon: Icons.info_outline,
|
|
|
@@ -310,61 +261,91 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
showArrow: false),
|
|
|
const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('applicant'),
|
|
|
- value: expense.applicantName,
|
|
|
+ label: l10n.get('voucherNo'),
|
|
|
+ value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
|
|
|
readOnly: true,
|
|
|
showArrow: false),
|
|
|
const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('department'),
|
|
|
- value: expense.deptName,
|
|
|
+ label: l10n.get('expensePersonnel'),
|
|
|
+ value: expense.applicantId.isNotEmpty
|
|
|
+ ? '${expense.applicantId}/${expense.applicantName}'
|
|
|
+ : expense.applicantName,
|
|
|
readOnly: true,
|
|
|
showArrow: false),
|
|
|
const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('date'),
|
|
|
- value: du.DateUtils.formatDateTime(expense.createTime),
|
|
|
+ label: l10n.get('expenseDept'),
|
|
|
+ value: expense.deptId.isNotEmpty
|
|
|
+ ? '${expense.deptId}/${expense.deptName}'
|
|
|
+ : expense.deptName,
|
|
|
readOnly: true,
|
|
|
showArrow: false),
|
|
|
const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('currency'),
|
|
|
- value: expense.currencyCode,
|
|
|
+ label: l10n.get('date'),
|
|
|
+ value: du.DateUtils.formatDateTime(expense.createTime),
|
|
|
readOnly: true,
|
|
|
showArrow: false),
|
|
|
const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('feeReason'),
|
|
|
- value: expense.purpose,
|
|
|
+ label: l10n.get('currency'),
|
|
|
+ value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
|
|
|
readOnly: true,
|
|
|
showArrow: false),
|
|
|
const SizedBox(height: 16),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('expenseAmount'),
|
|
|
- value: '¥${expense.totalAmount.toStringAsFixed(2)}',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false),
|
|
|
- if (expense.approvedAmount > 0) ...[
|
|
|
- const SizedBox(height: 16),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('approvedAmount'),
|
|
|
- value: '¥${expense.approvedAmount.toStringAsFixed(2)}',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false),
|
|
|
- ],
|
|
|
+ SizedBox(
|
|
|
+ height: 24,
|
|
|
+ child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
|
|
+ Text(l10n.get('feeReason'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Expanded(
|
|
|
+ child: Text(expense.purpose.isNotEmpty ? expense.purpose : '-',
|
|
|
+ textAlign: TextAlign.end,
|
|
|
+ maxLines: 2,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
+ ),
|
|
|
const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
label: l10n.get('paymentMethod'),
|
|
|
- value: pms.isNotEmpty ? pms : '-',
|
|
|
+ value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
|
|
|
readOnly: true,
|
|
|
showArrow: false),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ SizedBox(
|
|
|
+ height: 24,
|
|
|
+ child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
|
|
+ Text(l10n.get('remark'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Expanded(
|
|
|
+ child: Text(expense.remark.isNotEmpty ? expense.remark : '-',
|
|
|
+ textAlign: TextAlign.end,
|
|
|
+ maxLines: 2,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
+ ),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ═══ 费用明细 — 对应 create 页 detailSection + 数据库 ExpenseDetail ═══
|
|
|
+ // ═══ 费用明细 ═══
|
|
|
Widget _buildExpenseDetailSection(
|
|
|
ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ final totalAmount = expense.details.fold<double>(
|
|
|
+ 0,
|
|
|
+ (sum, d) => sum + d.totalAmount,
|
|
|
+ );
|
|
|
+ final totalApproved = expense.details.fold<double>(
|
|
|
+ 0,
|
|
|
+ (sum, d) => sum + d.approvedAmount,
|
|
|
+ );
|
|
|
return FormSection(
|
|
|
title: l10n.get('expenseDetails'),
|
|
|
leadingIcon: Icons.receipt_long_outlined,
|
|
|
@@ -372,254 +353,169 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
if (expense.details.isEmpty)
|
|
|
Padding(
|
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
|
- child: Text(
|
|
|
- l10n.get('noDetailData'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body, color: colors.textPlaceholder),
|
|
|
- ),
|
|
|
+ child: Text(l10n.get('noDetailData'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
|
|
|
)
|
|
|
else
|
|
|
...expense.details.asMap().entries.map((e) {
|
|
|
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(
|
|
|
- margin: const EdgeInsets.symmetric(vertical: 8),
|
|
|
+ margin: const EdgeInsets.symmetric(vertical: 6),
|
|
|
padding: const EdgeInsets.all(12),
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(8)),
|
|
|
- child: Column(
|
|
|
- crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
- children: [
|
|
|
- Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
|
- Expanded(
|
|
|
- child: Text(
|
|
|
- d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.subtitle,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.textPrimary),
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 16),
|
|
|
- Text(
|
|
|
- '¥${d.totalAmount.toStringAsFixed(2)}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.subtitle,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.amountPrimary),
|
|
|
- ),
|
|
|
- ]),
|
|
|
- const SizedBox(height: 4),
|
|
|
- Text(
|
|
|
- '¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)}${d.bankName.isNotEmpty ? ' | ${d.bankName}' : ''}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.textSecondary),
|
|
|
- ),
|
|
|
- if (d.projectName.isNotEmpty) ...[
|
|
|
- const SizedBox(height: 4),
|
|
|
- Text(
|
|
|
- '${l10n.get('relatedProject')}:${d.projectName} | ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.textSecondary),
|
|
|
- ),
|
|
|
- ],
|
|
|
- if (d.costDeptName.isNotEmpty) ...[
|
|
|
- const SizedBox(height: 4),
|
|
|
- Text(
|
|
|
- '${l10n.get('costDept')}:${d.costDeptName}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.textSecondary),
|
|
|
- ),
|
|
|
- ],
|
|
|
- if (d.customerVendorName.isNotEmpty) ...[
|
|
|
- const SizedBox(height: 4),
|
|
|
- Text(
|
|
|
- '${l10n.get('customerVendor')}:${d.customerVendorName}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.textSecondary),
|
|
|
- ),
|
|
|
- ],
|
|
|
- if (d.approvedAmount > 0) ...[
|
|
|
- const SizedBox(height: 4),
|
|
|
- Text(
|
|
|
- '${l10n.get('approvedAmount')}:¥${d.approvedAmount.toStringAsFixed(2)}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.success),
|
|
|
- ),
|
|
|
- ],
|
|
|
- if (d.remark.isNotEmpty) ...[
|
|
|
- const SizedBox(height: 4),
|
|
|
- Text(
|
|
|
- d.remark,
|
|
|
- maxLines: 2,
|
|
|
- overflow: TextOverflow.ellipsis,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.textSecondary),
|
|
|
- ),
|
|
|
- ],
|
|
|
- if (d.attachments.isNotEmpty) ...[
|
|
|
- const SizedBox(height: 8),
|
|
|
- Wrap(
|
|
|
- spacing: 6,
|
|
|
- runSpacing: 6,
|
|
|
- children: d.attachments.map((path) {
|
|
|
- final name = path.split('/').last.split('\\').last;
|
|
|
- return Container(
|
|
|
- width: 60,
|
|
|
- height: 60,
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.primaryLight,
|
|
|
- borderRadius: BorderRadius.circular(4),
|
|
|
+ decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ Expanded(
|
|
|
+ child: Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ Row(
|
|
|
+ children: [
|
|
|
+ Expanded(
|
|
|
+ child: Text(title,
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textPrimary)),
|
|
|
),
|
|
|
- child: Column(
|
|
|
- mainAxisAlignment: MainAxisAlignment.center,
|
|
|
+ Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
children: [
|
|
|
- Icon(_fileTypeIcon(path),
|
|
|
- size: 24, color: colors.primary),
|
|
|
- const SizedBox(height: 2),
|
|
|
- Padding(
|
|
|
- padding:
|
|
|
- const EdgeInsets.symmetric(horizontal: 2),
|
|
|
- child: Text(
|
|
|
- name,
|
|
|
- maxLines: 1,
|
|
|
- overflow: TextOverflow.ellipsis,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: 9,
|
|
|
- color: colors.textSecondary),
|
|
|
- ),
|
|
|
- ),
|
|
|
+ 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)),
|
|
|
],
|
|
|
),
|
|
|
- );
|
|
|
- }).toList(),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ]),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ 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.approvedAmount > 0)
|
|
|
+ Text('${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)}',
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w500, color: colors.success)),
|
|
|
+ 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}',
|
|
|
+ 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('dept')}: ${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}',
|
|
|
+ maxLines: 1, overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
|
|
|
+ if (d.remark.isNotEmpty)
|
|
|
+ Text(d.remark,
|
|
|
+ maxLines: 2, overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
);
|
|
|
}),
|
|
|
if (expense.details.isNotEmpty) ...[
|
|
|
const SizedBox(height: 8),
|
|
|
- Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
|
|
+ Text(l10n.get('total'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
|
|
|
+ Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
children: [
|
|
|
- Text(
|
|
|
- l10n.get('total'),
|
|
|
- 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),
|
|
|
- ),
|
|
|
- ]),
|
|
|
+ Text('¥${totalAmount.toStringAsFixed(2)}',
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
|
|
|
+ if (totalApproved > 0)
|
|
|
+ Text('${l10n.get('approvedAmount')} ¥${totalApproved.toStringAsFixed(2)}',
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.success)),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
],
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ═══ 附件 ═══
|
|
|
- Widget _buildAttachmentSection(
|
|
|
- ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ final headerAtts = _attachments.where((a) => a.isHeader).toList();
|
|
|
+ final bodyGroups = <int, List<BillAttachment>>{};
|
|
|
+ for (final a in _attachments.where((a) => a.isBody)) {
|
|
|
+ bodyGroups.putIfAbsent(a.srcItm, () => []).add(a);
|
|
|
+ }
|
|
|
+
|
|
|
+ final children = <Widget>[];
|
|
|
+ if (_attachments.isEmpty) {
|
|
|
+ children.add(Text(l10n.get('noAttachment'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
|
|
|
+ } else {
|
|
|
+ // 表头附件
|
|
|
+ if (headerAtts.isNotEmpty) {
|
|
|
+ children.add(Padding(
|
|
|
+ padding: const EdgeInsets.only(bottom: 8),
|
|
|
+ child: Text(l10n.get('headerAttachments'),
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
|
|
|
+ ));
|
|
|
+ for (final a in headerAtts) {
|
|
|
+ children.add(_buildAttachmentRow(a, colors));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 表身附件(按明细行分组)
|
|
|
+ for (final entry in bodyGroups.entries) {
|
|
|
+ children.add(const SizedBox(height: 8));
|
|
|
+ children.add(Padding(
|
|
|
+ padding: const EdgeInsets.only(bottom: 8),
|
|
|
+ child: Text('${l10n.get('detailLine')} ${entry.key}',
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
|
|
|
+ ));
|
|
|
+ for (final a in entry.value) {
|
|
|
+ children.add(_buildAttachmentRow(a, colors));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
return FormSection(
|
|
|
title: l10n.get('attachments'),
|
|
|
leadingIcon: Icons.attach_file_outlined,
|
|
|
- children: [
|
|
|
- if (expense.attachments.isEmpty)
|
|
|
- Text(l10n.get('noAttachment'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body, color: colors.textPlaceholder))
|
|
|
- else
|
|
|
- Wrap(
|
|
|
- spacing: 8,
|
|
|
- runSpacing: 8,
|
|
|
- children: expense.attachments.map((path) {
|
|
|
- final name = path.split('/').last.split('\\').last;
|
|
|
- return SizedBox(
|
|
|
- width: 80,
|
|
|
- child: Column(
|
|
|
- mainAxisSize: MainAxisSize.min,
|
|
|
- children: [
|
|
|
- Container(
|
|
|
- width: 80,
|
|
|
- height: 80,
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(4),
|
|
|
- border: Border.all(color: colors.border),
|
|
|
- ),
|
|
|
- child: Center(
|
|
|
- child: Icon(
|
|
|
- _fileTypeIcon(path),
|
|
|
- size: 28,
|
|
|
- color: colors.primary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(height: 4),
|
|
|
- Text(
|
|
|
- name,
|
|
|
- maxLines: 1,
|
|
|
- overflow: TextOverflow.ellipsis,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.textSecondary),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- );
|
|
|
- }).toList()),
|
|
|
- ],
|
|
|
+ children: children,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ═══ 财务合规查验 ═══
|
|
|
- Widget _buildComplianceSection(
|
|
|
- ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
- final checks = [
|
|
|
- (expense.isInvoiceVerified, l10n.get('invoiceCheck1')),
|
|
|
- (expense.isTaxIdMatched, l10n.get('invoiceCheck2')),
|
|
|
- (expense.isCategoryCompliant, l10n.get('invoiceCheck3')),
|
|
|
- ];
|
|
|
- return FormSection(
|
|
|
- title: l10n.get('invoiceCheck'),
|
|
|
- leadingIcon: Icons.verified_outlined,
|
|
|
- children: checks.asMap().entries.map((e) {
|
|
|
- final (passed, text) = e.value;
|
|
|
- return Padding(
|
|
|
- padding: EdgeInsets.only(top: e.key > 0 ? 12 : 0),
|
|
|
- child: SizedBox(
|
|
|
- height: 24,
|
|
|
- child: Row(children: [
|
|
|
- Icon(
|
|
|
- passed
|
|
|
- ? Icons.check_circle
|
|
|
- : Icons.radio_button_unchecked,
|
|
|
- size: 16,
|
|
|
- color: passed
|
|
|
- ? colors.success
|
|
|
- : colors.textPlaceholder,
|
|
|
- ),
|
|
|
- const SizedBox(width: 8),
|
|
|
- Text(text,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.subtitle,
|
|
|
- color: colors.textPrimary)),
|
|
|
- ]),
|
|
|
- ),
|
|
|
- );
|
|
|
- }).toList(),
|
|
|
+ Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
|
|
|
+ return Container(
|
|
|
+ margin: const EdgeInsets.symmetric(vertical: 4),
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.bgPage,
|
|
|
+ borderRadius: BorderRadius.circular(8),
|
|
|
+ ),
|
|
|
+ child: Row(children: [
|
|
|
+ Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary),
|
|
|
+ const SizedBox(width: 10),
|
|
|
+ Expanded(
|
|
|
+ child: Text(a.fileName,
|
|
|
+ maxLines: 1, overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -639,53 +535,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ═══ 财务归档 ═══
|
|
|
- Widget _buildArchiveSection(
|
|
|
- ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
- return FormSection(
|
|
|
- title: l10n.get('financialArchive'),
|
|
|
- leadingIcon: Icons.archive_outlined,
|
|
|
- children: [
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('voucherNo'),
|
|
|
- value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false),
|
|
|
- const SizedBox(height: 16),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('bankTransferNo'),
|
|
|
- value:
|
|
|
- expense.bankTransferNo.isNotEmpty ? expense.bankTransferNo : '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false),
|
|
|
- const SizedBox(height: 16),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('paymentStatus'),
|
|
|
- value: expense.paymentStatus == 'paid'
|
|
|
- ? l10n.get('paid')
|
|
|
- : l10n.get('unpaid'),
|
|
|
- readOnly: true,
|
|
|
- showArrow: false),
|
|
|
- ],
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- IconData _fileTypeIcon(String path) {
|
|
|
- final ext = path.split('.').last.toLowerCase();
|
|
|
- switch (ext) {
|
|
|
- case 'pdf':
|
|
|
- return Icons.picture_as_pdf;
|
|
|
- case 'doc':
|
|
|
- case 'docx':
|
|
|
- return Icons.description;
|
|
|
- case 'xls':
|
|
|
- case 'xlsx':
|
|
|
- return Icons.table_chart;
|
|
|
- case 'ppt':
|
|
|
- case 'pptx':
|
|
|
- return Icons.slideshow;
|
|
|
- default:
|
|
|
- return Icons.insert_drive_file;
|
|
|
+ IconData _fileTypeIcon(String ext) {
|
|
|
+ switch (ext.toLowerCase()) {
|
|
|
+ case 'pdf': return Icons.picture_as_pdf;
|
|
|
+ case 'doc': case 'docx': return Icons.description;
|
|
|
+ case 'xls': case 'xlsx': return Icons.table_chart;
|
|
|
+ case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
|
|
|
+ default: return Icons.insert_drive_file;
|
|
|
}
|
|
|
}
|
|
|
}
|