在庫管理機能実装(Sprint 5)

- InventoryMasterScreen の新規作成
- 新規登録・編集・削除機能の実装
- Product から在庫情報を表示
- Build: 49.4MB
This commit is contained in:
joe 2026-03-08 21:43:36 +09:00
parent fe38142ed4
commit 5480ae1a79

View file

@ -1,7 +1,9 @@
// Version: 1.6 - // Version: 1.7 -
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
/// ///
class InventoryMasterScreen extends StatefulWidget { class InventoryMasterScreen extends StatefulWidget {
const InventoryMasterScreen({super.key}); const InventoryMasterScreen({super.key});
@ -10,91 +12,241 @@ class InventoryMasterScreen extends StatefulWidget {
} }
class _InventoryMasterScreenState extends State<InventoryMasterScreen> { class _InventoryMasterScreenState extends State<InventoryMasterScreen> {
String _productName = ''; // List<Product> _products = [];
int _stock = 0; // Map<String, dynamic>? _newInventory; //
int _minStock = 10; //
String? _supplierName; //
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// TODO: DatabaseHelper.instance.getInventory() 使 _loadProducts();
}
Future<void> _loadProducts() async {
try {
final products = await DatabaseHelper.instance.getProducts();
if (mounted) setState(() => _products = products ?? const <Product>[]);
} catch (e) {}
}
void _submitNewInventory() async {
if (_newInventory == null || !(_newInventory!['product_code'] as String).isNotEmpty) return;
try {
final db = await DatabaseHelper.instance.database;
// product_code
final existing = await db.query('inventory', where: "product_code = ?", whereArgs: [(_newInventory!['product_code'] as String)]);
if (existing.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('同じ JAN コードの在庫は既に存在します'), backgroundColor: Colors.orange),
);
return;
}
final inventoryData = <String, dynamic>{
'product_code': _newInventory!['product_code'] as String,
'name': _newInventory!['name'] as String? ?? '',
'unit_price': (_newInventory!['unit_price'] as num?)?.toInt() ?? 0,
'stock': (_newInventory!['stock'] as num?)?.toInt() ?? 0,
'min_stock': (_newInventory!['min_stock'] as num?)?.toInt() ?? 10,
'max_stock': (_newInventory!['max_stock'] as num?)?.toInt() ?? 1000,
'supplier_name': _newInventory!['supplier_name'] as String? ?? '',
};
await DatabaseHelper.instance.insertInventory(inventoryData);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('在庫登録完了'), backgroundColor: Colors.green),
);
setState(() => _newInventory = null);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
);
}
}
void _showAddDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('新規在庫登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: const InputDecoration(labelText: 'JAN コード', hintText: '4901234567890'),
onChanged: (v) => _newInventory?['product_code'] = v,
),
TextField(
decoration: const InputDecoration(labelText: '商品名', hintText: '商品名を入力'),
onChanged: (v) => _newInventory?['name'] = v,
),
TextField(
decoration: const InputDecoration(labelText: '単価(円)', hintText: '0'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['unit_price'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '在庫数', hintText: '0'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['stock'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '最低在庫', hintText: '10'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['min_stock'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '最大在庫', hintText: '1000'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['max_stock'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '仕入先', hintText: '仕入先会社名'),
onChanged: (v) => _newInventory?['supplier_name'] = v,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: _newInventory != null ? _submitNewInventory : null,
child: const Text('登録'),
),
],
),
);
}
void _editInventory(int id) async {
final product = await DatabaseHelper.instance.getProduct(id);
if (product == null || mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('在庫編集'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(labelText: 'JAN コード', hintText: product.productCode),
readOnly: true,
),
TextField(
decoration: const InputDecoration(labelText: '商品名'),
controller: TextEditingController(text: product.name),
onChanged: (v) {
_updateInventory(id, {'name': v});
},
),
TextField(
decoration: const InputDecoration(labelText: '単価(円)'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'unit_price': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '在庫数'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'stock': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '最低在庫'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'min_stock': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '最大在庫'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'max_stock': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '仕入先'),
onChanged: (v) => _updateInventory(id, {'supplier_name': v}),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('保存'),
),
],
),
);
}
void _updateInventory(int id, Map<String, dynamic> data) async {
final inventoryData = <String, dynamic>{
'product_code': data['product_code'] as String?,
'name': data['name'] as String?,
'unit_price': data['unit_price'] as int?,
'stock': data['stock'] as int?,
'min_stock': data['min_stock'] as int?,
'max_stock': data['max_stock'] as int?,
'supplier_name': data['supplier_name'] as String?,
};
try {
await DatabaseHelper.instance.updateInventory(inventoryData);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('在庫更新完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('在庫管理')), appBar: AppBar(
body: SingleChildScrollView( title: const Text('在庫管理'),
child: Padding( actions: [
padding: const EdgeInsets.all(16), IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts,),
child: Column( ],
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
TextField(
decoration: const InputDecoration(hintText: '商品名', border: OutlineInputBorder()),
onChanged: (value) => setState(() => _productName = value),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '在庫数', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onChanged: (value) => setState(() => _stock = int.tryParse(value) ?? 0),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '再仕入れ水準', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onChanged: (value) => setState(() => _minStock = int.tryParse(value) ?? 10),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '供給元', border: OutlineInputBorder()),
onChanged: (value) => setState(() => _supplierName = value.isNotEmpty ? value : null),
),
const SizedBox(height: 16),
//
Card(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('現在の在庫'),
subtitle: Text('${_stock}', style: const TextStyle(fontSize: 18)),
trailing: _stock <= _minStock ? const Icon(Icons.warning, color: Colors.orange) : const SizedBox(),
),
),
const SizedBox(height: 16),
//
ElevatedButton(
onPressed: () {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('在庫データを更新しました')),
);
}
},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
child: const Text('更新'),
),
],
),
),
), ),
body: _products.isEmpty ? Center(child: const Text('在庫データがありません')) :
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: ListTile(
title: const Text('新規登録'),
subtitle: const Text('商品への在庫を登録'),
trailing: const Icon(Icons.add_circle),
onTap: _showAddDialog,
),
),
const SizedBox(height: 16),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(product.name),
subtitle: Text('JAN: ${product.productCode} / ¥${product.unitPrice} × ${product.stock ?? 0}'),
trailing: IconButton(icon: const Icon(Icons.edit), onPressed: () => _editInventory(product.id ?? 0),),
),
);
},
),
],
),
),
); );
} }
} }