Files
jiu/client/lib/widgets/finance_partner_drawer.dart
T
wangjia cd477ce81a fix(client): 抽屉内操作不再收抽屉(原地刷新)+ 日期输入框垂直居中
- 出入库详情抽屉:通过/拒绝/撤回/提交/结清/退单/确认定价/打印 全部不收抽屉,
  操作完成后原地重拉单据刷新内容(StatefulBuilder+refresh);仅 遮罩/X/
  修改·删除(离开语义)关闭——对齐拍板「点抽屉外才收回」
- 往来抽屉「编辑」、财务往来抽屉「登记收款/付款」(完成后流水原地刷新)、
  商品编辑抽屉「保存」同规则;「取消」保持关闭语义
- 日期组件输入框 forceStrutHeight 锁行盒:修等宽字体 ascent/descent 不对称
  导致的文字垂直偏移

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

255 lines
9.2 KiB
Dart

// widgets/finance_partner_drawer.dart — 财务屏往来流水抽屉(镜像原型 finance.html
// openPartner):应收/应付/净额 3 行 + 近期流水(类型徽章 + ±金额)+ 登记收款/付款。
import 'dart:math' as math;
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_chrome.g.dart';
import '../core/theme/app_dims.g.dart';
import '../core/theme/context_tokens.dart';
import '../models/finance.dart';
import '../providers/finance_provider.dart';
import 'ds/ds_atoms.dart';
import 'ds/m_sheet.dart';
import 'finance_entry_dialog.dart';
import 'write_guard.dart';
import '../core/theme/app_fonts.dart';
/// 该往来单位近期财务流水(抽屉内展示,最多 8 条)。
final _partnerFlowsProvider =
FutureProvider.family<List<FinanceRecord>, int>((ref, partnerId) async {
final page = await ref
.read(financeRepositoryProvider)
.listRecords(partnerId: partnerId, pageSize: 8);
return page.data;
});
String _yuan(double v) => '¥${NumberFormat.decimalPattern().format(
v == v.roundToDouble() ? v.round() : v,
)}';
/// 净额带符号(原型 net-pos 前缀 +、负数自带 -)。
String signedYuan(double net) {
if (net > 0) return '+${_yuan(net)}';
if (net < 0) return '-${_yuan(net.abs())}';
return _yuan(0);
}
/// 财务流水类型徽章(b-收款=ok / b-付款=danger / 应收=warn / 应付=info)。
DsBadge financeTypeBadge(FinanceRecord r) => DsBadge(r.typeLabel,
tone: switch (r.type) {
'receipt' => DsBadgeTone.ok,
'payment' => DsBadgeTone.danger,
'receivable' => DsBadgeTone.warn,
_ => DsBadgeTone.info,
});
Future<void> showFinancePartnerDrawer(
BuildContext context, {
required PartnerFinanceRow row,
}) {
// 窄屏:底部 sheet 形态(对齐原型移动 sheet 交互范式)
if (context.isMobile) {
return showMSheet<void>(
context,
title: null,
scrollable: false,
builder: (ctx) => SizedBox(
height: MediaQuery.of(ctx).size.height * 0.86,
child: _FinancePartnerDrawer(row: row),
),
);
}
return showGeneralDialog<void>(
context: context,
useRootNavigator: false,
barrierDismissible: true,
barrierLabel: '关闭',
barrierColor: AppChrome.scrim,
transitionDuration: const Duration(milliseconds: 250),
pageBuilder: (ctx, _, __) {
final w = math.min(480.0, MediaQuery.of(ctx).size.width * 0.94);
return Align(
alignment: Alignment.centerRight,
child: Material(
color: ctx.tokens.surface,
elevation: 16,
child: SelectionArea(
child: SizedBox(
width: w,
height: double.infinity,
child: _FinancePartnerDrawer(row: row),
),
),
),
);
},
transitionBuilder: (ctx, anim, _, child) => SlideTransition(
position: Tween<Offset>(begin: const Offset(1, 0), end: Offset.zero)
.animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
child: child,
),
);
}
class _FinancePartnerDrawer extends ConsumerWidget {
final PartnerFinanceRow row;
const _FinancePartnerDrawer({required this.row});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = context.tokens;
final flows = row.partnerId != null
? ref.watch(_partnerFlowsProvider(row.partnerId!))
: const AsyncValue<List<FinanceRecord>>.data([]);
final net = row.net;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// .drawer-head
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.border))),
child: Row(children: [
Expanded(
child: Text(row.name,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading)),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(LucideIcons.x, size: 18, color: t.muted),
tooltip: '关闭',
),
]),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_drow(t, '应收账款', _yuan(row.recv)),
_drow(t, '应付账款', _yuan(row.pay)),
_drow(t, '净额', signedYuan(net),
color: net > 0 ? t.success : (net < 0 ? t.danger : null)),
Padding(
padding: const EdgeInsets.only(top: 18, bottom: 8),
child: Text('近期流水',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.muted)),
),
flows.when(
loading: () => const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))),
),
error: (e, _) => Text('流水加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
data: (list) => list.isEmpty
? Text('本期暂无流水记录',
style: TextStyle(
color: t.faint, fontSize: AppDims.fsBody))
: Column(children: [
for (final r in list) _flowRow(t, r),
]),
),
],
),
),
),
// 底部:登记收款 / 登记付款
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration:
BoxDecoration(border: Border(top: BorderSide(color: t.border))),
child: WriteGuard(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 抽屉内按钮不收抽屉(2026-07-04 拍板):登记弹窗叠在抽屉之上,
// 保存后失效流水 provider → 抽屉内近期流水原地刷新
DsButton('登记收款', small: true, onPressed: () async {
await showFinanceEntryDialog(context,
type: 'receipt', partnerId: row.partnerId);
if (row.partnerId != null) {
ref.invalidate(_partnerFlowsProvider(row.partnerId!));
}
}),
const SizedBox(width: 10),
DsButton('登记付款', small: true, onPressed: () async {
await showFinanceEntryDialog(context,
type: 'payment', partnerId: row.partnerId);
if (row.partnerId != null) {
ref.invalidate(_partnerFlowsProvider(row.partnerId!));
}
}),
],
),
),
),
],
);
}
Widget _drow(dynamic t, String label, String value, {Color? color}) =>
Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
const Spacer(),
Text(value,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: color ?? t.text)),
]),
);
Widget _flowRow(dynamic t, FinanceRecord r) {
final isIn = r.type == 'receipt' || r.type == 'receivable';
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(children: [
Text((r.recordDate ?? '').split('T').first,
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.muted,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
const SizedBox(width: 8),
financeTypeBadge(r),
const Spacer(),
Text('${isIn ? '+' : '-'}${_yuan(r.amount.abs())}',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: t.heading)),
]),
);
}
}