Files
jiu/client/lib/screens/finance/finance_screen.dart
T
2026-07-14 12:35:11 +08:00

1305 lines
44 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 '../../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<FinanceScreen> createState() => _FinanceScreenState();
}
class _FinanceScreenState extends ConsumerState<FinanceScreen> {
// 时间范围(原型 .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 往来单位筛选控件;单选精确
// 匹配一行,× 清除恢复全量;候选=当前汇总行往来单位,口径=显示口径)。
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<void> _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<PartnerFinanceRow> _filterSummaryRows(List<PartnerFinanceRow> 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<PartnerFinanceRow> _filterSummaryByPartner(
List<PartnerFinanceRow> 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 <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),
),
),
),
// 应收/应付分段头部往来单位搜索(同口径本地过滤);「流水」分段
// 沿用上方时间行「往来单位」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<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) {
// 移动端时间行尾追加「往来单位」chip(D6:showMSheet 可搜单选,× 清除);
// 桌面往来单位筛选走 _flowTable toolbar 的 ComboSearchField(不在此行)。
final partnerName = mobile
? _partnerNameById(
ref.watch(allPartnersProvider).valueOrNull?.data ?? const [],
_partnerId,
)
: null;
final children = <Widget>[
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<Partner> 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<void> _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<int?>(
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 = <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。
/// [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);
// 下拉候选=当前汇总行往来单位(口径=显示口径,不用全量 partners,避免
// 选到无余额单位出空表);id 合成规则同 _filterSummaryByPartner。
final partnerOptions = <OptionItem>[
for (var i = 0; i < allRows.length; i++)
OptionItem(
id: allRows[i].partnerId ?? -(i + 1),
name: allRows[i].name,
),
];
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)。
header: Row(
mainAxisSize: MainAxisSize.min,
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<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 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<void>.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<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([String msg = '暂无数据']) => Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
msg,
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 = _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<OptionItem> 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),
),
],
);
}
}