diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 46d221d..098d468 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -17,6 +17,10 @@ import '../../providers/stock_in_provider.dart'; import '../../widgets/ds/ds_atoms.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/mobile_list_card.dart'; import '../../widgets/searchable_option_field.dart'; import '../../widgets/combo_search_field.dart'; @@ -32,7 +36,6 @@ import '../../providers/finance_provider.dart' show financeRepositoryProvider, financePartnerRowsProvider; import '../../providers/shop_provider.dart' show shopInfoProvider; import '../../widgets/write_guard.dart'; -import '../../widgets/order_row_actions.dart'; import '../../widgets/order_return_dialog.dart'; import '../../widgets/order_detail_drawer.dart'; import '../../widgets/wheel_date_picker.dart'; @@ -265,24 +268,47 @@ class _StockInListScreenState extends ConsumerState { ); Future _openAdvSearch() async { - final res = await showAppDialog<_AdvResult>( - context: context, - builder: (_) => _AdvSearchDialog( - initialDetail: _detail, - initialStatus: _statusFilter, - initialRange: _dateRange, - ), - ); - if (res == null || !mounted) return; + final _AdvResult? res; + if (context.isMobile) { + // 窄屏:底部 sheet 竖排单列(字段与桌面同集,原型 drawAdv)。 + final formKey = GlobalKey<_AdvSearchSheetState>(); + res = await showMSheet<_AdvResult>( + context, + title: '详细搜索', + builder: (_) => _AdvSearchSheet( + key: formKey, + initialDetail: _detail, + initialStatus: _statusFilter, + initialRange: _dateRange, + ), + actions: [ + DsButton('重置', onPressed: () => formKey.currentState?.reset()), + DsButton('应用', + variant: DsBtnVariant.primary, + onPressed: () => formKey.currentState?.apply()), + ], + ); + } else { + res = await showAppDialog<_AdvResult>( + context: context, + builder: (_) => _AdvSearchDialog( + initialDetail: _detail, + initialStatus: _statusFilter, + initialRange: _dateRange, + ), + ); + } + final r = res; + if (r == null || !mounted) return; setState(() { - _detail = res.detail; - _statusFilter = res.status; - _dateRange = res.dateRange; + _detail = r.detail; + _statusFilter = r.status; + _dateRange = r.dateRange; }); final n = ref.read(stockInListProvider.notifier); - n.setStatus(res.status); + n.setStatus(r.status); n.setDateRange(_startDate, _endDate); - n.setDetail(res.detail); + n.setDetail(r.detail); } int get _advCount { @@ -346,16 +372,25 @@ class _StockInListScreenState extends ConsumerState { final summary = ref.watch(stockInSummary30Provider).valueOrNull; + final mobile = context.isMobile; final content = Container( color: context.tokens.bg, - padding: context.isMobile + padding: mobile ? EdgeInsets.zero // 原型 .main{padding:22px 26px} : const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( children: [ - _buildHeader(result.total, summary), - _buildKpis(summary, result.total), + // 窄屏对齐移动原型 m-stock-in-list:标题在顶栏、正文从 KPI 起; + // 无页头按钮(移动端无建单/导出/打印入口——用户拍板)。 + if (!mobile) ...[ + _buildHeader(result.total, summary), + _buildKpis(summary, result.total), + ] else ...[ + _buildMobileKpis(summary, result.total), + _buildMobileSearchRow(), + _buildMobileSection(result.total), + ], Expanded( child: DsTable( total: result.total, @@ -366,12 +401,14 @@ class _StockInListScreenState extends ConsumerState { onPageSizeChanged: (s) => ref.read(stockInListProvider.notifier).setPageSize(s), emptyText: '没有匹配的入库单 · 试试调整筛选或搜索', - toolbar: _buildToolbar( - orders: orders, - warehouseOptions: warehouseOptions, - supplierOptions: supplierOptions, - selectedSupplierId: selectedSupplierId, - ), + toolbar: mobile + ? null + : _buildToolbar( + orders: orders, + warehouseOptions: warehouseOptions, + supplierOptions: supplierOptions, + selectedSupplierId: selectedSupplierId, + ), mobileCards: orders.map((o) => _orderCard(context, o)).toList(), columns: _colDefs @@ -534,6 +571,178 @@ class _StockInListScreenState extends ConsumerState { ); } + // ── 窄屏形态(对齐移动原型 m-stock-in-list.html)────────────────── + + /// 状态词 → (前景, 软底):镜像原型 .badge.b-*,全走 token。 + (Color, Color) _statusColors(String label) { + final t = context.tokens; + return switch (label) { + '待审核' => (t.warn, t.warnBg), + '已审核' => (t.success, t.successBg), + '已拒绝' => (t.danger, t.dangerBg), + '部分退单' => (t.warn, t.warnBg), + '已退单' => (t.danger, t.dangerBg), + '待定价' => (t.warn, t.warnBg), + _ => (t.muted, t.infoSoft), // 草稿 + }; + } + + /// 原型 .badge.ico:纯图标小徽章(卡片右上态标 / 状态 sheet 选项行)。 + Widget _icoBadge(String label) { + final (fg, bg) = _statusColors(label); + return Container( + height: 22, + width: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(AppDims.rPill), + ), + child: Icon(statusIcon(label), size: 12, color: fg), + ); + } + + /// 原型 .m-kpi 2×2:近30天笔数/金额 + 可点筛选的待审核/草稿(再点取消)。 + Widget _buildMobileKpis(StockSummary? summary, int total) { + String yuanWan(double v) => v >= 10000 + ? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}万' + : '¥${v.toStringAsFixed(0)}'; + String pct(double? p) => p == null + ? '较上期 —' + : '${p >= 0 ? '▲' : '▼'} ${p.abs().toStringAsFixed(1)}% 较上期'; + MKpiDeltaTone tone(double? p) => p == null + ? MKpiDeltaTone.normal + : (p >= 0 ? MKpiDeltaTone.up : MKpiDeltaTone.down); + final pendingCount = summary?.pendingCount ?? 0; + final draftCount = summary?.draftCount ?? 0; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: MKpiGrid(items: [ + MKpiItem( + label: '近30天入库笔数', + value: NumberFormat.decimalPattern() + .format(summary?.monthCount ?? total), + icon: LucideIcons.download, + delta: pct(summary?.countDeltaPct), + deltaTone: tone(summary?.countDeltaPct)), + MKpiItem( + label: '近30天入库金额', + value: summary != null ? yuanWan(summary.monthAmount) : '—', + icon: LucideIcons.wallet, + delta: pct(summary?.amountDeltaPct), + deltaTone: tone(summary?.amountDeltaPct)), + MKpiItem( + label: '待审核 · 点击筛选', + value: '$pendingCount', + icon: LucideIcons.clock, + delta: pendingCount > 0 ? '需尽快处理' : '点击筛选', + deltaTone: + pendingCount > 0 ? MKpiDeltaTone.warn : MKpiDeltaTone.normal, + selected: _statusFilter == 'pending', + onTap: () => + _setStatus(_statusFilter == 'pending' ? '' : 'pending')), + MKpiItem( + label: '草稿 · 点击筛选', + value: '$draftCount', + icon: LucideIcons.fileText, + delta: draftCount > 0 ? '待提交审核' : '点击筛选', + selected: _statusFilter == 'draft', + onTap: () => _setStatus(_statusFilter == 'draft' ? '' : 'draft')), + ]), + ); + } + + /// 详细搜索是否有生效条件(不含状态钮自己的 return_state)→ 详搜钮高亮。 + bool get _mobileAdvActive => + _detail.entries + .any((e) => e.key != 'return_state' && e.value.trim().isNotEmpty) || + _dateRange != null; + + /// 原型搜索区:搜索框 + 纯文字状态钮 + 图标详搜钮。 + Widget _buildMobileSearchRow() { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: MSearchRow( + controller: _searchCtrl, + hint: '搜索单号 / 供应商 / 酒名', + // setKeyword 内置 350ms 防抖 + 同词去重,oninput 直连即可(对齐原型)。 + onChanged: (v) => ref.read(stockInListProvider.notifier).setKeyword(v), + onSubmitted: (v) => + ref.read(stockInListProvider.notifier).setKeyword(v), + statusLabel: _statusLabelOf, + statusActive: _statusFilter.isNotEmpty, + onStatusTap: _openStatusSheet, + filterActive: _mobileAdvActive, + onFilterTap: _openAdvSearch, + ), + ); + } + + /// 原型 .m-section:入库单 · 共 N。 + Widget _buildMobileSection(int total) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text('入库单 · 共 $total', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: context.tokens.muted)), + ), + ); + } + + /// 状态筛选底部 sheet(原型 openStatusSheet 的 m-opt 列表:图标 + 状态词 + 勾)。 + Future _openStatusSheet() async { + final sel = await showMSheet( + context, + title: '单据状态', + builder: (ctx) { + final t = ctx.tokens; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < _statusOptions.length; i++) + InkWell( + onTap: () => Navigator.of(ctx).pop(_statusOptions[i].$1), + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 13, horizontal: 4), + decoration: BoxDecoration( + border: i < _statusOptions.length - 1 + ? Border(bottom: BorderSide(color: t.borderSubtle)) + : null, + ), + child: Row(children: [ + if (_statusOptions[i].$2 != '全部') ...[ + _icoBadge(_statusOptions[i].$2), + const SizedBox(width: 10), + ], + Text(_statusOptions[i].$2, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: _statusOptions[i].$1 == _statusFilter + ? FontWeight.w600 + : FontWeight.w400, + color: _statusOptions[i].$1 == _statusFilter + ? t.primary + : t.text)), + const Spacer(), + if (_statusOptions[i].$1 == _statusFilter) + Icon(LucideIcons.check, size: 18, color: t.primary), + ]), + ), + ), + ], + ); + }, + ); + if (sel == null || !mounted) return; + _setStatus(sel); + } + // ── 工具栏(原型 .toolbar)───────────────────────────────────────── Widget _buildToolbar({ required List orders, @@ -542,8 +751,6 @@ class _StockInListScreenState extends ConsumerState { required int? selectedSupplierId, }) { return Builder(builder: (ctx) { - final isMobile = ctx.isMobile; - final searchField = DsSearchBox( controller: _searchCtrl, hint: '单号 / 酒名 / 商品编码', @@ -613,49 +820,8 @@ class _StockInListScreenState extends ConsumerState { onPressed: _resetFilters, ); - if (isMobile) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - searchField, - const SizedBox(height: AppDims.sp2), - Wrap( - spacing: AppDims.sp2, - runSpacing: AppDims.sp2, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (!WriteGuard.isReadonly(ref)) - WriteGuard( - child: DsButton( - '新增', - icon: LucideIcons.plus, - small: true, - variant: DsBtnVariant.primary, - onPressed: () => context.go('/stock-in/new'), - ), - ), - supplierField, - warehouseChip, - statusChip, - dateChip, - advBtn, - resetBtn, - DsButton('导出', - icon: LucideIcons.download, - small: true, - onPressed: _doExport), - DsButton('刷新', - icon: LucideIcons.refreshCw, - small: true, - onPressed: () => - ref.read(stockInListProvider.notifier).reload()), - ], - ), - ], - ); - } - // 桌面:筛选左侧 Wrap(避免 1280 溢出),重置推到最右(对齐原型 .sp 布局)。 + // 窄屏不再走此工具栏(对齐移动原型:MSearchRow + 状态/详搜 sheet)。 return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -755,70 +921,9 @@ class _StockInListScreenState extends ConsumerState { } } - /// 操作按钮列表,表格与移动端卡片共用。入库特有的「打标签」通过 afterPrint 注入。 - List _orderActions(BuildContext context, StockInOrder o) { - return buildOrderRowActions( - context: context, - readonly: WriteGuard.isReadonly(ref), - canWithdraw: ref.watch(isAdminProvider) || - (o.operatorId != null && - o.operatorId == ref.watch(currentUserIdProvider)), - status: o.status, - orderId: o.id, - onDetail: () => _showDetail(context, o.id), - onPrint: () async { - final order = await ref.read(stockInRepositoryProvider).get(o.id); - if (context.mounted) { - await showStockInOrderPrint(context, ref, order); - } - }, - afterPrint: [ - TextButton( - onPressed: () async { - final order = await ref.read(stockInRepositoryProvider).get(o.id); - if (!context.mounted) return; - final shopInfo = ref.read(shopInfoProvider).valueOrNull; - final labels = order.items - .map((item) => LabelData( - productId: item.productId, - name: item.productName ?? '', - code: item.productCode ?? '', - series: item.productSeries, - spec: item.productSpec, - batchNo: item.batchNo, - productionDate: item.productionDate, - shopName: shopInfo?.name ?? '', - shopAddress: shopInfo?.address ?? '', - shopPhone: shopInfo?.phone ?? '', - )) - .toList(); - showAppDialog( - context: context, - builder: (_) => LabelPreviewDialog( - labels: labels, - qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes, - ), - ); - }, - child: Text('打标签', - style: TextStyle(fontSize: 12, color: context.tokens.primary)), - ), - ], - onSettle: () => _confirmSettle(context, o.id, 'stock_in'), - onEdit: () => context.go('/stock-in/edit/${o.id}'), - onDelete: () => _confirmDelete(context, o), - onSubmit: () => _confirmSubmit(context, o), - onApprove: () => _confirmApprove(context, o), - onReject: () => _confirmReject(context, o), - onWithdraw: () => _confirmWithdraw(context, o), - // 入库退单:仅管理员/超管 - canReturn: ref.watch(isAdminProvider), - onReturn: () => _confirmReturn(context, o), - ); - } - - /// 入库单:窄屏卡片 + /// 入库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)。 Widget _orderCard(BuildContext context, StockInOrder o) { + final hasPending = o.items.any((it) => it.costPrice == 0); return MobileListCard( onTap: () => _showDetail(context, o.id), title: Text(o.orderNo, @@ -827,21 +932,30 @@ class _StockInListScreenState extends ConsumerState { fontFamilyFallback: AppFonts.monoFallback, fontSize: 14)), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ - StatusBadge(_apiStatusToEnum(o.status)), - if (returnStateBadge(context, o.returnState) != null) ...[ - const SizedBox(width: 4), - returnStateBadge(context, o.returnState)!, + _icoBadge(_apiStatusToEnum(o.status).label), + if (o.returnState == 'partial') ...[ + const SizedBox(width: 5), + _icoBadge('部分退单'), + ] else if (o.returnState == 'full') ...[ + const SizedBox(width: 5), + _icoBadge('已退单'), ], + if (hasPending) ...[ + const SizedBox(width: 5), + _icoBadge('待定价'), + ], + const SizedBox(width: 6), + Icon(LucideIcons.chevronRight, size: 18, color: context.tokens.faint), ]), fields: [ MobileCardField('供应商', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), MobileCardField('合计金额', o.costTotal != null ? '¥${o.costTotal!.toStringAsFixed(2)}' : '-'), + MobileCardField('入库时间', o.orderDate?.substring(0, 10) ?? '-'), MobileCardField('入库员', o.operatorName ?? '-'), MobileCardField('审核员', o.reviewerName ?? '-'), ], - actions: _orderActions(context, o), ); } @@ -989,17 +1103,20 @@ class _StockInListScreenState extends ConsumerState { ])); } - groups.add(DrawerActionGroup('打印', [ - b('打印标签', () { - close(); - _printLabels(o); - }), - b('打印单据', () async { - close(); - final order = await ref.read(stockInRepositoryProvider).get(o.id); - if (mounted) await showStockInOrderPrint(context, ref, order); - }), - ])); + // 窄屏详情不出打印入口(移动端无打印——用户拍板)。 + if (!context.isMobile) { + groups.add(DrawerActionGroup('打印', [ + b('打印标签', () { + close(); + _printLabels(o); + }), + b('打印单据', () async { + close(); + final order = await ref.read(stockInRepositoryProvider).get(o.id); + if (mounted) await showStockInOrderPrint(context, ref, order); + }), + ])); + } return groups; } @@ -1038,52 +1155,102 @@ class _StockInListScreenState extends ConsumerState { final controllers = { for (final it in pending) it.id!: TextEditingController() }; - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('确认进价'), - content: SizedBox( - width: context.dialogWidth(440), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: pending.map((it) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Text( - '${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}', - style: const TextStyle(fontSize: 13), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 120, - child: TextField( - controller: controllers[it.id], - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - decoration: const InputDecoration( - prefixText: '¥', hintText: '进价', isDense: true), - ), - ), - ], - ), - ); - }).toList(), + final bool? ok; + if (context.isMobile) { + // 窄屏:底部 sheet(对齐原型 openConfirm)。 + ok = await showMSheet( + context, + title: '确认进价', + builder: (ctx) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '调货「先卖后定价」:填写真实进价后,系统前向补齐库存成本、已出库成本快照,并按差额补一条应付流水——不反审核、不影响售价 / 应收。', + style: TextStyle( + fontSize: AppDims.fsSm, height: 1.6, color: ctx.tokens.muted), ), - ), + const SizedBox(height: 14), + for (final it in pending) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${it.productName ?? ''} ${it.productSpec ?? ''} · 数量 ${it.quantity.toStringAsFixed(0)}', + style: TextStyle( + fontSize: AppDims.fsSm, color: ctx.tokens.muted)), + const SizedBox(height: 6), + TextField( + controller: controllers[it.id], + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + prefixText: '¥', + hintText: '真实进价 0.00', + isDense: true), + ), + ], + ), + ), + ], ), actions: [ - DsButton('取消', onPressed: () => Navigator.pop(ctx, false)), - DsButton('确认', + DsButton('取消', onPressed: () => Navigator.of(context).pop(false)), + DsButton('确认并补差额', variant: DsBtnVariant.primary, - onPressed: () => Navigator.pop(ctx, true)), + onPressed: () => Navigator.of(context).pop(true)), ], - ), - ); + ); + } else { + ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认进价'), + content: SizedBox( + width: context.dialogWidth(440), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: pending.map((it) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Text( + '${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}', + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 120, + child: TextField( + controller: controllers[it.id], + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + decoration: const InputDecoration( + prefixText: '¥', hintText: '进价', isDense: true), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + actions: [ + DsButton('取消', onPressed: () => Navigator.pop(ctx, false)), + DsButton('确认', + variant: DsBtnVariant.primary, + onPressed: () => Navigator.pop(ctx, true)), + ], + ), + ); + } final items = >[]; if (ok == true) { for (final it in pending) { @@ -1792,3 +1959,315 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> { ); } } + +// ── 详细搜索(窄屏底部 sheet,竖排单列;字段与桌面 _AdvSearchDialog 完全同集, +// 对齐原型 drawAdv;重置/应用按钮由 showMSheet actions 注入)────────────── +class _AdvSearchSheet extends ConsumerStatefulWidget { + final Map initialDetail; + final String initialStatus; + final DateTimeRange? initialRange; + const _AdvSearchSheet({ + super.key, + required this.initialDetail, + required this.initialStatus, + required this.initialRange, + }); + + @override + ConsumerState<_AdvSearchSheet> createState() => _AdvSearchSheetState(); +} + +class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> { + late final TextEditingController _orderNoCtrl; + late final TextEditingController _productCtrl; + late final TextEditingController _productCodeCtrl; + late final TextEditingController _batchCtrl; + int? _partnerId; + String? _seriesName; + String? _specName; + int? _operatorId; + int? _reviewerId; + late String _status; + DateTimeRange? _range; + + static const _statusCodes = ['', 'draft', 'pending', 'approved', 'rejected']; + static const _statusNames = ['全部', '草稿', '待审核', '已审核', '已拒绝']; + + @override + void initState() { + super.initState(); + final d = widget.initialDetail; + _orderNoCtrl = TextEditingController(text: d['order_no'] ?? ''); + _productCtrl = TextEditingController(text: d['product'] ?? ''); + _productCodeCtrl = TextEditingController(text: d['product_code'] ?? ''); + _batchCtrl = TextEditingController(text: d['batch'] ?? ''); + _partnerId = int.tryParse(d['partner_id'] ?? ''); + _seriesName = d['series']; + _specName = d['spec']; + _operatorId = int.tryParse(d['operator_id'] ?? ''); + _reviewerId = int.tryParse(d['reviewer_id'] ?? ''); + _status = widget.initialStatus; + _range = widget.initialRange; + } + + @override + void dispose() { + _orderNoCtrl.dispose(); + _productCtrl.dispose(); + _productCodeCtrl.dispose(); + _batchCtrl.dispose(); + super.dispose(); + } + + /// 重置(sheet actions「重置」调用)。 + void reset() { + setState(() { + _orderNoCtrl.clear(); + _productCtrl.clear(); + _productCodeCtrl.clear(); + _batchCtrl.clear(); + _partnerId = null; + _seriesName = null; + _specName = null; + _operatorId = null; + _reviewerId = null; + _status = ''; + _range = null; + }); + } + + /// 应用(sheet actions「应用」调用):组装结果并关闭 sheet。 + void apply() { + final d = {}; + void put(String k, String? v) { + if (v != null && v.trim().isNotEmpty) d[k] = v.trim(); + } + + put('order_no', _orderNoCtrl.text); + put('partner_id', _partnerId?.toString()); + put('product', _productCtrl.text); + put('product_code', _productCodeCtrl.text); + put('series', _seriesName); + put('spec', _specName); + put('batch', _batchCtrl.text); + put('operator_id', _operatorId?.toString()); + put('reviewer_id', _reviewerId?.toString()); + Navigator.of(context).pop(_AdvResult(d, _status, _range)); + } + + Future _pickDate() async { + final range = await showWheelDateRange(context, initial: _range); + if (range != null) setState(() => _range = range); + } + + List _supplierOpts() => + ref + .watch(allPartnersProvider) + .valueOrNull + ?.data + .map((p) => OptionItem(id: p.id, name: p.name)) + .toList() ?? + const []; + + List _seriesOpts() => + ref + .watch(productSeriesListProvider) + .valueOrNull + ?.map((s) => OptionItem(id: s.id, name: s.name)) + .toList() ?? + const []; + + List _specOpts() => + ref + .watch(productSpecListProvider) + .valueOrNull + ?.map((s) => OptionItem(id: s.id, name: s.name)) + .toList() ?? + const []; + + List _userOpts() => + ref + .watch(userListProvider) + .valueOrNull + ?.map((u) => OptionItem( + id: u.id, + name: (u.realName != null && u.realName!.isNotEmpty) + ? u.realName! + : u.username)) + .toList() ?? + const []; + + int? _idByName(List opts, String? name) { + if (name == null || name.isEmpty) return null; + return opts.where((o) => o.name == name).map((o) => o.id).firstOrNull; + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final seriesOpts = _seriesOpts(); + final specOpts = _specOpts(); + + // 原型 .m-field:label + 控件竖排,单列。 + Widget field(String label, Widget child) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + const SizedBox(height: 6), + child, + ], + ), + ); + + // 原型 .m-input:h42 带框文本输入。 + Widget textField(TextEditingController c, String hint) => Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 11), + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: TextField( + controller: c, + style: TextStyle(fontSize: AppDims.fsBody, color: t.text), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + filled: false, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: hint, + hintStyle: TextStyle(color: t.faint, fontSize: AppDims.fsBody), + ), + ), + ); + + Widget combo({ + required List opts, + required int? selectedId, + required String hint, + required ValueChanged onChanged, + }) => + ComboSearchField( + options: opts, + selectedId: selectedId, + hint: hint, + onChanged: onChanged, + height: 42, + fillColor: t.surface, + ); + + final statusItems = [ + for (var i = 0; i < _statusNames.length; i++) + OptionItem(id: i, name: _statusNames[i]), + ]; + final statusSelIdx = _statusCodes.indexOf(_status); + + final dateHas = _range != null; + final dateField = InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row(children: [ + Expanded( + child: Text( + dateHas + ? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}' + : '全部时间', + style: TextStyle( + fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint), + ), + ), + if (dateHas) + GestureDetector( + onTap: () => setState(() => _range = null), + child: Icon(LucideIcons.x, size: 14, color: t.muted), + ) + else + Icon(LucideIcons.calendar, size: 16, color: t.faint), + ]), + ), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + field('单据编号', textField(_orderNoCtrl, '如 RK20260401001')), + field( + '供应商', + combo( + opts: _supplierOpts(), + selectedId: _partnerId, + hint: '全部供应商', + onChanged: (v) => setState(() => _partnerId = v))), + field('商品名', textField(_productCtrl, '输入酒名')), + field('商品编码', textField(_productCodeCtrl, '输入商品编码')), + field( + '系列', + combo( + opts: seriesOpts, + selectedId: _idByName(seriesOpts, _seriesName), + hint: '全部系列', + onChanged: (v) => setState(() => _seriesName = v == null + ? null + : seriesOpts + .where((o) => o.id == v) + .map((o) => o.name) + .firstOrNull))), + field( + '规格', + combo( + opts: specOpts, + selectedId: _idByName(specOpts, _specName), + hint: '全部规格', + onChanged: (v) => setState(() => _specName = v == null + ? null + : specOpts + .where((o) => o.id == v) + .map((o) => o.name) + .firstOrNull))), + field('批次号', textField(_batchCtrl, '输入批次号')), + field( + '状态', + ComboSearchField( + options: statusItems, + selectedId: statusSelIdx <= 0 ? null : statusSelIdx, + hint: '全部', + height: 42, + fillColor: t.surface, + onChanged: (id) => setState( + () => _status = (id == null) ? '' : _statusCodes[id]), + )), + field( + '入库员', + combo( + opts: _userOpts(), + selectedId: _operatorId, + hint: '全部入库员', + onChanged: (v) => setState(() => _operatorId = v))), + field( + '审核人', + combo( + opts: _userOpts(), + selectedId: _reviewerId, + hint: '全部审核人', + onChanged: (v) => setState(() => _reviewerId = v))), + field('单据日期', dateField), + ], + ); + } +} diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 4ebe222..95e81e5 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -15,6 +15,7 @@ import '../../core/utils/print_util.dart' show LabelData; import '../../core/auth/auth_state.dart' show isAdminProvider, currentUserIdProvider; import '../../models/stock_out.dart'; +import '../../models/stock_summary.dart'; import '../../providers/stock_out_provider.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider, financePartnerRowsProvider; @@ -30,12 +31,15 @@ import '../../widgets/order_detail_drawer.dart'; import '../../widgets/ds/ds_atoms.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/mobile_list_card.dart'; import '../../widgets/status_badge.dart'; import '../../widgets/searchable_option_field.dart'; import '../../widgets/combo_search_field.dart'; import '../../widgets/order_print_preview_dialog.dart'; -import '../../widgets/order_row_actions.dart'; import '../../widgets/order_return_dialog.dart'; import '../../widgets/wheel_date_picker.dart'; import '../../widgets/write_guard.dart'; @@ -324,16 +328,25 @@ class _StockOutListScreenState extends ConsumerState { .toList(); } + final mobile = context.isMobile; return Container( color: context.tokens.bg, - padding: context.isMobile + padding: mobile ? EdgeInsets.zero // 原型 .main{padding:22px 26px} : const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( children: [ - _buildHeader(total), - _buildKpis(), + // 窄屏对齐移动原型 m-stock-out-list:标题在顶栏、正文从 KPI 起; + // 无页头按钮(移动端无建单/导出/打印入口——用户拍板)。 + if (!mobile) ...[ + _buildHeader(total), + _buildKpis(), + ] else ...[ + _buildMobileKpis(total), + _buildMobileSearchRow(), + _buildMobileSection(total), + ], Expanded( child: DsTable( total: total, @@ -343,7 +356,7 @@ class _StockOutListScreenState extends ConsumerState { ref.read(stockOutListProvider.notifier).setPage(p), onPageSizeChanged: (s) => ref.read(stockOutListProvider.notifier).setPageSize(s), - toolbar: _buildToolbar(orders), + toolbar: mobile ? null : _buildToolbar(orders), emptyText: '没有匹配的出库单 · 试试调整筛选或搜索', columns: const [ DsColumn('order_no', '单号'), @@ -571,13 +584,215 @@ class _StockOutListScreenState extends ConsumerState { ); } + // ── 窄屏形态(对齐移动原型 m-stock-out-list.html)───────────────── + + /// 状态设置(主状态 / ret:退单态互斥),KPI 卡与状态 sheet 共用。 + void _setStatus(String code) { + final n = ref.read(stockOutListProvider.notifier); + if (code.startsWith('ret:')) { + setState(() { + _statusFilter = code; + _returnState = code.substring(4); + }); + n.setStatus(''); + _applyDetail(); + } else { + setState(() { + _statusFilter = code; + _returnState = ''; + }); + n.setStatus(code); + _applyDetail(); + } + } + + /// 状态词 → (前景, 软底):镜像原型 .badge.b-*,全走 token。 + (Color, Color) _statusColors(String label) { + final t = context.tokens; + return switch (label) { + '待审核' => (t.warn, t.warnBg), + '已审核' => (t.success, t.successBg), + '已拒绝' => (t.danger, t.dangerBg), + '部分退单' => (t.warn, t.warnBg), + '已退单' => (t.danger, t.dangerBg), + '待定价' => (t.warn, t.warnBg), + _ => (t.muted, t.infoSoft), // 草稿 + }; + } + + /// 原型 .badge.ico:纯图标小徽章(卡片右上态标 / 状态 sheet 选项行)。 + Widget _icoBadge(String label) { + final (fg, bg) = _statusColors(label); + return Container( + height: 22, + width: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(AppDims.rPill), + ), + child: Icon(statusIcon(label), size: 12, color: fg), + ); + } + + /// 原型 .m-kpi 2×2:近30天笔数/金额 + 可点筛选的待审核/草稿(再点取消)。 + Widget _buildMobileKpis(int total) { + final StockSummary? summary = + ref.watch(stockOutSummary30Provider).valueOrNull; + String pct(double? p) => p == null + ? '较上期 —' + : '${p >= 0 ? '▲' : '▼'} ${p.abs().toStringAsFixed(1)}% 较上期'; + MKpiDeltaTone tone(double? p) => p == null + ? MKpiDeltaTone.normal + : (p >= 0 ? MKpiDeltaTone.up : MKpiDeltaTone.down); + final pendingCount = summary?.pendingCount ?? 0; + final draftCount = summary?.draftCount ?? 0; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: MKpiGrid(items: [ + MKpiItem( + label: '近30天出库笔数', + value: NumberFormat.decimalPattern() + .format(summary?.monthCount ?? total), + icon: LucideIcons.upload, + delta: pct(summary?.countDeltaPct), + deltaTone: tone(summary?.countDeltaPct)), + MKpiItem( + label: '近30天出库金额', + value: summary != null ? _yuanWan(summary.monthAmount) : '—', + icon: LucideIcons.banknote, + delta: pct(summary?.amountDeltaPct), + deltaTone: tone(summary?.amountDeltaPct)), + MKpiItem( + label: '待审核 · 点击筛选', + value: '$pendingCount', + icon: LucideIcons.clock, + delta: pendingCount > 0 ? '需尽快处理' : '点击筛选', + deltaTone: + pendingCount > 0 ? MKpiDeltaTone.warn : MKpiDeltaTone.normal, + selected: _statusFilter == 'pending', + onTap: () => + _setStatus(_statusFilter == 'pending' ? '' : 'pending')), + MKpiItem( + label: '草稿 · 点击筛选', + value: '$draftCount', + icon: LucideIcons.fileText, + delta: draftCount > 0 ? '待提交审核' : '点击筛选', + selected: _statusFilter == 'draft', + onTap: () => _setStatus(_statusFilter == 'draft' ? '' : 'draft')), + ]), + ); + } + + /// 详细搜索是否有生效条件(不含状态钮自己的态)→ 详搜钮高亮。 + bool get _mobileAdvActive => + _orderNo.isNotEmpty || + _productInfo.isNotEmpty || + _productCode.isNotEmpty || + _batch.isNotEmpty || + _series.isNotEmpty || + _spec.isNotEmpty || + _partnerId != null || + _operatorId != null || + _reviewerId != null || + _dateRange != null; + + /// 原型搜索区:搜索框 + 纯文字状态钮 + 图标详搜钮。 + Widget _buildMobileSearchRow() { + final statusLabel = _statusFilter.isEmpty + ? '全部' + : _statusOptions + .firstWhere((e) => e.$1 == _statusFilter, orElse: () => ('', '全部')) + .$2; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: MSearchRow( + controller: _searchCtrl, + hint: '搜索单号 / 客户 / 酒名', + // setKeyword 内置 350ms 防抖 + 同词去重,oninput 直连即可(对齐原型)。 + onChanged: (v) => ref.read(stockOutListProvider.notifier).setKeyword(v), + onSubmitted: (v) => + ref.read(stockOutListProvider.notifier).setKeyword(v), + statusLabel: statusLabel, + statusActive: _statusFilter.isNotEmpty, + onStatusTap: _openStatusSheet, + filterActive: _mobileAdvActive, + onFilterTap: _openAdvSearch, + ), + ); + } + + /// 原型 .m-section:出库单 · 共 N。 + Widget _buildMobileSection(int total) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text('出库单 · 共 $total', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: context.tokens.muted)), + ), + ); + } + + /// 状态筛选底部 sheet(原型 openStatusSheet 的 m-opt 列表:图标 + 状态词 + 勾)。 + Future _openStatusSheet() async { + final sel = await showMSheet( + context, + title: '单据状态', + builder: (ctx) { + final t = ctx.tokens; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < _statusOptions.length; i++) + InkWell( + onTap: () => Navigator.of(ctx).pop(_statusOptions[i].$1), + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 13, horizontal: 4), + decoration: BoxDecoration( + border: i < _statusOptions.length - 1 + ? Border(bottom: BorderSide(color: t.borderSubtle)) + : null, + ), + child: Row(children: [ + if (_statusOptions[i].$2 != '全部') ...[ + _icoBadge(_statusOptions[i].$2), + const SizedBox(width: 10), + ], + Text(_statusOptions[i].$2, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: _statusOptions[i].$1 == _statusFilter + ? FontWeight.w600 + : FontWeight.w400, + color: _statusOptions[i].$1 == _statusFilter + ? t.primary + : t.text)), + const Spacer(), + if (_statusOptions[i].$1 == _statusFilter) + Icon(LucideIcons.check, size: 18, color: t.primary), + ]), + ), + ), + ], + ); + }, + ); + if (sel == null || !mounted) return; + _setStatus(sel); + } + // ── 工具栏(原型 .toolbar)───────────────────────────────────── Widget _buildToolbar(List orders) { - final isMobile = context.isMobile; - + // 仅桌面调用(窄屏走 MSearchRow + 状态/详搜 sheet,不再进此工具栏)。 final searchField = SizedBox( - width: isMobile ? double.infinity : 260, + width: 260, child: DsSearchBox( controller: _searchCtrl, hint: '单号 / 酒名 / 商品编码', @@ -597,7 +812,7 @@ class _StockOutListScreenState extends ConsumerState { options: customers, selectedId: _partnerId, hint: '客户', - width: isMobile ? 320 : 180, + width: 180, onChanged: (id) { setState(() => _partnerId = id); _applyDetail(); @@ -680,33 +895,15 @@ class _StockOutListScreenState extends ConsumerState { onPressed: _resetFilters, ); - // 筛选 chip(不含重置)——重置在桌面推到最右、移动端并入 Wrap 末尾(对齐原型 .sp)。 + // 筛选 chip(不含重置)——重置推到最右(对齐原型 .sp)。 final filterChips = [ - if (!isMobile) customerField, + customerField, warehouseChip, statusChip, dateChip, advBtn, ]; - if (isMobile) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - searchField, - const SizedBox(height: AppDims.sp2), - customerField, - const SizedBox(height: AppDims.sp2), - Wrap( - spacing: AppDims.sp2, - runSpacing: AppDims.sp2, - crossAxisAlignment: WrapCrossAlignment.center, - children: [...filterChips, resetBtn], - ), - ], - ); - } - return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -744,75 +941,63 @@ class _StockOutListScreenState extends ConsumerState { // ── 详细搜索模态(11 字段)────────────────────────────────────── Future _openAdvSearch() async { - final result = await showAppDialog<_AdvResult>( - context: context, - builder: (_) => _AdvancedSearchDialog( - initial: _AdvResult( - orderNo: _orderNo, - productInfo: _productInfo, - productCode: _productCode, - batch: _batch, - partnerId: _partnerId, - series: _series, - spec: _spec, - operatorId: _operatorId, - reviewerId: _reviewerId, - status: _statusFilter, - dateRange: _dateRange, - ), - ), + final initial = _AdvResult( + orderNo: _orderNo, + productInfo: _productInfo, + productCode: _productCode, + batch: _batch, + partnerId: _partnerId, + series: _series, + spec: _spec, + operatorId: _operatorId, + reviewerId: _reviewerId, + status: _statusFilter, + dateRange: _dateRange, ); - if (result == null || !mounted) return; + final _AdvResult? result; + if (context.isMobile) { + // 窄屏:底部 sheet 竖排单列(字段与桌面同集,原型 drawAdv)。 + final formKey = GlobalKey<_AdvSearchSheetState>(); + result = await showMSheet<_AdvResult>( + context, + title: '详细搜索', + builder: (_) => _AdvSearchSheet(key: formKey, initial: initial), + actions: [ + DsButton('重置', onPressed: () => formKey.currentState?.reset()), + DsButton('应用', + variant: DsBtnVariant.primary, + onPressed: () => formKey.currentState?.apply()), + ], + ); + } else { + result = await showAppDialog<_AdvResult>( + context: context, + builder: (_) => _AdvancedSearchDialog(initial: initial), + ); + } + final r = result; + if (r == null || !mounted) return; setState(() { - _orderNo = result.orderNo; - _productInfo = result.productInfo; - _productCode = result.productCode; - _batch = result.batch; - _partnerId = result.partnerId; - _series = result.series; - _spec = result.spec; - _operatorId = result.operatorId; - _reviewerId = result.reviewerId; - _statusFilter = result.status; - _dateRange = result.dateRange; + _orderNo = r.orderNo; + _productInfo = r.productInfo; + _productCode = r.productCode; + _batch = r.batch; + _partnerId = r.partnerId; + _series = r.series; + _spec = r.spec; + _operatorId = r.operatorId; + _reviewerId = r.reviewerId; + _statusFilter = r.status; + _dateRange = r.dateRange; }); _applyAll(); } - // ── 行操作 / 卡片 ───────────────────────────────────────────── - - List _orderActions(BuildContext context, StockOutOrder o) { - return buildOrderRowActions( - context: context, - readonly: WriteGuard.isReadonly(ref), - canWithdraw: ref.watch(isAdminProvider) || - (o.operatorId != null && - o.operatorId == ref.watch(currentUserIdProvider)), - status: o.status, - orderId: o.id, - onDetail: () => _showDetail(context, o.id), - onPrint: () async { - final order = await ref.read(stockOutRepositoryProvider).get(o.id); - if (context.mounted) { - await showStockOutOrderPrint(context, ref, order); - } - }, - onSettle: () => _confirmSettle(context, o.id, 'stock_out'), - onEdit: () => context.go('/stock-out/edit/${o.id}'), - onDelete: () => _confirmDelete(context, o), - onSubmit: () => _confirmSubmit(context, o), - onApprove: () => _confirmApprove(context, o), - onReject: () => _confirmReject(context, o), - onWithdraw: () => _confirmWithdraw(context, o), - // 出库退单:管理员/超管 或 本人单 - canReturn: ref.watch(isAdminProvider) || - (o.operatorId != null && - o.operatorId == ref.watch(currentUserIdProvider)), - onReturn: () => _confirmReturn(context, o), - ); - } + // ── 窄屏卡片 ───────────────────────────────────────────────── + /// 出库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)。 Widget _orderCard(BuildContext context, StockOutOrder o) { + final hasPending = o.items.any((it) => it.salePrice <= 0); return MobileListCard( onTap: () => _showDetail(context, o.id), title: Text(o.orderNo, @@ -820,7 +1005,22 @@ class _StockOutListScreenState extends ConsumerState { fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: 14)), - trailing: _cellStatus(o), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + _icoBadge(_apiStatusToEnum(o.status).label), + if (o.returnState == 'partial') ...[ + const SizedBox(width: 5), + _icoBadge('部分退单'), + ] else if (o.returnState == 'full') ...[ + const SizedBox(width: 5), + _icoBadge('已退单'), + ], + if (hasPending) ...[ + const SizedBox(width: 5), + _icoBadge('待定价'), + ], + const SizedBox(width: 6), + Icon(LucideIcons.chevronRight, size: 18, color: context.tokens.faint), + ]), fields: [ MobileCardField('客户', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), @@ -828,7 +1028,6 @@ class _StockOutListScreenState extends ConsumerState { o.saleTotal != null ? '¥${o.saleTotal!.toStringAsFixed(2)}' : '-'), MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'), ], - actions: _orderActions(context, o), ); } @@ -984,17 +1183,20 @@ class _StockOutListScreenState extends ConsumerState { ])); } - groups.add(DrawerActionGroup('打印', [ - b('打印标签', () { - close(); - _printLabels(o); - }), - b('打印单据', () async { - close(); - final order = await ref.read(stockOutRepositoryProvider).get(o.id); - if (mounted) await showStockOutOrderPrint(context, ref, order); - }), - ])); + // 窄屏详情不出打印入口(移动端无打印——用户拍板)。 + if (!context.isMobile) { + groups.add(DrawerActionGroup('打印', [ + b('打印标签', () { + close(); + _printLabels(o); + }), + b('打印单据', () async { + close(); + final order = await ref.read(stockOutRepositoryProvider).get(o.id); + if (mounted) await showStockOutOrderPrint(context, ref, order); + }), + ])); + } return groups; } @@ -1033,61 +1235,112 @@ class _StockOutListScreenState extends ConsumerState { final controllers = { for (final it in pending) it.id!: TextEditingController() }; - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('确认售价'), - content: SizedBox( - width: context.dialogWidth(440), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '「先出后定价」:填写真实售价后,系统据此结算应收(售价 × 数量)并补一条应收流水——不影响成本 / 库存,不反审核。', - style: TextStyle(fontSize: 12, color: context.tokens.muted), - ), - const SizedBox(height: 12), - ...pending.map((it) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Text( - '${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}', - style: const TextStyle(fontSize: 13), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 130, - child: TextField( - controller: controllers[it.id], - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - decoration: const InputDecoration( - prefixText: '¥', - hintText: '真实售价', - isDense: true), - ), - ), - ], - ), - ); - }), - ], + final bool? ok; + if (context.isMobile) { + // 窄屏:底部 sheet(对齐原型 openConfirm)。 + ok = await showMSheet( + context, + title: '确认售价', + builder: (ctx) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '「先卖后定价」:填写真实售价后,系统按售价 × 数量结算应收并补一条应收流水——不反审核、不影响成本 / 应付。', + style: TextStyle( + fontSize: AppDims.fsSm, height: 1.6, color: ctx.tokens.muted), ), - ), + const SizedBox(height: 14), + for (final it in pending) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${it.productName ?? ''} ${it.productSpec ?? ''} · 数量 ${it.quantity.toStringAsFixed(0)}', + style: TextStyle( + fontSize: AppDims.fsSm, color: ctx.tokens.muted)), + const SizedBox(height: 6), + TextField( + controller: controllers[it.id], + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + prefixText: '¥', + hintText: '真实售价 0.00', + isDense: true), + ), + ], + ), + ), + ], ), actions: [ - DsButton('取消', onPressed: () => Navigator.pop(ctx, false)), + DsButton('取消', onPressed: () => Navigator.of(context).pop(false)), DsButton('确认并补应收', variant: DsBtnVariant.primary, - onPressed: () => Navigator.pop(ctx, true)), + onPressed: () => Navigator.of(context).pop(true)), ], - ), - ); + ); + } else { + ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认售价'), + content: SizedBox( + width: context.dialogWidth(440), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '「先出后定价」:填写真实售价后,系统据此结算应收(售价 × 数量)并补一条应收流水——不影响成本 / 库存,不反审核。', + style: TextStyle(fontSize: 12, color: context.tokens.muted), + ), + const SizedBox(height: 12), + ...pending.map((it) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Text( + '${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}', + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 130, + child: TextField( + controller: controllers[it.id], + keyboardType: + const TextInputType.numberWithOptions( + decimal: true), + decoration: const InputDecoration( + prefixText: '¥', + hintText: '真实售价', + isDense: true), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + actions: [ + DsButton('取消', onPressed: () => Navigator.pop(ctx, false)), + DsButton('确认并补应收', + variant: DsBtnVariant.primary, + onPressed: () => Navigator.pop(ctx, true)), + ], + ), + ); + } final items = >[]; if (ok == true) { for (final it in pending) { @@ -1795,3 +2048,274 @@ class _MenuChip extends StatelessWidget { ); } } + +// ── 详细搜索(窄屏底部 sheet,竖排单列;字段与桌面 _AdvancedSearchDialog 完全 +// 同集,对齐原型 drawAdv;重置/应用按钮由 showMSheet actions 注入)──────── +class _AdvSearchSheet extends ConsumerStatefulWidget { + final _AdvResult initial; + const _AdvSearchSheet({super.key, required this.initial}); + + @override + ConsumerState<_AdvSearchSheet> createState() => _AdvSearchSheetState(); +} + +class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> { + late final TextEditingController _orderNoCtrl; + late final TextEditingController _productCtrl; + late final TextEditingController _productCodeCtrl; + late final TextEditingController _batchCtrl; + late int? _partnerId; + late String _series; + late String _spec; + late int? _operatorId; + late int? _reviewerId; + late String _status; + DateTimeRange? _dateRange; + + static const _statusCodes = ['', 'draft', 'pending', 'approved', 'rejected']; + static const _statusNames = ['全部', '草稿', '待审核', '已审核', '已拒绝']; + + @override + void initState() { + super.initState(); + final i = widget.initial; + _orderNoCtrl = TextEditingController(text: i.orderNo); + _productCtrl = TextEditingController(text: i.productInfo); + _productCodeCtrl = TextEditingController(text: i.productCode); + _batchCtrl = TextEditingController(text: i.batch); + _partnerId = i.partnerId; + _series = i.series; + _spec = i.spec; + _operatorId = i.operatorId; + _reviewerId = i.reviewerId; + _status = i.status; + _dateRange = i.dateRange; + } + + @override + void dispose() { + _orderNoCtrl.dispose(); + _productCtrl.dispose(); + _productCodeCtrl.dispose(); + _batchCtrl.dispose(); + super.dispose(); + } + + /// 重置(sheet actions「重置」调用)。 + void reset() { + setState(() { + _orderNoCtrl.clear(); + _productCtrl.clear(); + _productCodeCtrl.clear(); + _batchCtrl.clear(); + _partnerId = null; + _series = ''; + _spec = ''; + _operatorId = null; + _reviewerId = null; + _status = ''; + _dateRange = null; + }); + } + + /// 应用(sheet actions「应用」调用):组装结果并关闭 sheet。 + void apply() { + Navigator.of(context).pop(_AdvResult( + orderNo: _orderNoCtrl.text.trim(), + productInfo: _productCtrl.text.trim(), + productCode: _productCodeCtrl.text.trim(), + batch: _batchCtrl.text.trim(), + partnerId: _partnerId, + series: _series, + spec: _spec, + operatorId: _operatorId, + reviewerId: _reviewerId, + status: _status, + dateRange: _dateRange, + )); + } + + Future _pickDate() async { + final range = await showWheelDateRange(context, initial: _dateRange); + if (range != null) setState(() => _dateRange = range); + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final customers = + (ref.watch(allPartnersProvider).valueOrNull?.data ?? const []) + .map((p) => OptionItem(id: p.id, name: p.name, code: p.code)) + .toList(); + final seriesOpts = + (ref.watch(productSeriesListProvider).valueOrNull ?? const []) + .map((s) => OptionItem(id: s.id, name: s.name)) + .toList(); + final specOpts = + (ref.watch(productSpecListProvider).valueOrNull ?? const []) + .map((s) => OptionItem(id: s.id, name: s.name)) + .toList(); + final users = (ref.watch(userListProvider).valueOrNull ?? const []) + .map((u) => OptionItem(id: u.id, name: u.realName ?? u.username)) + .toList(); + + int? idByName(List opts, String name) => + opts.where((o) => o.name == name).firstOrNull?.id; + + // 原型 .m-field:label + 控件竖排,单列。 + Widget field(String label, Widget child) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + const SizedBox(height: 6), + child, + ], + ), + ); + + // 原型 .m-input:h42 带框文本输入。 + Widget textField(TextEditingController c, String hint) => Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 11), + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: TextField( + controller: c, + style: TextStyle(fontSize: AppDims.fsBody, color: t.text), + decoration: InputDecoration( + isCollapsed: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + filled: false, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: hint, + hintStyle: TextStyle(color: t.faint, fontSize: AppDims.fsBody), + ), + ), + ); + + Widget combo({ + required List opts, + required int? selectedId, + required String hint, + required ValueChanged onChanged, + }) => + ComboSearchField( + options: opts, + selectedId: selectedId, + hint: hint, + onChanged: onChanged, + height: 42, + fillColor: t.surface, + ); + + final statusItems = [ + for (var i = 0; i < _statusNames.length; i++) + OptionItem(id: i, name: _statusNames[i]), + ]; + final statusSelIdx = _statusCodes.indexOf(_status); + + final dateHas = _dateRange != null; + final dateField = InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row(children: [ + Expanded( + child: Text( + dateHas + ? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}' + : '全部时间', + style: TextStyle( + fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint), + ), + ), + if (dateHas) + GestureDetector( + onTap: () => setState(() => _dateRange = null), + child: Icon(LucideIcons.x, size: 14, color: t.muted), + ) + else + Icon(LucideIcons.calendar, size: 16, color: t.faint), + ]), + ), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + field('单据编号', textField(_orderNoCtrl, '如 CK20260401001')), + field( + '客户', + combo( + opts: customers, + selectedId: _partnerId, + hint: '全部客户', + onChanged: (v) => setState(() => _partnerId = v))), + field('商品名', textField(_productCtrl, '输入酒名')), + field('商品编码', textField(_productCodeCtrl, '输入商品编码')), + field( + '系列', + combo( + opts: seriesOpts, + selectedId: idByName(seriesOpts, _series), + hint: '全部系列', + onChanged: (id) => setState(() => _series = id == null + ? '' + : seriesOpts.firstWhere((o) => o.id == id).name))), + field( + '规格', + combo( + opts: specOpts, + selectedId: idByName(specOpts, _spec), + hint: '全部规格', + onChanged: (id) => setState(() => _spec = id == null + ? '' + : specOpts.firstWhere((o) => o.id == id).name))), + field('批次号', textField(_batchCtrl, '输入批次号')), + field( + '状态', + ComboSearchField( + options: statusItems, + selectedId: statusSelIdx <= 0 ? null : statusSelIdx, + hint: '全部', + height: 42, + fillColor: t.surface, + onChanged: (id) => setState( + () => _status = (id == null) ? '' : _statusCodes[id]), + )), + field( + '出库员', + combo( + opts: users, + selectedId: _operatorId, + hint: '全部出库员', + onChanged: (id) => setState(() => _operatorId = id))), + field( + '审核人', + combo( + opts: users, + selectedId: _reviewerId, + hint: '全部审核人', + onChanged: (id) => setState(() => _reviewerId = id))), + field('单据日期', dateField), + ], + ); + } +} diff --git a/client/test/golden/goldens/m_stock_in_list_a.png b/client/test/golden/goldens/m_stock_in_list_a.png new file mode 100644 index 0000000..0073081 Binary files /dev/null and b/client/test/golden/goldens/m_stock_in_list_a.png differ diff --git a/client/test/golden/goldens/m_stock_in_list_b.png b/client/test/golden/goldens/m_stock_in_list_b.png new file mode 100644 index 0000000..142f5c2 Binary files /dev/null and b/client/test/golden/goldens/m_stock_in_list_b.png differ diff --git a/client/test/golden/goldens/m_stock_in_list_c.png b/client/test/golden/goldens/m_stock_in_list_c.png new file mode 100644 index 0000000..a5a9ebe Binary files /dev/null and b/client/test/golden/goldens/m_stock_in_list_c.png differ diff --git a/client/test/golden/goldens/m_stock_out_list_a.png b/client/test/golden/goldens/m_stock_out_list_a.png new file mode 100644 index 0000000..d04ac7b Binary files /dev/null and b/client/test/golden/goldens/m_stock_out_list_a.png differ diff --git a/client/test/golden/goldens/m_stock_out_list_b.png b/client/test/golden/goldens/m_stock_out_list_b.png new file mode 100644 index 0000000..7ddc999 Binary files /dev/null and b/client/test/golden/goldens/m_stock_out_list_b.png differ diff --git a/client/test/golden/goldens/m_stock_out_list_c.png b/client/test/golden/goldens/m_stock_out_list_c.png new file mode 100644 index 0000000..9452fba Binary files /dev/null and b/client/test/golden/goldens/m_stock_out_list_c.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_a.png b/client/test/golden/goldens/stock_in_list_mobile_a.png index faf9eb5..a1359d9 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_a.png and b/client/test/golden/goldens/stock_in_list_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_b.png b/client/test/golden/goldens/stock_in_list_mobile_b.png index d7b13bd..224fd2b 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_b.png and b/client/test/golden/goldens/stock_in_list_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_c.png b/client/test/golden/goldens/stock_in_list_mobile_c.png index 5d169e3..dfc6b36 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_c.png and b/client/test/golden/goldens/stock_in_list_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_a.png b/client/test/golden/goldens/stock_out_list_mobile_a.png index 3829f50..a2f4760 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_a.png and b/client/test/golden/goldens/stock_out_list_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_b.png b/client/test/golden/goldens/stock_out_list_mobile_b.png index 2e6dd10..0a5a458 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_b.png and b/client/test/golden/goldens/stock_out_list_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_c.png b/client/test/golden/goldens/stock_out_list_mobile_c.png index a425bca..7aecec4 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_c.png and b/client/test/golden/goldens/stock_out_list_mobile_c.png differ diff --git a/client/test/golden/stock_in_list_mobile_golden_test.dart b/client/test/golden/stock_in_list_mobile_golden_test.dart new file mode 100644 index 0000000..52a6d07 --- /dev/null +++ b/client/test/golden/stock_in_list_mobile_golden_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/models/stock_in.dart'; +import 'package:jiu_client/models/stock_summary.dart'; +import 'package:jiu_client/providers/stock_in_provider.dart'; +import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart'; + +import '../support/golden_harness.dart'; + +/// P1 单据域:入库列表窄屏 golden × 三主题(390×844 @2x)——对齐移动原型 +/// m-stock-in-list.html:KPI 2×2(待审核/草稿可点筛选)+ MSearchRow(搜索 + +/// 状态钮 + 详搜钮)+ .m-section + 卡片流(图标徽章含派生态 + › 箭头)。 +/// 更新基准:flutter test --update-goldens test/golden/stock_in_list_mobile_golden_test.dart + +const _orders = [ + // 已审核 + 部分退单 + 待定价(三徽章派生态齐全) + StockInOrder( + id: 1, + orderNo: 'RK-20260620-014', + warehouseId: 1, + warehouseName: '主仓', + partnerName: '贵州茅台经销', + operatorName: '王经理', + reviewerName: '张管理', + status: 'approved', + returnState: 'partial', + orderDate: '2026-06-20', + costTotal: 34320, + items: [ + StockInItem(productId: 1, quantity: 6, costPrice: 0, costAmount: 0), + StockInItem( + productId: 2, quantity: 6, costPrice: 2680, costAmount: 16080), + ]), + StockInOrder( + id: 2, + orderNo: 'RK-20260618-009', + warehouseId: 1, + warehouseName: '主仓', + partnerName: '宜宾五粮液', + operatorName: '李销售', + status: 'pending', + orderDate: '2026-06-18', + costTotal: 6700), + StockInOrder( + id: 3, + orderNo: 'RK-20260615-003', + warehouseId: 1, + warehouseName: '二号仓', + partnerName: '泸州老窖', + operatorName: '王经理', + status: 'draft', + orderDate: '2026-06-15', + costTotal: 5000), + StockInOrder( + id: 4, + orderNo: 'RK-20260612-001', + warehouseId: 1, + warehouseName: '主仓', + partnerName: '绵竹剑南春', + operatorName: '李销售', + reviewerName: '张管理', + status: 'rejected', + orderDate: '2026-06-12', + costTotal: 3500), +]; + +class _FakeStockInNotifier extends StockInListNotifier { + @override + Future> build() async => + const PageResult(data: _orders, total: 4, page: 1, pageSize: 20); + @override + void setPage(int page) {} + @override + void setPageSize(int pageSize) {} + @override + void setStatus(String status) {} + @override + void setDateRange(String? startDate, String? endDate) {} + @override + void setKeyword(String keyword) {} + @override + void reload() {} +} + +List _overrides() => [ + stockInListProvider.overrideWith(() => _FakeStockInNotifier()), + isReadonlyProvider.overrideWithValue(false), + // KPI 2×2(近30天笔数/金额/待审核/草稿):对齐原型示例数值。 + stockInSummary30Provider.overrideWith((ref) => const StockSummary( + monthCount: 28, + monthAmount: 1860000, + pendingCount: 4, + draftCount: 2, + lastMonthCount: 25, + lastMonthAmount: 1715000, + )), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'stock-in list 窄屏(移动原型)', + goldenPrefix: 'm_stock_in_list', + child: () => const Scaffold(body: StockInListScreen()), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); +} diff --git a/client/test/golden/stock_out_list_mobile_golden_test.dart b/client/test/golden/stock_out_list_mobile_golden_test.dart new file mode 100644 index 0000000..06d7f6f --- /dev/null +++ b/client/test/golden/stock_out_list_mobile_golden_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/models/page_result.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/models/stock_out.dart'; +import 'package:jiu_client/models/stock_summary.dart'; +import 'package:jiu_client/providers/stock_out_provider.dart'; +import 'package:jiu_client/screens/stock_out/stock_out_list_screen.dart'; + +import '../support/golden_harness.dart'; + +/// P1 单据域:出库列表窄屏 golden × 三主题(390×844 @2x)——对齐移动原型 +/// m-stock-out-list.html:KPI 2×2(待审核/草稿可点筛选)+ MSearchRow(搜索 + +/// 状态钮 + 详搜钮)+ .m-section + 卡片流(图标徽章含派生态 + › 箭头)。 +/// 更新基准:flutter test --update-goldens test/golden/stock_out_list_mobile_golden_test.dart + +const _orders = [ + // 已审核 + 部分退单(图标徽章派生态) + StockOutOrder( + id: 1, + orderNo: 'CK-20260620-031', + warehouseId: 1, + warehouseName: '主仓', + partnerName: '鼎丰超市', + operatorName: '李销售', + reviewerName: '张管理', + status: 'approved', + returnState: 'partial', + orderDate: '2026-06-20', + saleTotal: 12800, + items: [ + StockOutItem( + productId: 1, + quantity: 4, + salePrice: 3200, + saleAmount: 12800, + costPrice: 2680, + costAmount: 10720), + ]), + // 已审核 + 待定价(先卖后定价) + StockOutOrder( + id: 2, + orderNo: 'CK-20260619-026', + warehouseId: 1, + warehouseName: '名酒仓', + partnerName: '金樽会所', + operatorName: '王经理', + reviewerName: '张管理', + status: 'approved', + orderDate: '2026-06-19', + saleTotal: 28000, + items: [ + StockOutItem(productId: 2, quantity: 13, salePrice: 0, saleAmount: 0), + ]), + StockOutOrder( + id: 3, + orderNo: 'CK-20260618-022', + warehouseId: 1, + warehouseName: '主仓', + partnerName: '金樽酒楼', + operatorName: '张前台', + status: 'pending', + orderDate: '2026-06-18', + saleTotal: 5400), + StockOutOrder( + id: 4, + orderNo: 'CK-20260615-017', + warehouseId: 1, + warehouseName: '二号仓', + partnerName: '恒昌名酒行', + operatorName: '李销售', + status: 'draft', + orderDate: '2026-06-15', + saleTotal: 3200), +]; + +class _FakeStockOutNotifier extends StockOutListNotifier { + @override + Future> build() async => + const PageResult(data: _orders, total: 4, page: 1, pageSize: 20); + @override + void setPage(int page) {} + @override + void setPageSize(int pageSize) {} + @override + void setStatus(String status) {} + @override + void setDateRange(String? startDate, String? endDate) {} + @override + void setKeyword(String keyword) {} + @override + void setProductCode(String code) {} + @override + void reload() {} +} + +List _overrides() => [ + stockOutListProvider.overrideWith(() => _FakeStockOutNotifier()), + isReadonlyProvider.overrideWithValue(false), + // KPI 2×2(近30天笔数/金额/待审核/草稿):对齐原型示例数值。 + stockOutSummary30Provider.overrideWith((ref) => const StockSummary( + monthCount: 35, + monthAmount: 2160000, + pendingCount: 3, + draftCount: 1, + lastMonthCount: 32, + lastMonthAmount: 2022000, + )), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'stock-out list 窄屏(移动原型)', + goldenPrefix: 'm_stock_out_list', + child: () => const Scaffold(body: StockOutListScreen()), + overrides: _overrides, + logical: const Size(390, 844), + dpr: 2, + ); +}