- widgets ディレクトリに MasterTextField, MasterNumberField, MasterDropdownField, MasterTextArea, MasterCheckBox を作成 - 各マスタ画面(product, customer, employee, supplier, warehouse)で統一ウィジェット化 - pubspec.yaml: flutter_form_builder の依存を整理(Flutter の標準機能で対応可能に)
341 lines
No EOL
11 KiB
Dart
341 lines
No EOL
11 KiB
Dart
// Version: 1.8 - 仕入先マスタ画面(DB 連携実装・汎用フォーム実装)
|
||
import 'package:flutter/material.dart';
|
||
import '../../widgets/master_edit_fields.dart';
|
||
|
||
/// 仕入先マスタ管理画面(CRUD 機能付き)
|
||
class SupplierMasterScreen extends StatefulWidget {
|
||
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
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('仕入先マスタ'),
|
||
actions: [
|
||
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers),
|
||
IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,),
|
||
],
|
||
),
|
||
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||
_suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) :
|
||
ListView.builder(
|
||
padding: const EdgeInsets.all(8),
|
||
itemCount: _suppliers.length,
|
||
itemBuilder: (context, index) {
|
||
final supplier = _suppliers[index];
|
||
return Card(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
child: ListTile(
|
||
leading: CircleAvatar(backgroundColor: Colors.brown.shade50, child: Icon(Icons.shopping_bag)),
|
||
title: Text(supplier['name'] ?? '未入力'),
|
||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
if (supplier['representative'] != null) Text('担当:${supplier['representative']}'),
|
||
if (supplier['phone'] != null) Text('電話:${supplier['phone']}'),
|
||
if (supplier['address'] != null) Text('住所:${supplier['address']}'),
|
||
]),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editSupplier(supplier['id'] as int)),
|
||
IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteSupplier(supplier['id'] as int)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 仕入先フォーム部品(汎用フィールド使用)
|
||
class SupplierForm extends StatefulWidget {
|
||
final Map<String, dynamic> supplier;
|
||
|
||
const SupplierForm({super.key, required this.supplier});
|
||
|
||
@override
|
||
State<SupplierForm> createState() => _SupplierFormState();
|
||
}
|
||
|
||
class _SupplierFormState extends State<SupplierForm> {
|
||
late TextEditingController _nameController;
|
||
late TextEditingController _representativeController;
|
||
late TextEditingController _addressController;
|
||
late TextEditingController _phoneController;
|
||
late TextEditingController _emailController;
|
||
late TextEditingController _taxRateController;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_nameController = TextEditingController(text: widget.supplier['name'] ?? '');
|
||
_representativeController = TextEditingController(text: widget.supplier['representative'] ?? '');
|
||
_addressController = TextEditingController(text: widget.supplier['address'] ?? '');
|
||
_phoneController = TextEditingController(text: widget.supplier['phone'] ?? '');
|
||
_emailController = TextEditingController(text: widget.supplier['email'] ?? '');
|
||
_taxRateController = TextEditingController(text: (widget.supplier['taxRate'] ?? 10).toString());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameController.dispose();
|
||
_representativeController.dispose();
|
||
_addressController.dispose();
|
||
_phoneController.dispose();
|
||
_emailController.dispose();
|
||
_taxRateController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
String? _validateName(String? value) {
|
||
if (value == null || value.isEmpty) {
|
||
return '会社名は必須です';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _validateRepresentative(String? value) {
|
||
// 任意フィールドなのでバリデーションなし
|
||
return null;
|
||
}
|
||
|
||
String? _validateAddress(String? value) {
|
||
// 任意フィールドなのでバリデーションなし
|
||
return null;
|
||
}
|
||
|
||
String? _validatePhone(String? value) {
|
||
if (value != null && value.isNotEmpty) {
|
||
// 電話番号形式の簡易チェック(例:03-1234-5678)
|
||
final regex = RegExp(r'^[0-9\- ]+$');
|
||
if (!regex.hasMatch(value)) {
|
||
return '電話番号は半角数字とハイフンのみを使用してください';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _validateEmail(String? value) {
|
||
if (value != null && value.isNotEmpty) {
|
||
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||
if (!emailRegex.hasMatch(value)) {
|
||
return 'メールアドレスの形式が正しくありません';
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _validateTaxRate(String? value) {
|
||
final taxRate = double.tryParse(value ?? '');
|
||
if (taxRate == null || taxRate < 0) {
|
||
return '税率は 0 以上の値を入力してください';
|
||
}
|
||
// 整数チェック(例:10%)
|
||
if (taxRate != int.parse(taxRate.toString())) {
|
||
return '税率は整数のみを入力してください';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
void _onSavePressed() {
|
||
Navigator.pop(context, widget.supplier);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// セクションヘッダー:基本情報
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'基本情報',
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
|
||
MasterTextField(
|
||
label: '会社名 *',
|
||
hint: '例:株式会社サンプル',
|
||
controller: _nameController,
|
||
validator: _validateName,
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
MasterTextField(
|
||
label: '代表者名',
|
||
hint: '例:田中太郎',
|
||
controller: _representativeController,
|
||
validator: _validateRepresentative,
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
MasterTextField(
|
||
label: '住所',
|
||
hint: '例:東京都〇〇区',
|
||
controller: _addressController,
|
||
validator: _validateAddress,
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
MasterTextField(
|
||
label: '電話番号',
|
||
hint: '例:03-1234-5678',
|
||
controller: _phoneController,
|
||
keyboardType: TextInputType.phone,
|
||
validator: _validatePhone,
|
||
),
|
||
|
||
const SizedBox(height: 16),
|
||
|
||
MasterTextField(
|
||
label: 'Email',
|
||
hint: '例:contact@example.com',
|
||
controller: _emailController,
|
||
keyboardType: TextInputType.emailAddress,
|
||
validator: _validateEmail,
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// セクションヘッダー:設定情報
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'設定情報',
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
|
||
MasterNumberField(
|
||
label: '税率(%)',
|
||
hint: '10',
|
||
controller: _taxRateController,
|
||
validator: _validateTaxRate,
|
||
),
|
||
|
||
const SizedBox(height: 32),
|
||
|
||
// ボタン行
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')),
|
||
ElevatedButton(
|
||
onPressed: _onSavePressed,
|
||
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
|
||
child: const Text('保存'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
} |