5a3907e3f9
FilterableColumnHeader: - mainAxisAlignment.spaceBetween 将图标推到列头右侧 - 漏斗图标从 13px 增大到 16px,清除图标 14px 各页面改动: - batch_tracking:状态/仓库/供应商筛选移入列头,工具栏合并为一行 - finance:类型/往来单位筛选移入列头,工具栏保留月份选择和显示字段 - inventory:仓库筛选从 API 端单选改为客户端多选,移入"仓库"列头 - products:品牌列头加筛选,从加载数据中动态派生选项 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
367 lines
12 KiB
Dart
367 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/theme/app_theme.dart';
|
|
import '../../models/inventory.dart';
|
|
import '../../providers/inventory_provider.dart';
|
|
import '../../widgets/data_table_card.dart';
|
|
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
|
|
|
class BatchTrackingScreen extends ConsumerStatefulWidget {
|
|
const BatchTrackingScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<BatchTrackingScreen> createState() =>
|
|
_BatchTrackingScreenState();
|
|
}
|
|
|
|
class _BatchTrackingScreenState extends ConsumerState<BatchTrackingScreen> {
|
|
int _page = 1;
|
|
int _total = 0;
|
|
late Future<List<ProductTrackingRecord>> _future;
|
|
List<ProductTrackingRecord> _records = [];
|
|
|
|
Set<String> _filterStatus = {};
|
|
Set<String> _filterWarehouse = {};
|
|
Set<String> _filterSupplier = {};
|
|
Set<String> _hiddenCols = {};
|
|
|
|
static const _colDefs = [
|
|
ColDef('product', '商品', required: true),
|
|
ColDef('spec', '规格', minWidth: 1100),
|
|
ColDef('batch', '批次号', minWidth: 1000),
|
|
ColDef('order_no', '入库单号', minWidth: 900),
|
|
ColDef('supplier', '供应商', minWidth: 1100),
|
|
ColDef('warehouse', '仓库'),
|
|
ColDef('date', '入库日期', minWidth: 1000),
|
|
ColDef('qty', '数量'),
|
|
ColDef('price', '单价', minWidth: 900),
|
|
ColDef('status', '状态'),
|
|
ColDef('buyer', '买家/时间'),
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_fetch();
|
|
}
|
|
|
|
void _fetch() {
|
|
_future = ref
|
|
.read(inventoryRepositoryProvider)
|
|
.listProducts(page: _page, pageSize: 20)
|
|
.then((r) {
|
|
_total = r.total;
|
|
_records = r.data;
|
|
return r.data;
|
|
});
|
|
}
|
|
|
|
void _refetch() => setState(() => _fetch());
|
|
|
|
List<ProductTrackingRecord> _applyFilters(
|
|
List<ProductTrackingRecord> all) {
|
|
return all.where((r) {
|
|
if (_filterStatus.isNotEmpty) {
|
|
final label = r.isSoldOut ? '已卖出' : '在售';
|
|
if (!_filterStatus.contains(label)) return false;
|
|
}
|
|
if (_filterWarehouse.isNotEmpty) {
|
|
if (!_filterWarehouse.contains(r.warehouseName ?? '')) return false;
|
|
}
|
|
if (_filterSupplier.isNotEmpty) {
|
|
if (!_filterSupplier.contains(r.supplierName ?? '')) return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FutureBuilder<List<ProductTrackingRecord>>(
|
|
future: _future,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snap.hasError) {
|
|
// 有缓存数据时展示缓存,顶部加提示条
|
|
if (_records.isNotEmpty) {
|
|
return Column(
|
|
children: [
|
|
_OfflineBanner(onRetry: _refetch),
|
|
Expanded(child: _buildTable(_applyFilters(_records))),
|
|
],
|
|
);
|
|
}
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
|
|
const SizedBox(height: 12),
|
|
const Text('暂无数据,网络不可用', style: TextStyle(color: AppTheme.textSecondary)),
|
|
const SizedBox(height: 12),
|
|
ElevatedButton(onPressed: _refetch, child: const Text('重试')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
final filtered = _applyFilters(_records);
|
|
return _buildTable(filtered);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildTable(List<ProductTrackingRecord> records) {
|
|
// Derive filter options from all loaded records
|
|
final warehouseOptions = _records
|
|
.map((r) => r.warehouseName ?? '')
|
|
.where((s) => s.isNotEmpty)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
final supplierOptions = _records
|
|
.map((r) => r.supplierName ?? '')
|
|
.where((s) => s.isNotEmpty)
|
|
.toSet()
|
|
.toList()
|
|
..sort();
|
|
|
|
// Build visible columns (respect manual hide + responsive auto-hide)
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final visibleCols = _colDefs
|
|
.where((c) =>
|
|
!_hiddenCols.contains(c.key) &&
|
|
(c.minWidth == null || screenWidth >= c.minWidth!))
|
|
.toList();
|
|
|
|
final columns = visibleCols.map((c) {
|
|
final label = switch (c.key) {
|
|
'status' => FilterableColumnHeader(
|
|
text: c.label,
|
|
options: const ['在售', '已卖出'],
|
|
selected: _filterStatus,
|
|
onChanged: (v) => setState(() => _filterStatus = v),
|
|
),
|
|
'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 == 'qty' || c.key == 'price');
|
|
}).toList();
|
|
|
|
final rows = records.isEmpty
|
|
? [
|
|
DataRow(
|
|
cells: List.generate(
|
|
visibleCols.length,
|
|
(i) => i == 1
|
|
? const DataCell(Text('暂无记录',
|
|
style: TextStyle(color: AppTheme.textSecondary)))
|
|
: const DataCell(SizedBox()),
|
|
),
|
|
),
|
|
]
|
|
: records
|
|
.map((r) => DataRow(
|
|
cells: visibleCols
|
|
.map((c) => _buildCell(c.key, r))
|
|
.toList(),
|
|
))
|
|
.toList();
|
|
|
|
return DataTableCard(
|
|
totalCount: _total,
|
|
page: _page,
|
|
onPageChanged: (p) => setState(() {
|
|
_page = p;
|
|
_fetch();
|
|
}),
|
|
toolbar: Row(
|
|
children: [
|
|
const Text('已审核入库商品(含库存与销售状态)',
|
|
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 18),
|
|
onPressed: () => setState(() {
|
|
_page = 1;
|
|
_fetch();
|
|
}),
|
|
tooltip: '刷新',
|
|
),
|
|
const SizedBox(width: 4),
|
|
ColumnToggleButton(
|
|
columns: _colDefs,
|
|
hidden: _hiddenCols,
|
|
onChanged: (v) => setState(() => _hiddenCols = v),
|
|
),
|
|
],
|
|
),
|
|
columns: columns,
|
|
rows: rows,
|
|
);
|
|
}
|
|
|
|
DataCell _buildCell(String key, ProductTrackingRecord r) {
|
|
final batchText =
|
|
(r.batchNo != null && r.batchNo!.isNotEmpty) ? r.batchNo! : null;
|
|
switch (key) {
|
|
case 'product':
|
|
return DataCell(Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(r.productName ?? '-',
|
|
style: const TextStyle(
|
|
fontSize: 13, fontWeight: FontWeight.w500)),
|
|
if (r.productCode != null)
|
|
Text(r.productCode!,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppTheme.textSecondary,
|
|
fontFamily: 'monospace')),
|
|
],
|
|
));
|
|
case 'spec':
|
|
return DataCell(Text(r.productSpec ?? '-',
|
|
style: const TextStyle(fontSize: 12)));
|
|
case 'batch':
|
|
return DataCell(batchText != null
|
|
? Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primary.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(batchText,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppTheme.primary,
|
|
fontFamily: 'monospace')),
|
|
)
|
|
: const Text('无批次',
|
|
style: TextStyle(
|
|
fontSize: 12, color: AppTheme.textSecondary)));
|
|
case 'order_no':
|
|
return DataCell(Text(r.orderNo ?? '-',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: AppTheme.primary,
|
|
fontFamily: 'monospace')));
|
|
case 'supplier':
|
|
return DataCell(Text(r.supplierName ?? '-',
|
|
style: const TextStyle(fontSize: 12)));
|
|
case 'warehouse':
|
|
return DataCell(Text(r.warehouseName ?? '-',
|
|
style: const TextStyle(fontSize: 12)));
|
|
case 'date':
|
|
return DataCell(Text(r.orderDate?.substring(0, 10) ?? '-',
|
|
style: const TextStyle(fontSize: 12)));
|
|
case 'qty':
|
|
return DataCell(Text(
|
|
'${r.quantity.toStringAsFixed(0)} ${r.productUnit ?? ''}',
|
|
style: const TextStyle(fontWeight: FontWeight.w500)));
|
|
case 'price':
|
|
return DataCell(Text('¥${r.unitPrice.toStringAsFixed(2)}',
|
|
style: const TextStyle(fontSize: 13)));
|
|
case 'status':
|
|
return DataCell(_StatusBadge(r.isSoldOut));
|
|
case 'buyer':
|
|
return DataCell(r.isSoldOut
|
|
? Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
r.buyerName?.isNotEmpty == true ? r.buyerName! : '未知买家',
|
|
style: const TextStyle(
|
|
fontSize: 12, fontWeight: FontWeight.w500),
|
|
),
|
|
if (r.soldAt != null)
|
|
Text(
|
|
r.soldAt!.length > 10
|
|
? r.soldAt!.substring(0, 10)
|
|
: r.soldAt!,
|
|
style: const TextStyle(
|
|
fontSize: 11, color: AppTheme.textSecondary)),
|
|
],
|
|
)
|
|
: const Text('-',
|
|
style: TextStyle(color: AppTheme.textSecondary)));
|
|
default:
|
|
return const DataCell(SizedBox());
|
|
}
|
|
}
|
|
}
|
|
|
|
class _StatusBadge extends StatelessWidget {
|
|
final bool isSoldOut;
|
|
const _StatusBadge(this.isSoldOut);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: isSoldOut
|
|
? AppTheme.textSecondary.withOpacity(0.12)
|
|
: AppTheme.success.withOpacity(0.12),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
child: Text(
|
|
isSoldOut ? '已卖出' : '在售',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: isSoldOut ? AppTheme.textSecondary : AppTheme.success,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _OfflineBanner extends StatelessWidget {
|
|
final VoidCallback onRetry;
|
|
const _OfflineBanner({required this.onRetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
color: const Color(0xFFFFF8E1),
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.cloud_off, size: 14, color: Color(0xFFF57F17)),
|
|
const SizedBox(width: 8),
|
|
const Expanded(
|
|
child: Text('网络不可用,当前显示离线缓存数据',
|
|
style: TextStyle(color: Color(0xFFF57F17), fontSize: 12)),
|
|
),
|
|
TextButton(
|
|
onPressed: onRetry,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: const Color(0xFFF57F17),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8)),
|
|
child: const Text('重试', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|