feat: 完成7项功能任务
后端: - fix(backend): InventoryLog 增加 Product/Warehouse 关联,Logs 接口加 Preload 修复流水记录显示"-" - feat(backend): 新增 /inventory/batches 批次追踪接口 - feat(backend): 新增财务记录、编号规则接口(finance、number_rule handler) - fix(backend): service/stock_test.go 修复 model.Date 类型错误 前端: - feat(client): 入库/出库管理 Tab 调换顺序(审核在前),审核 Tab 加新建按钮,列表 Tab 无按钮 - feat(client): 入库单/出库单增加详情弹窗,显示完整单据信息和商品明细 - feat(client): 完善出库单新建表单(StockOutFormScreen),支持选客户/仓库/商品 - fix(client): StockInItem/StockOutItem.fromJson 修复读取嵌套 product 对象而非平铺字段 - feat(client): 批次追踪页面(BatchTrackingScreen)及侧边栏入口 - feat(client): 财务管理、盘点、编号规则接入真实后端 API - fix(client): 入库/出库审核通过后 invalidate inventoryListProvider 刷新库存 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
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';
|
||||
import '../../models/product.dart';
|
||||
import '../../providers/partner_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/stock_out_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
|
||||
class StockOutFormScreen extends ConsumerStatefulWidget {
|
||||
const StockOutFormScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<StockOutFormScreen> createState() => _StockOutFormScreenState();
|
||||
}
|
||||
|
||||
class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _remarkCtrl = TextEditingController();
|
||||
int? _warehouseId;
|
||||
int? _partnerId;
|
||||
DateTime _orderDate = DateTime.now();
|
||||
bool _submitting = false;
|
||||
|
||||
final List<_ItemRow> _items = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items.add(_ItemRow());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remarkCtrl.dispose();
|
||||
for (final item in _items) {
|
||||
item.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _totalAmount {
|
||||
double total = 0;
|
||||
for (final item in _items) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
total += qty * price;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void _addItem() {
|
||||
setState(() => _items.add(_ItemRow()));
|
||||
}
|
||||
|
||||
void _removeItem(int index) {
|
||||
setState(() {
|
||||
_items[index].dispose();
|
||||
_items.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submit(bool asDraft) async {
|
||||
if (!asDraft && !_formKey.currentState!.validate()) return;
|
||||
if (_warehouseId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请选择出库仓库'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请添加商品明细'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
|
||||
final itemsData = _items.map((item) {
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
return {
|
||||
'product_id': item.productId ?? 0,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'total_price': qty * price,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final data = {
|
||||
'warehouse_id': _warehouseId,
|
||||
if (_partnerId != null) 'partner_id': _partnerId,
|
||||
'order_date':
|
||||
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
|
||||
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
|
||||
'items': itemsData,
|
||||
'status': asDraft ? 'draft' : 'pending',
|
||||
};
|
||||
|
||||
try {
|
||||
await ref.read(stockOutListProvider.notifier).createOrder(data);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(asDraft ? '已保存为草稿' : '出库单已提交审核'),
|
||||
backgroundColor: AppTheme.success,
|
||||
),
|
||||
);
|
||||
context.go('/stock-out');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asyncWarehouses = ref.watch(warehouseListProvider);
|
||||
final asyncCustomers = ref.watch(customerListProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.background,
|
||||
body: Column(
|
||||
children: [
|
||||
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('/stock-out'),
|
||||
tooltip: '返回',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('新建出库单',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
OutlinedButton(
|
||||
onPressed: _submitting ? null : () => _submit(true),
|
||||
child: const Text('保存草稿'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _submitting ? null : () => _submit(false),
|
||||
icon: _submitting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Icon(Icons.send, size: 16),
|
||||
label: const Text('提交审核'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/stock-out'),
|
||||
icon: const Icon(Icons.cancel_outlined, size: 16),
|
||||
label: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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: [
|
||||
// Warehouse dropdown
|
||||
_FormField(
|
||||
label: '出库仓库',
|
||||
required: true,
|
||||
child: asyncWarehouses.when(
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (warehouses) =>
|
||||
DropdownButtonFormField<int>(
|
||||
value: _warehouseId,
|
||||
hint: const Text('请选择仓库',
|
||||
style:
|
||||
TextStyle(fontSize: 13)),
|
||||
items: warehouses
|
||||
.map((w) => DropdownMenuItem(
|
||||
value: w.id,
|
||||
child: Text(w.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(
|
||||
() => _warehouseId = v),
|
||||
validator: (v) =>
|
||||
v == null ? '不能为空' : null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Customer dropdown
|
||||
_FormField(
|
||||
label: '客户',
|
||||
child: asyncCustomers.when(
|
||||
loading: () =>
|
||||
const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (result) =>
|
||||
DropdownButtonFormField<int>(
|
||||
value: _partnerId,
|
||||
hint: const Text('请选择客户',
|
||||
style:
|
||||
TextStyle(fontSize: 13)),
|
||||
items: result.data
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(p.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(
|
||||
() => _partnerId = v),
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Date picker
|
||||
_FormField(
|
||||
label: '出库日期',
|
||||
required: true,
|
||||
child: InkWell(
|
||||
onTap: _pickDate,
|
||||
child: InputDecorator(
|
||||
decoration:
|
||||
const InputDecoration(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${_orderDate.year}-${_orderDate.month.toString().padLeft(2, '0')}-${_orderDate.day.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color:
|
||||
AppTheme.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FormField(
|
||||
label: '备注',
|
||||
width: double.infinity,
|
||||
child: TextFormField(
|
||||
controller: _remarkCtrl,
|
||||
maxLines: 2,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '选填,如有特殊说明请在此注明',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
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 Spacer(),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _addItem,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('添加商品'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, 32)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(3),
|
||||
2: FlexColumnWidth(1.5),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FixedColumnWidth(60),
|
||||
},
|
||||
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(
|
||||
_items.length,
|
||||
(i) => _buildItemRow(i)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text('合计金额:',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500)),
|
||||
Text(
|
||||
'¥${_totalAmount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.danger),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TableRow _buildItemRow(int index) {
|
||||
final item = _items[index];
|
||||
final asyncProducts = ref.watch(productListProvider);
|
||||
final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
|
||||
final price = double.tryParse(item.priceCtrl.text) ?? 0;
|
||||
final amount = qty * price;
|
||||
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: index.isEven ? Colors.white : const Color(0xFFFAFAFA),
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text('${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: asyncProducts.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (result) => DropdownButtonFormField<int>(
|
||||
value: item.productId,
|
||||
hint: const Text('选择商品',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
items: result.data
|
||||
.map((p) => DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text('${p.name}(${p.spec ?? p.unit})',
|
||||
style: const TextStyle(fontSize: 13))))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
item.productId = v;
|
||||
// Auto-fill sale price if available
|
||||
if (v != null) {
|
||||
final found = result.data.firstWhere(
|
||||
(p) => p.id == v,
|
||||
orElse: () => Product(
|
||||
id: 0, code: '', name: '', unit: ''));
|
||||
if (found.salePrice != null) {
|
||||
item.priceCtrl.text =
|
||||
found.salePrice!.toStringAsFixed(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (v) => v == null ? '不能为空' : null,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item.qtyCtrl,
|
||||
decoration: const InputDecoration(hintText: '0'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return '不能为空';
|
||||
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: TextFormField(
|
||||
controller: item.priceCtrl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '0.00', prefixText: '¥'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d+\.?\d{0,2}'))
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return '不能为空';
|
||||
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Text(
|
||||
'¥${amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete_outline,
|
||||
size: 18, color: AppTheme.danger),
|
||||
onPressed:
|
||||
_items.length > 1 ? () => _removeItem(index) : null,
|
||||
tooltip: '删除',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 28, minHeight: 28),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _orderDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (date != null) setState(() => _orderDate = date);
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemRow {
|
||||
int? productId;
|
||||
final TextEditingController qtyCtrl = TextEditingController();
|
||||
final TextEditingController priceCtrl = TextEditingController();
|
||||
|
||||
void dispose() {
|
||||
qtyCtrl.dispose();
|
||||
priceCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FormField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool required;
|
||||
final double width;
|
||||
|
||||
const _FormField({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.required = false,
|
||||
this.width = 240,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (required)
|
||||
const Text('*',
|
||||
style:
|
||||
TextStyle(color: AppTheme.danger, fontSize: 13)),
|
||||
Text(label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: AppTheme.textSecondary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../core/theme/app_theme.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/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
@@ -45,17 +47,17 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
return PageScaffold(
|
||||
title: '出库管理',
|
||||
tabs: const [
|
||||
Tab(text: '出库单'),
|
||||
Tab(text: '出库审核'),
|
||||
Tab(text: '出库单'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildListTab(filterStatus: null),
|
||||
_buildListTab(filterStatus: 'pending'),
|
||||
_buildListTab(filterStatus: 'pending', showNewButton: true),
|
||||
_buildListTab(filterStatus: null, showNewButton: false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListTab({String? filterStatus}) {
|
||||
Widget _buildListTab({String? filterStatus, required bool showNewButton}) {
|
||||
final asyncOrders = ref.watch(stockOutListProvider);
|
||||
return asyncOrders.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -85,6 +87,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
totalCount: filterStatus != null ? orders.length : result.total,
|
||||
page: result.page,
|
||||
showStatusFilter: filterStatus == null,
|
||||
showNewButton: showNewButton,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -95,6 +98,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
required int totalCount,
|
||||
required int page,
|
||||
required bool showStatusFilter,
|
||||
required bool showNewButton,
|
||||
}) {
|
||||
return DataTableCard(
|
||||
totalCount: totalCount,
|
||||
@@ -103,11 +107,12 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
ref.read(stockOutListProvider.notifier).setPage(p),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _showCreateDialog(context),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库单'),
|
||||
),
|
||||
if (showNewButton)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
@@ -186,6 +191,13 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primary)),
|
||||
),
|
||||
if (o.status == 'draft')
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
@@ -223,6 +235,16 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDetail(BuildContext context, int orderId) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _StockOutDetailDialog(
|
||||
orderId: orderId,
|
||||
repository: ref.read(stockOutRepositoryProvider),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final range = await showDateRangePicker(
|
||||
context: context,
|
||||
@@ -238,24 +260,6 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showCreateDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新建出库单'),
|
||||
content: const SizedBox(
|
||||
width: 400,
|
||||
child: Text('出库单完整创建功能请参考入库单流程。'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmSubmit(
|
||||
BuildContext context, StockOutOrder o) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -324,7 +328,6 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
content: Text('审核通过'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
// Show inventory-shortage error clearly
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('审核失败:$e'),
|
||||
@@ -376,6 +379,217 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Detail dialog — fetches full order with items
|
||||
class _StockOutDetailDialog extends StatefulWidget {
|
||||
final int orderId;
|
||||
final StockOutRepository repository;
|
||||
|
||||
const _StockOutDetailDialog({
|
||||
required this.orderId,
|
||||
required this.repository,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_StockOutDetailDialog> createState() => _StockOutDetailDialogState();
|
||||
}
|
||||
|
||||
class _StockOutDetailDialogState extends State<_StockOutDetailDialog> {
|
||||
late Future<StockOutOrder> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.get(widget.orderId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Container(
|
||||
width: 720,
|
||||
constraints: const BoxConstraints(maxHeight: 600),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('出库单详情',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FutureBuilder<StockOutOrder>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
child: Text('加载失败:${snap.error}',
|
||||
style: const TextStyle(color: AppTheme.danger)));
|
||||
}
|
||||
return _buildContent(snap.data!);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
const Text('商品明细',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
const SizedBox(height: 8),
|
||||
if (o.items.isEmpty)
|
||||
const Text('无商品明细',
|
||||
style: TextStyle(color: AppTheme.textSecondary))
|
||||
else
|
||||
Table(
|
||||
border: TableBorder.all(color: AppTheme.border, width: 0.5),
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(36),
|
||||
1: FlexColumnWidth(3),
|
||||
2: FlexColumnWidth(1.5),
|
||||
3: FlexColumnWidth(1.5),
|
||||
4: FlexColumnWidth(1.5),
|
||||
5: FlexColumnWidth(1.5),
|
||||
},
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
|
||||
children: ['序号', '商品', '规格', '数量', '单价', '金额']
|
||||
.map((h) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
child: Text(h,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primaryDark)),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
...o.items.asMap().entries.map((e) {
|
||||
final i = e.key;
|
||||
final item = e.value;
|
||||
return TableRow(
|
||||
decoration: BoxDecoration(
|
||||
color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)),
|
||||
children: [
|
||||
_TableCell('${i + 1}'),
|
||||
_TableCell(item.productName ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
|
||||
_TableCell('¥${item.totalPrice.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 {
|
||||
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: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary)),
|
||||
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<String?> onChanged;
|
||||
|
||||
Reference in New Issue
Block a user