Files
jiu/client/lib/screens/finance/finance_screen.dart
T
wangjia dde87a32b6 feat(client): 库存/盘点/财务窄屏对齐移动原型(KPI 网格/卡片流/图标徽章/sheet)
- 库存列表:窄屏隐藏页内大标题头;KPI 2×2 MKpiGrid(缺货卡 warn+点击筛选);
  MSearchRow(详搜钮开筛选 sheet:状态/规格/系列);卡片徽章换图标变体
- 库存盘点:窄屏卡片流(单号/范围·项数/进行中徽章带图标/差异/日期)+
  详情 sheet(drow 键值行)+ 底部操作条;DateTime.now→appNow 可注入时钟
- 财务:窄屏隐藏大标题头;KPI 2×2(收入 up 绿/支出 down 红/应收 warn);
  DsSeg 应收/应付/流水三分段 + 卡片流(类型徽章带图标、金额正负色)
- golden:新增 m_inventory / m_inventory_check / m_finance 三屏×三主题
  (390×844@2x);重生成既有 inventory_list_mobile / finance_mobile 基准
  (桌面 golden 零改动)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:18:17 +08:00

867 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/ds/m_kpi_grid.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/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<FinanceScreen> createState() => _FinanceScreenState();
}
class _FinanceScreenState extends ConsumerState<FinanceScreen> {
// 时间范围(原型 .timerow chips):真实过滤流水表;KPI 恒本月、趋势恒近 6 月
String _range = '本月';
String _rangeLabel = '本月';
// 流水类型筛选(原型 3 chips 扩到 5)
String _flowChip = '全部';
// 窄屏三分段(原型 m-finance .seg:应收 / 应付 / 流水)
int _mSeg = 0;
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<void> _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 <FinanceRecord>[];
// 窄屏(原型 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)),
),
),
..._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<FinanceRecord> 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<FinanceRecord> 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 = <Widget>[
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-headh2 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<FinanceRecord> 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<FinanceRecord> 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<void> _confirmClose(FinanceRecord r) async {
final ok = await showDialog<bool>(
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 <PartnerFinanceRow>[];
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<Widget> _mSegBody(dynamic t, List<FinanceRecord> 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() => Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text('暂无数据',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm))),
);
List<Widget> stacked(List<Widget> 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 = rows.where((r) => ar ? r.recv > 0 : r.pay > 0).toList();
return [
section(ar ? '应收账款' : '应付账款', list.length),
if (list.isEmpty)
empty()
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)),
],
),
);
}
}