6238b86dcb
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服 pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验; 登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段, 已记 CONTRACT,screens.mjs 留存根) - ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/ DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态 - 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、 h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、 BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast) - 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后 失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览 - 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/ SelectProductDialog/tabStateProvider - golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打; 修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
649 lines
23 KiB
Dart
649 lines
23 KiB
Dart
// 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<FinanceScreen> createState() => _FinanceScreenState();
|
||
}
|
||
|
||
class _FinanceScreenState extends ConsumerState<FinanceScreen> {
|
||
// 时间范围(原型 .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<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>[];
|
||
|
||
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-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<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,
|
||
);
|
||
}
|
||
}
|