- estimate_screen.dart: /S1. 見積入力 - invoice_screen.dart: /S2. 請求書入力 - order_screen.dart: /S3. 受発注入力 - sales_return_screen.dart: /S5. 売上返品入力 - sales_screen.dart: /S4. 売上入力(レジ) - product_master_screen.dart: /M1. 商品マスタ - customer_master_screen.dart: /M2. 得意先マスタ - supplier_master_screen.dart: /M3. 仕入先マスタ - warehouse_master_screen.dart: /M4. 倉庫マスタ - employee_master_screen.dart: /M5. 担当者マスタ README.md にも画面 ID マッピングを明記
293 lines
No EOL
9.4 KiB
Dart
293 lines
No EOL
9.4 KiB
Dart
// Version: 1.9 - 商品マスタ画面(汎用フォーム実装)
|
||
import 'package:flutter/material.dart';
|
||
import '../../models/product.dart';
|
||
import '../../services/database_helper.dart';
|
||
import '../../widgets/master_edit_fields.dart';
|
||
|
||
/// 商品マスタ管理画面(CRUD 機能付き・汎用フォーム実装)
|
||
class ProductMasterScreen extends StatefulWidget {
|
||
const ProductMasterScreen({super.key});
|
||
|
||
@override
|
||
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
||
}
|
||
|
||
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||
List<Product> _products = [];
|
||
bool _loading = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadProducts();
|
||
}
|
||
|
||
Future<void> _loadProducts() async {
|
||
setState(() => _loading = true);
|
||
try {
|
||
final products = await DatabaseHelper.instance.getProducts();
|
||
if (mounted) setState(() => _products = products ?? const <Product>[]);
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
|
||
);
|
||
} finally {
|
||
setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
Future<Product?> _showProductDialog({Product? initialProduct}) async {
|
||
final titleText = initialProduct == null ? '新規商品登録' : '商品編集';
|
||
|
||
return await showDialog<Product>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text(titleText),
|
||
content: SingleChildScrollView(child: ProductForm(initialProduct: initialProduct)),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||
ElevatedButton(
|
||
onPressed: () => Navigator.pop(context, initialProduct ?? null),
|
||
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
|
||
child: initialProduct == null ? const Text('登録') : const Text('更新'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _onAddPressed() async {
|
||
final result = await _showProductDialog();
|
||
|
||
if (result != null && mounted) {
|
||
try {
|
||
await DatabaseHelper.instance.insertProduct(result);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('商品登録完了'), backgroundColor: Colors.green),
|
||
);
|
||
_loadProducts();
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _onEditPressed(int id) async {
|
||
final product = await DatabaseHelper.instance.getProduct(id);
|
||
if (product == null || !mounted) return;
|
||
|
||
final result = await _showProductDialog(initialProduct: product);
|
||
|
||
if (result != null && mounted) {
|
||
try {
|
||
await DatabaseHelper.instance.updateProduct(result);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('商品更新完了'), backgroundColor: Colors.green),
|
||
);
|
||
_loadProducts();
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _onDeletePressed(int id) async {
|
||
final product = await DatabaseHelper.instance.getProduct(id);
|
||
if (!mounted) return;
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('商品削除'),
|
||
content: Text('"${product?.name ?? 'この商品'}"を削除しますか?'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||
ElevatedButton(
|
||
onPressed: () {
|
||
if (mounted) Navigator.pop(context, true);
|
||
},
|
||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||
child: const Text('削除'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (confirmed == true && mounted) {
|
||
try {
|
||
await DatabaseHelper.instance.deleteProduct(id);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('商品削除完了'), backgroundColor: Colors.green),
|
||
);
|
||
_loadProducts();
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('削除エラー:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('/M1. 商品マスタ'),
|
||
actions: [
|
||
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts,),
|
||
IconButton(icon: const Icon(Icons.add), onPressed: _onAddPressed,),
|
||
],
|
||
),
|
||
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||
_products.isEmpty ? Center(child: Text('商品データがありません')) :
|
||
ListView.builder(
|
||
padding: const EdgeInsets.all(8),
|
||
itemCount: _products.length,
|
||
itemBuilder: (context, index) {
|
||
final product = _products[index];
|
||
return Card(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
child: ListTile(
|
||
leading: CircleAvatar(backgroundColor: Colors.blue.shade50, child: Icon(Icons.shopping_basket)),
|
||
title: Text(product.name.isEmpty ? '商品(未入力)' : product.name),
|
||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text('コード:${product.productCode}'),
|
||
Text('単価:¥${(product.unitPrice ?? 0).toStringAsFixed(2)}'),
|
||
]),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _onEditPressed(product.id ?? 0)),
|
||
IconButton(icon: const Icon(Icons.delete), onPressed: () => _onDeletePressed(product.id ?? 0)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 商品フォーム部品(汎用フォーム実装)
|
||
class ProductForm extends StatefulWidget {
|
||
final Product? initialProduct;
|
||
|
||
const ProductForm({super.key, this.initialProduct});
|
||
|
||
@override
|
||
State<ProductForm> createState() => _ProductFormState();
|
||
}
|
||
|
||
class _ProductFormState extends State<ProductForm> {
|
||
late TextEditingController _productCodeController;
|
||
late TextEditingController _nameController;
|
||
late TextEditingController _unitPriceController;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
final initialProduct = widget.initialProduct;
|
||
_productCodeController = TextEditingController(text: initialProduct?.productCode ?? '');
|
||
_nameController = TextEditingController(text: initialProduct?.name ?? '');
|
||
_unitPriceController = TextEditingController(text: (initialProduct?.unitPrice ?? 0.0).toString());
|
||
|
||
if (_productCodeController.text.isEmpty) {
|
||
_productCodeController = TextEditingController();
|
||
}
|
||
if (_nameController.text.isEmpty) {
|
||
_nameController = TextEditingController();
|
||
}
|
||
if (_unitPriceController.text.isEmpty) {
|
||
_unitPriceController = TextEditingController(text: '0');
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_productCodeController.dispose();
|
||
_nameController.dispose();
|
||
_unitPriceController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
String? _validateProductCode(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
return '商品コードは必須です';
|
||
}
|
||
|
||
final regex = RegExp(r'^[0-9]+$');
|
||
if (!regex.hasMatch(value)) {
|
||
return '商品コードは数字のみを入力してください(例:9000)';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
String? _validateName(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
return '品名は必須です';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _validateUnitPrice(String? value) {
|
||
final price = double.tryParse(value ?? '');
|
||
if (price == null) {
|
||
return '単価は数値を入力してください';
|
||
}
|
||
|
||
if (price < 0) {
|
||
return '単価は 0 以上の値です';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// セクションヘッダー:基本情報
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'基本情報',
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
|
||
MasterTextField(
|
||
label: '商品コード',
|
||
hint: '例:9000',
|
||
controller: _productCodeController,
|
||
validator: _validateProductCode,
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
MasterTextField(
|
||
label: '品名',
|
||
hint: '商品の名称',
|
||
controller: _nameController,
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
MasterNumberField(
|
||
label: '単価(円)',
|
||
hint: '0',
|
||
controller: _unitPriceController,
|
||
validator: _validateUnitPrice,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
} |