diff --git a/lib/models/customer.dart b/lib/models/customer.dart new file mode 100644 index 0000000..2563d26 --- /dev/null +++ b/lib/models/customer.dart @@ -0,0 +1,85 @@ +// Version: 1.0.0 +import '../services/database_helper.dart'; + +class Customer { + int? id; + String customerCode; + String name; + int isDeleted = 0; // Soft delete flag + String phoneNumber; + String? email; + String address; + int salesPersonId; // NULL 可(担当未設定) + int taxRate; // 10%=8, 5%=4 など (小数点切り捨て) + int discountRate; // パーセント(例:10% なら 10) + + Customer({ + this.id, + required this.customerCode, + required this.name, + required this.phoneNumber, + this.email, + required this.address, + this.salesPersonId = -1, // -1: 未設定 + this.taxRate = 8, // Default 10% + this.discountRate = 0, + }); + + Map toMap() { + return { + 'id': id, + 'customer_code': customerCode, + 'name': name, + 'phone_number': phoneNumber, + 'email': email ?? '', + 'address': address, + 'sales_person_id': salesPersonId, + 'tax_rate': taxRate, + 'discount_rate': discountRate, + 'is_deleted': isDeleted, + }; + } + + factory Customer.fromMap(Map map) { + return Customer( + id: map['id'] as int?, + customerCode: map['customer_code'] as String, + name: map['name'] as String, + phoneNumber: map['phone_number'] as String, + email: map['email'] as String?, + address: map['address'] as String, + salesPersonId: map['sales_person_id'] as int? ?? -1, + taxRate: map['tax_rate'] as int? ?? 8, + discountRate: map['discount_rate'] as int? ?? 0, + ); + } + + Customer copyWith({ + int? id, + String? customerCode, + String? name, + String? phoneNumber, + String? email, + String? address, + int? salesPersonId, + int? taxRate, + int? discountRate, + }) { + return Customer( + id: id ?? this.id, + customerCode: customerCode ?? this.customerCode, + name: name ?? this.name, + phoneNumber: phoneNumber ?? this.phoneNumber, + email: email ?? this.email, + address: address ?? this.address, + salesPersonId: salesPersonId ?? this.salesPersonId, + taxRate: taxRate ?? this.taxRate, + discountRate: discountRate ?? this.discountRate, + ); + } + + // Snapshot for Event Sourcing(簡易版) + Map toSnapshot() { + return toMap(); + } +} \ No newline at end of file diff --git a/lib/screens/master/customer_master_screen.dart b/lib/screens/master/customer_master_screen.dart index 95f471a..5194a6b 100644 --- a/lib/screens/master/customer_master_screen.dart +++ b/lib/screens/master/customer_master_screen.dart @@ -1,126 +1,236 @@ // Version: 1.0.0 import 'package:flutter/material.dart'; +import '../../models/customer.dart'; +import '../../services/database_helper.dart'; -/// 得意先マスタ画面(Material Design 標準テンプレート) -class CustomerMasterScreen extends StatelessWidget { +class CustomerMasterScreen extends StatefulWidget { const CustomerMasterScreen({super.key}); + @override + State createState() => _CustomerMasterScreenState(); +} + +class _CustomerMasterScreenState extends State { + final DatabaseHelper _db = DatabaseHelper.instance; + List _customers = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCustomers(); + } + + Future _loadCustomers() async { + try { + final customers = await _db.getCustomers(); + setState(() { + _customers = customers; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + _showSnackBar(context, '顧客データを読み込みませんでした: $e'); + } + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('得意先マスタ'), actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => _showAddDialog(context), - ), + IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers,), ], ), - body: ListView( - padding: const EdgeInsets.all(8), - children: [ - // ヘッダー - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - '得意先名称', - style: TextStyle(fontWeight: FontWeight.bold), + body: _isLoading ? const Center(child: CircularProgressIndicator()) : _customers.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]), + SizedBox(height: 16), + Text('顧客データがありません', style: TextStyle(color: Colors.grey)), + SizedBox(height: 16), + IconButton( + icon: Icon(Icons.add, color: Theme.of(context).primaryColor), + onPressed: () => _showAddDialog(context), + ), + ], ), - ), - // カードリスト形式(標準 Material 部品) - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 5, // デモ用データ数 + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _customers.length, itemBuilder: (context, index) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.teal.shade100, - child: Icon(Icons.person, color: Colors.teal), - ), - title: Text('会社${index + 1}株式会社'), - subtitle: Text('担当者:山田花子'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => _showEditDialog(context, index), - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _showDeleteDialog(context, index), - ), - ], + final customer = _customers[index]; + return Dismissible( + key: Key(customer.customerCode), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + onDismissed: (_) => _deleteCustomer(customer.id!), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: CircleAvatar( + backgroundColor: Colors.blue.shade100, + child: const Icon(Icons.person, color: Colors.blue), + ), + title: Text(customer.name), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (customer.email != null) + Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)), + Text('税抜:${(customer.taxRate / 8 * 100).toStringAsFixed(1)}%'), + Text('割引:${customer.discountRate}%'), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _showEditDialog(context, customer),), + IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer),), + ], + ), ), ), ); }, ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('新規登録'), + onPressed: () => _showAddDialog(context), + ), + ); + } + + Future _addCustomer(Customer customer) async { + final db = await DatabaseHelper.instance.database; + + // 既存顧客リストを取得 + final existingCustomers = (await db.query('customers')) as List>; + final customerCodes = existingCustomers.map((e) => e['customer_code'] as String).toList(); + + if (customerCodes.contains(customer.customerCode)) { + _showSnackBar(context, '既に同じコードを持つ顧客が登録されています'); + return; + } + + try { + await db.insert('customers', customer.toMap()); + await DatabaseHelper.instance.saveSnapshot(customer); // Event sourcing snapshot + await _loadCustomers(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('顧客を登録しました')), + ); + } + } catch (e) { + _showSnackBar(context, '登録に失敗:$e'); + } + } + + Future _editCustomer(Customer customer) async { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('編集機能:${customer.name}'), + action: const SnackBarAction(label: 'キャンセル', onPressed: () {}), + ), + ); + } + + Future _deleteCustomer(int id) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('顧客削除'), + content: Text('この顧客を削除しますか?履歴データも消去されます。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル'),), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('削除'), + ), ], ), ); + + if (confirmed == true) { + try { + await DatabaseHelper.instance.delete(id); + await _loadCustomers(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('顧客を削除しました')), + ); + } + } catch (e) { + _showSnackBar(context, '削除に失敗:$e'); + } + } } void _showAddDialog(BuildContext context) { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('新規得意先登録'), + title: const Text('新規顧客登録'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField( - decoration: const InputDecoration( - labelText: '会社名', - hintText: '株式会社名を入力', - ), + ListTile( + title: const Text('得意先コード *'), + subtitle: const Text('JAN 形式など(半角数字)'), + onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '代表者名', - ), + ListTile( + title: const Text('顧客名称 *'), + subtitle: const Text('株式会社〇〇'), + onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '住所', - hintText: '〒000-0000 北海道...', - ), + ListTile( + title: const Text('電話番号'), + subtitle: const Text('03-1234-5678'), + onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '電話番号', - hintText: '0123-456789', - ), - keyboardType: TextInputType.phone, + ListTile( + title: const Text('Email'), + subtitle: const Text('example@example.com'), + onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '担当者名', - ), + ListTile( + title: const Text('住所'), + subtitle: const Text('〒000-0000 市区町村名・番地'), + onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), + ), + const SizedBox(height: 12), + Text( + '※ 保存ボタンを押すと、上記の値から作成された顧客データが登録されます', + textAlign: TextAlign.center, + style: TextStyle(fontStyle: FontStyle.italic), ), ], ), ), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),), ElevatedButton( onPressed: () { Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('得意先登録しました')), - ); + // 実際の登録処理は後期開発(プレースホルダ) + _showSnackBar(context, '顧客データを保存します...'); }, child: const Text('保存'), ), @@ -129,33 +239,139 @@ class CustomerMasterScreen extends StatelessWidget { ); } - void _showEditDialog(BuildContext context, int index) { - // 編集ダイアログ(構造は新規と同様) - } - - void _showDeleteDialog(BuildContext context, int index) { + void _showEditDialog(BuildContext context, Customer customer) { + if (!mounted) return; showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('得意先削除'), - content: Text('会社${index + 1}株式会社を削除しますか?'), + title: const Text('顧客編集'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: const Text('顧客コード'), + subtitle: Text(customer.customerCode), + onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'), + ), + ListTile( + title: const Text('名称'), + subtitle: Text(customer.name), + onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'), + ), + ListTile( + title: const Text('電話番号'), + subtitle: Text(customer.phoneNumber), + onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'), + ), + ListTile( + title: const Text('税抜率'), + subtitle: Text('${customer.taxRate}%'), + onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'), + ), + ], + ), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),), ElevatedButton( onPressed: () { + // 実際の保存処理は後期開発(プレースホルダ) + _showSnackBar(context, '編集を保存します...'); Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('得意先削除しました')), - ); }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('削除'), + child: const Text('保存'), ), ], ), ); } + + void _showMoreOptions(BuildContext context, Customer customer) { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('『${customer.name}』のオプション機能', style: Theme.of(context).textTheme.titleLarge), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('顧客詳細表示'), + onTap: () => _showCustomerDetail(context, customer), + ), + ListTile( + leading: const Icon(Icons.history_edu), + title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)), + onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発'), + ), + ListTile( + leading: const Icon(Icons.copy), + title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)), + onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で'), + ), + ], + ), + ), + ), + ); + } + + void _showCustomerDetail(BuildContext context, Customer customer) { + if (!mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('顧客詳細'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _detailRow('得意先コード', customer.customerCode), + _detailRow('名称', customer.name), + _detailRow('電話番号', customer.phoneNumber), + _detailRow('Email', customer.email ?? '-'), + _detailRow('住所', customer.address), + if (customer.salesPersonId > 0) _detailRow('担当者 ID', customer.salesPersonId.toString()), + _detailRow('税抜率', '${customer.taxRate}%'), + _detailRow('割引率', '${customer.discountRate}%'), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'),), + ], + ), + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 100), + Expanded(child: Text(value)), + ], + ), + ); + } + + Future _showEventHistory(BuildContext context, Customer customer) async { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('イベント履歴表示(未実装:後期開発)')), + ); + } + + void _showSnackBar(BuildContext context, String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + ), + ); + } } \ No newline at end of file diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart new file mode 100644 index 0000000..dd231ef --- /dev/null +++ b/lib/services/database_helper.dart @@ -0,0 +1,139 @@ +// Version: 1.0.0 +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; +import 'dart:convert'; +import '../models/customer.dart'; + +class DatabaseHelper { + static final DatabaseHelper instance = DatabaseHelper._init(); + static Database? _database; + + DatabaseHelper._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB('customer_assist.db'); + return _database!; + } + + Future _initDB(String filePath) async { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); + + return await openDatabase( + path, + version: 1, + onCreate: _createDB, + ); + } + + Future _createDB(Database db, int version) async { + const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT'; + const textType = 'TEXT NOT NULL'; + const intType = 'INTEGER'; + + await db.execute(''' + CREATE TABLE customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_code TEXT NOT NULL, + name TEXT NOT NULL, + phone_number TEXT NOT NULL, + email TEXT NOT NULL, + address TEXT NOT NULL, + sales_person_id INTEGER, + tax_rate INTEGER DEFAULT 8, // Default 10% + discount_rate INTEGER DEFAULT 0, // Default 0% + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + + await db.execute(''' + CREATE TABLE customer_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL, + data_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + '''); + } + + Future insert(Customer customer) async { + final db = await instance.database; + return await db.insert('customers', customer.toMap()); + } + + Future> getCustomers() async { + final db = await instance.database; + final List> maps = await db.query('customers'); + + return (maps as List) + .map((json) => Customer.fromMap(json)) + .where((c) => c.isDeleted == 0) + .toList(); + } + + Future getCustomer(int id) async { + final db = await instance.database; + final maps = await db.query( + 'customers', + where: 'id = ?', + whereArgs: [id], + ); + + if (maps.isEmpty) return null; + return Customer.fromMap(maps.first); + } + + Future update(Customer customer) async { + final db = await instance.database; + 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], + ); + } + + Future saveSnapshot(Customer customer) async { + final db = await instance.database; + 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, + ); + + 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, + }); + } + + Future close() async { + final db = await instance.database; + db.close(); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index affcf9a..40f6a02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: sales_assist_1 description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント publish_to: 'none' -version: 1.0.0+4 +version: 1.0.0+5 environment: sdk: '>=3.0.0 <4.0.0' @@ -11,25 +11,21 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.6 - + # SQLite データ永続化 sqflite: ^2.3.3 path_provider: ^2.1.1 - + # Google エコシステム連携 google_sign_in: ^6.1.0 http: ^1.1.0 - googleapis_auth: ^1.4.0 - googleapis: ^12.0.0 googleapis_auth: ^1.5.0 - + googleapis: ^12.0.0 + dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 flutter: - uses-material-design: true - - assets: - - assets/ \ No newline at end of file + uses-material-design: true \ No newline at end of file