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'; import 'package:easy_refresh/easy_refresh.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/skeleton_list_card.dart'; import '../../shared/widgets/list_footer.dart'; import 'expense_api.dart'; /// 可导入的费用申请明细项 class ImportableItem { final String aeNo; final String aeDd; final String reason; final double headAmtnYj; final int itm; final String sqMan; final String sqName; final String typeNo; final String typeName; final double amtnYj; final String accNo; final String accName; final String dep; final String depName; final String objNo; final String objName; final String startDd; final String endDd; final String priority; final String rem; bool selected = false; ImportableItem({ required this.aeNo, required this.aeDd, required this.reason, required this.headAmtnYj, required this.itm, required this.sqMan, required this.sqName, required this.typeNo, required this.typeName, required this.amtnYj, required this.accNo, required this.accName, required this.dep, required this.depName, required this.objNo, required this.objName, required this.startDd, required this.endDd, required this.priority, required this.rem, }); factory ImportableItem.fromJson(Map json) => ImportableItem( aeNo: json['aeNo'] as String? ?? '', aeDd: _fmtDate(json['aeDd'] as String?), reason: json['reason'] as String? ?? '', headAmtnYj: (json['headAmtnYj'] as num?)?.toDouble() ?? 0, itm: json['itm'] as int? ?? 0, sqMan: json['sqMan'] as String? ?? '', sqName: json['sqName'] as String? ?? '', typeNo: json['typeNo'] as String? ?? '', typeName: json['typeName'] as String? ?? '', amtnYj: (json['amtnYj'] as num?)?.toDouble() ?? 0, accNo: json['accNo'] as String? ?? '', accName: json['accName'] as String? ?? '', dep: json['dep'] as String? ?? '', depName: json['depName'] as String? ?? '', objNo: json['objNo'] as String? ?? '', objName: json['objName'] as String? ?? '', startDd: _fmtDate(json['startDd'] as String?), endDd: _fmtDate(json['endDd'] as String?), priority: json['priority'] as String? ?? '1', rem: json['rem'] as String? ?? '', ); static String _fmtDate(String? raw) { if (raw == null || raw.isEmpty) return ''; try { final d = DateTime.parse(raw); return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; } catch (_) { return raw; } } } class ExpenseApplyImportPage extends ConsumerStatefulWidget { const ExpenseApplyImportPage({super.key}); @override ConsumerState createState() => _ExpenseApplyImportPageState(); } class _ExpenseApplyImportPageState extends ConsumerState with WidgetsBindingObserver { final _aeNoCtrl = TextEditingController(); final _startDateCtrl = TextEditingController(); final _endDateCtrl = TextEditingController(); List _items = []; final Map _expandedGroups = {}; bool _loading = false; bool _hasMore = true; int _page = 1; late final ScrollController _scrollCtrl; late final EasyRefreshController _refreshCtrl; bool _sortDesc = true; @override 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')}'; _scrollCtrl = ScrollController()..addListener(_onScroll); _refreshCtrl = EasyRefreshController(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _aeNoCtrl.dispose(); _startDateCtrl.dispose(); _endDateCtrl.dispose(); _scrollCtrl.dispose(); _refreshCtrl.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _aeNoCtrl.clear(); 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')}'; _items = []; _page = 1; _hasMore = true; _generation++; _load(); } } void _onScroll() { if (!_loading && _hasMore && _scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 200) { _load(append: true); } } int _generation = 0; Future _load({bool append = false}) async { if (_loading) return; final gen = ++_generation; setState(() => _loading = true); try { final api = ref.read(expenseApiProvider); final result = await api.getImportableExpenseApplies( keyword: _aeNoCtrl.text.trim(), startDate: _startDateCtrl.text, endDate: _endDateCtrl.text, page: append ? _page : 1, sortDir: _sortDesc ? 'DESC' : 'ASC', ); if (!mounted) return; if (gen != _generation) return; final list = (result['list'] as List?) ?.map((e) => ImportableItem.fromJson(e as Map)) .toList() ?? []; setState(() { if (append) { _items.addAll(list); _page++; } else { _items = list; _page = 2; } _loading = false; _hasMore = list.length >= 20; }); } catch (_) { if (mounted) setState(() => _loading = false); } } void _search() { FocusScope.of(context).unfocus(); if (_startDateCtrl.text.isNotEmpty && _endDateCtrl.text.isNotEmpty && _startDateCtrl.text.compareTo(_endDateCtrl.text) > 0) { TDToast.showText(AppLocalizations.of(context).get('filterDateStartAfterEnd'), context: context); return; } _refreshCtrl.callRefresh(); } Future _refresh() async { FocusScope.of(context).unfocus(); _page = 1; _loading = false; await _load(); } void _toggleItem(int idx) { setState(() => _items[idx].selected = !_items[idx].selected); } void _toggleGroup(String aeNo) { setState(() { final items = _items.where((e) => e.aeNo == aeNo).toList(); final allSelected = items.every((e) => e.selected); final newVal = !allSelected; for (final e in items) { e.selected = newVal; } }); } bool _isGroupAllSelected(String aeNo) { final items = _items.where((e) => e.aeNo == aeNo); if (items.isEmpty) return false; return items.every((e) => e.selected); } bool _isGroupAnySelected(String aeNo) { return _items.any((e) => e.aeNo == aeNo && e.selected); } void _confirmImport() { final l10n = AppLocalizations.of(context); final selected = _items.where((e) => e.selected).toList(); if (selected.isEmpty) { TDToast.showText(l10n.get('pleaseSelect'), context: context); return; } Navigator.of(context).pop(selected); } void _pickDate(TextEditingController ctrl) { FocusScope.of(context).unfocus(); final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; 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) { final d = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}'; if (_validateDateRange(ctrl, d)) { ctrl.text = d; setState(() {}); } Navigator.of(context).pop(); }, ); } bool _validateDateRange(TextEditingController changed, String newValue) { final start = changed == _startDateCtrl ? newValue : _startDateCtrl.text; final end = changed == _endDateCtrl ? newValue : _endDateCtrl.text; if (start.isNotEmpty && end.isNotEmpty && start.compareTo(end) > 0) { TDToast.showText(AppLocalizations.of(context).get('filterDateStartAfterEnd'), context: context); return false; } return true; } Widget _buildSearchBar(AppLocalizations l10n, AppColorsExtension colors) { final tdTheme = TDTheme.of(context); return Container( decoration: BoxDecoration( color: colors.bgCard, border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [ Expanded( child: GestureDetector( 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( onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors), ), ), ]), ), const SizedBox(height: 8), Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [ Expanded( child: Container( height: 40, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor), ), child: Row(children: [ Expanded( child: TextField( controller: _aeNoCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration( hintText: l10n.get('searchImportHint'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true, ), onChanged: (_) => setState(() {}), ), ), if (_aeNoCtrl.text.isNotEmpty) GestureDetector( onTap: () { _aeNoCtrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary), ), ]), ), ), const SizedBox(width: 8), GestureDetector( onTap: () { setState(() => _sortDesc = !_sortDesc); _search(); }, child: Container( width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(_sortDesc ? Icons.arrow_downward : Icons.arrow_upward, color: Colors.white, size: 20)), ), ), const SizedBox(width: 8), GestureDetector( onTap: _search, child: Container( width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22), ), ), ]), ), ], ), ); } 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), ), ]), ); } Widget _buildListContent(AppLocalizations l10n, AppColorsExtension colors, Map> grouped) { if (_loading && _items.isEmpty) { return EasyRefresh( header: TDRefreshHeader(), controller: _refreshCtrl, onRefresh: _refresh, child: SkeletonLoadingList( cardBuilder: () => const SkeletonImportCard(), ), ); } if (_items.isEmpty) { return EasyRefresh( header: TDRefreshHeader(), controller: _refreshCtrl, onRefresh: _refresh, child: ListView( children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noData'))], ), ); } return EasyRefresh( header: TDRefreshHeader(), controller: _refreshCtrl, onRefresh: _refresh, child: ListView.builder( controller: _scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), itemCount: grouped.length + 1, itemBuilder: (_, i) { if (i == grouped.length) { return ListFooter(itemCount: _items.length, hasMore: _hasMore); } final aeNo = grouped.keys.elementAt(i); return _buildGroupCard(aeNo, grouped[aeNo]!, l10n, colors); }, ), ); } Widget _buildGroupCard(String aeNo, List items, AppLocalizations l10n, AppColorsExtension colors) { return Padding( padding: const EdgeInsets.only(bottom: 16), child: Container( decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _toggleGroup(aeNo), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ _buildHeaderCheckbox(aeNo, colors), const SizedBox(width: 8), Expanded(child: Text(aeNo, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.textPrimary))), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(items.first.aeDd, style: TextStyle(fontSize: 13, color: colors.textSecondary)), const SizedBox(height: 2), _priorityChip(items.first.priority, l10n, colors), ]), ]), if (items.first.reason.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4, left: 30), child: Text(items.first.reason, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: colors.textSecondary)), ), ]), ), ), Divider(height: 1, color: colors.border), if (items.length <= 3) ...items.asMap().entries.map((entry) => _buildDetailRow(entry.value, _items.indexOf(entry.value), l10n, colors)) else ...[ ...items.take(3).toList().asMap().entries.map((entry) => _buildDetailRow(entry.value, _items.indexOf(entry.value), l10n, colors)), GestureDetector( onTap: () => setState(() => _expandedGroups[aeNo] = !(_expandedGroups[aeNo] ?? false)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Text( _expandedGroups[aeNo] == true ? '${l10n.getString('collapseRemaining', args: {'count': (items.length - 3).toString()})} ▲' : '${l10n.getString('expandRemaining', args: {'count': (items.length - 3).toString()})} ▼', style: TextStyle(fontSize: 13, color: colors.primary), ), ], ), ), ), if (_expandedGroups[aeNo] == true) ...items.skip(3).toList().asMap().entries.map((entry) => _buildDetailRow(entry.value, _items.indexOf(entry.value), l10n, colors)), ], if (items.length > 1) Padding( padding: const EdgeInsets.fromLTRB(0, 4, 16, 12), child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ Text('${l10n.get('total')} ${items.length} ${l10n.get('unitItem')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)), ]), ) else const SizedBox(height: 8), ], ), ), ); } Widget _buildDetailRow(ImportableItem d, int idx, AppLocalizations l10n, AppColorsExtension colors) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _toggleItem(idx), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row(children: [ SizedBox(width: 30, child: _buildItemCheckbox(idx, colors)), const SizedBox(width: 4), Container(width: 24, alignment: Alignment.center, child: Text('#${d.itm}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.textSecondary))), const SizedBox(width: 8), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${d.typeName.isNotEmpty ? '${d.typeNo}/${d.typeName}' : d.typeNo} ${d.accName}', style: TextStyle(fontSize: 14, color: colors.textPrimary)), if (d.rem.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 2), child: Text(d.rem, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: colors.textSecondary))), const SizedBox(height: 3), if (d.sqMan.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 2), child: Text('${l10n.get('applicant')}: ${d.sqName.isNotEmpty ? '${d.sqMan}/${d.sqName}' : d.sqMan}', style: TextStyle(fontSize: 13, color: colors.textSecondary))), Padding(padding: const EdgeInsets.only(bottom: 2), child: Text('${l10n.get('acctSubject')}: ${d.accNo}/${d.accName}', style: TextStyle(fontSize: 13, color: colors.textSecondary))), if (d.depName.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 2), child: Text('${l10n.get('dept')}: ${d.dep}/${d.depName}', style: TextStyle(fontSize: 13, color: colors.textSecondary))), if (d.objName.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 2), child: Text('${l10n.get('project')}: ${d.objNo}/${d.objName}', style: TextStyle(fontSize: 13, color: colors.textSecondary))), if (d.startDd.isNotEmpty || d.endDd.isNotEmpty) Text('${l10n.get('date')}: ${d.startDd} ~ ${d.endDd}', style: TextStyle(fontSize: 13, color: colors.textSecondary)), ]), ), Text('¥${d.amtnYj.toStringAsFixed(2)}', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.amountPrimary)), ]), ), ); } Widget _buildHeaderCheckbox(String aeNo, AppColorsExtension colors) { final allSel = _isGroupAllSelected(aeNo); final anySel = _isGroupAnySelected(aeNo); IconData icon; Color iconColor; if (allSel) { icon = Icons.check_box; iconColor = colors.primary; } else if (anySel) { icon = Icons.indeterminate_check_box; iconColor = colors.primary; } else { icon = Icons.check_box_outline_blank; iconColor = colors.textPlaceholder; } return GestureDetector( onTap: () => _toggleGroup(aeNo), child: Icon(icon, size: 22, color: iconColor), ); } Widget _buildItemCheckbox(int idx, AppColorsExtension colors) { final item = _items[idx]; return GestureDetector( onTap: () => _toggleItem(idx), child: Icon( item.selected ? Icons.check_box : Icons.check_box_outline_blank, size: 18, color: item.selected ? colors.primary : colors.textPlaceholder, ), ); } Widget _priorityChip(String priority, AppLocalizations l10n, AppColorsExtension colors) { final label = priority == '3' ? l10n.get('filterCritical') : priority == '2' ? l10n.get('urgent') : l10n.get('normal'); final chipColor = priority == '3' ? colors.danger : priority == '2' ? colors.warning : colors.primary; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: chipColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), border: Border.all(color: chipColor, width: 0.5), ), child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: chipColor)), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; setNavBarTitle(context, ref, NavBarConfig( title: l10n.get('importExpenseApply'), showBack: true, onBack: () => context.pop(), )); final grouped = >{}; for (final item in _items) { grouped.putIfAbsent(item.aeNo, () => []).add(item); } return Scaffold( backgroundColor: colors.bgPage, body: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => FocusScope.of(context).unfocus(), child: Column(children: [ _buildSearchBar(l10n, colors), const SizedBox(height: 8), Expanded( child: _buildListContent(l10n, colors, grouped), ), ]), ), bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.all(12), child: TDButton( text: l10n.get('confirmImport'), size: TDButtonSize.large, theme: TDButtonTheme.primary, onTap: _confirmImport, ), ), ), ); } }