import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_theme.dart'; import '../../models/finance.dart'; import '../../providers/finance_provider.dart'; import '../../widgets/data_table_card.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; import '../../widgets/page_scaffold.dart'; import '../../providers/connectivity_provider.dart'; import '../../core/utils/export_util.dart'; import '../../repositories/finance_repository.dart'; class FinanceScreen extends ConsumerWidget { const FinanceScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { return PageScaffold( title: '财务管理', tabs: const [ Tab(text: '全部记录'), Tab(text: '应付账款'), Tab(text: '应收账款'), ], tabViews: [ _FinanceTab(typeFilter: ''), _FinanceTab(typeFilter: 'payable'), _FinanceTab(typeFilter: 'receivable'), ], ); } } class _FinanceTab extends ConsumerStatefulWidget { final String typeFilter; const _FinanceTab({required this.typeFilter}); @override ConsumerState<_FinanceTab> createState() => _FinanceTabState(); } class _FinanceTabState extends ConsumerState<_FinanceTab> { late String _month; int _page = 1; int _pageSize = 20; late Future> _future; List _allRecords = []; int _total = 0; Set _filterType = {}; Set _filterPartner = {}; Set _hiddenCols = {}; static const _colDefs = [ ColDef('date', '日期', required: true), ColDef('type', '类型'), ColDef('partner', '往来单位'), ColDef('ref', '关联单据', minWidth: 900), ColDef('amount', '金额'), ColDef('balance', '余额'), ColDef('status', '状态'), ColDef('remark', '备注', minWidth: 1000), ColDef('actions', '操作'), ]; @override void initState() { super.initState(); final now = DateTime.now(); _month = '${now.year}-${now.month.toString().padLeft(2, '0')}'; _fetch(); } void _fetch() { _future = ref .read(financeRepositoryProvider) .listRecords( type: widget.typeFilter.isEmpty ? null : widget.typeFilter, month: _month, page: _page, pageSize: _pageSize, ) .then((r) { _allRecords = r.data; _total = r.total; return r.data; }); } void _refetch() { setState(() => _fetch()); } List _applyFilters(List all) { return all.where((r) { if (_filterType.isNotEmpty && !_filterType.contains(r.typeLabel)) { return false; } if (_filterPartner.isNotEmpty && !_filterPartner.contains(r.partnerName ?? '')) { return false; } return true; }).toList(); } Future _closeRecord(FinanceRecord r) async { try { await ref.read(financeRepositoryProvider).close(r.id); _refetch(); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('操作失败:$e'), backgroundColor: AppTheme.danger), ); } } } void _showAddDialog() { showDialog( context: context, builder: (_) => _AddPaymentDialog( typeFilter: widget.typeFilter, onSaved: _refetch, repo: ref.read(financeRepositoryProvider), ), ); } /// 财务记录:窄屏卡片 Widget _financeCard(FinanceRecord r) { final canClose = (r.type == 'payable' || r.type == 'receivable') && r.status == 'open'; final showStatus = r.type == 'payable' || r.type == 'receivable'; return MobileListCard( title: Text(r.partnerName?.isNotEmpty == true ? r.partnerName! : r.typeLabel), subtitle: Text(r.recordDate?.substring(0, 10) ?? '-'), trailing: _TypeBadge(r.typeLabel), fields: [ MobileCardField('金额', '¥${r.amount.toStringAsFixed(2)}'), MobileCardField('余额', null, valueWidget: Text( '¥${r.balance.toStringAsFixed(2)}', style: TextStyle( fontSize: 13, color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary, fontWeight: FontWeight.w600, ), )), if (r.refType != null && r.refId != null) MobileCardField( '关联单据', '${r.refType!.replaceAll('_', '-')}#${r.refId}'), if (showStatus) MobileCardField('状态', null, valueWidget: _StatusBadge(r.status)), if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark), ], actions: canClose ? [ TextButton( onPressed: () => _closeRecord(r), child: const Text('结清', style: TextStyle(fontSize: 13, color: AppTheme.success)), ), ] : null, ); } @override Widget build(BuildContext context) { ref.listen(networkRecoveryCountProvider, (_, __) => _refetch()); return FutureBuilder>( future: _future, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snap.hasError) { if (_allRecords.isNotEmpty) { return Column( children: [ _OfflineBanner(onRetry: _refetch), Expanded(child: _buildContent(_applyFilters(_allRecords))), ], ); } return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary), const SizedBox(height: 12), const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)), const SizedBox(height: 12), ElevatedButton(onPressed: _refetch, child: const Text('重试')), ], ), ); } final filtered = _applyFilters(_allRecords); return _buildContent(filtered); }, ); } Widget _buildContent(List records) { // Summary: only for payable/receivable tabs final totalAmount = records.fold(0.0, (s, r) => s + r.amount); final openAmount = records .where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'open') .fold(0.0, (s, r) => s + r.amount); final closedAmount = records .where((r) => (r.type == 'payable' || r.type == 'receivable') && r.status == 'closed') .fold(0.0, (s, r) => s + r.amount); final typeOptions = _allRecords.map((r) => r.typeLabel).toSet().toList()..sort(); final partnerOptions = _allRecords .map((r) => r.partnerName ?? '') .where((s) => s.isNotEmpty) .toSet() .toList() ..sort(); final screenWidth = MediaQuery.of(context).size.width; final visibleCols = _colDefs .where((c) => !_hiddenCols.contains(c.key) && (c.minWidth == null || screenWidth >= c.minWidth!)) .toList(); final columns = visibleCols.map((c) { final label = switch (c.key) { 'type' => FilterableColumnHeader( text: c.label, options: typeOptions, selected: _filterType, onChanged: (v) => setState(() => _filterType = v), ), 'partner' => FilterableColumnHeader( text: c.label, options: partnerOptions, selected: _filterPartner, onChanged: (v) => setState(() => _filterPartner = v), ), _ => Text(c.label), }; return DataColumn( label: label, numeric: c.key == 'amount' || c.key == 'balance'); }).toList(); DataCell buildFinanceCell(String key, FinanceRecord r) { switch (key) { case 'date': return DataCell(Text( r.recordDate?.substring(0, 10) ?? '-', style: const TextStyle(fontSize: 12), )); case 'type': return DataCell(_TypeBadge(r.typeLabel)); case 'partner': return DataCell(SizedBox( width: 160, child: Text(r.partnerName ?? '-', overflow: TextOverflow.ellipsis), )); case 'ref': return DataCell(Text( r.refType != null && r.refId != null ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' : '-', style: const TextStyle( fontSize: 11, fontFamily: 'monospace', color: AppTheme.primary), )); case 'amount': return DataCell(Text( '¥${r.amount.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w500), )); case 'balance': return DataCell(Text( '¥${r.balance.toStringAsFixed(2)}', style: TextStyle( color: r.balance > 0 ? AppTheme.danger : AppTheme.textSecondary, fontWeight: FontWeight.w600, ), )); case 'status': if (r.type != 'payable' && r.type != 'receivable') { return const DataCell(SizedBox()); } return DataCell(_StatusBadge(r.status)); case 'remark': return DataCell(SizedBox( width: 160, child: Text(r.remark ?? '-', overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), )); case 'actions': if ((r.type == 'payable' || r.type == 'receivable') && r.status == 'open') { return DataCell(TextButton( onPressed: () => _closeRecord(r), child: const Text('结清', style: TextStyle(fontSize: 12, color: AppTheme.success)), )); } return const DataCell(SizedBox()); default: return const DataCell(SizedBox()); } } final rows = records.isEmpty ? [ DataRow( cells: List.generate( visibleCols.length, (i) => i == 0 ? const DataCell(Text('暂无记录', style: TextStyle(color: AppTheme.textSecondary))) : const DataCell(SizedBox()), ), ), ] : records .map((r) => DataRow( cells: visibleCols.map((c) => buildFinanceCell(c.key, r)).toList(), )) .toList(); // Determine add button label final addLabel = widget.typeFilter == 'payable' ? '添加付款' : widget.typeFilter == 'receivable' ? '添加收款' : null; return Column( children: [ if (widget.typeFilter.isNotEmpty && records.isNotEmpty) Builder(builder: (context) { final w = context.isMobile ? 150.0 : null; final cards = [ _SummaryCard( title: '合计金额', value: '¥${(totalAmount / 10000).toStringAsFixed(2)}万', icon: Icons.account_balance_wallet, color: AppTheme.primary, width: w, ), const SizedBox(width: 12), _SummaryCard( title: '未结清', value: '¥${(openAmount / 10000).toStringAsFixed(2)}万', icon: Icons.pending_actions, color: AppTheme.danger, width: w, ), const SizedBox(width: 12), _SummaryCard( title: '已结清', value: '¥${(closedAmount / 10000).toStringAsFixed(2)}万', icon: Icons.check_circle, color: AppTheme.success, width: w, ), ]; return Container( color: AppTheme.background, padding: const EdgeInsets.all(12), child: context.isMobile ? SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: cards), ) : Row(children: cards), ); }), if (widget.typeFilter.isNotEmpty && records.isNotEmpty) const Divider(height: 1), Expanded( child: DataTableCard( totalCount: _total > 0 ? _total : records.length, page: _page, pageSize: _pageSize, onPageChanged: (p) { setState(() { _page = p; _fetch(); }); }, onPageSizeChanged: (s) { setState(() { _pageSize = s; _page = 1; _fetch(); }); }, toolbar: Row( children: [ if (addLabel != null) ElevatedButton.icon( onPressed: _showAddDialog, icon: const Icon(Icons.add, size: 16), label: Text(addLabel), ), if (addLabel != null) const SizedBox(width: 8), OutlinedButton.icon( onPressed: () { final tabName = widget.typeFilter.isEmpty ? '财务全部记录' : widget.typeFilter == 'payable' ? '应付账款' : '应收账款'; exportExcel( filename: tabName, headers: ['日期', '类型', '往来单位', '关联单据', '金额', '余额', '状态', '备注'], rows: records.map((r) => [ r.recordDate?.substring(0, 10) ?? '', r.typeLabel, r.partnerName ?? '', r.refType != null && r.refId != null ? '${r.refType!.replaceAll('_', '-')}#${r.refId}' : '', r.amount, r.balance, r.status == 'open' ? '未结清' : '已结清', r.remark ?? '', ]).toList(), ); }, icon: const Icon(Icons.download, size: 16), label: const Text('导出'), ), const Spacer(), _MonthSelector( value: _month, onChanged: (v) { _month = v; _page = 1; _refetch(); }, ), const SizedBox(width: 8), ColumnToggleButton( columns: _colDefs, hidden: _hiddenCols, onChanged: (v) => setState(() => _hiddenCols = v), ), ], ), columns: columns, rows: rows, mobileCards: records.map(_financeCard).toList(), ), ), ], ); } } // ── 添加付款/收款弹窗 ───────────────────────────────────────── class _AddPaymentDialog extends StatefulWidget { final String typeFilter; // 'payable' | 'receivable' final VoidCallback onSaved; final FinanceRepository repo; const _AddPaymentDialog({ required this.typeFilter, required this.onSaved, required this.repo, }); @override State<_AddPaymentDialog> createState() => _AddPaymentDialogState(); } class _AddPaymentDialogState extends State<_AddPaymentDialog> { final _amountCtrl = TextEditingController(); final _remarkCtrl = TextEditingController(); final _partnerCtrl = TextEditingController(); DateTime _date = DateTime.now(); bool _saving = false; String get _type => widget.typeFilter == 'payable' ? 'payment' : 'receipt'; String get _title => widget.typeFilter == 'payable' ? '添加付款记录' : '添加收款记录'; @override void dispose() { _amountCtrl.dispose(); _remarkCtrl.dispose(); _partnerCtrl.dispose(); super.dispose(); } Future _save() async { final amount = double.tryParse(_amountCtrl.text.trim()); if (amount == null || amount <= 0) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('请输入有效金额'), backgroundColor: AppTheme.danger), ); return; } setState(() => _saving = true); try { final body = { 'type': _type, 'amount': amount, 'record_date': '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}', if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(), }; await widget.repo.create(body); if (mounted) { Navigator.of(context).pop(); widget.onSaved(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('添加成功'), backgroundColor: AppTheme.success), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('添加失败:$e'), backgroundColor: AppTheme.danger), ); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { return AlertDialog( title: Text(_title), content: SizedBox( width: context.dialogWidth(360), child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _amountCtrl, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: const InputDecoration(labelText: '金额', prefixText: '¥ '), ), const SizedBox(height: 12), InkWell( onTap: () async { final picked = await showDatePicker( context: context, initialDate: _date, firstDate: DateTime(2020), lastDate: DateTime.now().add(const Duration(days: 30)), ); if (picked != null) setState(() => _date = picked); }, child: InputDecorator( decoration: const InputDecoration(labelText: '日期'), child: Text( '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}', style: const TextStyle(fontSize: 14), ), ), ), const SizedBox(height: 12), TextField( controller: _remarkCtrl, decoration: const InputDecoration(labelText: '备注(选填)'), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('取消'), ), ElevatedButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('保存'), ), ], ); } } // ── Widgets ────────────────────────────────────────────────── class _StatusBadge extends StatelessWidget { final String status; const _StatusBadge(this.status); @override Widget build(BuildContext context) { final isOpen = status == 'open'; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: isOpen ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(3), ), child: Text( isOpen ? '未结清' : '已结清', style: TextStyle( color: isOpen ? AppTheme.accent : AppTheme.textSecondary, fontSize: 12, fontWeight: FontWeight.w500, ), ), ); } } class _TypeBadge extends StatelessWidget { final String label; const _TypeBadge(this.label); @override Widget build(BuildContext context) { Color bg, fg; switch (label) { case '应收账款': bg = const Color(0xFFE3F2FD); fg = AppTheme.primary; break; case '应付账款': bg = const Color(0xFFFFEBEE); fg = AppTheme.danger; break; case '收款': bg = const Color(0xFFE8F5E9); fg = AppTheme.success; break; case '付款': bg = const Color(0xFFFFF3E0); fg = AppTheme.accent; break; default: bg = const Color(0xFFF5F5F5); fg = AppTheme.textSecondary; } return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(3)), child: Text(label, style: TextStyle(color: fg, fontSize: 12, fontWeight: FontWeight.w500)), ); } } class _SummaryCard extends StatelessWidget { final String title; final String value; final IconData icon; final Color color; final double? width; const _SummaryCard({ required this.title, required this.value, required this.icon, required this.color, this.width, }); @override Widget build(BuildContext context) { final card = Container( height: 72, padding: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration( color: AppTheme.surface, borderRadius: BorderRadius.circular(4), border: Border.all(color: AppTheme.border, width: 0.5), ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, color: color, size: 22), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Text(title, style: const TextStyle( fontSize: 12, color: AppTheme.textSecondary)), const SizedBox(height: 4), Text(value, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, color: color)), ], ), ], ), ); return width != null ? SizedBox(width: width, child: card) : Expanded(child: card); } } class _MonthSelector extends StatelessWidget { final String value; final ValueChanged onChanged; const _MonthSelector({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final now = DateTime.now(); final months = List.generate(12, (i) { final d = DateTime(now.year, now.month - i, 1); return '${d.year}-${d.month.toString().padLeft(2, '0')}'; }); final safeValue = months.contains(value) ? value : months.first; return Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( border: Border.all(color: AppTheme.border), borderRadius: BorderRadius.circular(4), color: AppTheme.surface, ), child: DropdownButtonHideUnderline( child: DropdownButton( value: safeValue, items: months .map((m) => DropdownMenuItem( value: m, child: Text(m, style: const TextStyle(fontSize: 13)))) .toList(), onChanged: (v) => onChanged(v!), style: const TextStyle(fontSize: 13, color: AppTheme.textPrimary), ), ), ); } } class _OfflineBanner extends StatelessWidget { final VoidCallback onRetry; const _OfflineBanner({required this.onRetry}); @override Widget build(BuildContext context) { return Container( width: double.infinity, color: const Color(0xFFFFF8E1), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Row( children: [ const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)), const SizedBox(width: 8), const Expanded( child: Text('网络不可用,当前显示离线缓存数据', style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)), ), TextButton( onPressed: onRetry, style: TextButton.styleFrom( foregroundColor: const Color(0xFFF57F17), padding: const EdgeInsets.symmetric(horizontal: 8)), child: const Text('重试', style: TextStyle(fontSize: 12)), ), ], ), ); } }