cd477ce81a
- 出入库详情抽屉:通过/拒绝/撤回/提交/结清/退单/确认定价/打印 全部不收抽屉, 操作完成后原地重拉单据刷新内容(StatefulBuilder+refresh);仅 遮罩/X/ 修改·删除(离开语义)关闭——对齐拍板「点抽屉外才收回」 - 往来抽屉「编辑」、财务往来抽屉「登记收款/付款」(完成后流水原地刷新)、 商品编辑抽屉「保存」同规则;「取消」保持关闭语义 - 日期组件输入框 forceStrutHeight 锁行盒:修等宽字体 ascent/descent 不对称 导致的文字垂直偏移 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
314 lines
12 KiB
Dart
314 lines
12 KiB
Dart
// widgets/partner_detail_drawer.dart — 往来单位详情抽屉(镜像原型 partners.html openDetail)。
|
||
// 结构照 atoms .drawer/.dsec/.drow/.dli:联系信息 / 应收应付(应收染 success、应付染 accent)
|
||
// / 近期单据(财务流水,已覆盖入库应付、出库应收与收付款)/ 底部 对账单+编辑。
|
||
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 '../models/partner.dart';
|
||
import '../providers/partner_provider.dart';
|
||
import 'ds/ds_atoms.dart';
|
||
import 'ds/m_sheet.dart';
|
||
import 'write_guard.dart';
|
||
import '../core/theme/app_fonts.dart';
|
||
import 'ds/ds_toast.dart';
|
||
|
||
/// 打开往来单位详情抽屉。应收/应付由列表页的 partnerBalancesProvider 传入;
|
||
/// 近期单据抽屉内经 partnerRecentDocsProvider 拉取。
|
||
Future<void> showPartnerDetailDrawer(
|
||
BuildContext context, {
|
||
required Partner partner,
|
||
({double recv, double pay})? balance,
|
||
VoidCallback? onEdit,
|
||
}) {
|
||
// 窄屏:底部 sheet 形态(原型 m-partners openP 详情 sheet)
|
||
if (context.isMobile) {
|
||
return showMSheet<void>(
|
||
context,
|
||
title: null,
|
||
scrollable: false,
|
||
builder: (ctx) => SizedBox(
|
||
height: MediaQuery.of(ctx).size.height * 0.86,
|
||
child: _PartnerDetailDrawer(
|
||
partner: partner, balance: balance, onEdit: onEdit),
|
||
),
|
||
);
|
||
}
|
||
return showGeneralDialog<void>(
|
||
context: context,
|
||
useRootNavigator: false,
|
||
barrierDismissible: true,
|
||
barrierLabel: '关闭',
|
||
barrierColor: AppChrome.scrim, // .drawer-mask --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: _PartnerDetailDrawer(
|
||
partner: partner, balance: balance, onEdit: onEdit),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
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,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 金额:原型预格式化字符串风格 ¥x,xxx(整数无小数,非整保留两位)。
|
||
String fmtYuan(double v) {
|
||
final money = v == v.roundToDouble()
|
||
? NumberFormat.decimalPattern().format(v.round())
|
||
: NumberFormat('#,##0.00').format(v);
|
||
return '¥$money';
|
||
}
|
||
|
||
class _PartnerDetailDrawer extends ConsumerWidget {
|
||
final Partner partner;
|
||
final ({double recv, double pay})? balance;
|
||
final VoidCallback? onEdit;
|
||
const _PartnerDetailDrawer(
|
||
{required this.partner, this.balance, this.onEdit});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final t = context.tokens;
|
||
final docs = ref.watch(partnerRecentDocsProvider(partner.id));
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// ── .drawer-head:名称 + X ──
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: t.border))),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(partner.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: '关闭',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// ── .drawer-body ──
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_dsec(t, '联系信息', first: true),
|
||
_drow(t, '类型', badge: partnerTypeBadge(partner)),
|
||
_drow(t, '联系人',
|
||
text: (partner.contact?.isNotEmpty == true)
|
||
? partner.contact!
|
||
: '—'),
|
||
_drow(t, '电话',
|
||
text: (partner.phone?.isNotEmpty == true)
|
||
? partner.phone!
|
||
: '—',
|
||
mono: true),
|
||
_drow(t, '地址',
|
||
text: (partner.address?.isNotEmpty == true)
|
||
? partner.address!
|
||
: '—',
|
||
wrap: true),
|
||
_dsec(t, '应收 / 应付'),
|
||
_drow(t, '应收账款',
|
||
text: fmtYuan(balance?.recv ?? 0),
|
||
mono: true,
|
||
color: t.success),
|
||
_drow(t, '应付账款',
|
||
text: fmtYuan(balance?.pay ?? 0),
|
||
mono: true,
|
||
color: t.accent),
|
||
_dsec(t, '近期单据'),
|
||
docs.when(
|
||
loading: () => const Padding(
|
||
padding: EdgeInsets.all(20),
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2))),
|
||
),
|
||
error: (e, _) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
child: Text('单据加载失败',
|
||
style:
|
||
TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
|
||
),
|
||
data: (rows) => rows.isEmpty
|
||
? Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
child: Text('暂无往来单据',
|
||
style: TextStyle(
|
||
color: t.muted, fontSize: AppDims.fsSm)),
|
||
)
|
||
: Column(children: [for (final r in rows) _dli(t, r)]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
// ── 底部操作(原型:对账单 / 编辑)──
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||
decoration:
|
||
BoxDecoration(border: Border(top: BorderSide(color: t.border))),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
DsButton('对账单', small: true, onPressed: () {
|
||
showDsToast(context, '对账单功能即将上线');
|
||
}),
|
||
const SizedBox(width: 10),
|
||
if (onEdit != null)
|
||
WriteGuard(
|
||
// 抽屉内按钮不收抽屉(2026-07-04 拍板):编辑弹窗叠在抽屉之上
|
||
child: DsButton('编辑', small: true, onPressed: onEdit),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 原型 .dsec:区块小标题(fs-xs muted 600,上距 18)。
|
||
Widget _dsec(dynamic t, String label, {bool first = false}) => Padding(
|
||
padding: EdgeInsets.only(top: first ? 0 : 18, bottom: 4),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(label,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
color: t.muted,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: .4)),
|
||
),
|
||
);
|
||
|
||
/// 原型 .drow:label(muted) + b(text 600),pad 11 0,下边 border-subtle。
|
||
Widget _drow(dynamic t, String label,
|
||
{String? text,
|
||
Widget? badge,
|
||
bool mono = false,
|
||
bool wrap = false,
|
||
Color? color}) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: t.borderSubtle))),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label,
|
||
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||
const Spacer(),
|
||
if (badge != null)
|
||
badge
|
||
else
|
||
ConstrainedBox(
|
||
// 原型地址行 max-width:200 右对齐显示完整地址
|
||
constraints: BoxConstraints(maxWidth: wrap ? 200 : 260),
|
||
child: Text(text ?? '—',
|
||
textAlign: TextAlign.right,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
fontWeight: FontWeight.w600,
|
||
color: color ?? t.text,
|
||
fontFamily: mono ? 'monospace' : null)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 原型 .dli:近期单据行(左 标题+small 日期,右 金额 mono 600)。
|
||
Widget _dli(dynamic t, FinanceRecord r) {
|
||
// 收款/付款是清账方向,按原型示例带负号
|
||
final out = r.type == 'receipt' || r.type == 'payment';
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||
decoration: BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: t.borderSubtle))),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(r.docTitle,
|
||
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 2),
|
||
child: Text(
|
||
[
|
||
if ((r.recordDate ?? '').isNotEmpty)
|
||
r.recordDate!.split('T').first,
|
||
if ((r.remark ?? '').isNotEmpty) r.remark!,
|
||
].join(' · '),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
color: t.faint,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Text('${out ? '-' : ''}${fmtYuan(r.amount)}',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsSm,
|
||
fontWeight: FontWeight.w600,
|
||
color: t.heading,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 类型徽章(原型 b-供应商=info / b-客户=accent / b-两者=warn),列表/卡片/抽屉共用。
|
||
DsBadge partnerTypeBadge(Partner p) => DsBadge(p.typeLabel,
|
||
tone: switch (p.typeLabel) {
|
||
'两者' => DsBadgeTone.warn,
|
||
'客户' => DsBadgeTone.accent,
|
||
_ => DsBadgeTone.info,
|
||
});
|