// 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 '../../models/partner.dart'; import '../../providers/finance_provider.dart'; import '../../providers/partner_provider.dart' show allPartnersProvider; import '../../providers/stock_in_provider.dart'; import '../../providers/stock_out_provider.dart'; import '../../widgets/combo_search_field.dart'; import '../../widgets/searchable_option_field.dart' show OptionItem; 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/ds/m_kpi_grid.dart'; import '../../widgets/ds/m_search_row.dart'; import '../../widgets/ds/m_sheet.dart'; import '../../widgets/ds/status_icon_map.dart'; import '../../widgets/finance_entry_dialog.dart'; import '../../widgets/finance_partner_drawer.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/wheel_date_picker.dart' show showDateRangeDropdown; 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 = '全部'; // 往来单位筛选(桌面 toolbar ComboSearchField / 移动端时间行尾 chip→sheet 共用) int? _partnerId; // 自定义时间区间(记住上次选择,重开 showDateRangeDropdown 时回显) DateTimeRange? _customRange; // 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水) int _mSeg = 0; // 应收/应付汇总卡「往来单位」搜索(本地即时过滤——financePartnerRowsProvider // 全量加载非分页,搜索口径=显示口径;移动 应收·应付 分段头 MSearchRow 沿用) final _summaryKwCtrl = TextEditingController(); String _summaryKw = ''; // 桌面汇总表表头「往来单位」下拉筛选(验收反馈改版:由卡标题行文字搜索 // 改为表头内嵌 ComboSearchField,同 _flowTable 往来单位筛选控件;单选精确 // 匹配一行,× 清除恢复全量;候选=全量往来单位(allPartnersProvider,与 // _flowTable toolbar 同源),选中一个汇总里没有行的单位时显示空态)。 int? _summaryPartnerId; 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('本月', context); }); } /// [anchorContext] 用于「自定义」分支锚定 showDateRangeDropdown(照 stock_in /// _dateMenu 写法:传入触发该 chip 的 Builder context,桌面下拉/窄屏 sheet 由 /// 组件内部自适应);本月/本季分支不弹层,传外层 context 即可。 Future _applyRange(String range, BuildContext anchorContext) 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 showDateRangeDropdown( anchorContext, initial: _customRange ?? 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; _customRange = picked; _rangeLabel = '${_ymd.format(picked.start)} ~ ${_ymd.format(picked.end)}'; }); } } void _setPartner(int? partnerId) { setState(() => _partnerId = partnerId); ref.read(financeListProvider.notifier).setPartner(partnerId); } void _setSummaryKw(String kw) => setState(() => _summaryKw = kw); /// 汇总行本地过滤(往来单位名 contains,大小写不敏感;移动端专用)。 List _filterSummaryRows(List rows) { final kw = _summaryKw.trim().toLowerCase(); if (kw.isEmpty) return rows; return rows.where((r) => r.name.toLowerCase().contains(kw)).toList(); } void _setSummaryPartner(int? id) => setState(() => _summaryPartnerId = id); /// 桌面汇总表头下拉筛选:选中 → 精确匹配单行;未选 → 原样返回(专用)。 /// [id] 优先取 partnerId,缺失(自由文本往来单位)时用行下标取负数兜底, /// 与 header 里构建 ComboSearchField options 的合成 id 规则保持一致。 List _filterSummaryByPartner( List rows, ) { if (_summaryPartnerId == null) return rows; for (var i = 0; i < rows.length; i++) { if ((rows[i].partnerId ?? -(i + 1)) == _summaryPartnerId) { return [rows[i]]; } } return const []; } @override void dispose() { _summaryKwCtrl.dispose(); super.dispose(); } 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 []; // 窄屏(原型 m-finance):隐藏页内大标题头,KPI 2×2 网格 + 三分段 + 卡片流。 if (mobile) { final content = Container( color: t.bg, child: SingleChildScrollView( padding: const EdgeInsets.all(AppDims.sp4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _timeRow(t, true), _mKpis(t), const SizedBox(height: 14), Align( alignment: Alignment.centerLeft, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: DsSeg( items: const ['应收', '应付', '流水'], index: _mSeg, onChanged: (i) => setState(() => _mSeg = i), ), ), ), // 应收/应付分段头部往来单位搜索(同口径本地过滤);「流水」分段 // 沿用上方时间行「往来单位」chip → sheet 单选,不重复放置。 if (_mSeg != 2) ...[ const SizedBox(height: 12), MSearchRow( controller: _summaryKwCtrl, hint: '搜索往来单位', onChanged: _setSummaryKw, ), ], ..._mSegBody(t, flows), ], ), ), ); return Stack( children: [ content, if (flowsAsync.isLoading) const Positioned.fill(child: DsLoadingScrim()), ], ); } 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) { // 移动端时间行尾追加「往来单位」chip(D6:showMSheet 可搜单选,× 清除); // 桌面往来单位筛选走 _flowTable toolbar 的 ComboSearchField(不在此行)。 final partnerName = mobile ? _partnerNameById( ref.watch(allPartnersProvider).valueOrNull?.data ?? const [], _partnerId, ) : null; final 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: false, onTap: () => _applyRange(r, context), ), const SizedBox(width: 10), ], Builder( // anchorCtx = chip 自身位置:自定义范围下拉/sheet 锚定在 chip 下方 // (照 stock_in_list_screen.dart _dateMenu 写法) builder: (anchorCtx) => DsChip( label: '自定义', selected: _range == '自定义', onTap: () => _applyRange('自定义', anchorCtx), ), ), if (mobile) ...[ const SizedBox(width: 10), DsChip( label: '往来单位', value: partnerName, onTap: _openPartnerSheet, onClear: () => _setPartner(null), ), ], ]; if (mobile) { children.addAll([ const SizedBox(width: 10), WriteGuard( child: DsButton( '登记', small: true, icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: () => showFinanceEntryDialog(context), ), ), ]); // 窄屏内容(label+4 chip+登记)常超宽:整行横向可滚(照 _flowTable 移动 // 工具栏 chips 横向可滚同款写法),而非把登记钉死右侧裁掉往来单位 chip。 return Padding( padding: const EdgeInsets.only(bottom: 18), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(children: children), ), ); } return Padding( padding: const EdgeInsets.only(bottom: 18), child: Row(children: children), ); } String? _partnerNameById(List partners, int? id) { if (id == null) return null; for (final p in partners) { if (p.id == id) return p.name; } return null; } /// 移动端「往来单位」chip → 底部 sheet 可搜索单选(D6,对齐桌面 ComboSearchField)。 Future _openPartnerSheet() async { final partners = ref.read(allPartnersProvider).valueOrNull?.data ?? const []; final options = [ for (final p in partners) OptionItem(id: p.id, name: p.name, code: p.code), ]; final sel = await showMSheet( context, title: '往来单位', builder: (_) => _PartnerSheetBody(options: options, selectedId: _partnerId), ); if (sel == null || !mounted) return; // -1 = 清除选择(照 SearchableOptionField 的既有约定) _setPartner(sel == -1 ? null : sel); } // ── 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。 /// [trailing] 靠右附加控件(当前各段落调用均未传入,保留供后续段落复用)。 Widget _secHead(dynamic t, String title, String sub, {Widget? trailing}) => 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), ), ), if (trailing != null) ...[const Spacer(), trailing], ], ), ); // ── 收支趋势 ── 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: (allRows) { final rows = _filterSummaryByPartner(allRows); // 下拉候选=全量往来单位(验收反馈:与 _flowTable toolbar 往来单位筛选 // 同源 allPartnersProvider,而非只列汇总行里出现的单位);选中一个汇总 // 无余额的单位时,_filterSummaryByPartner 匹配不到行会返回空列表, // 走既有「没有匹配的往来单位」空态。 final partnerOptions = (ref.watch(allPartnersProvider).valueOrNull?.data ?? const []) .map((p) => OptionItem(id: p.id, name: p.name, code: p.code)) .toList(); return DsTable( shrinkWrap: true, emptyText: _summaryPartnerId == null ? '暂无未结清的应收 / 应付' : '没有匹配的往来单位', 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: [ DsColumn( 'name', '往来单位', // 表头「往来单位」文字右边内嵌下拉(验收反馈:由卡标题行搜索框 // 改到此处,与 _flowTable 往来单位筛选同款 ComboSearchField)。 // 外包 SizedBox 固定高=ComboSearchField 自身高度:Row 的 // crossAxisAlignment.center 依赖两个子项各自的内在高度居中, // 标签文字行高在不同字体下可能与 34 有偏差,导致下拉在表头行 // 里视觉偏上(验收反馈);显式钉住 Row 高度消除该偏差,不改 // 表头行 pad 11/14 的既有行高口径。 header: SizedBox( height: 34, child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '往来单位', style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.muted, ), ), const SizedBox(width: 10), ComboSearchField( options: partnerOptions, selectedId: _summaryPartnerId, hint: '往来单位', width: 180, onChanged: _setSummaryPartner, ), ], ), ), ), const DsColumn('recv', '应收', numeric: true), const DsColumn('pay', '应付', numeric: true), const DsColumn('net', '净额', numeric: true), const 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 SizedBox(width: 10), // 往来单位:可搜索单选下拉(→ financeListProvider.setPartner;照 // stock_out_list_screen.dart 客户 ComboSearchField 写法)。仅桌面 // 工具栏放置,移动端对应筛选在 _timeRow 尾部的「往来单位」chip → sheet。 ComboSearchField( options: (ref.watch(allPartnersProvider).valueOrNull?.data ?? const []) .map( (p) => OptionItem(id: p.id, name: p.name, code: p.code), ) .toList(), selectedId: _partnerId, hint: '往来单位', width: 180, onChanged: _setPartner, ), 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, // 下拉刷新 = 桌面「刷新」(_reload 连带 KPI/汇总/趋势) onRefresh: () async { _reload(); await Future.delayed(const Duration(milliseconds: 600)); }, 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, ); } // ── 窄屏形态(原型 m-finance)────────────────────────────────────── /// delta 箭头/色调映射(与 DsKpi 同规则:▲/▼ 文本字符)。 String _mDeltaText(String text, DsKpiDelta tone) => switch (tone) { DsKpiDelta.up => '▲ $text', DsKpiDelta.down => '▼ $text', DsKpiDelta.neutral => text, }; MKpiDeltaTone _mTone(DsKpiDelta tone) => switch (tone) { DsKpiDelta.up => MKpiDeltaTone.up, DsKpiDelta.down => MKpiDeltaTone.down, DsKpiDelta.neutral => MKpiDeltaTone.normal, }; /// 窄屏 KPI 2×2(原型 .m-kpi:本月收入 / 本月支出 / 应收合计 / 应付合计)。 Widget _mKpis(dynamic t) { 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); return MKpiGrid( items: [ MKpiItem( label: '本月收入', value: outSum != null ? _yuanWan(outSum.monthAmount) : '—', delta: _mDeltaText(saleDelta, saleTone), deltaTone: _mTone(saleTone), ), MKpiItem( label: '本月支出', value: inSum != null ? _yuanWan(inSum.monthAmount) : '—', delta: _mDeltaText(buyDelta, buyTone), deltaTone: _mTone(buyTone), ), // 原型 delta「12 笔未结」:应收/应付分侧笔数无数据源 → 用未结清总笔数(已知差异) MKpiItem( label: '应收合计', value: _yuanWan(recv), delta: '未结清 $openCount 笔', deltaTone: MKpiDeltaTone.warn, ), MKpiItem( label: '应付合计', value: _yuanWan(pay), delta: '按到期及时结清', deltaTone: MKpiDeltaTone.normal, ), ], ); } /// 三分段列表体:m-section 计数标题 + 卡片流(应收/应付=往来汇总,流水=收支记录)。 List _mSegBody(dynamic t, List flows) { Widget section(String title, int count) => Padding( padding: const EdgeInsets.fromLTRB(2, 16, 2, 8), child: Text( '$title · 共 $count', style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w700, letterSpacing: .4, color: t.muted, ), ), ); Widget empty([String msg = '暂无数据']) => Padding( padding: const EdgeInsets.all(24), child: Center( child: Text( msg, style: TextStyle(color: t.muted, fontSize: AppDims.fsSm), ), ), ); List stacked(List cards) => [ for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(height: 10), cards[i], ], ]; if (_mSeg == 2) { return [ section('资金流水', flows.length), if (flows.isEmpty) empty() else ...stacked([for (final r in flows) _mFlowCard(t, r)]), ]; } final async = ref.watch(financePartnerRowsProvider); return async.when( loading: () => [ const Padding( padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()), ), ], error: (e, _) => [ Padding( padding: const EdgeInsets.all(24), child: Center( child: Text( '汇总加载失败', style: TextStyle(color: t.muted, fontSize: AppDims.fsSm), ), ), ), ], data: (rows) { final ar = _mSeg == 0; final list = _filterSummaryRows( rows.where((r) => ar ? r.recv > 0 : r.pay > 0).toList(), ); return [ section(ar ? '应收账款' : '应付账款', list.length), if (list.isEmpty) empty(_summaryKw.trim().isEmpty ? '暂无数据' : '没有匹配的往来单位') else ...stacked([for (final r in list) _mPartnerCard(t, r, ar: ar)]), ]; }, ); } /// 应收/应付卡(原型 .m-card:往来单位 + 金额 + 徽章带图标)。 Widget _mPartnerCard(dynamic t, PartnerFinanceRow r, {required bool ar}) { return MobileListCard( onTap: () => showFinancePartnerDrawer(context, row: r), title: Text(r.name), subtitle: Text('未结清 ${r.openCount} 笔'), trailing: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ DsBadge('未结清', tone: DsBadgeTone.warn, icon: statusIcon('未结清')), const SizedBox(height: 6), Text( _yuan(ar ? r.recv : r.pay), style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w600, fontSize: 13, color: t.heading, ), ), ], ), ); } /// 流水卡(原型 .m-card:类型徽章带图标 收款/付款 + 金额正负色)。 Widget _mFlowCard(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: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ DsBadge( r.typeLabel, tone: switch (r.type) { 'receipt' => DsBadgeTone.ok, 'payment' => DsBadgeTone.danger, 'receivable' => DsBadgeTone.warn, _ => DsBadgeTone.info, }, icon: statusIcon(r.typeLabel), ), const SizedBox(height: 6), Text( '${isIn ? '+' : '-'}${_yuan(r.amount.abs())}', style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w600, fontSize: 13, color: isIn ? t.success : t.danger, ), ), ], ), ); } } /// 移动端「往来单位」sheet 内容:搜索框 + 全部/逐项列表 + 选中勾(照 /// combo_search_field.dart _ComboPop 的搜索+勾选样式,装进 showMSheet 底部 /// 容器而非锚定弹层)。选中返回 id;点「全部往来单位」返回 -1(清除,约定 /// 同 SearchableOptionField)。 class _PartnerSheetBody extends StatefulWidget { final List options; final int? selectedId; const _PartnerSheetBody({required this.options, required this.selectedId}); @override State<_PartnerSheetBody> createState() => _PartnerSheetBodyState(); } class _PartnerSheetBodyState extends State<_PartnerSheetBody> { String _kw = ''; @override Widget build(BuildContext context) { final t = context.tokens; final hits = _kw.trim().isEmpty ? widget.options : widget.options.where((o) => o.matches(_kw)).toList(); Widget optRow({ required String name, required bool selected, required VoidCallback onTap, }) => InkWell( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 4), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.borderSubtle)), ), child: Row( children: [ Expanded( child: Text( name, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? t.primary : t.text, ), ), ), if (selected) ...[ const SizedBox(width: 8), Icon(LucideIcons.check, size: 18, color: t.primary), ], ], ), ), ); return Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), decoration: BoxDecoration( color: t.bg, borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Row( children: [ Icon(LucideIcons.search, size: 15, color: t.faint), const SizedBox(width: 8), Expanded( child: TextField( style: TextStyle(fontSize: AppDims.fsBody, color: t.text), decoration: InputDecoration( isCollapsed: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, hintText: '搜索往来单位…', hintStyle: TextStyle( fontSize: AppDims.fsBody, color: t.faint, ), ), onChanged: (v) => setState(() => _kw = v), ), ), ], ), ), const SizedBox(height: 6), optRow( name: '全部往来单位', selected: widget.selectedId == null, onTap: () => Navigator.of(context).pop(-1), ), if (hits.isEmpty) Padding( padding: const EdgeInsets.all(20), child: Text( '无匹配结果', style: TextStyle(color: t.muted, fontSize: AppDims.fsSm), ), ) else for (final o in hits) optRow( name: o.name, selected: o.id == widget.selectedId, onTap: () => Navigator.of(context).pop(o.id), ), ], ); } }