// screens/partners/partners_screen.dart — 往来单位(照原型 partners.html 1:1 重建)。 // 版式:.head(标题+副标+刷新/新增) + .toolbar(搜索 260 + 类型 chips + 重置) // + .table 7 列(名称双行/类型徽章/联系人/电话/应收/应付/操作) + .pager 仅文案。 // 行点击 → 详情抽屉(partner_detail_drawer);新增/编辑 → 弹窗(原型 frow 布局)。 import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/config/app_constants.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/utils/dialog_util.dart'; import '../../core/utils/export_util.dart'; import '../../models/partner.dart'; import '../../providers/partner_provider.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_table.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/partner_detail_drawer.dart'; import '../../widgets/write_guard.dart'; import '../../core/theme/app_fonts.dart'; import '../../widgets/ds/ds_toast.dart'; class PartnersScreen extends ConsumerStatefulWidget { const PartnersScreen({super.key}); @override ConsumerState createState() => _PartnersScreenState(); } class _PartnersScreenState extends ConsumerState { final _searchCtrl = TextEditingController(); Timer? _debounce; // 类型 chips(原型 TYPES 筛选):全部 / 供应商 / 客户;「两者」在两侧均出现 String _typeChip = '全部'; static const _typeChips = ['全部', '供应商', '客户']; @override void dispose() { _debounce?.cancel(); _searchCtrl.dispose(); super.dispose(); } void _setType(String chip) { setState(() => _typeChip = chip); ref.read(partnersScreenProvider.notifier).setType(switch (chip) { '供应商' => 'supplier', '客户' => 'customer', _ => null, }); } void _clearFilters() { _searchCtrl.clear(); setState(() => _typeChip = '全部'); final n = ref.read(partnersScreenProvider.notifier); n.setKeyword(''); n.setType(null); } void _reload() { ref.read(partnersScreenProvider.notifier).reload(); ref.invalidate(partnerBalancesProvider); } @override Widget build(BuildContext context) { final t = context.tokens; final async = ref.watch(partnersScreenProvider); final balances = ref.watch(partnerBalancesProvider).valueOrNull ?? const {}; return async.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.cloudOff, size: 40, color: t.muted), const SizedBox(height: 12), Text('暂无数据,网络不可用', style: TextStyle(color: t.muted)), const SizedBox(height: 12), DsButton('重试', variant: DsBtnVariant.primary, onPressed: _reload), ], ), ), data: (result) { final partners = result.data; final mobile = context.isMobile; // 原型 .main{padding:22px 26px};窄屏保持紧凑 return Container( color: t.bg, padding: mobile ? EdgeInsets.zero : const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( children: [ // ── 头部(原型 .head{margin-bottom:18px})── Container( width: double.infinity, color: t.bg, padding: mobile ? const EdgeInsets.fromLTRB( AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2) : 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), Padding( padding: const EdgeInsets.only(bottom: 2), child: Text('供应商与客户档案 · 共 ${result.total} 个', style: TextStyle( fontSize: AppDims.fsSm, color: t.muted)), ), const Spacer(), if (!mobile) ...[ DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: _reload), const SizedBox(width: 10), WriteGuard( child: DsButton('新增往来单位', icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: () => _openForm()), ), ], ], ), ), Expanded( child: DsTable( pagerInfoText: '共 ${result.total} 个往来单位', emptyText: '没有匹配的往来单位 · 试试调整筛选或搜索', toolbar: _toolbar(mobile, partners), mobileCards: partners.map((p) => _card(p, balances)).toList(), columns: const [ DsColumn('name', '名称'), DsColumn('type', '类型'), DsColumn('contact', '联系人'), DsColumn('phone', '电话'), DsColumn('recv', '应收', numeric: true), DsColumn('pay', '应付', numeric: true), DsColumn('actions', '操作', action: true), ], rows: partners .map((p) => DsRow( onTap: () => _openDetail(p, balances), cells: _cells(p, balances), )) .toList(), ), ), ], ), ); }, ); } // ── toolbar(原型:searchbox 260 + chips + sp + 重置)── Widget _toolbar(bool mobile, List partners) { final search = DsSearchBox( controller: _searchCtrl, hint: '名称 / 拼音', width: mobile ? null : 260, onChanged: (v) { _debounce?.cancel(); _debounce = Timer(AppConstants.searchDebounce, () { ref.read(partnersScreenProvider.notifier).setKeyword(v.trim()); }); }, ); final chips = [ for (final c in _typeChips) ...[ DsChip( label: c, selected: _typeChip == c, caret: false, onTap: () => _setType(c)), const SizedBox(width: 10), ], ]; final reset = DsButton('重置', small: true, onPressed: _clearFilters); final export = DsButton('导出', small: true, icon: LucideIcons.download, onPressed: () { exportExcel( filename: '往来单位', headers: ['编码', '名称', '类型', '联系人', '电话', '地址', '备注'], rows: partners .map((p) => [ p.code ?? '', p.name, p.typeLabel, p.contact ?? '', p.phone ?? '', p.address ?? '', p.remark ?? '', ]) .toList(), ); }); if (mobile) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ search, const SizedBox(height: AppDims.sp2), Row(children: [ ...chips, const Spacer(), WriteGuard( child: DsButton('新增', small: true, icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: () => _openForm()), ), ]), ], ); } return Row(children: [ search, const SizedBox(width: 10), ...chips, const Spacer(), export, const SizedBox(width: 10), reset ]); } // ── 表格单元格(原型 7 列)── List _cells(Partner p, Map bal) { final t = context.tokens; final b = bal[p.id]; return [ // 名称双行:.pname + .pcode(地址前 12 字…) Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(p.name, style: TextStyle(fontWeight: FontWeight.w600, color: t.heading)), // 地址为空则不渲染副行(不占位、不显示「—」),行高回落单行 if (p.address?.isNotEmpty == true) Padding( padding: const EdgeInsets.only(top: 2), child: Text(_addrBrief(p.address!), style: TextStyle( fontSize: AppDims.fsXs, color: t.faint, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ), ], ), partnerTypeBadge(p), Text((p.contact?.isNotEmpty == true) ? p.contact! : '—'), Text((p.phone?.isNotEmpty == true) ? p.phone! : '—', style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), Text(fmtYuan(b?.recv ?? 0), style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), Text(fmtYuan(b?.pay ?? 0), style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), Row( mainAxisSize: MainAxisSize.min, children: [ _iconBtn(LucideIcons.eye, '详情', () => _openDetail(p, bal)), const SizedBox(width: 8), WriteGuard( child: _iconBtn(LucideIcons.pencil, '编辑', () => _openForm(p)), ), ], ), ]; } /// 原型 .pcode = addr.slice(0,12)…(地址前 12 字 + 省略号) String _addrBrief(String addr) => addr.length > 12 ? '${addr.substring(0, 12)}…' : addr; Widget _iconBtn(IconData icon, String tip, VoidCallback onTap) { final t = context.tokens; return Tooltip( message: tip, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppDims.rSm), child: Padding( padding: const EdgeInsets.all(2), child: Icon(icon, size: 16, color: t.muted), ), ), ); } // ── 窄屏卡片(m-partners:右侧类型徽章 + 应收/应付)── Widget _card(Partner p, Map bal) { final b = bal[p.id]; return MobileListCard( title: Text(p.name), subtitle: (p.code?.isNotEmpty == true) ? Text(p.code!) : null, trailing: partnerTypeBadge(p), fields: [ if (p.contact?.isNotEmpty == true) MobileCardField('联系人', p.contact), if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone), MobileCardField('应收', fmtYuan(b?.recv ?? 0)), MobileCardField('应付', fmtYuan(b?.pay ?? 0)), ], onTap: () => _openDetail(p, bal), ); } void _openDetail(Partner p, Map bal) { showPartnerDetailDrawer( context, partner: p, balance: bal[p.id], onEdit: WriteGuard.isReadonly(ref) ? null : () => _openForm(p), ); } void _openForm([Partner? partner]) { showAppDialog( context: context, builder: (ctx) => _PartnerFormDialog( partner: partner, onSaved: _reload, onDelete: partner == null ? null : () => _confirmDelete(partner), ), ); } Future _confirmDelete(Partner partner) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('确认删除'), content: Text('确认删除「${partner.name}」?此操作不可恢复。'), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), DsButton('删除', variant: DsBtnVariant.danger, onPressed: () => Navigator.of(ctx).pop(true)), ], ), ); if (confirmed == true && mounted) { try { await ref .read(partnersScreenProvider.notifier) .deletePartner(partner.id); if (mounted) { showDsToast(context, '删除成功', bg: context.tokens.success); } } catch (e) { if (mounted) { showDsToast(context, '删除失败:$e', bg: context.tokens.danger); } } } } } // ── 新增/编辑弹窗(原型 openAdd 的 frow 布局:名称*+类型 / 联系人+电话 / 地址 / // 期初余额 + 备注(备注为既有能力保留))── class _PartnerFormDialog extends ConsumerStatefulWidget { final Partner? partner; final VoidCallback onSaved; final VoidCallback? onDelete; const _PartnerFormDialog( {this.partner, required this.onSaved, this.onDelete}); @override ConsumerState<_PartnerFormDialog> createState() => _PartnerFormDialogState(); } class _PartnerFormDialogState extends ConsumerState<_PartnerFormDialog> { final _formKey = GlobalKey(); late final TextEditingController _nameCtrl; late final TextEditingController _contactCtrl; late final TextEditingController _phoneCtrl; late final TextEditingController _addressCtrl; late final TextEditingController _balanceCtrl; late final TextEditingController _remarkCtrl; late String _type; // supplier / customer / supplier,customer bool _saving = false; static const _typeOptions = { 'supplier': '供应商', 'customer': '客户', 'supplier,customer': '两者', }; @override void initState() { super.initState(); final p = widget.partner; _nameCtrl = TextEditingController(text: p?.name ?? ''); _contactCtrl = TextEditingController(text: p?.contact ?? ''); _phoneCtrl = TextEditingController(text: p?.phone ?? ''); _addressCtrl = TextEditingController(text: p?.address ?? ''); _balanceCtrl = TextEditingController( text: (p?.balance ?? 0) == 0 ? '' : '${p!.balance}'); _remarkCtrl = TextEditingController(text: p?.remark ?? ''); // SET 值可能乱序(customer,supplier);归一化到选项 key final raw = p?.type ?? 'supplier'; final isS = raw.contains('supplier'), isC = raw.contains('customer'); _type = isS && isC ? 'supplier,customer' : (isC ? 'customer' : 'supplier'); } @override void dispose() { _nameCtrl.dispose(); _contactCtrl.dispose(); _phoneCtrl.dispose(); _addressCtrl.dispose(); _balanceCtrl.dispose(); _remarkCtrl.dispose(); super.dispose(); } Future _save() async { if (!_formKey.currentState!.validate()) return; setState(() => _saving = true); final data = { 'name': _nameCtrl.text.trim(), 'type': _type, 'contact': _contactCtrl.text.trim(), 'phone': _phoneCtrl.text.trim(), 'address': _addressCtrl.text.trim(), 'balance': double.tryParse(_balanceCtrl.text.trim()) ?? 0, 'remark': _remarkCtrl.text.trim(), if (widget.partner?.code != null) 'code': widget.partner!.code, }; try { final notifier = ref.read(partnersScreenProvider.notifier); if (widget.partner != null) { await notifier.updatePartner(widget.partner!.id, data); } else { await notifier.createPartner(data); } if (mounted) { Navigator.of(context).pop(); widget.onSaved(); showDsToast(context, widget.partner != null ? '更新成功' : '创建成功', bg: context.tokens.success); } } catch (e) { if (mounted) { showDsToast(context, '保存失败:$e', bg: context.tokens.danger); } } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { final isEdit = widget.partner != null; return Dialog( child: Container( width: context.dialogWidth(560), padding: const EdgeInsets.all(24), child: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(isEdit ? '编辑往来单位' : '新增往来单位', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 20), // frow1:名称* + 类型* Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: DsField('名称', required: true, input: TextFormField( controller: _nameCtrl, decoration: const InputDecoration(hintText: '如:茅台华东总代'), validator: (v) => (v == null || v.trim().isEmpty) ? '不能为空' : null, )), ), const SizedBox(width: 14), Expanded( child: DsField('类型', required: true, input: DsSelect( value: _type, options: [ for (final e in _typeOptions.entries) (e.key, e.value), ], onChanged: (v) => setState(() => _type = v), )), ), ]), const SizedBox(height: 14), // frow2:联系人 + 电话 Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: DsField('联系人', input: TextFormField( controller: _contactCtrl, decoration: const InputDecoration(hintText: '姓名'), )), ), const SizedBox(width: 14), Expanded( child: DsField('电话', input: TextFormField( controller: _phoneCtrl, decoration: const InputDecoration(hintText: '手机 / 座机'), )), ), ]), const SizedBox(height: 14), // frow3:地址(单列占满) DsField('地址', input: TextFormField( controller: _addressCtrl, decoration: const InputDecoration(hintText: '省 / 市 / 详细地址'), )), const SizedBox(height: 14), // frow4:期初余额 + 备注 Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: DsField('期初余额', input: TextFormField( controller: _balanceCtrl, keyboardType: const TextInputType.numberWithOptions( decimal: true), decoration: const InputDecoration(hintText: '0.00'), )), ), const SizedBox(width: 14), Expanded( child: DsField('备注', input: TextFormField( controller: _remarkCtrl, decoration: const InputDecoration(hintText: '备注说明'), )), ), ]), const SizedBox(height: 24), Row( children: [ if (widget.onDelete != null) WriteGuard( child: DsButton('删除', variant: DsBtnVariant.danger, onPressed: () { Navigator.of(context).pop(); widget.onDelete!(); }), ), const Spacer(), DsButton('取消', onPressed: () => Navigator.of(context).pop()), const SizedBox(width: 10), DsButton(_saving ? '保存中…' : '保存', variant: DsBtnVariant.primary, onPressed: _saving ? null : _save), ], ), ], ), ), ), ), ); } }