feat(client): 初版 Flutter UI 框架,仿照参考截图实现
- 蓝色顶栏(#1565C0)+ 深蓝侧边栏(#0D47A1),含折叠/展开 - 底部状态栏:门店编号、登录用户、登录时间、实时时钟、版本号 - 登录页:居中卡片,支持密码显示/隐藏 - 入库单列表:筛选/搜索/日期选择/分页,三个 Tab(入库单/查询/审核) - 新建入库单:基本信息 + 商品明细动态增删,实时计算合计金额 - 出库单列表:同入库单风格 - 库存查询:4 张统计卡片 + 颜色高亮缺货/预警行,库存预警 Tab - 往来单位、财务、商品、系统设置(用户/仓库/编号规则/系统参数) - 使用 Riverpod 状态管理 + go_router 路由 + Dio HTTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
class InventoryCheckScreen extends ConsumerStatefulWidget {
|
||||
const InventoryCheckScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InventoryCheckScreen> createState() =>
|
||||
_InventoryCheckScreenState();
|
||||
}
|
||||
|
||||
class _InventoryCheckScreenState
|
||||
extends ConsumerState<InventoryCheckScreen> {
|
||||
final _checkNoCtrl =
|
||||
TextEditingController(text: 'PD20260404001');
|
||||
String _warehouse = '主仓库';
|
||||
String _checkType = '全盘';
|
||||
bool _submitting = false;
|
||||
|
||||
// Mock inventory items for checking
|
||||
final List<Map<String, dynamic>> _checkItems = [
|
||||
{
|
||||
'code': 'SP001',
|
||||
'name': '茅台酒(飞天)53度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 286,
|
||||
'actualQtyCtrl': TextEditingController(text: '286'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP002',
|
||||
'name': '五粮液(普五)52度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 152,
|
||||
'actualQtyCtrl': TextEditingController(text: '150'),
|
||||
'remark': TextEditingController(text: '破损2瓶'),
|
||||
},
|
||||
{
|
||||
'code': 'SP003',
|
||||
'name': '洋河梦之蓝M6+ 45度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 88,
|
||||
'actualQtyCtrl': TextEditingController(text: '88'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP005',
|
||||
'name': '泸州老窖(国窖1573)52度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 68,
|
||||
'actualQtyCtrl': TextEditingController(text: '70'),
|
||||
'remark': TextEditingController(text: '盘盈2瓶'),
|
||||
},
|
||||
{
|
||||
'code': 'SP006',
|
||||
'name': '汾酒(青花30)53度500ml',
|
||||
'unit': '瓶',
|
||||
'systemQty': 45,
|
||||
'actualQtyCtrl': TextEditingController(text: '45'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
{
|
||||
'code': 'SP010',
|
||||
'name': '青岛啤酒(经典)500ml',
|
||||
'unit': '箱',
|
||||
'systemQty': 35,
|
||||
'actualQtyCtrl': TextEditingController(text: '35'),
|
||||
'remark': TextEditingController(),
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_checkNoCtrl.dispose();
|
||||
for (final item in _checkItems) {
|
||||
(item['actualQtyCtrl'] as TextEditingController).dispose();
|
||||
(item['remark'] as TextEditingController).dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _getDiff(Map<String, dynamic> item) {
|
||||
final actual = int.tryParse(
|
||||
(item['actualQtyCtrl'] as TextEditingController).text) ??
|
||||
0;
|
||||
return actual - (item['systemQty'] as int);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() => _submitting = true);
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('盘点单已提交,待审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/inventory');
|
||||
}
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
height: 52,
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: () => context.go('/inventory'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('库存盘点',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('保存草稿'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.check_circle_outline, size: 16),
|
||||
label: const Text('提交盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go('/inventory'),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Basic info
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('盘点基本信息',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: [
|
||||
_InfoField(
|
||||
label: '盘点单号',
|
||||
child: TextFormField(
|
||||
controller: _checkNoCtrl,
|
||||
readOnly: true,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace'),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
label: '盘点仓库',
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _warehouse,
|
||||
items: ['主仓库', '副仓库', '全部仓库']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _warehouse = v!),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
label: '盘点类型',
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _checkType,
|
||||
items: ['全盘', '抽盘', '循环盘点']
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) =>
|
||||
setState(() => _checkType = v!),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
_InfoField(
|
||||
label: '盘点日期',
|
||||
child: InputDecorator(
|
||||
decoration: const InputDecoration(),
|
||||
child: Text(
|
||||
'2026-04-04',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Check items table
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('盘点明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accent.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'差异 ${_checkItems.where((i) => _getDiff(i) != 0).length} 项',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Table
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FixedColumnWidth(80),
|
||||
2: FlexColumnWidth(3),
|
||||
3: FixedColumnWidth(50),
|
||||
4: FixedColumnWidth(80),
|
||||
5: FixedColumnWidth(120),
|
||||
6: FixedColumnWidth(80),
|
||||
7: FlexColumnWidth(2),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF0F4FF)),
|
||||
children: [
|
||||
'序号',
|
||||
'商品编码',
|
||||
'商品名称',
|
||||
'单位',
|
||||
'账面数量',
|
||||
'实际数量',
|
||||
'差异',
|
||||
'备注',
|
||||
]
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 10),
|
||||
child: Text(h,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
color: AppTheme
|
||||
.primaryDark)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...List.generate(
|
||||
_checkItems.length,
|
||||
(i) => _buildCheckRow(i)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Summary
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
_SummaryItem(
|
||||
label: '盘点商品',
|
||||
value: '${_checkItems.length}种',
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘盈',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) > 0).length}种',
|
||||
color: AppTheme.success,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '盘亏',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) < 0).length}种',
|
||||
color: AppTheme.danger,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_SummaryItem(
|
||||
label: '相符',
|
||||
value:
|
||||
'${_checkItems.where((i) => _getDiff(i) == 0).length}种',
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TableRow _buildCheckRow(int index) {
|
||||
final item = _checkItems[index];
|
||||
final diff = _getDiff(item);
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: diff != 0
|
||||
? (diff > 0
|
||||
? AppTheme.success.withOpacity(0.04)
|
||||
: AppTheme.danger.withOpacity(0.04))
|
||||
: (index.isEven ? Colors.white : const Color(0xFFFAFAFA)),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['name'] as String,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(item['unit'] as String,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text('${item['systemQty']}',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item['actualQtyCtrl'] as TextEditingController,
|
||||
decoration: const InputDecoration(),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
child: Text(
|
||||
diff == 0 ? '0' : (diff > 0 ? '+$diff' : '$diff'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: diff == 0
|
||||
? AppTheme.textSecondary
|
||||
: (diff > 0 ? AppTheme.success : AppTheme.danger),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item['remark'] as TextEditingController,
|
||||
decoration: const InputDecoration(hintText: '备注'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
|
||||
const _InfoField({required this.label, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 220,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
const _SummaryItem(
|
||||
{required this.label,
|
||||
required this.value,
|
||||
required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(width: 4),
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
|
||||
class InventoryListScreen extends ConsumerStatefulWidget {
|
||||
const InventoryListScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<InventoryListScreen> createState() =>
|
||||
_InventoryListScreenState();
|
||||
}
|
||||
|
||||
class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
int _page = 1;
|
||||
final _searchCtrl = TextEditingController();
|
||||
String _categoryFilter = '全部';
|
||||
String _warehouseFilter = '全部';
|
||||
|
||||
final List<Map<String, dynamic>> _mockInventory = [
|
||||
{
|
||||
'code': 'SP001',
|
||||
'name': '茅台酒(飞天)53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '茅台',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 286,
|
||||
'available': 280,
|
||||
'reserved': 6,
|
||||
'cost': 2100.00,
|
||||
'price': 2600.00,
|
||||
'totalValue': '600600.00',
|
||||
'minQty': 50,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP002',
|
||||
'name': '五粮液(普五)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '五粮液',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 152,
|
||||
'available': 148,
|
||||
'reserved': 4,
|
||||
'cost': 850.00,
|
||||
'price': 1050.00,
|
||||
'totalValue': '129200.00',
|
||||
'minQty': 30,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP003',
|
||||
'name': '洋河梦之蓝M6+ 45度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '洋河',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 88,
|
||||
'available': 85,
|
||||
'reserved': 3,
|
||||
'cost': 480.00,
|
||||
'price': 598.00,
|
||||
'totalValue': '42240.00',
|
||||
'minQty': 20,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP004',
|
||||
'name': '剑南春(水晶剑)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '剑南春',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 12,
|
||||
'available': 12,
|
||||
'reserved': 0,
|
||||
'cost': 288.00,
|
||||
'price': 368.00,
|
||||
'totalValue': '3456.00',
|
||||
'minQty': 20,
|
||||
'status': '库存不足',
|
||||
},
|
||||
{
|
||||
'code': 'SP005',
|
||||
'name': '泸州老窖(国窖1573)52度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '泸州老窖',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 68,
|
||||
'available': 65,
|
||||
'reserved': 3,
|
||||
'cost': 680.00,
|
||||
'price': 860.00,
|
||||
'totalValue': '46240.00',
|
||||
'minQty': 20,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP006',
|
||||
'name': '汾酒(青花30)53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '汾酒',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 45,
|
||||
'available': 45,
|
||||
'reserved': 0,
|
||||
'cost': 320.00,
|
||||
'price': 418.00,
|
||||
'totalValue': '14400.00',
|
||||
'minQty': 15,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP007',
|
||||
'name': '拉菲古堡正牌红葡萄酒2018',
|
||||
'category': '葡萄酒',
|
||||
'brand': '拉菲',
|
||||
'spec': '750ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 24,
|
||||
'available': 24,
|
||||
'reserved': 0,
|
||||
'cost': 5200.00,
|
||||
'price': 6800.00,
|
||||
'totalValue': '124800.00',
|
||||
'minQty': 6,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP008',
|
||||
'name': '人头马XO特优香槟干邑700ml',
|
||||
'category': '洋酒',
|
||||
'brand': '人头马',
|
||||
'spec': '700ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 18,
|
||||
'available': 16,
|
||||
'reserved': 2,
|
||||
'cost': 1680.00,
|
||||
'price': 2180.00,
|
||||
'totalValue': '30240.00',
|
||||
'minQty': 6,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP009',
|
||||
'name': '百威啤酒330ml',
|
||||
'category': '啤酒',
|
||||
'brand': '百威',
|
||||
'spec': '330ml×24罐',
|
||||
'unit': '箱',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 8,
|
||||
'available': 6,
|
||||
'reserved': 2,
|
||||
'cost': 58.00,
|
||||
'price': 88.00,
|
||||
'totalValue': '464.00',
|
||||
'minQty': 20,
|
||||
'status': '库存不足',
|
||||
},
|
||||
{
|
||||
'code': 'SP010',
|
||||
'name': '青岛啤酒(经典)500ml',
|
||||
'category': '啤酒',
|
||||
'brand': '青岛',
|
||||
'spec': '500ml×12瓶',
|
||||
'unit': '箱',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 35,
|
||||
'available': 35,
|
||||
'reserved': 0,
|
||||
'cost': 42.00,
|
||||
'price': 68.00,
|
||||
'totalValue': '1470.00',
|
||||
'minQty': 20,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP011',
|
||||
'name': '芝华士12年苏格兰威士忌700ml',
|
||||
'category': '洋酒',
|
||||
'brand': '芝华士',
|
||||
'spec': '700ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '副仓库',
|
||||
'qty': 30,
|
||||
'available': 28,
|
||||
'reserved': 2,
|
||||
'cost': 288.00,
|
||||
'price': 398.00,
|
||||
'totalValue': '8640.00',
|
||||
'minQty': 10,
|
||||
'status': '正常',
|
||||
},
|
||||
{
|
||||
'code': 'SP012',
|
||||
'name': '郎酒红花郎15年53度500ml',
|
||||
'category': '白酒',
|
||||
'brand': '郎酒',
|
||||
'spec': '500ml/瓶',
|
||||
'unit': '瓶',
|
||||
'warehouse': '主仓库',
|
||||
'qty': 0,
|
||||
'available': 0,
|
||||
'reserved': 0,
|
||||
'cost': 620.00,
|
||||
'price': 798.00,
|
||||
'totalValue': '0.00',
|
||||
'minQty': 10,
|
||||
'status': '缺货',
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _filtered {
|
||||
return _mockInventory.where((item) {
|
||||
if (_categoryFilter != '全部' &&
|
||||
item['category'] != _categoryFilter) return false;
|
||||
if (_warehouseFilter != '全部' &&
|
||||
item['warehouse'] != _warehouseFilter) return false;
|
||||
final q = _searchCtrl.text.toLowerCase();
|
||||
if (q.isNotEmpty) {
|
||||
final name = (item['name'] as String).toLowerCase();
|
||||
final code = (item['code'] as String).toLowerCase();
|
||||
final brand = (item['brand'] as String).toLowerCase();
|
||||
if (!name.contains(q) && !code.contains(q) && !brand.contains(q)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Summary stats
|
||||
double get _totalInventoryValue {
|
||||
return _mockInventory.fold(0, (sum, item) {
|
||||
return sum + (double.tryParse(item['totalValue'] as String) ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
int get _lowStockCount {
|
||||
return _mockInventory.where((item) {
|
||||
return (item['qty'] as int) < (item['minQty'] as int);
|
||||
}).length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageScaffold(
|
||||
title: '库存管理',
|
||||
tabs: const [
|
||||
Tab(text: '库存查询'),
|
||||
Tab(text: '库存预警'),
|
||||
Tab(text: '库存盘点'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildInventoryList(),
|
||||
_buildWarningList(),
|
||||
_buildCheckTab(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInventoryList() {
|
||||
final items = _filtered;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Summary cards
|
||||
Container(
|
||||
color: AppTheme.background,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
_SummaryCard(
|
||||
title: '商品总数',
|
||||
value: '${_mockInventory.length}',
|
||||
unit: '种',
|
||||
icon: Icons.inventory_2,
|
||||
color: AppTheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '库存总价值',
|
||||
value: '¥${(_totalInventoryValue / 10000).toStringAsFixed(1)}万',
|
||||
unit: '',
|
||||
icon: Icons.monetization_on,
|
||||
color: AppTheme.success),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '库存预警',
|
||||
value: '$_lowStockCount',
|
||||
unit: '种',
|
||||
icon: Icons.warning_amber,
|
||||
color: AppTheme.accent),
|
||||
const SizedBox(width: 12),
|
||||
_SummaryCard(
|
||||
title: '缺货商品',
|
||||
value:
|
||||
'${_mockInventory.where((i) => (i['qty'] as int) == 0).length}',
|
||||
unit: '种',
|
||||
icon: Icons.remove_shopping_cart,
|
||||
color: AppTheme.danger),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: DataTableCard(
|
||||
totalCount: items.length,
|
||||
page: _page,
|
||||
onPageChanged: (p) => setState(() => _page = p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.file_download_outlined, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const Spacer(),
|
||||
_DropdownFilter(
|
||||
value: _categoryFilter,
|
||||
items: ['全部', '白酒', '葡萄酒', '洋酒', '啤酒'],
|
||||
onChanged: (v) => setState(() => _categoryFilter = v!),
|
||||
hint: '商品分类'),
|
||||
const SizedBox(width: 8),
|
||||
_DropdownFilter(
|
||||
value: _warehouseFilter,
|
||||
items: ['全部', '主仓库', '副仓库'],
|
||||
onChanged: (v) =>
|
||||
setState(() => _warehouseFilter = v!),
|
||||
hint: '仓库'),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '搜索商品名/编码/品牌',
|
||||
prefixIcon: Icon(Icons.search, size: 16),
|
||||
hintStyle: TextStyle(fontSize: 13),
|
||||
),
|
||||
onChanged: (_) => setState(() => _page = 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('商品编码')),
|
||||
DataColumn(label: Text('商品名称')),
|
||||
DataColumn(label: Text('分类')),
|
||||
DataColumn(label: Text('品牌')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('库存'), numeric: true),
|
||||
DataColumn(label: Text('可用'), numeric: true),
|
||||
DataColumn(label: Text('预留'), numeric: true),
|
||||
DataColumn(label: Text('成本价'), numeric: true),
|
||||
DataColumn(label: Text('库存价值'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
],
|
||||
rows: items
|
||||
.map((item) => DataRow(
|
||||
color: WidgetStateProperty.resolveWith((states) {
|
||||
if ((item['qty'] as int) == 0) {
|
||||
return AppTheme.danger.withOpacity(0.04);
|
||||
}
|
||||
if ((item['qty'] as int) < (item['minQty'] as int)) {
|
||||
return AppTheme.accent.withOpacity(0.04);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
cells: [
|
||||
DataCell(Text(item['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox(
|
||||
width: 200,
|
||||
child: Text(item['name'] as String,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
)),
|
||||
DataCell(Text(item['category'] as String)),
|
||||
DataCell(Text(item['brand'] as String)),
|
||||
DataCell(Text(item['warehouse'] as String)),
|
||||
DataCell(Text('${item['qty']}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: (item['qty'] as int) == 0
|
||||
? AppTheme.danger
|
||||
: (item['qty'] as int) <
|
||||
(item['minQty'] as int)
|
||||
? AppTheme.accent
|
||||
: AppTheme.textPrimary))),
|
||||
DataCell(Text('${item['available']}')),
|
||||
DataCell(Text('${item['reserved']}',
|
||||
style: TextStyle(
|
||||
color: (item['reserved'] as int) > 0
|
||||
? AppTheme.accent
|
||||
: AppTheme.textSecondary))),
|
||||
DataCell(Text(
|
||||
'¥${(item['cost'] as double).toStringAsFixed(2)}')),
|
||||
DataCell(Text('¥${item['totalValue']}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500))),
|
||||
DataCell(_InventoryStatusBadge(
|
||||
item['status'] as String)),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWarningList() {
|
||||
final warnings = _mockInventory
|
||||
.where((item) => (item['qty'] as int) < (item['minQty'] as int))
|
||||
.toList();
|
||||
return DataTableCard(
|
||||
totalCount: warnings.length,
|
||||
page: 1,
|
||||
toolbar: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber, color: AppTheme.accent, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'共 ${warnings.length} 个商品库存低于安全库存',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.accent),
|
||||
),
|
||||
const Spacer(),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.mail_outline, size: 16),
|
||||
label: const Text('发送预警通知'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.shopping_cart_checkout, size: 16),
|
||||
label: const Text('一键补货申请'),
|
||||
),
|
||||
],
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('商品编码')),
|
||||
DataColumn(label: Text('商品名称')),
|
||||
DataColumn(label: Text('分类')),
|
||||
DataColumn(label: Text('仓库')),
|
||||
DataColumn(label: Text('当前库存'), numeric: true),
|
||||
DataColumn(label: Text('安全库存'), numeric: true),
|
||||
DataColumn(label: Text('缺口'), numeric: true),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: warnings
|
||||
.map((item) => DataRow(
|
||||
color: WidgetStateProperty.all(
|
||||
(item['qty'] as int) == 0
|
||||
? AppTheme.danger.withOpacity(0.05)
|
||||
: AppTheme.accent.withOpacity(0.04)),
|
||||
cells: [
|
||||
DataCell(Text(item['code'] as String,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 12))),
|
||||
DataCell(SizedBox(
|
||||
width: 180,
|
||||
child: Text(item['name'] as String,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
)),
|
||||
DataCell(Text(item['category'] as String)),
|
||||
DataCell(Text(item['warehouse'] as String)),
|
||||
DataCell(Text('${item['qty']}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: (item['qty'] as int) == 0
|
||||
? AppTheme.danger
|
||||
: AppTheme.accent))),
|
||||
DataCell(Text('${item['minQty']}')),
|
||||
DataCell(Text(
|
||||
'${(item['minQty'] as int) - (item['qty'] as int)}',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.danger,
|
||||
fontWeight: FontWeight.w600))),
|
||||
DataCell(_InventoryStatusBadge(item['status'] as String)),
|
||||
DataCell(
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 28)),
|
||||
child: const Text('申请补货',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckTab() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.fact_check_outlined,
|
||||
size: 64, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 16),
|
||||
const Text('点击下方按钮发起新的库存盘点',
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
label: const Text('新建盘点单', style: TextStyle(fontSize: 15)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(160, 44)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final String unit;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SummaryCard({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 72,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: AppTheme.border, width: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color)),
|
||||
if (unit.isNotEmpty) ...[
|
||||
const SizedBox(width: 2),
|
||||
Text(unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary)),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InventoryStatusBadge extends StatelessWidget {
|
||||
final String status;
|
||||
const _InventoryStatusBadge(this.status);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
switch (status) {
|
||||
case '正常':
|
||||
bg = const Color(0xFFE8F5E9);
|
||||
fg = AppTheme.success;
|
||||
break;
|
||||
case '库存不足':
|
||||
bg = const Color(0xFFFFF3E0);
|
||||
fg = AppTheme.accent;
|
||||
break;
|
||||
case '缺货':
|
||||
bg = const Color(0xFFFFEBEE);
|
||||
fg = AppTheme.danger;
|
||||
break;
|
||||
default:
|
||||
bg = const Color(0xFFF5F5F5);
|
||||
fg = AppTheme.textSecondary;
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bg, borderRadius: BorderRadius.circular(3)),
|
||||
child: Text(status,
|
||||
style: TextStyle(
|
||||
color: fg, fontSize: 12, fontWeight: FontWeight.w500)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DropdownFilter extends StatelessWidget {
|
||||
final String value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final String hint;
|
||||
|
||||
const _DropdownFilter({
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppTheme.border),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: AppTheme.surface,
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
items: items
|
||||
.map((s) => DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(s, style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user