diff --git a/lib/models/product.dart b/lib/models/product.dart new file mode 100644 index 0000000..fd23dce --- /dev/null +++ b/lib/models/product.dart @@ -0,0 +1,75 @@ +// Version: 1.0.0 +import '../services/database_helper.dart'; + +class Product { + int? id; + String productCode; + String name; + int price; // 単価(税込) + int stock; + String? category; + String? unit; + int isDeleted = 0; // Soft delete flag + + Product({ + this.id, + required this.productCode, + required this.name, + required this.price, + this.stock = 0, + this.category, + this.unit, + this.isDeleted = 0, + }); + + Map toMap() { + return { + 'id': id, + 'product_code': productCode, + 'name': name, + 'price': price, + 'stock': stock, + 'category': category ?? '', + 'unit': unit ?? '', + 'is_deleted': isDeleted, + }; + } + + factory Product.fromMap(Map map) { + return Product( + id: map['id'] as int?, + productCode: map['product_code'] as String, + name: map['name'] as String, + price: map['price'] as int, + stock: map['stock'] as int? ?? 0, + category: map['category'] as String?, + unit: map['unit'] as String?, + isDeleted: map['is_deleted'] as int? ?? 0, + ); + } + + Product copyWith({ + int? id, + String? productCode, + String? name, + int? price, + int? stock, + String? category, + String? unit, + int? isDeleted, + }) { + return Product( + id: id ?? this.id, + productCode: productCode ?? this.productCode, + name: name ?? this.name, + price: price ?? this.price, + stock: stock ?? this.stock, + category: category ?? this.category, + unit: unit ?? this.unit, + isDeleted: isDeleted ?? this.isDeleted, + ); + } + + // 税込価格(引数にない場合は自身) + int get taxPrice => price; +} \ No newline at end of file diff --git a/lib/screens/estimate_screen.dart b/lib/screens/estimate_screen.dart index 22c8bef..068e56b 100644 --- a/lib/screens/estimate_screen.dart +++ b/lib/screens/estimate_screen.dart @@ -1,68 +1,97 @@ // Version: 1.0.0 import 'package:flutter/material.dart'; +import '../services/database_helper.dart'; +import '../models/product.dart'; /// 見積入力画面(Material Design テンプレート) -class EstimateScreen extends StatelessWidget { +class EstimateScreen extends StatefulWidget { const EstimateScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('見積入力'), - actions: [ - IconButton( - icon: const Icon(Icons.save), - onPressed: () => _showSaveDialog(context), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // 得意先選択 - TextField( - decoration: const InputDecoration( - labelText: '得意先', - hintText: '得意先マスタから選択', - prefixIcon: Icon(Icons.person_search), - ), - readOnly: true, - onTap: () => _showCustomerPicker(context), - ), - const SizedBox(height: 16), + State createState() => _EstimateScreenState(); +} - // 商品追加リスト(簡易テンプレート) - Card( - margin: EdgeInsets.zero, - child: ExpansionTile( - title: const Text('見積商品'), - children: [ - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 0, // デモ用 - itemBuilder: (context, index) => Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.blue.shade100, - child: Icon(Icons.receipt, color: Colors.blue), - ), - title: Text('商品${index + 1}'), - subtitle: Text('単価:¥${(index + 1) * 1000}'), - ), - ), - ), - ], - ), - ), - ], - ), - ); +class _EstimateScreenState extends State { + Customer? _selectedCustomer; + final DatabaseHelper _db = DatabaseHelper.instance; + List _products = []; + List _customers = []; + List _items = []; + + @override + void initState() { + super.initState(); + _loadProducts(); + _loadCustomers(); } - void _showSaveDialog(BuildContext context) { + Future _loadProducts() async { + try { + final products = await _db.getProducts(); + setState(() => _products = products); + } catch (e) { + debugPrint('Product loading failed: $e'); + } + } + + Future _loadCustomers() async { + try { + final customers = await _db.getCustomers(); + setState(() => _customers = customers.where((c) => c.isDeleted == 0).toList()); + } catch (e) { + debugPrint('Customer loading failed: $e'); + } + } + + Future _showCustomerPicker() async { + if (_customers.isEmpty) await _loadCustomers(); + + final selected = await showModalBottomSheet( + context: context, + builder: (ctx) => SizedBox( + height: MediaQuery.of(context).size.height * 0.4, + child: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _customers.length, + itemBuilder: (ctx, index) => ListTile( + title: Text(_customers[index].name), + subtitle: Text('コード:${_customers[index].customerCode}'), + onTap: () => Navigator.pop(ctx, _customers[index]), + ), + ), + ), + ); + + if (selected is Customer && selected.id != _selectedCustomer?.id) { + setState(() => _selectedCustomer = selected); + } + } + + void _addSelectedProducts() async { + for (final product in _products) { + setState(() => _items.add(LineItem( + productId: product.id, + productName: product.name, + unitPrice: product.price, + quantity: 1, + total: product.price, + ))); + } + _showAddDialog(); + } + + void _removeLineItem(int index) { + setState(() => _items.removeAt(index)); + } + + void _updateLineItemQuantity(int index, int quantity) { + setState(() { + _items[index].quantity = quantity; + _items[index].total = quantity * _items[index].unitPrice; + }); + } + + void _showSaveDialog() { showDialog( context: context, builder: (ctx) => AlertDialog( @@ -71,7 +100,26 @@ class EstimateScreen extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('見積データを保存しますか?'), + if (_selectedCustomer != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('得意先:${_selectedCustomer!.name}'), + ), + ], + Text('合計:¥${_calculateTotal()}'), + if (_items.isNotEmpty) ...[ + Divider(), + ..._items.map((item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.productName), + Text('¥${item.unitPrice} × ${item.quantity} = ¥${item.total}'), + ], + ), + )), + ], ], ), ), @@ -81,10 +129,16 @@ class EstimateScreen extends StatelessWidget { child: const Text('キャンセル'), ), ElevatedButton( - onPressed: () { + onPressed: () async { + if (_items.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('商品を追加してください')), + ); + return; + } Navigator.pop(ctx); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('見積保存しました')), + const SnackBar(content: Text('見積保存しました'))..behavior: SnackBarBehavior.floating, ); }, child: const Text('確定'), @@ -94,7 +148,117 @@ class EstimateScreen extends StatelessWidget { ); } - void _showCustomerPicker(BuildContext context) { - // TODO: CustomerPickerModal を再利用して実装 + int _calculateTotal() { + return _items.fold(0, (sum, item) => sum + item.total); } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.receipt_long, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text('見積商品を追加してください', style: TextStyle(color: Colors.grey.shade600)), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('見積入力')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildCustomerField(), + const SizedBox(height: 16), + Card( + margin: EdgeInsets.zero, + child: ExpansionTile( + title: const Text('見積商品'), + children: [ + if (_items.isEmpty) ...[ + Padding( + padding: const EdgeInsets.all(16), + child: _buildEmptyState(), + ), + ] else ...[ + ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _items.length, + itemBuilder: (context, index) => Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blue.shade100, + child: Icon(Icons.receipt, color: Colors.blue), + ), + title: Text(_items[index].productName), + subtitle: Text('単価:¥${_items[index].unitPrice}'), + trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildCustomerField() { + return TextField( + decoration: InputDecoration( + labelText: '得意先', + hintText: _selectedCustomer != null ? _selectedCustomer.name : '得意先マスタから選択', + prefixIcon: Icon(Icons.person_search), + isReadOnly: true, + ), + onTap: () => _showCustomerPicker(), + ); + } + + void _showAddDialog() async { + final selected = await showModalBottomSheet( + context: context, + builder: (ctx) => ListView.builder( + padding: EdgeInsets.zero, + itemCount: _products.length, + itemBuilder: (ctx, index) => CheckboxListTile( + title: Text(_products[index].name), + subtitle: Text('¥${_products[index].price} / 在庫:${_products[index].stock}${_products[index].unit ?? ''}'), + value: _items.any((i) => i.productId == _products[index].id), + onChanged: (value) { + if (value) _addSelectedProducts(); + }, + ), + ), + ); + + if (selected != null && selected.id != null && !_items.any((i) => i.productId == selected.id)) { + setState(() => _items.add(LineItem( + productId: selected.id, + productName: selected.name, + unitPrice: selected.price, + quantity: 1, + total: selected.price, + ))); + } + } +} + +/// 見積行モデル +class LineItem { + final int? productId; + final String productName; + final int unitPrice; + int quantity = 1; + int get total => quantity * unitPrice; + + LineItem({required this.productId, required this.productName, required this.unitPrice, this.quantity = 1}); } \ No newline at end of file diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 017a3bb..593bf94 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -3,6 +3,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'dart:convert'; import '../models/customer.dart'; +import '../models/product.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); @@ -29,7 +30,7 @@ class DatabaseHelper { Future _createDB(Database db, int version) async { const textType = 'TEXT NOT NULL'; - + // customers テーブル作成(テストデータ挿入含む) await db.execute(''' CREATE TABLE customers ( @@ -110,6 +111,16 @@ class DatabaseHelper { ) '''); + // product_snapshots テーブル(イベントソーシング用) + await db.execute(''' + CREATE TABLE product_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + data_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + '''); + // テストデータ初期化(各テーブルが空の場合のみ) await _insertTestData(db); } @@ -122,7 +133,6 @@ class DatabaseHelper { .toList(); if (existingCustomerCodes.isEmpty) { - // テスト顧客データ await db.insert('customers', { 'customer_code': 'C00001', 'name': 'テスト株式会社 Alpha', @@ -162,7 +172,7 @@ class DatabaseHelper { 'updated_at': DateTime.now().toIso8601String(), }); - // employees テーブル(存在する場合はスキップ) + // employees テーブル try { await db.execute('CREATE TABLE IF NOT EXISTS employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, phone_number TEXT)'); @@ -172,27 +182,13 @@ class DatabaseHelper { .toList(); if (existingEmployees.isEmpty) { - await db.insert('employees', { - 'name': '山田 太郎', - 'position': '営業部長', - 'phone_number': '090-1234-5678', - }); - - await db.insert('employees', { - 'name': '鈴木 花子', - 'position': '経理部長', - 'phone_number': '090-8765-4321', - }); - - await db.insert('employees', { - 'name': '田中 次郎', - 'position': '技術部長', - 'phone_number': '090-1111-2222', - }); + await db.insert('employees', {'name': '山田 太郎', 'position': '営業部長', 'phone_number': '090-1234-5678'}); + await db.insert('employees', {'name': '鈴木 花子', 'position': '経理部長', 'phone_number': '090-8765-4321'}); + await db.insert('employees', {'name': '田中 次郎', 'position': '技術部長', 'phone_number': '090-1111-2222'}); } } catch (e) {} - // warehouses テーブル(存在する場合はスキップ) + // warehouses テーブル try { await db.execute('CREATE TABLE IF NOT EXISTS warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, manager TEXT)'); @@ -202,21 +198,12 @@ class DatabaseHelper { .toList(); if (existingWarehouses.isEmpty) { - await db.insert('warehouses', { - 'name': '中央倉庫', - 'address': '千葉県市川市_物流センター 1-1', - 'manager': '佐藤 健一', - }); - - await db.insert('warehouses', { - 'name': '東京支庫', - 'address': '東京都品川区_倉庫棟 2-2', - 'manager': '高橋 美咲', - }); + await db.insert('warehouses', {'name': '中央倉庫', 'address': '千葉県市川市_物流センター 1-1', 'manager': '佐藤 健一'}); + await db.insert('warehouses', {'name': '東京支庫', 'address': '東京都品川区_倉庫棟 2-2', 'manager': '高橋 美咲'}); } } catch (e) {} - // suppliers テーブル(存在する場合はスキップ) + // suppliers テーブル try { await db.execute('CREATE TABLE IF NOT EXISTS suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, tax_rate INTEGER DEFAULT 8)'); @@ -226,24 +213,13 @@ class DatabaseHelper { .toList(); if (existingSuppliers.isEmpty) { - await db.insert('suppliers', { - 'name': '仕入元 Alpha', - 'tax_rate': 8, - }); - - await db.insert('suppliers', { - 'name': '仕入元 Beta', - 'tax_rate': 10, - }); - - await db.insert('suppliers', { - 'name': '仕入元 Gamma', - 'tax_rate': 10, - }); + await db.insert('suppliers', {'name': '仕入元 Alpha', 'tax_rate': 8}); + await db.insert('suppliers', {'name': '仕入元 Beta', 'tax_rate': 10}); + await db.insert('suppliers', {'name': '仕入元 Gamma', 'tax_rate': 10}); } } catch (e) {} - // products テーブル(存在する場合はスキップ) + // products テーブル try { await db.execute(''' CREATE TABLE IF NOT EXISTS products ( @@ -263,37 +239,16 @@ class DatabaseHelper { .toList(); if (existingProducts.isEmpty) { - await db.insert('products', { - 'product_code': 'PRD001', - 'name': 'テスト商品 A', - 'price': 3500, - 'stock': 100, - 'category': '食品', - 'unit': '個', - }); - - await db.insert('products', { - 'product_code': 'PRD002', - 'name': 'テスト商品 B', - 'price': 5500, - 'stock': 50, - 'category': '家電', - 'unit': '台', - }); - - await db.insert('products', { - 'product_code': 'PRD003', - 'name': 'テスト商品 C', - 'price': 2800, - 'stock': 200, - 'category': '文具', - 'unit': '冊', - }); + await db.insert('products', {'product_code': 'PRD001', 'name': 'テスト商品 A', 'price': 3500, 'stock': 100, 'category': '食品', 'unit': '個'}); + await db.insert('products', {'product_code': 'PRD002', 'name': 'テスト商品 B', 'price': 5500, 'stock': 50, 'category': '家電', 'unit': '台'}); + await db.insert('products', {'product_code': 'PRD003', 'name': 'テスト商品 C', 'price': 2800, 'stock': 200, 'category': '文具', 'unit': '冊'}); } } catch (e) {} } } + // ========== Customer CRUD ========== + Future insert(Customer customer) async { final db = await instance.database; return await db.insert('customers', customer.toMap()); @@ -311,11 +266,7 @@ class DatabaseHelper { Future getCustomer(int id) async { final db = await instance.database; - final maps = await db.query( - 'customers', - where: 'id = ?', - whereArgs: [id], - ); + final maps = await db.query('customers', where: 'id = ?', whereArgs: [id]); if (maps.isEmpty) return null; return Customer.fromMap(maps.first); @@ -323,22 +274,12 @@ class DatabaseHelper { Future update(Customer customer) async { final db = await instance.database; - return await db.update( - 'customers', - customer.toMap(), - where: 'id = ?', - whereArgs: [customer.id], - ); + return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]); } Future delete(int id) async { final db = await instance.database; - return await db.update( - 'customers', - {'is_deleted': 1}, // Soft delete - where: 'id = ?', - whereArgs: [id], - ); + return await db.update('customers', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]); } Future saveSnapshot(Customer customer) async { @@ -346,26 +287,64 @@ class DatabaseHelper { final id = customer.id; final now = DateTime.now().toIso8601String(); - // Check if snapshot already exists for this customer (keep last 5 snapshots) - final existing = await db.query( - 'customer_snapshots', - where: 'customer_id = ?', - whereArgs: [id], - limit: 6, - ); + final existing = await db.query('customer_snapshots', where: 'customer_id = ?', whereArgs: [id], limit: 6); if (existing.length > 5) { - // Delete oldest snapshot final oldestId = existing.first['id'] as int; await db.delete('customer_snapshots', where: 'id = ?', whereArgs: [oldestId]); } - // Insert new snapshot - await db.insert('customer_snapshots', { - 'customer_id': id, - 'data_json': jsonEncode(customer.toMap()), - 'created_at': now, - }); + await db.insert('customer_snapshots', {'customer_id': id, 'data_json': jsonEncode(customer.toMap()), 'created_at': now}); + } + + // ========== Product CRUD ========== + + Future insert(Product product) async { + final db = await instance.database; + return await db.insert('products', product.toMap()); + } + + Future> getProducts() async { + final db = await instance.database; + final List> maps = await db.query('products', orderBy: 'product_code ASC'); + + return (maps as List) + .map((json) => Product.fromMap(json)) + .where((p) => p.isDeleted == 0) + .toList(); + } + + Future getProduct(int id) async { + final db = await instance.database; + final maps = await db.query('products', where: 'id = ?', whereArgs: [id]); + + if (maps.isEmpty) return null; + return Product.fromMap(maps.first); + } + + Future update(Product product) async { + final db = await instance.database; + return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]); + } + + Future delete(int id) async { + final db = await instance.database; + return await db.update('products', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]); + } + + Future saveSnapshot(Product product) async { + final db = await instance.database; + final id = product.id; + final now = DateTime.now().toIso8601String(); + + final existing = await db.query('product_snapshots', where: 'product_id = ?', whereArgs: [id], limit: 6); + + if (existing.length > 5) { + final oldestId = existing.first['id'] as int; + await db.delete('product_snapshots', where: 'id = ?', whereArgs: [oldestId]); + } + + await db.insert('product_snapshots', {'product_id': id, 'data_json': jsonEncode(product.toMap()), 'created_at': now}); } Future close() async {