diff --git a/client/lib/models/stock_summary.dart b/client/lib/models/stock_summary.dart new file mode 100644 index 0000000..f598f44 --- /dev/null +++ b/client/lib/models/stock_summary.dart @@ -0,0 +1,33 @@ +/// 出/入库 KPI 汇总(本月 + 上月同期供环比 + 待审核)。 +/// 对应后端 `GET /stock-in/summary`、`/stock-out/summary`。 +class StockSummary { + final int monthCount; + final double monthAmount; + final int pendingCount; + final int lastMonthCount; + final double lastMonthAmount; + + const StockSummary({ + this.monthCount = 0, + this.monthAmount = 0, + this.pendingCount = 0, + this.lastMonthCount = 0, + this.lastMonthAmount = 0, + }); + + factory StockSummary.fromJson(Map j) => StockSummary( + monthCount: (j['month_count'] as num?)?.toInt() ?? 0, + monthAmount: (j['month_amount'] as num?)?.toDouble() ?? 0, + pendingCount: (j['pending_count'] as num?)?.toInt() ?? 0, + lastMonthCount: (j['last_month_count'] as num?)?.toInt() ?? 0, + lastMonthAmount: (j['last_month_amount'] as num?)?.toDouble() ?? 0, + ); + + /// 笔数环比(%);上月为 0 时返回 null(无从比较)。 + double? get countDeltaPct => + lastMonthCount == 0 ? null : (monthCount - lastMonthCount) / lastMonthCount * 100; + + /// 金额环比(%);上月为 0 时返回 null。 + double? get amountDeltaPct => + lastMonthAmount == 0 ? null : (monthAmount - lastMonthAmount) / lastMonthAmount * 100; +} diff --git a/client/lib/providers/partner_provider.dart b/client/lib/providers/partner_provider.dart index 17458dc..6774f82 100644 --- a/client/lib/providers/partner_provider.dart +++ b/client/lib/providers/partner_provider.dart @@ -23,14 +23,23 @@ final customerListProvider = () => PartnerListNotifier(type: 'customer'), ); +/// 工具栏/详细搜索的往来单位下拉专用:不分 supplier/customer、一次取全部。 +/// 说明:历史 partner 的 type 多为默认 'supplier'(customer 常为空), +/// 按 type 过滤会漏项甚至空列表;下拉按「全部往来单位」呈现最稳。 +final allPartnersProvider = + AsyncNotifierProvider>( + () => PartnerListNotifier(type: null, pageSize: 1000), +); + class PartnerListNotifier extends AsyncNotifier> { final String? type; int _page = 1; - int _pageSize = AppConstants.defaultPageSize; + int _pageSize; String _keyword = ''; PageResult? _cache; - PartnerListNotifier({this.type}); + PartnerListNotifier({this.type, int pageSize = AppConstants.defaultPageSize}) + : _pageSize = pageSize; @override Future> build() async { diff --git a/client/lib/providers/stock_in_provider.dart b/client/lib/providers/stock_in_provider.dart index 1f9383c..5837584 100644 --- a/client/lib/providers/stock_in_provider.dart +++ b/client/lib/providers/stock_in_provider.dart @@ -6,6 +6,7 @@ import '../core/auth/auth_state.dart'; import '../core/config/app_constants.dart'; import '../core/models/page_result.dart'; import '../models/stock_in.dart'; +import '../models/stock_summary.dart'; import '../repositories/stock_in_repository.dart'; import 'connectivity_provider.dart'; import 'inventory_provider.dart'; @@ -19,6 +20,13 @@ final stockInListProvider = StockInListNotifier.new, ); +/// 入库 KPI 汇总(换店/网络恢复自动刷新)。 +final stockInSummaryProvider = FutureProvider.autoDispose((ref) { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + ref.watch(networkRecoveryCountProvider); + return ref.read(stockInRepositoryProvider).summary(); +}); + class StockInListNotifier extends AsyncNotifier> { int _page = 1; int _pageSize = AppConstants.defaultPageSize; @@ -26,6 +34,7 @@ class StockInListNotifier extends AsyncNotifier> { String? _startDate; String? _endDate; String _keyword = ''; + Map _detail = const {}; PageResult? _cache; Timer? _searchDebounce; @@ -50,11 +59,21 @@ class StockInListNotifier extends AsyncNotifier> { startDate: _startDate, endDate: _endDate, keyword: _keyword.isEmpty ? null : _keyword, + detail: _detail.isEmpty ? null : _detail, page: _page, pageSize: _pageSize, ); } + Map get currentDetail => _detail; + + /// 详细搜索多字段(order_no/partner_id/series/spec/category_id/... → 后端组合 AND)。 + void setDetail(Map detail) { + _detail = detail; + _page = 1; + reload(); + } + void setPage(int page) { _page = page; reload(); @@ -98,7 +117,9 @@ class StockInListNotifier extends AsyncNotifier> { } void reload() { - state = const AsyncValue.loading(); + // 保留上一次数据(isReloading)→ 屏幕可继续渲染旧内容 + 叠加半透明进度,不白屏。 + state = const AsyncValue>.loading() + .copyWithPrevious(state); _fetch().then((result) { _cache = result; state = AsyncValue.data(result); diff --git a/client/lib/providers/stock_out_provider.dart b/client/lib/providers/stock_out_provider.dart index a1668c4..7820436 100644 --- a/client/lib/providers/stock_out_provider.dart +++ b/client/lib/providers/stock_out_provider.dart @@ -6,6 +6,7 @@ import '../core/auth/auth_state.dart'; import '../core/config/app_constants.dart'; import '../core/models/page_result.dart'; import '../models/stock_out.dart'; +import '../models/stock_summary.dart'; import '../repositories/stock_out_repository.dart'; import 'connectivity_provider.dart'; import 'inventory_provider.dart'; @@ -19,6 +20,13 @@ final stockOutListProvider = StockOutListNotifier.new, ); +/// 出库 KPI 汇总(换店/网络恢复自动刷新)。 +final stockOutSummaryProvider = FutureProvider.autoDispose((ref) { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + ref.watch(networkRecoveryCountProvider); + return ref.read(stockOutRepositoryProvider).summary(); +}); + class StockOutListNotifier extends AsyncNotifier> { int _page = 1; int _pageSize = AppConstants.defaultPageSize; @@ -27,6 +35,7 @@ class StockOutListNotifier extends AsyncNotifier> { String? _endDate; String _keyword = ''; String _productCode = ''; + Map _detail = const {}; PageResult? _cache; Timer? _searchDebounce; @@ -52,6 +61,7 @@ class StockOutListNotifier extends AsyncNotifier> { endDate: _endDate, keyword: _keyword.isEmpty ? null : _keyword, productCode: _productCode.isEmpty ? null : _productCode, + detail: _detail.isEmpty ? null : _detail, page: _page, pageSize: _pageSize, ); @@ -106,8 +116,19 @@ class StockOutListNotifier extends AsyncNotifier> { reload(); } + Map get currentDetail => _detail; + + /// 详细搜索多字段(order_no/partner_id/series/spec/category_id/... → 后端组合 AND)。 + void setDetail(Map detail) { + _detail = detail; + _page = 1; + reload(); + } + void reload() { - state = const AsyncValue.loading(); + // 保留上一次数据(isReloading)→ 屏幕可继续渲染旧内容 + 叠加半透明进度,不白屏。 + state = const AsyncValue>.loading() + .copyWithPrevious(state); _fetch().then((result) { _cache = result; state = AsyncValue.data(result); @@ -156,4 +177,10 @@ class StockOutListNotifier extends AsyncNotifier> { reload(); ref.invalidate(inventoryListProvider); } + + /// 确认售价(先出后定价):items 为 [{item_id, sale_price}]。 + Future confirmSale(int id, List> items) async { + await ref.read(stockOutRepositoryProvider).confirmSale(id, items); + reload(); + } } diff --git a/client/lib/repositories/stock_in_repository.dart b/client/lib/repositories/stock_in_repository.dart index 293c52a..5050018 100644 --- a/client/lib/repositories/stock_in_repository.dart +++ b/client/lib/repositories/stock_in_repository.dart @@ -3,6 +3,7 @@ import '../core/api/api_client.dart'; import '../core/exceptions.dart'; import '../core/models/page_result.dart'; import '../models/stock_in.dart'; +import '../models/stock_summary.dart'; class StockInRepository { final ApiClient _client; @@ -14,6 +15,7 @@ class StockInRepository { String? startDate, String? endDate, String? keyword, + Map? detail, int page = 1, int pageSize = 20, }) async { @@ -25,6 +27,7 @@ class StockInRepository { if (startDate != null) 'start_date': startDate, if (endDate != null) 'end_date': endDate, if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, + if (detail != null) ...detail, // 详细搜索多字段 }; final resp = await _client.get('/stock-in/orders', params: params); return PageResult.fromJson( @@ -39,6 +42,19 @@ class StockInRepository { } } + /// 入库 KPI 汇总(本月笔数/金额 + 上月同期 + 待审核)。 + Future summary() async { + try { + final resp = await _client.get('/stock-in/summary'); + return StockSummary.fromJson(resp.data as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取入库汇总失败', + statusCode: e.response?.statusCode, + ); + } + } + Future get(int id) async { try { final resp = await _client.get('/stock-in/orders/$id'); diff --git a/client/lib/repositories/stock_out_repository.dart b/client/lib/repositories/stock_out_repository.dart index a5f88c5..f3a1b05 100644 --- a/client/lib/repositories/stock_out_repository.dart +++ b/client/lib/repositories/stock_out_repository.dart @@ -3,6 +3,7 @@ import '../core/api/api_client.dart'; import '../core/exceptions.dart'; import '../core/models/page_result.dart'; import '../models/stock_out.dart'; +import '../models/stock_summary.dart'; class StockOutRepository { final ApiClient _client; @@ -15,6 +16,7 @@ class StockOutRepository { String? endDate, String? keyword, String? productCode, + Map? detail, int page = 1, int pageSize = 20, }) async { @@ -28,6 +30,7 @@ class StockOutRepository { if (keyword != null && keyword.isNotEmpty) 'keyword': keyword, if (productCode != null && productCode.isNotEmpty) 'product_code': productCode, + if (detail != null) ...detail, // 详细搜索多字段 }; final resp = await _client.get('/stock-out/orders', params: params); return PageResult.fromJson( @@ -42,6 +45,32 @@ class StockOutRepository { } } + /// 出库 KPI 汇总(本月笔数/金额 + 上月同期 + 待审核)。 + Future summary() async { + try { + final resp = await _client.get('/stock-out/summary'); + return StockSummary.fromJson(resp.data as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取出库汇总失败', + statusCode: e.response?.statusCode, + ); + } + } + + /// 确认售价(先出后定价):items 为 [{item_id, sale_price}]。 + Future confirmSale(int id, List> items) async { + try { + await _client.post('/stock-out/orders/$id/confirm-sale', + data: {'items': items}); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '确认售价失败', + statusCode: e.response?.statusCode, + ); + } + } + Future get(int id) async { try { final resp = await _client.get('/stock-out/orders/$id'); diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 3b24a98..00b3953 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -1,5 +1,4 @@ import '../../core/utils/dialog_util.dart'; -import '../../core/errors/error_reporter.dart'; import '../../core/exceptions.dart'; import '../../core/utils/print_util.dart' show LabelData; import '../../widgets/label_preview_dialog.dart'; @@ -7,29 +6,35 @@ import '../../widgets/order_print_preview_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/theme/app_dims.g.dart'; import '../../models/stock_in.dart'; +import '../../models/stock_summary.dart'; import '../../providers/stock_in_provider.dart'; -import '../../repositories/stock_in_repository.dart'; -import '../../widgets/data_table_card.dart'; +import '../../widgets/ds/ds_atoms.dart'; +import '../../widgets/ds/ds_kpi.dart'; +import '../../widgets/ds/ds_table.dart'; import '../../widgets/mobile_list_card.dart'; -import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; -import '../../core/storage/column_prefs.dart'; -import '../../widgets/page_scaffold.dart'; +import '../../widgets/searchable_option_field.dart'; +import '../../widgets/combo_search_field.dart'; import '../../widgets/status_badge.dart'; -import '../../widgets/search_chip.dart'; import '../../core/utils/export_util.dart'; -import '../../providers/inventory_provider.dart'; -import '../../providers/tab_state_provider.dart'; +import '../../providers/partner_provider.dart' show allPartnersProvider; +import '../../providers/warehouse_provider.dart' show warehouseListProvider; +import '../../providers/user_provider.dart' show userListProvider; +import '../../providers/product_option_provider.dart' + show productSeriesListProvider, productSpecListProvider; import '../../providers/product_provider.dart' show productRepositoryProvider; -import '../../repositories/product_repository.dart'; import '../../providers/finance_provider.dart' show financeRepositoryProvider; 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'; import '../../core/auth/auth_state.dart' show isAdminProvider, currentUserIdProvider; @@ -41,45 +46,59 @@ class StockInListScreen extends ConsumerStatefulWidget { } class _StockInListScreenState extends ConsumerState { - String _statusFilter = ''; - DateTimeRange? _dateRange; - Set _filterWarehouse = {}; - Set _filterSupplier = {}; - Set? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) final _searchCtrl = TextEditingController(); - String _appliedKw = ''; // 已生效的搜索词(框内 chip 标签展示) - - static const _screenId = 'stock_in_list'; + String _statusFilter = ''; // '' = 全部 + DateTimeRange? _dateRange; + String _datePresetLabel = ''; // 入库时间 chip 显示的预设名(近7天/本月/自定义…) + String? _warehouseName; // 仓库客户端筛选(单选) + // 详细搜索多字段(含 partner_id,与工具栏供应商联动)。key 用后端参数名。 + Map _detail = const {}; + // 表格列(照原型顺序,固定列,无列设置)。 static const _colDefs = [ - ColDef('order_no', '入库单号', required: true), - ColDef('supplier', '供应商', minWidth: 900), - ColDef('warehouse', '仓库'), - ColDef('amount', '金额', minWidth: 800), - ColDef('status', '状态'), - ColDef('date', '入库时间', minWidth: 900), - ColDef('operator', '入库员', minWidth: 1100), - ColDef('reviewer', '审核员', minWidth: 1100), - ColDef('actions', '操作', required: true), + ('order_no', '单号'), + ('supplier', '供应商'), + ('warehouse', '仓库'), + ('amount', '合计金额'), + ('status', '状态'), + ('date', '入库时间'), + ('operator', '入库员'), + ('reviewer', '审核员'), + ('actions', '操作'), ]; + static const _statusOptions = [ + ('', '全部'), + ('draft', '草稿'), + ('pending', '待审核'), + ('approved', '已审核'), + ('rejected', '已拒绝'), + // 退单状态(return_state):与主状态互斥,走 detail 服务端过滤。 + ('ret:partial', '部分退单'), + ('ret:full', '已退单'), + ]; + + // 单视图已知主状态;未知值(如旧版残留的 'pending,draft' 多值)进页面时归零。 + static const _knownStatuses = {'', 'draft', 'pending', 'approved', 'rejected'}; + @override void initState() { super.initState(); - ColumnPrefs.load(_screenId).then((saved) { - if (saved != null && mounted) setState(() => _hiddenCols = saved); - }); - // 进入页面时把服务端状态过滤对齐到当前标签/下拉(provider 持久化,State 会重建) + // 进入页面时把「持久化 provider 里已生效的筛选」回填到 UI, + // 避免残留关键词/状态/详细搜索「看不见却仍在发」导致列表恒空(与出库屏一致)。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final tab = ref.read(stockInTabProvider); - final want = tab == 1 ? 'pending,draft' : _statusFilter; - final notifier = ref.read(stockInListProvider.notifier); - if (notifier.currentStatus != want) notifier.setStatus(want); - // 同步已生效的搜索词到框内 chip(provider 持久化,切走再回来不丢) - if (notifier.currentKeyword != _appliedKw) { - setState(() => _appliedKw = notifier.currentKeyword); + final n = ref.read(stockInListProvider.notifier); + if (!_knownStatuses.contains(n.currentStatus)) { + n.setStatus(''); // 归零无法在单视图呈现的残留状态 + } else { + _statusFilter = n.currentStatus; } + _searchCtrl.text = n.currentKeyword; + _detail = Map.of(n.currentDetail); + final rs = _detail['return_state']; + if (rs != null && rs.isNotEmpty) _statusFilter = 'ret:$rs'; + setState(() {}); }); } @@ -90,17 +109,7 @@ class _StockInListScreenState extends ConsumerState { } void _triggerSearch() { - final kw = _searchCtrl.text.trim(); - ref.read(stockInListProvider.notifier).setKeyword(kw); - // 仅支持单个搜索词:生效后输入框清空,搜索词转为框内 chip 标签 - setState(() => _appliedKw = kw); - if (kw.isNotEmpty) _searchCtrl.clear(); - } - - void _clearSearch() { - _searchCtrl.clear(); - setState(() => _appliedKw = ''); - ref.read(stockInListProvider.notifier).setKeyword(''); + ref.read(stockInListProvider.notifier).setKeyword(_searchCtrl.text.trim()); } String? get _startDate => _dateRange != null @@ -111,6 +120,14 @@ class _StockInListScreenState extends ConsumerState { ? '${_dateRange!.end.year}-${_dateRange!.end.month.toString().padLeft(2, '0')}-${_dateRange!.end.day.toString().padLeft(2, '0')}' : null; + String get _statusLabelOf { + return _statusOptions + .where((o) => o.$1 == _statusFilter) + .map((o) => o.$2) + .firstOrNull ?? + '全部'; + } + OrderStatus _apiStatusToEnum(String status) { switch (status) { case 'pending': @@ -124,45 +141,167 @@ class _StockInListScreenState extends ConsumerState { } } - @override - Widget build(BuildContext context) { - return PageScaffold( - title: '入库管理', - initialTab: ref.read(stockInTabProvider), - onTabChanged: (i) { - ref.read(stockInTabProvider.notifier).state = i; - // 审核标签页按 pending+draft 服务端拉取;入库单页沿用下拉状态('' 为全部) - ref - .read(stockInListProvider.notifier) - .setStatus(i == 1 ? 'pending,draft' : _statusFilter); - }, - tabs: const [ - Tab(text: '入库单'), - Tab(text: '入库审核'), - ], - tabViews: [ - _buildListTab(filterStatus: 'exclude_pending', showNewButton: false), - _buildListTab(filterStatus: 'pending', showNewButton: true), - ], - ); + void _setStatus(String s) { + setState(() => _statusFilter = s); + final n = ref.read(stockInListProvider.notifier); + final d = Map.of(_detail); + if (s.startsWith('ret:')) { + // 退单状态:走 return_state 服务端过滤,主状态清空(二者互斥)。 + d['return_state'] = s.substring(4); + _detail = d; + n.setStatus(''); + n.setDetail(d); + } else { + d.remove('return_state'); + _detail = d; + n.setStatus(s); + n.setDetail(d); + } } - Widget _buildListTab({String? filterStatus, required bool showNewButton}) { + void _setDetailPartner(int? id) { + final d = Map.of(_detail); + if (id == null) { + d.remove('partner_id'); + } else { + d['partner_id'] = id.toString(); + } + setState(() => _detail = d); + ref.read(stockInListProvider.notifier).setDetail(d); + } + + void _resetFilters() { + setState(() { + _searchCtrl.clear(); + _statusFilter = ''; + _dateRange = null; + _datePresetLabel = ''; + _warehouseName = null; + _detail = const {}; + }); + final n = ref.read(stockInListProvider.notifier); + n.setKeyword(''); + n.setStatus(''); + n.setDateRange(null, null); + n.setDetail(const {}); + } + + Future _pickDateRange() async { + final range = await showWheelDateRange(context, initial: _dateRange); + if (range != null) { + setState(() { + _dateRange = range; + _datePresetLabel = '$_startDate ~ $_endDate'; + }); + ref.read(stockInListProvider.notifier).setDateRange(_startDate, _endDate); + } + } + + /// 入库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。 + void _setDatePreset(String preset) { + if (preset == 'custom') { + _pickDateRange(); + return; + } + final now = DateTime.now(); + DateTimeRange? range; + String label; + switch (preset) { + case '7': + range = DateTimeRange( + start: now.subtract(const Duration(days: 6)), end: now); + label = '近 7 天'; + break; + case '30': + range = DateTimeRange( + start: now.subtract(const Duration(days: 29)), end: now); + label = '近 30 天'; + break; + case 'month': + range = DateTimeRange(start: DateTime(now.year, now.month, 1), end: now); + label = '本月'; + break; + default: // all + range = null; + label = ''; + } + setState(() { + _dateRange = range; + _datePresetLabel = label; + }); + ref.read(stockInListProvider.notifier).setDateRange(_startDate, _endDate); + } + + Widget _dateMenu(BuildContext ctx) => PopupMenuButton( + onSelected: _setDatePreset, + offset: const Offset(0, 40), + color: ctx.tokens.surface, + elevation: 8, + shape: RoundedRectangleBorder( + side: BorderSide(color: ctx.tokens.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + itemBuilder: (_) => const [ + PopupMenuItem(value: 'all', height: 38, child: Text('全部时间')), + PopupMenuItem(value: '7', height: 38, child: Text('近 7 天')), + PopupMenuItem(value: '30', height: 38, child: Text('近 30 天')), + PopupMenuItem(value: 'month', height: 38, child: Text('本月')), + PopupMenuItem(value: 'custom', height: 38, child: Text('自定义…')), + ], + child: DsChip( + label: '入库时间', + value: _datePresetLabel.isEmpty ? null : _datePresetLabel, + onClear: () => _setDatePreset('all')), + ); + + Future _openAdvSearch() async { + final res = await showAppDialog<_AdvResult>( + context: context, + builder: (_) => _AdvSearchDialog( + initialDetail: _detail, + initialStatus: _statusFilter, + initialRange: _dateRange, + ), + ); + if (res == null || !mounted) return; + setState(() { + _detail = res.detail; + _statusFilter = res.status; + _dateRange = res.dateRange; + }); + final n = ref.read(stockInListProvider.notifier); + n.setStatus(res.status); + n.setDateRange(_startDate, _endDate); + n.setDetail(res.detail); + } + + int get _advCount { + var c = _detail.entries.where((e) => e.value.trim().isNotEmpty).length; + // 主状态也算一项(退单状态已作为 _detail.return_state 计入,故排除 ret:)。 + if (_statusFilter.isNotEmpty && !_statusFilter.startsWith('ret:')) c++; + return c; + } + + @override + Widget build(BuildContext context) { + // 照原型 stock-in-list.html:单视图(无 tab)。审核并入状态筛选 + KPI「待审核」卡点击筛选。 final asyncOrders = ref.watch(stockInListProvider); return asyncOrders.when( + // 搜索/筛选触发的 reload 保留旧数据,走 data 分支叠加半透明进度,不整屏白屏。 + skipLoadingOnReload: true, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.cloud_off, size: 40, color: context.tokens.muted), - const SizedBox(height: 12), - Text('暂无数据,网络不可用', - style: TextStyle(color: context.tokens.muted)), + Icon(LucideIcons.cloudOff, size: 40, color: context.tokens.muted), + const SizedBox(height: 12), + Text('加载失败:$e', + style: TextStyle(color: context.tokens.muted), + textAlign: TextAlign.center), const SizedBox(height: 12), ElevatedButton( - onPressed: () => - ref.read(stockInListProvider.notifier).reload(), + onPressed: () => ref.read(stockInListProvider.notifier).reload(), child: const Text('重试'), ), ], @@ -170,371 +309,416 @@ class _StockInListScreenState extends ConsumerState { ), data: (result) { final allOrders = result.data; - // 状态过滤已改为服务端驱动(setStatus)。这里仅按标签做轻量显示守卫, - // 避免共用 provider 在标签切换、refetch 在途时短暂闪现他状态数据。 - final List statusFiltered; - if (filterStatus == 'pending') { - // 入库审核:服务端按 pending,draft 拉取 - statusFiltered = allOrders - .where((o) => o.status == 'draft' || o.status == 'pending') - .toList(); - } else if (filterStatus == 'exclude_pending') { - // 入库单:服务端按下拉状态拉取;'全部状态' 显示全部(含未审核) - statusFiltered = _statusFilter.isEmpty - ? allOrders - : allOrders.where((o) => o.status == _statusFilter).toList(); - } else { - statusFiltered = allOrders; - } + // 仓库客户端筛选(服务端未提供仓库过滤参数)。 + final orders = _warehouseName == null + ? allOrders + : allOrders + .where((o) => (o.warehouseName ?? '') == _warehouseName) + .toList(); - // Derive filter options from all loaded orders - final warehouseOptions = allOrders - .map((o) => o.warehouseName ?? '') - .where((s) => s.isNotEmpty) - .toSet() - .toList() - ..sort(); - final supplierOptions = allOrders - .map((o) => o.partnerName ?? '') - .where((s) => s.isNotEmpty) - .toSet() - .toList() + final warehouseOptions = (ref + .watch(warehouseListProvider) + .valueOrNull + ?.map((w) => w.name) + .toList() ?? + []) ..sort(); + final supplierOptions = ref + .watch(allPartnersProvider) + .valueOrNull + ?.data + .map((p) => OptionItem(id: p.id, name: p.name)) + .toList() ?? + []; + final selectedSupplierId = _detail['partner_id'] != null + ? int.tryParse(_detail['partner_id']!) + : null; - // Apply multi-select filters - var orders = statusFiltered; - if (_filterWarehouse.isNotEmpty) { - orders = orders - .where((o) => _filterWarehouse.contains(o.warehouseName ?? '')) - .toList(); - } - if (_filterSupplier.isNotEmpty) { - orders = orders - .where((o) => _filterSupplier.contains(o.partnerName ?? '')) - .toList(); - } + final summary = ref.watch(stockInSummaryProvider).valueOrNull; - return _buildOrderTable( - orders: orders, - totalCount: result.total, - page: result.page, - pageSize: result.pageSize, - showStatusFilter: filterStatus == 'exclude_pending', - showNewButton: showNewButton, - warehouseOptions: warehouseOptions, - supplierOptions: supplierOptions, + final content = Column( + children: [ + _buildHeader(result.total, summary), + _buildKpis(summary, result.total), + const Divider(height: 1), + Expanded( + child: DsTable( + total: result.total, + page: result.page, + pageSize: result.pageSize, + onPageChanged: (p) => + ref.read(stockInListProvider.notifier).setPage(p), + onPageSizeChanged: (s) => + ref.read(stockInListProvider.notifier).setPageSize(s), + emptyText: '没有匹配的入库单 · 试试调整筛选或搜索', + toolbar: _buildToolbar( + orders: orders, + warehouseOptions: warehouseOptions, + supplierOptions: supplierOptions, + selectedSupplierId: selectedSupplierId, + ), + mobileCards: orders.map((o) => _orderCard(context, o)).toList(), + columns: _colDefs + .map((c) => DsColumn(c.$1, c.$2, + numeric: c.$1 == 'amount', action: c.$1 == 'actions')) + .toList(), + rows: orders + .map((o) => DsRow( + onTap: () => _showDetail(context, o.id), + cells: _colDefs + .map((c) => _buildCell(c.$1, o)) + .toList(), + )) + .toList(), + ), + ), + ], + ); + return Stack( + children: [ + content, + // 搜索/筛选 reload 中:叠加半透明进度,保留旧内容不白屏。 + if (asyncOrders.isLoading) const Positioned.fill(child: DsLoadingScrim()), + ], ); }, ); } - Widget _buildOrderTable({ - required List orders, - required int totalCount, - required int page, - required int pageSize, - required bool showStatusFilter, - required bool showNewButton, - required List warehouseOptions, - required List supplierOptions, - }) { - final screenWidth = MediaQuery.of(context).size.width; - // 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。 - final hidden = _hiddenCols ?? - _colDefs - .where((c) => c.minWidth != null && screenWidth < c.minWidth!) - .map((c) => c.key) - .toSet(); - // 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。 - final visibleCols = - _colDefs.where((c) => !hidden.contains(c.key)).toList(); - - final columns = visibleCols.map((c) { - final label = switch (c.key) { - 'warehouse' => FilterableColumnHeader( - text: c.label, - options: warehouseOptions, - selected: _filterWarehouse, - onChanged: (v) => setState(() => _filterWarehouse = v), - ), - 'supplier' => FilterableColumnHeader( - text: c.label, - options: supplierOptions, - selected: _filterSupplier, - onChanged: (v) => setState(() => _filterSupplier = v), - ), - _ => Text(c.label), - }; - return DataColumn(label: label, numeric: c.key == 'amount'); - }).toList(); - - DataCell buildOrderCell(String key, StockInOrder o) { - switch (key) { - case 'order_no': - return DataCell(GestureDetector( - onTap: () => _showDetail(context, o.id), - child: Text(o.orderNo, - style: TextStyle( - color: context.tokens.primary, - fontFamily: 'monospace', - fontSize: 12, - decoration: TextDecoration.underline)), - )); - case 'supplier': - return DataCell(Text(o.partnerName ?? '-')); - case 'warehouse': - return DataCell(Text(o.warehouseName ?? '-')); - case 'amount': - return DataCell(Text(o.totalAmount != null - ? '¥${o.totalAmount!.toStringAsFixed(2)}' - : '-')); - case 'status': - final rb = returnStateBadge(context, o.returnState); - return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [ - StatusBadge(_apiStatusToEnum(o.status)), - if (rb != null) ...[const SizedBox(width: 4), rb], - ])); - case 'date': - return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); - case 'operator': - return DataCell(Text(o.operatorName ?? '-', - style: const TextStyle(fontSize: 13))); - case 'reviewer': - return DataCell(Text(o.reviewerName ?? '-', - style: const TextStyle(fontSize: 13))); - case 'actions': - return DataCell(SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: _orderActions(context, o), - ), - )); - default: - return const DataCell(SizedBox()); - } - } - - final rows = orders.isEmpty - ? [ - DataRow( - cells: List.generate( - visibleCols.length, - (i) => i == 0 - ? DataCell(Text('暂无入库单', - style: TextStyle(color: context.tokens.muted))) - : const DataCell(SizedBox()), - ), - ), - ] - : orders - .map((o) => DataRow( - cells: visibleCols - .map((c) => buildOrderCell(c.key, o)) - .toList(), - )) - .toList(); - - return Column( - children: [ - // 副标(还原原型 .head .sub) - Container( - width: double.infinity, - color: context.tokens.bg, - padding: const EdgeInsets.fromLTRB( - AppDims.sp3, AppDims.sp3, AppDims.sp3, 0), - child: Text('共 $totalCount 笔入库单', + // ── 头部(原型 .head):标题 + 副标 + 刷新/导出/新增 ───────────────── + Widget _buildHeader(int total, StockSummary? summary) { + final monthCount = summary?.monthCount ?? total; + return Container( + width: double.infinity, + color: context.tokens.bg, + padding: const EdgeInsets.fromLTRB( + AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('入库管理', style: TextStyle( - fontSize: AppDims.fsSm, color: context.tokens.muted)), - ), - Expanded( - child: DataTableCard( - totalCount: totalCount, - page: page, - pageSize: pageSize, - onPageChanged: (p) => - ref.read(stockInListProvider.notifier).setPage(p), - onPageSizeChanged: (s) => - ref.read(stockInListProvider.notifier).setPageSize(s), - toolbar: Builder(builder: (ctx) { - final isMobile = ctx.isMobile; - void doExport() => exportExcel( - filename: '入库单列表', - headers: ['单号', '仓库', '供应商', '状态', '日期', '总金额'], - rows: orders.map((o) => [ - o.orderNo, o.warehouseName ?? '', o.partnerName ?? '', - o.status, o.orderDate, o.totalAmount, - ]).toList(), - ); - - final newBtn = (showNewButton && !WriteGuard.isReadonly(ref)) - ? WriteGuard( - child: ElevatedButton.icon( + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w700, + color: context.tokens.heading)), + const SizedBox(width: AppDims.sp3), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text('本月 $monthCount 笔入库单', + style: TextStyle( + fontSize: AppDims.fsSm, color: context.tokens.muted)), + ), + const Spacer(), + if (!context.isMobile) ...[ + DsButton( + '刷新', + icon: LucideIcons.refreshCw, + onPressed: () => ref.read(stockInListProvider.notifier).reload(), + ), + const SizedBox(width: AppDims.sp2), + DsButton( + '导出', + icon: LucideIcons.download, + onPressed: _doExport, + ), + const SizedBox(width: AppDims.sp2), + if (!WriteGuard.isReadonly(ref)) + WriteGuard( + child: DsButton( + '新增入库', + icon: LucideIcons.plus, + variant: DsBtnVariant.primary, onPressed: () => context.go('/stock-in/new'), - icon: const Icon(Icons.add, size: 16), - label: Text(isMobile ? '新建' : '新建入库审核单'), - style: isMobile - ? ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ) - : null, ), - ) - : null; - - final statusFilter = showStatusFilter - ? _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref.read(stockInListProvider.notifier).setStatus(v ?? ''); - }, - ) - : null; - - final hasKw = _appliedKw.isNotEmpty; - final searchField = TextField( - controller: _searchCtrl, - decoration: InputDecoration( - hintText: hasKw ? '继续输入新关键词…' : '单号/往来单位/酒名,回车搜索', - hintStyle: const TextStyle(fontSize: 12), - // 搜索图标 +(已生效时)框内 chip 标签,仅一个词;✕ 删除即清除搜索。 - // 用 prefixIcon 而非 prefix:后者仅在聚焦/有文字时才显示,搜索后输入框为空会被隐藏。 - prefixIconConstraints: - const BoxConstraints(minWidth: 0, minHeight: 0), - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 8, right: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.search, size: 16), - if (hasKw) ...[ - const SizedBox(width: 6), - SearchChip(label: _appliedKw, onDeleted: _clearSearch), - ], - ], ), - ), - suffixIcon: IconButton( - icon: const Icon(Icons.search, size: 16), - tooltip: '搜索', - onPressed: _triggerSearch, - ), - ), - onSubmitted: (_) => _triggerSearch(), - ); - - final refreshBtn = IconButton( - tooltip: '刷新', - icon: const Icon(Icons.refresh, size: 20), - onPressed: () => ref.read(stockInListProvider.notifier).reload(), - ); - - final dateBtn = OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), - ); - - final clearDate = _dateRange != null - ? IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref - .read(stockInListProvider.notifier) - .setDateRange(null, null); - }, - ) - : null; - - if (isMobile) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - searchField, - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (newBtn != null) newBtn, - if (statusFilter != null) statusFilter, - dateBtn, - if (clearDate != null) clearDate, - IconButton( - tooltip: '导出', - icon: const Icon(Icons.download, size: 20), - onPressed: doExport, - ), - ColumnToggleButton( - columns: _colDefs, - hidden: hidden, - compact: true, - onChanged: (v) { - setState(() => _hiddenCols = v); - ColumnPrefs.save(_screenId, v); - }, - ), - refreshBtn, - ], - ), - ], - ); - } - - return Row( - children: [ - SizedBox(width: 220, child: searchField), - const SizedBox(width: 12), - if (newBtn != null) newBtn, - const Spacer(), - if (statusFilter != null) ...[ - statusFilter, - const SizedBox(width: 8), - ], - dateBtn, - if (clearDate != null) ...[ - const SizedBox(width: 4), - clearDate, - ], - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: doExport, - icon: const Icon(Icons.download, size: 16), - label: const Text('导出'), - ), - const SizedBox(width: 8), - ColumnToggleButton( - columns: _colDefs, - hidden: hidden, - onChanged: (v) { - setState(() => _hiddenCols = v); - ColumnPrefs.save(_screenId, v); - }, - ), - refreshBtn, ], - ); - }), - columns: columns, - rows: rows, - mobileCards: orders.map((o) => _orderCard(context, o)).toList(), - ), - ), - ], + ], + ), ); } - /// 操作按钮列表,表格与移动端卡片共用。结构见 [buildOrderRowActions], - /// 入库特有的「打标签」通过 afterPrint 注入。 + // ── KPI 卡(原型 .kpis,3 等分):本月笔数 / 本月金额 / 待审核 ────────── + Widget _buildKpis(StockSummary? summary, int total) { + String yuanWan(double v) => v >= 10000 + ? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}万' + : '¥${v.toStringAsFixed(0)}'; + String pctText(double? pct) => + pct == null ? '较上月 —' : '${pct.abs().toStringAsFixed(1)}% 较上月'; + DsKpiDelta pctTone(double? pct) => pct == null + ? DsKpiDelta.neutral + : (pct >= 0 ? DsKpiDelta.up : DsKpiDelta.down); + + final cards = [ + DsKpi( + title: '本月入库笔数', + value: NumberFormat.decimalPattern() + .format(summary?.monthCount ?? total), + icon: LucideIcons.download, + tone: DsKpiTone.info, + delta: pctText(summary?.countDeltaPct), + deltaTone: pctTone(summary?.countDeltaPct), + ), + DsKpi( + title: '本月入库金额', + value: summary != null ? yuanWan(summary.monthAmount) : '—', + icon: LucideIcons.wallet, + tone: DsKpiTone.ok, + delta: pctText(summary?.amountDeltaPct), + deltaTone: pctTone(summary?.amountDeltaPct), + ), + DsKpi( + title: '待审核单数 · 点击筛选', + value: '${summary?.pendingCount ?? 0}', + icon: LucideIcons.clock, + tone: DsKpiTone.alert, + delta: (summary?.pendingCount ?? 0) > 0 ? '需尽快处理' : '点击筛选', + deltaTone: (summary?.pendingCount ?? 0) > 0 + ? DsKpiDelta.down + : DsKpiDelta.neutral, + onTap: () => _setStatus('pending'), + ), + ]; + + final mobile = context.isMobile; + final row = []; + for (var i = 0; i < cards.length; i++) { + if (i > 0) row.add(const SizedBox(width: AppDims.sp3)); + row.add(mobile + ? SizedBox(width: 200, child: cards[i]) + : Expanded(child: cards[i])); + } + return Container( + color: context.tokens.bg, + padding: const EdgeInsets.all(AppDims.sp3), + child: mobile + ? SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: IntrinsicHeight(child: Row(children: row)), + ) + : IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: row)), + ); + } + + // ── 工具栏(原型 .toolbar)───────────────────────────────────────── + Widget _buildToolbar({ + required List orders, + required List warehouseOptions, + required List supplierOptions, + required int? selectedSupplierId, + }) { + return Builder(builder: (ctx) { + final isMobile = ctx.isMobile; + + final searchField = DsSearchBox( + controller: _searchCtrl, + hint: '单号 / 酒名 / 商品编码', + onSubmitted: (_) => _triggerSearch(), + // 清空输入框立即清掉服务端关键词,避免「框空了但仍按残留词过滤」。 + onChanged: (v) { + if (v.trim().isEmpty) _triggerSearch(); + }, + ); + + final supplierField = ComboSearchField( + options: supplierOptions, + selectedId: selectedSupplierId, + hint: '供应商', + onChanged: _setDetailPartner, + width: 180, + ); + + final warehouseChip = PopupMenuButton( + onSelected: (v) => setState(() => _warehouseName = v.isEmpty ? null : v), + offset: const Offset(0, 40), + color: ctx.tokens.surface, + elevation: 8, + shape: RoundedRectangleBorder( + side: BorderSide(color: ctx.tokens.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + itemBuilder: (_) => [ + const PopupMenuItem(value: '', height: 38, child: Text('全部仓库')), + ...warehouseOptions + .map((w) => PopupMenuItem(value: w, height: 38, child: Text(w))), + ], + child: DsChip( + label: '仓库', + value: _warehouseName, + onClear: () => setState(() => _warehouseName = null)), + ); + + final statusChip = PopupMenuButton( + onSelected: _setStatus, + offset: const Offset(0, 40), + color: ctx.tokens.surface, + elevation: 8, + shape: RoundedRectangleBorder( + side: BorderSide(color: ctx.tokens.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + itemBuilder: (_) => _statusOptions + .map((s) => + PopupMenuItem(value: s.$1, height: 38, child: Text(s.$2))) + .toList(), + child: DsChip( + label: '状态', + value: _statusFilter.isEmpty ? null : _statusLabelOf, + onClear: () => _setStatus('')), + ); + + final dateChip = _dateMenu(ctx); + + final advBtn = _AdvSearchButton(count: _advCount, onTap: _openAdvSearch); + + final resetBtn = DsButton( + '重置', + icon: LucideIcons.rotateCcw, + small: true, + 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 布局)。 + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Wrap( + spacing: AppDims.sp2, + runSpacing: AppDims.sp2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox(width: 240, child: searchField), + supplierField, + warehouseChip, + statusChip, + dateChip, + advBtn, + ], + ), + ), + const SizedBox(width: AppDims.sp2), + resetBtn, + ], + ); + }); + } + + void _doExport() { + final data = ref.read(stockInListProvider).valueOrNull?.data ?? []; + final orders = _warehouseName == null + ? data + : data.where((o) => (o.warehouseName ?? '') == _warehouseName).toList(); + exportExcel( + filename: '入库单列表', + headers: const ['单号', '仓库', '供应商', '状态', '日期', '总金额'], + rows: orders + .map((o) => [ + o.orderNo, + o.warehouseName ?? '', + o.partnerName ?? '', + o.status, + o.orderDate, + o.totalAmount, + ]) + .toList(), + ); + } + + // ── 表格单元格 ───────────────────────────────────────────────────── + Widget _buildCell(String key, StockInOrder o) { + final t = context.tokens; + switch (key) { + case 'order_no': + return Text(o.orderNo, + style: TextStyle( + color: t.primary, + fontFamily: 'monospace', + fontSize: AppDims.fsBody)); + case 'supplier': + return Text(o.partnerName ?? '-'); + case 'warehouse': + return Text(o.warehouseName ?? '-'); + case 'amount': + return Text( + o.totalAmount != null + ? '¥${o.totalAmount!.toStringAsFixed(2)}' + : '-', + style: const TextStyle(fontFamily: 'monospace')); + case 'status': + final rb = returnStateBadge(context, o.returnState); + return Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (rb != null) ...[const SizedBox(width: 4), rb], + ]); + case 'date': + return Text(o.orderDate?.substring(0, 10) ?? '-', + style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)); + case 'operator': + return Text(o.operatorName ?? '-'); + case 'reviewer': + return Text(o.reviewerName ?? '-', + style: o.reviewerName == null ? TextStyle(color: t.faint) : null); + case 'actions': + // 原型:操作列只放一个眼睛图标,点开右侧详情抽屉(其余操作在抽屉内)。 + return IconButton( + icon: Icon(LucideIcons.eye, size: 16, color: t.muted), + tooltip: '详情', + splashRadius: 18, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + onPressed: () => _showDetail(context, o.id), + ); + default: + return const SizedBox(); + } + } + + /// 操作按钮列表,表格与移动端卡片共用。入库特有的「打标签」通过 afterPrint 注入。 List _orderActions(BuildContext context, StockInOrder o) { return buildOrderRowActions( context: context, @@ -613,54 +797,291 @@ class _StockInListScreenState extends ConsumerState { MobileCardField('供应商', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), MobileCardField( - '金额', + '合计金额', o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'), - MobileCardField('入库时间', o.orderDate?.substring(0, 10) ?? '-'), + MobileCardField('入库员', o.operatorName ?? '-'), + MobileCardField('审核员', o.reviewerName ?? '-'), ], actions: _orderActions(context, o), ); } + // 点击行/眼睛 → 右侧详情抽屉(对齐原型 openDetail)。 Future _showDetail(BuildContext context, int orderId) async { - showAppDialog( - context: context, - // 用分支 Navigator 承载,全屏弹窗只覆盖内容区,不盖住左侧栏/顶栏/状态栏 - useRootNavigator: false, - builder: (ctx) => _StockInDetailDialog( - orderId: orderId, - repository: ref.read(stockInRepositoryProvider), + await showOrderDetailDrawer( + context, + builder: (ctx) => FutureBuilder( + future: ref.read(stockInRepositoryProvider).get(orderId), + builder: (c, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError || !snap.hasData) { + return Center( + child: Text('暂无数据,网络不可用', + style: TextStyle(color: context.tokens.muted)), + ); + } + return _buildDetailDrawer(snap.data!); + }, ), ); } - Future _pickDateRange() async { - final range = await showDateRangePicker( - context: context, - firstDate: DateTime(2020), - lastDate: DateTime(2030), - initialDateRange: _dateRange, + static final _money = + NumberFormat.currency(locale: 'zh_CN', symbol: '¥', decimalDigits: 0); + static String _fmtQty(double q) => + q == q.roundToDouble() ? q.toStringAsFixed(0) : q.toString(); + + Widget _buildDetailDrawer(StockInOrder o) { + final t = context.tokens; + final rb = returnStateBadge(context, o.returnState); + return OrderDetailDrawer( + title: o.orderNo, + infoRows: [ + (label: '单据日期', value: Text(o.orderDate?.substring(0, 10) ?? '—')), + (label: '供应商', value: Text(o.partnerName ?? '—')), + (label: '仓库', value: Text(o.warehouseName ?? '—')), + (label: '商品数', value: Text('${o.items.length}')), + (label: '入库员', value: Text(o.operatorName ?? '—')), + ( + label: '审核员', + value: Text(o.reviewerName ?? '—', + style: o.reviewerName == null ? TextStyle(color: t.faint) : null) + ), + ( + label: '状态', + value: Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (rb != null) ...[const SizedBox(width: 4), rb], + ]) + ), + ], + linesLabel: '入库明细', + lines: o.items + .map((it) => OrderLine( + name: it.productName ?? '—', + code: it.productCode ?? '—', + series: it.productSeries ?? '—', + spec: it.productSpec ?? '', + qty: _fmtQty(it.quantity), + price: it.unitPrice == 0 ? '待定价' : _money.format(it.unitPrice), + amount: + it.totalPrice == 0 ? '待定价' : _money.format(it.totalPrice), + pending: it.unitPrice == 0, + )) + .toList(), + totalText: o.totalAmount != null ? _money.format(o.totalAmount) : '—', + actionGroups: _detailActionGroups(o), ); - if (range != null) { - setState(() => _dateRange = range); - ref - .read(stockInListProvider.notifier) - .setDateRange(_startDate, _endDate); + } + + List _detailActionGroups(StockInOrder o) { + final readonly = WriteGuard.isReadonly(ref); + final isAdmin = ref.read(isAdminProvider); + final canWithdraw = isAdmin || + (o.operatorId != null && o.operatorId == ref.read(currentUserIdProvider)); + void close() => Navigator.of(context).pop(); + DsButton b(String label, VoidCallback onTap, + {DsBtnVariant v = DsBtnVariant.ghost}) => + DsButton(label, small: true, variant: v, onPressed: onTap); + + final groups = []; + final audit = []; + if (!readonly) { + switch (o.status) { + case 'draft': + audit.add(b('修改', () { + close(); + context.go('/stock-in/edit/${o.id}'); + })); + audit.add(b('删除', () { + close(); + _confirmDelete(context, o); + }, v: DsBtnVariant.danger)); + audit.add(b('提交', () { + close(); + _confirmSubmit(context, o); + }, v: DsBtnVariant.primary)); + break; + case 'pending': + audit.add(b('通过', () { + close(); + _confirmApprove(context, o); + }, v: DsBtnVariant.primary)); + audit.add(b('拒绝', () { + close(); + _confirmReject(context, o); + }, v: DsBtnVariant.danger)); + if (canWithdraw) { + audit.add(b('撤回', () { + close(); + _confirmWithdraw(context, o); + })); + } + break; + case 'approved': + audit.add(b('结清', () { + close(); + _confirmSettle(context, o.id, 'stock_in'); + })); + if (isAdmin) { + audit.add(b('退单', () { + close(); + _confirmReturn(context, o); + }, v: DsBtnVariant.danger)); + } + break; + } + } + if (audit.isNotEmpty) groups.add(DrawerActionGroup('单据审核', audit)); + + final pending = o.items.where((it) => it.unitPrice == 0).length; + if (o.status == 'approved' && isAdmin && pending > 0) { + groups.add(DrawerActionGroup('成本 · $pending 项待定价', [ + b('确认进价', () { + close(); + _confirmCost(o); + }, v: DsBtnVariant.primary), + ])); + } + + 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; + } + + Future _printLabels(StockInOrder o) async { + final order = await ref.read(stockInRepositoryProvider).get(o.id); + if (!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, + ), + ); + } + + /// 确认进价:为 0 价(待定)明细补填真实进价,调后端前向补偿后刷新列表。 + Future _confirmCost(StockInOrder o) async { + final pending = + o.items.where((it) => it.unitPrice == 0 && it.id != null).toList(); + if (pending.isEmpty) return; + final controllers = {for (final it in pending) it.id!: TextEditingController()}; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认进价'), + content: SizedBox( + width: context.dialogWidth(440), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: pending.map((it) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Text( + '${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}', + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 120, + child: TextField( + controller: controllers[it.id], + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + decoration: const InputDecoration( + prefixText: '¥', hintText: '进价', isDense: true), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认')), + ], + ), + ); + final items = >[]; + if (ok == true) { + for (final it in pending) { + final v = double.tryParse(controllers[it.id]!.text.trim()); + if (v != null && v > 0) items.add({'item_id': it.id, 'unit_price': v}); + } + } + for (final c in controllers.values) { + c.dispose(); + } + if (ok != true || items.isEmpty) return; + try { + await ref.read(stockInRepositoryProvider).confirmCost(o.id, items); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('进价已确认'))); + ref.read(stockInListProvider.notifier).reload(); + } on AppException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('确认失败:${e.message}'))); } } - Future _confirmSettle(BuildContext context, int orderId, String refType) async { + Future _confirmSettle( + BuildContext context, int orderId, String refType) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('结清确认'), content: const Text('确认将该单据的账款标记为已结清?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: Text('确认结清', style: TextStyle(color: context.tokens.accent)), + child: + Text('确认结清', style: TextStyle(color: context.tokens.accent)), ), ], ), @@ -670,13 +1091,17 @@ class _StockInListScreenState extends ConsumerState { await ref.read(financeRepositoryProvider).closeByRef(refType, orderId); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: const Text('已结清'), backgroundColor: context.tokens.success), + SnackBar( + content: const Text('已结清'), + backgroundColor: context.tokens.success), ); } } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString()), backgroundColor: context.tokens.danger), + SnackBar( + content: Text(e.toString()), + backgroundColor: context.tokens.danger), ); } } @@ -707,7 +1132,8 @@ class _StockInListScreenState extends ConsumerState { await ref.read(stockInListProvider.notifier).deleteOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('已删除'), backgroundColor: context.tokens.success)); + content: const Text('已删除'), + backgroundColor: context.tokens.success)); } } catch (e) { if (mounted) { @@ -737,9 +1163,7 @@ class _StockInListScreenState extends ConsumerState { ); if (confirmed == true && mounted) { try { - await ref - .read(stockInListProvider.notifier) - .submitOrder(o.id); + await ref.read(stockInListProvider.notifier).submitOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: const Text('已提交审核'), @@ -777,12 +1201,11 @@ class _StockInListScreenState extends ConsumerState { ); if (confirmed == true && mounted) { try { - await ref - .read(stockInListProvider.notifier) - .approveOrder(o.id); + await ref.read(stockInListProvider.notifier).approveOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('审核通过'), backgroundColor: context.tokens.success)); + content: const Text('审核通过'), + backgroundColor: context.tokens.success)); } } catch (e) { if (mounted) { @@ -819,7 +1242,8 @@ class _StockInListScreenState extends ConsumerState { await ref.read(stockInListProvider.notifier).rejectOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('已拒绝'), backgroundColor: context.tokens.accent)); + content: const Text('已拒绝'), + backgroundColor: context.tokens.accent)); } } catch (e) { if (mounted) { @@ -856,7 +1280,8 @@ class _StockInListScreenState extends ConsumerState { await ref.read(stockInListProvider.notifier).withdrawOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('已撤回为草稿'), backgroundColor: context.tokens.accent)); + content: const Text('已撤回为草稿'), + backgroundColor: context.tokens.accent)); } } catch (e) { if (mounted) { @@ -881,7 +1306,8 @@ class _StockInListScreenState extends ConsumerState { ElevatedButton( onPressed: () => Navigator.of(ctx).pop(true), style: ElevatedButton.styleFrom( - backgroundColor: context.tokens.danger, foregroundColor: Colors.white), + backgroundColor: context.tokens.danger, + foregroundColor: Colors.white), child: const Text('确认'), ), ], @@ -893,8 +1319,9 @@ class _StockInListScreenState extends ConsumerState { full = await ref.read(stockInRepositoryProvider).get(o.id); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('加载明细失败:$e'), backgroundColor: context.tokens.danger)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('加载明细失败:$e'), + backgroundColor: context.tokens.danger)); } return; } @@ -931,199 +1358,403 @@ class _StockInListScreenState extends ConsumerState { } } -// Detail dialog — fetches full order with items -class _StockInDetailDialog extends ConsumerStatefulWidget { - final int orderId; - final StockInRepository repository; +// ── 详细搜索按钮(原型 .btn.soft,浅蓝突出;有条件时转 primary)──────────── +class _AdvSearchButton extends StatelessWidget { + final int count; + final VoidCallback onTap; + const _AdvSearchButton({required this.count, required this.onTap}); - const _StockInDetailDialog({ - required this.orderId, - required this.repository, + @override + Widget build(BuildContext context) { + final t = context.tokens; + final active = count > 0; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: t.infoSoft, + border: Border.all(color: active ? t.primary : t.infoSoft), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.slidersHorizontal, size: 14, color: t.primary), + const SizedBox(width: 6), + Text('详细搜索', + style: TextStyle( + fontSize: AppDims.fsSm, + color: t.primary, + fontWeight: FontWeight.w600)), + if (active) ...[ + const SizedBox(width: 6), + Text('· $count', + style: TextStyle( + fontSize: AppDims.fsSm, + color: t.primary, + fontWeight: FontWeight.w700)), + ], + ], + ), + ), + ); + } +} + +// ── 详细搜索模态框(原型 11 字段)──────────────────────────────────── +class _AdvResult { + final Map detail; + final String status; + final DateTimeRange? dateRange; + const _AdvResult(this.detail, this.status, this.dateRange); +} + +class _AdvSearchDialog extends ConsumerStatefulWidget { + final Map initialDetail; + final String initialStatus; + final DateTimeRange? initialRange; + const _AdvSearchDialog({ + required this.initialDetail, + required this.initialStatus, + required this.initialRange, }); @override - ConsumerState<_StockInDetailDialog> createState() => _StockInDetailDialogState(); + ConsumerState<_AdvSearchDialog> createState() => _AdvSearchDialogState(); } -class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { - late Future _future; - Map _inventoryMap = {}; - StockInOrder? _loadedOrder; +class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> { + 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; + + // 状态下拉项(映射到 ComboSearchField 的 int id:0=全部…4=已拒绝)。 + static const _statusCodes = ['', 'draft', 'pending', 'approved', 'rejected']; + static const _statusNames = ['全部', '草稿', '待审核', '已审核', '已拒绝']; @override void initState() { super.initState(); - _future = _load(); + 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; } - Future _load() { - return widget.repository.get(widget.orderId).then((order) async { - if (mounted) setState(() => _loadedOrder = order); - try { - final result = await ref - .read(inventoryRepositoryProvider) - .listInventory(warehouseId: order.warehouseId, pageSize: 500); - if (mounted) { - setState(() { - _inventoryMap = { - for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity - }; - }); - } - } catch (_) {} - return order; + @override + void dispose() { + _orderNoCtrl.dispose(); + _productCtrl.dispose(); + _productCodeCtrl.dispose(); + _batchCtrl.dispose(); + super.dispose(); + } + + void _reset() { + setState(() { + _orderNoCtrl.clear(); + _productCtrl.clear(); + _productCodeCtrl.clear(); + _batchCtrl.clear(); + _partnerId = null; + _seriesName = null; + _specName = null; + _operatorId = null; + _reviewerId = null; + _status = ''; + _range = null; }); } - /// 确认进价:为 0 价(待定)明细补填真实进价,调后端前向补偿后刷新。 - Future _confirmCost(StockInOrder o) async { - final pending = - o.items.where((it) => it.unitPrice == 0 && it.id != null).toList(); - if (pending.isEmpty) return; - final controllers = { - for (final it in pending) it.id!: TextEditingController() - }; - - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('确认进价'), - content: SizedBox( - width: context.dialogWidth(440), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: pending.map((it) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Text( - '${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}', - style: const TextStyle(fontSize: 13), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 120, - child: TextField( - controller: controllers[it.id], - keyboardType: const TextInputType.numberWithOptions( - decimal: true), - decoration: const InputDecoration( - prefixText: '¥', - hintText: '进价', - isDense: true), - ), - ), - ], - ), - ); - }).toList(), - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('取消')), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('确认')), - ], - ), - ); - - final items = >[]; - if (ok == true) { - for (final it in pending) { - final v = double.tryParse(controllers[it.id]!.text.trim()); - if (v != null && v > 0) { - items.add({'item_id': it.id, 'unit_price': v}); - } - } + void _apply() { + final d = {}; + void put(String k, String? v) { + if (v != null && v.trim().isNotEmpty) d[k] = v.trim(); } - for (final c in controllers.values) { - c.dispose(); - } - if (ok != true || items.isEmpty) return; - try { - await widget.repository.confirmCost(o.id, items); - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('进价已确认'))); - setState(() => _future = _load()); - ref.invalidate(stockInListProvider); - } on AppException catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('确认失败:${e.message}'))); - } + put('order_no', _orderNoCtrl.text); + put('partner_id', _partnerId?.toString()); + put('product', _productCtrl.text); + put('product_code', _productCodeCtrl.text); + put('series', _seriesName); + put('spec', _specName); + put('batch', _batchCtrl.text); + put('operator_id', _operatorId?.toString()); + put('reviewer_id', _reviewerId?.toString()); + Navigator.of(context).pop(_AdvResult(d, _status, _range)); + } + + Future _pickDate() async { + final range = await showWheelDateRange(context, initial: _range); + if (range != null) setState(() => _range = range); + } + + List _supplierOpts() => + ref.watch(allPartnersProvider).valueOrNull?.data + .map((p) => OptionItem(id: p.id, name: p.name)) + .toList() ?? + const []; + + List _seriesOpts() => + ref.watch(productSeriesListProvider).valueOrNull + ?.map((s) => OptionItem(id: s.id, name: s.name)) + .toList() ?? + const []; + + List _specOpts() => + ref.watch(productSpecListProvider).valueOrNull + ?.map((s) => OptionItem(id: s.id, name: s.name)) + .toList() ?? + const []; + + List _userOpts() => + ref.watch(userListProvider).valueOrNull + ?.map((u) => OptionItem( + id: u.id, + name: (u.realName != null && u.realName!.isNotEmpty) + ? u.realName! + : u.username)) + .toList() ?? + const []; + + int? _idByName(List opts, String? name) { + if (name == null || name.isEmpty) return null; + return opts.where((o) => o.name == name).map((o) => o.id).firstOrNull; } @override Widget build(BuildContext context) { - return Dialog.fullscreen( - child: SafeArea( - child: Column( + final seriesOpts = _seriesOpts(); + final specOpts = _specOpts(); + final isMobile = context.isMobile; + + final t = context.tokens; + + Widget field(String label, Widget child) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + const SizedBox(height: 6), + child, + ], + ); + + // 文本输入:用与 ComboSearchField 完全相同的 h38 带框 Container 包无边框 TextField, + // 保证与下拉框等高等样式(对齐原型 .input / .combo)。 + Widget textField(TextEditingController c, String hint) => Container( + height: 38, + 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, + border: InputBorder.none, + hintText: hint, + hintStyle: TextStyle(color: t.faint, fontSize: AppDims.fsBody), + ), + ), + ); + + // 下拉:锚定式 ComboSearchField,h38 + 白底,与文本框统一(对齐原型 .combo)。 + Widget combo({ + required List opts, + required int? selectedId, + required String hint, + required ValueChanged onChanged, + }) => + ComboSearchField( + options: opts, + selectedId: selectedId, + hint: hint, + onChanged: onChanged, + height: 38, + fillColor: t.surface, + ); + + // 状态:复用 ComboSearchField(把固定状态映射成 int id 项)。 + final statusItems = [ + for (var i = 0; i < _statusNames.length; i++) + OptionItem(id: i, name: _statusNames[i]), + ]; + final statusSelIdx = _statusCodes.indexOf(_status); + Widget statusCombo() => ComboSearchField( + options: statusItems, + selectedId: statusSelIdx <= 0 ? null : statusSelIdx, + hint: '全部', + height: 38, + fillColor: t.surface, + onChanged: (id) => + setState(() => _status = (id == null) ? '' : _statusCodes[id]), + ); + + Widget row(List children) { + if (isMobile) { + return Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + children[i], + ], + ], + ); + } + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (int i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(width: 16), + Expanded(child: children[i]), + ], + ], + ), + ); + } + + return AlertDialog( + backgroundColor: t.surface, + surfaceTintColor: Colors.transparent, + contentPadding: EdgeInsets.zero, + content: SizedBox( + width: context.dialogWidth(680), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 头(原型 .modal-head):标题 + 关闭 Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.fromLTRB(20, 16, 16, 16), decoration: BoxDecoration( - color: context.tokens.brand50, - border: Border( - bottom: BorderSide(color: context.tokens.border)), - ), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, - color: context.tokens.primaryDark), - tooltip: '返回', - onPressed: () => Navigator.of(context).pop(), - ), - Text('入库单详情', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: context.tokens.primaryDark)), - const Spacer(), - if (_loadedOrder != null) - IconButton( - icon: Icon(Icons.print_outlined, - color: context.tokens.primaryDark), - tooltip: '打印', - onPressed: () => showStockInOrderPrint(context, ref, _loadedOrder!), - ), - ], + border: Border(bottom: BorderSide(color: t.border))), + child: Row(children: [ + Text('详细搜索', + style: TextStyle( + fontSize: AppDims.fsH2, + fontWeight: FontWeight.w700, + color: t.heading)), + const Spacer(), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: Icon(Icons.close, size: 20, color: t.muted), + ), + ]), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + row([ + field('单据编号', + textField(_orderNoCtrl, '如 RK20260401001')), + field( + '供应商', + combo( + opts: _supplierOpts(), + selectedId: _partnerId, + hint: '全部供应商', + onChanged: (v) => setState(() => _partnerId = v))), + ]), + row([ + field('商品名', textField(_productCtrl, '输入酒名')), + field('商品编码', textField(_productCodeCtrl, '输入商品编码')), + ]), + row([ + 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))), + ]), + row([ + field('批次号', textField(_batchCtrl, '输入批次号')), + field('状态', statusCombo()), + ]), + row([ + field( + '入库员', + combo( + opts: _userOpts(), + selectedId: _operatorId, + hint: '全部入库员', + onChanged: (v) => setState(() => _operatorId = v))), + field( + '审核人', + combo( + opts: _userOpts(), + selectedId: _reviewerId, + hint: '全部审核人', + onChanged: (v) => setState(() => _reviewerId = v))), + ]), + row([ + field('单据日期', _dateField()), + ]), + ], + ), ), ), - Expanded( - child: FutureBuilder( - future: _future, - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.cloud_off, size: 40, color: context.tokens.muted), - const SizedBox(height: 12), - Text('暂无数据,网络不可用', - style: TextStyle(color: context.tokens.muted)), - ], - ), - ); - } - return _buildContent(snap.data!); - }, - ), + // 脚(原型 .modal-foot):重置(最左) / 取消 / 确认,均为 .btn 盒子按钮。 + Container( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: t.border))), + child: Row(children: [ + DsButton('重置', onPressed: _reset), + const Spacer(), + DsButton('取消', + onPressed: () => Navigator.of(context).pop()), + const SizedBox(width: 10), + DsButton('确认', + variant: DsBtnVariant.primary, onPressed: _apply), + ]), ), ], ), @@ -1131,271 +1762,39 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { ); } - Widget _buildContent(StockInOrder o) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Basic info - Wrap( - spacing: 32, - runSpacing: 12, - children: [ - _InfoField('入库单号', o.orderNo), - _InfoField('状态', _statusLabel(o.status)), - _InfoField('供应商', o.partnerName ?? '-'), - _InfoField('仓库', o.warehouseName ?? '-'), - _InfoField('入库日期', o.orderDate?.substring(0, 10) ?? '-'), - _InfoField('合计金额', - o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'), - if (o.remark != null && o.remark!.isNotEmpty) - _InfoField('备注', o.remark!), - ], + Widget _dateField() { + final t = context.tokens; + final has = _range != null; + final label = has + ? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}' + : '全部时间'; + return InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 38, + 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(label, + style: TextStyle( + fontSize: AppDims.fsBody, + color: has ? t.text : t.faint)), ), - const SizedBox(height: 20), - Text('商品明细', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: context.tokens.primaryDark)), - const SizedBox(height: 8), - if (o.items.isEmpty) - Text('无商品明细', - style: TextStyle(color: context.tokens.muted)) - else if (context.isMobile) - // 窄屏:每个商品渲染为卡片,字段竖排 - Column( - children: o.items.asMap().entries.map((e) { - final i = e.key; - final item = e.value; - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: MobileListCard( - title: Text('商品 ${i + 1} ${item.productName ?? ''}'), - subtitle: item.productCode != null - ? Text('编码 ${item.productCode}') - : null, - fields: [ - if (item.productSeries?.isNotEmpty ?? false) - MobileCardField('系列', item.productSeries!), - MobileCardField('规格', item.productSpec ?? '-'), - MobileCardField('数量', item.quantity.toStringAsFixed(3)), - MobileCardField('单价', - item.unitPrice == 0 ? '待定价' : '¥${item.unitPrice.toStringAsFixed(2)}'), - MobileCardField('金额', - item.totalPrice == 0 ? '待定' : '¥${item.totalPrice.toStringAsFixed(2)}'), - if (item.batchNo?.isNotEmpty ?? false) - MobileCardField('批次号', item.batchNo!), - if (item.productionDate?.isNotEmpty ?? false) - MobileCardField('生产日期', - item.productionDate!.length >= 10 - ? item.productionDate!.substring(0, 10) - : item.productionDate!), - if (item.productOrigin?.isNotEmpty ?? false) - MobileCardField('产地', item.productOrigin!), - if (item.productShelfLife?.isNotEmpty ?? false) - MobileCardField('保质期', item.productShelfLife!), - if (item.productStorage?.isNotEmpty ?? false) - MobileCardField('储存方式', item.productStorage!), - ], - ), - ); - }).toList(), + if (has) + GestureDetector( + onTap: () => setState(() => _range = null), + child: Icon(Icons.close, size: 14, color: t.muted), ) else - // 宽屏:保持原有表格展示 - Table( - border: TableBorder.all(color: context.tokens.border, width: 0.5), - columnWidths: const { - 0: FixedColumnWidth(36), - 1: FlexColumnWidth(1.2), - 2: FlexColumnWidth(2.2), - 3: FlexColumnWidth(1.3), - 4: FlexColumnWidth(1.3), - 5: FlexColumnWidth(1.3), - 6: FlexColumnWidth(1.3), - 7: FlexColumnWidth(1.0), - 8: FlexColumnWidth(1.1), - 9: FlexColumnWidth(1.1), - }, - children: [ - TableRow( - decoration: BoxDecoration(color: context.tokens.thBg), - children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '单价', '金额'] - .map((h) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 10), - child: Text(h, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: context.tokens.primaryDark)), - )) - .toList(), - ), - ...o.items.asMap().entries.map((e) { - final i = e.key; - final item = e.value; - final attrs = [ - if (item.productOrigin?.isNotEmpty ?? false) - item.productOrigin!, - if (item.productShelfLife?.isNotEmpty ?? false) - item.productShelfLife!, - if (item.productStorage?.isNotEmpty ?? false) - item.productStorage!, - ].join(' · '); - return TableRow( - decoration: BoxDecoration( - color: i.isEven - ? context.tokens.surface - : context.tokens.bg), - children: [ - _TableCell('${i + 1}'), - _TableCell(item.productCode ?? '-'), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(item.productName ?? '-', - style: const TextStyle(fontSize: 13)), - if (attrs.isNotEmpty) - Text(attrs, - style: TextStyle( - fontSize: 11, - color: context.tokens.muted)), - ], - ), - ), - _TableCell(item.productSeries ?? '-'), - _TableCell(item.productSpec ?? '-'), - _TableCell((item.productionDate != null && - item.productionDate!.length >= 10) - ? item.productionDate!.substring(0, 10) - : (item.productionDate ?? '-')), - _TableCell( - (item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'), - _TableCell(item.quantity.toStringAsFixed(3)), - _TableCell(item.unitPrice == 0 ? '待定价' : '¥${item.unitPrice.toStringAsFixed(2)}'), - _TableCell(item.totalPrice == 0 ? '待定' : '¥${item.totalPrice.toStringAsFixed(2)}'), - ], - ); - }), - ], - ), - if (o.status == 'approved' && - ref.watch(isAdminProvider) && - o.items.any((it) => it.unitPrice == 0)) ...[ - const SizedBox(height: 16), - Align( - alignment: Alignment.centerLeft, - child: ElevatedButton.icon( - onPressed: () => _confirmCost(o), - icon: const Icon(Icons.price_check, size: 18), - label: const Text('确认进价'), - ), - ), - const SizedBox(height: 4), - Text( - '该单含「待定价」明细:价格确定后补填真实进价,将自动回填库存成本、已出库成本快照与应付差额(不影响售价/应收)。', - style: TextStyle(fontSize: 12, color: context.tokens.muted), - ), - ], - ], - ), - ); - } - - String _statusLabel(String status) { - switch (status) { - case 'draft': - return '草稿'; - case 'pending': - return '待审核'; - case 'approved': - return '已审核'; - case 'rejected': - return '已拒绝'; - default: - return status; - } - } -} - -class _InfoField extends StatelessWidget { - final String label; - final String value; - - const _InfoField(this.label, this.value); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: TextStyle( - fontSize: 12, color: context.tokens.muted)), - const SizedBox(height: 2), - Text(value, - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w500)), - ], - ); - } -} - -class _TableCell extends StatelessWidget { - final String text; - - const _TableCell(this.text); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Text(text, style: const TextStyle(fontSize: 13)), - ); - } -} - -class _StatusFilterDropdown extends StatelessWidget { - final String value; - final ValueChanged onChanged; - - const _StatusFilterDropdown({ - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - border: Border.all(color: context.tokens.border), - borderRadius: BorderRadius.circular(4), - color: context.tokens.surface, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value.isEmpty ? '' : value, - items: const [ - DropdownMenuItem(value: '', child: Text('全部状态', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'rejected', child: Text('已拒绝', style: TextStyle(fontSize: 13))), - ], - onChanged: onChanged, - style: TextStyle(fontSize: 13, color: context.tokens.text), - ), + Icon(Icons.date_range, size: 16, color: t.faint), + ]), ), ); } } - diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index c8a50eb..fde751d 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1,33 +1,47 @@ -import '../../core/utils/dialog_util.dart'; -import '../../core/errors/error_reporter.dart'; -import '../../widgets/order_print_preview_dialog.dart'; +import 'package:flutter/foundation.dart' show mapEquals; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../core/utils/dialog_util.dart'; +import '../../core/exceptions.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/theme/app_dims.g.dart'; -import '../../models/stock_out.dart'; -import '../../providers/stock_out_provider.dart'; -import '../../repositories/stock_out_repository.dart'; -import '../../widgets/data_table_card.dart'; -import '../../widgets/mobile_list_card.dart'; -import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader; -import '../../core/storage/column_prefs.dart'; -import '../../widgets/page_scaffold.dart'; -import '../../widgets/status_badge.dart'; -import '../../widgets/search_chip.dart'; import '../../core/utils/export_util.dart'; -import '../../providers/inventory_provider.dart'; -import '../../providers/tab_state_provider.dart'; -import '../../providers/product_provider.dart'; -import '../../providers/finance_provider.dart' show financeRepositoryProvider; -import '../../widgets/write_guard.dart'; -import '../../widgets/order_row_actions.dart'; -import '../../widgets/order_return_dialog.dart'; +import '../../core/utils/print_util.dart' show LabelData; import '../../core/auth/auth_state.dart' show isAdminProvider, currentUserIdProvider; +import '../../models/stock_out.dart'; +import '../../providers/stock_out_provider.dart'; +import '../../providers/finance_provider.dart' show financeRepositoryProvider; +import '../../providers/partner_provider.dart' show allPartnersProvider; +import '../../providers/warehouse_provider.dart' show warehouseListProvider; +import '../../providers/user_provider.dart' show userListProvider; +import '../../providers/product_option_provider.dart' + show productSeriesListProvider, productSpecListProvider; +import '../../providers/product_provider.dart' show productRepositoryProvider; +import '../../providers/shop_provider.dart' show shopInfoProvider; +import '../../widgets/label_preview_dialog.dart'; +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/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'; +/// 出库管理列表屏(照原型 stock-out-list.html 重建)。 +/// 单视图(无双 tab):审核并入状态筛选 + KPI「待审核」卡点击筛选。 +/// 结构对齐 inventory_list_screen:Column[ 头部, KPI 行, Divider, Expanded(DsTable) ]。 class StockOutListScreen extends ConsumerStatefulWidget { const StockOutListScreen({super.key}); @@ -37,131 +51,241 @@ class StockOutListScreen extends ConsumerStatefulWidget { } class _StockOutListScreenState extends ConsumerState { + final _searchCtrl = TextEditingController(); + + // 状态('' = 全部)+ 日期区间:走 setStatus / setDateRange。 String _statusFilter = ''; DateTimeRange? _dateRange; - Set _filterWarehouse = {}; - Set _filterCustomer = {}; - Set? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) - final _searchCtrl = TextEditingController(); - final _codeSearchCtrl = TextEditingController(); - String _appliedKw = ''; // 已生效的搜索词(框内 chip 标签展示) + String _appliedDateKey = ''; + String _datePresetLabel = ''; // 出库时间 chip 显示的预设名 - static const _screenId = 'stock_out_list'; + // 仓库:后端 list 无 warehouse 参数 → 当前页客户端筛选(单选)。 + String? _filterWarehouseName; + + // 详细搜索 / 客户快捷筛选共享的字段(→ setDetail,key 用后端参数名)。 + String _orderNo = ''; + String _productInfo = ''; // 商品名(后端 product) + String _productCode = ''; // 商品编码(后端 product_code) + String _batch = ''; + int? _partnerId; + String _series = ''; + String _spec = ''; + int? _operatorId; + int? _reviewerId; + String _returnState = ''; // '' / partial / full(退单状态,走 detail 服务端过滤) + + static const _knownStatuses = {'', 'draft', 'pending', 'approved', 'rejected'}; + static const _statusOptions = <(String, String)>[ + ('', '全部'), + ('draft', '草稿'), + ('pending', '待审核'), + ('approved', '已审核'), + ('rejected', '已拒绝'), + // 退单状态(return_state):与主状态互斥。 + ('ret:partial', '部分退单'), + ('ret:full', '已退单'), + ]; @override void initState() { super.initState(); - ColumnPrefs.load(_screenId).then((saved) { - if (saved != null && mounted) setState(() => _hiddenCols = saved); - }); - // 进入页面时把服务端状态过滤对齐到当前标签/下拉(provider 持久化,State 会重建) + // 进入页面时对齐服务端状态到单视图(旧版可能残留 'pending,draft'), + // 并把已生效的关键词/详细搜索回填到 UI。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final tab = ref.read(stockOutTabProvider); - final want = tab == 1 ? 'pending,draft' : _statusFilter; - final notifier = ref.read(stockOutListProvider.notifier); - if (notifier.currentStatus != want) notifier.setStatus(want); - // 同步已生效的搜索词到框内 chip(provider 持久化,切走再回来不丢) - if (notifier.currentKeyword != _appliedKw) { - setState(() => _appliedKw = notifier.currentKeyword); + final n = ref.read(stockOutListProvider.notifier); + if (!_knownStatuses.contains(n.currentStatus)) { + n.setStatus(''); + } else { + _statusFilter = n.currentStatus; } + _searchCtrl.text = n.currentKeyword; + final d = n.currentDetail; + _orderNo = d['order_no'] ?? ''; + _productInfo = d['product'] ?? ''; + _productCode = d['product_code'] ?? ''; + _batch = d['batch'] ?? ''; + _series = d['series'] ?? ''; + _spec = d['spec'] ?? ''; + _partnerId = int.tryParse(d['partner_id'] ?? ''); + _operatorId = int.tryParse(d['operator_id'] ?? ''); + _reviewerId = int.tryParse(d['reviewer_id'] ?? ''); + _returnState = d['return_state'] ?? ''; + if (_returnState.isNotEmpty) _statusFilter = 'ret:$_returnState'; + setState(() {}); }); } - static const _colDefs = [ - ColDef('order_no', '出库单号', required: true), - ColDef('customer', '客户', minWidth: 900), - ColDef('warehouse', '仓库'), - ColDef('amount', '金额', minWidth: 800), - ColDef('status', '状态'), - ColDef('date', '出库时间', minWidth: 900), - ColDef('created_at', '创建时间', minWidth: 900), - ColDef('operator', '出库员', minWidth: 1100), - ColDef('reviewer', '审核员', minWidth: 1100), - ColDef('actions', '操作', required: true), - ]; - @override void dispose() { _searchCtrl.dispose(); - _codeSearchCtrl.dispose(); super.dispose(); } - void _triggerSearch() { - final kw = _searchCtrl.text.trim(); - ref.read(stockOutListProvider.notifier).setKeyword(kw); - // 仅支持单个搜索词:生效后输入框清空,搜索词转为框内 chip 标签 - setState(() => _appliedKw = kw); - if (kw.isNotEmpty) _searchCtrl.clear(); + // ── 筛选组装 ────────────────────────────────────────────────── + + Map _buildDetail() { + final m = {}; + if (_orderNo.isNotEmpty) m['order_no'] = _orderNo; + if (_partnerId != null) m['partner_id'] = '$_partnerId'; + if (_productInfo.isNotEmpty) m['product'] = _productInfo; + if (_productCode.isNotEmpty) m['product_code'] = _productCode; + if (_series.isNotEmpty) m['series'] = _series; + if (_spec.isNotEmpty) m['spec'] = _spec; + if (_batch.isNotEmpty) m['batch'] = _batch; + if (_operatorId != null) m['operator_id'] = '$_operatorId'; + if (_reviewerId != null) m['reviewer_id'] = '$_reviewerId'; + if (_returnState.isNotEmpty) m['return_state'] = _returnState; + return m; } - void _clearSearch() { - _searchCtrl.clear(); - setState(() => _appliedKw = ''); - ref.read(stockOutListProvider.notifier).setKeyword(''); + /// 详细搜索里「advanced-only」字段的激活计数(用于「详细搜索」按钮徽标)。 + int get _advCount { + var c = 0; + if (_orderNo.isNotEmpty) c++; + if (_productInfo.isNotEmpty) c++; + if (_productCode.isNotEmpty) c++; + if (_batch.isNotEmpty) c++; + if (_series.isNotEmpty) c++; + if (_spec.isNotEmpty) c++; + if (_operatorId != null) c++; + if (_reviewerId != null) c++; + // 状态也是详细搜索里的一项(含退单状态)——之前漏算导致徽标少 1。 + if (_statusFilter.isNotEmpty) c++; + return c; } - void _triggerCodeSearch() => ref - .read(stockOutListProvider.notifier) - .setProductCode(_codeSearchCtrl.text.trim()); + bool get _hasAnyFilter => + _searchCtrl.text.isNotEmpty || + _statusFilter.isNotEmpty || + _dateRange != null || + _filterWarehouseName != null || + _partnerId != null || + _returnState.isNotEmpty || + _advCount > 0; String? get _startDate => _dateRange != null - ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' + ? DateFormat('yyyy-MM-dd').format(_dateRange!.start) : null; - String? get _endDate => _dateRange != null - ? '${_dateRange!.end.year}-${_dateRange!.end.month.toString().padLeft(2, '0')}-${_dateRange!.end.day.toString().padLeft(2, '0')}' - : null; + String? get _endDate => + _dateRange != null ? DateFormat('yyyy-MM-dd').format(_dateRange!.end) : null; - OrderStatus _apiStatusToEnum(String status) { - switch (status) { - case 'pending': - return OrderStatus.pending; - case 'approved': - return OrderStatus.approved; - case 'rejected': - return OrderStatus.rejected; - default: - return OrderStatus.draft; + /// 只更新 detail 轴(客户快捷筛选)。 + void _applyDetail() { + ref.read(stockOutListProvider.notifier).setDetail(_buildDetail()); + } + + /// 详细搜索确认:只对变更的轴发请求,避免多次重复拉取。 + void _applyAll() { + final n = ref.read(stockOutListProvider.notifier); + final detail = _buildDetail(); + if (!mapEquals(n.currentDetail, detail)) n.setDetail(detail); + if (n.currentStatus != _statusFilter) n.setStatus(_statusFilter); + final key = '${_startDate ?? ''}~${_endDate ?? ''}'; + if (key != _appliedDateKey) { + _appliedDateKey = key; + n.setDateRange(_startDate, _endDate); } } - @override - Widget build(BuildContext context) { - return PageScaffold( - title: '出库管理', - initialTab: ref.read(stockOutTabProvider), - onTabChanged: (i) { - ref.read(stockOutTabProvider.notifier).state = i; - // 审核标签页按 pending+draft 服务端拉取;出库单页沿用下拉状态('' 为全部) - ref - .read(stockOutListProvider.notifier) - .setStatus(i == 1 ? 'pending,draft' : _statusFilter); - }, - tabs: const [ - Tab(text: '出库单'), - Tab(text: '出库审核'), - ], - tabViews: [ - _buildListTab(filterStatus: 'exclude_pending', showNewButton: false), - _buildListTab(filterStatus: 'pending', showNewButton: true), - ], - ); + void _resetFilters() { + setState(() { + _searchCtrl.clear(); + _statusFilter = ''; + _dateRange = null; + _appliedDateKey = ''; + _datePresetLabel = ''; + _filterWarehouseName = null; + _orderNo = ''; + _productInfo = ''; + _productCode = ''; + _batch = ''; + _partnerId = null; + _series = ''; + _spec = ''; + _operatorId = null; + _reviewerId = null; + _returnState = ''; + }); + final n = ref.read(stockOutListProvider.notifier); + n.setKeyword(''); + n.setStatus(''); + n.setDateRange(null, null); + n.setDetail(const {}); } - Widget _buildListTab({String? filterStatus, required bool showNewButton}) { + Future _pickDateRange() async { + final range = await showWheelDateRange(context, initial: _dateRange); + if (range == null) return; + setState(() { + _dateRange = range; + _datePresetLabel = '$_startDate ~ $_endDate'; + }); + _appliedDateKey = '${_startDate ?? ''}~${_endDate ?? ''}'; + ref.read(stockOutListProvider.notifier).setDateRange(_startDate, _endDate); + } + + /// 出库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。 + void _setDatePreset(String label) { + if (label == '自定义…') { + _pickDateRange(); + return; + } + final now = DateTime.now(); + DateTimeRange? range; + switch (label) { + case '近 7 天': + range = DateTimeRange( + start: now.subtract(const Duration(days: 6)), end: now); + break; + case '近 30 天': + range = DateTimeRange( + start: now.subtract(const Duration(days: 29)), end: now); + break; + case '本月': + range = DateTimeRange(start: DateTime(now.year, now.month, 1), end: now); + break; + default: // 全部时间 + range = null; + } + setState(() { + _dateRange = range; + _datePresetLabel = range == null ? '' : label; + }); + _appliedDateKey = '${_startDate ?? ''}~${_endDate ?? ''}'; + ref.read(stockOutListProvider.notifier).setDateRange(_startDate, _endDate); + } + + OrderStatus _apiStatusToEnum(String status) => switch (status) { + 'pending' => OrderStatus.pending, + 'approved' => OrderStatus.approved, + 'rejected' => OrderStatus.rejected, + _ => OrderStatus.draft, + }; + + String _yuanWan(double v) => v >= 10000 + ? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}万' + : '¥${v.toStringAsFixed(0)}'; + + // ── 构建 ────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { final asyncOrders = ref.watch(stockOutListProvider); return asyncOrders.when( + // 搜索/筛选触发的 reload 保留旧数据,走 data 分支叠加半透明进度,不整屏白屏。 + skipLoadingOnReload: true, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.cloud_off, size: 40, color: context.tokens.muted), - const SizedBox(height: 12), - Text('暂无数据,网络不可用', - style: TextStyle(color: context.tokens.muted)), + const SizedBox(height: 12), + Text('加载失败:$e', + style: TextStyle(color: context.tokens.muted), + textAlign: TextAlign.center), const SizedBox(height: 12), ElevatedButton( onPressed: () => @@ -171,398 +295,456 @@ class _StockOutListScreenState extends ConsumerState { ], ), ), - data: (result) { - final allOrders = result.data; - // 状态过滤已改为服务端驱动(setStatus)。这里仅按标签做轻量显示守卫, - // 避免共用 provider 在标签切换、refetch 在途时短暂闪现他状态数据。 - final List statusFiltered; - if (filterStatus == 'pending') { - // 出库审核:服务端按 pending,draft 拉取 - statusFiltered = allOrders - .where((o) => o.status == 'draft' || o.status == 'pending') - .toList(); - } else if (filterStatus == 'exclude_pending') { - // 出库单:服务端按下拉状态拉取;'全部状态' 显示全部(含未审核) - statusFiltered = _statusFilter.isEmpty - ? allOrders - : allOrders.where((o) => o.status == _statusFilter).toList(); - } else { - statusFiltered = allOrders; - } - - // Derive filter options from all loaded orders - final warehouseOptions = allOrders - .map((o) => o.warehouseName ?? '') - .where((s) => s.isNotEmpty) - .toSet() - .toList() - ..sort(); - final customerOptions = allOrders - .map((o) => o.partnerName ?? '') - .where((s) => s.isNotEmpty) - .toSet() - .toList() - ..sort(); - - // Apply multi-select filters - var orders = statusFiltered; - if (_filterWarehouse.isNotEmpty) { - orders = orders - .where((o) => _filterWarehouse.contains(o.warehouseName ?? '')) - .toList(); - } - if (_filterCustomer.isNotEmpty) { - orders = orders - .where((o) => _filterCustomer.contains(o.partnerName ?? '')) - .toList(); - } - - return _buildOrderTable( - orders: orders, - totalCount: result.total, - page: result.page, - pageSize: result.pageSize, - showStatusFilter: filterStatus == 'exclude_pending', - showNewButton: showNewButton, - warehouseOptions: warehouseOptions, - customerOptions: customerOptions, - ); - }, + data: (result) => Stack( + children: [ + _buildLoaded( + result.data, result.total, result.page, result.pageSize), + if (asyncOrders.isLoading) + const Positioned.fill(child: DsLoadingScrim()), + ], + ), ); } - Widget _buildOrderTable({ - required List orders, - required int totalCount, - required int page, - required int pageSize, - required bool showStatusFilter, - required bool showNewButton, - required List warehouseOptions, - required List customerOptions, - }) { - final screenWidth = MediaQuery.of(context).size.width; - // 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。 - final hidden = _hiddenCols ?? - _colDefs - .where((c) => c.minWidth != null && screenWidth < c.minWidth!) - .map((c) => c.key) - .toSet(); - // 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。 - final visibleCols = - _colDefs.where((c) => !hidden.contains(c.key)).toList(); - - final columns = visibleCols.map((c) { - final label = switch (c.key) { - 'warehouse' => FilterableColumnHeader( - text: c.label, - options: warehouseOptions, - selected: _filterWarehouse, - onChanged: (v) => setState(() => _filterWarehouse = v), - ), - 'customer' => FilterableColumnHeader( - text: c.label, - options: customerOptions, - selected: _filterCustomer, - onChanged: (v) => setState(() => _filterCustomer = v), - ), - _ => Text(c.label), - }; - return DataColumn(label: label, numeric: c.key == 'amount'); - }).toList(); - - DataCell buildOrderCell(String key, StockOutOrder o) { - switch (key) { - case 'order_no': - return DataCell(GestureDetector( - onTap: () => _showDetail(context, o.id), - child: Text(o.orderNo, - style: TextStyle( - color: context.tokens.primary, - fontFamily: 'monospace', - fontSize: 12, - decoration: TextDecoration.underline)), - )); - case 'customer': - return DataCell(Text(o.partnerName ?? '-')); - case 'warehouse': - return DataCell(Text(o.warehouseName ?? '-')); - case 'amount': - return DataCell(Text(o.totalAmount != null - ? '¥${o.totalAmount!.toStringAsFixed(2)}' - : '-')); - case 'status': - final rb = returnStateBadge(context, o.returnState); - return DataCell(Row(mainAxisSize: MainAxisSize.min, children: [ - StatusBadge(_apiStatusToEnum(o.status)), - if (rb != null) ...[const SizedBox(width: 4), rb], - ])); - case 'date': - return DataCell(Text(o.orderDate?.substring(0, 10) ?? '-')); - case 'created_at': - return DataCell(Text(o.createdAt != null - ? o.createdAt!.length >= 16 - ? o.createdAt!.substring(0, 16) - : o.createdAt!.substring(0, 10) - : '-')); - case 'operator': - return DataCell(Text(o.operatorName ?? '-', - style: const TextStyle(fontSize: 13))); - case 'reviewer': - return DataCell(Text(o.reviewerName ?? '-', - style: const TextStyle(fontSize: 13))); - case 'actions': - return DataCell(SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: _orderActions(context, o), - ), - )); - default: - return const DataCell(SizedBox()); - } + Widget _buildLoaded( + List all, int total, int page, int pageSize) { + // 仓库:客户端单选筛选(当前页)。 + var orders = all; + if (_filterWarehouseName != null) { + orders = orders + .where((o) => (o.warehouseName ?? '') == _filterWarehouseName) + .toList(); } - final rows = orders.isEmpty - ? [ - DataRow( - cells: List.generate( - visibleCols.length, - (i) => i == 0 - ? DataCell(Text('暂无出库单', - style: TextStyle(color: context.tokens.muted))) - : const DataCell(SizedBox()), - ), - ), - ] - : orders - .map((o) => DataRow( - cells: visibleCols - .map((c) => buildOrderCell(c.key, o)) - .toList(), - )) - .toList(); - return Column( children: [ - // 副标(还原原型 .head .sub) - Container( - width: double.infinity, - color: context.tokens.bg, - padding: const EdgeInsets.fromLTRB( - AppDims.sp3, AppDims.sp3, AppDims.sp3, 0), - child: Text('共 $totalCount 笔出库单', - style: TextStyle( - fontSize: AppDims.fsSm, color: context.tokens.muted)), - ), + _buildHeader(total), + _buildKpis(), + const Divider(height: 1), Expanded( - child: DataTableCard( - totalCount: totalCount, - page: page, - pageSize: pageSize, - onPageChanged: (p) => - ref.read(stockOutListProvider.notifier).setPage(p), - onPageSizeChanged: (s) => - ref.read(stockOutListProvider.notifier).setPageSize(s), - toolbar: Builder(builder: (ctx) { - final isMobile = ctx.isMobile; - void doExport() => exportExcel( - filename: '出库单列表', - headers: ['单号', '仓库', '客户', '状态', '日期', '总金额'], - rows: orders.map((o) => [ - o.orderNo, o.warehouseName ?? '', o.partnerName ?? '', - o.status, o.orderDate, o.totalAmount, - ]).toList(), - ); - - final newBtn = (showNewButton && !WriteGuard.isReadonly(ref)) - ? WriteGuard( - child: ElevatedButton.icon( - onPressed: () => context.go('/stock-out/new'), - icon: const Icon(Icons.add, size: 16), - label: Text(isMobile ? '新建' : '新建出库审核单'), - style: isMobile - ? ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ) - : null, - ), - ) - : null; - - final statusFilter = showStatusFilter - ? _StatusFilterDropdown( - value: _statusFilter, - onChanged: (v) { - setState(() => _statusFilter = v ?? ''); - ref.read(stockOutListProvider.notifier).setStatus(v ?? ''); - }, - ) - : null; - - final hasKw = _appliedKw.isNotEmpty; - final searchField = TextField( - controller: _searchCtrl, - decoration: InputDecoration( - hintText: hasKw ? '继续输入新关键词…' : '单号/往来单位/酒名,回车搜索', - hintStyle: const TextStyle(fontSize: 12), - // 搜索图标 +(已生效时)框内 chip 标签,仅一个词;✕ 删除即清除搜索。 - // 用 prefixIcon 而非 prefix:后者仅在聚焦/有文字时才显示,搜索后输入框为空会被隐藏。 - prefixIconConstraints: - const BoxConstraints(minWidth: 0, minHeight: 0), - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 8, right: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.search, size: 16), - if (hasKw) ...[ - const SizedBox(width: 6), - SearchChip(label: _appliedKw, onDeleted: _clearSearch), - ], - ], - ), - ), - suffixIcon: IconButton( - icon: const Icon(Icons.search, size: 16), - tooltip: '搜索', - onPressed: _triggerSearch, - ), - ), - onSubmitted: (_) => _triggerSearch(), - ); - - // 商品编码精确反查(独立搜索框) - final codeSearchField = TextField( - controller: _codeSearchCtrl, - decoration: InputDecoration( - hintText: '商品编码,回车精确查', - prefixIcon: const Icon(Icons.qr_code, size: 16), - hintStyle: const TextStyle(fontSize: 12), - suffixIcon: IconButton( - icon: const Icon(Icons.search, size: 16), - tooltip: '按商品编码查', - onPressed: _triggerCodeSearch, - ), - ), - onSubmitted: (_) => _triggerCodeSearch(), - ); - - final refreshBtn = IconButton( - tooltip: '刷新', - icon: const Icon(Icons.refresh, size: 20), - onPressed: () => ref.read(stockOutListProvider.notifier).reload(), - ); - - final dateBtn = OutlinedButton.icon( - onPressed: _pickDateRange, - icon: const Icon(Icons.date_range, size: 16), - label: Text( - _dateRange == null ? '选择日期' : '$_startDate ~ $_endDate', - style: const TextStyle(fontSize: 13), - ), - ); - - final clearDate = _dateRange != null - ? IconButton( - icon: const Icon(Icons.clear, size: 16), - onPressed: () { - setState(() => _dateRange = null); - ref - .read(stockOutListProvider.notifier) - .setDateRange(null, null); - }, - ) - : null; - - if (isMobile) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - searchField, - const SizedBox(height: 8), - codeSearchField, - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (newBtn != null) newBtn, - if (statusFilter != null) statusFilter, - dateBtn, - if (clearDate != null) clearDate, - IconButton( - tooltip: '导出', - icon: const Icon(Icons.download, size: 20), - onPressed: doExport, - ), - ColumnToggleButton( - columns: _colDefs, - hidden: hidden, - compact: true, - onChanged: (v) { - setState(() => _hiddenCols = v); - ColumnPrefs.save(_screenId, v); - }, - ), - refreshBtn, - ], - ), + child: DsTable( + total: total, + page: page, + pageSize: pageSize, + onPageChanged: (p) => + ref.read(stockOutListProvider.notifier).setPage(p), + onPageSizeChanged: (s) => + ref.read(stockOutListProvider.notifier).setPageSize(s), + toolbar: _buildToolbar(orders), + emptyText: '没有匹配的出库单 · 试试调整筛选或搜索', + columns: const [ + DsColumn('order_no', '单号'), + DsColumn('customer', '客户'), + DsColumn('warehouse', '仓库'), + DsColumn('amount', '合计金额', numeric: true), + DsColumn('status', '状态'), + DsColumn('date', '出库时间'), + DsColumn('operator', '出库员'), + DsColumn('reviewer', '审核员'), + DsColumn('actions', '操作', action: true), ], - ); - } - - return Row( - children: [ - SizedBox(width: 220, child: searchField), - const SizedBox(width: 8), - SizedBox(width: 180, child: codeSearchField), - const SizedBox(width: 12), - if (newBtn != null) newBtn, - const Spacer(), - if (statusFilter != null) ...[ - statusFilter, - const SizedBox(width: 8), - ], - dateBtn, - if (clearDate != null) ...[ - const SizedBox(width: 4), - clearDate, - ], - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: doExport, - icon: const Icon(Icons.download, size: 16), - label: const Text('导出'), - ), - const SizedBox(width: 8), - ColumnToggleButton( - columns: _colDefs, - hidden: hidden, - onChanged: (v) { - setState(() => _hiddenCols = v); - ColumnPrefs.save(_screenId, v); - }, - ), - refreshBtn, - ], - ); - }), - columns: columns, - rows: rows, - mobileCards: orders.map((o) => _orderCard(context, o)).toList(), + rows: orders + .map((o) => DsRow( + onTap: () => _showDetail(context, o.id), + cells: [ + _cellOrderNo(o), + Text(o.partnerName ?? '-'), + Text(o.warehouseName ?? '-'), + Text( + o.totalAmount != null + ? '¥${o.totalAmount!.toStringAsFixed(2)}' + : '-', + style: const TextStyle(fontFamily: 'monospace')), + _cellStatus(o), + Text(o.orderDate?.substring(0, 10) ?? '-', + style: TextStyle( + color: context.tokens.muted, + fontSize: AppDims.fsSm)), + Text(o.operatorName ?? '-'), + Text(o.reviewerName ?? '-'), + // 原型:操作列只放眼睛,点开右侧详情抽屉。 + IconButton( + icon: Icon(LucideIcons.eye, + size: 16, color: context.tokens.muted), + tooltip: '详情', + splashRadius: 18, + constraints: const BoxConstraints( + minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + onPressed: () => _showDetail(context, o.id), + ), + ], + )) + .toList(), + mobileCards: orders.map((o) => _orderCard(context, o)).toList(), ), ), ], ); } - /// 操作按钮列表,表格与移动端卡片共用。 + Widget _cellOrderNo(StockOutOrder o) => Text( + o.orderNo, + style: TextStyle( + color: context.tokens.primary, + fontFamily: 'monospace', + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + ), + ); + + Widget _cellStatus(StockOutOrder o) { + final rb = returnStateBadge(context, o.returnState); + return Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (rb != null) ...[const SizedBox(width: 4), rb], + ]); + } + + // ── 头部(原型 .head)────────────────────────────────────────── + + Widget _buildHeader(int total) { + final refreshBtn = DsButton( + '刷新', + icon: Icons.refresh, + onPressed: () => ref.read(stockOutListProvider.notifier).reload(), + ); + final exportBtn = DsButton( + '导出', + icon: Icons.download, + onPressed: () => _doExport( + ref.read(stockOutListProvider).valueOrNull?.data ?? const []), + ); + final newBtn = WriteGuard.isReadonly(ref) + ? null + : WriteGuard( + child: DsButton( + '新增出库', + icon: Icons.add, + variant: DsBtnVariant.primary, + onPressed: () => context.go('/stock-out/new'), + ), + ); + + return Container( + width: double.infinity, + color: context.tokens.bg, + padding: const EdgeInsets.fromLTRB( + AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('出库管理', + style: TextStyle( + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w700, + color: context.tokens.heading)), + const SizedBox(width: AppDims.sp3), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text('本月 $total 笔出库单', + style: TextStyle( + fontSize: AppDims.fsSm, color: context.tokens.muted)), + ), + const Spacer(), + if (!context.isMobile) ...[ + refreshBtn, + const SizedBox(width: AppDims.sp2), + exportBtn, + if (newBtn != null) ...[ + const SizedBox(width: AppDims.sp2), + newBtn, + ], + ] else if (newBtn != null) + newBtn, + ], + ), + ); + } + + // ── KPI(原型 .kpis)────────────────────────────────────────── + + Widget _buildKpis() { + final summary = ref.watch(stockOutSummaryProvider).valueOrNull; + + String deltaText(double? pct) { + if (pct == null) return '— 较上月'; + final sign = pct >= 0 ? '+' : ''; + return '$sign${pct.toStringAsFixed(1)}% 较上月'; + } + + DsKpiDelta deltaTone(double? pct) => pct == null + ? DsKpiDelta.neutral + : (pct >= 0 ? DsKpiDelta.up : DsKpiDelta.down); + + final cards = [ + DsKpi( + title: '本月出库笔数', + value: '${summary?.monthCount ?? 0}', + icon: LucideIcons.fileText, + tone: DsKpiTone.info, + delta: summary != null ? deltaText(summary.countDeltaPct) : null, + deltaTone: + summary != null ? deltaTone(summary.countDeltaPct) : DsKpiDelta.neutral, + ), + DsKpi( + title: '本月出库金额', + value: summary != null ? _yuanWan(summary.monthAmount) : '—', + icon: LucideIcons.banknote, + tone: DsKpiTone.ok, + delta: summary != null ? deltaText(summary.amountDeltaPct) : null, + deltaTone: summary != null + ? deltaTone(summary.amountDeltaPct) + : DsKpiDelta.neutral, + ), + DsKpi( + title: '待审核单数 · 点击筛选', + value: '${summary?.pendingCount ?? 0}', + icon: LucideIcons.clock, + tone: DsKpiTone.alert, + delta: (summary?.pendingCount ?? 0) > 0 ? '需尽快处理' : '点击筛选', + onTap: () { + setState(() => _statusFilter = 'pending'); + ref.read(stockOutListProvider.notifier).setStatus('pending'); + }, + ), + ]; + + final mobile = context.isMobile; + final row = []; + for (var i = 0; i < cards.length; i++) { + if (i > 0) row.add(const SizedBox(width: AppDims.sp3)); + row.add(mobile + ? SizedBox(width: 180, child: cards[i]) + : Expanded(child: cards[i])); + } + return Container( + color: context.tokens.bg, + padding: const EdgeInsets.fromLTRB( + AppDims.sp4, 0, AppDims.sp4, AppDims.sp3), + child: mobile + ? SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: IntrinsicHeight(child: Row(children: row)), + ) + : IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: row)), + ); + } + + // ── 工具栏(原型 .toolbar)───────────────────────────────────── + + Widget _buildToolbar(List orders) { + final isMobile = context.isMobile; + + final searchField = SizedBox( + width: isMobile ? double.infinity : 260, + child: DsSearchBox( + controller: _searchCtrl, + hint: '单号 / 酒名 / 商品编码', + onSubmitted: (v) { + ref.read(stockOutListProvider.notifier).setKeyword(v.trim()); + setState(() {}); + }, + ), + ); + + // 客户:可搜索单选下拉(→ setDetail partner_id)。 + final customers = (ref.watch(allPartnersProvider).valueOrNull?.data ?? + const []) + .map((p) => OptionItem(id: p.id, name: p.name, code: p.code)) + .toList(); + final customerField = ComboSearchField( + options: customers, + selectedId: _partnerId, + hint: '客户', + width: isMobile ? 320 : 180, + onChanged: (id) { + setState(() => _partnerId = id); + _applyDetail(); + }, + ); + + // 仓库:客户端单选 chip(当前页)。 + final warehouseNames = (ref.watch(warehouseListProvider).valueOrNull ?? + const []) + .map((w) => w.name) + .toList(); + final warehouseChip = _MenuChip( + label: '仓库', + value: _filterWarehouseName, + options: ['全部', ...warehouseNames], + onSelected: (v) => + setState(() => _filterWarehouseName = v == '全部' ? null : v), + onClear: () => setState(() => _filterWarehouseName = null), + ); + + // 状态:DsChip + 菜单。 + final statusLabel = _statusOptions + .firstWhere((e) => e.$1 == _statusFilter, orElse: () => ('', '')) + .$2; + final statusChip = _MenuChip( + label: '状态', + value: _statusFilter.isEmpty ? null : statusLabel, + options: _statusOptions.map((e) => e.$2).toList(), + onSelected: (label) { + final code = + _statusOptions.firstWhere((e) => e.$2 == label).$1; + final n = ref.read(stockOutListProvider.notifier); + if (code.startsWith('ret:')) { + // 退单状态:走 return_state,主状态清空(互斥)。 + setState(() { + _statusFilter = code; + _returnState = code.substring(4); + }); + n.setStatus(''); + _applyDetail(); + } else { + setState(() { + _statusFilter = code; + _returnState = ''; + }); + n.setStatus(code); + _applyDetail(); + } + }, + onClear: () { + setState(() { + _statusFilter = ''; + _returnState = ''; + }); + ref.read(stockOutListProvider.notifier).setStatus(''); + _applyDetail(); + }, + ); + + final dateChip = _MenuChip( + label: '出库时间', + value: _datePresetLabel.isEmpty ? null : _datePresetLabel, + options: const ['全部时间', '近 7 天', '近 30 天', '本月', '自定义…'], + onSelected: _setDatePreset, + onClear: () => _setDatePreset('全部时间'), + ); + + final advBtn = DsButton( + _advCount > 0 ? '详细搜索 · $_advCount' : '详细搜索', + icon: Icons.tune, + small: true, + variant: _advCount > 0 ? DsBtnVariant.primary : DsBtnVariant.ghost, + onPressed: _openAdvSearch, + ); + + final resetBtn = DsButton( + '重置', + icon: Icons.refresh, + small: true, + variant: _hasAnyFilter ? DsBtnVariant.primary : DsBtnVariant.ghost, + onPressed: _resetFilters, + ); + + // 筛选 chip(不含重置)——重置在桌面推到最右、移动端并入 Wrap 末尾(对齐原型 .sp)。 + final filterChips = [ + if (!isMobile) 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: [ + searchField, + const SizedBox(width: AppDims.sp3), + Expanded( + child: Wrap( + spacing: AppDims.sp2, + runSpacing: AppDims.sp2, + crossAxisAlignment: WrapCrossAlignment.center, + children: filterChips, + ), + ), + const SizedBox(width: AppDims.sp2), + resetBtn, + ], + ); + } + + void _doExport(List orders) => exportExcel( + filename: '出库单列表', + headers: const ['单号', '仓库', '客户', '状态', '日期', '总金额'], + rows: orders + .map((o) => [ + o.orderNo, + o.warehouseName ?? '', + o.partnerName ?? '', + o.status, + o.orderDate, + o.totalAmount, + ]) + .toList(), + ); + + // ── 详细搜索模态(11 字段)────────────────────────────────────── + + Future _openAdvSearch() async { + final result = await showAppDialog<_AdvResult>( + context: context, + builder: (_) => _AdvancedSearchDialog( + initial: _AdvResult( + orderNo: _orderNo, + productInfo: _productInfo, + productCode: _productCode, + batch: _batch, + partnerId: _partnerId, + series: _series, + spec: _spec, + operatorId: _operatorId, + reviewerId: _reviewerId, + status: _statusFilter, + dateRange: _dateRange, + ), + ), + ); + if (result == 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; + }); + _applyAll(); + } + + // ── 行操作 / 卡片 ───────────────────────────────────────────── + List _orderActions(BuildContext context, StockOutOrder o) { return buildOrderRowActions( context: context, @@ -594,19 +776,12 @@ class _StockOutListScreenState extends ConsumerState { ); } - /// 出库单:窄屏卡片 Widget _orderCard(BuildContext context, StockOutOrder o) { return MobileListCard( onTap: () => _showDetail(context, o.id), title: Text(o.orderNo, style: const TextStyle(fontFamily: 'monospace', 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)!, - ], - ]), + trailing: _cellStatus(o), fields: [ MobileCardField('客户', o.partnerName ?? '-'), MobileCardField('仓库', o.warehouseName ?? '-'), @@ -621,44 +796,290 @@ class _StockOutListScreenState extends ConsumerState { ); } + // 点击行/眼睛 → 右侧详情抽屉(对齐原型 openDetail)。 Future _showDetail(BuildContext context, int orderId) async { - showAppDialog( - context: context, - // 用分支 Navigator 承载,全屏弹窗只覆盖内容区,不盖住左侧栏/顶栏/状态栏 - useRootNavigator: false, - builder: (ctx) => _StockOutDetailDialog( - orderId: orderId, - repository: ref.read(stockOutRepositoryProvider), + await showOrderDetailDrawer( + context, + builder: (ctx) => FutureBuilder( + future: ref.read(stockOutRepositoryProvider).get(orderId), + builder: (c, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError || !snap.hasData) { + return Center( + child: Text('暂无数据,网络不可用', + style: TextStyle(color: context.tokens.muted)), + ); + } + return _buildDetailDrawer(snap.data!); + }, ), ); } - Future _pickDateRange() async { - final range = await showDateRangePicker( - context: context, - firstDate: DateTime(2020), - lastDate: DateTime(2030), - initialDateRange: _dateRange, + static final _money = + NumberFormat.currency(locale: 'zh_CN', symbol: '¥', decimalDigits: 0); + static String _fmtQty(double q) => + q == q.roundToDouble() ? q.toStringAsFixed(0) : q.toString(); + + Widget _buildDetailDrawer(StockOutOrder o) { + final t = context.tokens; + final rb = returnStateBadge(context, o.returnState); + return OrderDetailDrawer( + title: o.orderNo, + infoRows: [ + (label: '单据日期', value: Text(o.orderDate?.substring(0, 10) ?? '—')), + (label: '客户', value: Text(o.partnerName ?? '—')), + (label: '仓库', value: Text(o.warehouseName ?? '—')), + (label: '商品数', value: Text('${o.items.length}')), + (label: '出库员', value: Text(o.operatorName ?? '—')), + ( + label: '审核员', + value: Text(o.reviewerName ?? '—', + style: o.reviewerName == null ? TextStyle(color: t.faint) : null) + ), + ( + label: '状态', + value: Row(mainAxisSize: MainAxisSize.min, children: [ + StatusBadge(_apiStatusToEnum(o.status)), + if (rb != null) ...[const SizedBox(width: 4), rb], + ]) + ), + ], + linesLabel: '出库明细', + lines: o.items + .map((it) => OrderLine( + name: it.productName ?? '—', + code: it.productCode ?? '—', + series: it.productSeries ?? '—', + spec: it.productSpec ?? '', + qty: _fmtQty(it.quantity), + price: it.salePrice <= 0 ? '待定价' : _money.format(it.salePrice), + amount: + it.totalPrice == 0 ? '待定价' : _money.format(it.totalPrice), + pending: it.salePrice <= 0, + )) + .toList(), + totalText: o.totalAmount != null ? _money.format(o.totalAmount) : '—', + actionGroups: _detailActionGroups(o), ); - if (range != null) { - setState(() => _dateRange = range); - ref - .read(stockOutListProvider.notifier) - .setDateRange(_startDate, _endDate); + } + + List _detailActionGroups(StockOutOrder o) { + final readonly = WriteGuard.isReadonly(ref); + final isAdmin = ref.read(isAdminProvider); + final isMine = o.operatorId != null && + o.operatorId == ref.read(currentUserIdProvider); + void close() => Navigator.of(context).pop(); + DsButton b(String label, VoidCallback onTap, + {DsBtnVariant v = DsBtnVariant.ghost}) => + DsButton(label, small: true, variant: v, onPressed: onTap); + + final groups = []; + final audit = []; + if (!readonly) { + switch (o.status) { + case 'draft': + audit.add(b('修改', () { + close(); + context.go('/stock-out/edit/${o.id}'); + })); + audit.add(b('删除', () { + close(); + _confirmDelete(context, o); + }, v: DsBtnVariant.danger)); + audit.add(b('提交', () { + close(); + _confirmSubmit(context, o); + }, v: DsBtnVariant.primary)); + break; + case 'pending': + audit.add(b('通过', () { + close(); + _confirmApprove(context, o); + }, v: DsBtnVariant.primary)); + audit.add(b('拒绝', () { + close(); + _confirmReject(context, o); + }, v: DsBtnVariant.danger)); + if (isAdmin || isMine) { + audit.add(b('撤回', () { + close(); + _confirmWithdraw(context, o); + })); + } + break; + case 'approved': + audit.add(b('结清', () { + close(); + _confirmSettle(context, o.id, 'stock_out'); + })); + if (isAdmin || isMine) { + audit.add(b('退单', () { + close(); + _confirmReturn(context, o); + }, v: DsBtnVariant.danger)); + } + break; + } + } + if (audit.isNotEmpty) groups.add(DrawerActionGroup('单据审核', audit)); + + final pending = o.items.where((it) => it.salePrice <= 0).length; + if (o.status == 'approved' && pending > 0 && !readonly) { + groups.add(DrawerActionGroup('售价 · $pending 项待定价', [ + b('确认售价', () { + close(); + _confirmSale(o); + }, v: DsBtnVariant.primary), + ])); + } + + 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; + } + + Future _printLabels(StockOutOrder o) async { + final order = await ref.read(stockOutRepositoryProvider).get(o.id); + if (!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, + ), + ); + } + + /// 确认售价:为「先出后定价」明细补填真实售价,补应收流水后刷新列表。 + Future _confirmSale(StockOutOrder o) async { + final pending = + o.items.where((it) => it.salePrice <= 0 && it.id != null).toList(); + if (pending.isEmpty) return; + final controllers = {for (final it in pending) it.id!: TextEditingController()}; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('确认售价'), + content: SizedBox( + width: context.dialogWidth(440), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '「先出后定价」:填写真实售价后,系统据此结算应收(售价 × 数量)并补一条应收流水——不影响成本 / 库存,不反审核。', + style: TextStyle(fontSize: 12, color: context.tokens.muted), + ), + const SizedBox(height: 12), + ...pending.map((it) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Text( + '${it.productName ?? ''} ${it.productSpec ?? ''} ×${it.quantity.toStringAsFixed(0)}', + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 130, + child: TextField( + controller: controllers[it.id], + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + decoration: const InputDecoration( + prefixText: '¥', + hintText: '真实售价', + isDense: true), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认并补应收')), + ], + ), + ); + final items = >[]; + if (ok == true) { + for (final it in pending) { + final v = double.tryParse(controllers[it.id]!.text.trim()); + if (v != null && v > 0) items.add({'item_id': it.id, 'sale_price': v}); + } + } + for (final c in controllers.values) { + c.dispose(); + } + if (ok != true || items.isEmpty) return; + try { + await ref.read(stockOutListProvider.notifier).confirmSale(o.id, items); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('售价已确认,已补应收流水'))); + } on AppException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('确认失败:${e.message}'))); } } - Future _confirmSettle(BuildContext context, int orderId, String refType) async { + // ── 确认对话框(保留原逻辑)──────────────────────────────────── + + Future _confirmSettle( + BuildContext context, int orderId, String refType) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('结清确认'), content: const Text('确认将该单据的账款标记为已结清?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消')), TextButton( onPressed: () => Navigator.pop(ctx, true), - child: Text('确认结清', style: TextStyle(color: context.tokens.accent)), + child: + Text('确认结清', style: TextStyle(color: context.tokens.accent)), ), ], ), @@ -667,15 +1088,15 @@ class _StockOutListScreenState extends ConsumerState { try { await ref.read(financeRepositoryProvider).closeByRef(refType, orderId); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: const Text('已结清'), backgroundColor: context.tokens.success), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: const Text('已结清'), + backgroundColor: context.tokens.success)); } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString()), backgroundColor: context.tokens.danger), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(e.toString()), + backgroundColor: context.tokens.danger)); } } } @@ -705,7 +1126,8 @@ class _StockOutListScreenState extends ConsumerState { await ref.read(stockOutListProvider.notifier).deleteOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('已删除'), backgroundColor: context.tokens.success)); + content: const Text('已删除'), + backgroundColor: context.tokens.success)); } } catch (e) { if (mounted) { @@ -717,8 +1139,7 @@ class _StockOutListScreenState extends ConsumerState { } } - Future _confirmSubmit( - BuildContext context, StockOutOrder o) async { + Future _confirmSubmit(BuildContext context, StockOutOrder o) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -736,9 +1157,7 @@ class _StockOutListScreenState extends ConsumerState { ); if (confirmed == true && mounted) { try { - await ref - .read(stockOutListProvider.notifier) - .submitOrder(o.id); + await ref.read(stockOutListProvider.notifier).submitOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: const Text('已提交审核'), @@ -754,8 +1173,7 @@ class _StockOutListScreenState extends ConsumerState { } } - Future _confirmApprove( - BuildContext context, StockOutOrder o) async { + Future _confirmApprove(BuildContext context, StockOutOrder o) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -777,12 +1195,11 @@ class _StockOutListScreenState extends ConsumerState { ); if (confirmed == true && mounted) { try { - await ref - .read(stockOutListProvider.notifier) - .approveOrder(o.id); + await ref.read(stockOutListProvider.notifier).approveOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('审核通过'), backgroundColor: context.tokens.success)); + content: const Text('审核通过'), + backgroundColor: context.tokens.success)); } } catch (e) { if (mounted) { @@ -795,8 +1212,7 @@ class _StockOutListScreenState extends ConsumerState { } } - Future _confirmReject( - BuildContext context, StockOutOrder o) async { + Future _confirmReject(BuildContext context, StockOutOrder o) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -821,7 +1237,8 @@ class _StockOutListScreenState extends ConsumerState { await ref.read(stockOutListProvider.notifier).rejectOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('已拒绝'), backgroundColor: context.tokens.accent)); + content: const Text('已拒绝'), + backgroundColor: context.tokens.accent)); } } catch (e) { if (mounted) { @@ -833,8 +1250,7 @@ class _StockOutListScreenState extends ConsumerState { } } - Future _confirmWithdraw( - BuildContext context, StockOutOrder o) async { + Future _confirmWithdraw(BuildContext context, StockOutOrder o) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -859,7 +1275,8 @@ class _StockOutListScreenState extends ConsumerState { await ref.read(stockOutListProvider.notifier).withdrawOrder(o.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('已撤回为草稿'), backgroundColor: context.tokens.accent)); + content: const Text('已撤回为草稿'), + backgroundColor: context.tokens.accent)); } } catch (e) { if (mounted) { @@ -884,7 +1301,8 @@ class _StockOutListScreenState extends ConsumerState { ElevatedButton( onPressed: () => Navigator.of(ctx).pop(true), style: ElevatedButton.styleFrom( - backgroundColor: context.tokens.danger, foregroundColor: Colors.white), + backgroundColor: context.tokens.danger, + foregroundColor: Colors.white), child: const Text('确认'), ), ], @@ -896,8 +1314,9 @@ class _StockOutListScreenState extends ConsumerState { full = await ref.read(stockOutRepositoryProvider).get(o.id); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('加载明细失败:$e'), backgroundColor: context.tokens.danger)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('加载明细失败:$e'), + backgroundColor: context.tokens.danger)); } return; } @@ -934,294 +1353,399 @@ class _StockOutListScreenState extends ConsumerState { } } -// Detail dialog — fetches full order with items -class _StockOutDetailDialog extends ConsumerStatefulWidget { - final int orderId; - final StockOutRepository repository; +// ── 详细搜索模态框 ──────────────────────────────────────────────── - const _StockOutDetailDialog({ - required this.orderId, - required this.repository, +/// 详细搜索结果值(提交回父屏一次性应用)。 +class _AdvResult { + String orderNo; + String productInfo; // 商品名 + String productCode; // 商品编码 + String batch; + int? partnerId; + String series; + String spec; + int? operatorId; + int? reviewerId; + String status; + DateTimeRange? dateRange; + + _AdvResult({ + required this.orderNo, + required this.productInfo, + required this.productCode, + required this.batch, + required this.partnerId, + required this.series, + required this.spec, + required this.operatorId, + required this.reviewerId, + required this.status, + required this.dateRange, }); - - @override - ConsumerState<_StockOutDetailDialog> createState() => _StockOutDetailDialogState(); } -class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> { - late Future _future; - Map _inventoryMap = {}; - StockOutOrder? _loadedOrder; +class _AdvancedSearchDialog extends ConsumerStatefulWidget { + final _AdvResult initial; + const _AdvancedSearchDialog({required this.initial}); + + @override + ConsumerState<_AdvancedSearchDialog> createState() => + _AdvancedSearchDialogState(); +} + +class _AdvancedSearchDialogState extends ConsumerState<_AdvancedSearchDialog> { + late final TextEditingController _orderNoCtrl; + late final TextEditingController _productCtrl; + late final TextEditingController _productCodeCtrl; + late final TextEditingController _batchCtrl; + late int? _partnerId; + late String _series; // 系列/规格:按名称(后端 key series/spec) + late String _spec; + late int? _operatorId; + late int? _reviewerId; + late String _status; + DateTimeRange? _dateRange; + + // 状态下拉项(映射到 ComboSearchField 的 int id:0=全部…4=已拒绝)。 + static const _statusCodes = ['', 'draft', 'pending', 'approved', 'rejected']; + static const _statusNames = ['全部', '草稿', '待审核', '已审核', '已拒绝']; @override void initState() { super.initState(); - _future = widget.repository.get(widget.orderId).then((order) async { - if (mounted) setState(() => _loadedOrder = order); - try { - final result = await ref - .read(inventoryRepositoryProvider) - .listInventory(warehouseId: order.warehouseId, pageSize: 500); - if (mounted) { - setState(() { - _inventoryMap = { - for (final inv in result.data.where((inv) => inv.productId != null)) inv.productId!: inv.quantity - }; - }); - } - } catch (_) {} - return order; + 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(); + } + + void _reset() { + setState(() { + _orderNoCtrl.clear(); + _productCtrl.clear(); + _productCodeCtrl.clear(); + _batchCtrl.clear(); + _partnerId = null; + _series = ''; + _spec = ''; + _operatorId = null; + _reviewerId = null; + _status = ''; + _dateRange = null; }); } + void _submit() { + Navigator.of(context).pop(_AdvResult( + orderNo: _orderNoCtrl.text.trim(), + productInfo: _productCtrl.text.trim(), + productCode: _productCodeCtrl.text.trim(), + batch: _batchCtrl.text.trim(), + partnerId: _partnerId, + series: _series, + spec: _spec, + operatorId: _operatorId, + reviewerId: _reviewerId, + status: _status, + dateRange: _dateRange, + )); + } + + Future _pickDate() async { + final range = await showWheelDateRange(context, initial: _dateRange); + if (range != null) setState(() => _dateRange = range); + } + @override Widget build(BuildContext context) { - return Dialog.fullscreen( - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - color: context.tokens.brand50, - border: Border( - bottom: BorderSide(color: context.tokens.border)), - ), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, - color: context.tokens.primaryDark), - tooltip: '返回', - onPressed: () => Navigator.of(context).pop(), - ), - Text('出库单详情', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: context.tokens.primaryDark)), - const Spacer(), - if (_loadedOrder != null) - IconButton( - icon: Icon(Icons.print_outlined, - color: context.tokens.primaryDark), - tooltip: '打印', - onPressed: () => showStockOutOrderPrint(context, ref, _loadedOrder!), - ), - ], + final customers = + (ref.watch(allPartnersProvider).valueOrNull?.data ?? const []) + .map((p) => OptionItem(id: p.id, name: p.name, code: p.code)) + .toList(); + final seriesOpts = + (ref.watch(productSeriesListProvider).valueOrNull ?? const []) + .map((s) => OptionItem(id: s.id, name: s.name)) + .toList(); + final specOpts = + (ref.watch(productSpecListProvider).valueOrNull ?? const []) + .map((s) => OptionItem(id: s.id, name: s.name)) + .toList(); + final users = (ref.watch(userListProvider).valueOrNull ?? const []) + .map((u) => OptionItem(id: u.id, name: u.realName ?? u.username)) + .toList(); + + int? idByName(List opts, String name) => + opts.where((o) => o.name == name).firstOrNull?.id; + + final t = context.tokens; + final width = context.dialogWidth(680); + final isMobile = context.isMobile; + final fullWidth = width - 40; // 体 padding 左右各 20 + final fieldWidth = isMobile ? fullWidth : (fullWidth - 16) / 2; + + Widget labeled(String label, Widget control, {double? w}) => SizedBox( + width: w ?? fieldWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + const SizedBox(height: 6), + control, + ], + ), + ); + + // 文本输入:用与 ComboSearchField 完全相同的 h38 带框 Container 包无边框 TextField, + // 保证与下拉框等高等样式(对齐原型 .input / .combo)。 + Widget textField(String label, TextEditingController ctrl, String hint) => + labeled( + label, + Container( + height: 38, + 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: ctrl, + style: TextStyle(fontSize: AppDims.fsBody, color: t.text), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: hint, + hintStyle: TextStyle(color: t.faint, fontSize: AppDims.fsBody), ), ), + ), + ); + + Widget optionField(String label, List opts, int? selected, + String hint, ValueChanged onChanged) => + labeled( + label, + ComboSearchField( + options: opts, + selectedId: selected, + hint: hint, + onChanged: onChanged, + height: 38, + 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 = labeled( + '单据日期', + InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 38, + 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: FutureBuilder( - future: _future, - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.cloud_off, size: 40, color: context.tokens.muted), - const SizedBox(height: 12), - Text('暂无数据,网络不可用', - style: TextStyle(color: context.tokens.muted)), - ], - ), - ); - } - return _buildContent(snap.data!); - }, + 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(Icons.close, size: 14, color: t.muted), + ) + else + Icon(Icons.date_range, size: 16, color: t.faint), + ]), + ), + ), + w: fullWidth, + ); + + return AlertDialog( + backgroundColor: t.surface, + surfaceTintColor: Colors.transparent, + contentPadding: EdgeInsets.zero, + content: SizedBox( + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 头(原型 .modal-head):标题 + 关闭 + Container( + padding: const EdgeInsets.fromLTRB(20, 16, 16, 16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.border))), + child: Row(children: [ + Text('详细搜索', + style: TextStyle( + fontSize: AppDims.fsH2, + fontWeight: FontWeight.w700, + color: t.heading)), + const Spacer(), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: Icon(Icons.close, size: 20, color: t.muted), + ), + ]), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), + child: Wrap( + spacing: 16, + runSpacing: 12, + children: [ + textField('单据编号', _orderNoCtrl, '如 CK20260401001'), + optionField('客户', customers, _partnerId, '全部客户', + (id) => setState(() => _partnerId = id)), + textField('商品名', _productCtrl, '输入酒名'), + textField('商品编码', _productCodeCtrl, '输入商品编码'), + optionField( + '系列', + seriesOpts, + idByName(seriesOpts, _series), + '全部系列', + (id) => setState(() => _series = id == null + ? '' + : seriesOpts.firstWhere((o) => o.id == id).name)), + optionField( + '规格', + specOpts, + idByName(specOpts, _spec), + '全部规格', + (id) => setState(() => _spec = id == null + ? '' + : specOpts.firstWhere((o) => o.id == id).name)), + textField('批次号', _batchCtrl, '输入批次号'), + labeled( + '状态', + ComboSearchField( + options: statusItems, + selectedId: statusSelIdx <= 0 ? null : statusSelIdx, + hint: '全部', + height: 38, + fillColor: t.surface, + onChanged: (id) => setState(() => + _status = (id == null) ? '' : _statusCodes[id]), + ), + ), + optionField('出库员', users, _operatorId, '全部出库员', + (id) => setState(() => _operatorId = id)), + optionField('审核人', users, _reviewerId, '全部审核人', + (id) => setState(() => _reviewerId = id)), + dateField, + ], + ), + ), + ), + // 脚(原型 .modal-foot):重置(最左) / 取消 / 确认 + Container( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: t.border))), + child: Row(children: [ + DsButton('重置', onPressed: _reset), + const Spacer(), + DsButton('取消', onPressed: () => Navigator.of(context).pop()), + const SizedBox(width: 10), + DsButton('确认', + variant: DsBtnVariant.primary, onPressed: _submit), + ]), + ), ], ), ), ); } - - Widget _buildContent(StockOutOrder o) { - return SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 32, - runSpacing: 12, - children: [ - _InfoField('出库单号', o.orderNo), - _InfoField('状态', _statusLabel(o.status)), - _InfoField('客户/往来单位', o.partnerName ?? '-'), - _InfoField('仓库', o.warehouseName ?? '-'), - _InfoField('出库日期', o.orderDate?.substring(0, 10) ?? '-'), - _InfoField('合计金额', - o.totalAmount != null ? '¥${o.totalAmount!.toStringAsFixed(2)}' : '-'), - if (o.remark != null && o.remark!.isNotEmpty) - _InfoField('备注', o.remark!), - ], - ), - const SizedBox(height: 20), - Text('商品明细', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: context.tokens.primaryDark)), - const SizedBox(height: 8), - if (o.items.isEmpty) - Text('无商品明细', - style: TextStyle(color: context.tokens.muted)) - else - Table( - border: TableBorder.all(color: context.tokens.border, width: 0.5), - columnWidths: const { - 0: FixedColumnWidth(36), - 1: FlexColumnWidth(1.2), - 2: FlexColumnWidth(2.2), - 3: FlexColumnWidth(1.3), - 4: FlexColumnWidth(1.3), - 5: FlexColumnWidth(1.3), - 6: FlexColumnWidth(1.3), - 7: FlexColumnWidth(1.0), - 8: FlexColumnWidth(1.1), - 9: FlexColumnWidth(1.1), - }, - children: [ - TableRow( - decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), - children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '成本价', '售价', '利润'] - .map((h) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Text(h, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: context.tokens.primaryDark)), - )) - .toList(), - ), - ...o.items.asMap().entries.map((e) { - final i = e.key; - final item = e.value; - return TableRow( - decoration: BoxDecoration( - color: i.isEven - ? context.tokens.surface - : context.tokens.bg), - children: [ - _TableCell('${i + 1}'), - _TableCell(item.productCode ?? '-'), - _TableCell(item.productName ?? '-'), - _TableCell(item.productSeries ?? '-'), - _TableCell(item.productSpec ?? '-'), - _TableCell((item.productionDate != null && - item.productionDate!.length >= 10) - ? item.productionDate!.substring(0, 10) - : (item.productionDate ?? '-')), - _TableCell( - (item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'), - _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), - _TableCell('¥${item.salePrice.toStringAsFixed(2)}'), - _TableCell('¥${(item.salePrice - item.unitPrice).toStringAsFixed(2)}'), - ], - ); - }), - ], - ), - ], - ), - ); - } - - String _statusLabel(String status) { - switch (status) { - case 'draft': - return '草稿'; - case 'pending': - return '待审核'; - case 'approved': - return '已审核'; - case 'rejected': - return '已拒绝'; - default: - return status; - } - } } -class _InfoField extends StatelessWidget { +// ── 工具栏菜单 chip(DsChip + PopupMenu 单选)────────────────────── + +class _MenuChip extends StatelessWidget { final String label; - final String value; - - const _InfoField(this.label, this.value); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: TextStyle( - fontSize: 12, color: context.tokens.muted)), - const SizedBox(height: 2), - Text(value, - style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w500)), - ], - ); - } -} - -class _TableCell extends StatelessWidget { - final String text; - - const _TableCell(this.text); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Text(text, style: const TextStyle(fontSize: 13)), - ); - } -} - -class _StatusFilterDropdown extends StatelessWidget { - final String value; - final ValueChanged onChanged; - - const _StatusFilterDropdown({ + final String? value; + final List options; + final ValueChanged onSelected; + final VoidCallback? onClear; // 选中态下 × 单独清该筛选 + const _MenuChip({ + required this.label, required this.value, - required this.onChanged, + required this.options, + required this.onSelected, + this.onClear, }); @override Widget build(BuildContext context) { - return Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - border: Border.all(color: context.tokens.border), - borderRadius: BorderRadius.circular(4), - color: context.tokens.surface, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: value.isEmpty ? '' : value, - items: const [ - DropdownMenuItem(value: '', child: Text('全部状态', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'draft', child: Text('草稿', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'pending', child: Text('待审核', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'approved', child: Text('已审核', style: TextStyle(fontSize: 13))), - DropdownMenuItem(value: 'rejected', child: Text('已拒绝', style: TextStyle(fontSize: 13))), - ], - onChanged: onChanged, - style: TextStyle(fontSize: 13, color: context.tokens.text), - ), + return PopupMenuButton( + onSelected: onSelected, + offset: const Offset(0, 40), + color: context.tokens.surface, + elevation: 8, + shape: RoundedRectangleBorder( + side: BorderSide(color: context.tokens.border), + borderRadius: BorderRadius.circular(AppDims.rMd), ), + itemBuilder: (_) => options.map((s) { + final sel = s == value || (value == null && s == '全部'); + return PopupMenuItem( + value: s, + height: 36, + child: Row(children: [ + SizedBox( + width: 18, + child: sel + ? Icon(LucideIcons.check, + size: 15, color: context.tokens.primary) + : null, + ), + const SizedBox(width: 4), + Text(s, + style: TextStyle( + fontSize: 13, + color: sel ? context.tokens.primary : context.tokens.text, + fontWeight: sel ? FontWeight.w600 : FontWeight.w400)), + ]), + ); + }).toList(), + child: DsChip(label: label, value: value, onClear: onClear), ); } } - diff --git a/client/lib/widgets/combo_search_field.dart b/client/lib/widgets/combo_search_field.dart new file mode 100644 index 0000000..7c4c0d5 --- /dev/null +++ b/client/lib/widgets/combo_search_field.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; + +import '../core/theme/context_tokens.dart'; +import '../core/theme/app_dims.g.dart'; +import 'searchable_option_field.dart' show OptionItem; + +/// 搜索下拉框(对齐原型 .combo + .combo-pop): +/// 触发器(带框 input 样式 + caret/清除)+ 锚定在下方的弹层 +/// (顶部搜索框 + 名称/编码两列 + 选中蓝勾 + 底部「共 N 条结果」)。 +/// +/// 与 SearchableOptionField(居中 AlertDialog)不同——这是**锚定弹层**, +/// 用于列表工具栏「供应商/客户」等就地筛选。 +class ComboSearchField extends StatefulWidget { + final List options; + final int? selectedId; + final String hint; + final ValueChanged onChanged; + /// 固定宽度;传 null 则填满父约束(用于弹窗内 Expanded 布局)。 + final double? width; + /// 触发器高度(工具栏 34,表单/弹窗 38,对齐原型 .combo)。 + final double height; + /// 触发器填充色;null=透明(工具栏用,透出底色)。弹窗内传 surface 保持不透明一致。 + final Color? fillColor; + + const ComboSearchField({ + super.key, + required this.options, + required this.selectedId, + required this.hint, + required this.onChanged, + this.width, + this.height = 34, + this.fillColor, + }); + + @override + State createState() => _ComboSearchFieldState(); +} + +class _ComboSearchFieldState extends State { + final _link = LayerLink(); + OverlayEntry? _entry; + + OptionItem? get _selected { + for (final o in widget.options) { + if (o.id == widget.selectedId) return o; + } + return null; + } + + @override + void dispose() { + _remove(); + super.dispose(); + } + + void _remove() { + _entry?.remove(); + _entry = null; + } + + void _open() { + if (_entry != null) return; + _entry = OverlayEntry(builder: (_) => _buildPopup()); + Overlay.of(context).insert(_entry!); + } + + void _pick(int? id) { + widget.onChanged(id); + _remove(); + } + + Widget _buildPopup() { + final t = context.tokens; + return Stack( + children: [ + // 外部点击关闭 + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _remove, + ), + ), + CompositedTransformFollower( + link: _link, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + offset: const Offset(0, 6), + child: Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(AppDims.rMd), + color: t.surface, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: (widget.width == null || widget.width! < 240) + ? 240 + : widget.width!, + maxWidth: 320), + child: _ComboPop( + options: widget.options, + selectedId: widget.selectedId, + hint: widget.hint, + onPick: _pick, + ), + ), + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final sel = _selected; + return CompositedTransformTarget( + link: _link, + child: SizedBox( + width: widget.width, // null → 由父约束决定(Expanded 内填满) + height: widget.height, + child: InkWell( + borderRadius: BorderRadius.circular(AppDims.rMd), + onTap: _open, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: widget.fillColor, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row( + children: [ + Expanded( + child: Text( + sel?.name ?? widget.hint, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsBody, + color: sel == null ? t.faint : t.text, + ), + ), + ), + if (sel != null) + GestureDetector( + onTap: () => _pick(null), + child: Icon(Icons.close, size: 14, color: t.muted), + ) + else + Icon(Icons.keyboard_arrow_down, size: 16, color: t.faint), + ], + ), + ), + ), + ), + ); + } +} + +/// 弹层内容:搜索框 + 列表(名称 + 编码 + 选中勾)+ 底部计数。 +class _ComboPop extends StatefulWidget { + final List options; + final int? selectedId; + final String hint; + final ValueChanged onPick; + const _ComboPop({ + required this.options, + required this.selectedId, + required this.hint, + required this.onPick, + }); + + @override + State<_ComboPop> createState() => _ComboPopState(); +} + +class _ComboPopState extends State<_ComboPop> { + String _kw = ''; + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final q = _kw.trim().toLowerCase(); + final hits = q.isEmpty + ? widget.options + : widget.options.where((o) => o.matches(_kw)).toList(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 顶部搜索框 + Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.borderSubtle)), + ), + child: Row( + children: [ + Icon(Icons.search, size: 14, color: t.faint), + const SizedBox(width: 8), + Expanded( + child: TextField( + autofocus: true, + style: TextStyle(fontSize: AppDims.fsBody, color: t.text), + decoration: InputDecoration( + isCollapsed: true, + border: InputBorder.none, + hintText: '搜索${widget.hint}…', + hintStyle: + TextStyle(fontSize: AppDims.fsBody, color: t.faint), + ), + onChanged: (v) => setState(() => _kw = v), + ), + ), + ], + ), + ), + // 列表 + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 264), + child: hits.isEmpty + ? Padding( + padding: const EdgeInsets.all(20), + child: Text('无匹配结果', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted))) + : ListView.builder( + padding: const EdgeInsets.all(5), + shrinkWrap: true, + itemCount: hits.length, + itemBuilder: (_, i) { + final o = hits[i]; + final sel = o.id == widget.selectedId; + return InkWell( + borderRadius: BorderRadius.circular(AppDims.rSm), + onTap: () => widget.onPick(o.id), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text(o.name, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsBody, + color: sel ? t.primary : t.text, + fontWeight: sel + ? FontWeight.w600 + : FontWeight.w400)), + ), + if (o.code != null && o.code!.isNotEmpty) ...[ + const SizedBox(width: 12), + Text(o.code!, + style: TextStyle( + fontSize: AppDims.fsXs, + fontFamily: 'monospace', + color: t.faint)), + ], + if (sel) ...[ + const SizedBox(width: 8), + Icon(Icons.check, size: 15, color: t.primary), + ], + ], + ), + ), + ); + }, + ), + ), + // 底部计数 + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: t.bg, + border: Border(top: BorderSide(color: t.borderSubtle)), + ), + child: Text( + q.isEmpty + ? '共 ${widget.options.length} 条结果' + : '匹配 ${hits.length} 条 · 共 ${widget.options.length} 条', + style: TextStyle(fontSize: AppDims.fsXs, color: t.muted), + ), + ), + ], + ); + } +} diff --git a/client/lib/widgets/ds/ds_atoms.dart b/client/lib/widgets/ds/ds_atoms.dart index 3eeeb06..d5d4bdb 100644 --- a/client/lib/widgets/ds/ds_atoms.dart +++ b/client/lib/widgets/ds/ds_atoms.dart @@ -119,7 +119,13 @@ class DsChip extends StatelessWidget { final String label; final String? value; // 选中值(.cv);非空即 on 态 final VoidCallback? onTap; - const DsChip({super.key, required this.label, this.value, this.onTap}); + final VoidCallback? onClear; // 选中态下点 × 单独清除该筛选(不触发展开菜单) + const DsChip( + {super.key, + required this.label, + this.value, + this.onTap, + this.onClear}); @override Widget build(BuildContext context) { @@ -150,7 +156,15 @@ class DsChip extends StatelessWidget { fontWeight: FontWeight.w600)), ], const SizedBox(width: 7), - Icon(Icons.keyboard_arrow_down, size: 14, color: t.faint), + // 选中且提供 onClear → 展示可点 ×(清该筛选);否则展示下拉箭头。 + if (on && onClear != null) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onClear, + child: Icon(Icons.close, size: 14, color: t.muted), + ) + else + Icon(Icons.keyboard_arrow_down, size: 14, color: t.faint), ], ), ), @@ -207,9 +221,54 @@ class DsSearchBox extends StatelessWidget { ), ), ), + // 有输入时显示 × 清除(清空并触发一次空关键词搜索)。 + if (controller != null) + ValueListenableBuilder( + valueListenable: controller!, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + controller!.clear(); + onChanged?.call(''); + onSubmitted?.call(''); + }, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: Icon(Icons.close, size: 14, color: t.muted), + ), + ); + }, + ), ], ), ), ); } } + +/// 加载遮罩(reload 时叠在旧内容之上):轻微半透明底 + 屏幕中心进度圈。 +/// 搜索/筛选刷新时用它替代整屏白屏(见列表屏 skipLoadingOnReload)。 +class DsLoadingScrim extends StatelessWidget { + const DsLoadingScrim({super.key}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return GestureDetector( + behavior: HitTestBehavior.opaque, // 加载期间拦截点击,避免误触 + onTap: () {}, + child: ColoredBox( + color: t.bg.withValues(alpha: 0.55), + child: Center( + child: SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator(strokeWidth: 3, color: t.primary), + ), + ), + ), + ); + } +} diff --git a/client/lib/widgets/ds/ds_table.dart b/client/lib/widgets/ds/ds_table.dart index 148f292..c2ece6c 100644 --- a/client/lib/widgets/ds/ds_table.dart +++ b/client/lib/widgets/ds/ds_table.dart @@ -79,6 +79,8 @@ class _DsTableState extends State { ), clipBehavior: Clip.antiAlias, child: Column( + // 整宽拉伸:toolbar/表格/分页都填满卡片宽度(否则 toolbar 按内容宽居中→左侧留白)。 + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.toolbar != null) ...[ Padding( diff --git a/client/lib/widgets/order_detail_drawer.dart b/client/lib/widgets/order_detail_drawer.dart new file mode 100644 index 0000000..9f45269 --- /dev/null +++ b/client/lib/widgets/order_detail_drawer.dart @@ -0,0 +1,350 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../core/theme/context_tokens.dart'; +import '../core/theme/app_dims.g.dart'; + +/// 右侧滑入抽屉呈现(对齐原型 .drawer / .drawer-mask)。 +/// useRootNavigator:false → 只覆盖内容区(分支 Navigator),不盖侧栏/顶栏。 +Future showOrderDetailDrawer( + BuildContext context, { + required WidgetBuilder builder, +}) { + return showGeneralDialog( + context: context, + useRootNavigator: false, + barrierDismissible: true, + barrierLabel: '关闭', + barrierColor: Colors.black.withValues(alpha: 0.32), // .drawer-mask scrim + transitionDuration: const Duration(milliseconds: 250), + pageBuilder: (ctx, _, __) { + final w = math.min(600.0, MediaQuery.of(ctx).size.width * 0.94); + return Align( + alignment: Alignment.centerRight, + child: Material( + color: ctx.tokens.surface, + elevation: 16, + // 抽屉在 app_shell 的全局 SelectionArea 之外(独立 overlay)→ 自带一层,文字可选中复制。 + child: SelectionArea( + child: SizedBox( + width: w, height: double.infinity, child: builder(ctx)), + ), + ), + ); + }, + transitionBuilder: (ctx, anim, _, child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero) + .animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), + child: child, + ), + ); +} + +/// 抽屉明细行(对齐原型 .lines .lr,5 列:商品·编码 / 系列·规格 / 数量 / 单价 / 金额)。 +class OrderLine { + final String name; + final String code; + final String series; + final String spec; + final String qty; + final String price; // 已算好文案(或「待定价」) + final String amount; + final bool pending; // 单价<=0:单价/金额显示待定价样式 + const OrderLine({ + required this.name, + required this.code, + required this.series, + required this.spec, + required this.qty, + required this.price, + required this.amount, + this.pending = false, + }); +} + +/// 抽屉底部操作分组(对齐原型 .dact-group:标签 + 一排按钮)。 +class DrawerActionGroup { + final String label; + final List buttons; + const DrawerActionGroup(this.label, this.buttons); +} + +/// 详情抽屉骨架(对齐原型 openDetail 布局): +/// 头(单号+X) / 信息行(.drow) / 明细标题 / 明细表(.lines) / 合计(.ltotal) / 操作组(.dactions)。 +class OrderDetailDrawer extends StatelessWidget { + final String title; + final List<({String label, Widget value})> infoRows; + final String linesLabel; // 入库明细 / 出库明细 + final List lines; + final String totalText; // 合计金额(已格式化) + final List actionGroups; + + const OrderDetailDrawer({ + super.key, + required this.title, + required this.infoRows, + required this.linesLabel, + required this.lines, + required this.totalText, + required this.actionGroups, + }); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── 头:单号 + 关闭 ──(.drawer-head) + Container( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 18), + decoration: + BoxDecoration(border: Border(bottom: BorderSide(color: t.border))), + child: Row( + children: [ + Expanded( + child: Text(title, + style: TextStyle( + fontSize: AppDims.fsH2, + fontWeight: FontWeight.w700, + color: t.heading)), + ), + IconButton( + icon: Icon(Icons.close, size: 22, color: t.muted), + splashRadius: 20, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── 信息行(.drow)── + for (final r in infoRows) _DrawerRow(label: r.label, value: r.value), + // ── 明细标题 ── + Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), + child: Text(linesLabel, + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ), + _LinesTable(lines: lines), + // ── 合计(.ltotal)── + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('合计金额 ', + style: TextStyle( + fontSize: AppDims.fsBody, color: t.muted)), + const SizedBox(width: 6), + Text(totalText, + style: TextStyle( + fontSize: 18, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + color: t.accent)), + ], + ), + ), + // ── 操作组(.dactions)── + if (actionGroups.isNotEmpty) const SizedBox(height: 18), + for (var i = 0; i < actionGroups.length; i++) ...[ + if (i > 0) const SizedBox(height: 14), + _ActionGroup(group: actionGroups[i]), + ], + ], + ), + ), + ), + ], + ); + } +} + +class _DrawerRow extends StatelessWidget { + final String label; + final Widget value; + const _DrawerRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.borderSubtle))), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), + const Spacer(), + DefaultTextStyle( + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.text), + child: value, + ), + ], + ), + ); + } +} + +class _LinesTable extends StatelessWidget { + final List lines; + const _LinesTable({required this.lines}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Container( + decoration: BoxDecoration( + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Column( + children: [ + // 表头 + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: t.thBg, + borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), + ), + child: _lineRow( + context, + c1: Text('商品 · 编码', + style: _hStyle(t), overflow: TextOverflow.ellipsis), + c2: Text('系列 / 规格', style: _hStyle(t)), + c3: Text('数量', textAlign: TextAlign.right, style: _hStyle(t)), + c4: Text('单价', textAlign: TextAlign.right, style: _hStyle(t)), + c5: Text('金额', textAlign: TextAlign.right, style: _hStyle(t)), + ), + ), + for (var i = 0; i < lines.length; i++) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + border: i == 0 + ? null + : Border(top: BorderSide(color: t.borderSubtle)), + ), + child: _lineRow( + context, + c1: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(lines[i].name, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.text)), + Text(lines[i].code, + style: TextStyle( + fontSize: AppDims.fsXs, + fontFamily: 'monospace', + color: t.faint)), + ], + ), + c2: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(lines[i].series, + style: + TextStyle(fontSize: AppDims.fsSm, color: t.text)), + Text(lines[i].spec, + style: + TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + ], + ), + c3: Text(lines[i].qty, + textAlign: TextAlign.right, + style: const TextStyle(fontFamily: 'monospace')), + c4: _priceCell(context, lines[i].price, lines[i].pending), + c5: _priceCell(context, lines[i].amount, lines[i].pending, + accent: true), + ), + ), + ], + ), + ); + } + + TextStyle _hStyle(dynamic t) => TextStyle( + fontSize: AppDims.fsSm, color: t.muted, fontWeight: FontWeight.w600); + + Widget _priceCell(BuildContext context, String text, bool pending, + {bool accent = false}) { + final t = context.tokens; + if (pending) { + return Text(text, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: AppDims.fsXs, + color: t.warn, + fontWeight: FontWeight.w600)); + } + return Text(text, + textAlign: TextAlign.right, + style: TextStyle( + fontFamily: 'monospace', + fontWeight: accent ? FontWeight.w700 : FontWeight.w400, + color: accent ? t.text : t.text)); + } + + // 5 列布局(对齐原型 grid 1fr 124 40 64 78)。 + Widget _lineRow(BuildContext context, + {required Widget c1, + required Widget c2, + required Widget c3, + required Widget c4, + required Widget c5}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: c1), + const SizedBox(width: 8), + SizedBox(width: 96, child: c2), + const SizedBox(width: 8), + SizedBox(width: 34, child: c3), + const SizedBox(width: 8), + SizedBox(width: 60, child: c4), + const SizedBox(width: 8), + SizedBox(width: 72, child: c5), + ], + ); + } +} + +class _ActionGroup extends StatelessWidget { + final DrawerActionGroup group; + const _ActionGroup({required this.group}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(group.label, + style: TextStyle( + fontSize: AppDims.fsXs, + color: t.muted, + fontWeight: FontWeight.w600, + letterSpacing: 0.4)), + const SizedBox(height: 8), + Wrap(spacing: 10, runSpacing: 10, children: group.buttons), + ], + ); + } +} diff --git a/client/lib/widgets/searchable_option_field.dart b/client/lib/widgets/searchable_option_field.dart index 414d221..51fc49b 100644 --- a/client/lib/widgets/searchable_option_field.dart +++ b/client/lib/widgets/searchable_option_field.dart @@ -39,6 +39,10 @@ class SearchableOptionField extends StatelessWidget { final bool isRequired; final bool isDense; + /// 带框样式(OutlineInputBorder);默认 false 沿用主题下划线。 + /// 详细搜索等「表单里与文本框并列」的场景传 true,视觉统一为带框。 + final bool bordered; + /// 可选:搜索无匹配时「新增到基础数据」。回调收到当前关键字(用户可在确认框里改), /// 创建成功返回新选项 id(自动选中),失败/取消返回 null。为空则不显示新增入口。 final Future Function(String keyword)? onCreate; @@ -55,6 +59,7 @@ class SearchableOptionField extends StatelessWidget { required this.onChanged, this.isRequired = false, this.isDense = true, + this.bordered = false, this.onCreate, this.onSearch, }); @@ -96,6 +101,7 @@ class SearchableOptionField extends StatelessWidget { child: InputDecorator( decoration: InputDecoration( isDense: isDense, + border: bordered ? const OutlineInputBorder() : null, errorText: state.errorText, suffixIcon: selected ? GestureDetector( diff --git a/client/lib/widgets/wheel_date_picker.dart b/client/lib/widgets/wheel_date_picker.dart new file mode 100644 index 0000000..e74c073 --- /dev/null +++ b/client/lib/widgets/wheel_date_picker.dart @@ -0,0 +1,133 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../core/theme/context_tokens.dart'; +import '../core/theme/app_dims.g.dart'; +import '../core/responsive/responsive.dart'; +import '../core/utils/dialog_util.dart'; + +/// 年/月/日 滚轮日期选择器(对齐原型 .wheel-pop)。选中返回 DateTime,取消返回 null。 +/// 「今天」快速跳到今天;「确定」确认。范围选择由调用方调两次(起/止)拼成。 +Future showWheelDatePicker( + BuildContext context, { + DateTime? initial, + String title = '选择日期', +}) { + return showAppDialog( + context: context, + builder: (_) => _WheelDateDialog(initial: initial ?? DateTime.now(), title: title), + ); +} + +class _WheelDateDialog extends StatefulWidget { + final DateTime initial; + final String title; + const _WheelDateDialog({required this.initial, required this.title}); + + @override + State<_WheelDateDialog> createState() => _WheelDateDialogState(); +} + +class _WheelDateDialogState extends State<_WheelDateDialog> { + late DateTime _value; + + @override + void initState() { + super.initState(); + _value = DateTime(widget.initial.year, widget.initial.month, widget.initial.day); + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppDims.rXl)), + child: SizedBox( + width: context.dialogWidth(320), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Row( + children: [ + Text(widget.title, + style: TextStyle( + fontSize: AppDims.fsTitle, + fontWeight: FontWeight.w600, + color: t.heading)), + ], + ), + ), + // 三列滚轮(年/月/日),中间高亮行由 CupertinoDatePicker 提供。 + SizedBox( + height: 200, + child: CupertinoTheme( + data: CupertinoThemeData( + textTheme: CupertinoTextThemeData( + dateTimePickerTextStyle: TextStyle(fontSize: 18, color: t.text), + ), + ), + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.date, + initialDateTime: _value, + minimumYear: 2015, + maximumYear: 2035, + onDateTimeChanged: (d) => + _value = DateTime(d.year, d.month, d.day), + ), + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), + child: Row( + children: [ + TextButton( + onPressed: () { + final now = DateTime.now(); + setState(() => _value = DateTime(now.year, now.month, now.day)); + }, + child: Text('今天', style: TextStyle(color: t.muted)), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('取消', style: TextStyle(color: t.muted)), + ), + const SizedBox(width: 4), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(_value), + style: ElevatedButton.styleFrom( + backgroundColor: t.primary, + foregroundColor: t.onPrimary, + elevation: 0, + ), + child: const Text('确定'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 范围选择:先选起始日、再选结束日;任一取消则返回 null。 +Future showWheelDateRange( + BuildContext context, { + DateTimeRange? initial, +}) async { + final start = await showWheelDatePicker(context, + initial: initial?.start, title: '起始日期'); + if (start == null || !context.mounted) return null; + final end = await showWheelDatePicker(context, + initial: initial?.end ?? start, title: '结束日期'); + if (end == null) return null; + // 保证 start <= end + return end.isBefore(start) + ? DateTimeRange(start: end, end: start) + : DateTimeRange(start: start, end: end); +} diff --git a/client/macos/Runner/MainFlutterWindow.swift b/client/macos/Runner/MainFlutterWindow.swift index 3cc05eb..8eb18c2 100644 --- a/client/macos/Runner/MainFlutterWindow.swift +++ b/client/macos/Runner/MainFlutterWindow.swift @@ -4,9 +4,17 @@ import FlutterMacOS class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController() - let windowFrame = self.frame self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) + + // 最小尺寸,避免窗口被拖得过小导致密集表格挤压。 + self.minSize = NSSize(width: 1120, height: 720) + // 记住用户上次调整的窗口尺寸/位置。 + _ = self.setFrameAutosaveName("YanmeiJiuMainWindow") + // 首次启动(无历史或过小)→ 采用较大默认尺寸并居中。 + if self.frame.size.width < self.minSize.width { + self.setContentSize(NSSize(width: 1440, height: 900)) + self.center() + } RegisterGeneratedPlugins(registry: flutterViewController) diff --git a/client/test/golden/goldens/stock_in_form_a.png b/client/test/golden/goldens/stock_in_form_a.png index 32eef7d..eb1bd8f 100644 Binary files a/client/test/golden/goldens/stock_in_form_a.png and b/client/test/golden/goldens/stock_in_form_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_b.png b/client/test/golden/goldens/stock_in_form_b.png index a4f5d08..aeeda40 100644 Binary files a/client/test/golden/goldens/stock_in_form_b.png and b/client/test/golden/goldens/stock_in_form_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_c.png b/client/test/golden/goldens/stock_in_form_c.png index 1647b66..2653e52 100644 Binary files a/client/test/golden/goldens/stock_in_form_c.png and b/client/test/golden/goldens/stock_in_form_c.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_a.png b/client/test/golden/goldens/stock_in_form_mobile_a.png index af4ebd9..a1ead43 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_a.png and b/client/test/golden/goldens/stock_in_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_b.png b/client/test/golden/goldens/stock_in_form_mobile_b.png index c4a3ec3..2aee146 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_b.png and b/client/test/golden/goldens/stock_in_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_c.png b/client/test/golden/goldens/stock_in_form_mobile_c.png index c642cf1..fafcab4 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_c.png and b/client/test/golden/goldens/stock_in_form_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_in_list_a.png b/client/test/golden/goldens/stock_in_list_a.png index acde251..dc008ff 100644 Binary files a/client/test/golden/goldens/stock_in_list_a.png and b/client/test/golden/goldens/stock_in_list_a.png differ diff --git a/client/test/golden/goldens/stock_in_list_b.png b/client/test/golden/goldens/stock_in_list_b.png index 62c09f2..1ce99c6 100644 Binary files a/client/test/golden/goldens/stock_in_list_b.png and b/client/test/golden/goldens/stock_in_list_b.png differ diff --git a/client/test/golden/goldens/stock_in_list_c.png b/client/test/golden/goldens/stock_in_list_c.png index 1e3bada..0615192 100644 Binary files a/client/test/golden/goldens/stock_in_list_c.png and b/client/test/golden/goldens/stock_in_list_c.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_a.png b/client/test/golden/goldens/stock_in_list_mobile_a.png index 71f9ca4..46d7203 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_a.png and b/client/test/golden/goldens/stock_in_list_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_b.png b/client/test/golden/goldens/stock_in_list_mobile_b.png index 751fa0c..5b56517 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_b.png and b/client/test/golden/goldens/stock_in_list_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_list_mobile_c.png b/client/test/golden/goldens/stock_in_list_mobile_c.png index 42879f7..c1bc085 100644 Binary files a/client/test/golden/goldens/stock_in_list_mobile_c.png and b/client/test/golden/goldens/stock_in_list_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_a.png b/client/test/golden/goldens/stock_out_form_a.png index a521fa7..6498baa 100644 Binary files a/client/test/golden/goldens/stock_out_form_a.png and b/client/test/golden/goldens/stock_out_form_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_b.png b/client/test/golden/goldens/stock_out_form_b.png index 4bd726e..566735e 100644 Binary files a/client/test/golden/goldens/stock_out_form_b.png and b/client/test/golden/goldens/stock_out_form_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_c.png b/client/test/golden/goldens/stock_out_form_c.png index b22a064..92857f5 100644 Binary files a/client/test/golden/goldens/stock_out_form_c.png and b/client/test/golden/goldens/stock_out_form_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_a.png b/client/test/golden/goldens/stock_out_form_mobile_a.png index cbe9326..b6399a0 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_a.png and b/client/test/golden/goldens/stock_out_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_b.png b/client/test/golden/goldens/stock_out_form_mobile_b.png index 9b0c616..4aa8516 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_b.png and b/client/test/golden/goldens/stock_out_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_c.png b/client/test/golden/goldens/stock_out_form_mobile_c.png index d88f928..bbc1ea4 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_c.png and b/client/test/golden/goldens/stock_out_form_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_out_list_a.png b/client/test/golden/goldens/stock_out_list_a.png index 52cbd7a..b8486a8 100644 Binary files a/client/test/golden/goldens/stock_out_list_a.png and b/client/test/golden/goldens/stock_out_list_a.png differ diff --git a/client/test/golden/goldens/stock_out_list_b.png b/client/test/golden/goldens/stock_out_list_b.png index ccd095d..355ba57 100644 Binary files a/client/test/golden/goldens/stock_out_list_b.png and b/client/test/golden/goldens/stock_out_list_b.png differ diff --git a/client/test/golden/goldens/stock_out_list_c.png b/client/test/golden/goldens/stock_out_list_c.png index 3041662..9d93975 100644 Binary files a/client/test/golden/goldens/stock_out_list_c.png and b/client/test/golden/goldens/stock_out_list_c.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_a.png b/client/test/golden/goldens/stock_out_list_mobile_a.png index b27e35c..78989d6 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_a.png and b/client/test/golden/goldens/stock_out_list_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_b.png b/client/test/golden/goldens/stock_out_list_mobile_b.png index 8448506..c7f748f 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_b.png and b/client/test/golden/goldens/stock_out_list_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_out_list_mobile_c.png b/client/test/golden/goldens/stock_out_list_mobile_c.png index 7fb0223..a526d0d 100644 Binary files a/client/test/golden/goldens/stock_out_list_mobile_c.png and b/client/test/golden/goldens/stock_out_list_mobile_c.png differ diff --git a/client/test/stock_in_screen_test.dart b/client/test/stock_in_screen_test.dart index 6f6659a..385498a 100644 --- a/client/test/stock_in_screen_test.dart +++ b/client/test/stock_in_screen_test.dart @@ -12,7 +12,8 @@ import 'package:jiu_client/providers/license_provider.dart'; import 'package:jiu_client/providers/stock_in_provider.dart'; import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart'; -/// WriteGuard 内层会 watch licenseProvider;覆写成同步 normal 授权,避免真实网络请求。 +/// 入库列表屏(照原型重建:单视图无 tab)的行为测试。 +/// WriteGuard 内层 watch licenseProvider;覆写成同步 normal 授权,避免真实网络请求。 class _FakeLicenseNotifier extends LicenseNotifier { @override Future build() async => const LicenseInfo( @@ -24,13 +25,8 @@ class _FakeLicenseNotifier extends LicenseNotifier { ); } -// --------------------------------------------------------------------------- -// Fake notifier -// --------------------------------------------------------------------------- - class _FakeStockInNotifier extends StockInListNotifier { final AsyncValue> _fixed; - _FakeStockInNotifier(this._fixed); @override @@ -40,67 +36,65 @@ class _FakeStockInNotifier extends StockInListNotifier { const PageResult(data: [], total: 0, page: 1, pageSize: 20); } + // 交互 setter 全部 no-op,避免命中真实 repo。 @override void reload() {} - @override void setPage(int page) {} - + @override + void setPageSize(int pageSize) {} @override void setStatus(String status) {} - + @override + void setKeyword(String keyword) {} @override void setDateRange(String? startDate, String? endDate) {} - + @override + void setDetail(Map detail) {} @override Future createOrder(Map data) async {} - @override Future submitOrder(int id) async {} - @override Future approveOrder(int id) async {} - @override Future rejectOrder(int id) async {} } -/// A notifier that stays permanently in loading state (build never completes). +/// 永远停在 loading(build 不完成)。 class _FakeLoadingStockInNotifier extends StockInListNotifier { @override - Future> build() { - // Never completes — keeps the widget in CircularProgressIndicator state. - return Completer>().future; + Future> build() => + Completer>().future; + @override + void reload() {} +} + +/// 持久化 provider 里已有残留关键词 'sfs'(模拟用户上次搜索后未清)。 +/// build 仍返回数据——用来验证「进页面把残留筛选回填到 UI」而非隐形丢数据。 +class _StaleKeywordStockInNotifier extends StockInListNotifier { + @override + Future> build() async { + state = AsyncValue.data( + PageResult(data: [_makeOrder()], total: 1, page: 1, pageSize: 20)); + return state.value!; } @override - void reload() {} - + String get currentKeyword => 'sfs'; @override - void setPage(int page) {} - + void reload() {} @override void setStatus(String status) {} - + @override + void setKeyword(String keyword) {} @override void setDateRange(String? startDate, String? endDate) {} - @override - Future createOrder(Map data) async {} - - @override - Future submitOrder(int id) async {} - - @override - Future approveOrder(int id) async {} - - @override - Future rejectOrder(int id) async {} + void setDetail(Map detail) {} } -/// Build a testable app. GoRouter is needed because the screen uses -/// `context.go('/stock-in/new')`. -Widget _buildApp(AsyncValue> state) { +Widget _appWith(Override overrideNotifier) { final router = GoRouter( initialLocation: '/stock-in', routes: [ @@ -114,39 +108,20 @@ Widget _buildApp(AsyncValue> state) { ), ], ); - return ProviderScope( overrides: [ licenseProvider.overrideWith(() => _FakeLicenseNotifier()), - stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)), + overrideNotifier, ], child: MaterialApp.router(theme: buildTheme('a'), routerConfig: router), ); } -Widget _buildLoadingApp() { - final router = GoRouter( - initialLocation: '/stock-in', - routes: [ - GoRoute( - path: '/stock-in', - builder: (_, __) => const Scaffold(body: StockInListScreen()), - ), - GoRoute( - path: '/stock-in/new', - builder: (_, __) => const Scaffold(body: Text('new order form')), - ), - ], - ); +Widget _buildApp(AsyncValue> state) => _appWith( + stockInListProvider.overrideWith(() => _FakeStockInNotifier(state))); - return ProviderScope( - overrides: [ - licenseProvider.overrideWith(() => _FakeLicenseNotifier()), - stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()), - ], - child: MaterialApp.router(theme: buildTheme('a'), routerConfig: router), - ); -} +Widget _buildLoadingApp() => _appWith( + stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier())); StockInOrder _makeOrder({ int id = 1, @@ -166,253 +141,99 @@ StockInOrder _makeOrder({ orderDate: '2024-01-10', ); -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- -void main() { - group('StockInListScreen', () { - testWidgets('shows CircularProgressIndicator while loading', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); +void _bigView(WidgetTester tester) { + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); +} +void main() { + group('StockInListScreen(单视图重建版)', () { + testWidgets('loading 显示进度圈', (tester) async { + _bigView(tester); await tester.pumpWidget(_buildLoadingApp()); await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsOneWidget); }); - testWidgets('shows error message and retry button on failure', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - + testWidgets('error 显示「加载失败」+ 重试', (tester) async { + _bigView(tester); await tester.pumpWidget( - _buildApp(AsyncValue.error('连接超时', StackTrace.empty)), - ); - await tester.pump(); - - expect(find.text('暂无数据,网络不可用'), findsOneWidget); + _buildApp(AsyncValue.error('连接超时', StackTrace.empty))); + await tester.pumpAndSettle(); + expect(find.textContaining('加载失败'), findsOneWidget); expect(find.text('重试'), findsOneWidget); }); - testWidgets('shows 暂无入库单 when list is empty', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - + testWidgets('空列表显示空态提示', (tester) async { + _bigView(tester); const empty = PageResult(data: [], total: 0, page: 1, pageSize: 20); - await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); - await tester.pump(); - - expect(find.text('暂无入库单'), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.textContaining('没有匹配的入库单'), findsOneWidget); }); - testWidgets('shows order list with order number and supplier', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - + testWidgets('列表直接展示单号与供应商(无 tab)', (tester) async { + _bigView(tester); final orders = [ _makeOrder(id: 1, orderNo: 'SI2024010001', partnerName: '张家酒水'), _makeOrder(id: 2, orderNo: 'SI2024010002', partnerName: '李记酒行'), ]; - - final result = - PageResult(data: orders, total: 2, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(AsyncValue.data(result))); - await tester.pump(); - - // draft orders only appear in the "入库审核" tab (index 1) - await tester.tap(find.text('入库审核')); + await tester.pumpWidget(_buildApp( + AsyncValue.data(PageResult(data: orders, total: 2, page: 1, pageSize: 20)))); await tester.pumpAndSettle(); - expect(find.text('SI2024010001'), findsOneWidget); - expect(find.text('SI2024010002'), findsOneWidget); - expect(find.text('张家酒水'), findsOneWidget); expect(find.text('李记酒行'), findsOneWidget); }); - testWidgets('shows 新建入库单 button in toolbar', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - + testWidgets('工具栏有「新增入库」并可导航', (tester) async { + _bigView(tester); const empty = PageResult(data: [], total: 0, page: 1, pageSize: 20); - await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); - await tester.pump(); - - // "新建入库审核单" button only appears in the "入库审核" tab (index 1) - await tester.tap(find.text('入库审核')); await tester.pumpAndSettle(); - - expect(find.text('新建入库审核单'), findsOneWidget); - }); - - testWidgets('tapping 新建入库单 navigates to new order route', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - - const empty = - PageResult(data: [], total: 0, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); - await tester.pump(); - - // button only exists in the "入库审核" tab (index 1) - await tester.tap(find.text('入库审核')); + final newBtn = find.text('新增入库'); + expect(newBtn, findsOneWidget); + await tester.tap(newBtn); await tester.pumpAndSettle(); - - await tester.tap(find.text('新建入库审核单')); - await tester.pumpAndSettle(); - expect(find.text('new order form'), findsOneWidget); }); - testWidgets('draft order shows 提交 button', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - - final orders = [_makeOrder(id: 1, status: 'draft')]; - final result = - PageResult(data: orders, total: 1, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(AsyncValue.data(result))); - await tester.pump(); - - // draft orders only appear in the "入库审核" tab (index 1) - await tester.tap(find.text('入库审核')); - await tester.pumpAndSettle(); - - expect(find.text('提交'), findsOneWidget); + // 操作已改为「行内只放眼睛,审核/退单/打印移入右侧详情抽屉」。 + // 抽屉内容与动作布局由 order_detail_drawer 组件保真验证,这里只验行内行为。 + testWidgets('各状态行内只显示眼睛(详情入口),无内联审核按钮', (tester) async { + _bigView(tester); + for (final status in ['draft', 'pending', 'approved']) { + final orders = [_makeOrder(id: 1, status: status)]; + await tester.pumpWidget(_buildApp(AsyncValue.data( + PageResult(data: orders, total: 1, page: 1, pageSize: 20)))); + await tester.pumpAndSettle(); + expect(find.byTooltip('详情'), findsOneWidget); // 眼睛图标 + expect(find.byKey(const Key('btn_approve_1')), findsNothing); + expect(find.byKey(const Key('btn_reject_1')), findsNothing); + expect(find.text('提交'), findsNothing); + } }); - testWidgets('pending order shows 通过 and 拒绝 buttons', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - - final orders = [_makeOrder(id: 1, status: 'pending')]; - final result = - PageResult(data: orders, total: 1, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(AsyncValue.data(result))); - await tester.pump(); - - // pending orders only appear in the "入库审核" tab (index 1) - await tester.tap(find.text('入库审核')); + testWidgets('进页面把 provider 残留关键词回填到搜索框(不隐形过滤)', + (tester) async { + _bigView(tester); + await tester.pumpWidget(_appWith( + stockInListProvider.overrideWith(() => _StaleKeywordStockInNotifier()))); await tester.pumpAndSettle(); - - expect(find.byKey(const Key('btn_approve_1')), findsOneWidget); - expect(find.byKey(const Key('btn_reject_1')), findsOneWidget); + // 残留 'sfs' 必须显示在搜索框里,用户看得见可清除; + // 修复前入库屏无 initState 回填 → 框空但仍按 'sfs' 过滤 → 列表恒空。 + expect(find.text('sfs'), findsOneWidget); }); - testWidgets('approved order shows no action buttons', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - - final orders = [_makeOrder(id: 1, status: 'approved')]; - final result = - PageResult(data: orders, total: 1, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(AsyncValue.data(result))); - await tester.pump(); - - expect(find.byKey(const Key('btn_approve_1')), findsNothing); - expect(find.byKey(const Key('btn_reject_1')), findsNothing); - expect(find.text('提交'), findsNothing); - }); - - testWidgets('tapping approve shows confirmation dialog', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - - final orders = [ - _makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001') - ]; - final result = - PageResult(data: orders, total: 1, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(AsyncValue.data(result))); - await tester.pump(); - - // pending orders only appear in the "入库审核" tab (index 1) - await tester.tap(find.text('入库审核')); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('btn_approve_1'))); - await tester.pumpAndSettle(); - - expect(find.text('审核确认'), findsOneWidget); - expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1)); - expect(find.text('通过'), findsAtLeastNWidgets(1)); - }); - - testWidgets('tapping reject shows confirmation dialog', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - - final orders = [ - _makeOrder(id: 1, status: 'pending', orderNo: 'SI2024010001') - ]; - final result = - PageResult(data: orders, total: 1, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(AsyncValue.data(result))); - await tester.pump(); - - // pending orders only appear in the "入库审核" tab (index 1) - await tester.tap(find.text('入库审核')); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('btn_reject_1'))); - await tester.pumpAndSettle(); - - expect(find.text('拒绝确认'), findsOneWidget); - expect(find.textContaining('SI2024010001'), findsAtLeastNWidgets(1)); - expect(find.text('拒绝'), findsAtLeastNWidgets(1)); - }); - - testWidgets('shows status filter dropdown in first tab toolbar', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - - const empty = - PageResult(data: [], total: 0, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(const AsyncValue.data(empty))); - await tester.pump(); - - await tester.tap(find.text('入库单')); - await tester.pumpAndSettle(); - - expect(find.text('全部状态'), findsOneWidget); - }); - - testWidgets('shows total record count in pagination bar', (tester) async { - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - + testWidgets('分页栏显示总数', (tester) async { + _bigView(tester); final orders = [_makeOrder(id: 1)]; - final result = - PageResult(data: orders, total: 35, page: 1, pageSize: 20); - - await tester.pumpWidget(_buildApp(AsyncValue.data(result))); - await tester.pump(); - - expect(find.textContaining('共 35 条记录'), findsOneWidget); + await tester.pumpWidget(_buildApp( + AsyncValue.data(PageResult(data: orders, total: 35, page: 1, pageSize: 20)))); + await tester.pumpAndSettle(); + expect(find.textContaining('共 35'), findsOneWidget); }); }); } diff --git a/client/test/stock_in_search_debounce_test.dart b/client/test/stock_in_search_debounce_test.dart index 010003c..ba29fe8 100644 --- a/client/test/stock_in_search_debounce_test.dart +++ b/client/test/stock_in_search_debounce_test.dart @@ -19,6 +19,7 @@ class _CountingStockInRepo extends StockInRepository { String? startDate, String? endDate, String? keyword, + Map? detail, int page = 1, int pageSize = 20, }) async { diff --git a/client/windows/runner/main.cpp b/client/windows/runner/main.cpp index 78ecdfe..e40d558 100644 --- a/client/windows/runner/main.cpp +++ b/client/windows/runner/main.cpp @@ -28,7 +28,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); + Win32Window::Size size(1440, 900); // Window title as UTF-8 bytes (hex-escaped to keep this source pure ASCII and // avoid MSVC C4819 under GBK code page 936). Decoded to UTF-16 for Win32. // Bytes below are the UTF-8 encoding of the Chinese app title.