// screens/finance/finance_screen.dart — 财务管理(照原型 finance.html 重建)。 // 版式:head + 时间范围 chips + KPI 4 卡 + 收支趋势柱状图 + 应收/应付汇总表 // + 收支流水表 + 往来抽屉。无分页(原型无 .pager,流水按当前范围一次取)。 // 已知差异(记 design/CONTRACT.md):KPI3/4 delta 用「未结清 N 笔」(原型 逾期/到期额 // 无数据源);流水类型 chips 扩到 5(含应收/应付)并追加 状态/操作 列(结清能力不回退); // 时间范围真实过滤流水(原型只改文案)。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/utils/clock.dart'; import '../../core/utils/export_util.dart'; import '../../models/finance.dart'; import '../../providers/finance_provider.dart'; import '../../providers/stock_in_provider.dart'; import '../../providers/stock_out_provider.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_bar_chart.dart'; import '../../widgets/ds/ds_kpi.dart'; import '../../widgets/ds/ds_table.dart'; import '../../widgets/finance_entry_dialog.dart'; import '../../widgets/finance_partner_drawer.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/write_guard.dart'; import '../../core/theme/app_fonts.dart'; import '../../widgets/ds/ds_toast.dart'; class FinanceScreen extends ConsumerStatefulWidget { const FinanceScreen({super.key}); @override ConsumerState createState() => _FinanceScreenState(); } class _FinanceScreenState extends ConsumerState { // 时间范围(原型 .timerow chips):真实过滤流水表;KPI 恒本月、趋势恒近 6 月 String _range = '本月'; String _rangeLabel = '本月'; // 流水类型筛选(原型 3 chips 扩到 5) String _flowChip = '全部'; static const _flowChips = ['全部', '应收', '应付', '收款', '付款']; static const _chipToType = { '全部': '', '应收': 'receivable', '应付': 'payable', '收款': 'receipt', '付款': 'payment', }; static final _ymd = DateFormat('yyyy-MM-dd'); @override void initState() { super.initState(); // 默认本月(与 KPI 口径一致) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _applyRange('本月'); }); } Future _applyRange(String range) async { final now = appNow(); final notifier = ref.read(financeListProvider.notifier); switch (range) { case '本月': final start = DateTime(now.year, now.month, 1); notifier.setRange(_ymd.format(start), _ymd.format(now)); setState(() { _range = range; _rangeLabel = '本月'; }); case '本季': final qMonth = ((now.month - 1) ~/ 3) * 3 + 1; final start = DateTime(now.year, qMonth, 1); notifier.setRange(_ymd.format(start), _ymd.format(now)); setState(() { _range = range; _rangeLabel = '本季'; }); case '自定义': final picked = await showDateRangePicker( context: context, firstDate: DateTime(now.year - 3), lastDate: now, initialDateRange: DateTimeRange(start: DateTime(now.year, now.month, 1), end: now), ); if (picked == null) return; notifier.setRange(_ymd.format(picked.start), _ymd.format(picked.end)); setState(() { _range = range; _rangeLabel = '${_ymd.format(picked.start)} ~ ${_ymd.format(picked.end)}'; }); } } void _reload() { ref.read(financeListProvider.notifier).reload(); ref.invalidate(financePartnerRowsProvider); ref.invalidate(financeTrendProvider); ref.invalidate(stockInSummaryProvider); ref.invalidate(stockOutSummaryProvider); } // ── 金额格式 ── String _yuan(double v) => '¥${NumberFormat.decimalPattern().format( v == v.roundToDouble() ? v.round() : v, )}'; /// KPI 大数:¥186万 / ¥48.6万(<1万显示整数元)。 String _yuanWan(double v) { if (v >= 10000) { final wan = v / 10000; return '¥${wan == wan.roundToDouble() ? wan.round() : wan.toStringAsFixed(1)}万'; } return _yuan(v); } (String, DsKpiDelta) _momDelta(double cur, double last) { if (last <= 0) return ('较上月 —', DsKpiDelta.neutral); final pct = (cur - last) / last * 100; if (pct == 0) return ('与上月持平', DsKpiDelta.neutral); return ( '${pct.abs().toStringAsFixed(1)}% 较上月', pct > 0 ? DsKpiDelta.up : DsKpiDelta.down, ); } @override Widget build(BuildContext context) { final t = context.tokens; final mobile = context.isMobile; final flowsAsync = ref.watch(financeListProvider); final flows = flowsAsync.valueOrNull?.data ?? const []; final content = Container( color: t.bg, child: SingleChildScrollView( padding: mobile ? const EdgeInsets.all(AppDims.sp4) : const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _head(t, mobile, flows), _timeRow(t, mobile), _kpis(t, mobile), _secHead(t, '收支趋势', '近 6 个月 · 单位 万元'), _trendChart(), const SizedBox(height: 22), _secHead(t, '应收 / 应付 汇总', '按往来单位 · 点击行查看流水'), _summaryTable(t, mobile), const SizedBox(height: 22), _secHead(t, '收支流水', _flowSub(flows)), _flowTable(t, mobile, flows, flowsAsync.valueOrNull?.total ?? 0), ], ), ), ); return Stack(children: [ content, if (flowsAsync.isLoading) const Positioned.fill(child: DsLoadingScrim()), ]); } // ── head ── Widget _head(dynamic t, bool mobile, List flows) { return Padding( padding: const EdgeInsets.only(bottom: 18), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('财务管理', style: TextStyle( fontSize: AppDims.fsH1, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(width: AppDims.sp3), Expanded( child: Padding( padding: const EdgeInsets.only(bottom: 2), child: Text('应收/应付概览 · $_rangeLabel', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), ), if (!mobile) ...[ DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload), const SizedBox(width: 10), DsButton('导出报表', icon: LucideIcons.download, onPressed: () => _export(flows)), const SizedBox(width: 10), WriteGuard( child: DsButton('登记收支', icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: () => showFinanceEntryDialog(context)), ), ], ], ), ); } void _export(List flows) { exportExcel( filename: '财务流水', headers: ['日期', '类型', '往来单位', '金额', '关联单据', '状态', '备注'], rows: flows .map((r) => [ (r.recordDate ?? '').split('T').first, r.typeLabel, r.partnerName ?? '', r.amount, r.refType != null ? '${r.docTitle}#${r.refId ?? ''}' : '', r.status == 'open' ? '未结清' : '已结清', r.remark ?? '', ]) .toList(), ); } // ── 时间范围 chips(原型 .timerow)── Widget _timeRow(dynamic t, bool mobile) { return Padding( padding: const EdgeInsets.only(bottom: 18), child: Row(children: [ Text('时间范围', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(width: 12), for (final r in const ['本月', '本季', '自定义']) ...[ DsChip( label: r, selected: _range == r, caret: r == '自定义', onTap: () => _applyRange(r)), const SizedBox(width: 10), ], if (mobile) ...[ const Spacer(), WriteGuard( child: DsButton('登记', small: true, icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: () => showFinanceEntryDialog(context)), ), ], ]), ); } // ── KPI 4 卡 ── Widget _kpis(dynamic t, bool mobile) { final outSum = ref.watch(stockOutSummaryProvider).valueOrNull; final inSum = ref.watch(stockInSummaryProvider).valueOrNull; final rows = ref.watch(financePartnerRowsProvider).valueOrNull ?? const []; var recv = 0.0, pay = 0.0, openCount = 0; for (final r in rows) { recv += r.recv; pay += r.pay; openCount += r.openCount; } final (saleDelta, saleTone) = outSum != null ? _momDelta(outSum.monthAmount, outSum.lastMonthAmount) : ('较上月 —', DsKpiDelta.neutral); final (buyDelta, buyTone) = inSum != null ? _momDelta(inSum.monthAmount, inSum.lastMonthAmount) : ('较上月 —', DsKpiDelta.neutral); final cards = [ DsKpi( title: '本月销售额', value: outSum != null ? _yuanWan(outSum.monthAmount) : '—', icon: LucideIcons.box, tone: DsKpiTone.info, delta: saleDelta, deltaTone: saleTone, ), DsKpi( title: '本月采购额', value: inSum != null ? _yuanWan(inSum.monthAmount) : '—', icon: LucideIcons.shoppingCart, tone: DsKpiTone.blue, delta: buyDelta, deltaTone: buyTone, ), // 原型 delta「逾期 ¥9.2万」无数据源 → 未结清笔数(已知差异) DsKpi( title: '应收账款 · 待收', value: _yuanWan(recv), icon: LucideIcons.circleDollarSign, tone: DsKpiTone.alert, delta: '未结清 $openCount 笔', deltaTone: DsKpiDelta.down, ), DsKpi( title: '应付账款', value: _yuanWan(pay), icon: LucideIcons.receipt, tone: DsKpiTone.ok, delta: '按到期及时结清', deltaTone: DsKpiDelta.up, ), ]; if (mobile) { return Padding( padding: const EdgeInsets.only(bottom: 20), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: IntrinsicHeight( child: Row(children: [ for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(width: AppDims.sp3), SizedBox(width: 170, child: cards[i]), ], ]), ), ), ); } return Padding( padding: const EdgeInsets.only(bottom: 20), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(width: 14), Expanded(child: cards[i]), ], ], ), ), ); } /// 原型 .sec-head:h2 17/700 + 副标 fs-xs muted,下距 12。 Widget _secHead(dynamic t, String title, String sub) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(title, style: TextStyle( fontSize: AppDims.fsH2, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(width: 10), Padding( padding: const EdgeInsets.only(bottom: 1), child: Text(sub, style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), ), ], ), ); // ── 收支趋势 ── Widget _trendChart() { final t = context.tokens; final trend = ref.watch(financeTrendProvider); Widget placeholder(Widget child) => Container( height: 120, alignment: Alignment.center, decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: child, ); return trend.when( loading: () => placeholder(const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), error: (e, _) => placeholder(Text('趋势加载失败', style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))), data: (points) => DsBarChart( groups: [ for (final p in points) DsBarGroup(_monthLabel(p.month), p.inAmount, p.outAmount), ], legendA: '收入', legendB: '支出', // 柱顶标签:万元取整(原型 TREND 值即万元整数) format: (v) => '${(v / 10000).round()}', ), ); } String _monthLabel(String ym) { final parts = ym.split('-'); return parts.length == 2 ? '${int.tryParse(parts[1]) ?? parts[1]}月' : ym; } // ── 应收/应付汇总表 ── Widget _summaryTable(dynamic t, bool mobile) { final async = ref.watch(financePartnerRowsProvider); return async.when( loading: () => const Padding( padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator())), error: (e, _) => Row(children: [ Text('汇总加载失败', style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)), const SizedBox(width: 10), DsButton('重试', small: true, onPressed: () => ref.invalidate(financePartnerRowsProvider)), ]), data: (rows) => DsTable( shrinkWrap: true, emptyText: '暂无未结清的应收 / 应付', mobileCards: rows .map((r) => MobileListCard( title: Text(r.name), trailing: Text(signedYuan(r.net), style: TextStyle( fontWeight: FontWeight.w600, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: r.net > 0 ? t.success : (r.net < 0 ? t.danger : t.text))), fields: [ MobileCardField('应收', r.recv > 0 ? _yuan(r.recv) : '—'), MobileCardField('应付', r.pay > 0 ? _yuan(r.pay) : '—'), ], onTap: () => showFinancePartnerDrawer(context, row: r), )) .toList(), columns: const [ DsColumn('name', '往来单位'), DsColumn('recv', '应收', numeric: true), DsColumn('pay', '应付', numeric: true), DsColumn('net', '净额', numeric: true), DsColumn('actions', '操作', action: true), ], rows: rows.map((r) { return DsRow( onTap: () => showFinancePartnerDrawer(context, row: r), cells: [ Text(r.name, style: TextStyle(fontWeight: FontWeight.w600, color: t.heading)), Text(r.recv > 0 ? _yuan(r.recv) : '—', style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), Text(r.pay > 0 ? _yuan(r.pay) : '—', style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), Text(signedYuan(r.net), style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w600, color: r.net > 0 ? t.success : (r.net < 0 ? t.danger : t.text))), InkWell( onTap: () => showFinancePartnerDrawer(context, row: r), child: Text('查看', style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.primary)), ), ], ); }).toList(), ), ); } String _flowSub(List flows) { final receipts = flows.where((r) => r.type == 'receipt').length; final payments = flows.where((r) => r.type == 'payment').length; return '$_rangeLabel ${flows.length} 笔 · 收 $receipts / 付 $payments'; } // ── 收支流水表 ── Widget _flowTable( dynamic t, bool mobile, List flows, int total) { final chips = [ for (final c in _flowChips) ...[ DsChip( label: c, selected: _flowChip == c, caret: false, onTap: () => _setFlowChip(c)), const SizedBox(width: 10), ], ]; final toolbar = mobile ? SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: chips)) : Row(children: [ Text('类型', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(width: 12), ...chips, const Spacer(), Text('共 $total 条', style: TextStyle( fontSize: AppDims.fsSm, color: t.muted, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ]); return DsTable( shrinkWrap: true, emptyText: '没有匹配的流水记录', toolbar: toolbar, mobileCards: flows.map((r) => _flowCard(t, r)).toList(), columns: const [ DsColumn('date', '日期'), DsColumn('type', '类型'), DsColumn('partner', '往来单位'), DsColumn('amount', '金额', numeric: true), DsColumn('ref', '关联单号'), DsColumn('remark', '备注'), DsColumn('status', '状态'), DsColumn('actions', '操作', action: true), ], rows: flows.map((r) { final isIn = r.type == 'receipt' || r.type == 'receivable'; return DsRow(cells: [ Text((r.recordDate ?? '').split('T').first, style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), financeTypeBadge(r), Text((r.partnerName?.isNotEmpty == true) ? r.partnerName! : '—'), // 原型 .qty:+/- 前缀、恒 heading 色(不上正负色) Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}', style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w600, color: t.heading)), Text( r.refType != null && r.refId != null ? '${r.docTitle} #${r.refId}' : '—', style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: AppDims.fsSm, color: t.muted)), Text((r.remark?.isNotEmpty == true) ? r.remark! : '—', style: TextStyle(color: t.muted)), r.status == 'open' ? const DsBadge('未结清', tone: DsBadgeTone.warn) : const DsBadge('已结清', tone: DsBadgeTone.muted), _closeAction(t, r), ]); }).toList(), ); } void _setFlowChip(String c) { setState(() => _flowChip = c); ref.read(financeListProvider.notifier).setType(_chipToType[c] ?? ''); } Widget _closeAction(dynamic t, FinanceRecord r) { final closable = r.status == 'open' && (r.type == 'receivable' || r.type == 'payable'); if (!closable) return Text('—', style: TextStyle(color: t.faint)); return WriteGuard( child: InkWell( onTap: () => _confirmClose(r), child: Text('结清', style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.primary)), ), ); } Future _confirmClose(FinanceRecord r) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('确认结清'), content: Text('确认将「${r.partnerName ?? ''} ${r.typeLabel} ' '${_yuan(r.amount.abs())}」标记为已结清?'), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), DsButton('结清', variant: DsBtnVariant.primary, onPressed: () => Navigator.of(ctx).pop(true)), ], ), ); if (ok != true || !mounted) return; try { await ref.read(financeRepositoryProvider).close(r.id); ref.read(financeListProvider.notifier).reload(); ref.invalidate(financePartnerRowsProvider); if (mounted) { showDsToast(context, '已结清 ✓', bg: context.tokens.success); } } catch (e) { if (mounted) { showDsToast(context, '操作失败:$e', bg: context.tokens.danger); } } } Widget _flowCard(dynamic t, FinanceRecord r) { final isIn = r.type == 'receipt' || r.type == 'receivable'; return MobileListCard( title: Text( (r.partnerName?.isNotEmpty == true) ? r.partnerName! : r.typeLabel), subtitle: Text((r.recordDate ?? '').split('T').first), trailing: financeTypeBadge(r), fields: [ MobileCardField('金额', '${isIn ? '+' : '-'}${_yuan(r.amount.abs())}'), MobileCardField('状态', r.status == 'open' ? '未结清' : '已结清'), if (r.remark?.isNotEmpty == true) MobileCardField('备注', r.remark), ], actions: (r.status == 'open' && (r.type == 'receivable' || r.type == 'payable')) ? [ WriteGuard( child: TextButton( onPressed: () => _confirmClose(r), child: const Text('结清', style: TextStyle(fontSize: 13)), ), ), ] : null, ); } }