|
|
@@ -6,12 +6,14 @@ import '../../shared/widgets/nav_bar_config.dart';
|
|
|
import '../../core/utils/responsive.dart';
|
|
|
import '../../shared/widgets/form_section.dart';
|
|
|
import '../../shared/widgets/form_field_row.dart';
|
|
|
-import '../../shared/widgets/action_bar.dart';
|
|
|
+
|
|
|
import 'expense_apply_controller.dart';
|
|
|
import '../../core/i18n/app_localizations.dart';
|
|
|
import 'expense_model.dart';
|
|
|
import '../../core/theme/app_colors.dart';
|
|
|
import '../../core/theme/app_colors_extension.dart';
|
|
|
+import '../../core/data/mock_api_data.dart';
|
|
|
+import 'widgets/expense_detail_edit_dialog.dart';
|
|
|
|
|
|
class ExpenseApplyPage extends ConsumerStatefulWidget {
|
|
|
final String? editId;
|
|
|
@@ -24,13 +26,20 @@ class ExpenseApplyPage extends ConsumerStatefulWidget {
|
|
|
class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
final _remarkController = TextEditingController();
|
|
|
final _purposeController = TextEditingController();
|
|
|
+ final _voucherNoController = TextEditingController();
|
|
|
final _bankNameController = TextEditingController(text: '中国银行');
|
|
|
final _accountNameController = TextEditingController(text: '张三');
|
|
|
|
|
|
+ // 关联管控
|
|
|
+ String? _selectedProjectName;
|
|
|
+ String? _selectedSubjectName;
|
|
|
+ double _availableBudget = 0;
|
|
|
+
|
|
|
@override
|
|
|
void dispose() {
|
|
|
_remarkController.dispose();
|
|
|
_purposeController.dispose();
|
|
|
+ _voucherNoController.dispose();
|
|
|
_bankNameController.dispose();
|
|
|
_accountNameController.dispose();
|
|
|
super.dispose();
|
|
|
@@ -54,6 +63,8 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
onBack: () => context.pop(),
|
|
|
),
|
|
|
);
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final bottomInset = MediaQuery.of(context).padding.bottom;
|
|
|
return Column(
|
|
|
children: [
|
|
|
Expanded(
|
|
|
@@ -64,15 +75,24 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
child: SingleChildScrollView(
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
child: Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
children: [
|
|
|
+ // 1. 导入事前申请链接
|
|
|
_buildImportLink(),
|
|
|
const SizedBox(height: 16),
|
|
|
+ // 2. 基本信息区
|
|
|
_buildBasicInfoSection(controller, state),
|
|
|
const SizedBox(height: 16),
|
|
|
+ // 3. 关联管控区
|
|
|
+ _buildControlSection(controller, state),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 4. 收款账户区
|
|
|
_buildAccountSection(controller, state),
|
|
|
const SizedBox(height: 16),
|
|
|
+ // 5. 报销明细区
|
|
|
_buildDetailSection(controller, state),
|
|
|
const SizedBox(height: 16),
|
|
|
+ // 6. 附件上传区
|
|
|
_buildInvoiceSection(controller, state),
|
|
|
],
|
|
|
),
|
|
|
@@ -80,11 +100,22 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
- _buildBottomButtons(controller, state),
|
|
|
+ // 7. 底部操作栏 + iOS 横条指示器背景
|
|
|
+ ColoredBox(
|
|
|
+ color: colors.bgCard,
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ _buildBottomButtons(controller, state),
|
|
|
+ if (bottomInset > 0) SizedBox(height: bottomInset),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ═══ 1. 导入事前申请链接 ═══
|
|
|
Widget _buildImportLink() {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
@@ -116,41 +147,197 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ═══ 2. 基本信息区 ═══
|
|
|
+ // 报销日期(只读)、报销单号(只读)、币别(TDPicker)、报销人员(只读)、报销部门(只读)、
|
|
|
+ // 支付方式(TDPicker)、凭证号码、备注
|
|
|
Widget _buildBasicInfoSection(
|
|
|
ExpenseApplyController controller,
|
|
|
ExpenseApplyState state,
|
|
|
) {
|
|
|
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
final expense = state.expense;
|
|
|
return FormSection(
|
|
|
title: l10n.get('basicInfo'),
|
|
|
+ leadingIcon: Icons.info_outline,
|
|
|
children: [
|
|
|
+ // 报销日期(只读)
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('expenseReason'),
|
|
|
- hint: l10n.get('enterExpenseReason'),
|
|
|
+ label: l10n.get('expenseDate'),
|
|
|
+ value: expense.applicationDate != null
|
|
|
+ ? '${expense.applicationDate!.year}-${expense.applicationDate!.month.toString().padLeft(2, '0')}-${expense.applicationDate!.day.toString().padLeft(2, '0')}'
|
|
|
+ : _today(),
|
|
|
+ readOnly: true,
|
|
|
+ showArrow: false,
|
|
|
),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 报销单号(只读)
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('relatedProject'),
|
|
|
- value: expense.projectName.isNotEmpty ? expense.projectName : null,
|
|
|
- hint: l10n.get('selectProject'),
|
|
|
- onTap: () {
|
|
|
- TDToast.showText(l10n.get('projectSelection'), context: context);
|
|
|
- },
|
|
|
+ label: l10n.get('reportNo'),
|
|
|
+ value: expense.reportNo.isNotEmpty ? expense.reportNo : null,
|
|
|
+ hint: l10n.get('autoGenerated'),
|
|
|
+ readOnly: true,
|
|
|
+ showArrow: false,
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 币别(TDPicker)
|
|
|
+ FormFieldRow(
|
|
|
+ label: l10n.get('currency'),
|
|
|
+ value: expense.currencyCode.isNotEmpty ? expense.currencyCode : 'CNY',
|
|
|
+ hint: l10n.get('selectCurrency'),
|
|
|
+ onTap: () => _showCurrencyPicker(controller, expense.currencyCode),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 报销人员(只读)
|
|
|
+ FormFieldRow(
|
|
|
+ label: l10n.get('applicant'),
|
|
|
+ value: expense.applicantName.isNotEmpty
|
|
|
+ ? expense.applicantName
|
|
|
+ : '张三',
|
|
|
+ readOnly: true,
|
|
|
+ showArrow: false,
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 报销部门(只读)
|
|
|
+ FormFieldRow(
|
|
|
+ label: l10n.get('department'),
|
|
|
+ value: expense.deptName.isNotEmpty ? expense.deptName : '技术部',
|
|
|
+ readOnly: true,
|
|
|
+ showArrow: false,
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 支付方式(TDPicker)
|
|
|
+ FormFieldRow(
|
|
|
+ label: l10n.get('paymentMethod'),
|
|
|
+ value: expense.paymentMethod.isNotEmpty
|
|
|
+ ? expense.paymentMethod
|
|
|
+ : null,
|
|
|
+ hint: l10n.get('selectPaymentMethod'),
|
|
|
+ onTap: () => _showPaymentMethodPicker(controller),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 凭证号码
|
|
|
+ FormFieldRow(
|
|
|
+ label: l10n.get('voucherNo'),
|
|
|
+ value: _voucherNoController.text.isNotEmpty
|
|
|
+ ? _voucherNoController.text
|
|
|
+ : null,
|
|
|
+ hint: l10n.get('enterVoucherNo'),
|
|
|
+ onTap: () => _showTextInput(
|
|
|
+ l10n.get('voucherNo'),
|
|
|
+ l10n.get('enterVoucherNo'),
|
|
|
+ (v) => setState(() {
|
|
|
+ _voucherNoController.text = v;
|
|
|
+ _voucherNoController.selection = TextSelection.fromPosition(
|
|
|
+ TextPosition(offset: v.length),
|
|
|
+ );
|
|
|
+ }),
|
|
|
+ initialText: _voucherNoController.text,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 备注
|
|
|
+ FormFieldRow(
|
|
|
+ label: l10n.get('remark'),
|
|
|
+ value: _remarkController.text.isNotEmpty
|
|
|
+ ? _remarkController.text
|
|
|
+ : null,
|
|
|
+ hint: l10n.get('enterRemark'),
|
|
|
+ onTap: () => _showTextInput(
|
|
|
+ l10n.get('remark'),
|
|
|
+ l10n.get('enterRemark'),
|
|
|
+ (v) => setState(() {
|
|
|
+ _remarkController.text = v;
|
|
|
+ _remarkController.selection = TextSelection.fromPosition(
|
|
|
+ TextPosition(offset: v.length),
|
|
|
+ );
|
|
|
+ }),
|
|
|
+ initialText: _remarkController.text,
|
|
|
+ ),
|
|
|
),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══ 3. 关联管控区 ═══
|
|
|
+ // 报销事由、关联项目&预算科目(级联)、成本中心、可用预算、报销金额合计(只读)、核准金额合计(只读)
|
|
|
+ Widget _buildControlSection(
|
|
|
+ ExpenseApplyController controller,
|
|
|
+ ExpenseApplyState state,
|
|
|
+ ) {
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ final expense = state.expense;
|
|
|
+
|
|
|
+ final cascadeLabel = _selectedProjectName != null &&
|
|
|
+ _selectedSubjectName != null
|
|
|
+ ? '$_selectedProjectName / $_selectedSubjectName'
|
|
|
+ : _selectedProjectName;
|
|
|
+
|
|
|
+ return FormSection(
|
|
|
+ title: l10n.get('relatedControl'),
|
|
|
+ leadingIcon: Icons.link_outlined,
|
|
|
+ children: [
|
|
|
+ // 报销事由
|
|
|
FormFieldRow(
|
|
|
- label: l10n.get('budgetSubject'),
|
|
|
- value: expense.budgetSubjectId.isNotEmpty
|
|
|
- ? expense.budgetSubjectId
|
|
|
+ label: l10n.get('expenseReason'),
|
|
|
+ value: _purposeController.text.isNotEmpty
|
|
|
+ ? _purposeController.text
|
|
|
: null,
|
|
|
- hint: l10n.get('selectSubject'),
|
|
|
+ hint: l10n.get('enterExpenseReason'),
|
|
|
+ onTap: () => _showTextInput(
|
|
|
+ l10n.get('expenseReason'),
|
|
|
+ l10n.get('enterExpenseReason'),
|
|
|
+ (v) {
|
|
|
+ setState(() {
|
|
|
+ _purposeController.text = v;
|
|
|
+ _purposeController.selection = TextSelection.fromPosition(
|
|
|
+ TextPosition(offset: v.length),
|
|
|
+ );
|
|
|
+ });
|
|
|
+ controller.updatePurpose(v);
|
|
|
+ },
|
|
|
+ initialText: _purposeController.text,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 关联项目 & 预算科目(级联选择器,参考 ExpenseApplicationApplyPage)
|
|
|
+ FormFieldRow(
|
|
|
+ label: l10n.get('relatedProject'),
|
|
|
+ value: cascadeLabel,
|
|
|
+ hint: l10n.get('selectProjectAndSubject'),
|
|
|
+ required: true,
|
|
|
onTap: () {
|
|
|
- TDToast.showText(
|
|
|
- l10n.get('budgetSubjectSelection'),
|
|
|
- context: context,
|
|
|
+ FocusScope.of(context).unfocus();
|
|
|
+ TDCascader.showMultiCascader(
|
|
|
+ context,
|
|
|
+ title: l10n.get('selectProjectAndSubject'),
|
|
|
+ data: _buildCascadeData(),
|
|
|
+ subTitles: [
|
|
|
+ l10n.get('project'),
|
|
|
+ l10n.get('budgetSubject'),
|
|
|
+ ],
|
|
|
+ onClose: () => Navigator.of(context).pop(),
|
|
|
+ onChange: (selected) {
|
|
|
+ if (selected.length >= 2) {
|
|
|
+ final pId = int.tryParse(selected[0].value ?? '');
|
|
|
+ final sId = int.tryParse(selected[1].value ?? '');
|
|
|
+ if (pId != null && sId != null) {
|
|
|
+ setState(() {
|
|
|
+ _selectedProjectName = selected[0].label;
|
|
|
+ _selectedSubjectName = selected[1].label;
|
|
|
+ _availableBudget = getMockBudget(pId, sId);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
);
|
|
|
},
|
|
|
),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 可用预算
|
|
|
+ _buildBudgetRow(l10n, colors),
|
|
|
+ const SizedBox(height: 16),
|
|
|
+ // 成本中心
|
|
|
FormFieldRow(
|
|
|
label: l10n.get('costCenter'),
|
|
|
value: expense.costCenterId.isNotEmpty ? expense.costCenterId : null,
|
|
|
@@ -159,34 +346,55 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
TDToast.showText(l10n.get('costCenterSelection'), context: context);
|
|
|
},
|
|
|
),
|
|
|
- Container(
|
|
|
- height: 44,
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- l10n.get('totalExpense'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- color: colors.textSecondary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- Text(
|
|
|
- '¥${expense.totalAmount.toStringAsFixed(2)}',
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.subtitle,
|
|
|
- fontWeight: FontWeight.w700,
|
|
|
- color: colors.amountPrimary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, dynamic>> _buildCascadeData() {
|
|
|
+ return mockProjects
|
|
|
+ .map(
|
|
|
+ (p) => <String, dynamic>{
|
|
|
+ 'label': p.name,
|
|
|
+ 'value': p.id.toString(),
|
|
|
+ 'children': mockBudgetSubjects
|
|
|
+ .map(
|
|
|
+ (s) => <String, dynamic>{
|
|
|
+ 'label': s.name,
|
|
|
+ 'value': s.id.toString(),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .toList(),
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _buildBudgetRow(AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ final over = _availableBudget <= 0;
|
|
|
+ return Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ children: [
|
|
|
+ Text(
|
|
|
+ l10n.get('availableBudget'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.subtitle,
|
|
|
+ color: colors.textSecondary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ Text(
|
|
|
+ '¥${_availableBudget.toStringAsFixed(2)}',
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.subtitle,
|
|
|
+ fontWeight: FontWeight.w700,
|
|
|
+ color: over ? colors.danger : colors.amountPrimary,
|
|
|
),
|
|
|
),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ═══ 4. 收款账户区 ═══
|
|
|
+ // 开户行(bankName)、户名(accountName)、银行账号(bankAccount)
|
|
|
Widget _buildAccountSection(
|
|
|
ExpenseApplyController controller,
|
|
|
ExpenseApplyState state,
|
|
|
@@ -194,6 +402,7 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
return FormSection(
|
|
|
title: l10n.get('receiptAccount'),
|
|
|
+ leadingIcon: Icons.account_balance_outlined,
|
|
|
children: [
|
|
|
FormFieldRow(
|
|
|
label: l10n.get('bankName'),
|
|
|
@@ -205,14 +414,19 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
TDToast.showText(l10n.get('bankSelection'), context: context);
|
|
|
},
|
|
|
),
|
|
|
+ const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
label: l10n.get('accountName'),
|
|
|
value: _accountNameController.text,
|
|
|
readOnly: true,
|
|
|
showArrow: false,
|
|
|
),
|
|
|
+ const SizedBox(height: 16),
|
|
|
FormFieldRow(
|
|
|
label: l10n.get('bankAccount'),
|
|
|
+ value: state.expense.bankAccount.isNotEmpty
|
|
|
+ ? state.expense.bankAccount
|
|
|
+ : null,
|
|
|
hint: l10n.get('enterBankAccount'),
|
|
|
onTap: () {
|
|
|
TDToast.showText(l10n.get('bankAccountInput'), context: context);
|
|
|
@@ -222,104 +436,158 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ═══ 5. 报销明细区 ═══
|
|
|
Widget _buildDetailSection(
|
|
|
ExpenseApplyController controller,
|
|
|
ExpenseApplyState state,
|
|
|
) {
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
+
|
|
|
+ final totalApproved = state.expense.details.fold<double>(
|
|
|
+ 0,
|
|
|
+ (sum, d) => sum + d.approvedAmount,
|
|
|
+ );
|
|
|
+
|
|
|
return FormSection(
|
|
|
title: l10n.get('expenseDetails'),
|
|
|
- showAction: state.expense.details.isNotEmpty,
|
|
|
+ leadingIcon: Icons.receipt_long_outlined,
|
|
|
+ showAction: true,
|
|
|
actionText: l10n.get('add'),
|
|
|
onActionTap: () => _showAddDetailDialog(controller),
|
|
|
children: [
|
|
|
- if (state.expense.details.isEmpty) ...[
|
|
|
- GestureDetector(
|
|
|
- onTap: () => _showAddDetailDialog(controller),
|
|
|
- child: Container(
|
|
|
- padding: const EdgeInsets.symmetric(vertical: 12),
|
|
|
- decoration: BoxDecoration(
|
|
|
- border: Border.all(
|
|
|
- color: colors.border,
|
|
|
- strokeAlign: BorderSide.strokeAlignInside,
|
|
|
- ),
|
|
|
- borderRadius: BorderRadius.circular(4),
|
|
|
- color: colors.bgPage,
|
|
|
- ),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.center,
|
|
|
- children: [
|
|
|
- Icon(Icons.add, size: 16, color: colors.primary),
|
|
|
- const SizedBox(width: 4),
|
|
|
- Text(
|
|
|
- l10n.get('addExpenseDetail'),
|
|
|
- style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- color: colors.primary,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
+ if (state.expense.details.isEmpty)
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.symmetric(vertical: 8),
|
|
|
+ child: Text(
|
|
|
+ l10n.get('noDetailHint'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.subtitle,
|
|
|
+ color: colors.textPlaceholder,
|
|
|
),
|
|
|
),
|
|
|
- ),
|
|
|
- ] else
|
|
|
+ )
|
|
|
+ else
|
|
|
...state.expense.details.asMap().entries.map((entry) {
|
|
|
final d = entry.value;
|
|
|
return Container(
|
|
|
- height: 38,
|
|
|
- padding: const EdgeInsets.symmetric(vertical: 4),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ margin: const EdgeInsets.symmetric(vertical: 8),
|
|
|
+ padding: const EdgeInsets.all(12),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.bgPage,
|
|
|
+ borderRadius: BorderRadius.circular(8),
|
|
|
+ ),
|
|
|
+ child: Stack(
|
|
|
children: [
|
|
|
- Row(
|
|
|
+ Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
children: [
|
|
|
- Icon(
|
|
|
- Icons.receipt_long,
|
|
|
- size: 14,
|
|
|
- color: colors.textSecondary,
|
|
|
+ // 第一行:费用项目 + 金额含税
|
|
|
+ Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ Expanded(
|
|
|
+ child: Text(
|
|
|
+ d.expenseDesc.isNotEmpty
|
|
|
+ ? d.expenseDesc
|
|
|
+ : l10n.get('expenseName'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.subtitle,
|
|
|
+ color: colors.textPrimary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ Text(
|
|
|
+ '¥${d.totalAmount.toStringAsFixed(2)}',
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.subtitle,
|
|
|
+ fontWeight: FontWeight.w600,
|
|
|
+ color: colors.amountPrimary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- const SizedBox(width: 8),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ // 第二行(小字):金额不含税 / 核准金额 / 税金 / 税率
|
|
|
Text(
|
|
|
- d.expenseDesc,
|
|
|
+ '${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)} '
|
|
|
+ '${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)} '
|
|
|
+ '${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)} '
|
|
|
+ '${l10n.get('taxRate')}: ${(d.taxRate * 100).toStringAsFixed(0)}%',
|
|
|
style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- color: colors.textPrimary,
|
|
|
+ fontSize: AppFontSizes.caption,
|
|
|
+ color: colors.textSecondary,
|
|
|
),
|
|
|
),
|
|
|
- ],
|
|
|
- ),
|
|
|
- Row(
|
|
|
- mainAxisSize: MainAxisSize.min,
|
|
|
- children: [
|
|
|
+ const SizedBox(height: 6),
|
|
|
+ // 第三行(小字):客户/厂商 / 已充金额
|
|
|
Text(
|
|
|
- '¥${d.totalAmount.toStringAsFixed(2)}',
|
|
|
+ '${l10n.get('customerVendor')}: ${d.customerVendorName.isNotEmpty ? d.customerVendorName : '--'} '
|
|
|
+ '${l10n.get('offsetAmount')}: ¥${d.offsetAmount.toStringAsFixed(2)}',
|
|
|
style: TextStyle(
|
|
|
- fontSize: AppFontSizes.body,
|
|
|
- fontWeight: FontWeight.w500,
|
|
|
- color: colors.amountPrimary,
|
|
|
+ fontSize: AppFontSizes.caption,
|
|
|
+ color: colors.textSecondary,
|
|
|
),
|
|
|
),
|
|
|
- const SizedBox(width: 8),
|
|
|
- GestureDetector(
|
|
|
- onTap: () {
|
|
|
- controller.removeDetail(entry.key);
|
|
|
- controller.recalculateAmount();
|
|
|
- },
|
|
|
+ const SizedBox(height: 6),
|
|
|
+ // 第四行(小字):费用科目 / 项目代号 / 科目代号 / 项目类别
|
|
|
+ Text(
|
|
|
+ '${l10n.get('expenseCategory')}: ${d.expenseType.isNotEmpty ? d.expenseType : '--'} '
|
|
|
+ '${l10n.get('projectCode')}: ${d.projectCode.isNotEmpty ? d.projectCode : '--'} '
|
|
|
+ '${l10n.get('subjectCode')}: ${d.subjectCode.isNotEmpty ? d.subjectCode : '--'} '
|
|
|
+ '${l10n.get('projectCategory')}: ${d.projectCategory.isNotEmpty ? d.projectCategory : '--'}',
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.caption,
|
|
|
+ color: colors.textSecondary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ // 第五行:摘要
|
|
|
+ if (d.remark.isNotEmpty) ...[
|
|
|
+ const SizedBox(height: 6),
|
|
|
+ Text(
|
|
|
+ d.remark,
|
|
|
+ maxLines: 2,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.caption,
|
|
|
+ color: colors.textPlaceholder,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ // 右上角圆形删除按钮
|
|
|
+ Positioned(
|
|
|
+ right: 0,
|
|
|
+ top: 0,
|
|
|
+ child: GestureDetector(
|
|
|
+ onTap: () {
|
|
|
+ controller.removeDetail(entry.key);
|
|
|
+ controller.recalculateAmount();
|
|
|
+ },
|
|
|
+ child: Container(
|
|
|
+ width: 24,
|
|
|
+ height: 24,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primaryLight,
|
|
|
+ shape: BoxShape.circle,
|
|
|
+ ),
|
|
|
child: Icon(
|
|
|
Icons.close,
|
|
|
- size: 16,
|
|
|
- color: colors.textPlaceholder,
|
|
|
+ size: 14,
|
|
|
+ color: colors.primary700,
|
|
|
),
|
|
|
),
|
|
|
- ],
|
|
|
+ ),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
);
|
|
|
}),
|
|
|
- if (state.expense.details.isNotEmpty) ...[
|
|
|
- Container(height: 1, color: colors.border),
|
|
|
+ // 报销金额合计 & 报销核准金额合计(始终显示)
|
|
|
+ const SizedBox(height: 8),
|
|
|
Container(
|
|
|
height: 36,
|
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
|
@@ -327,7 +595,7 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
children: [
|
|
|
Text(
|
|
|
- l10n.get('total'),
|
|
|
+ l10n.get('totalExpense'),
|
|
|
style: TextStyle(
|
|
|
fontSize: AppFontSizes.body,
|
|
|
fontWeight: FontWeight.w600,
|
|
|
@@ -345,11 +613,36 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
- ],
|
|
|
+ Container(
|
|
|
+ height: 36,
|
|
|
+ padding: const EdgeInsets.symmetric(vertical: 8),
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
+ children: [
|
|
|
+ Text(
|
|
|
+ l10n.get('approvedTotal'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.body,
|
|
|
+ fontWeight: FontWeight.w600,
|
|
|
+ color: colors.textPrimary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ Text(
|
|
|
+ '¥${totalApproved.toStringAsFixed(2)}',
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.subtitle,
|
|
|
+ fontWeight: FontWeight.w700,
|
|
|
+ color: colors.amountPrimary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ═══ 6. 附件上传区 ═══
|
|
|
Widget _buildInvoiceSection(
|
|
|
ExpenseApplyController controller,
|
|
|
ExpenseApplyState state,
|
|
|
@@ -358,6 +651,7 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
return FormSection(
|
|
|
title: l10n.get('invoiceUpload'),
|
|
|
+ leadingIcon: Icons.attach_file_outlined,
|
|
|
children: [
|
|
|
Text(
|
|
|
l10n.get('maxInvoices'),
|
|
|
@@ -370,115 +664,274 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
|
|
|
Wrap(
|
|
|
spacing: 8,
|
|
|
runSpacing: 8,
|
|
|
- children: List.generate(6, (i) {
|
|
|
- return Container(
|
|
|
- width: 80,
|
|
|
- height: 80,
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgPage,
|
|
|
- borderRadius: BorderRadius.circular(4),
|
|
|
- border: Border.all(
|
|
|
- color: colors.border,
|
|
|
- strokeAlign: BorderSide.strokeAlignInside,
|
|
|
+ children: [
|
|
|
+ GestureDetector(
|
|
|
+ onTap: () {
|
|
|
+ TDToast.showText(
|
|
|
+ l10n.get('expenseApplyImport'),
|
|
|
+ context: context,
|
|
|
+ );
|
|
|
+ },
|
|
|
+ child: Container(
|
|
|
+ width: 80,
|
|
|
+ height: 80,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.bgPage,
|
|
|
+ borderRadius: BorderRadius.circular(4),
|
|
|
+ border: Border.all(color: colors.border, width: 1),
|
|
|
+ ),
|
|
|
+ child: Center(
|
|
|
+ child: Icon(
|
|
|
+ Icons.add,
|
|
|
+ size: 24,
|
|
|
+ color: colors.textPlaceholder,
|
|
|
+ ),
|
|
|
),
|
|
|
),
|
|
|
- child: i == 0
|
|
|
- ? Center(
|
|
|
- child: Icon(
|
|
|
- Icons.add,
|
|
|
- size: 24,
|
|
|
- color: colors.textPlaceholder,
|
|
|
- ),
|
|
|
- )
|
|
|
- : const SizedBox.shrink(),
|
|
|
- );
|
|
|
- }),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ═══ 7. 底部操作栏 ═══
|
|
|
Widget _buildBottomButtons(
|
|
|
ExpenseApplyController controller,
|
|
|
ExpenseApplyState state,
|
|
|
) {
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ return Container(
|
|
|
+ height: 72,
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
|
+ decoration: BoxDecoration(color: colors.bgCard),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ // 存草稿
|
|
|
+ Expanded(
|
|
|
+ child: SizedBox(
|
|
|
+ height: 40,
|
|
|
+ child: Material(
|
|
|
+ color: colors.bgCard,
|
|
|
+ borderRadius: BorderRadius.circular(22),
|
|
|
+ child: InkWell(
|
|
|
+ onTap: state.isSubmitting
|
|
|
+ ? null
|
|
|
+ : () async {
|
|
|
+ final ok = await controller.saveDraft();
|
|
|
+ if (!mounted) return;
|
|
|
+ if (ok) {
|
|
|
+ TDToast.showText(
|
|
|
+ l10n.get('draftSavedToast'),
|
|
|
+ context: context,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ },
|
|
|
+ borderRadius: BorderRadius.circular(22),
|
|
|
+ child: Center(
|
|
|
+ child: Text(
|
|
|
+ l10n.get('saveDraftShort'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.body,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: colors.textSecondary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ // 重置
|
|
|
+ Expanded(
|
|
|
+ child: SizedBox(
|
|
|
+ height: 40,
|
|
|
+ child: Material(
|
|
|
+ color: colors.bgCard,
|
|
|
+ borderRadius: BorderRadius.circular(22),
|
|
|
+ child: InkWell(
|
|
|
+ onTap: () {
|
|
|
+ setState(() {
|
|
|
+ _purposeController.clear();
|
|
|
+ _remarkController.clear();
|
|
|
+ _voucherNoController.clear();
|
|
|
+ });
|
|
|
+ },
|
|
|
+ borderRadius: BorderRadius.circular(22),
|
|
|
+ child: Center(
|
|
|
+ child: Text(
|
|
|
+ l10n.get('reset'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.body,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: colors.textSecondary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ // 保存
|
|
|
+ Expanded(
|
|
|
+ child: SizedBox(
|
|
|
+ height: 40,
|
|
|
+ child: Material(
|
|
|
+ color: colors.primary,
|
|
|
+ borderRadius: BorderRadius.circular(22),
|
|
|
+ child: InkWell(
|
|
|
+ onTap: state.isSubmitting
|
|
|
+ ? null
|
|
|
+ : () async {
|
|
|
+ await controller.saveDraft();
|
|
|
+ if (!mounted) return;
|
|
|
+ context.pop();
|
|
|
+ },
|
|
|
+ borderRadius: BorderRadius.circular(22),
|
|
|
+ child: Center(
|
|
|
+ child: Text(
|
|
|
+ l10n.get('save'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: AppFontSizes.body,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: colors.bgCard,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ═══════════════════════════════════════════
|
|
|
+ // 弹窗 / 选择器方法
|
|
|
+ // ═══════════════════════════════════════════
|
|
|
+
|
|
|
+ /// 添加报销明细对话框(底部滑出,参照 ExpenseDetailDialog 样式)
|
|
|
+ Future<void> _showAddDetailDialog(ExpenseApplyController controller) async {
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ final result = await ExpenseDetailEditDialog.show(
|
|
|
+ context,
|
|
|
+ categories: mockCostCategories,
|
|
|
+ l10n: l10n,
|
|
|
+ );
|
|
|
+ if (result != null && mounted) {
|
|
|
+ final amountExclTax = result.taxRate > 0
|
|
|
+ ? result.amount / (1 + result.taxRate)
|
|
|
+ : result.amount;
|
|
|
+ final taxAmount = result.amount - amountExclTax;
|
|
|
+ controller.addDetail(
|
|
|
+ ExpenseDetailModel(
|
|
|
+ id: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
|
+ expenseId: '',
|
|
|
+ expenseDate: DateTime.now(),
|
|
|
+ expenseType: result.category,
|
|
|
+ expenseDesc: result.expenseDesc,
|
|
|
+ amount: amountExclTax,
|
|
|
+ taxRate: result.taxRate,
|
|
|
+ taxAmount: taxAmount,
|
|
|
+ totalAmount: result.amount,
|
|
|
+ customerVendorName: result.customerVendorName,
|
|
|
+ offsetAmount: result.offsetAmount,
|
|
|
+ remark: result.remark,
|
|
|
+ baseAmount: result.amount,
|
|
|
+ projectCode: _selectedProjectName ?? '',
|
|
|
+ subjectCode: _selectedSubjectName ?? '',
|
|
|
+ projectCategory: '',
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ controller.recalculateAmount();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 币别选择器
|
|
|
+ void _showCurrencyPicker(
|
|
|
+ ExpenseApplyController controller,
|
|
|
+ String currentCurrency,
|
|
|
+ ) {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- return ActionBar(
|
|
|
- leftLabel: l10n.get('reset'),
|
|
|
- centerLabel: l10n.get('saveDraft'),
|
|
|
- rightLabel: l10n.get('submitApproval'),
|
|
|
- onLeftTap: () {
|
|
|
- setState(() {
|
|
|
- _purposeController.clear();
|
|
|
- _remarkController.clear();
|
|
|
- });
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ const currencies = ['CNY', 'USD', 'EUR', 'JPY', 'HKD', 'GBP'];
|
|
|
+ TDPicker.showMultiPicker(
|
|
|
+ context,
|
|
|
+ title: l10n.get('selectCurrency'),
|
|
|
+ backgroundColor: colors.bgCard,
|
|
|
+ data: [currencies],
|
|
|
+ onConfirm: (selected) {
|
|
|
+ if (selected.isNotEmpty && selected[0] is int) {
|
|
|
+ final idx = selected[0] as int;
|
|
|
+ if (idx >= 0 && idx < currencies.length) {
|
|
|
+ Navigator.of(context).pop();
|
|
|
+ controller.updateCurrencyCode(currencies[idx]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 支付方式选择器
|
|
|
+ void _showPaymentMethodPicker(ExpenseApplyController controller) {
|
|
|
+ final l10n = AppLocalizations.of(context);
|
|
|
+ final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
+ const methods = ['bankTransfer', 'cash', 'check', 'alipay', 'wechat'];
|
|
|
+ TDPicker.showMultiPicker(
|
|
|
+ context,
|
|
|
+ title: l10n.get('selectPaymentMethod'),
|
|
|
+ backgroundColor: colors.bgCard,
|
|
|
+ data: [methods],
|
|
|
+ onConfirm: (selected) {
|
|
|
+ if (selected.isNotEmpty && selected[0] is int) {
|
|
|
+ final idx = selected[0] as int;
|
|
|
+ if (idx >= 0 && idx < methods.length) {
|
|
|
+ Navigator.of(context).pop();
|
|
|
+ // The controller can be extended with updatePaymentMethod if needed
|
|
|
+ TDToast.showText(methods[idx], context: context);
|
|
|
+ }
|
|
|
+ }
|
|
|
},
|
|
|
- onCenterTap: state.isSubmitting
|
|
|
- ? null
|
|
|
- : () async {
|
|
|
- await controller.saveDraft();
|
|
|
- if (!mounted) return;
|
|
|
- context.pop();
|
|
|
- },
|
|
|
- onRightTap: state.isSubmitting
|
|
|
- ? null
|
|
|
- : () async {
|
|
|
- final ok = await controller.submit();
|
|
|
- if (!mounted) return;
|
|
|
- if (ok) context.pop();
|
|
|
- },
|
|
|
- showLeft: true,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- void _showAddDetailDialog(ExpenseApplyController controller) {
|
|
|
+ /// 通用文本输入对话框
|
|
|
+ void _showTextInput(
|
|
|
+ String title,
|
|
|
+ String hint,
|
|
|
+ void Function(String) onConfirm, {
|
|
|
+ String initialText = '',
|
|
|
+ }) {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
- final nameCtrl = TextEditingController();
|
|
|
- final amountCtrl = TextEditingController();
|
|
|
- final descCtrl = TextEditingController();
|
|
|
- showDialog(
|
|
|
+ final c = TextEditingController(text: initialText);
|
|
|
+ showGeneralDialog(
|
|
|
context: context,
|
|
|
- builder: (_) => TDAlertDialog(
|
|
|
- title: l10n.get('addDetail'),
|
|
|
- contentWidget: Column(
|
|
|
- mainAxisSize: MainAxisSize.min,
|
|
|
- children: [
|
|
|
- TDInput(controller: nameCtrl, hintText: l10n.get('expenseName')),
|
|
|
- const SizedBox(height: 8),
|
|
|
- TDInput(
|
|
|
- controller: amountCtrl,
|
|
|
- hintText: l10n.get('amount'),
|
|
|
- inputType: TextInputType.number,
|
|
|
- ),
|
|
|
- const SizedBox(height: 8),
|
|
|
- TDInput(controller: descCtrl, hintText: l10n.get('description')),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
|
|
|
+ textEditingController: c,
|
|
|
+ title: title,
|
|
|
+ hintText: hint,
|
|
|
leftBtn: TDDialogButtonOptions(
|
|
|
title: l10n.get('cancel'),
|
|
|
- action: () => Navigator.pop(context),
|
|
|
+ action: () => Navigator.pop(ctx),
|
|
|
),
|
|
|
rightBtn: TDDialogButtonOptions(
|
|
|
- title: l10n.get('add'),
|
|
|
+ title: l10n.get('confirm'),
|
|
|
action: () {
|
|
|
- final amount = double.tryParse(amountCtrl.text) ?? 0.0;
|
|
|
- controller.addDetail(
|
|
|
- ExpenseDetailModel(
|
|
|
- id: DateTime.now().millisecondsSinceEpoch.toString(),
|
|
|
- expenseId: '',
|
|
|
- expenseDate: DateTime.now(),
|
|
|
- expenseType: '',
|
|
|
- expenseDesc: nameCtrl.text,
|
|
|
- amount: amount,
|
|
|
- totalAmount: amount,
|
|
|
- remark: descCtrl.text,
|
|
|
- ),
|
|
|
- );
|
|
|
- controller.recalculateAmount();
|
|
|
- Navigator.pop(context);
|
|
|
+ onConfirm(c.text);
|
|
|
+ Navigator.pop(ctx);
|
|
|
},
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
+
|
|
|
+ /// 获取今天的日期字符串
|
|
|
+ String _today() {
|
|
|
+ final n = DateTime.now();
|
|
|
+ return '${n.year}-${n.month.toString().padLeft(2, '0')}-${n.day.toString().padLeft(2, '0')}';
|
|
|
+ }
|
|
|
}
|