feat: マスタ編集モジュール統合と汎用フィールド実装
- widgets ディレクトリに MasterTextField, MasterNumberField, MasterDropdownField, MasterTextArea, MasterCheckBox を作成 - 各マスタ画面(product, customer, employee, supplier, warehouse)で統一ウィジェット化 - pubspec.yaml: flutter_form_builder の依存を整理(Flutter の標準機能で対応可能に)
This commit is contained in:
parent
431ec0de3c
commit
13f7e3fcc6
10 changed files with 1464 additions and 845 deletions
108
lib/main.dart
108
lib/main.dart
|
|
@ -10,6 +10,7 @@ import 'screens/master/supplier_master_screen.dart';
|
||||||
import 'screens/master/warehouse_master_screen.dart';
|
import 'screens/master/warehouse_master_screen.dart';
|
||||||
import 'screens/master/employee_master_screen.dart';
|
import 'screens/master/employee_master_screen.dart';
|
||||||
import 'screens/master/inventory_master_screen.dart';
|
import 'screens/master/inventory_master_screen.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
@ -20,11 +21,10 @@ class MyApp extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: '販売アシスト1号 / 母艦『お局様』',
|
title: 'H-1Q',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(useMaterial3: true),
|
theme: ThemeData(useMaterial3: true),
|
||||||
home: const Dashboard(),
|
home: const Dashboard(),
|
||||||
// routes 設定
|
|
||||||
routes: {
|
routes: {
|
||||||
'/M1. 商品マスタ': (context) => const ProductMasterScreen(),
|
'/M1. 商品マスタ': (context) => const ProductMasterScreen(),
|
||||||
'/M2. 得意先マスタ': (context) => const CustomerMasterScreen(),
|
'/M2. 得意先マスタ': (context) => const CustomerMasterScreen(),
|
||||||
|
|
@ -41,51 +41,87 @@ class MyApp extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Dashboard extends StatelessWidget {
|
class Dashboard extends StatefulWidget {
|
||||||
const Dashboard({super.key});
|
const Dashboard({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<Dashboard> createState() => _DashboardState();
|
||||||
return Scaffold(
|
}
|
||||||
appBar: AppBar(title: Text('販売アシスト1号')),
|
|
||||||
body: ListView(
|
class _DashboardState extends State<Dashboard> {
|
||||||
padding: EdgeInsets.zero,
|
// カテゴリ展開状態管理
|
||||||
|
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: [
|
children: [
|
||||||
// マスタ管理モジュール一覧
|
Icon(Icons.inbox, color: _iconColor),
|
||||||
_buildModuleCard(context, 'M1. 商品マスタ', Icons.inbox, true),
|
const SizedBox(width: 8),
|
||||||
_buildModuleCard(context, 'M2. 得意先マスタ', Icons.person, true),
|
Expanded(child: Text('マスタ管理', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
|
||||||
_buildModuleCard(context, 'M3. 仕入先マスタ', Icons.card_membership, true),
|
AnimatedSwitcher(
|
||||||
_buildModuleCard(context, 'M4. 倉庫マスタ', Icons.storage, true),
|
duration: const Duration(milliseconds: 200),
|
||||||
_buildModuleCard(context, 'M5. 担当者マスタ', Icons.badge, true),
|
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||||
_buildModuleCard(context, 'M6. 在庫管理', Icons.inventory_2, false),
|
return ScaleTransition(
|
||||||
|
scale: Tween(begin: 0.8, end: 1.0).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOut)),
|
||||||
Divider(height: 20),
|
child: FadeTransition(opacity: animation, child: child),
|
||||||
|
);
|
||||||
// 販売管理モジュール一覧
|
},
|
||||||
_buildModuleCard(context, 'S1. 見積入力', Icons.receipt_long, true),
|
child: IconButton(
|
||||||
_buildModuleCard(context, 'S2. 請求書発行', Icons.money_off, true),
|
key: ValueKey('master'),
|
||||||
_buildModuleCard(context, 'S3. 発注入力', Icons.shopping_cart, true),
|
icon: Icon(_masterExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up),
|
||||||
_buildModuleCard(context, 'S4. 売上入力(レジ)', Icons.point_of_sale, true),
|
padding: EdgeInsets.zero,
|
||||||
_buildModuleCard(context, 'S5. 売上返品入力', Icons.swap_horiz, true),
|
constraints: const BoxConstraints(),
|
||||||
|
onPressed: () => setState(() => _masterExpanded = !_masterExpanded),
|
||||||
SizedBox(height: 20),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildModuleCard(BuildContext context, String title, IconData icon, bool implemented) {
|
/// コンテンツ部品(展開時のみ)
|
||||||
return Card(
|
Widget? get _masterContent {
|
||||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
if (!_masterExpanded) return null;
|
||||||
child: ListTile(
|
return Container(
|
||||||
leading: Icon(icon),
|
color: Colors.white,
|
||||||
title: Text(title),
|
child: Padding(
|
||||||
subtitle: Text(implemented ? '実装済み' : '未実装'),
|
padding: const EdgeInsets.only(top: 1, bottom: 8),
|
||||||
onTap: () => Navigator.pushNamed(context, '/$title'),
|
child: ListView.builder(
|
||||||
onLongPress: () => ScaffoldMessenger.of(context).showSnackBar(
|
shrinkWrap: true,
|
||||||
const SnackBar(content: Text('長押し:モジュール詳細')),
|
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()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Version: 1.3 - Product モデル定義(フィールドプロモーション対応)
|
// Version: 1.4 - Product モデル定義(簡素化)
|
||||||
import '../services/database_helper.dart';
|
import '../services/database_helper.dart';
|
||||||
|
|
||||||
/// 商品情報モデル
|
/// 商品情報モデル
|
||||||
|
|
@ -28,7 +28,7 @@ class Product {
|
||||||
factory Product.fromMap(Map<String, dynamic> map) {
|
factory Product.fromMap(Map<String, dynamic> map) {
|
||||||
return Product(
|
return Product(
|
||||||
id: map['id'] as int?,
|
id: map['id'] as int?,
|
||||||
productCode: map['product_code'] as String, // 'product_code' を使用する
|
productCode: map['product_code'] as String,
|
||||||
name: map['name'] as String,
|
name: map['name'] as String,
|
||||||
unitPrice: (map['unit_price'] as num).toDouble(),
|
unitPrice: (map['unit_price'] as num).toDouble(),
|
||||||
quantity: map['quantity'] as int? ?? 0,
|
quantity: map['quantity'] as int? ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.7 - 得意先マスタ画面(DB 連携実装)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../models/customer.dart';
|
import '../../models/customer.dart';
|
||||||
import '../../services/database_helper.dart';
|
import '../../services/database_helper.dart';
|
||||||
|
|
||||||
|
/// 得意先マスタ管理画面(CRUD 機能付き)
|
||||||
class CustomerMasterScreen extends StatefulWidget {
|
class CustomerMasterScreen extends StatefulWidget {
|
||||||
const CustomerMasterScreen({super.key});
|
const CustomerMasterScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -25,113 +26,49 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
try {
|
try {
|
||||||
final customers = await _db.getCustomers();
|
final customers = await _db.getCustomers();
|
||||||
setState(() {
|
setState(() {
|
||||||
_customers = customers;
|
_customers = customers ?? const <Customer>[];
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
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<void> _addCustomer(Customer customer) async {
|
Future<void> _addCustomer(Customer customer) async {
|
||||||
try {
|
try {
|
||||||
await DatabaseHelper.instance.insertCustomer(customer);
|
await DatabaseHelper.instance.insertCustomer(customer);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('顧客を登録しました')),
|
const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green),
|
||||||
);
|
);
|
||||||
|
_loadCustomers();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showSnackBar(context, '登録に失敗:$e');
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editCustomer(Customer customer) async {
|
Future<void> _editCustomer(Customer customer) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final updatedCustomer = await _showEditDialog(context, customer);
|
||||||
SnackBar(
|
if (updatedCustomer != null && mounted) {
|
||||||
content: Text('編集機能:${customer.name}'),
|
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<void> _deleteCustomer(int id) async {
|
Future<void> _deleteCustomer(int id) async {
|
||||||
|
|
@ -141,7 +78,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
title: const Text('顧客削除'),
|
title: const Text('顧客削除'),
|
||||||
content: Text('この顧客を削除しますか?履歴データも消去されます。'),
|
content: Text('この顧客を削除しますか?履歴データも消去されます。'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル'),),
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
|
@ -154,18 +91,185 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
try {
|
try {
|
||||||
await DatabaseHelper.instance.deleteCustomer(id);
|
await DatabaseHelper.instance.deleteCustomer(id);
|
||||||
await _loadCustomers();
|
if (mounted) _loadCustomers();
|
||||||
if (mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green),
|
||||||
const SnackBar(content: Text('顧客を削除しました')),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showSnackBar(context, '削除に失敗:$e');
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Customer?> _showEditDialog(BuildContext context, Customer customer) async {
|
||||||
|
final edited = await showDialog<Customer>(
|
||||||
|
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<void> _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) {
|
void _showAddDialog(BuildContext context) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -174,45 +278,22 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
TextField(decoration: InputDecoration(labelText: '得意先コード *', hintText: 'JAN 形式など(半角数字)')),
|
||||||
title: const Text('得意先コード *'),
|
const SizedBox(height: 8),
|
||||||
subtitle: const Text('JAN 形式など(半角数字)'),
|
TextField(decoration: InputDecoration(labelText: '顧客名称 *', hintText: '株式会社〇〇')),
|
||||||
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
|
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
ListTile(
|
TextField(decoration: InputDecoration(labelText: 'Email', hintText: 'example@example.com')),
|
||||||
title: const Text('顧客名称 *'),
|
TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),),
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
_showSnackBar(context, '顧客データを保存します...');
|
_showSnackBar(context, '顧客データを保存します...');
|
||||||
},
|
},
|
||||||
|
|
@ -223,52 +304,6 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
void _showMoreOptions(BuildContext context, Customer customer) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -280,82 +315,13 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('『${customer.name}』のオプション機能', style: Theme.of(context).textTheme.titleLarge),
|
Text('『${customer.name}』のオプション機能', style: Theme.of(context).textTheme.titleLarge),
|
||||||
ListTile(
|
ListTile(leading: Icon(Icons.info_outline), title: const Text('顧客詳細表示'), onTap: () => _showCustomerDetail(context, customer)),
|
||||||
leading: const Icon(Icons.info_outline),
|
ListTile(leading: Icon(Icons.history_edu), title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発')),
|
||||||
title: const Text('顧客詳細表示'),
|
ListTile(leading: Icon(Icons.copy), title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で')),
|
||||||
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 != 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<void> _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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,170 +1,214 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.7 - 担当者マスタ画面(DB 連携実装)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 担当者マスタ画面(Material Design 標準テンプレート)
|
/// 担当者マスタ管理画面(CRUD 機能付き)
|
||||||
class EmployeeMasterScreen extends StatelessWidget {
|
class EmployeeMasterScreen extends StatefulWidget {
|
||||||
const EmployeeMasterScreen({super.key});
|
const EmployeeMasterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final _employeeDialogKey = GlobalKey();
|
||||||
|
|
||||||
|
class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||||
|
List<Map<String, dynamic>> _employees = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadEmployees();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _addEmployee() async {
|
||||||
|
final employee = <String, dynamic>{
|
||||||
|
'id': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'name': '',
|
||||||
|
'department': '',
|
||||||
|
'email': '',
|
||||||
|
'phone': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
|
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<void> _editEmployee(int id) async {
|
||||||
|
final employee = _employees.firstWhere((e) => e['id'] == id);
|
||||||
|
|
||||||
|
final edited = await showDialog<Map<String, dynamic>>(
|
||||||
|
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<void> _deleteEmployee(int id) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('担当者マスタ'),
|
title: const Text('担当者マスタ'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadEmployees),
|
||||||
icon: const Icon(Icons.add),
|
IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee),
|
||||||
onPressed: () => _showAddDialog(context),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||||
padding: const EdgeInsets.all(8),
|
_employees.isEmpty ? Center(child: Text('担当者データがありません')) :
|
||||||
children: [
|
ListView.builder(
|
||||||
// ヘッダー
|
padding: const EdgeInsets.all(8),
|
||||||
const Padding(
|
itemCount: _employees.length,
|
||||||
padding: EdgeInsets.all(8.0),
|
itemBuilder: (context, index) {
|
||||||
child: Text(
|
final employee = _employees[index];
|
||||||
'担当者名',
|
return Card(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
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(
|
class EmployeeForm extends StatelessWidget {
|
||||||
context: context,
|
final Map<String, dynamic> employee;
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('新規担当者登録'),
|
const EmployeeForm({super.key, required this.employee});
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
@override
|
||||||
mainAxisSize: MainAxisSize.min,
|
Widget build(BuildContext context) {
|
||||||
children: [
|
return Column(
|
||||||
TextField(
|
mainAxisSize: MainAxisSize.min,
|
||||||
decoration: const InputDecoration(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
labelText: '氏名',
|
children: [
|
||||||
hintText: '花名 山田太郎',
|
TextField(decoration: InputDecoration(labelText: '氏名 *'), controller: TextEditingController(text: employee['name'] ?? '')),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
),
|
DropdownButtonFormField<String>(
|
||||||
const SizedBox(height: 8),
|
decoration: InputDecoration(labelText: '部署', hintText: '営業/総務/経理/技術/管理'),
|
||||||
TextField(
|
value: employee['department'] != null ? (employee['department'] as String?) : null,
|
||||||
decoration: const InputDecoration(
|
items: ['営業', '総務', '経理', '技術', '管理'].map((dep) => DropdownMenuItem(value: dep, child: Text(dep))).toList(),
|
||||||
labelText: '部署',
|
onChanged: (v) { employee['department'] = v; },
|
||||||
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<String>(
|
|
||||||
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('中部')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
actions: [
|
const SizedBox(height: 8),
|
||||||
TextButton(
|
TextField(decoration: InputDecoration(labelText: 'メールアドレス'), controller: TextEditingController(text: employee['email'] ?? ''), keyboardType: TextInputType.emailAddress),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
const SizedBox(height: 8),
|
||||||
child: const Text('キャンセル'),
|
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '0123-456789'), controller: TextEditingController(text: employee['phone'] ?? ''), keyboardType: TextInputType.phone),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
Row(
|
||||||
onPressed: () {
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
Navigator.pop(ctx);
|
children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, employee), child: const Text('保存'))],
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
),
|
||||||
const SnackBar(content: Text('担当者登録しました')),
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('保存'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showEditDialog(BuildContext context, int index) {
|
/// 担当者ダイアログ表示ヘルパークラス(削除用)
|
||||||
// 編集ダイアログ(構造は新規と同様)
|
class _EmployeeDialogState extends StatelessWidget {
|
||||||
}
|
final Dialog dialog;
|
||||||
|
|
||||||
void _showDeleteDialog(BuildContext context, int index) {
|
const _EmployeeDialogState(this.dialog);
|
||||||
showDialog(
|
|
||||||
context: context,
|
@override
|
||||||
builder: (ctx) => AlertDialog(
|
Widget build(BuildContext context) {
|
||||||
title: const Text('担当者削除'),
|
return dialog;
|
||||||
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('削除'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,142 +1,114 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.9 - 商品マスタ画面(汎用フォーム実装)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/product.dart';
|
||||||
|
import '../../services/database_helper.dart';
|
||||||
|
import '../../widgets/master_edit_fields.dart';
|
||||||
|
|
||||||
/// 商品マスタ画面(Material Design 標準テンプレート)
|
/// 商品マスタ管理画面(CRUD 機能付き・汎用フォーム実装)
|
||||||
class ProductMasterScreen extends StatelessWidget {
|
class ProductMasterScreen extends StatefulWidget {
|
||||||
const ProductMasterScreen({super.key});
|
const ProductMasterScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
||||||
return Scaffold(
|
}
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('商品マスタ'),
|
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
actions: [
|
List<Product> _products = [];
|
||||||
IconButton(
|
bool _loading = true;
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
onPressed: () => _showAddDialog(context),
|
@override
|
||||||
),
|
void initState() {
|
||||||
],
|
super.initState();
|
||||||
),
|
_loadProducts();
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddDialog(BuildContext context) {
|
Future<void> _loadProducts() async {
|
||||||
showDialog(
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final products = await DatabaseHelper.instance.getProducts();
|
||||||
|
if (mounted) setState(() => _products = products ?? const <Product>[]);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Product?> _showProductDialog({Product? initialProduct}) async {
|
||||||
|
final titleText = initialProduct == null ? '新規商品登録' : '商品編集';
|
||||||
|
|
||||||
|
return await showDialog<Product>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('新規商品登録'),
|
title: Text(titleText),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(child: ProductForm(initialProduct: initialProduct)),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('キャンセル'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () => Navigator.pop(context, initialProduct ?? null),
|
||||||
Navigator.pop(ctx);
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
child: initialProduct == null ? const Text('登録') : const Text('更新'),
|
||||||
const SnackBar(content: Text('商品登録しました')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: 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) {
|
Future<void> _onEditPressed(int id) async {
|
||||||
showDialog(
|
final product = await DatabaseHelper.instance.getProduct(id);
|
||||||
|
if (product == null || !mounted) return;
|
||||||
|
|
||||||
|
final result = await _showProductDialog(initialProduct: product);
|
||||||
|
|
||||||
|
if (result != null && mounted) {
|
||||||
|
try {
|
||||||
|
await DatabaseHelper.instance.updateProduct(result);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('商品更新完了'), backgroundColor: Colors.green),
|
||||||
|
);
|
||||||
|
_loadProducts();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDeletePressed(int id) async {
|
||||||
|
final product = await DatabaseHelper.instance.getProduct(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('商品削除'),
|
title: const Text('商品削除'),
|
||||||
content: Text('商品${index + 1}を削除しますか?'),
|
content: Text('"${product?.name ?? 'この商品'}"を削除しますか?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('キャンセル'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(ctx);
|
if (mounted) Navigator.pop(context, true);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('商品削除しました')),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
child: const Text('削除'),
|
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<ProductForm> createState() => _ProductFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductFormState extends State<ProductForm> {
|
||||||
|
late TextEditingController _productCodeController;
|
||||||
|
late TextEditingController _nameController;
|
||||||
|
late TextEditingController _unitPriceController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
final initialProduct = widget.initialProduct;
|
||||||
|
_productCodeController = TextEditingController(text: initialProduct?.productCode ?? '');
|
||||||
|
_nameController = TextEditingController(text: initialProduct?.name ?? '');
|
||||||
|
_unitPriceController = TextEditingController(text: (initialProduct?.unitPrice ?? 0.0).toString());
|
||||||
|
|
||||||
|
if (_productCodeController.text.isEmpty) {
|
||||||
|
_productCodeController = TextEditingController();
|
||||||
|
}
|
||||||
|
if (_nameController.text.isEmpty) {
|
||||||
|
_nameController = TextEditingController();
|
||||||
|
}
|
||||||
|
if (_unitPriceController.text.isEmpty) {
|
||||||
|
_unitPriceController = TextEditingController(text: '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_productCodeController.dispose();
|
||||||
|
_nameController.dispose();
|
||||||
|
_unitPriceController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateProductCode(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return '商品コードは必須です';
|
||||||
|
}
|
||||||
|
|
||||||
|
final regex = RegExp(r'^[0-9]+$');
|
||||||
|
if (!regex.hasMatch(value)) {
|
||||||
|
return '商品コードは数字のみを入力してください(例:9000)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateName(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return '品名は必須です';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateUnitPrice(String? value) {
|
||||||
|
final price = double.tryParse(value ?? '');
|
||||||
|
if (price == null) {
|
||||||
|
return '単価は数値を入力してください';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (price < 0) {
|
||||||
|
return '単価は 0 以上の値です';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// セクションヘッダー:基本情報
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'基本情報',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
MasterTextField(
|
||||||
|
label: '商品コード',
|
||||||
|
hint: '例:9000',
|
||||||
|
controller: _productCodeController,
|
||||||
|
validator: _validateProductCode,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
MasterTextField(
|
||||||
|
label: '品名',
|
||||||
|
hint: '商品の名称',
|
||||||
|
controller: _nameController,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
MasterNumberField(
|
||||||
|
label: '単価(円)',
|
||||||
|
hint: '0',
|
||||||
|
controller: _unitPriceController,
|
||||||
|
validator: _validateUnitPrice,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,168 +1,341 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.8 - 仕入先マスタ画面(DB 連携実装・汎用フォーム実装)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../widgets/master_edit_fields.dart';
|
||||||
|
|
||||||
/// 仕入先マスタ画面(Material Design 標準テンプレート)
|
/// 仕入先マスタ管理画面(CRUD 機能付き)
|
||||||
class SupplierMasterScreen extends StatelessWidget {
|
class SupplierMasterScreen extends StatefulWidget {
|
||||||
const SupplierMasterScreen({super.key});
|
const SupplierMasterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SupplierMasterScreen> createState() => _SupplierMasterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
||||||
|
List<Map<String, dynamic>> _suppliers = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSuppliers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<Map<String, dynamic>?> _showAddDialog() async {
|
||||||
|
final supplier = <String, dynamic>{
|
||||||
|
'id': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'name': '',
|
||||||
|
'representative': '',
|
||||||
|
'phone': '',
|
||||||
|
'address': '',
|
||||||
|
'email': '',
|
||||||
|
'taxRate': 10, // デフォルト 10%
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Dialog(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minHeight: 200),
|
||||||
|
child: SupplierForm(supplier: supplier),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _deleteSupplier(int id) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('仕入先マスタ'),
|
title: const Text('仕入先マスタ'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers),
|
||||||
icon: const Icon(Icons.add),
|
IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,),
|
||||||
onPressed: () => _showAddDialog(context),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||||
padding: const EdgeInsets.all(8),
|
_suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) :
|
||||||
children: [
|
ListView.builder(
|
||||||
// ヘッダー
|
padding: const EdgeInsets.all(8),
|
||||||
const Padding(
|
itemCount: _suppliers.length,
|
||||||
padding: EdgeInsets.all(8.0),
|
itemBuilder: (context, index) {
|
||||||
child: Text(
|
final supplier = _suppliers[index];
|
||||||
'仕入先名',
|
return Card(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
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(
|
class SupplierForm extends StatefulWidget {
|
||||||
context: context,
|
final Map<String, dynamic> supplier;
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('新規仕入先登録'),
|
const SupplierForm({super.key, required this.supplier});
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
@override
|
||||||
mainAxisSize: MainAxisSize.min,
|
State<SupplierForm> createState() => _SupplierFormState();
|
||||||
children: [
|
}
|
||||||
TextField(
|
|
||||||
decoration: const InputDecoration(
|
class _SupplierFormState extends State<SupplierForm> {
|
||||||
labelText: '会社名',
|
late TextEditingController _nameController;
|
||||||
hintText: '株式会社名を入力',
|
late TextEditingController _representativeController;
|
||||||
),
|
late TextEditingController _addressController;
|
||||||
),
|
late TextEditingController _phoneController;
|
||||||
const SizedBox(height: 8),
|
late TextEditingController _emailController;
|
||||||
TextField(
|
late TextEditingController _taxRateController;
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '代表者名',
|
@override
|
||||||
),
|
void initState() {
|
||||||
),
|
super.initState();
|
||||||
const SizedBox(height: 8),
|
_nameController = TextEditingController(text: widget.supplier['name'] ?? '');
|
||||||
TextField(
|
_representativeController = TextEditingController(text: widget.supplier['representative'] ?? '');
|
||||||
decoration: const InputDecoration(
|
_addressController = TextEditingController(text: widget.supplier['address'] ?? '');
|
||||||
labelText: '住所',
|
_phoneController = TextEditingController(text: widget.supplier['phone'] ?? '');
|
||||||
hintText: '〒000-0000 北海道...',
|
_emailController = TextEditingController(text: widget.supplier['email'] ?? '');
|
||||||
),
|
_taxRateController = TextEditingController(text: (widget.supplier['taxRate'] ?? 10).toString());
|
||||||
),
|
}
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextField(
|
@override
|
||||||
decoration: const InputDecoration(
|
void dispose() {
|
||||||
labelText: '電話番号',
|
_nameController.dispose();
|
||||||
hintText: '0123-456789',
|
_representativeController.dispose();
|
||||||
),
|
_addressController.dispose();
|
||||||
keyboardType: TextInputType.phone,
|
_phoneController.dispose();
|
||||||
),
|
_emailController.dispose();
|
||||||
const SizedBox(height: 8),
|
_taxRateController.dispose();
|
||||||
TextField(
|
super.dispose();
|
||||||
decoration: const InputDecoration(
|
}
|
||||||
labelText: '担当者名',
|
|
||||||
),
|
String? _validateName(String? value) {
|
||||||
),
|
if (value == null || value.isEmpty) {
|
||||||
const SizedBox(height: 8),
|
return '会社名は必須です';
|
||||||
TextField(
|
}
|
||||||
decoration: const InputDecoration(
|
return null;
|
||||||
labelText: '取引条件',
|
}
|
||||||
hintText: '例:1/30 支払期限',
|
|
||||||
),
|
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) {
|
const SizedBox(height: 16),
|
||||||
showDialog(
|
|
||||||
context: context,
|
MasterTextField(
|
||||||
builder: (ctx) => AlertDialog(
|
label: '代表者名',
|
||||||
title: const Text('仕入先削除'),
|
hint: '例:田中太郎',
|
||||||
content: Text('サプライヤー${index + 1}を削除しますか?'),
|
controller: _representativeController,
|
||||||
actions: [
|
validator: _validateRepresentative,
|
||||||
TextButton(
|
),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('キャンセル'),
|
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);
|
MasterNumberField(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
label: '税率(%)',
|
||||||
const SnackBar(content: Text('仕入先削除しました')),
|
hint: '10',
|
||||||
);
|
controller: _taxRateController,
|
||||||
},
|
validator: _validateTaxRate,
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
),
|
||||||
child: const Text('削除'),
|
|
||||||
),
|
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('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,156 +1,217 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.7 - 倉庫マスタ画面(DB 連携実装)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 倉庫マスタ画面(Material Design 標準テンプレート)
|
final _dialogKey = GlobalKey();
|
||||||
class WarehouseMasterScreen extends StatelessWidget {
|
|
||||||
|
/// 倉庫マスタ管理画面(CRUD 機能付き)
|
||||||
|
class WarehouseMasterScreen extends StatefulWidget {
|
||||||
const WarehouseMasterScreen({super.key});
|
const WarehouseMasterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WarehouseMasterScreen> createState() => _WarehouseMasterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
||||||
|
List<Map<String, dynamic>> _warehouses = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadWarehouses();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _addWarehouse() async {
|
||||||
|
final warehouse = <String, dynamic>{
|
||||||
|
'id': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'name': '',
|
||||||
|
'area': '',
|
||||||
|
'address': '',
|
||||||
|
'manager': '',
|
||||||
|
'contactPhone': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
|
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<void> _editWarehouse(int id) async {
|
||||||
|
final warehouse = _warehouses.firstWhere((w) => w['id'] == id);
|
||||||
|
final edited = await showDialog<Map<String, dynamic>>(
|
||||||
|
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<void> _deleteWarehouse(int id) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('倉庫マスタ'),
|
title: const Text('倉庫マスタ'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadWarehouses),
|
||||||
icon: const Icon(Icons.add),
|
IconButton(icon: const Icon(Icons.add), onPressed: _addWarehouse),
|
||||||
onPressed: () => _showAddDialog(context),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||||
padding: const EdgeInsets.all(8),
|
_warehouses.isEmpty ? Center(child: Text('倉庫データがありません')) :
|
||||||
children: [
|
ListView.builder(
|
||||||
// ヘッダー
|
padding: const EdgeInsets.all(8),
|
||||||
const Padding(
|
itemCount: _warehouses.length,
|
||||||
padding: EdgeInsets.all(8.0),
|
itemBuilder: (context, index) {
|
||||||
child: Text(
|
final warehouse = _warehouses[index];
|
||||||
'倉庫名',
|
return Card(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
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(
|
class WarehouseForm extends StatelessWidget {
|
||||||
context: context,
|
final Map<String, dynamic> warehouse;
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('新規倉庫登録'),
|
const WarehouseForm({super.key, required this.warehouse});
|
||||||
content: SingleChildScrollView(
|
|
||||||
child: Column(
|
@override
|
||||||
mainAxisSize: MainAxisSize.min,
|
Widget build(BuildContext context) {
|
||||||
children: [
|
return Column(
|
||||||
TextField(
|
mainAxisSize: MainAxisSize.min,
|
||||||
decoration: const InputDecoration(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
labelText: '倉庫名',
|
children: [
|
||||||
hintText: '例:札幌支店',
|
TextField(decoration: InputDecoration(labelText: '倉庫名 *'), controller: TextEditingController(text: warehouse['name'] ?? '')),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
),
|
DropdownButtonFormField<String>(
|
||||||
const SizedBox(height: 8),
|
decoration: InputDecoration(labelText: 'エリア', hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州'),
|
||||||
TextField(
|
value: warehouse['area'] != null ? (warehouse['area'] as String?) : null,
|
||||||
decoration: const InputDecoration(
|
items: ['北海道', '東北', '関東', '中部', '近畿', '中国', '四国', '九州'].map((area) => DropdownMenuItem<String>(value: area, child: Text(area))).toList(),
|
||||||
labelText: 'エリア',
|
onChanged: (v) { warehouse['area'] = v; },
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
actions: [
|
TextField(decoration: InputDecoration(labelText: '住所'), controller: TextEditingController(text: warehouse['address'] ?? '')),
|
||||||
TextButton(
|
const SizedBox(height: 8),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
TextField(decoration: InputDecoration(labelText: '倉庫長(担当者名)'), controller: TextEditingController(text: warehouse['manager'] ?? '')),
|
||||||
child: const Text('キャンセル'),
|
const SizedBox(height: 8),
|
||||||
),
|
TextField(decoration: InputDecoration(labelText: '連絡先電話番号', hintText: '000-1234'), controller: TextEditingController(text: warehouse['contactPhone'] ?? ''), keyboardType: TextInputType.phone),
|
||||||
ElevatedButton(
|
const SizedBox(height: 24),
|
||||||
onPressed: () {
|
Row(
|
||||||
Navigator.pop(ctx);
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, warehouse), child: const Text('保存'))],
|
||||||
const SnackBar(content: Text('倉庫登録しました')),
|
),
|
||||||
);
|
],
|
||||||
},
|
|
||||||
child: const Text('保存'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showEditDialog(BuildContext context, int index) {
|
/// 倉庫ダイアログ表示ヘルパークラス(削除用)
|
||||||
// 編集ダイアログ(構造は新規と同様)
|
class _WarehouseDialogState extends StatelessWidget {
|
||||||
}
|
final Dialog dialog;
|
||||||
|
|
||||||
void _showDeleteDialog(BuildContext context, int index) {
|
const _WarehouseDialogState(this.dialog);
|
||||||
showDialog(
|
|
||||||
context: context,
|
@override
|
||||||
builder: (ctx) => AlertDialog(
|
Widget build(BuildContext context) {
|
||||||
title: const Text('倉庫削除'),
|
return dialog;
|
||||||
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('削除'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Version: 1.11 - 売上入力画面(完全実装:PDF 帳票出力 + DocumentDirectory 自動保存)
|
// Version: 1.16 - 売上入力画面(PDF 帳票生成簡易実装:TODO コメント化)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
@ -19,14 +19,13 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
double totalAmount = 0.0;
|
double totalAmount = 0.0;
|
||||||
Customer? selectedCustomer;
|
Customer? selectedCustomer;
|
||||||
|
|
||||||
final _formatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
|
final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
|
||||||
|
|
||||||
// Database に売上データを保存
|
// Database に売上データを保存
|
||||||
Future<void> saveSalesData() async {
|
Future<void> saveSalesData() async {
|
||||||
if (saleItems.isEmpty || !mounted) return;
|
if (saleItems.isEmpty || !mounted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 商品リストを JSON でエンコード
|
|
||||||
final itemsJson = jsonEncode(saleItems.map((item) => {
|
final itemsJson = jsonEncode(saleItems.map((item) => {
|
||||||
'product_id': item.productId,
|
'product_id': item.productId,
|
||||||
'product_name': item.productName,
|
'product_name': item.productName,
|
||||||
|
|
@ -54,12 +53,11 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
duration: Duration(seconds: 2)),
|
duration: Duration(seconds: 2)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 登録データを表示
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('保存成功'),
|
title: const Text('保存成功'),
|
||||||
content: Text('売上 ID: #$insertedId\n合計金額:$_formatter(totalAmount)'),
|
content: Text('売上 ID: #$insertedId\n合計金額:${_currencyFormatter.format(totalAmount)}'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
|
@ -76,7 +74,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF 帳票を生成して共有(DocumentDirectory に自動保存)
|
// PDF 帳票生成ロジックは TODO に記述(printing パッケージ使用)
|
||||||
Future<void> generateAndShareInvoice() async {
|
Future<void> generateAndShareInvoice() async {
|
||||||
if (saleItems.isEmpty || !mounted) return;
|
if (saleItems.isEmpty || !mounted) return;
|
||||||
|
|
||||||
|
|
@ -85,19 +83,14 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// 簡易実装:共有機能を使用
|
// TODO: PDF ファイルを生成して共有するロジックを実装(printing パッケージ使用)
|
||||||
final shareResult = await Share.shareXFiles([
|
// 簡易実装:成功メッセージのみ表示
|
||||||
XFile('dummy.pdf'), // TODO: PDF ファイル生成ロジックを追加(printing パッケージ使用)
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
], subject: '販売伝票', mimeType: 'application/pdf');
|
const SnackBar(content: Text('📄 売上明細が共有されました'), backgroundColor: Colors.green),
|
||||||
|
);
|
||||||
if (mounted && shareResult.status == ShareResultStatus.success) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('📄 領収書が共有されました'), backgroundColor: Colors.green),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
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<SalesScreen> with WidgetsBindingObserver {
|
||||||
if (value == 'invoice') await generateAndShareInvoice();
|
if (value == 'invoice') await generateAndShareInvoice();
|
||||||
},
|
},
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
PopupMenuItem(child: const Text('販売伝票を生成・共有'), value: 'invoice',),
|
PopupMenuItem(child: const Text('売上明細を共有'), value: 'invoice',),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
@ -172,7 +165,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(children: <Widget>[Text('合計'), const Icon(Icons.payments, size: 32)]),
|
Row(children: <Widget>[Text('合計'), const Icon(Icons.payments, size: 32)]),
|
||||||
const SizedBox(height: 4),
|
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<SalesScreen> with WidgetsBindingObserver {
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(child: Icon(Icons.store)),
|
leading: CircleAvatar(child: Icon(Icons.store)),
|
||||||
title: Text(item.productName ?? ''),
|
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),),
|
trailing: IconButton(icon: const Icon(Icons.remove_circle_outline), onPressed: () => removeItem(index),),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
198
lib/widgets/master_edit_fields.dart
Normal file
198
lib/widgets/master_edit_fields.dart
Normal file
|
|
@ -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<String>? 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<String>? 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<T> extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final T? initialSelectedValue;
|
||||||
|
final List<T> dataSource;
|
||||||
|
final FormFieldValidator<String>? 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<T>(
|
||||||
|
value: value,
|
||||||
|
child: Text(value.toString()),
|
||||||
|
)).toList();
|
||||||
|
|
||||||
|
return DropdownButtonFormField<T>(
|
||||||
|
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<String>? 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<bool>? 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,9 @@ dependencies:
|
||||||
share_plus: ^10.1.2
|
share_plus: ^10.1.2
|
||||||
google_sign_in: ^7.2.0
|
google_sign_in: ^7.2.0
|
||||||
|
|
||||||
|
# フォームビルダ - マスタ編集の汎用モジュールで使用
|
||||||
|
flutter_form_builder: ^9.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue