|
|
@@ -1,4 +1,4 @@
|
|
|
-import 'package:flutter/material.dart';
|
|
|
+import 'package:flutter/material.dart';
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
import 'package:tdesign_flutter/tdesign_flutter.dart';
|
|
|
@@ -6,6 +6,7 @@ import 'package:easy_refresh/easy_refresh.dart';
|
|
|
import '../../core/theme/app_colors_extension.dart';
|
|
|
import '../../core/utils/date_utils.dart' as du;
|
|
|
import '../../core/utils/responsive.dart';
|
|
|
+import '../../shared/widgets/date_range_picker.dart';
|
|
|
import '../../shared/widgets/list_card.dart';
|
|
|
import '../../shared/widgets/empty_state.dart';
|
|
|
import '../../shared/widgets/app_skeletons.dart';
|
|
|
@@ -15,10 +16,12 @@ import 'expense_apply_list_controller.dart';
|
|
|
import 'expense_apply_model.dart';
|
|
|
import 'expense_apply_approval_api.dart';
|
|
|
import '../../core/utils/amount_utils.dart';
|
|
|
+
|
|
|
class ExpenseApplyListPage extends ConsumerStatefulWidget {
|
|
|
const ExpenseApplyListPage({super.key});
|
|
|
@override
|
|
|
- ConsumerState<ExpenseApplyListPage> createState() => _ExpenseApplyListPageState();
|
|
|
+ ConsumerState<ExpenseApplyListPage> createState() =>
|
|
|
+ _ExpenseApplyListPageState();
|
|
|
}
|
|
|
|
|
|
class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
|
|
|
@@ -28,6 +31,7 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
|
|
|
late final EasyRefreshController _refreshCtrl;
|
|
|
bool _firstBuild = true;
|
|
|
bool _invalidateDone = false;
|
|
|
+ bool _filterLoading = false;
|
|
|
|
|
|
final _scrollCtrl = ScrollController();
|
|
|
bool _showBackToTop = false;
|
|
|
@@ -36,8 +40,10 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
final now = DateTime.now();
|
|
|
- _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
|
|
|
- _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
|
|
|
+ _startDateCtrl.text =
|
|
|
+ '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
|
|
|
+ _endDateCtrl.text =
|
|
|
+ '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
|
|
|
_refreshCtrl = EasyRefreshController();
|
|
|
_scrollCtrl.addListener(() {
|
|
|
final show = _scrollCtrl.hasClients && _scrollCtrl.offset > 300;
|
|
|
@@ -46,8 +52,16 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
|
|
|
}
|
|
|
});
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
- ref.read(expenseApplyDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
|
|
|
- ref.read(expenseApplyDateEndProvider.notifier).state = DateTime(now.year, now.month, DateTime(now.year, now.month + 1, 0).day);
|
|
|
+ ref.read(expenseApplyDateStartProvider.notifier).state = DateTime(
|
|
|
+ now.year,
|
|
|
+ now.month,
|
|
|
+ 1,
|
|
|
+ );
|
|
|
+ ref.read(expenseApplyDateEndProvider.notifier).state = DateTime(
|
|
|
+ now.year,
|
|
|
+ now.month,
|
|
|
+ DateTime(now.year, now.month + 1, 0).day,
|
|
|
+ );
|
|
|
ref.invalidate(expenseApplyMyListProvider(''));
|
|
|
_invalidateDone = true;
|
|
|
setState(() {}); // 触发重建以挂载 ref.listen
|
|
|
@@ -63,48 +77,27 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
|
|
|
super.dispose();
|
|
|
}
|
|
|
|
|
|
- void _pickDate(TextEditingController ctrl) {
|
|
|
- FocusManager.instance.primaryFocus?.unfocus();
|
|
|
- final l10n = AppLocalizations.of(context);
|
|
|
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
- final now = DateTime.now();
|
|
|
- TDPicker.showDatePicker(
|
|
|
- context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard,
|
|
|
- useYear: true, useMonth: true, useDay: true, useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
|
|
|
- dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31], initialDate: [now.year, now.month, now.day],
|
|
|
- onConfirm: (selected) {
|
|
|
- ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
|
|
|
- setState(() {});
|
|
|
- Navigator.of(context).pop();
|
|
|
- },
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
void _applyFilter() {
|
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
|
- _refreshCtrl.callRefresh();
|
|
|
+ setState(() => _filterLoading = true);
|
|
|
+ _doRefresh();
|
|
|
}
|
|
|
|
|
|
Future<void> _doRefresh() async {
|
|
|
ref.read(expenseApplyKeywordProvider.notifier).state = _keyword;
|
|
|
- ref.read(expenseApplyDateStartProvider.notifier).state = _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null;
|
|
|
- ref.read(expenseApplyDateEndProvider.notifier).state = _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null;
|
|
|
+ ref
|
|
|
+ .read(expenseApplyDateStartProvider.notifier)
|
|
|
+ .state = _startDateCtrl.text.isNotEmpty
|
|
|
+ ? DateTime.tryParse(_startDateCtrl.text)
|
|
|
+ : null;
|
|
|
+ ref
|
|
|
+ .read(expenseApplyDateEndProvider.notifier)
|
|
|
+ .state = _endDateCtrl.text.isNotEmpty
|
|
|
+ ? DateTime.tryParse(_endDateCtrl.text)
|
|
|
+ : null;
|
|
|
ref.read(expenseApplyRefreshProvider.notifier).state++;
|
|
|
}
|
|
|
|
|
|
- Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
|
|
|
- final text = ctrl.text;
|
|
|
- return Container(
|
|
|
- height: 40, padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
|
- decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
|
|
|
- child: Row(children: [
|
|
|
- Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 6),
|
|
|
- Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
|
|
|
- if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
|
|
|
- ]),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
@override
|
|
|
Widget build(BuildContext context) {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
@@ -120,88 +113,167 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
|
|
|
if (mounted) setState(() {});
|
|
|
});
|
|
|
}
|
|
|
+ if (_filterLoading && !next.isReloading && !next.isLoading) {
|
|
|
+ setState(() => _filterLoading = false);
|
|
|
+ }
|
|
|
});
|
|
|
}
|
|
|
|
|
|
- return Stack(children: [
|
|
|
- Column(children: [
|
|
|
- Container(
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.bgCard,
|
|
|
- border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
|
|
|
- ),
|
|
|
- child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
|
- Padding(padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Row(children: [
|
|
|
- Expanded(child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
|
|
|
- const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
|
|
|
- Expanded(child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
|
|
|
- ])),
|
|
|
- const SizedBox(height: 8),
|
|
|
- Padding(padding: const EdgeInsets.fromLTRB(0, 0, 12, 8), child: Row(children: [
|
|
|
- Expanded(
|
|
|
- child: TDSearchBar(
|
|
|
- placeHolder: l10n.get('searchExpenseApply'),
|
|
|
- style: TDSearchStyle.round,
|
|
|
- onTextChanged: (String text) {
|
|
|
- setState(() {
|
|
|
- _keyword = text;
|
|
|
- });
|
|
|
- if (text.isEmpty) _applyFilter();
|
|
|
- },
|
|
|
- onSubmitted: (_) => _applyFilter(),
|
|
|
+ return Stack(
|
|
|
+ children: [
|
|
|
+ Column(
|
|
|
+ children: [
|
|
|
+ Container(
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.bgCard,
|
|
|
+ border: Border(
|
|
|
+ bottom: BorderSide(color: tdTheme.componentStrokeColor),
|
|
|
),
|
|
|
),
|
|
|
- GestureDetector(
|
|
|
- onTap: () {
|
|
|
- final dir = ref.read(expenseApplySortDirProvider);
|
|
|
- ref.read(expenseApplySortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
|
|
|
- _applyFilter();
|
|
|
- },
|
|
|
- child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseApplySortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
|
|
|
+ child: Column(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
|
+ child: DateRangePicker(
|
|
|
+ startCtrl: _startDateCtrl,
|
|
|
+ endCtrl: _endDateCtrl,
|
|
|
+ onChanged: () {
|
|
|
+ setState(() {});
|
|
|
+ _applyFilter();
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ Padding(
|
|
|
+ padding: const EdgeInsets.fromLTRB(0, 0, 12, 8),
|
|
|
+ child: Row(
|
|
|
+ children: [
|
|
|
+ Expanded(
|
|
|
+ child: TDSearchBar(
|
|
|
+ placeHolder: l10n.get('searchExpenseApply'),
|
|
|
+ style: TDSearchStyle.round,
|
|
|
+ onTextChanged: (String text) {
|
|
|
+ setState(() {
|
|
|
+ _keyword = text;
|
|
|
+ });
|
|
|
+ if (text.isEmpty) _applyFilter();
|
|
|
+ },
|
|
|
+ onSubmitted: (_) => _applyFilter(),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ GestureDetector(
|
|
|
+ onTap: () {
|
|
|
+ final dir = ref.read(expenseApplySortDirProvider);
|
|
|
+ ref
|
|
|
+ .read(expenseApplySortDirProvider.notifier)
|
|
|
+ .state = dir == 'ASC'
|
|
|
+ ? 'DESC'
|
|
|
+ : 'ASC';
|
|
|
+ _applyFilter();
|
|
|
+ },
|
|
|
+ child: Container(
|
|
|
+ width: 40,
|
|
|
+ height: 40,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primary,
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ ),
|
|
|
+ child: Center(
|
|
|
+ child: Icon(
|
|
|
+ ref.watch(expenseApplySortDirProvider) == 'ASC'
|
|
|
+ ? Icons.arrow_upward
|
|
|
+ : Icons.arrow_downward,
|
|
|
+ color: Colors.white,
|
|
|
+ size: 20,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- ])),
|
|
|
- ]),
|
|
|
- ),
|
|
|
- Expanded(child: Container(color: colors.bgPage, child: _firstBuild ? const Center(child: SkeletonLoadingList()) : _ExpenseApplyListContent(refreshCtrl: _refreshCtrl, onRefresh: _doRefresh, scrollCtrl: _scrollCtrl))),
|
|
|
- ]),
|
|
|
- AnimatedPositioned(
|
|
|
- duration: const Duration(milliseconds: 200),
|
|
|
- bottom: _showBackToTop ? 80 : -60,
|
|
|
- left: 0,
|
|
|
- right: 0,
|
|
|
- child: Center(
|
|
|
- child: GestureDetector(
|
|
|
- onTap: () {
|
|
|
- if (_scrollCtrl.hasClients) {
|
|
|
- _scrollCtrl.jumpTo(0);
|
|
|
- }
|
|
|
- },
|
|
|
- child: AnimatedOpacity(
|
|
|
- duration: const Duration(milliseconds: 200),
|
|
|
- opacity: _showBackToTop ? 1.0 : 0.0,
|
|
|
+ ),
|
|
|
+ Expanded(
|
|
|
child: Container(
|
|
|
- width: 36, height: 36,
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: colors.primary400,
|
|
|
- shape: BoxShape.circle,
|
|
|
- boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 6, offset: const Offset(0, 2))],
|
|
|
+ color: colors.bgPage,
|
|
|
+ child: Stack(
|
|
|
+ children: [
|
|
|
+ _firstBuild
|
|
|
+ ? const Center(child: SkeletonLoadingList())
|
|
|
+ : _ExpenseApplyListContent(
|
|
|
+ refreshCtrl: _refreshCtrl,
|
|
|
+ onRefresh: _doRefresh,
|
|
|
+ scrollCtrl: _scrollCtrl,
|
|
|
+ ),
|
|
|
+ if (_filterLoading) ...[
|
|
|
+ Container(color: colors.bgPage.withValues(alpha: 0.6)),
|
|
|
+ const Center(
|
|
|
+ child: TDLoading(
|
|
|
+ size: TDLoadingSize.medium,
|
|
|
+ icon: TDLoadingIcon.activity,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ AnimatedPositioned(
|
|
|
+ duration: const Duration(milliseconds: 200),
|
|
|
+ bottom: _showBackToTop ? 80 : -60,
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ child: Center(
|
|
|
+ child: GestureDetector(
|
|
|
+ onTap: () {
|
|
|
+ if (_scrollCtrl.hasClients) {
|
|
|
+ _scrollCtrl.jumpTo(0);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ child: AnimatedOpacity(
|
|
|
+ duration: const Duration(milliseconds: 200),
|
|
|
+ opacity: _showBackToTop ? 1.0 : 0.0,
|
|
|
+ child: Container(
|
|
|
+ width: 36,
|
|
|
+ height: 36,
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primary400,
|
|
|
+ shape: BoxShape.circle,
|
|
|
+ boxShadow: [
|
|
|
+ BoxShadow(
|
|
|
+ color: Colors.black.withValues(alpha: 0.15),
|
|
|
+ blurRadius: 6,
|
|
|
+ offset: const Offset(0, 2),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ child: const Icon(
|
|
|
+ Icons.keyboard_arrow_up,
|
|
|
+ color: Colors.white,
|
|
|
+ size: 22,
|
|
|
+ ),
|
|
|
),
|
|
|
- child: const Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 22),
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
- ),
|
|
|
- Positioned(
|
|
|
- right: 16, bottom: 16,
|
|
|
- child: FloatingActionButton(
|
|
|
- onPressed: () => context.push('/report/expense-apply-detail'),
|
|
|
- backgroundColor: colors.primary,
|
|
|
- shape: const CircleBorder(),
|
|
|
- child: const Icon(Icons.bar_chart, color: Colors.white),
|
|
|
+ Positioned(
|
|
|
+ right: 16,
|
|
|
+ bottom: 16,
|
|
|
+ child: FloatingActionButton(
|
|
|
+ onPressed: () => context.push('/report/expense-apply-detail'),
|
|
|
+ backgroundColor: colors.primary,
|
|
|
+ shape: const CircleBorder(),
|
|
|
+ child: const Icon(Icons.bar_chart, color: Colors.white),
|
|
|
+ ),
|
|
|
),
|
|
|
- ),
|
|
|
- ]);
|
|
|
+ ],
|
|
|
+ );
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -209,77 +281,184 @@ class _ExpenseApplyListContent extends ConsumerWidget {
|
|
|
final EasyRefreshController refreshCtrl;
|
|
|
final Future<void> Function() onRefresh;
|
|
|
final ScrollController scrollCtrl;
|
|
|
- const _ExpenseApplyListContent({required this.refreshCtrl, required this.onRefresh, required this.scrollCtrl});
|
|
|
+ const _ExpenseApplyListContent({
|
|
|
+ required this.refreshCtrl,
|
|
|
+ required this.onRefresh,
|
|
|
+ required this.scrollCtrl,
|
|
|
+ });
|
|
|
|
|
|
@override
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
final r = ResponsiveHelper.of(context);
|
|
|
final itemsAsync = ref.watch(expenseApplyMyListProvider(''));
|
|
|
- debugPrint('[TBOSS_OA][ExpenseApply] Content build: isLoading=${itemsAsync.isLoading}, hasValue=${itemsAsync.hasValue}, isReloading=${itemsAsync.isReloading}');
|
|
|
- if (itemsAsync.isLoading && !itemsAsync.hasValue) return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: const SkeletonLoadingList()));
|
|
|
- return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: onRefresh, child: _buildContent(itemsAsync, context, ref))));
|
|
|
+ debugPrint(
|
|
|
+ '[TBOSS_OA][ExpenseApply] Content build: isLoading=${itemsAsync.isLoading}, hasValue=${itemsAsync.hasValue}, isReloading=${itemsAsync.isReloading}',
|
|
|
+ );
|
|
|
+ if (itemsAsync.isLoading && !itemsAsync.hasValue)
|
|
|
+ return Center(
|
|
|
+ child: ConstrainedBox(
|
|
|
+ constraints: BoxConstraints(maxWidth: r.listMaxWidth),
|
|
|
+ child: const SkeletonLoadingList(),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ return Center(
|
|
|
+ child: ConstrainedBox(
|
|
|
+ constraints: BoxConstraints(maxWidth: r.listMaxWidth),
|
|
|
+ child: EasyRefresh(
|
|
|
+ controller: refreshCtrl,
|
|
|
+ header: TDRefreshHeader(),
|
|
|
+ onRefresh: onRefresh,
|
|
|
+ child: _buildContent(itemsAsync, context, ref),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
- Widget _buildContent(AsyncValue<List<ExpenseApplyModel>> itemsAsync, BuildContext context, WidgetRef ref) {
|
|
|
+ Widget _buildContent(
|
|
|
+ AsyncValue<List<ExpenseApplyModel>> itemsAsync,
|
|
|
+ BuildContext context,
|
|
|
+ WidgetRef ref,
|
|
|
+ ) {
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
final colors = Theme.of(context).extension<AppColorsExtension>()!;
|
|
|
if (itemsAsync.isReloading) {
|
|
|
final oldItems = itemsAsync.valueOrNull ?? [];
|
|
|
- if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenseApplications'))]);
|
|
|
- return ListView.builder(controller: scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
|
|
|
- final m = oldItems[i];
|
|
|
- final card = ListCard(
|
|
|
- cardNo: m.expenseApplyNo, amount: formatAmount(m.estimatedAmount),
|
|
|
- applicant: m.deptName.isNotEmpty ? '${m.applicantName} · ${m.deptName}' : m.applicantName,
|
|
|
- description: m.remark.isNotEmpty ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}' : m.purpose,
|
|
|
- date: du.DateUtils.formatDate(m.createTime),
|
|
|
- statusTag: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
|
- if (m.isTransferred == 1)
|
|
|
- Container(
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
|
- decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
|
|
|
- child: Text(l10n.get('statusConvertedToExpense'), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary)),
|
|
|
- ),
|
|
|
- if (m.isTransferred == 1) const SizedBox(width: 6),
|
|
|
- if (m.isTransferred != 1) _buildAuditTag(m, l10n, colors),
|
|
|
- if (m.isTransferred != 1) const SizedBox(width: 6),
|
|
|
- _buildUrgencyChip(m.urgency, l10n, colors),
|
|
|
- ]),
|
|
|
- onTap: () { context.push('/expense-apply/detail/${m.expenseApplyNo}'); },
|
|
|
+ if (oldItems.isEmpty)
|
|
|
+ return ListView(
|
|
|
+ children: [
|
|
|
+ const SizedBox(height: 120),
|
|
|
+ EmptyState(message: l10n.get('noExpenseApplications')),
|
|
|
+ ],
|
|
|
);
|
|
|
- return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
|
|
|
- });
|
|
|
+ return ListView.builder(
|
|
|
+ controller: scrollCtrl,
|
|
|
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
|
|
+ itemCount: oldItems.length,
|
|
|
+ itemBuilder: (_, i) {
|
|
|
+ final m = oldItems[i];
|
|
|
+ final card = ListCard(
|
|
|
+ cardNo: m.expenseApplyNo,
|
|
|
+ amount: formatAmount(m.estimatedAmount),
|
|
|
+ applicant: m.deptName.isNotEmpty
|
|
|
+ ? '${m.applicantName} · ${m.deptName}'
|
|
|
+ : m.applicantName,
|
|
|
+ description: m.remark.isNotEmpty
|
|
|
+ ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}'
|
|
|
+ : m.purpose,
|
|
|
+ date: du.DateUtils.formatDate(m.createTime),
|
|
|
+ statusTag: Row(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ if (m.isTransferred == 1)
|
|
|
+ Container(
|
|
|
+ padding: const EdgeInsets.symmetric(
|
|
|
+ horizontal: 8,
|
|
|
+ vertical: 2,
|
|
|
+ ),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primary.withValues(alpha: 0.1),
|
|
|
+ borderRadius: BorderRadius.circular(4),
|
|
|
+ ),
|
|
|
+ child: Text(
|
|
|
+ l10n.get('statusConvertedToExpense'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 11,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: colors.primary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ if (m.isTransferred == 1) const SizedBox(width: 6),
|
|
|
+ if (m.isTransferred != 1) _buildAuditTag(m, l10n, colors),
|
|
|
+ if (m.isTransferred != 1) const SizedBox(width: 6),
|
|
|
+ _buildUrgencyChip(m.urgency, l10n, colors),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ onTap: () {
|
|
|
+ context.push('/expense-apply/detail/${m.expenseApplyNo}');
|
|
|
+ },
|
|
|
+ );
|
|
|
+ return Padding(
|
|
|
+ padding: const EdgeInsets.only(bottom: 16),
|
|
|
+ child: card,
|
|
|
+ );
|
|
|
+ },
|
|
|
+ );
|
|
|
}
|
|
|
- if (itemsAsync.hasError) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed'))]);
|
|
|
+ if (itemsAsync.hasError)
|
|
|
+ return ListView(
|
|
|
+ children: [
|
|
|
+ const SizedBox(height: 120),
|
|
|
+ EmptyState(message: l10n.get('loadFailed')),
|
|
|
+ ],
|
|
|
+ );
|
|
|
final items = itemsAsync.requireValue;
|
|
|
- if (items.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenseApplications'))]);
|
|
|
- return ListView.builder(controller: scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
|
|
|
- if (i == items.length) return ListFooter(itemCount: items.length);
|
|
|
- final m = items[i];
|
|
|
- final card = ListCard(
|
|
|
- cardNo: m.expenseApplyNo, amount: formatAmount(m.estimatedAmount),
|
|
|
- applicant: m.deptName.isNotEmpty ? '${m.applicantName} · ${m.deptName}' : m.applicantName,
|
|
|
- description: m.remark.isNotEmpty ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}' : m.purpose,
|
|
|
- date: du.DateUtils.formatDate(m.createTime),
|
|
|
- statusTag: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
|
- if (m.isTransferred == 1)
|
|
|
- Container(
|
|
|
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
|
- decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4)),
|
|
|
- child: Text(l10n.get('statusConvertedToExpense'), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary)),
|
|
|
- ),
|
|
|
- if (m.isTransferred == 1) const SizedBox(width: 6),
|
|
|
- if (m.isTransferred != 1) _buildAuditTag(m, l10n, colors),
|
|
|
- if (m.isTransferred != 1) const SizedBox(width: 6),
|
|
|
- _buildUrgencyChip(m.urgency, l10n, colors),
|
|
|
- ]),
|
|
|
- onTap: () { context.push('/expense-apply/detail/${m.expenseApplyNo}'); },
|
|
|
+ if (items.isEmpty)
|
|
|
+ return ListView(
|
|
|
+ children: [
|
|
|
+ const SizedBox(height: 120),
|
|
|
+ EmptyState(message: l10n.get('noExpenseApplications')),
|
|
|
+ ],
|
|
|
);
|
|
|
- return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
|
|
|
- });
|
|
|
+ return ListView.builder(
|
|
|
+ controller: scrollCtrl,
|
|
|
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
|
|
+ itemCount: items.length + 1,
|
|
|
+ itemBuilder: (_, i) {
|
|
|
+ if (i == items.length) return ListFooter(itemCount: items.length);
|
|
|
+ final m = items[i];
|
|
|
+ final card = ListCard(
|
|
|
+ cardNo: m.expenseApplyNo,
|
|
|
+ amount: formatAmount(m.estimatedAmount),
|
|
|
+ applicant: m.deptName.isNotEmpty
|
|
|
+ ? '${m.applicantName} · ${m.deptName}'
|
|
|
+ : m.applicantName,
|
|
|
+ description: m.remark.isNotEmpty
|
|
|
+ ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}'
|
|
|
+ : m.purpose,
|
|
|
+ date: du.DateUtils.formatDate(m.createTime),
|
|
|
+ statusTag: Row(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: [
|
|
|
+ if (m.isTransferred == 1)
|
|
|
+ Container(
|
|
|
+ padding: const EdgeInsets.symmetric(
|
|
|
+ horizontal: 8,
|
|
|
+ vertical: 2,
|
|
|
+ ),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: colors.primary.withValues(alpha: 0.1),
|
|
|
+ borderRadius: BorderRadius.circular(4),
|
|
|
+ ),
|
|
|
+ child: Text(
|
|
|
+ l10n.get('statusConvertedToExpense'),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 11,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: colors.primary,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ if (m.isTransferred == 1) const SizedBox(width: 6),
|
|
|
+ if (m.isTransferred != 1) _buildAuditTag(m, l10n, colors),
|
|
|
+ if (m.isTransferred != 1) const SizedBox(width: 6),
|
|
|
+ _buildUrgencyChip(m.urgency, l10n, colors),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ onTap: () {
|
|
|
+ context.push('/expense-apply/detail/${m.expenseApplyNo}');
|
|
|
+ },
|
|
|
+ );
|
|
|
+ return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
|
|
|
+ },
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
- Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ Widget _buildUrgencyChip(
|
|
|
+ String urgency,
|
|
|
+ AppLocalizations l10n,
|
|
|
+ AppColorsExtension colors,
|
|
|
+ ) {
|
|
|
// 与费用申请单创建页保持一致: critical→danger, urgent→warning, normal→primary
|
|
|
final (label, color) = switch (urgency) {
|
|
|
'3' || 'critical' => (l10n.get('critical'), colors.danger),
|
|
|
@@ -293,11 +472,22 @@ class _ExpenseApplyListContent extends ConsumerWidget {
|
|
|
borderRadius: BorderRadius.circular(4),
|
|
|
border: Border.all(color: color, width: 0.5),
|
|
|
),
|
|
|
- child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
|
|
|
+ child: Text(
|
|
|
+ label,
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 11,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: color,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- Widget _buildAuditTag(ExpenseApplyModel item, AppLocalizations l10n, AppColorsExtension colors) {
|
|
|
+ Widget _buildAuditTag(
|
|
|
+ ExpenseApplyModel item,
|
|
|
+ AppLocalizations l10n,
|
|
|
+ AppColorsExtension colors,
|
|
|
+ ) {
|
|
|
if (item.isAuditApproved) {
|
|
|
return Container(
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
|
@@ -307,7 +497,11 @@ class _ExpenseApplyListContent extends ConsumerWidget {
|
|
|
),
|
|
|
child: Text(
|
|
|
l10n.get('statusApproved'),
|
|
|
- style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.success),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 11,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: colors.success,
|
|
|
+ ),
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
@@ -319,7 +513,11 @@ class _ExpenseApplyListContent extends ConsumerWidget {
|
|
|
),
|
|
|
child: Text(
|
|
|
l10n.get('statusPending'),
|
|
|
- style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.statusGray),
|
|
|
+ style: TextStyle(
|
|
|
+ fontSize: 11,
|
|
|
+ fontWeight: FontWeight.w500,
|
|
|
+ color: colors.statusGray,
|
|
|
+ ),
|
|
|
),
|
|
|
);
|
|
|
}
|