|
|
@@ -1,4 +1,4 @@
|
|
|
-// ignore_for_file: use_build_context_synchronously
|
|
|
+// ignore_for_file: use_build_context_synchronously
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
import 'package:flutter/services.dart';
|
|
|
@@ -6,7 +6,8 @@ import 'package:tdesign_flutter/tdesign_flutter.dart';
|
|
|
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 '../../../shared/widgets/searchable_picker_sheet.dart';
|
|
|
+import '../expense_apply_api.dart';
|
|
|
|
|
|
/// 费用明细输入数据。
|
|
|
class ExpenseDetailData {
|
|
|
@@ -23,6 +24,8 @@ class ExpenseDetailData {
|
|
|
final String endDate;
|
|
|
final double estimatedAmount;
|
|
|
final String remark;
|
|
|
+ final String sqMan;
|
|
|
+ final String sqManName;
|
|
|
|
|
|
const ExpenseDetailData({
|
|
|
required this.category,
|
|
|
@@ -38,39 +41,42 @@ class ExpenseDetailData {
|
|
|
required this.endDate,
|
|
|
required this.estimatedAmount,
|
|
|
required this.remark,
|
|
|
+ this.sqMan = '',
|
|
|
+ this.sqManName = '',
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/// 添加费用明细弹窗。
|
|
|
///
|
|
|
/// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
|
|
|
+/// 费用项目、关联项目、费用承担部门均从接口实时加载,无需预传数据。
|
|
|
class ExpenseApplyDetailDialog extends StatefulWidget {
|
|
|
- final List<CostCategory> categories;
|
|
|
- final List<Project> projects;
|
|
|
- final List<CostDept> costDepts;
|
|
|
+ final ExpenseApplyApi api;
|
|
|
final AppLocalizations l10n;
|
|
|
final ExpenseDetailData? initialData;
|
|
|
final dynamic acctTree;
|
|
|
+ final String? defaultSqMan;
|
|
|
+ final String? defaultSqManName;
|
|
|
|
|
|
const ExpenseApplyDetailDialog({
|
|
|
super.key,
|
|
|
- required this.categories,
|
|
|
- required this.projects,
|
|
|
- required this.costDepts,
|
|
|
+ required this.api,
|
|
|
required this.l10n,
|
|
|
required this.acctTree,
|
|
|
this.initialData,
|
|
|
+ this.defaultSqMan,
|
|
|
+ this.defaultSqManName,
|
|
|
});
|
|
|
|
|
|
/// 显示弹窗,返回 [ExpenseDetailData] 或 `null`(取消时)。
|
|
|
static Future<ExpenseDetailData?> show(
|
|
|
BuildContext context, {
|
|
|
- required List<CostCategory> categories,
|
|
|
- required List<Project> projects,
|
|
|
- required List<CostDept> costDepts,
|
|
|
+ required ExpenseApplyApi api,
|
|
|
required AppLocalizations l10n,
|
|
|
required dynamic acctTree,
|
|
|
ExpenseDetailData? initialData,
|
|
|
+ String? defaultSqMan,
|
|
|
+ String? defaultSqManName,
|
|
|
}) {
|
|
|
FocusScope.of(context).unfocus();
|
|
|
return Navigator.push<ExpenseDetailData>(
|
|
|
@@ -79,12 +85,12 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
|
|
|
slideTransitionFrom: SlideTransitionFrom.bottom,
|
|
|
isDismissible: true,
|
|
|
builder: (_) => ExpenseApplyDetailDialog(
|
|
|
- categories: categories,
|
|
|
- projects: projects,
|
|
|
- costDepts: costDepts,
|
|
|
+ api: api,
|
|
|
l10n: l10n,
|
|
|
acctTree: acctTree,
|
|
|
initialData: initialData,
|
|
|
+ defaultSqMan: defaultSqMan,
|
|
|
+ defaultSqManName: defaultSqManName,
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
@@ -96,12 +102,14 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
|
|
|
}
|
|
|
|
|
|
class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
- // 费用类别
|
|
|
- late CostCategory _selCat;
|
|
|
+ // 费用项目
|
|
|
+ late CostProjectItem _selCat;
|
|
|
// 关联项目
|
|
|
- Project? _selProject;
|
|
|
+ ProjectCodeItem? _selProject;
|
|
|
// 费用承担部门
|
|
|
- CostDept? _selDept;
|
|
|
+ DepartmentItem? _selDept;
|
|
|
+ // 申请人
|
|
|
+ EmployeeItem? _selEmployee;
|
|
|
// 预计开始/结束日期
|
|
|
String _startDate = '';
|
|
|
String _endDate = '';
|
|
|
@@ -117,9 +125,6 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
// 滚动控制
|
|
|
final _scrollCtrl = ScrollController();
|
|
|
|
|
|
- List<CostCategory> get _cats => widget.categories;
|
|
|
- List<Project> get _projects => widget.projects;
|
|
|
- List<CostDept> get _depts => widget.costDepts;
|
|
|
AppLocalizations get _l10n => widget.l10n;
|
|
|
|
|
|
bool get _isEdit => widget.initialData != null;
|
|
|
@@ -128,49 +133,23 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
final d = widget.initialData;
|
|
|
- if (_cats.isEmpty) {
|
|
|
- // 费用类别无数据,弹窗无法使用
|
|
|
- WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
- if (mounted) {
|
|
|
- TDToast.showText(_l10n.get('noData'), context: context);
|
|
|
- Navigator.pop(context);
|
|
|
- }
|
|
|
- });
|
|
|
- }
|
|
|
- _selCat = _cats.isNotEmpty
|
|
|
- ? (d != null
|
|
|
- ? _cats.firstWhere(
|
|
|
- (c) => c.code == d.category,
|
|
|
- orElse: () => _cats.first,
|
|
|
- )
|
|
|
- : _cats.first)
|
|
|
- : const CostCategory(
|
|
|
- code: '',
|
|
|
- nameKey: '',
|
|
|
- acctSubjectId: '',
|
|
|
- acctSubjectName: '',
|
|
|
- );
|
|
|
- // 编辑时恢复已选的会计科目(可能和费用类别默认科目不同)
|
|
|
- if (d != null && d.acctSubjectId.isNotEmpty) {
|
|
|
- _selCat = CostCategory(
|
|
|
- code: _selCat.code,
|
|
|
- nameKey: _selCat.nameKey,
|
|
|
- acctSubjectId: d.acctSubjectId,
|
|
|
- acctSubjectName: d.acctSubjectName,
|
|
|
- );
|
|
|
- }
|
|
|
- _selProject = (d != null && d.projectId.isNotEmpty && _projects.isNotEmpty)
|
|
|
- ? _projects.firstWhere(
|
|
|
- (p) => p.id.toString() == d.projectId,
|
|
|
- orElse: () => _projects.first,
|
|
|
- )
|
|
|
+ _selCat = CostProjectItem(
|
|
|
+ idx1: d?.category ?? '',
|
|
|
+ name: d?.categoryName ?? '',
|
|
|
+ accNo: d?.acctSubjectId ?? '',
|
|
|
+ accName: d?.acctSubjectName ?? '',
|
|
|
+ );
|
|
|
+ _selProject = (d != null && d.projectId.isNotEmpty)
|
|
|
+ ? ProjectCodeItem(objNo: d.projectId, name: d.projectName)
|
|
|
: null;
|
|
|
- _selDept = (d != null && d.costDeptId.isNotEmpty && _depts.isNotEmpty)
|
|
|
- ? _depts.firstWhere(
|
|
|
- (dept) => dept.id == d.costDeptId,
|
|
|
- orElse: () => _depts.first,
|
|
|
- )
|
|
|
+ _selDept = (d != null && d.costDeptId.isNotEmpty)
|
|
|
+ ? DepartmentItem(dep: d.costDeptId, name: d.costDeptName)
|
|
|
: null;
|
|
|
+ _selEmployee = (d != null && d.sqMan.isNotEmpty)
|
|
|
+ ? EmployeeItem(salNo: d.sqMan, name: d.sqManName)
|
|
|
+ : (widget.defaultSqMan != null
|
|
|
+ ? EmployeeItem(salNo: widget.defaultSqMan!, name: widget.defaultSqManName ?? '')
|
|
|
+ : null);
|
|
|
_startDate = d?.startDate ?? '';
|
|
|
_endDate = d?.endDate ?? '';
|
|
|
if (d != null) {
|
|
|
@@ -183,7 +162,6 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
_purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
|
|
|
_amountFocus.addListener(() => _ensureVisible(_amountFocus));
|
|
|
_remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
|
|
|
-
|
|
|
}
|
|
|
|
|
|
void _ensureVisible(FocusNode node) {
|
|
|
@@ -228,6 +206,20 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
}
|
|
|
|
|
|
void _confirm() {
|
|
|
+ if (_selCat.idx1.isEmpty) {
|
|
|
+ TDToast.showText(
|
|
|
+ '${_l10n.get('pleaseSelect')}${_l10n.get('expenseCategory')}',
|
|
|
+ context: context,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (_selEmployee == null) {
|
|
|
+ TDToast.showText(
|
|
|
+ '${_l10n.get('pleaseSelect')}${_l10n.get('applicant')}',
|
|
|
+ context: context,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
final amt = double.tryParse(_amountCtrl.text) ?? 0;
|
|
|
if (amt <= 0) {
|
|
|
TDToast.showText(_l10n.get('estimatedAmountPositive'), context: context);
|
|
|
@@ -242,15 +234,17 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
Navigator.pop(
|
|
|
context,
|
|
|
ExpenseDetailData(
|
|
|
- category: _selCat.code,
|
|
|
- categoryName: _l10n.get(_selCat.nameKey),
|
|
|
- acctSubjectId: _selCat.acctSubjectId,
|
|
|
- acctSubjectName: _selCat.acctSubjectName,
|
|
|
+ category: _selCat.idx1,
|
|
|
+ categoryName: _selCat.name,
|
|
|
+ acctSubjectId: _selCat.accNo,
|
|
|
+ acctSubjectName: _selCat.accName,
|
|
|
purpose: _purposeCtrl.text.trim(),
|
|
|
- projectId: _selProject != null ? _selProject!.id.toString() : '',
|
|
|
+ projectId: _selProject != null ? _selProject!.objNo.toString() : '',
|
|
|
projectName: _selProject?.name ?? '',
|
|
|
- costDeptId: _selDept?.id ?? '',
|
|
|
+ costDeptId: _selDept?.dep ?? '',
|
|
|
costDeptName: _selDept?.name ?? '',
|
|
|
+ sqMan: _selEmployee?.salNo ?? '',
|
|
|
+ sqManName: _selEmployee?.name ?? '',
|
|
|
startDate: _startDate,
|
|
|
endDate: _endDate,
|
|
|
estimatedAmount: amt,
|
|
|
@@ -302,6 +296,8 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
const SizedBox(height: 12),
|
|
|
_buildAcctSubjectCard(colors),
|
|
|
const SizedBox(height: 12),
|
|
|
+ _buildEmployeePicker(colors),
|
|
|
+ const SizedBox(height: 12),
|
|
|
_buildAmountInput(colors),
|
|
|
const SizedBox(height: 12),
|
|
|
_buildProjectPicker(colors),
|
|
|
@@ -356,7 +352,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
|
|
|
child: Row(
|
|
|
children: [
|
|
|
- const SizedBox(width: 28),
|
|
|
+ const SizedBox(width: 24),
|
|
|
Expanded(
|
|
|
child: Center(
|
|
|
child: Text(
|
|
|
@@ -392,94 +388,137 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
required String label,
|
|
|
required bool required,
|
|
|
required String currentLabel,
|
|
|
- required List<String> labels,
|
|
|
- required ValueChanged<int> onSelected,
|
|
|
+ required bool hasValue,
|
|
|
+ required VoidCallback onTap,
|
|
|
VoidCallback? onClear,
|
|
|
}) {
|
|
|
final tdTheme = TDTheme.of(context);
|
|
|
- final hasValue = onClear != null;
|
|
|
return GestureDetector(
|
|
|
onTap: () {
|
|
|
- if (labels.isEmpty) {
|
|
|
- TDToast.showText(_l10n.get('noData'), context: context);
|
|
|
- return;
|
|
|
- }
|
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
|
- TDPicker.showMultiPicker(
|
|
|
- context,
|
|
|
- title: label,
|
|
|
- data: [labels],
|
|
|
- onConfirm: (selected) {
|
|
|
- if (selected.isNotEmpty && selected[0] is int) {
|
|
|
- final idx = selected[0] as int;
|
|
|
- if (idx >= 0 && idx < labels.length) {
|
|
|
- Navigator.of(context).pop();
|
|
|
- onSelected(idx);
|
|
|
- }
|
|
|
- }
|
|
|
- },
|
|
|
- );
|
|
|
+ onTap();
|
|
|
},
|
|
|
child: Container(
|
|
|
- padding: const EdgeInsets.only(left: 16, right: 10, 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),
|
|
|
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)),
|
|
|
+ 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: 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),
|
|
|
+ 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: 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),
|
|
|
+ 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: hasValue
|
|
|
+ ? tdTheme.textColorPrimary
|
|
|
+ : tdTheme.textColorPlaceholder,
|
|
|
+ textAlign: TextAlign.end,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 4),
|
|
|
+ SizedBox(
|
|
|
+ width: 18,
|
|
|
+ height: 18,
|
|
|
+ child: onClear != null
|
|
|
+ ? GestureDetector(
|
|
|
+ onTap: onClear,
|
|
|
+ child: Icon(
|
|
|
+ Icons.close,
|
|
|
+ size: 18,
|
|
|
+ color: tdTheme.textColorPlaceholder,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ : Icon(
|
|
|
+ Icons.chevron_right,
|
|
|
+ size: 18,
|
|
|
+ color: tdTheme.textColorPlaceholder,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- ]),
|
|
|
- ),
|
|
|
- ]),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- // ── 1. 费用类别 ──
|
|
|
+ // ── 1. 费用项目 ──
|
|
|
Widget _buildCategoryPicker(AppColorsExtension colors) {
|
|
|
- final labels = _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList();
|
|
|
return _pickerCard(
|
|
|
label: _l10n.get('expenseCategory'),
|
|
|
required: true,
|
|
|
- currentLabel: _selCat.code.isNotEmpty ? '${_selCat.code}/${_l10n.get(_selCat.nameKey)}' : _l10n.get('pleaseSelect'),
|
|
|
- labels: labels,
|
|
|
- onSelected: (i) => setState(() => _selCat = _cats[i]),
|
|
|
+ hasValue: _selCat.idx1.isNotEmpty,
|
|
|
+ currentLabel: _selCat.idx1.isNotEmpty
|
|
|
+ ? '${_selCat.idx1}/${_selCat.name}'
|
|
|
+ : _l10n.get('pleaseSelect'),
|
|
|
+ onTap: () async {
|
|
|
+ final result = await showSearchablePicker<CostProjectItem>(
|
|
|
+ context,
|
|
|
+ title: '${_l10n.get('select')}${_l10n.get('expenseCategory')}',
|
|
|
+ searchHint: _l10n.get('search'),
|
|
|
+ loader: (keyword, page) => widget.api
|
|
|
+ .getCostProjects(keyword: keyword, page: page, size: 20)
|
|
|
+ .then((list) => list.cast<CostProjectItem>()),
|
|
|
+ labelBuilder: (c) => '${c.idx1}/${c.name}',
|
|
|
+ );
|
|
|
+ if (result != null && mounted) {
|
|
|
+ setState(() => _selCat = result);
|
|
|
+ }
|
|
|
+ },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── 2. 会计科目(级联选择器) ──
|
|
|
Widget _buildAcctSubjectCard(AppColorsExtension colors) {
|
|
|
final tdTheme = TDTheme.of(context);
|
|
|
- final hasValue = _selCat.acctSubjectId.isNotEmpty;
|
|
|
+ final hasValue = _selCat.accNo.isNotEmpty;
|
|
|
final displayValue = hasValue
|
|
|
- ? (_selCat.acctSubjectName.startsWith(_selCat.acctSubjectId)
|
|
|
- ? _selCat.acctSubjectName
|
|
|
- : (_selCat.acctSubjectName.toString().isNotEmpty
|
|
|
- ? '${_selCat.acctSubjectId}/${_selCat.acctSubjectName}'
|
|
|
- : _selCat.acctSubjectId))
|
|
|
+ ? (_selCat.accName.startsWith(_selCat.accNo)
|
|
|
+ ? _selCat.accName
|
|
|
+ : (_selCat.accName.toString().isNotEmpty
|
|
|
+ ? '${_selCat.accNo}/${_selCat.accName}'
|
|
|
+ : _selCat.accNo))
|
|
|
: _l10n.get('pleaseSelect');
|
|
|
return GestureDetector(
|
|
|
onTap: () {
|
|
|
- debugPrint('[AcctSubject] onTap fired, acctTree isNull: ${widget.acctTree == null}');
|
|
|
+ debugPrint(
|
|
|
+ '[AcctSubject] onTap fired, acctTree isNull: ${widget.acctTree == null}',
|
|
|
+ );
|
|
|
if (widget.acctTree == null) {
|
|
|
TDToast.showText(_l10n.get('loading'), context: context);
|
|
|
return;
|
|
|
@@ -490,67 +529,165 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
title: _l10n.get('acctSubject'),
|
|
|
data: widget.acctTree as List<Map>,
|
|
|
theme: 'step',
|
|
|
- onChange: (List<MultiCascaderListModel> selected) {
|
|
|
- setState(() {
|
|
|
- _selCat = CostCategory(
|
|
|
- code: _selCat.code,
|
|
|
- nameKey: _selCat.nameKey,
|
|
|
- acctSubjectId: (selected.last.value ?? '').replaceFirst('self:', ''),
|
|
|
- acctSubjectName: (selected.last.label ?? '').replaceFirst(RegExp(r'^> '), '').replaceFirst('${selected.last.value ?? ''}/', ''),
|
|
|
- );
|
|
|
- });
|
|
|
- },
|
|
|
- );
|
|
|
+ onChange: (List<MultiCascaderListModel> selected) {
|
|
|
+ setState(() {
|
|
|
+ _selCat = CostProjectItem(
|
|
|
+ idx1: _selCat.idx1,
|
|
|
+ name: _selCat.name,
|
|
|
+ accNo: (selected.last.value ?? '').replaceFirst('self:', ''),
|
|
|
+ accName: (selected.last.label ?? '')
|
|
|
+ .replaceFirst(RegExp(r'^> '), '')
|
|
|
+ .replaceFirst('${selected.last.value ?? ''}/', ''),
|
|
|
+ );
|
|
|
+ });
|
|
|
+ },
|
|
|
+ );
|
|
|
},
|
|
|
child: Container(
|
|
|
- padding: const EdgeInsets.only(left: 16, right: 10, 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),
|
|
|
border: Border.all(color: tdTheme.componentStrokeColor),
|
|
|
),
|
|
|
- child: Row(children: [
|
|
|
- TDText(_l10n.get('acctSubject'), maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
|
|
|
- const SizedBox(width: 12),
|
|
|
- Expanded(
|
|
|
- child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
|
|
|
- Flexible(child: TDText(displayValue, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: hasValue ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder, textAlign: TextAlign.end)),
|
|
|
- const SizedBox(width: 4),
|
|
|
- SizedBox(width: 18, height: 18, child: Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder)),
|
|
|
- ]),
|
|
|
- ),
|
|
|
- ]),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ TDText(
|
|
|
+ _l10n.get('acctSubject'),
|
|
|
+ maxLines: 1,
|
|
|
+ overflow: TextOverflow.visible,
|
|
|
+ font: tdTheme.fontBodyLarge,
|
|
|
+ fontWeight: FontWeight.w400,
|
|
|
+ style: const TextStyle(letterSpacing: 0),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.end,
|
|
|
+ mainAxisSize: MainAxisSize.max,
|
|
|
+ children: [
|
|
|
+ Flexible(
|
|
|
+ child: TDText(
|
|
|
+ displayValue,
|
|
|
+ maxLines: 1,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
+ font: tdTheme.fontBodyLarge,
|
|
|
+ fontWeight: FontWeight.w400,
|
|
|
+ textColor: hasValue
|
|
|
+ ? tdTheme.textColorPrimary
|
|
|
+ : tdTheme.textColorPlaceholder,
|
|
|
+ textAlign: TextAlign.end,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 4),
|
|
|
+ SizedBox(
|
|
|
+ width: 18,
|
|
|
+ height: 18,
|
|
|
+ child: Icon(
|
|
|
+ Icons.chevron_right,
|
|
|
+ size: 18,
|
|
|
+ color: tdTheme.textColorPlaceholder,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-
|
|
|
// ── 3. 关联项目 ──
|
|
|
Widget _buildProjectPicker(AppColorsExtension colors) {
|
|
|
- final labels = _projects.map((p) => '${p.id}/${p.name}').toList();
|
|
|
return _pickerCard(
|
|
|
label: _l10n.get('relatedProject'),
|
|
|
required: false,
|
|
|
- currentLabel: _selProject != null ? '${_selProject!.id}/${_selProject!.name}' : _l10n.get('pleaseSelect'),
|
|
|
- labels: labels,
|
|
|
- onSelected: (i) => setState(() => _selProject = _projects[i]),
|
|
|
- onClear: _selProject != null ? () => setState(() => _selProject = null) : null,
|
|
|
+ hasValue: _selProject != null,
|
|
|
+ currentLabel: _selProject != null
|
|
|
+ ? '${_selProject!.objNo}/${_selProject!.name}'
|
|
|
+ : _l10n.get('pleaseSelect'),
|
|
|
+ onTap: () async {
|
|
|
+ final result = await showSearchablePicker<ProjectCodeItem>(
|
|
|
+ context,
|
|
|
+ title: '${_l10n.get('select')}${_l10n.get('relatedProject')}',
|
|
|
+ searchHint: _l10n.get('search'),
|
|
|
+ loader: (keyword, page) => widget.api
|
|
|
+ .getProjectCodes(keyword: keyword, page: page, size: 20)
|
|
|
+ .then((list) => list.cast<ProjectCodeItem>()),
|
|
|
+ labelBuilder: (p) => '${p.objNo}/${p.name}',
|
|
|
+ );
|
|
|
+ if (result != null && mounted) {
|
|
|
+ setState(() => _selProject = result);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onClear: _selProject != null
|
|
|
+ ? () => setState(() => _selProject = null)
|
|
|
+ : null,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── 5. 费用承担部门 ──
|
|
|
Widget _buildCostDeptPicker(AppColorsExtension colors) {
|
|
|
- final labels = _depts.map((d) => '${d.id}/${d.name}').toList();
|
|
|
return _pickerCard(
|
|
|
label: _l10n.get('costDept'),
|
|
|
required: false,
|
|
|
- currentLabel: _selDept != null ? '${_selDept!.id}/${_selDept!.name}' : _l10n.get('pleaseSelect'),
|
|
|
- labels: labels,
|
|
|
- onSelected: (i) => setState(() => _selDept = _depts[i]),
|
|
|
+ hasValue: _selDept != null,
|
|
|
+ currentLabel: _selDept != null
|
|
|
+ ? '${_selDept!.dep}/${_selDept!.name}'
|
|
|
+ : _l10n.get('pleaseSelect'),
|
|
|
+ onTap: () async {
|
|
|
+ final result = await showSearchablePicker<DepartmentItem>(
|
|
|
+ context,
|
|
|
+ title: '${_l10n.get('select')}${_l10n.get('costDept')}',
|
|
|
+ searchHint: _l10n.get('search'),
|
|
|
+ loader: (keyword, page) => widget.api
|
|
|
+ .getDepartments(keyword: keyword, page: page, size: 20)
|
|
|
+ .then((list) => list.cast<DepartmentItem>()),
|
|
|
+ labelBuilder: (d) => '${d.dep}/${d.name}',
|
|
|
+ );
|
|
|
+ if (result != null && mounted) {
|
|
|
+ setState(() => _selDept = result);
|
|
|
+ }
|
|
|
+ },
|
|
|
onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ // ── 申请人 ──
|
|
|
+ Widget _buildEmployeePicker(AppColorsExtension colors) {
|
|
|
+ return _pickerCard(
|
|
|
+ label: _l10n.get('applicant'),
|
|
|
+ required: true,
|
|
|
+ hasValue: _selEmployee != null,
|
|
|
+ currentLabel: _selEmployee != null
|
|
|
+ ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
|
|
|
+ : _l10n.get('pleaseSelect'),
|
|
|
+ onTap: () async {
|
|
|
+ final result = await showSearchablePicker<EmployeeItem>(
|
|
|
+ context,
|
|
|
+ title: '${_l10n.get('select')}${_l10n.get('applicant')}',
|
|
|
+ searchHint: _l10n.get('search'),
|
|
|
+ loader: (keyword, page) =>
|
|
|
+ widget.api.getEmployees(keyword: keyword, page: page, size: 20),
|
|
|
+ labelBuilder: (e) =>
|
|
|
+ e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
|
|
|
+ );
|
|
|
+ if (result != null && mounted) {
|
|
|
+ setState(() => _selEmployee = result);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onClear: _selEmployee != null
|
|
|
+ ? () => setState(() => _selEmployee = null)
|
|
|
+ : null,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
// ── 6. 预计开始日期 ──
|
|
|
Widget _buildStartDatePicker(AppColorsExtension colors) {
|
|
|
return _datePickerCard(
|
|
|
@@ -694,7 +831,9 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
controller: _amountCtrl,
|
|
|
hintText: '>0',
|
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
|
- inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
|
|
|
+ inputFormatters: [
|
|
|
+ FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
|
|
|
+ ],
|
|
|
focusNode: _amountFocus,
|
|
|
);
|
|
|
}
|
|
|
@@ -767,48 +906,79 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
|
|
|
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)),
|
|
|
+ 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: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
|
|
|
- Flexible(
|
|
|
- child: TextField(
|
|
|
- controller: controller,
|
|
|
- focusNode: focusNode,
|
|
|
- textAlign: TextAlign.end,
|
|
|
- keyboardType: keyboardType,
|
|
|
- inputFormatters: inputFormatters,
|
|
|
- style: TextStyle(fontSize: 16, color: tdTheme.textColorPrimary),
|
|
|
- decoration: InputDecoration(
|
|
|
- hintText: hintText,
|
|
|
- hintStyle: TextStyle(fontSize: 16, color: tdTheme.textColorPlaceholder),
|
|
|
- border: InputBorder.none,
|
|
|
- isDense: true,
|
|
|
- contentPadding: EdgeInsets.zero,
|
|
|
- ),
|
|
|
- onChanged: (_) => setState(() {}),
|
|
|
+ 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: 4),
|
|
|
- SizedBox(
|
|
|
- width: 18, height: 18,
|
|
|
- child: hasValue
|
|
|
- ? GestureDetector(
|
|
|
- onTap: () { controller.clear(); setState(() {}); },
|
|
|
- child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder),
|
|
|
- )
|
|
|
- : null,
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ Expanded(
|
|
|
+ child: Row(
|
|
|
+ mainAxisAlignment: MainAxisAlignment.end,
|
|
|
+ mainAxisSize: MainAxisSize.max,
|
|
|
+ children: [
|
|
|
+ Flexible(
|
|
|
+ child: TextField(
|
|
|
+ controller: controller,
|
|
|
+ focusNode: focusNode,
|
|
|
+ textAlign: TextAlign.end,
|
|
|
+ keyboardType: keyboardType,
|
|
|
+ inputFormatters: inputFormatters,
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 16,
|
|
|
+ color: tdTheme.textColorPrimary,
|
|
|
+ ),
|
|
|
+ decoration: InputDecoration(
|
|
|
+ hintText: hintText,
|
|
|
+ hintStyle: TextStyle(
|
|
|
+ fontSize: 16,
|
|
|
+ color: tdTheme.textColorPlaceholder,
|
|
|
+ ),
|
|
|
+ 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,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- ]),
|
|
|
- ),
|
|
|
- ]),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
-
|
|
|
}
|