|
|
@@ -5,27 +5,59 @@ import '../../../core/i18n/app_localizations.dart';
|
|
|
import '../../../core/theme/app_colors.dart';
|
|
|
import '../../../core/theme/app_colors_extension.dart';
|
|
|
import '../../../core/data/mock_api_data.dart';
|
|
|
+import '../expense_api.dart';
|
|
|
+import '../../../shared/widgets/attachment_picker.dart';
|
|
|
|
|
|
/// 报销明细输入数据。
|
|
|
class ExpenseDetailInputData {
|
|
|
final String category;
|
|
|
final String categoryName;
|
|
|
+ final String acctSubjectId;
|
|
|
+ final String acctSubjectName;
|
|
|
final String purpose;
|
|
|
final double amount; // 含税金额
|
|
|
final double taxRate;
|
|
|
+ final String projectId;
|
|
|
+ final String projectName;
|
|
|
+ final String costDeptId;
|
|
|
+ final String costDeptName;
|
|
|
+ final String customerVendorId;
|
|
|
final String customerVendorName;
|
|
|
final double offsetAmount;
|
|
|
+ final String bankName;
|
|
|
+ final String bankAccountName;
|
|
|
+ final String bankAccount;
|
|
|
final String remark;
|
|
|
+ final List<String> attachmentPaths;
|
|
|
+ final String sqMan;
|
|
|
+ final String sqManName;
|
|
|
+ final String aeNo;
|
|
|
+ final String aeDd;
|
|
|
|
|
|
const ExpenseDetailInputData({
|
|
|
required this.category,
|
|
|
required this.categoryName,
|
|
|
+ required this.acctSubjectId,
|
|
|
+ required this.acctSubjectName,
|
|
|
required this.purpose,
|
|
|
required this.amount,
|
|
|
required this.taxRate,
|
|
|
+ this.projectId = '',
|
|
|
+ this.projectName = '',
|
|
|
+ this.costDeptId = '',
|
|
|
+ this.costDeptName = '',
|
|
|
+ this.customerVendorId = '',
|
|
|
this.customerVendorName = '',
|
|
|
this.offsetAmount = 0.0,
|
|
|
+ this.bankName = '',
|
|
|
+ this.bankAccountName = '',
|
|
|
+ this.bankAccount = '',
|
|
|
this.remark = '',
|
|
|
+ this.attachmentPaths = const [],
|
|
|
+ this.sqMan = '',
|
|
|
+ this.sqManName = '',
|
|
|
+ this.aeNo = '',
|
|
|
+ this.aeDd = '',
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -35,19 +67,34 @@ class ExpenseDetailInputData {
|
|
|
/// 参照 ExpenseApplyCreatePage 的 ExpenseDetailDialog 样式。
|
|
|
class ExpenseDetailDialog extends StatefulWidget {
|
|
|
final List<CostCategory> categories;
|
|
|
+ final List<Project> projects;
|
|
|
+ final List<CostDept> costDepts;
|
|
|
+ final List<CustomerVendor> customers;
|
|
|
+ final List<EmployeeItem> employees;
|
|
|
final AppLocalizations l10n;
|
|
|
+ final ExpenseDetailInputData? initialData;
|
|
|
|
|
|
const ExpenseDetailDialog({
|
|
|
super.key,
|
|
|
required this.categories,
|
|
|
+ required this.projects,
|
|
|
+ required this.costDepts,
|
|
|
+ required this.customers,
|
|
|
+ required this.employees,
|
|
|
required this.l10n,
|
|
|
+ this.initialData,
|
|
|
});
|
|
|
|
|
|
/// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
|
|
|
static Future<ExpenseDetailInputData?> show(
|
|
|
BuildContext context, {
|
|
|
required List<CostCategory> categories,
|
|
|
+ required List<Project> projects,
|
|
|
+ required List<CostDept> costDepts,
|
|
|
+ required List<CustomerVendor> customers,
|
|
|
+ required List<EmployeeItem> employees,
|
|
|
required AppLocalizations l10n,
|
|
|
+ ExpenseDetailInputData? initialData,
|
|
|
}) {
|
|
|
FocusScope.of(context).unfocus();
|
|
|
return Navigator.push<ExpenseDetailInputData>(
|
|
|
@@ -57,7 +104,12 @@ class ExpenseDetailDialog extends StatefulWidget {
|
|
|
isDismissible: false,
|
|
|
builder: (_) => ExpenseDetailDialog(
|
|
|
categories: categories,
|
|
|
+ projects: projects,
|
|
|
+ costDepts: costDepts,
|
|
|
+ customers: customers,
|
|
|
+ employees: employees,
|
|
|
l10n: l10n,
|
|
|
+ initialData: initialData,
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
@@ -73,10 +125,18 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
late String _catLabel;
|
|
|
late TextEditingController _descCtrl;
|
|
|
late TextEditingController _amountCtrl;
|
|
|
- late TextEditingController _customerCtrl;
|
|
|
+ CustomerVendor? _selCustomer;
|
|
|
late TextEditingController _offsetCtrl;
|
|
|
late TextEditingController _remarkCtrl;
|
|
|
+ late TextEditingController _bankNameCtrl;
|
|
|
+ late TextEditingController _bankAccountNameCtrl;
|
|
|
+ late TextEditingController _bankAccountCtrl;
|
|
|
double _taxRate = 0.06;
|
|
|
+ Project? _selProject;
|
|
|
+ CostDept? _selDept;
|
|
|
+ EmployeeItem? _selEmployee;
|
|
|
+ late final AttachmentPickerController _attachmentCtrl;
|
|
|
+ final ScrollController _scrollCtrl = ScrollController();
|
|
|
|
|
|
static const _taxOptions = [0.06, 0.09, 0.13];
|
|
|
static const _taxLabels = ['6%', '9%', '13%'];
|
|
|
@@ -84,25 +144,62 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
List<CostCategory> get _cats => widget.categories;
|
|
|
AppLocalizations get _l10n => widget.l10n;
|
|
|
|
|
|
+ CostCategory get _selCat => _cats.firstWhere((c) => c.code == _cat);
|
|
|
+
|
|
|
+ bool get _isEdit => widget.initialData != null;
|
|
|
+
|
|
|
@override
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
- _cat = _cats.isNotEmpty ? _cats.first.code : 'other';
|
|
|
+ final d = widget.initialData;
|
|
|
+ _cat = d != null
|
|
|
+ ? (_cats.any((c) => c.code == d.category) ? d.category : _cats.first.code)
|
|
|
+ : _cats.isNotEmpty ? _cats.first.code : 'other';
|
|
|
_catLabel = _l10n.get(_cats.firstWhere((c) => c.code == _cat).nameKey);
|
|
|
- _descCtrl = TextEditingController();
|
|
|
- _amountCtrl = TextEditingController();
|
|
|
- _customerCtrl = TextEditingController();
|
|
|
- _offsetCtrl = TextEditingController();
|
|
|
- _remarkCtrl = TextEditingController();
|
|
|
+ _descCtrl = TextEditingController(text: d?.purpose ?? '');
|
|
|
+ _amountCtrl = TextEditingController(text: d != null && d.amount > 0 ? d.amount.toStringAsFixed(2) : '');
|
|
|
+ if (d != null && d.customerVendorName.isNotEmpty) {
|
|
|
+ _selCustomer = CustomerVendor(id: '', name: d.customerVendorName);
|
|
|
+ }
|
|
|
+ _offsetCtrl = TextEditingController(text: d != null && d.offsetAmount > 0 ? d.offsetAmount.toStringAsFixed(2) : '');
|
|
|
+ _remarkCtrl = TextEditingController(text: d?.remark ?? '');
|
|
|
+ _bankNameCtrl = TextEditingController(text: d?.bankName ?? '');
|
|
|
+ _bankAccountNameCtrl = TextEditingController(text: d?.bankAccountName ?? '');
|
|
|
+ _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? '');
|
|
|
+ _taxRate = d?.taxRate ?? 0.13;
|
|
|
+ if (d != null && d.sqMan.isNotEmpty && widget.employees.isNotEmpty) {
|
|
|
+ final idx = widget.employees.indexWhere((e) => e.salNo == d.sqMan);
|
|
|
+ if (idx >= 0) _selEmployee = widget.employees[idx];
|
|
|
+ }
|
|
|
+ if (d != null) {
|
|
|
+ if (d.projectId.isNotEmpty && widget.projects.isNotEmpty) {
|
|
|
+ _selProject = widget.projects.firstWhere((p) => p.id.toString() == d.projectId, orElse: () => widget.projects.first);
|
|
|
+ }
|
|
|
+ if (d.costDeptId.isNotEmpty && widget.costDepts.isNotEmpty) {
|
|
|
+ _selDept = widget.costDepts.firstWhere((dept) => dept.id == d.costDeptId, orElse: () => widget.costDepts.first);
|
|
|
+ }
|
|
|
+ if (d.attachmentPaths.isNotEmpty) {
|
|
|
+ // Restore attachments will be handled after build
|
|
|
+ WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
+ _attachmentCtrl.restoreFromPaths(d.attachmentPaths);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _attachmentCtrl = AttachmentPickerController(maxCount: 9)
|
|
|
+ ..addListener(() => setState(() {}));
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
void dispose() {
|
|
|
_descCtrl.dispose();
|
|
|
_amountCtrl.dispose();
|
|
|
- _customerCtrl.dispose();
|
|
|
_offsetCtrl.dispose();
|
|
|
_remarkCtrl.dispose();
|
|
|
+ _bankNameCtrl.dispose();
|
|
|
+ _bankAccountNameCtrl.dispose();
|
|
|
+ _bankAccountCtrl.dispose();
|
|
|
+ _attachmentCtrl.dispose();
|
|
|
+ _scrollCtrl.dispose();
|
|
|
super.dispose();
|
|
|
}
|
|
|
|
|
|
@@ -121,15 +218,28 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
context,
|
|
|
ExpenseDetailInputData(
|
|
|
category: _cat,
|
|
|
- categoryName: _l10n.get(
|
|
|
- _cats.firstWhere((c) => c.code == _cat).nameKey,
|
|
|
- ),
|
|
|
+ categoryName: _l10n.get(_selCat.nameKey),
|
|
|
+ acctSubjectId: _selCat.acctSubjectId,
|
|
|
+ acctSubjectName: _selCat.acctSubjectName,
|
|
|
purpose: desc,
|
|
|
amount: amount,
|
|
|
taxRate: _taxRate,
|
|
|
- customerVendorName: _customerCtrl.text.trim(),
|
|
|
+ projectId: _selProject?.id.toString() ?? '',
|
|
|
+ projectName: _selProject?.name ?? '',
|
|
|
+ costDeptId: _selDept?.id ?? '',
|
|
|
+ costDeptName: _selDept?.name ?? '',
|
|
|
+ customerVendorId: _selCustomer?.id ?? '',
|
|
|
+ customerVendorName: _selCustomer?.name ?? '',
|
|
|
offsetAmount: double.tryParse(_offsetCtrl.text) ?? 0,
|
|
|
+ bankName: _bankNameCtrl.text.trim(),
|
|
|
+ bankAccountName: _bankAccountNameCtrl.text.trim(),
|
|
|
+ bankAccount: _bankAccountCtrl.text.trim(),
|
|
|
remark: _remarkCtrl.text.trim(),
|
|
|
+ attachmentPaths: _attachmentCtrl.toPathList(),
|
|
|
+ sqMan: _selEmployee?.salNo ?? '',
|
|
|
+ sqManName: _selEmployee?.name ?? '',
|
|
|
+ aeNo: widget.initialData?.aeNo ?? '',
|
|
|
+ aeDd: widget.initialData?.aeDd ?? '',
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
@@ -146,9 +256,13 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
|
|
|
|
|
return SafeArea(
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.only(bottom: bottomInset),
|
|
|
- child: Container(
|
|
|
+ child: ConstrainedBox(
|
|
|
+ constraints: BoxConstraints(
|
|
|
+ maxHeight: MediaQuery.of(context).size.height * 0.8,
|
|
|
+ ),
|
|
|
+ child: Padding(
|
|
|
+ padding: EdgeInsets.only(bottom: bottomInset),
|
|
|
+ child: Container(
|
|
|
decoration: BoxDecoration(
|
|
|
color: colors.bgPage,
|
|
|
borderRadius:
|
|
|
@@ -160,32 +274,55 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
children: [
|
|
|
_buildHeader(colors),
|
|
|
Flexible(
|
|
|
+ child: GestureDetector(
|
|
|
+ onTap: () => FocusScope.of(context).unfocus(),
|
|
|
+ behavior: HitTestBehavior.translucent,
|
|
|
child: SingleChildScrollView(
|
|
|
+ controller: _scrollCtrl,
|
|
|
+ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
|
child: Column(
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
children: [
|
|
|
+ if (_isEdit && widget.initialData!.aeNo.isNotEmpty) ...[
|
|
|
+ _buildAeInfoCard(colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ ],
|
|
|
_buildCategoryCard(colors),
|
|
|
const SizedBox(height: 12),
|
|
|
- _buildDescCard(),
|
|
|
+ _buildAcctSubjectCard(colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _buildPurposeInput(colors),
|
|
|
const SizedBox(height: 12),
|
|
|
_buildAmountCard(),
|
|
|
const SizedBox(height: 12),
|
|
|
_buildTaxRateCard(colors),
|
|
|
+ if ((double.tryParse(_amountCtrl.text) ?? 0) > 0) ...[
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _buildCalcInfo(colors),
|
|
|
+ ],
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _buildProjectCard(colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _buildCostDeptCard(colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _buildEmployeeCard(colors),
|
|
|
const SizedBox(height: 12),
|
|
|
- // 自动计算展示
|
|
|
- _buildCalcInfo(colors),
|
|
|
+ _buildBankInfoCard(colors),
|
|
|
const SizedBox(height: 12),
|
|
|
- _buildCustomerCard(),
|
|
|
+ _buildCustomerCard(colors),
|
|
|
const SizedBox(height: 12),
|
|
|
_buildOffsetCard(),
|
|
|
const SizedBox(height: 12),
|
|
|
- _buildRemarkCard(colors),
|
|
|
+ _buildRemarkInput(colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _buildAttachmentCard(colors),
|
|
|
],
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
+ ),
|
|
|
Container(
|
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
|
|
decoration: BoxDecoration(
|
|
|
@@ -200,6 +337,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -262,8 +400,10 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
required List<String> labels,
|
|
|
required ValueChanged<int> onSelected,
|
|
|
required AppColorsExtension colors,
|
|
|
+ VoidCallback? onClear,
|
|
|
}) {
|
|
|
final tdTheme = TDTheme.of(context);
|
|
|
+ final hasValue = onClear != null;
|
|
|
return GestureDetector(
|
|
|
onTap: () {
|
|
|
TDPicker.showMultiPicker(
|
|
|
@@ -283,9 +423,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
);
|
|
|
},
|
|
|
child: Container(
|
|
|
- padding: const EdgeInsets.only(
|
|
|
- left: 16, right: 16, top: 12, bottom: 12,
|
|
|
- ),
|
|
|
+ padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
|
|
|
decoration: BoxDecoration(
|
|
|
color: tdTheme.bgColorContainer,
|
|
|
borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
|
|
|
@@ -293,49 +431,21 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
),
|
|
|
child: Row(
|
|
|
children: [
|
|
|
- TDText(
|
|
|
- label,
|
|
|
- maxLines: 1,
|
|
|
- overflow: TextOverflow.visible,
|
|
|
- font: tdTheme.fontBodyLarge,
|
|
|
- fontWeight: FontWeight.w400,
|
|
|
- style: const TextStyle(letterSpacing: 0),
|
|
|
- ),
|
|
|
+ TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
|
|
|
if (required)
|
|
|
- Padding(
|
|
|
- padding: const EdgeInsets.only(left: 4),
|
|
|
- child: TDText(
|
|
|
- '*',
|
|
|
- font: tdTheme.fontBodyLarge,
|
|
|
- fontWeight: FontWeight.w400,
|
|
|
- style: TextStyle(color: tdTheme.errorColor6),
|
|
|
- ),
|
|
|
- ),
|
|
|
+ Padding(padding: const EdgeInsets.only(left: 4), child: TDText('*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6))),
|
|
|
const SizedBox(width: 12),
|
|
|
Expanded(
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.end,
|
|
|
- mainAxisSize: MainAxisSize.max,
|
|
|
- children: [
|
|
|
- Flexible(
|
|
|
- child: TDText(
|
|
|
- currentLabel,
|
|
|
- maxLines: 1,
|
|
|
- overflow: TextOverflow.ellipsis,
|
|
|
- font: tdTheme.fontBodyLarge,
|
|
|
- fontWeight: FontWeight.w400,
|
|
|
- textColor: tdTheme.textColorPrimary,
|
|
|
- textAlign: TextAlign.end,
|
|
|
- ),
|
|
|
- ),
|
|
|
- const SizedBox(width: 4),
|
|
|
- Icon(
|
|
|
- Icons.chevron_right,
|
|
|
- size: 18,
|
|
|
- color: tdTheme.textColorPlaceholder,
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
|
|
|
+ Flexible(child: TDText(currentLabel, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end)),
|
|
|
+ const SizedBox(width: 4),
|
|
|
+ SizedBox(
|
|
|
+ width: 18, height: 18,
|
|
|
+ child: hasValue
|
|
|
+ ? GestureDetector(onTap: onClear, child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder))
|
|
|
+ : Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder),
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
@@ -358,49 +468,98 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 费用项目 ──
|
|
|
- Widget _buildDescCard() {
|
|
|
- final screenWidth = MediaQuery.of(context).size.width;
|
|
|
- return TDInput(
|
|
|
- type: TDInputType.cardStyle,
|
|
|
- cardStyle: TDCardStyle.topText,
|
|
|
- width: screenWidth - 32,
|
|
|
- leftLabel: _l10n.get('expenseName'),
|
|
|
- required: true,
|
|
|
+ // ── 输入卡片(对齐 pickerCard 样式) ──
|
|
|
+ Widget _inputCard({
|
|
|
+ required String label,
|
|
|
+ required bool required,
|
|
|
+ required TextEditingController controller,
|
|
|
+ required String hintText,
|
|
|
+ required AppColorsExtension colors,
|
|
|
+ TextInputType? keyboardType,
|
|
|
+ List<TextInputFormatter>? inputFormatters,
|
|
|
+ }) {
|
|
|
+ final tdTheme = TDTheme.of(context);
|
|
|
+ final hasValue = controller.text.isNotEmpty;
|
|
|
+ return Container(
|
|
|
+ padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: tdTheme.bgColorContainer,
|
|
|
+ borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
|
|
|
+ border: Border.all(color: tdTheme.componentStrokeColor),
|
|
|
+ ),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
|
|
|
+ if (required)
|
|
|
+ Padding(padding: const EdgeInsets.only(left: 4), child: TDText('*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6))),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(
|
|
|
+ child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
|
|
|
+ Flexible(
|
|
|
+ child: TextField(
|
|
|
+ controller: controller,
|
|
|
+ textAlign: TextAlign.end,
|
|
|
+ keyboardType: keyboardType,
|
|
|
+ inputFormatters: inputFormatters,
|
|
|
+ style: TextStyle(fontSize: 16, color: colors.textPrimary),
|
|
|
+ decoration: InputDecoration(
|
|
|
+ hintText: hintText,
|
|
|
+ hintStyle: TextStyle(fontSize: 16, color: colors.textPlaceholder),
|
|
|
+ border: InputBorder.none,
|
|
|
+ isDense: true,
|
|
|
+ contentPadding: EdgeInsets.zero,
|
|
|
+ ),
|
|
|
+ onChanged: (_) => setState(() {}),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 4),
|
|
|
+ SizedBox(
|
|
|
+ width: 18, height: 18,
|
|
|
+ child: hasValue
|
|
|
+ ? GestureDetector(
|
|
|
+ onTap: () { controller.clear(); setState(() {}); },
|
|
|
+ child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder),
|
|
|
+ )
|
|
|
+ : null,
|
|
|
+ ),
|
|
|
+ ]),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 费用事由 ──
|
|
|
+ Widget _buildPurposeInput(AppColorsExtension colors) {
|
|
|
+ final tdTheme = TDTheme.of(context);
|
|
|
+ return TDTextarea(
|
|
|
controller: _descCtrl,
|
|
|
- hintText: _l10n.get('enterExpenseName'),
|
|
|
- contentAlignment: TextAlign.center,
|
|
|
- showBottomDivider: false,
|
|
|
+ label: _l10n.get('feeReason'),
|
|
|
+ required: true,
|
|
|
+ hintText: _l10n.get('enterFeeReason'),
|
|
|
+ maxLines: 3,
|
|
|
+ minLines: 1,
|
|
|
+ maxLength: 500,
|
|
|
+ indicator: true,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: tdTheme.bgColorContainer,
|
|
|
+ borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
|
|
|
+ border: Border.all(color: tdTheme.componentStrokeColor),
|
|
|
+ ),
|
|
|
onChanged: (_) => setState(() {}),
|
|
|
- onClearTap: () {
|
|
|
- _descCtrl.clear();
|
|
|
- setState(() {});
|
|
|
- },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 金额(含税) ──
|
|
|
+ // ── 含税金额 ──
|
|
|
Widget _buildAmountCard() {
|
|
|
- final screenWidth = MediaQuery.of(context).size.width;
|
|
|
- return TDInput(
|
|
|
- type: TDInputType.cardStyle,
|
|
|
- cardStyle: TDCardStyle.topText,
|
|
|
- width: screenWidth - 32,
|
|
|
- leftLabel: _l10n.get('amountInclTax'),
|
|
|
+ return _inputCard(
|
|
|
+ label: _l10n.get('amountInclTax'),
|
|
|
required: true,
|
|
|
controller: _amountCtrl,
|
|
|
hintText: '>0',
|
|
|
- contentAlignment: TextAlign.center,
|
|
|
- inputType: const TextInputType.numberWithOptions(decimal: true),
|
|
|
- inputFormatters: [
|
|
|
- FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
|
|
|
- ],
|
|
|
- showBottomDivider: false,
|
|
|
- onChanged: (_) => setState(() {}),
|
|
|
- onClearTap: () {
|
|
|
- _amountCtrl.clear();
|
|
|
- setState(() {});
|
|
|
- },
|
|
|
+ colors: Theme.of(context).extension<AppColorsExtension>()!,
|
|
|
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
|
+ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -427,7 +586,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
return Container(
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
|
decoration: BoxDecoration(
|
|
|
- color: colors.primary50,
|
|
|
+ color: colors.primaryLight,
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
),
|
|
|
child: Row(
|
|
|
@@ -453,52 +612,110 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ── 导入单据信息 ──
|
|
|
+ Widget _buildAeInfoCard(AppColorsExtension colors) {
|
|
|
+ final d = widget.initialData!;
|
|
|
+ final tdTheme = TDTheme.of(context);
|
|
|
+ return Container(
|
|
|
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
|
+ decoration: _cardDecoration(tdTheme),
|
|
|
+ child: Column(
|
|
|
+ children: [
|
|
|
+ _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ _readOnlyRow(tdTheme, _l10n.get('applyDate'), d.aeDd),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) {
|
|
|
+ return Row(children: [
|
|
|
+ TDText(label, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(child: TDText(value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end)),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ BoxDecoration _cardDecoration(TDThemeData tdTheme) => BoxDecoration(
|
|
|
+ color: tdTheme.bgColorContainer,
|
|
|
+ borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
|
|
|
+ border: Border.all(color: tdTheme.componentStrokeColor),
|
|
|
+ );
|
|
|
+
|
|
|
+ // ── 申请人 ──
|
|
|
+ Widget _buildEmployeeCard(AppColorsExtension colors) {
|
|
|
+ final employees = widget.employees;
|
|
|
+ return _pickerCard(
|
|
|
+ label: _l10n.get('applicant'),
|
|
|
+ required: false,
|
|
|
+ currentLabel: _selEmployee != null ? '${_selEmployee!.salNo}/${_selEmployee!.name}' : _l10n.get('pleaseSelect'),
|
|
|
+ labels: employees.map((e) => '${e.salNo}/${e.name}').toList(),
|
|
|
+ colors: colors,
|
|
|
+ onSelected: (idx) => setState(() {
|
|
|
+ _selEmployee = employees[idx];
|
|
|
+ _bankNameCtrl.text = _selEmployee!.bnkNo;
|
|
|
+ _bankAccountNameCtrl.text = _selEmployee!.accName;
|
|
|
+ _bankAccountCtrl.text = _selEmployee!.bnkId;
|
|
|
+ }),
|
|
|
+ onClear: _selEmployee != null ? () => setState(() {
|
|
|
+ _selEmployee = null;
|
|
|
+ _bankNameCtrl.clear();
|
|
|
+ _bankAccountNameCtrl.clear();
|
|
|
+ _bankAccountCtrl.clear();
|
|
|
+ }) : null,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
// ── 客户/厂商 ──
|
|
|
- Widget _buildCustomerCard() {
|
|
|
- final screenWidth = MediaQuery.of(context).size.width;
|
|
|
- return TDInput(
|
|
|
- type: TDInputType.cardStyle,
|
|
|
- cardStyle: TDCardStyle.topText,
|
|
|
- width: screenWidth - 32,
|
|
|
- leftLabel: _l10n.get('customerVendor'),
|
|
|
- controller: _customerCtrl,
|
|
|
- hintText: _l10n.get('optional'),
|
|
|
- contentAlignment: TextAlign.center,
|
|
|
- showBottomDivider: false,
|
|
|
- onChanged: (_) => setState(() {}),
|
|
|
- onClearTap: () {
|
|
|
- _customerCtrl.clear();
|
|
|
- setState(() {});
|
|
|
- },
|
|
|
+ Widget _buildCustomerCard(AppColorsExtension colors) {
|
|
|
+ final vendors = widget.customers;
|
|
|
+ return _pickerCard(
|
|
|
+ label: _l10n.get('customerVendor'),
|
|
|
+ required: false,
|
|
|
+ currentLabel: _selCustomer?.name ?? _l10n.get('pleaseSelect'),
|
|
|
+ labels: vendors.map((v) => v.name).toList(),
|
|
|
+ colors: colors,
|
|
|
+ onSelected: (idx) => setState(() => _selCustomer = vendors[idx]),
|
|
|
+ onClear: _selCustomer != null ? () => setState(() => _selCustomer = null) : null,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── 已充金额 ──
|
|
|
Widget _buildOffsetCard() {
|
|
|
- final screenWidth = MediaQuery.of(context).size.width;
|
|
|
- return TDInput(
|
|
|
- type: TDInputType.cardStyle,
|
|
|
- cardStyle: TDCardStyle.topText,
|
|
|
- width: screenWidth - 32,
|
|
|
- leftLabel: _l10n.get('offsetAmount'),
|
|
|
+ return _inputCard(
|
|
|
+ label: _l10n.get('offsetAmount'),
|
|
|
+ required: false,
|
|
|
controller: _offsetCtrl,
|
|
|
hintText: '0',
|
|
|
- contentAlignment: TextAlign.center,
|
|
|
- inputType: const TextInputType.numberWithOptions(decimal: true),
|
|
|
- inputFormatters: [
|
|
|
- FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
|
|
|
- ],
|
|
|
- showBottomDivider: false,
|
|
|
- onChanged: (_) => setState(() {}),
|
|
|
- onClearTap: () {
|
|
|
- _offsetCtrl.clear();
|
|
|
- setState(() {});
|
|
|
- },
|
|
|
+ colors: Theme.of(context).extension<AppColorsExtension>()!,
|
|
|
+ keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
|
+ inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── 备注 ──
|
|
|
- Widget _buildRemarkCard(AppColorsExtension colors) {
|
|
|
+ Widget _buildRemarkInput(AppColorsExtension colors) {
|
|
|
+ final tdTheme = TDTheme.of(context);
|
|
|
+ return TDTextarea(
|
|
|
+ controller: _remarkCtrl,
|
|
|
+ label: _l10n.get('remark'),
|
|
|
+ hintText: _l10n.get('enterRemark'),
|
|
|
+ maxLines: 3,
|
|
|
+ minLines: 1,
|
|
|
+ maxLength: 500,
|
|
|
+ indicator: true,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: tdTheme.bgColorContainer,
|
|
|
+ borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
|
|
|
+ border: Border.all(color: tdTheme.componentStrokeColor),
|
|
|
+ ),
|
|
|
+ onChanged: (_) => setState(() {}),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 操作按钮 ──
|
|
|
+ Widget _buildAttachmentCard(AppColorsExtension colors) {
|
|
|
final tdTheme = TDTheme.of(context);
|
|
|
return Column(
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
@@ -506,30 +723,103 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
Padding(
|
|
|
padding: const EdgeInsets.only(left: 4),
|
|
|
child: TDText(
|
|
|
- _l10n.get('detailRemark'),
|
|
|
+ _l10n.get('attachmentUpload'),
|
|
|
font: tdTheme.fontBodyLarge,
|
|
|
fontWeight: FontWeight.w400,
|
|
|
style: const TextStyle(letterSpacing: 0),
|
|
|
),
|
|
|
),
|
|
|
- const SizedBox(height: 8),
|
|
|
- TDTextarea(
|
|
|
- controller: _remarkCtrl,
|
|
|
- hintText: _l10n.get('optional'),
|
|
|
- maxLines: 3,
|
|
|
- minLines: 1,
|
|
|
- maxLength: 200,
|
|
|
- indicator: true,
|
|
|
- padding: EdgeInsets.zero,
|
|
|
- bordered: true,
|
|
|
- inputType: TextInputType.multiline,
|
|
|
- backgroundColor: tdTheme.bgColorContainer,
|
|
|
+ const SizedBox(height: 4),
|
|
|
+ AttachmentPicker(
|
|
|
+ controller: _attachmentCtrl,
|
|
|
+ maxImageSizeMB: 10,
|
|
|
+ maxFileSizeMB: 20,
|
|
|
+ allowedExtensions: const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
|
|
|
+ onFileRejected: (file, reason) {
|
|
|
+ if (context.mounted) TDToast.showText(reason, context: context);
|
|
|
+ },
|
|
|
),
|
|
|
],
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 操作按钮 ──
|
|
|
+ // ── 会计科目(只读,选择类别后自动带出) ──
|
|
|
+ Widget _buildAcctSubjectCard(AppColorsExtension colors) {
|
|
|
+ return _readOnlyCard(
|
|
|
+ label: _l10n.get('acctSubject'),
|
|
|
+ value: '${_selCat.acctSubjectId} ${_selCat.acctSubjectName}',
|
|
|
+ colors: colors,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ Widget _readOnlyCard({
|
|
|
+ required String label,
|
|
|
+ required String value,
|
|
|
+ required AppColorsExtension colors,
|
|
|
+ }) {
|
|
|
+ final tdTheme = TDTheme.of(context);
|
|
|
+ return Container(
|
|
|
+ padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 12),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: tdTheme.bgColorContainer,
|
|
|
+ borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
|
|
|
+ border: Border.all(color: tdTheme.componentStrokeColor),
|
|
|
+ ),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(
|
|
|
+ child: TDText(value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400,
|
|
|
+ textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 关联项目 ──
|
|
|
+ Widget _buildProjectCard(AppColorsExtension colors) {
|
|
|
+ final projects = widget.projects;
|
|
|
+ return _pickerCard(
|
|
|
+ label: _l10n.get('relatedProject'),
|
|
|
+ required: false,
|
|
|
+ currentLabel: _selProject?.name ?? _l10n.get('pleaseSelect'),
|
|
|
+ labels: projects.map((p) => p.name).toList(),
|
|
|
+ colors: colors,
|
|
|
+ onSelected: (idx) => setState(() => _selProject = projects[idx]),
|
|
|
+ onClear: _selProject != null ? () => setState(() => _selProject = null) : null,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 费用承担部门 ──
|
|
|
+ Widget _buildCostDeptCard(AppColorsExtension colors) {
|
|
|
+ final depts = widget.costDepts;
|
|
|
+ return _pickerCard(
|
|
|
+ label: _l10n.get('costDept'),
|
|
|
+ required: false,
|
|
|
+ currentLabel: _selDept?.name ?? _l10n.get('pleaseSelect'),
|
|
|
+ labels: depts.map((d) => d.name).toList(),
|
|
|
+ colors: colors,
|
|
|
+ onSelected: (idx) => setState(() => _selDept = depts[idx]),
|
|
|
+ onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── 收款银行信息 ──
|
|
|
+ Widget _buildBankInfoCard(AppColorsExtension colors) {
|
|
|
+ return Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ _inputCard(label: _l10n.get('bankName'), required: false, controller: _bankNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _inputCard(label: _l10n.get('bankAccountName'), required: false, controller: _bankAccountNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ _inputCard(label: _l10n.get('bankAccount'), required: false, controller: _bankAccountCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
Widget _buildActions() {
|
|
|
return Row(
|
|
|
children: [
|
|
|
@@ -546,7 +836,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
|
|
|
const SizedBox(width: 12),
|
|
|
Expanded(
|
|
|
child: TDButton(
|
|
|
- text: _l10n.get('confirmAdd'),
|
|
|
+ text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'),
|
|
|
size: TDButtonSize.large,
|
|
|
type: TDButtonType.fill,
|
|
|
shape: TDButtonShape.rectangle,
|