feat(client): 出入库列表窄屏对齐移动原型(KPI 筛选/搜索行/状态与详搜 sheet/图标徽章)
- 窄屏隐藏页内大标题头与新增/导出/打印入口(移动端无建单与打印——拍板) - KPI 改 MKpiGrid 2×2:待审核/草稿卡可点切换状态筛选(再点取消,selected 联动) - 工具栏改 MSearchRow:搜索(provider 内置防抖)+ 纯文字状态钮 + 图标详搜钮 - 状态筛选/详细搜索改 showMSheet 底部 sheet(详搜字段与桌面完全同集,重置/应用在 actions) - 卡片流徽章换图标变体(含派生态待定价/部分退单/已退单)+ › 箭头,行内操作收进详情 sheet - 确认进价/确认售价窄屏走 showMSheet;窄屏详情操作组不出打印 - 新增 390×844 三主题 golden(m_stock_in_list/m_stock_out_list);重生成既有 390×1300 移动 golden 基准(桌面 golden 零漂移) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -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<StockInListScreen> {
|
||||
);
|
||||
|
||||
Future<void> _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<StockInListScreen> {
|
||||
|
||||
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<StockInListScreen> {
|
||||
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<StockInListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 窄屏形态(对齐移动原型 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<void> _openStatusSheet() async {
|
||||
final sel = await showMSheet<String>(
|
||||
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<StockInOrder> orders,
|
||||
@@ -542,8 +751,6 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
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<StockInListScreen> {
|
||||
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<StockInListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 操作按钮列表,表格与移动端卡片共用。入库特有的「打标签」通过 afterPrint 注入。
|
||||
List<Widget> _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<StockInListScreen> {
|
||||
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<StockInListScreen> {
|
||||
]));
|
||||
}
|
||||
|
||||
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<StockInListScreen> {
|
||||
final controllers = {
|
||||
for (final it in pending) it.id!: TextEditingController()
|
||||
};
|
||||
final ok = await showDialog<bool>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
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 = <Map<String, dynamic>>[];
|
||||
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<String, String> 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 = <String, String>{};
|
||||
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<void> _pickDate() async {
|
||||
final range = await showWheelDateRange(context, initial: _range);
|
||||
if (range != null) setState(() => _range = range);
|
||||
}
|
||||
|
||||
List<OptionItem> _supplierOpts() =>
|
||||
ref
|
||||
.watch(allPartnersProvider)
|
||||
.valueOrNull
|
||||
?.data
|
||||
.map((p) => OptionItem(id: p.id, name: p.name))
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
List<OptionItem> _seriesOpts() =>
|
||||
ref
|
||||
.watch(productSeriesListProvider)
|
||||
.valueOrNull
|
||||
?.map((s) => OptionItem(id: s.id, name: s.name))
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
List<OptionItem> _specOpts() =>
|
||||
ref
|
||||
.watch(productSpecListProvider)
|
||||
.valueOrNull
|
||||
?.map((s) => OptionItem(id: s.id, name: s.name))
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
List<OptionItem> _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<OptionItem> 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<OptionItem> opts,
|
||||
required int? selectedId,
|
||||
required String hint,
|
||||
required ValueChanged<int?> 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<StockOutListScreen> {
|
||||
.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<StockOutListScreen> {
|
||||
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<StockOutListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 窄屏形态(对齐移动原型 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<void> _openStatusSheet() async {
|
||||
final sel = await showMSheet<String>(
|
||||
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<StockOutOrder> 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<StockOutListScreen> {
|
||||
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<StockOutListScreen> {
|
||||
onPressed: _resetFilters,
|
||||
);
|
||||
|
||||
// 筛选 chip(不含重置)——重置在桌面推到最右、移动端并入 Wrap 末尾(对齐原型 .sp)。
|
||||
// 筛选 chip(不含重置)——重置推到最右(对齐原型 .sp)。
|
||||
final filterChips = <Widget>[
|
||||
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<StockOutListScreen> {
|
||||
// ── 详细搜索模态(11 字段)──────────────────────────────────────
|
||||
|
||||
Future<void> _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<Widget> _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<StockOutListScreen> {
|
||||
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<StockOutListScreen> {
|
||||
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<StockOutListScreen> {
|
||||
]));
|
||||
}
|
||||
|
||||
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<StockOutListScreen> {
|
||||
final controllers = {
|
||||
for (final it in pending) it.id!: TextEditingController()
|
||||
};
|
||||
final ok = await showDialog<bool>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
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 = <Map<String, dynamic>>[];
|
||||
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<void> _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<OptionItem> 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<OptionItem> opts,
|
||||
required int? selectedId,
|
||||
required String hint,
|
||||
required ValueChanged<int?> 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 159 KiB |
@@ -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<PageResult<StockInOrder>> 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<Override> _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,
|
||||
);
|
||||
}
|
||||
@@ -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<PageResult<StockOutOrder>> 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<Override> _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,
|
||||
);
|
||||
}
|
||||