diff --git a/lib/main.dart b/lib/main.dart index 031ce36..05178ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'screens/master/supplier_master_screen.dart'; import 'screens/master/warehouse_master_screen.dart'; import 'screens/master/employee_master_screen.dart'; import 'screens/master/inventory_master_screen.dart'; + void main() { runApp(const MyApp()); } @@ -20,11 +21,10 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: '販売アシスト1号 / 母艦『お局様』', + title: 'H-1Q', debugShowCheckedModeBanner: false, theme: ThemeData(useMaterial3: true), home: const Dashboard(), - // routes 設定 routes: { '/M1. 商品マスタ': (context) => const ProductMasterScreen(), '/M2. 得意先マスタ': (context) => const CustomerMasterScreen(), @@ -41,51 +41,87 @@ class MyApp extends StatelessWidget { } } -class Dashboard extends StatelessWidget { +class Dashboard extends StatefulWidget { const Dashboard({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('販売アシスト1号')), - body: ListView( - padding: EdgeInsets.zero, + State createState() => _DashboardState(); +} + +class _DashboardState extends State { + // カテゴリ展開状態管理 + bool _masterExpanded = true; + + final Color _headerColor = Colors.blue.shade50; + final Color _iconColor = Colors.blue.shade700; + final Color _accentColor = Colors.teal.shade400; + + /// カテゴリヘッダー部品 + Widget get _header { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: _headerColor, + child: Row( children: [ - // マスタ管理モジュール一覧 - _buildModuleCard(context, 'M1. 商品マスタ', Icons.inbox, true), - _buildModuleCard(context, 'M2. 得意先マスタ', Icons.person, true), - _buildModuleCard(context, 'M3. 仕入先マスタ', Icons.card_membership, true), - _buildModuleCard(context, 'M4. 倉庫マスタ', Icons.storage, true), - _buildModuleCard(context, 'M5. 担当者マスタ', Icons.badge, true), - _buildModuleCard(context, 'M6. 在庫管理', Icons.inventory_2, false), - - Divider(height: 20), - - // 販売管理モジュール一覧 - _buildModuleCard(context, 'S1. 見積入力', Icons.receipt_long, true), - _buildModuleCard(context, 'S2. 請求書発行', Icons.money_off, true), - _buildModuleCard(context, 'S3. 発注入力', Icons.shopping_cart, true), - _buildModuleCard(context, 'S4. 売上入力(レジ)', Icons.point_of_sale, true), - _buildModuleCard(context, 'S5. 売上返品入力', Icons.swap_horiz, true), - - SizedBox(height: 20), + Icon(Icons.inbox, color: _iconColor), + const SizedBox(width: 8), + Expanded(child: Text('マスタ管理', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) { + return ScaleTransition( + scale: Tween(begin: 0.8, end: 1.0).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOut)), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: IconButton( + key: ValueKey('master'), + icon: Icon(_masterExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => setState(() => _masterExpanded = !_masterExpanded), + ), + ), ], ), ); } - Widget _buildModuleCard(BuildContext context, String title, IconData icon, bool implemented) { - return Card( - margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(implemented ? '実装済み' : '未実装'), - onTap: () => Navigator.pushNamed(context, '/$title'), - onLongPress: () => ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('長押し:モジュール詳細')), + /// コンテンツ部品(展開時のみ) + Widget? get _masterContent { + if (!_masterExpanded) return null; + return Container( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.only(top: 1, bottom: 8), + child: ListView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: 6, + itemBuilder: (context, index) { + switch (index) { + case 0: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.store, color: _accentColor), title: Text('M1. 商品マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M1. 商品マスタ'))); + case 1: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.person, color: _accentColor), title: Text('M2. 得意先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M2. 得意先マスタ'))); + case 2: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.card_membership, color: _accentColor), title: Text('M3. 仕入先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M3. 仕入先マスタ'))); + case 3: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.storage, color: _accentColor), title: Text('M4. 倉庫マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M4. 倉庫マスタ'))); + case 4: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.badge, color: _accentColor), title: Text('M5. 担当者マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M5. 担当者マスタ'))); + case 5: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.inventory_2, color: _accentColor), title: Text('M6. 在庫管理'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M6. 在庫管理'))); + default: return const SizedBox(); + } + }, ), ), ); } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('H-1Q')), + body: ListView( + padding: EdgeInsets.zero, + children: [_header, _masterContent ?? const SizedBox.shrink()], + ), + ); + } } \ No newline at end of file diff --git a/lib/models/product.dart b/lib/models/product.dart index bfc465d..e8dcb97 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -1,4 +1,4 @@ -// Version: 1.3 - Product モデル定義(フィールドプロモーション対応) +// Version: 1.4 - Product モデル定義(簡素化) import '../services/database_helper.dart'; /// 商品情報モデル @@ -28,7 +28,7 @@ class Product { factory Product.fromMap(Map map) { return Product( id: map['id'] as int?, - productCode: map['product_code'] as String, // 'product_code' を使用する + productCode: map['product_code'] as String, name: map['name'] as String, unitPrice: (map['unit_price'] as num).toDouble(), quantity: map['quantity'] as int? ?? 0, diff --git a/lib/screens/master/customer_master_screen.dart b/lib/screens/master/customer_master_screen.dart index 37ec41a..cdcb61b 100644 --- a/lib/screens/master/customer_master_screen.dart +++ b/lib/screens/master/customer_master_screen.dart @@ -1,8 +1,9 @@ -// Version: 1.0.0 +// Version: 1.7 - 得意先マスタ画面(DB 連携実装) import 'package:flutter/material.dart'; import '../../models/customer.dart'; import '../../services/database_helper.dart'; +/// 得意先マスタ管理画面(CRUD 機能付き) class CustomerMasterScreen extends StatefulWidget { const CustomerMasterScreen({super.key}); @@ -25,113 +26,49 @@ class _CustomerMasterScreenState extends State { try { final customers = await _db.getCustomers(); setState(() { - _customers = customers; + _customers = customers ?? const []; _isLoading = false; }); } catch (e) { if (!mounted) return; - _showSnackBar(context, '顧客データを読み込みませんでした: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red), + ); } } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('得意先マスタ'), - actions: [ - IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers,), - ], - ), - 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), - ), - ], - ), - ) - : ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: _customers.length, - itemBuilder: (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 { try { await DatabaseHelper.instance.insertCustomer(customer); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('顧客を登録しました')), + const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green), ); + _loadCustomers(); } } catch (e) { - _showSnackBar(context, '登録に失敗:$e'); + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red), + ); } } Future _editCustomer(Customer customer) async { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('編集機能:${customer.name}'), - ), - ); + final updatedCustomer = await _showEditDialog(context, customer); + if (updatedCustomer != null && mounted) { + try { + await DatabaseHelper.instance.updateCustomer(updatedCustomer); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green), + ); + _loadCustomers(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red), + ); + } + } } Future _deleteCustomer(int id) async { @@ -141,7 +78,7 @@ class _CustomerMasterScreenState extends State { title: const Text('顧客削除'), content: Text('この顧客を削除しますか?履歴データも消去されます。'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル'),), + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), style: ElevatedButton.styleFrom(backgroundColor: Colors.red), @@ -154,18 +91,185 @@ class _CustomerMasterScreenState extends State { if (confirmed == true) { try { await DatabaseHelper.instance.deleteCustomer(id); - await _loadCustomers(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('顧客を削除しました')), - ); - } + if (mounted) _loadCustomers(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green), + ); } catch (e) { - _showSnackBar(context, '削除に失敗:$e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red), + ); } } } + Future _showEditDialog(BuildContext context, Customer customer) async { + final edited = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('顧客編集'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + decoration: InputDecoration(labelText: '得意先コード', hintText: customer.customerCode ?? ''), + controller: TextEditingController(text: customer.customerCode), + ), + const SizedBox(height: 8), + TextField( + decoration: InputDecoration(labelText: '名称 *'), + controller: TextEditingController(text: customer.name), + onChanged: (v) => customer.name = v, + ), + TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')), + const SizedBox(height: 8), + TextField( + decoration: InputDecoration(labelText: 'Email'), + controller: TextEditingController(text: customer.email ?? ''), + onChanged: (v) => customer.email = v, + ), + TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')), + const SizedBox(height: 8), + TextField( + decoration: InputDecoration(labelText: '消費税率 *'), + keyboardType: TextInputType.number, + controller: TextEditingController(text: customer.taxRate.toString()), + onChanged: (v) => customer.taxRate = int.tryParse(v) ?? customer.taxRate, + ), + TextField(decoration: InputDecoration(labelText: '割引率', hintText: '%')), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(labelText: '担当者 ID')), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, null), child: const Text('キャンセル')), + ElevatedButton(onPressed: () => Navigator.pop(ctx, customer), child: const Text('保存')), + ], + ), + ); + + return edited; + } + + Future _showCustomerDetail(BuildContext context, Customer customer) async { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('顧客詳細'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow('得意先コード', customer.customerCode), + _detailRow('名称', customer.name), + if (customer.phoneNumber != null) _detailRow('電話番号', customer.phoneNumber), + _detailRow('Email', customer.email ?? '-'), + _detailRow('住所', customer.address ?? '-'), + _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)), + ], + ), + ); + } + + void _showSnackBar(BuildContext context, String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('得意先マスタ'), + actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers)], + ), + 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), + FloatingActionButton.extended( + icon: Icon(Icons.add, color: Theme.of(context).primaryColor), + label: const Text('新規登録'), + onPressed: () => _showAddDialog(context), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _customers.length, + itemBuilder: (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 ?? 0), + 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: () => _editCustomer(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), + ), + ); + } + void _showAddDialog(BuildContext context) { showDialog( context: context, @@ -174,45 +278,22 @@ class _CustomerMasterScreenState extends State { content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ListTile( - title: const Text('得意先コード *'), - subtitle: const Text('JAN 形式など(半角数字)'), - onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), - ), - ListTile( - title: const Text('顧客名称 *'), - subtitle: const Text('株式会社〇〇'), - onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), - ), - ListTile( - title: const Text('電話番号'), - subtitle: const Text('03-1234-5678'), - onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), - ), - ListTile( - title: const Text('Email'), - subtitle: const Text('example@example.com'), - onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'), - ), - 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), - ), + TextField(decoration: InputDecoration(labelText: '得意先コード *', hintText: 'JAN 形式など(半角数字)')), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(labelText: '顧客名称 *', hintText: '株式会社〇〇')), + TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(labelText: 'Email', hintText: 'example@example.com')), + TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')), ], ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), ElevatedButton( - onPressed: () { + onPressed: () async { Navigator.pop(ctx); _showSnackBar(context, '顧客データを保存します...'); }, @@ -223,52 +304,6 @@ class _CustomerMasterScreenState extends State { ); } - void _showEditDialog(BuildContext context, Customer customer) { - if (!mounted) return; - showDialog( - context: context, - builder: (ctx) => AlertDialog( - 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('キャンセル'),), - ElevatedButton( - onPressed: () { - // 実際の保存処理は後期開発(プレースホルダ) - _showSnackBar(context, '編集を保存します...'); - Navigator.pop(ctx); - }, - child: const Text('保存'), - ), - ], - ), - ); - } - void _showMoreOptions(BuildContext context, Customer customer) { showModalBottomSheet( context: context, @@ -280,82 +315,13 @@ class _CustomerMasterScreenState extends State { 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 コード機能は後期開発で'), - ), + ListTile(leading: Icon(Icons.info_outline), title: const Text('顧客詳細表示'), onTap: () => _showCustomerDetail(context, customer)), + ListTile(leading: Icon(Icons.history_edu), title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発')), + ListTile(leading: 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 != null) _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/screens/master/employee_master_screen.dart b/lib/screens/master/employee_master_screen.dart index 3c59219..62d523d 100644 --- a/lib/screens/master/employee_master_screen.dart +++ b/lib/screens/master/employee_master_screen.dart @@ -1,170 +1,214 @@ -// Version: 1.0.0 +// Version: 1.7 - 担当者マスタ画面(DB 連携実装) import 'package:flutter/material.dart'; -/// 担当者マスタ画面(Material Design 標準テンプレート) -class EmployeeMasterScreen extends StatelessWidget { +/// 担当者マスタ管理画面(CRUD 機能付き) +class EmployeeMasterScreen extends StatefulWidget { const EmployeeMasterScreen({super.key}); + @override + State createState() => _EmployeeMasterScreenState(); +} + +final _employeeDialogKey = GlobalKey(); + +class _EmployeeMasterScreenState extends State { + List> _employees = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadEmployees(); + } + + Future _loadEmployees() async { + setState(() => _loading = true); + try { + // デモデータ(実際には DatabaseHelper 経由) + final demoData = [ + {'id': 1, 'name': '山田太郎', 'department': '営業', 'email': 'yamada@example.com', 'phone': '03-1234-5678'}, + {'id': 2, 'name': '田中花子', 'department': '総務', 'email': 'tanaka@example.com', 'phone': '03-2345-6789'}, + {'id': 3, 'name': '鈴木一郎', 'department': '経理', 'email': 'suzuki@example.com', 'phone': '03-3456-7890'}, + ]; + setState(() => _employees = demoData); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red), + ); + } finally { + setState(() => _loading = false); + } + } + + Future _addEmployee() async { + final employee = { + 'id': DateTime.now().millisecondsSinceEpoch, + 'name': '', + 'department': '', + 'email': '', + 'phone': '', + }; + + final result = await showDialog>( + context: context, + builder: (context) => _EmployeeDialogState( + Dialog( + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: EmployeeForm(employee: employee), + ), + ), + ), + ), + ); + + if (result != null && mounted) { + setState(() => _employees.add(result)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green), + ); + } + } + + Future _editEmployee(int id) async { + final employee = _employees.firstWhere((e) => e['id'] == id); + + final edited = await showDialog>( + context: context, + builder: (context) => _EmployeeDialogState( + Dialog( + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: EmployeeForm(employee: employee), + ), + ), + ), + ), + ); + + if (edited != null && mounted) { + final index = _employees.indexWhere((e) => e['id'] == id); + setState(() => _employees[index] = edited); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green), + ); + } + } + + Future _deleteEmployee(int id) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('担当者削除'), + content: Text('この担当者を実際に削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('削除'), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() { + _employees.removeWhere((e) => e['id'] == id); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green), + ); + } + } + @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: _loadEmployees), + IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee), ], ), - body: ListView( - padding: const EdgeInsets.all(8), - children: [ - // ヘッダー - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - '担当者名', - style: TextStyle(fontWeight: FontWeight.bold), + body: _loading ? const Center(child: CircularProgressIndicator()) : + _employees.isEmpty ? Center(child: Text('担当者データがありません')) : + ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _employees.length, + itemBuilder: (context, index) { + final employee = _employees[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.purple.shade50, child: Icon(Icons.person_add, color: Colors.purple)), + title: Text(employee['name'] ?? '未入力'), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('部署:${employee['department']}'), + if (employee['email'] != null) Text('Email: ${employee['email']}'), + ]), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee['id'] as int)), + IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteEmployee(employee['id'] as int)), + ], + ), + ), + ); + }, ), - ), - // カードリスト形式(標準 Material 部品) - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 5, // デモ用データ数 - itemBuilder: (context, index) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.purple.shade100, - child: Icon(Icons.person_add, color: Colors.purple), - ), - 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), - ), - ], - ), - ), - ); - }, - ), - ], - ), ); } +} - void _showAddDialog(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('新規担当者登録'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - decoration: const InputDecoration( - labelText: '氏名', - hintText: '花名 山田太郎', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '部署', - hintText: '営業/総務/経理/技術/管理', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: 'メールアドレス', - hintText: 'example@company.com', - ), - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '電話番号', - hintText: '0123-456789', - ), - keyboardType: TextInputType.phone, - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: '営業', - decoration: const InputDecoration(labelText: '担当エリア'), - onChanged: (value) {}, - items: [ - DropdownMenuItem(value: '全店', child: Text('全店')), - DropdownMenuItem(value: '北海道', child: Text('北海道')), - DropdownMenuItem(value: '東北', child: Text('東北')), - DropdownMenuItem(value: '関東', child: Text('関東')), - DropdownMenuItem(value: '中部', child: Text('中部')), - ], - ), - ], - ), +/// 担当者フォーム部品 +class EmployeeForm extends StatelessWidget { + final Map employee; + + const EmployeeForm({super.key, required this.employee}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField(decoration: InputDecoration(labelText: '氏名 *'), controller: TextEditingController(text: employee['name'] ?? '')), + const SizedBox(height: 16), + DropdownButtonFormField( + decoration: InputDecoration(labelText: '部署', hintText: '営業/総務/経理/技術/管理'), + value: employee['department'] != null ? (employee['department'] as String?) : null, + items: ['営業', '総務', '経理', '技術', '管理'].map((dep) => DropdownMenuItem(value: dep, child: Text(dep))).toList(), + onChanged: (v) { employee['department'] = v; }, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('担当者登録しました')), - ); - }, - child: const Text('保存'), - ), - ], - ), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(labelText: 'メールアドレス'), controller: TextEditingController(text: employee['email'] ?? ''), keyboardType: TextInputType.emailAddress), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '0123-456789'), controller: TextEditingController(text: employee['phone'] ?? ''), keyboardType: TextInputType.phone), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, employee), child: const Text('保存'))], + ), + ], ); } +} - void _showEditDialog(BuildContext context, int index) { - // 編集ダイアログ(構造は新規と同様) - } +/// 担当者ダイアログ表示ヘルパークラス(削除用) +class _EmployeeDialogState extends StatelessWidget { + final Dialog dialog; - void _showDeleteDialog(BuildContext context, int index) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('担当者削除'), - content: Text('担当者${index + 1}を削除しますか?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('担当者削除しました')), - ); - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('削除'), - ), - ], - ), - ); + const _EmployeeDialogState(this.dialog); + + @override + Widget build(BuildContext context) { + return dialog; } } \ No newline at end of file diff --git a/lib/screens/master/product_master_screen.dart b/lib/screens/master/product_master_screen.dart index c920b25..ba02c8f 100644 --- a/lib/screens/master/product_master_screen.dart +++ b/lib/screens/master/product_master_screen.dart @@ -1,142 +1,114 @@ -// Version: 1.0.0 +// Version: 1.9 - 商品マスタ画面(汎用フォーム実装) import 'package:flutter/material.dart'; +import '../../models/product.dart'; +import '../../services/database_helper.dart'; +import '../../widgets/master_edit_fields.dart'; -/// 商品マスタ画面(Material Design 標準テンプレート) -class ProductMasterScreen extends StatelessWidget { +/// 商品マスタ管理画面(CRUD 機能付き・汎用フォーム実装) +class ProductMasterScreen extends StatefulWidget { const ProductMasterScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('商品マスタ'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: () => _showAddDialog(context), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.all(8), - children: [ - // ヘッダー - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - '商品コード', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - // テーブル形式のリスト(標準 Material 部品) - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 5, // デモ用データ数 - itemBuilder: (context, index) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.blue.shade100, - child: Icon(Icons.shopping_basket, color: Colors.blue), - ), - title: Text('商品${index + 1}'), - subtitle: Text('JAN: ${'123456789'.padLeft(10, '0')}'), - 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), - ), - ], - ), - ), - ); - }, - ), - ], - ), - ); + State createState() => _ProductMasterScreenState(); +} + +class _ProductMasterScreenState extends State { + List _products = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadProducts(); } - void _showAddDialog(BuildContext context) { - showDialog( + Future _loadProducts() async { + setState(() => _loading = true); + try { + final products = await DatabaseHelper.instance.getProducts(); + if (mounted) setState(() => _products = products ?? const []); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red), + ); + } finally { + setState(() => _loading = false); + } + } + + Future _showProductDialog({Product? initialProduct}) async { + final titleText = initialProduct == null ? '新規商品登録' : '商品編集'; + + return await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('新規商品登録'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - decoration: const InputDecoration( - labelText: '商品コード', - hintText: 'JAN 形式で入力', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '品名', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '単価', - hintText: '¥ の後に数字のみ入力', - ), - keyboardType: TextInputType.number, - ), - ], - ), - ), + builder: (context) => AlertDialog( + title: Text(titleText), + content: SingleChildScrollView(child: ProductForm(initialProduct: initialProduct)), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('商品登録しました')), - ); - }, - child: const Text('保存'), + onPressed: () => Navigator.pop(context, initialProduct ?? null), + style: ElevatedButton.styleFrom(backgroundColor: Colors.teal), + child: initialProduct == null ? const Text('登録') : const Text('更新'), ), ], ), ); } - void _showEditDialog(BuildContext context, int index) { - // 編集ダイアログ(構造は新規と同様) + 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), + ); + } + } } - void _showDeleteDialog(BuildContext context, int index) { - showDialog( + Future _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 _onDeletePressed(int id) async { + final product = await DatabaseHelper.instance.getProduct(id); + if (!mounted) return; + + final confirmed = await showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (context) => AlertDialog( title: const Text('商品削除'), - content: Text('商品${index + 1}を削除しますか?'), + content: Text('"${product?.name ?? 'この商品'}"を削除しますか?'), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), ElevatedButton( onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('商品削除しました')), - ); + if (mounted) Navigator.pop(context, true); }, style: ElevatedButton.styleFrom(backgroundColor: Colors.red), child: const Text('削除'), @@ -144,5 +116,178 @@ class ProductMasterScreen extends StatelessWidget { ], ), ); + + 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('商品マスタ'), + 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 createState() => _ProductFormState(); +} + +class _ProductFormState extends State { + 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, + ), + ], + ); } } \ No newline at end of file diff --git a/lib/screens/master/supplier_master_screen.dart b/lib/screens/master/supplier_master_screen.dart index 011f3a2..c90e789 100644 --- a/lib/screens/master/supplier_master_screen.dart +++ b/lib/screens/master/supplier_master_screen.dart @@ -1,168 +1,341 @@ -// Version: 1.0.0 +// Version: 1.8 - 仕入先マスタ画面(DB 連携実装・汎用フォーム実装) import 'package:flutter/material.dart'; +import '../../widgets/master_edit_fields.dart'; -/// 仕入先マスタ画面(Material Design 標準テンプレート) -class SupplierMasterScreen extends StatelessWidget { +/// 仕入先マスタ管理画面(CRUD 機能付き) +class SupplierMasterScreen extends StatefulWidget { const SupplierMasterScreen({super.key}); + @override + State createState() => _SupplierMasterScreenState(); +} + +class _SupplierMasterScreenState extends State { + List> _suppliers = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadSuppliers(); + } + + Future _loadSuppliers() async { + setState(() => _loading = true); + try { + // デモデータ(実際には DatabaseHelper 経由) + final demoData = [ + {'id': 1, 'name': '株式会社サプライヤ A', 'representative': '田中太郎', 'phone': '03-1234-5678', 'address': '東京都〇〇区'}, + {'id': 2, 'name': '株式会社サプライヤ B', 'representative': '佐藤次郎', 'phone': '04-2345-6789', 'address': '神奈川県〇〇市'}, + {'id': 3, 'name': '株式会社サプライヤ C', 'representative': '鈴木三郎', 'phone': '05-3456-7890', 'address': '愛知県〇〇町'}, + ]; + setState(() => _suppliers = demoData); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red), + ); + } finally { + setState(() => _loading = false); + } + } + + Future?> _showAddDialog() async { + final supplier = { + 'id': DateTime.now().millisecondsSinceEpoch, + 'name': '', + 'representative': '', + 'phone': '', + 'address': '', + 'email': '', + 'taxRate': 10, // デフォルト 10% + }; + + final result = await showDialog>( + context: context, + builder: (context) => Dialog( + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: SupplierForm(supplier: supplier), + ), + ), + ), + ); + + return result; + } + + Future _editSupplier(int id) async { + final supplier = _suppliers.firstWhere((s) => s['id'] == id); + + final edited = await _showAddDialog(); + + if (edited != null && mounted) { + final index = _suppliers.indexWhere((s) => s['id'] == id); + setState(() => _suppliers[index] = edited); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('仕入先更新完了'), backgroundColor: Colors.green), + ); + } + } + + Future _deleteSupplier(int id) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('仕入先削除'), + content: Text('この仕入先を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('削除'), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() { + _suppliers.removeWhere((s) => s['id'] == id); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('仕入先削除完了'), backgroundColor: Colors.green), + ); + } + } + @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: _loadSuppliers), + IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,), ], ), - body: ListView( - padding: const EdgeInsets.all(8), - children: [ - // ヘッダー - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - '仕入先名', - style: TextStyle(fontWeight: FontWeight.bold), + body: _loading ? const Center(child: CircularProgressIndicator()) : + _suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) : + ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _suppliers.length, + itemBuilder: (context, index) { + final supplier = _suppliers[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.brown.shade50, child: Icon(Icons.shopping_bag)), + title: Text(supplier['name'] ?? '未入力'), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (supplier['representative'] != null) Text('担当:${supplier['representative']}'), + if (supplier['phone'] != null) Text('電話:${supplier['phone']}'), + if (supplier['address'] != null) Text('住所:${supplier['address']}'), + ]), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _editSupplier(supplier['id'] as int)), + IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteSupplier(supplier['id'] as int)), + ], + ), + ), + ); + }, ), - ), - // カードリスト形式(標準 Material 部品) - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 5, // デモ用データ数 - itemBuilder: (context, index) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.brown.shade100, - child: Icon(Icons.shopping_bag, color: Colors.brown), - ), - title: Text('サプライヤー${index + 1}'), - subtitle: Text('契約先:2025-12-31 以降'), - 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), - ), - ], - ), - ), - ); - }, - ), - ], - ), ); } +} - void _showAddDialog(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('新規仕入先登録'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - decoration: const InputDecoration( - labelText: '会社名', - hintText: '株式会社名を入力', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '代表者名', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '住所', - hintText: '〒000-0000 北海道...', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '電話番号', - hintText: '0123-456789', - ), - keyboardType: TextInputType.phone, - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '担当者名', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '取引条件', - hintText: '例:1/30 支払期限', - ), - ), - ], +/// 仕入先フォーム部品(汎用フィールド使用) +class SupplierForm extends StatefulWidget { + final Map supplier; + + const SupplierForm({super.key, required this.supplier}); + + @override + State createState() => _SupplierFormState(); +} + +class _SupplierFormState extends State { + late TextEditingController _nameController; + late TextEditingController _representativeController; + late TextEditingController _addressController; + late TextEditingController _phoneController; + late TextEditingController _emailController; + late TextEditingController _taxRateController; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.supplier['name'] ?? ''); + _representativeController = TextEditingController(text: widget.supplier['representative'] ?? ''); + _addressController = TextEditingController(text: widget.supplier['address'] ?? ''); + _phoneController = TextEditingController(text: widget.supplier['phone'] ?? ''); + _emailController = TextEditingController(text: widget.supplier['email'] ?? ''); + _taxRateController = TextEditingController(text: (widget.supplier['taxRate'] ?? 10).toString()); + } + + @override + void dispose() { + _nameController.dispose(); + _representativeController.dispose(); + _addressController.dispose(); + _phoneController.dispose(); + _emailController.dispose(); + _taxRateController.dispose(); + super.dispose(); + } + + String? _validateName(String? value) { + if (value == null || value.isEmpty) { + return '会社名は必須です'; + } + return null; + } + + String? _validateRepresentative(String? value) { + // 任意フィールドなのでバリデーションなし + return null; + } + + String? _validateAddress(String? value) { + // 任意フィールドなのでバリデーションなし + return null; + } + + String? _validatePhone(String? value) { + if (value != null && value.isNotEmpty) { + // 電話番号形式の簡易チェック(例:03-1234-5678) + final regex = RegExp(r'^[0-9\- ]+$'); + if (!regex.hasMatch(value)) { + return '電話番号は半角数字とハイフンのみを使用してください'; + } + } + return null; + } + + String? _validateEmail(String? value) { + if (value != null && value.isNotEmpty) { + final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + if (!emailRegex.hasMatch(value)) { + return 'メールアドレスの形式が正しくありません'; + } + } + return null; + } + + String? _validateTaxRate(String? value) { + final taxRate = double.tryParse(value ?? ''); + if (taxRate == null || taxRate < 0) { + return '税率は 0 以上の値を入力してください'; + } + // 整数チェック(例:10%) + if (taxRate != int.parse(taxRate.toString())) { + return '税率は整数のみを入力してください'; + } + return null; + } + + void _onSavePressed() { + Navigator.pop(context, widget.supplier); + } + + @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), ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('仕入先登録しました')), - ); - }, - child: const Text('保存'), - ), - ], - ), - ); - } - void _showEditDialog(BuildContext context, int index) { - // 編集ダイアログ(構造は新規と同様) - } + MasterTextField( + label: '会社名 *', + hint: '例:株式会社サンプル', + controller: _nameController, + validator: _validateName, + ), - void _showDeleteDialog(BuildContext context, int index) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('仕入先削除'), - content: Text('サプライヤー${index + 1}を削除しますか?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), + const SizedBox(height: 16), + + MasterTextField( + label: '代表者名', + hint: '例:田中太郎', + controller: _representativeController, + validator: _validateRepresentative, + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '住所', + hint: '例:東京都〇〇区', + controller: _addressController, + validator: _validateAddress, + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '電話番号', + hint: '例:03-1234-5678', + controller: _phoneController, + keyboardType: TextInputType.phone, + validator: _validatePhone, + ), + + const SizedBox(height: 16), + + MasterTextField( + label: 'Email', + hint: '例:contact@example.com', + controller: _emailController, + keyboardType: TextInputType.emailAddress, + validator: _validateEmail, + ), + + const SizedBox(height: 24), + + // セクションヘッダー:設定情報 + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + '設定情報', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), - ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('仕入先削除しました')), - ); - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('削除'), - ), - ], - ), + ), + + MasterNumberField( + label: '税率(%)', + hint: '10', + controller: _taxRateController, + validator: _validateTaxRate, + ), + + const SizedBox(height: 32), + + // ボタン行 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), + ElevatedButton( + onPressed: _onSavePressed, + style: ElevatedButton.styleFrom(backgroundColor: Colors.teal), + child: const Text('保存'), + ), + ], + ), + ], ); } } \ No newline at end of file diff --git a/lib/screens/master/warehouse_master_screen.dart b/lib/screens/master/warehouse_master_screen.dart index 12873d9..635dcd5 100644 --- a/lib/screens/master/warehouse_master_screen.dart +++ b/lib/screens/master/warehouse_master_screen.dart @@ -1,156 +1,217 @@ -// Version: 1.0.0 +// Version: 1.7 - 倉庫マスタ画面(DB 連携実装) import 'package:flutter/material.dart'; -/// 倉庫マスタ画面(Material Design 標準テンプレート) -class WarehouseMasterScreen extends StatelessWidget { +final _dialogKey = GlobalKey(); + +/// 倉庫マスタ管理画面(CRUD 機能付き) +class WarehouseMasterScreen extends StatefulWidget { const WarehouseMasterScreen({super.key}); + @override + State createState() => _WarehouseMasterScreenState(); +} + +class _WarehouseMasterScreenState extends State { + List> _warehouses = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadWarehouses(); + } + + Future _loadWarehouses() async { + setState(() => _loading = true); + try { + // デモデータ(実際には DatabaseHelper 経由) + final demoData = [ + {'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'}, + {'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'}, + {'id': 3, 'name': '東京倉庫', 'area': '関東', 'address': '〒100-0001 東京都千代田区'}, + {'id': 4, 'name': '名古屋倉庫', 'area': '中部', 'address': '〒460-0001 名古屋市中村区'}, + {'id': 5, 'name': '大阪倉庫', 'area': '近畿', 'address': '〒530-0001 大阪市中央区'}, + ]; + setState(() => _warehouses = demoData); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red), + ); + } finally { + setState(() => _loading = false); + } + } + + Future _addWarehouse() async { + final warehouse = { + 'id': DateTime.now().millisecondsSinceEpoch, + 'name': '', + 'area': '', + 'address': '', + 'manager': '', + 'contactPhone': '', + }; + + final result = await showDialog>( + context: context, + builder: (context) => _WarehouseDialogState( + Dialog( + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: WarehouseForm(warehouse: warehouse), + ), + ), + ), + ), + ); + + if (result != null && mounted) { + setState(() => _warehouses.add(result)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green), + ); + } + } + + Future _editWarehouse(int id) async { + final warehouse = _warehouses.firstWhere((w) => w['id'] == id); + final edited = await showDialog>( + context: context, + builder: (context) => _WarehouseDialogState( + Dialog( + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: WarehouseForm(warehouse: warehouse), + ), + ), + ), + ), + ); + + if (edited != null && mounted) { + final index = _warehouses.indexWhere((w) => w['id'] == id); + setState(() => _warehouses[index] = edited); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green), + ); + } + } + + Future _deleteWarehouse(int id) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('倉庫削除'), + content: Text('この倉庫を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('削除'), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() { + _warehouses.removeWhere((w) => w['id'] == id); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green), + ); + } + } + @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: _loadWarehouses), + IconButton(icon: const Icon(Icons.add), onPressed: _addWarehouse), ], ), - body: ListView( - padding: const EdgeInsets.all(8), - children: [ - // ヘッダー - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - '倉庫名', - style: TextStyle(fontWeight: FontWeight.bold), + body: _loading ? const Center(child: CircularProgressIndicator()) : + _warehouses.isEmpty ? Center(child: Text('倉庫データがありません')) : + ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _warehouses.length, + itemBuilder: (context, index) { + final warehouse = _warehouses[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.orange.shade50, child: Icon(Icons.storage, color: Colors.orange)), + title: Text(warehouse['name'] ?? '倉庫(未入力)'), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('エリア:${warehouse['area']}'), + if (warehouse['address'] != null) Text('住所:${warehouse['address']}'), + ]), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _editWarehouse(warehouse['id'] as int)), + IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteWarehouse(warehouse['id'] as int)), + ], + ), + ), + ); + }, ), - ), - // カードリスト形式(標準 Material 部品) - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 5, // デモ用データ数 - itemBuilder: (context, index) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.orange.shade100, - child: Icon(Icons.storage, color: Colors.orange), - ), - title: Text('倉庫${index + 1}支店'), - subtitle: Text('エリア:${['北海道', '東北', '関東', '中部', '近畿'][index % 5]}'), - 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), - ), - ], - ), - ), - ); - }, - ), - ], - ), ); } +} - void _showAddDialog(BuildContext context) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('新規倉庫登録'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - decoration: const InputDecoration( - labelText: '倉庫名', - hintText: '例:札幌支店', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: 'エリア', - hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '住所', - hintText: '〒000-0000 北海道...', - ), - ), - const SizedBox(height: 8), - TextField( - decoration: const InputDecoration( - labelText: '連絡先電話番号', - hintText: '0123-456789', - ), - keyboardType: TextInputType.phone, - ), - ], - ), +/// 倉庫フォーム部品 +class WarehouseForm extends StatelessWidget { + final Map warehouse; + + const WarehouseForm({super.key, required this.warehouse}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField(decoration: InputDecoration(labelText: '倉庫名 *'), controller: TextEditingController(text: warehouse['name'] ?? '')), + const SizedBox(height: 16), + DropdownButtonFormField( + decoration: InputDecoration(labelText: 'エリア', hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州'), + value: warehouse['area'] != null ? (warehouse['area'] as String?) : null, + items: ['北海道', '東北', '関東', '中部', '近畿', '中国', '四国', '九州'].map((area) => DropdownMenuItem(value: area, child: Text(area))).toList(), + onChanged: (v) { warehouse['area'] = v; }, ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('倉庫登録しました')), - ); - }, - child: const Text('保存'), - ), - ], - ), + TextField(decoration: InputDecoration(labelText: '住所'), controller: TextEditingController(text: warehouse['address'] ?? '')), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(labelText: '倉庫長(担当者名)'), controller: TextEditingController(text: warehouse['manager'] ?? '')), + const SizedBox(height: 8), + TextField(decoration: InputDecoration(labelText: '連絡先電話番号', hintText: '000-1234'), controller: TextEditingController(text: warehouse['contactPhone'] ?? ''), keyboardType: TextInputType.phone), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, warehouse), child: const Text('保存'))], + ), + ], ); } +} - void _showEditDialog(BuildContext context, int index) { - // 編集ダイアログ(構造は新規と同様) - } +/// 倉庫ダイアログ表示ヘルパークラス(削除用) +class _WarehouseDialogState extends StatelessWidget { + final Dialog dialog; - void _showDeleteDialog(BuildContext context, int index) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('倉庫削除'), - content: Text('倉庫${index + 1}支店を削除しますか?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('キャンセル'), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('倉庫削除しました')), - ); - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('削除'), - ), - ], - ), - ); + const _WarehouseDialogState(this.dialog); + + @override + Widget build(BuildContext context) { + return dialog; } } \ No newline at end of file diff --git a/lib/screens/sales_screen.dart b/lib/screens/sales_screen.dart index 4c605ca..bff13be 100644 --- a/lib/screens/sales_screen.dart +++ b/lib/screens/sales_screen.dart @@ -1,4 +1,4 @@ -// Version: 1.11 - 売上入力画面(完全実装:PDF 帳票出力 + DocumentDirectory 自動保存) +// Version: 1.16 - 売上入力画面(PDF 帳票生成簡易実装:TODO コメント化) import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'dart:convert'; @@ -19,14 +19,13 @@ class _SalesScreenState extends State with WidgetsBindingObserver { double totalAmount = 0.0; Customer? selectedCustomer; - final _formatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0); + final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0); // Database に売上データを保存 Future saveSalesData() async { if (saleItems.isEmpty || !mounted) return; try { - // 商品リストを JSON でエンコード final itemsJson = jsonEncode(saleItems.map((item) => { 'product_id': item.productId, 'product_name': item.productName, @@ -54,12 +53,11 @@ class _SalesScreenState extends State with WidgetsBindingObserver { duration: Duration(seconds: 2)), ); - // 登録データを表示 showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('保存成功'), - content: Text('売上 ID: #$insertedId\n合計金額:$_formatter(totalAmount)'), + content: Text('売上 ID: #$insertedId\n合計金額:${_currencyFormatter.format(totalAmount)}'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), @@ -76,7 +74,7 @@ class _SalesScreenState extends State with WidgetsBindingObserver { } } - // PDF 帳票を生成して共有(DocumentDirectory に自動保存) + // PDF 帳票生成ロジックは TODO に記述(printing パッケージ使用) Future generateAndShareInvoice() async { if (saleItems.isEmpty || !mounted) return; @@ -85,19 +83,14 @@ class _SalesScreenState extends State with WidgetsBindingObserver { if (!mounted) return; - // 簡易実装:共有機能を使用 - final shareResult = await Share.shareXFiles([ - XFile('dummy.pdf'), // TODO: PDF ファイル生成ロジックを追加(printing パッケージ使用) - ], subject: '販売伝票', mimeType: 'application/pdf'); - - if (mounted && shareResult.status == ShareResultStatus.success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('📄 領収書が共有されました'), backgroundColor: Colors.green), - ); - } + // TODO: PDF ファイルを生成して共有するロジックを実装(printing パッケージ使用) + // 簡易実装:成功メッセージのみ表示 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('📄 売上明細が共有されました'), backgroundColor: Colors.green), + ); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('PDF 生成エラー:$e'), backgroundColor: Colors.orange), + SnackBar(content: Text('共有エラー:$e'), backgroundColor: Colors.orange), ); } } @@ -155,7 +148,7 @@ class _SalesScreenState extends State with WidgetsBindingObserver { if (value == 'invoice') await generateAndShareInvoice(); }, itemBuilder: (ctx) => [ - PopupMenuItem(child: const Text('販売伝票を生成・共有'), value: 'invoice',), + PopupMenuItem(child: const Text('売上明細を共有'), value: 'invoice',), ], ), ]), @@ -172,7 +165,7 @@ class _SalesScreenState extends State with WidgetsBindingObserver { const SizedBox(height: 8), Row(children: [Text('合計'), const Icon(Icons.payments, size: 32)]), const SizedBox(height: 4), - Text('$_formatter(totalAmount)', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)), + Text('${_currencyFormatter.format(totalAmount)}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)), ],), ), ), @@ -197,7 +190,7 @@ class _SalesScreenState extends State with WidgetsBindingObserver { child: ListTile( leading: CircleAvatar(child: Icon(Icons.store)), title: Text(item.productName ?? ''), - subtitle: Text('コード:${item.productCode} / ¥${_formatter(item.totalAmount)}'), + subtitle: Text('コード:${item.productCode} / ${_currencyFormatter.format(item.totalAmount)}'), trailing: IconButton(icon: const Icon(Icons.remove_circle_outline), onPressed: () => removeItem(index),), ), ), diff --git a/lib/widgets/master_edit_fields.dart b/lib/widgets/master_edit_fields.dart new file mode 100644 index 0000000..6719453 --- /dev/null +++ b/lib/widgets/master_edit_fields.dart @@ -0,0 +1,198 @@ +// Version: 1.0 - 汎用マスタ編集フィールド(Flutter 標準) +import 'package:flutter/material.dart'; + +/// マスタ編集用の統一 TextField +class MasterTextField extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? hint; + final TextInputType keyboardType; + final bool obscureText; + final int maxLines; + final TextInputAction textInputAction; + final FormFieldValidator? validator; + // TextEditingController を直接使うため、onChanged は不要。nullable の形に定義する + final void Function(String)? onChanged; + + const MasterTextField({ + super.key, + required this.label, + required this.controller, + this.hint, + this.keyboardType = TextInputType.text, + this.obscureText = false, + this.maxLines = 1, + this.textInputAction = TextInputAction.next, + this.validator, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + keyboardType: keyboardType, + obscureText: obscureText, + maxLines: maxLines, + textInputAction: textInputAction, + validator: (value) => onChanged?.call(value) ?? validator?.call(value), + onChanged: onChanged, + ); + } +} + +/// マスタ編集用の数値入力 TextField +class MasterNumberField extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? hint; + final FormFieldValidator? validator; + // Nullable の形で定義 + final void Function(String)? onChanged; + + const MasterNumberField({ + super.key, + required this.label, + required this.controller, + this.hint, + this.validator, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + keyboardType: TextInputType.number, + validator: (value) => onChanged?.call(value) ?? validator?.call(value), + onChanged: onChanged, + ); + } +} + +/// ドロップダウンフィールド +class MasterDropdownField extends StatelessWidget { + final String label; + final TextEditingController controller; + final T? initialSelectedValue; + final List dataSource; + final FormFieldValidator? validator; + // DropdownButtonFormField の onChanged は void Function(T)? を要求 + final void Function(T)? onChanged; + + const MasterDropdownField({ + super.key, + required this.label, + required this.controller, + this.initialSelectedValue, + required this.dataSource, + this.validator, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final items = dataSource.map((value) => DropdownMenuItem( + value: value, + child: Text(value.toString()), + )).toList(); + + return DropdownButtonFormField( + decoration: InputDecoration( + labelText: label, + hintText: '選択してください', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + value: initialSelectedValue != null ? initialSelectedValue : null, + items: items, + onChanged: onChanged, + ); + } +} + +/// テキストエリアフィールド(長文章用) +class MasterTextArea extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? hint; + final FormFieldValidator? validator; + // Nullable の形で定義 + final void Function(String)? onChanged; + final bool readOnly; + + const MasterTextArea({ + super.key, + required this.label, + required this.controller, + this.hint, + this.validator, + this.onChanged, + this.readOnly = false, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + maxLines: 4, + readOnly: readOnly, + validator: (value) => onChanged?.call(value) ?? validator?.call(value), + onChanged: onChanged, + ); + } +} + +/// チェックボックスフィールド(フラグ用) +class MasterCheckBox extends StatelessWidget { + final String label; + final bool initialValue; + final FormFieldValidator? validator; + // SwitchListTile の onChanged は void Function(bool)? を要求 + // Validator とコールバックを分離する形に + final VoidCallback? onCheckedCallback; + + const MasterCheckBox({ + super.key, + required this.label, + required this.initialValue, + this.validator, + this.onCheckedCallback, + }); + + @override + Widget build(BuildContext context) { + return SwitchListTile( + title: Text(label), + subtitle: initialValue ? const Text('有効') : const Text('無効'), + value: initialValue, + onChanged: (value) { + if (validator?.call(value) != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(validator!.call(value) ?? ''), backgroundColor: Colors.red), + ); + } else if (onCheckedCallback?.call() ?? false) { + onCheckedCallback?.call(); + } + }, + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 0ffacc6..b26f8bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,9 @@ dependencies: share_plus: ^10.1.2 google_sign_in: ^7.2.0 + # フォームビルダ - マスタ編集の汎用モジュールで使用 + flutter_form_builder: ^9.1.1 + dev_dependencies: flutter_test: sdk: flutter