|
|
@@ -8,7 +8,6 @@ import '../../shared/widgets/form_section.dart';
|
|
|
import '../../shared/widgets/form_field_row.dart';
|
|
|
import '../../shared/widgets/status_banner.dart';
|
|
|
import '../../shared/widgets/action_bar.dart';
|
|
|
-import '../../shared/widgets/approval_timeline.dart';
|
|
|
import 'expense_model.dart';
|
|
|
import '../../core/i18n/app_localizations.dart';
|
|
|
import 'expense_list_controller.dart';
|
|
|
@@ -28,7 +27,6 @@ class ExpenseDetailPage extends ConsumerWidget {
|
|
|
orElse: () => mockExpenses.first,
|
|
|
);
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
-
|
|
|
final isFinance = ref.watch(isFinanceProvider);
|
|
|
final isAdmin = ref.watch(isAdminProvider);
|
|
|
|
|
|
@@ -49,24 +47,24 @@ class ExpenseDetailPage extends ConsumerWidget {
|
|
|
child: Column(
|
|
|
children: [
|
|
|
_buildStatusBanner(expense, l10n, colors),
|
|
|
- const SizedBox(height: 4),
|
|
|
+ const SizedBox(height: 8),
|
|
|
_buildSubmitTime(expense, l10n, colors),
|
|
|
const SizedBox(height: 16),
|
|
|
_buildBasicInfoSection(expense, l10n, colors),
|
|
|
const SizedBox(height: 16),
|
|
|
- _buildAccountSection(expense, l10n),
|
|
|
- const SizedBox(height: 16),
|
|
|
- _buildDetailSection(expense, l10n, colors),
|
|
|
+ _buildExpenseDetailSection(expense, l10n, colors),
|
|
|
const SizedBox(height: 16),
|
|
|
_buildInvoiceSection(expense, l10n, colors),
|
|
|
+ if (isFinance) ...[
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ _buildComplianceSection(expense, l10n, colors),
|
|
|
+ ],
|
|
|
const SizedBox(height: 16),
|
|
|
- if (isFinance) _buildComplianceSection(expense, l10n, colors),
|
|
|
- const SizedBox(height: 16),
|
|
|
- if (expense.approvalRecords.isNotEmpty ||
|
|
|
- expense.approvalChain.isNotEmpty)
|
|
|
- _buildApprovalSection(expense, l10n),
|
|
|
- const SizedBox(height: 16),
|
|
|
- if (isFinance || isAdmin) _buildArchiveSection(expense, l10n),
|
|
|
+ _buildApprovalSection(l10n, colors),
|
|
|
+ if (isFinance || isAdmin) ...[
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ _buildArchiveSection(expense, l10n, colors),
|
|
|
+ ],
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
@@ -76,11 +74,7 @@ class ExpenseDetailPage extends ConsumerWidget {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildStatusBanner(
|
|
|
- ExpenseModel expense,
|
|
|
- AppLocalizations l10n,
|
|
|
- AppColorsExtension colors,
|
|
|
- ) {
|
|
|
+ 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')),
|
|
|
@@ -88,471 +82,296 @@ class ExpenseDetailPage extends ConsumerWidget {
|
|
|
_ => (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}',
|
|
|
+ '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,
|
|
|
- );
|
|
|
+ return StatusBanner(icon: icon, statusText: label, subText: approverText, color: color);
|
|
|
}
|
|
|
|
|
|
- Widget _buildSubmitTime(
|
|
|
- ExpenseModel expense,
|
|
|
- AppLocalizations l10n,
|
|
|
- AppColorsExtension colors,
|
|
|
- ) {
|
|
|
+ Widget _buildSubmitTime(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
return Padding(
|
|
|
- padding: const EdgeInsets.only(left: 4, top: 4),
|
|
|
- child: Align(
|
|
|
- alignment: Alignment.centerLeft,
|
|
|
- child: Text(
|
|
|
- '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- color: colors.textPlaceholder,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
+ padding: const EdgeInsets.only(left: 4),
|
|
|
+ child: Text('${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildBasicInfoSection(
|
|
|
- ExpenseModel expense,
|
|
|
- AppLocalizations l10n,
|
|
|
- AppColorsExtension colors,
|
|
|
- ) {
|
|
|
+ // ═══ 基本信息 + 收款账户 — 对应 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,
|
|
|
children: [
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('applicant'),
|
|
|
- value: expense.applicantName,
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('department'),
|
|
|
- value: expense.deptName,
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('expenseType'),
|
|
|
- value: '费用报销',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- Container(
|
|
|
- height: 44,
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- l10n.get('expenseAmount'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- Text(
|
|
|
- '¥${expense.totalAmount.toStringAsFixed(2)}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.subtitle,
|
|
|
- fontWeight: FontWeight.w700,
|
|
|
- color: colors.amountPrimary,
|
|
|
- ),
|
|
|
+ FormFieldRow(label: l10n.get('expenseNo'), value: expense.expenseNo, readOnly: true, showArrow: false),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ FormFieldRow(label: l10n.get('applicant'), value: expense.applicantName, readOnly: true, showArrow: false),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ FormFieldRow(label: l10n.get('department'), value: expense.deptName, readOnly: true, showArrow: false),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(expense.createTime), readOnly: true, showArrow: false),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ FormFieldRow(label: l10n.get('currency'), value: expense.currencyCode, readOnly: true, showArrow: false),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ FormFieldRow(label: l10n.get('feeReason'), value: expense.purpose, 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),
|
|
|
+ ],
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ FormFieldRow(label: l10n.get('paymentMethod'), value: pms.isNotEmpty ? pms : '-', readOnly: true, showArrow: false),
|
|
|
+ if (expense.details.any((d) => d.bankName.isNotEmpty)) ...[
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ FormSection(title: l10n.get('receiptAccount'), leadingIcon: Icons.account_balance_outlined, children: [
|
|
|
+ for (final d in expense.details.where((d) => d.bankName.isNotEmpty))
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.only(bottom: 12),
|
|
|
+ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
|
+ Text('${d.bankName}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
|
|
|
+ const SizedBox(height: 4),
|
|
|
+ Text('${d.bankAccountName} ${d.bankAccount}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
|
|
|
+ ]),
|
|
|
),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('relatedProject'),
|
|
|
- value: '' as String? ?? '', // moved to detail
|
|
|
- hint: '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('budgetSubject'),
|
|
|
- value: ''.isNotEmpty
|
|
|
- ? ''
|
|
|
- : null,
|
|
|
- hint: '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- ],
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- Widget _buildAccountSection(ExpenseModel expense, AppLocalizations l10n) {
|
|
|
- return FormSection(
|
|
|
- title: l10n.get('receiptAccount'),
|
|
|
- children: [
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('bankName'),
|
|
|
- value: '' as String? ?? '', // moved to detail
|
|
|
- hint: '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('accountName'),
|
|
|
- value: '' as String? ?? '', // moved to detail
|
|
|
- hint: '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('bankAccount'),
|
|
|
- value: '' as String? ?? '', // moved to detail
|
|
|
- hint: '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
+ ]),
|
|
|
+ ],
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildDetailSection(
|
|
|
- ExpenseModel expense,
|
|
|
- AppLocalizations l10n,
|
|
|
- AppColorsExtension colors,
|
|
|
- ) {
|
|
|
+ // ═══ 费用明细 — 对应 create 页 detailSection + 数据库 ExpenseDetail ═══
|
|
|
+ Widget _buildExpenseDetailSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
return FormSection(
|
|
|
title: l10n.get('expenseDetails'),
|
|
|
+ leadingIcon: Icons.receipt_long_outlined,
|
|
|
children: [
|
|
|
- // Table header
|
|
|
- Container(
|
|
|
- height: 36,
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(4),
|
|
|
- ),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- flex: 3,
|
|
|
- child: Text(
|
|
|
- l10n.get('expenseProject'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- fontWeight: FontWeight.w500,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- Expanded(
|
|
|
- flex: 2,
|
|
|
- child: Text(
|
|
|
- l10n.get('amount'),
|
|
|
- textAlign: TextAlign.right,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.caption,
|
|
|
- fontWeight: FontWeight.w500,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
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.map(
|
|
|
- (d) => SizedBox(
|
|
|
- height: 28,
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- flex: 3,
|
|
|
- child: Text(
|
|
|
- d.purpose,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
+ ...expense.details.asMap().entries.map((e) {
|
|
|
+ final d = e.value;
|
|
|
+ return Container(
|
|
|
+ margin: const EdgeInsets.symmetric(vertical: 8),
|
|
|
+ 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(
|
|
|
- flex: 2,
|
|
|
- child: Text(
|
|
|
- '¥${d.totalAmount.toStringAsFixed(2)}',
|
|
|
- textAlign: TextAlign.right,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- fontWeight: FontWeight.w500,
|
|
|
- color: colors.amountPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
+ 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 (expense.details.isNotEmpty) ...[
|
|
|
- Container(height: 1, color: colors.border),
|
|
|
- Container(
|
|
|
- height: 36,
|
|
|
- padding: const EdgeInsets.symmetric(vertical: 8),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- l10n.get('total'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- fontWeight: FontWeight.w600,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- Text(
|
|
|
- '¥${expense.totalAmount.toStringAsFixed(2)}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.subtitle,
|
|
|
- fontWeight: FontWeight.w700,
|
|
|
- color: colors.amountPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
|
|
+ Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
|
|
|
+ Text('¥${expense.totalAmount.toStringAsFixed(2)}',
|
|
|
+ style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
|
|
|
+ ]),
|
|
|
],
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildInvoiceSection(
|
|
|
- ExpenseModel expense,
|
|
|
- AppLocalizations l10n,
|
|
|
- AppColorsExtension colors,
|
|
|
- ) {
|
|
|
- final hasInvoices = expense.attachments.isNotEmpty;
|
|
|
-
|
|
|
+ // ═══ 发票附件 ═══
|
|
|
+ Widget _buildInvoiceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
return FormSection(
|
|
|
title: l10n.get('invoiceAttachment'),
|
|
|
+ leadingIcon: Icons.attach_file_outlined,
|
|
|
children: [
|
|
|
- if (!hasInvoices)
|
|
|
- Padding(
|
|
|
- padding: const EdgeInsets.symmetric(vertical: 8),
|
|
|
- child: Text(
|
|
|
- l10n.get('noInvoice'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- color: colors.textPlaceholder,
|
|
|
- ),
|
|
|
- ),
|
|
|
- )
|
|
|
+ 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((url) {
|
|
|
- return Container(
|
|
|
- width: 80,
|
|
|
- height: 80,
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(4),
|
|
|
- border: Border.all(
|
|
|
- color: colors.border,
|
|
|
- strokeAlign: BorderSide.strokeAlignInside,
|
|
|
- ),
|
|
|
- ),
|
|
|
- child: Center(
|
|
|
- child: Icon(
|
|
|
- Icons.image_outlined,
|
|
|
- size: 24,
|
|
|
- color: colors.textPlaceholder,
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
- }).toList(),
|
|
|
- ),
|
|
|
+ Wrap(spacing: 8, runSpacing: 8, children: expense.attachments.map((url) {
|
|
|
+ return Container(width: 80, height: 80,
|
|
|
+ decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
|
|
|
+ child: Center(child: Icon(Icons.image_outlined, size: 24, color: colors.textPlaceholder)));
|
|
|
+ }).toList()),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildComplianceSection(
|
|
|
- ExpenseModel expense,
|
|
|
- AppLocalizations l10n,
|
|
|
- AppColorsExtension colors,
|
|
|
- ) {
|
|
|
+ // ═══ 财务合规查验 ═══
|
|
|
+ Widget _buildComplianceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
final checks = [
|
|
|
- l10n.get('invoiceCheck1'),
|
|
|
- l10n.get('invoiceCheck2'),
|
|
|
- l10n.get('invoiceCheck3'),
|
|
|
- l10n.get('invoiceCheck4'),
|
|
|
+ (expense.isInvoiceVerified, l10n.get('invoiceCheck1')),
|
|
|
+ (expense.isTaxIdMatched, l10n.get('invoiceCheck2')),
|
|
|
+ (expense.isCategoryCompliant, l10n.get('invoiceCheck3')),
|
|
|
];
|
|
|
-
|
|
|
return FormSection(
|
|
|
title: l10n.get('invoiceCheck'),
|
|
|
- children: checks.map((text) {
|
|
|
- return SizedBox(
|
|
|
- height: 44,
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Icon(Icons.check_circle, size: 16, color: colors.success),
|
|
|
- const SizedBox(width: 8),
|
|
|
- Text(
|
|
|
- text,
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- color: colors.textPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ 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 _buildApprovalSection(ExpenseModel expense, AppLocalizations l10n) {
|
|
|
+ // ═══ 审核流程 — 时间线组件 ═══
|
|
|
+ Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[
|
|
|
+ (title: l10n.get('approvalStepSubmitted'), desc: l10n.get('approvalDescSubmitted'), time: '2026-06-29 09:15', icon: Icons.check_circle, iconColor: colors.success),
|
|
|
+ (title: l10n.get('approvalStepApproved'), desc: l10n.get('approvalDescApproved'), time: '2026-06-29 14:30', icon: Icons.check_circle, iconColor: colors.success),
|
|
|
+ (title: l10n.get('approvalStepInvoice'), desc: l10n.get('approvalDescInvoice'), time: null, icon: Icons.schedule, iconColor: colors.warning),
|
|
|
+ (title: l10n.get('approvalStepPayment'), desc: l10n.get('approvalStepPaymentDesc'), time: null, icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder),
|
|
|
+ ];
|
|
|
return FormSection(
|
|
|
title: l10n.get('approvalFlow'),
|
|
|
+ leadingIcon: Icons.fact_check_outlined,
|
|
|
children: [
|
|
|
- ApprovalTimeline(
|
|
|
- records: expense.approvalRecords,
|
|
|
- chain: expense.approvalChain,
|
|
|
- currentApproverId: expense.currentApproverId,
|
|
|
- ),
|
|
|
+ ...steps.asMap().entries.map((e) {
|
|
|
+ final s = e.value;
|
|
|
+ final isLast = e.key == steps.length - 1;
|
|
|
+ final isActive = e.key <= 2;
|
|
|
+ final iconColor = isActive ? s.iconColor : colors.textPlaceholder;
|
|
|
+ final textColor = isActive ? colors.textPrimary : colors.textPlaceholder;
|
|
|
+ final subColor = isActive ? colors.textSecondary : colors.textPlaceholder;
|
|
|
+ return IntrinsicHeight(
|
|
|
+ child: Row(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ SizedBox(
|
|
|
+ width: 24,
|
|
|
+ child: Column(
|
|
|
+ children: [
|
|
|
+ Container(
|
|
|
+ width: 24, height: 24,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled,
|
|
|
+ shape: BoxShape.circle,
|
|
|
+ ),
|
|
|
+ child: Icon(s.icon, size: 14, color: iconColor),
|
|
|
+ ),
|
|
|
+ if (!isLast)
|
|
|
+ Expanded(
|
|
|
+ child: Container(
|
|
|
+ width: 2,
|
|
|
+ margin: const EdgeInsets.symmetric(vertical: 4),
|
|
|
+ color: isActive ? s.iconColor.withAlpha(60) : colors.border,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(
|
|
|
+ child: Padding(
|
|
|
+ padding: EdgeInsets.only(bottom: isLast ? 0 : 16),
|
|
|
+ child: Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)),
|
|
|
+ const SizedBox(height: 4),
|
|
|
+ Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)),
|
|
|
+ if (s.time != null) ...[
|
|
|
+ const SizedBox(height: 2),
|
|
|
+ Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
|
|
|
+ ],
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n) {
|
|
|
+ // ═══ 财务归档 ═══
|
|
|
+ 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 : null,
|
|
|
- hint: '-',
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('archiveDate'),
|
|
|
- value: du.DateUtils.formatDate(expense.updateTime),
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
- FormFieldRow(
|
|
|
- label: l10n.get('archiver'),
|
|
|
- value: l10n.get('financeDept'),
|
|
|
- readOnly: true,
|
|
|
- showArrow: false,
|
|
|
- ),
|
|
|
+ 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),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildBottomBar(
|
|
|
- BuildContext context,
|
|
|
- ExpenseModel expense, {
|
|
|
- required bool isFinance,
|
|
|
- required bool isAdmin,
|
|
|
- }) {
|
|
|
+ Widget _buildBottomBar(BuildContext context, ExpenseModel expense, {required bool isFinance, required bool isAdmin}) {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final canWithdraw =
|
|
|
- expense.status == 'pending' || expense.status == 'draft';
|
|
|
-
|
|
|
- // 财务角色:已审批 + 未付款 → 显示打款归档按钮
|
|
|
- if (isFinance &&
|
|
|
- expense.status == 'approved' &&
|
|
|
- expense.paymentStatus == 'unpaid') {
|
|
|
+ if (isFinance && expense.status == 'approved' && expense.paymentStatus == 'unpaid') {
|
|
|
return ActionBar(
|
|
|
- showLeft: true,
|
|
|
- leftLabel: l10n.get('confirmPaymentAndArchive'),
|
|
|
- centerLabel: l10n.get('nextPendingPayment'),
|
|
|
- rightLabel: l10n.get('confirmPaymentAndArchive'),
|
|
|
+ showLeft: true, leftLabel: l10n.get('confirmPaymentAndArchive'),
|
|
|
+ centerLabel: l10n.get('nextPendingPayment'), rightLabel: l10n.get('confirmPaymentAndArchive'),
|
|
|
onLeftTap: () {
|
|
|
- showDialog(
|
|
|
- context: context,
|
|
|
- builder: (ctx) => TDAlertDialog(
|
|
|
- title: l10n.get('confirmPaymentAndArchive'),
|
|
|
- content: l10n.get('confirmPaymentAndArchiveTip'),
|
|
|
- leftBtn: TDDialogButtonOptions(
|
|
|
- title: l10n.get('cancel'),
|
|
|
- action: () => Navigator.pop(ctx),
|
|
|
- ),
|
|
|
- rightBtn: TDDialogButtonOptions(
|
|
|
- title: l10n.get('confirm'),
|
|
|
- action: () {
|
|
|
- Navigator.pop(ctx);
|
|
|
- TDToast.showText(
|
|
|
- l10n.get('paymentArchiveSuccess'),
|
|
|
- context: context,
|
|
|
- );
|
|
|
- context.pop();
|
|
|
- },
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
- },
|
|
|
- onCenterTap: () {
|
|
|
- TDToast.showText(
|
|
|
- l10n.get('allPaymentsProcessed'),
|
|
|
- context: context,
|
|
|
- );
|
|
|
+ showDialog(context: context, builder: (ctx) => TDAlertDialog(
|
|
|
+ title: l10n.get('confirmPaymentAndArchive'), content: l10n.get('confirmPaymentAndArchiveTip'),
|
|
|
+ leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
|
|
|
+ rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('paymentArchiveSuccess'), context: context); context.pop(); }),
|
|
|
+ ));
|
|
|
},
|
|
|
+ onCenterTap: () => TDToast.showText(l10n.get('allPaymentsProcessed'), context: context),
|
|
|
);
|
|
|
}
|
|
|
-
|
|
|
- // 非 employee/manager 角色不显示撤回按钮
|
|
|
- if (isFinance || isAdmin) {
|
|
|
- return const SizedBox.shrink();
|
|
|
- }
|
|
|
-
|
|
|
- if (!canWithdraw) {
|
|
|
- return const SizedBox.shrink();
|
|
|
- }
|
|
|
-
|
|
|
+ if (isFinance || isAdmin) return const SizedBox.shrink();
|
|
|
+ if (expense.status != 'pending' && expense.status != 'draft') return const SizedBox.shrink();
|
|
|
return ActionBar(
|
|
|
- showLeft: false,
|
|
|
- centerLabel: l10n.get('withdrawApplication'),
|
|
|
- rightLabel: l10n.get('submitApproval'),
|
|
|
+ showLeft: false, centerLabel: l10n.get('withdrawApplication'), rightLabel: l10n.get('submitApproval'),
|
|
|
onCenterTap: () {
|
|
|
- showDialog(
|
|
|
- context: context,
|
|
|
- builder: (ctx) => TDAlertDialog(
|
|
|
- title: l10n.get('withdrawConfirm'),
|
|
|
- content: l10n.get('withdrawConfirmTip'),
|
|
|
- leftBtn: TDDialogButtonOptions(
|
|
|
- title: l10n.get('cancel'),
|
|
|
- action: () => Navigator.pop(ctx),
|
|
|
- ),
|
|
|
- rightBtn: TDDialogButtonOptions(
|
|
|
- title: l10n.get('confirm'),
|
|
|
- action: () {
|
|
|
- Navigator.pop(ctx);
|
|
|
- TDToast.showText(l10n.get('withdrawn'), context: context);
|
|
|
- context.pop();
|
|
|
- },
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
+ showDialog(context: context, builder: (ctx) => TDAlertDialog(
|
|
|
+ title: l10n.get('withdrawConfirm'), content: l10n.get('withdrawConfirmTip'),
|
|
|
+ leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
|
|
|
+ rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); }),
|
|
|
+ ));
|
|
|
},
|
|
|
onRightTap: null,
|
|
|
);
|