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>
This commit is contained in:
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user