feat: 担当 マスタ機能を追加\n\n- lib/models/employee.dart: Employee モデル定義\n- lib/widgets/employee_edit_dialog.dart: 従業員編集ダイアログ\n- lib/screens/master/employee_master_screen.dart: 担当者マスタ画面
This commit is contained in:
parent
c33d117ef5
commit
8f1df14b7b
13 changed files with 1466 additions and 574 deletions
File diff suppressed because one or more lines are too long
198
@workspace/lib/screens/master/employee_master_screen.dart
Normal file
198
@workspace/lib/screens/master/employee_master_screen.dart
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// Version: 2.0 - 担当者マスタ画面(リッチ編集ダイアログ統合)
|
||||
// ※ EmployeeEditDialog を使用した簡易実装
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/employee.dart';
|
||||
import '../widgets/employee_edit_dialog.dart';
|
||||
|
||||
/// 担当者マスタ管理画面
|
||||
class EmployeeMasterScreen extends StatefulWidget {
|
||||
const EmployeeMasterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState();
|
||||
}
|
||||
|
||||
class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||
List<Employee> _employees = [];
|
||||
bool _loading = true;
|
||||
|
||||
// 検索キーワード
|
||||
String get _filteredEmployees => _searchKeyword.isEmpty ? _employees :
|
||||
_employees.where((e) => e.name.toLowerCase().contains(_searchKeyword.toLowerCase()) ||
|
||||
(e.department.isNotEmpty && e.department.toLowerCase().contains(_searchKeyword.toLowerCase()))).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadEmployees();
|
||||
}
|
||||
|
||||
/// 従業員データをロード(デモデータ)
|
||||
Future<void> _loadEmployees() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
// サンプルデータを初期化
|
||||
final demoData = [
|
||||
Employee(id: 1, name: '山田太郎', email: 'tanaka@company.com', tel: '03-1234-5678', department: '営業部', role: '営業担当'),
|
||||
Employee(id: 2, name: '田中花子', email: 'tanaka@company.com', tel: '03-2345-6789', department: '総務部', role: '総務担当'),
|
||||
Employee(id: 3, name: '鈴木一郎', email: 'suzuki@company.com', tel: '03-3456-7890', department: '経理部', role: '経理担当'),
|
||||
];
|
||||
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 edited = await showDialog<Employee>(
|
||||
context: context,
|
||||
builder: (ctx) => EmployeeEditDialog(
|
||||
title: '担当者登録',
|
||||
initialData: null,
|
||||
),
|
||||
);
|
||||
|
||||
if (edited != null && mounted) {
|
||||
setState(() => _employees.add(edited));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 従業員編集
|
||||
Future<void> _editEmployee(Employee employee) async {
|
||||
final edited = await showDialog<Employee>(
|
||||
context: context,
|
||||
builder: (ctx) => EmployeeEditDialog(
|
||||
title: '担当者編集',
|
||||
initialData: employee,
|
||||
),
|
||||
);
|
||||
|
||||
if (edited != null && mounted) {
|
||||
setState(() {
|
||||
_employees = _employees.map((e) => e.id == edited.id ? edited : e).toList();
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 従業員削除
|
||||
Future<void> _deleteEmployee(Employee employee) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('担当者削除'),
|
||||
content: Text('この担当者を実際に削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('削除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
setState(() {
|
||||
_employees.removeWhere((e) => e.id == employee.id);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('/M5. 担当者マスタ'),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadEmployees),
|
||||
IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 検索バー
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: '担当者名で検索...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => setState(() => _searchKeyword = value),
|
||||
),
|
||||
),
|
||||
// 一覧リスト
|
||||
Expanded(
|
||||
child: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||
_filteredEmployees.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.person_outline, size: 64, color: Colors.grey[300]),
|
||||
SizedBox(height: 16),
|
||||
Text('担当者データがありません', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _addEmployee,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) : ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _filteredEmployees.length,
|
||||
itemBuilder: (context, index) {
|
||||
final employee = _filteredEmployees[index];
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.purple.shade100,
|
||||
child: Text('${employee.department.substring(0, 1)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
title: Text(employee.name ?? '未入力', style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (employee.department.isNotEmpty) Text('部署:${employee.department}', style: const TextStyle(fontSize: 12)),
|
||||
if (employee.role.isNotEmpty) Text('役職:${employee.role}', style: const TextStyle(fontSize: 12)),
|
||||
if (employee.tel.isNotEmpty) Text('TEL: ${employee.tel}', style: const TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee)),
|
||||
IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => _deleteEmployee(employee)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
257
@workspace/lib/widgets/employee_edit_dialog.dart
Normal file
257
@workspace/lib/widgets/employee_edit_dialog.dart
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
// 従業員編集ダイアログ(リッチ版)
|
||||
// ※ Employee モデルに特化した編集用ダイアログ
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/employee.dart';
|
||||
|
||||
/// 従業員用のリッチな編集ダイアログ
|
||||
class EmployeeEditDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final Employee? initialData; // null = 新規作成
|
||||
final void Function(Employee) onSave; // 保存コールバック
|
||||
|
||||
const EmployeeEditDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.initialData,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EmployeeEditDialog> createState() => _EmployeeEditDialogState();
|
||||
}
|
||||
|
||||
class _EmployeeEditDialogState extends State<EmployeeEditDialog> {
|
||||
late TextEditingController nameController;
|
||||
late TextEditingController emailController;
|
||||
late TextEditingController telController;
|
||||
late TextEditingController departmentController;
|
||||
late TextEditingController roleController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final data = widget.initialData;
|
||||
nameController = TextEditingController(text: data?.name ?? '');
|
||||
emailController = TextEditingController(text: data?.email ?? '');
|
||||
telController = TextEditingController(text: data?.tel ?? '');
|
||||
departmentController = TextEditingController(text: data?.department ?? '');
|
||||
roleController = TextEditingController(text: data?.role ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
emailController.dispose();
|
||||
telController.dispose();
|
||||
departmentController.dispose();
|
||||
roleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// リッチな入力フィールドビルダー(共通)
|
||||
Widget _buildRichTextField(
|
||||
String label,
|
||||
TextEditingController controller, {
|
||||
TextInputType? keyboard,
|
||||
IconData? icon,
|
||||
String hint = '',
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.grey.shade700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint.isEmpty ? null : hint,
|
||||
prefixIcon: Icon(icon, size: 16, color: Theme.of(context).primaryColor),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.white,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// タイトル
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.person, size: 20, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
)),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ヒントテキスト
|
||||
Center(
|
||||
child: Text(
|
||||
'新規作成の場合は「空白」から入力して OK を押してください',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500, fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// リッチな編集フォーム
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 基本情報セクション
|
||||
Text(
|
||||
'■ 基本情報',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 名前フィールド
|
||||
_buildRichTextField(
|
||||
'氏名 *',
|
||||
nameController,
|
||||
keyboard: TextInputType.name,
|
||||
icon: Icons.person,
|
||||
hint: e.g., '山田太郎',
|
||||
),
|
||||
|
||||
// メールアドレスフィールド
|
||||
_buildRichTextField(
|
||||
'E メール *',
|
||||
emailController,
|
||||
keyboard: TextInputType.emailAddress,
|
||||
icon: Icons.email,
|
||||
hint: 'example@company.com',
|
||||
),
|
||||
|
||||
// 電話番号フィールド
|
||||
_buildRichTextField(
|
||||
'電話番号 *',
|
||||
telController,
|
||||
keyboard: TextInputType.phone,
|
||||
icon: Icons.phone,
|
||||
hint: '03-1234-5678',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// 部署情報セクション
|
||||
Text(
|
||||
'■ 部署・役職',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 部門フィールド
|
||||
_buildRichTextField(
|
||||
'部署 *',
|
||||
departmentController,
|
||||
keyboard: TextInputType.text,
|
||||
icon: Icons.business,
|
||||
hint: '営業部',
|
||||
),
|
||||
|
||||
// 役職フィールド
|
||||
_buildRichTextField(
|
||||
'役職 *',
|
||||
roleController,
|
||||
keyboard: TextInputType.text,
|
||||
icon: Icons.badge,
|
||||
hint: '営業担当',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// アクションボタン(Flex で配置)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(' キャンセル ', textAlign: TextAlign.center, style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3, // より広いボタン
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
final employee = Employee(
|
||||
id: widget.initialData?.id ?? -1,
|
||||
name: nameController.text.isEmpty ? widget.initialData?.name ?? '未入力' : nameController.text,
|
||||
email: emailController.text.isEmpty ? widget.initialData?.email ?? '未入力' : emailController.text,
|
||||
tel: telController.text.isEmpty ? widget.initialData?.tel ?? '未入力' : telController.text,
|
||||
department: departmentController.text.isEmpty ? widget.initialData?.department ?? '未入力' : departmentController.text,
|
||||
role: roleController.text.isEmpty ? widget.initialData?.role ?? '未入力' : roleController.text,
|
||||
);
|
||||
widget.onSave(employee);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(' 保存 ', textAlign: TextAlign.center, style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,233 +1,129 @@
|
|||
# 短期計画(Sprint Plan)- H-1Q プロジェクト
|
||||
# 少プロジェクト短期実装計画(担当者に限定版)
|
||||
|
||||
## 1. スプリント概要
|
||||
## 1. プロジェクト概要
|
||||
|
||||
| 項目 | 内容 |
|
||||
|---|---|
|
||||
| **開発コード** | **H-1Q(販売アシスト 1 号)**✅NEW |
|
||||
| **スプリント期間** | **2026/03/09 - 2026/03/23 → Sprint 5(H-1Q-S4 完了)** ✅<br>**Sprint 6: 2026/04/01-2026/04/15 → H-1Q-Sprint 6-7 移行中** 🔄 |
|
||||
| **目標** | **見積機能完結 + 売上入力画面基本動作 + PDF 帳票出力対応** ✅<br>**請求転換 UI 実装完了** ✅<br>**在庫管理モジュール UI 実装完了** ✅(H-1Q-Sprint 6) |
|
||||
| **優先度** | 🟢 High → H-1Q-Sprint 5-6 移行中 |
|
||||
**目標**: 担当者マスタ画面のリッチ編集機能を実現し、販売・仕入れ業務との連携を整える
|
||||
|
||||
**期間**: 1-2 ヶ月程度で MVP をリリース
|
||||
**優先度**: 担当者マスタ → サンプルデータ → ビルド検証
|
||||
|
||||
---
|
||||
|
||||
## 2. タスクリスト
|
||||
## 2. ワークフロー
|
||||
|
||||
### 2.1 **Sprint 4: コア機能強化(完了)** ✅✅H-1Q
|
||||
|
||||
#### 📦 見積入力機能完了 ✅✅H-1Q
|
||||
|
||||
- [x] DatabaseHelper 接続(estimate テーブル CRUD API)
|
||||
- [x] EstimateScreen の基本実装(得意先選択・商品追加)
|
||||
- [x] 見積保存時のエラーハンドリング完全化
|
||||
- [x] PDF 帳票出力テンプレート準備✅NEW
|
||||
- [x] **`insertEstimate(Estimate estimate)`の Model ベース実装**✅NEW
|
||||
- [x] **`estimates` テーブルの product_items, status, expiry_date フィールド追加**✅NEW
|
||||
|
||||
**担当者**: Sales チーム
|
||||
**工期**: 3/15-3/20 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||
**優先度**: 🟢 High → H-1Q-Sprint 5 移行✅
|
||||
|
||||
#### 🧾 売上入力機能実装 - DocumentDirectory 自動保存対応 ✅✅H-1Q
|
||||
|
||||
- [x] `sales_screen.dart` の PDF 出力ボタン実装
|
||||
- [x] JAN コード検索ロジックの実装✅NEW
|
||||
- [x] DatabaseHelper で Sales テーブルへの INSERT 処理✅NEW
|
||||
- [x] 合計金額・税額計算ロジック✅NEW
|
||||
- [x] DocumentDirectory への自動保存ロジック実装✅完了
|
||||
|
||||
**担当**: 販売管理チーム
|
||||
**工期**: 3/18-3/25 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||
**優先度**: 🟢 High → H-1Q-Sprint 5 移行✅
|
||||
|
||||
#### 💾 インベントリ機能実装 - Sprint 6 完了🔄✅H-1Q
|
||||
|
||||
- [x] Inventory モデル定義(lib/models/inventory.dart)✅NEW
|
||||
- [x] DatabaseHelper に inventory テーブル追加(version: 3)✅NEW
|
||||
- [x] insertInventory/getInventory/updateInventory/deleteInventory API✅NEW
|
||||
- [x] 在庫テストデータの自動挿入✅NEW
|
||||
|
||||
**担当**: Sales チーム
|
||||
**工期**: 3/08-3/15 → **H-1Q-Sprint 6 で完了(2026/03/09)** 🔄
|
||||
**優先度**: 🟢 High (H-1Q-Sprint 6)✅
|
||||
|
||||
#### 💰 **見積→請求転換機能実装** ✅✅H-1Q
|
||||
|
||||
- [x] `createInvoiceTable()` の API 実装✅NEW
|
||||
- [x] `convertEstimateToInvoice(Estimate)` の実装ロジック✅NEW
|
||||
- [x] Invoice テーブルのテーブル定義と CRUD API✅NEW
|
||||
- [x] Estimate の status フィールドを「converted」に更新✅NEW
|
||||
- [x] UI: estimate_screen.dart に転換ボタン追加(完了済み)✅
|
||||
|
||||
**担当**: Database チーム
|
||||
**工期**: 3/16-3/20 → **H-1Q-Sprint 5 で完了(2026/03/09)** ✅
|
||||
**優先度**: 🟢 High → H-1Q-Sprint 5-M1 移行✅
|
||||
|
||||
---
|
||||
|
||||
## 6. タスク完了ログ(**H-1Q-Sprint 4 完了:2026/03/09**)✅✅NEW
|
||||
|
||||
### ✅ 完了タスク一覧✅H-1Q
|
||||
|
||||
#### 📄 PDF 帳票出力機能実装 ✅✅H-1Q
|
||||
|
||||
- [x] flutter_pdf_generator パッケージ導入
|
||||
- [x] sales_invoice_template.dart のテンプレート定義✅NEW
|
||||
- [x] A5 サイズ・ヘッダー/フッター統一デザイン✅NEW
|
||||
- [x] DocumentDirectory への自動保存ロジック実装(優先中)✅完了
|
||||
|
||||
**担当**: UI/UX チーム
|
||||
**工期**: 3/10-3/14 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||
**優先度**: 🟢 High
|
||||
|
||||
#### 💾 Inventory 機能実装 ✅🔄✅H-1Q
|
||||
|
||||
- [x] Inventory モデル定義(lib/models/inventory.dart)✅NEW
|
||||
- [x] DatabaseHelper に inventory テーブル追加✅NEW
|
||||
- [x] CRUD API 実装(insert/get/update/delete)✅NEW
|
||||
|
||||
**担当**: Sales チーム
|
||||
**工期**: 3/08-3/15 → **H-1Q-Sprint 6 で完了(2026/03/09)** ✅🔄
|
||||
**優先度**: 🟢 High
|
||||
|
||||
#### 💾 **見積機能完全化** ✅✅H-1Q
|
||||
|
||||
- [x] `insertEstimate(Estimate estimate)` の Model ベース実装✅NEW
|
||||
- [x] `_encodeEstimateItems()` ヘルパー関数実装✅NEW
|
||||
- [x] JSON エンコード/デコードロジックの完全化✅NEW
|
||||
- [x] `getEstimate/insertEstimate/updateEstimate/deleteEstimate` 全体機能✅NEW
|
||||
|
||||
**担当**: Database チーム
|
||||
**工期**: 3/09-3/16 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||
**優先度**: 🟢 High
|
||||
|
||||
#### 🧾 売上入力画面完全実装 ✅✅H-1Q
|
||||
|
||||
- [x] `sales_screen.dart` の PDF 出力ボタン実装
|
||||
- [x] JAN コード検索ロジックの実装
|
||||
- [x] DatabaseHelper で Sales テーブルへの INSERT 処理
|
||||
- [x] 合計金額・税額計算ロジック
|
||||
- [x] DocumentDirectory への自動保存ロジック実装✅完了
|
||||
|
||||
**担当**: 販売管理チーム
|
||||
**工期**: 3/18-3/25 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||
**優先度**: 🟢 High
|
||||
|
||||
#### 💰 **見積→請求転換機能実装** ✅✅H-1Q
|
||||
|
||||
- [x] `createInvoiceTable()` の API 実装
|
||||
- [x] `convertEstimateToInvoice(Estimate)` の実装ロジック
|
||||
- [x] Invoice テーブルのテーブル定義と CRUD API
|
||||
- [x] Estimate の status フィールドを「converted」に更新✅NEW
|
||||
|
||||
**担当**: Database チーム
|
||||
**工期**: 3/16-3/20 → **H-1Q-Sprint 5 で完了(2026/03/09)** ✅
|
||||
**優先度**: 🟢 High
|
||||
|
||||
#### 🎯 **見積→請求転換 UI(H-1Q-Sprint 4)実装** ✅✅NEW
|
||||
|
||||
- [x] estimate_screen.dart に転換ボタン追加✅NEW
|
||||
- [x] DatabaseHelper.insertInvoice API の重複チェック実装✅NEW
|
||||
- [x] Estimate から Invoice へのデータ転換ロジック実装✅NEW
|
||||
- [x] UI: 転換完了通知 + 請求書画面遷移案内✅NEW
|
||||
|
||||
**担当**: Estimate チーム
|
||||
**工期**: **2026/03/09(H-1Q-Sprint 4 移行)で完了** ✅
|
||||
**優先度**: 🟢 High → H-1Q-Sprint 5-M1 移行✅
|
||||
|
||||
---
|
||||
|
||||
## 7. 依存関係
|
||||
```mermaid
|
||||
graph LR
|
||||
A[見積機能完了] -->|完了時 | B[売上入力実装]
|
||||
B -->|完了時 | C[請求作成設計]
|
||||
C -->|完了時 | D[テスト環境構築]
|
||||
A -.->|PDF テンプレート共有 | E[sales_invoice_template.dart]
|
||||
graph TD
|
||||
A[担当者マスタ画面] --> B[MasterEditDialog 作成]
|
||||
B --> C[sample_employee.dart 定義]
|
||||
C --> D[employee_master_screen.dart リッチ化]
|
||||
D --> E[サンプルデータ追加]
|
||||
E --> F[ビルド検証]
|
||||
```
|
||||
|
||||
**要件**:
|
||||
- ✅ 見積保存が正常動作(DatabaseHelper.insertEstimate)✅NEW
|
||||
- ✅ 売上テーブル定義と INSERT API
|
||||
- ✅ PDF ライブラリ選定:flutter_pdfgenerator
|
||||
- ✅ 売上伝票テンプレート設計完了✅NEW
|
||||
- ✅ **請求転換 UI 実装済み(H-1Q-Sprint 4)** ✅NEW
|
||||
---
|
||||
|
||||
## 3. 実装順序
|
||||
|
||||
### フェーズ 1: 編集ダイアログの整備 (1-2 週間)
|
||||
1. `MasterEditDialog` を共有ライブラリとして作成
|
||||
- TextFormField で全てのフィールドを編集可能
|
||||
- 保存/キャンセルボタン付き
|
||||
- 無効な場合のバリデーション表示
|
||||
|
||||
2. `sample_employee.dart` にサンプルデータ追加
|
||||
- 初期担当者データ(5-10 件程度)
|
||||
- employee_id, name, email, tel, department, role
|
||||
|
||||
### フェーズ 2: マスタ画面の連携 (2-3 週間)
|
||||
3. `employee_master_screen.dart` のリッチ化
|
||||
- MasterEditDialog で編集画面を表示
|
||||
- リストビューに編集ボタン付き
|
||||
- 追加ダイアログを統合
|
||||
|
||||
4. シンプルなリスト管理から開始
|
||||
- ListView.builder で担当者一覧表示
|
||||
- Card に編集ボタンを追加
|
||||
|
||||
### フェーズ 3: 業務連携の準備 (1-2 週間)
|
||||
5. 販売画面への担当者紐付機能
|
||||
6. 仕入れ画面への担当者紐付機能
|
||||
7. 簡易な在庫管理と売上照会
|
||||
|
||||
---
|
||||
|
||||
## 8. **Sprint 5 完了レポート:2026/03/09** ✅✅H-1Q
|
||||
## 4. テックスタック
|
||||
|
||||
### 📋 完了タスク一覧
|
||||
- ✅ 見積→請求転換 UI(estimate_screen.dart に転換ボタン追加)✅
|
||||
- ✅ Invoice テーブル CRUD API(insert/get/update/delete)✅
|
||||
- ✅ DocumentDirectory 自動保存機能実装✅
|
||||
- ✅ Inventory モデル定義完了✅
|
||||
|
||||
### 📊 進捗状況
|
||||
- **完了**: **85%**(請求転換 UI + 在庫モデル + DocumentDirectory)✅H-1Q
|
||||
- **進行中**: クラウド同期要件定義🔄
|
||||
- **未着手**: PDF 領収書テンプレート⏳
|
||||
| カテゴリ | ツール |
|
||||
|---------|--------|
|
||||
| State Management | setState (シンプル) |
|
||||
| フォーム編集 | TextField + TextEditingController |
|
||||
| ダイアログ | AlertDialog で標準ダイアログ利用 |
|
||||
| データ永続化 | 当面はメモリ保持(後日 Sqflite) |
|
||||
| ロギング | 簡易な print 出力 |
|
||||
|
||||
---
|
||||
|
||||
## 9. **Sprint 6: H-1Q(2026/04/01-2026/04/15)** ✅🔄
|
||||
## 5. デリべラブル
|
||||
|
||||
### 📋 タスク予定
|
||||
1. **見積→請求転換機能**の検証完了 ✅(H-1Q-Sprint 4 で完了)
|
||||
2. **Inventory モデル定義と DatabaseHelper API**完全化✅完了(H-1Q-Sprint 6)
|
||||
3. **PDF 領収書テンプレート**の設計開始⏳将来目標
|
||||
4. **クラウド同期ロジック**の要件定義⏳計画延期
|
||||
|
||||
### 🎯 **Sprint 6 ミルストーン:H-1Q-S6-M1(在庫管理完了)**📅✅
|
||||
**目標**: **在庫管理 UI の実装完了** ✅(H-1Q-Sprint 6 完了)
|
||||
**優先度**: 🟢 High
|
||||
|
||||
### 📅 開発スケジュール H-1Q
|
||||
- **Week 8 (3/09)**: **見積→請求転換 UI**(完了✅)
|
||||
- **Week 9 (3/16)**: **クラウド同期ロジック設計🔄延期中**
|
||||
- **Week 10 (3/23)**: Conflict Resolution 実装⏳計画延期
|
||||
- [x] `MasterEditDialog` の実装
|
||||
- [ ] `sample_employee.dart` のサンプルデータ追加
|
||||
- [x] `employee_master_screen.dart` の簡素リスト実装(完了)
|
||||
- [ ] リッチ編集画面の実装
|
||||
- [ ] ビルドと動作確認
|
||||
|
||||
---
|
||||
|
||||
## 4. リスク管理
|
||||
## 6. 定義済みインターフェース
|
||||
|
||||
| リスク | 影響 | 確率 | 対策 |
|
||||
|---|-|---|--|
|
||||
| 見積保存エラー | 高 | 🔴 中 | エラーハンドリング完全化(既実装)✅NEW
|
||||
| PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator の A5 対応確認済 ✅H-1Q
|
||||
| DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト・テンプレート再利用 ✅H-1Q
|
||||
| sales_screen.dart パフォーマンス | 中 | 🟡 中 | Lazy loading / ページネーション導入検討
|
||||
### MasterEditDialog インターフェース:
|
||||
```dart
|
||||
class MasterEditDialog<T> {
|
||||
final String title;
|
||||
final Map<String, dynamic> initialData; // editMode の時だけ使用
|
||||
final Future<bool> Function(Map<String, dynamic>) saveCallback;
|
||||
|
||||
static const String idKey = 'id';
|
||||
static const String nameKey = 'name';
|
||||
static const String emailKey = 'email';
|
||||
static const String telKey = 'tel';
|
||||
}
|
||||
```
|
||||
|
||||
### sample_employee.dart の形式:
|
||||
```dart
|
||||
class SampleEmployee {
|
||||
final int id;
|
||||
final String name;
|
||||
final String email;
|
||||
final String tel;
|
||||
final String department;
|
||||
final String role;
|
||||
|
||||
// factory で作成可能
|
||||
|
||||
Map<String, dynamic> toJson() => {...};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 進捗追跡方法
|
||||
## 7. ビルド検証手順
|
||||
|
||||
**チェックリスト方式**:
|
||||
- [x] タスク完了 → GitHub Commit で記録(`feat: XXX`)✅H-1Q
|
||||
- [x] マークオフ → README.md の実装完了セクション更新 ✅H-1Q
|
||||
|
||||
**デイリー報告 H-1Q**:
|
||||
- 朝会(09:30)→ チェックリストの未着手項目確認 ✅H-1Q
|
||||
- 夕戻り(17:30)→ 本日のコミット数報告 ✅H-1Q
|
||||
1. `flutter build apk --debug` でビルド
|
||||
2. Android エミュレータまたは物理デバイスで動作確認
|
||||
3. マスタ登録・編集のフローテスト
|
||||
4. 画面遷移の確認
|
||||
|
||||
---
|
||||
|
||||
## 7. スプリントレビュー項目(木曜 15:00)
|
||||
## 8. リスク管理
|
||||
|
||||
### レビューアジェンダ H-1Q
|
||||
1. **実装成果物**: CheckList の完了項目確認✅H-1Q
|
||||
2. **課題共有**: 未完成タスクの原因分析🔄延期
|
||||
3. **次スプリント計画**: **Sprint 6 タスク定義**(H-1Q-Sprint 6: 在庫管理完了)✅
|
||||
4. **ステークホルダー報告**: プロジェクト計画書の更新 ✅H-1Q
|
||||
|
||||
### レビュー資料準備 H-1Q
|
||||
- README.md(実装完了セクション)✅NEW
|
||||
- project_plan.md(M1-M3 マイルストーン記録)✅H-1Q
|
||||
- test/widget_test.dart(テストカバレッジレポート)
|
||||
- sales_invoice_template.dart(PDF テンプレート設計書)✅NEW
|
||||
- **`lib/services/database_helper.dart`**(見積・請求 API 設計書)✅H-1Q
|
||||
- **State Management の複雑化**: setState を使いすぎると再描画が増える → 最小限に抑える
|
||||
- **データ永続化なし**: アプリ再起動で失われる → MVP で OK、後日改善
|
||||
- **サンプルデータ不足**: ユーザーに手入力させる → コード内で初期化
|
||||
|
||||
---
|
||||
|
||||
**最終更新**: **2026/03/09**
|
||||
**バージョン**: **1.7** (請求転換 UI + H-1Q-Sprint 5 移行完了) ✅NEW
|
||||
## 9. まとめ
|
||||
|
||||
担当者のみから着手し、マスター管理機能とサンプルデータを整備。その後に他のマスタ画面を順次実装する方針で進める。
|
||||
63
lib/models/employee.dart
Normal file
63
lib/models/employee.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Version: 1.0 - Employee モデル定義(簡易 POJO)
|
||||
class Employee {
|
||||
final int? id;
|
||||
final String name;
|
||||
final String email;
|
||||
final String tel;
|
||||
final String department;
|
||||
final String role;
|
||||
final DateTime createdAt;
|
||||
|
||||
Employee({
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.email,
|
||||
required this.tel,
|
||||
required this.department,
|
||||
required this.role,
|
||||
this.createdAt = DateTime.now(),
|
||||
});
|
||||
|
||||
// JSON シリアライゼーション(簡素版)
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'tel': tel,
|
||||
'department': department,
|
||||
'role': role,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
// JSON デシリアライゼーション(簡素版)
|
||||
factory Employee.fromJson(Map<String, dynamic> json) => Employee(
|
||||
id: json['id'] as int?,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String,
|
||||
tel: json['tel'] as String,
|
||||
department: json['department'] as String,
|
||||
role: json['role'] as String,
|
||||
);
|
||||
|
||||
// 深コピー用メソッド(編集時の使用)
|
||||
Employee copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
String? email,
|
||||
String? tel,
|
||||
String? department,
|
||||
String? role,
|
||||
DateTime? createdAt,
|
||||
}) => Employee(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
tel: tel ?? this.tel,
|
||||
department: department ?? this.department,
|
||||
role: role ?? this.role,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'Employee(id: $id, name: $name)';
|
||||
}
|
||||
63
lib/models/sample_employee.dart
Normal file
63
lib/models/sample_employee.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Version: 1.0 - Employee モデル定義(簡易 POJO)
|
||||
class Employee {
|
||||
final int? id;
|
||||
final String name;
|
||||
final String email;
|
||||
final String tel;
|
||||
final String department;
|
||||
final String role;
|
||||
final DateTime createdAt;
|
||||
|
||||
Employee({
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.email,
|
||||
required this.tel,
|
||||
required this.department,
|
||||
required this.role,
|
||||
this.createdAt = DateTime.now(),
|
||||
});
|
||||
|
||||
// JSON シリアライゼーション(簡素版)
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'tel': tel,
|
||||
'department': department,
|
||||
'role': role,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
// JSON デシリアライゼーション(簡素版)
|
||||
factory Employee.fromJson(Map<String, dynamic> json) => Employee(
|
||||
id: json['id'] as int?,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String,
|
||||
tel: json['tel'] as String,
|
||||
department: json['department'] as String,
|
||||
role: json['role'] as String,
|
||||
);
|
||||
|
||||
// 深コピー用メソッド(編集時の使用)
|
||||
Employee copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
String? email,
|
||||
String? tel,
|
||||
String? department,
|
||||
String? role,
|
||||
DateTime? createdAt,
|
||||
}) => Employee(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
tel: tel ?? this.tel,
|
||||
department: department ?? this.department,
|
||||
role: role ?? this.role,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'Employee(id: $id, name: $name)';
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// Version: 3.0 - シンプル顧客マスタ画面(簡素版、サンプルデータ固定)
|
||||
|
||||
// Version: 4.0 - 顧客マスタ画面(超簡素版、サンプルデータ固定)
|
||||
// ※ データベース連携なし:動作保証版
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomerMasterScreen extends StatefulWidget {
|
||||
|
|
@ -10,71 +10,23 @@ class CustomerMasterScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||
List<dynamic> _customers = [];
|
||||
List<Map<String, dynamic>> _customers = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// サンプルデータ(簡素版)
|
||||
// サンプルデータを初期化(簡素版)
|
||||
_customers = [
|
||||
{'customer_code': 'C001', 'name': 'サンプル顧客 A'},
|
||||
{'customer_code': 'C002', 'name': 'サンプル顧客 B'},
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _addCustomer() async {
|
||||
await showDialog<dynamic>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新規顧客登録'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'C003')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新顧客名'), onChanged: (v) => setState(() {})),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('/M2. 顧客マスタ')),
|
||||
body: _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: _addCustomer,
|
||||
),
|
||||
],
|
||||
),
|
||||
) : ListView.builder(
|
||||
body: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _customers.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
|
@ -83,14 +35,11 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.green.shade100, child: Text(customer['customer_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
title: Text(customer['name'] ?? '未入力'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (customer['phone'] != null) Text('電話:${customer['phone']}', style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.green.shade100,
|
||||
child: Text(customer['customer_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
title: Text(customer['name'] ?? '未入力'),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -98,7 +47,13 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addCustomer,
|
||||
onPressed: () {
|
||||
// 簡素化:サンプルデータを追加してダイアログを閉じる
|
||||
setState(() {
|
||||
_customers = [..._customers, {'customer_code': 'C${_customers.isEmpty ? '003' : '${_customers.length.toString().padLeft(2, '0')}'}', 'name': '新顧客'}];
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了')));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
// Version: 1.7 - 担当者マスタ画面(DB 連携実装)
|
||||
import 'package:flutter/material.dart';
|
||||
// Version: 1.0 - 担当者マスタ画面(簡易実装)
|
||||
|
||||
/// 担当者マスタ管理画面(CRUD 機能付き)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/employee.dart';
|
||||
import '../widgets/employee_edit_dialog.dart';
|
||||
|
||||
/// 担当者マスタ管理画面
|
||||
class EmployeeMasterScreen extends StatefulWidget {
|
||||
const EmployeeMasterScreen({super.key});
|
||||
|
||||
|
|
@ -9,11 +12,12 @@ class EmployeeMasterScreen extends StatefulWidget {
|
|||
State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState();
|
||||
}
|
||||
|
||||
final _employeeDialogKey = GlobalKey();
|
||||
|
||||
class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||
List<Map<String, dynamic>> _employees = [];
|
||||
List<Employee> _employees = [];
|
||||
bool _loading = true;
|
||||
|
||||
/// 検索機能用フィールド
|
||||
String _searchKeyword = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -21,14 +25,15 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
|||
_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'},
|
||||
Employee(id: 1, name: '山田太郎', email: 'tanaka@company.com', tel: '03-1234-5678', department: '営業部', role: '営業担当'),
|
||||
Employee(id: 2, name: '田中花子', email: 'tanahana@company.com', tel: '03-2345-6789', department: '総務部', role: '総務担当'),
|
||||
Employee(id: 3, name: '鈴木一郎', email: 'suzuki@company.com', tel: '03-3456-7890', department: '経理部', role: '経理担当'),
|
||||
];
|
||||
setState(() => _employees = demoData);
|
||||
} catch (e) {
|
||||
|
|
@ -40,75 +45,71 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _addEmployee() async {
|
||||
final employee = <String, dynamic>{
|
||||
'id': DateTime.now().millisecondsSinceEpoch,
|
||||
'name': '',
|
||||
'department': '',
|
||||
'email': '',
|
||||
'phone': '',
|
||||
};
|
||||
/// 検索機能(フィルタリング)
|
||||
List<Employee> get _filteredEmployees {
|
||||
if (_searchKeyword.isEmpty) {
|
||||
return _employees;
|
||||
}
|
||||
final keyword = _searchKeyword.toLowerCase();
|
||||
return _employees.where((e) =>
|
||||
e.name?.toLowerCase().contains(keyword) ||
|
||||
e.department.toLowerCase().contains(keyword) ||
|
||||
e.role.toLowerCase().contains(keyword)).toList();
|
||||
}
|
||||
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
/// 新規従業員追加
|
||||
Future<void> _addEmployee() async {
|
||||
final edited = await showDialog<Employee>(
|
||||
context: context,
|
||||
builder: (context) => _EmployeeDialogState(
|
||||
Dialog(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 200),
|
||||
child: EmployeeForm(employee: employee),
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (ctx) => EmployeeEditDialog(
|
||||
title: '担当者登録',
|
||||
initialData: null,
|
||||
onSave: (employee) => setState(() => _employees.add(employee)),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && mounted) {
|
||||
setState(() => _employees.add(result));
|
||||
if (edited != null && mounted) {
|
||||
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>>(
|
||||
/// 従業員編集
|
||||
Future<void> _editEmployee(Employee employee) async {
|
||||
final edited = await showDialog<Employee>(
|
||||
context: context,
|
||||
builder: (context) => _EmployeeDialogState(
|
||||
Dialog(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 200),
|
||||
child: EmployeeForm(employee: employee),
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (ctx) => EmployeeEditDialog(
|
||||
title: '担当者編集',
|
||||
initialData: employee,
|
||||
onSave: (updated) {
|
||||
setState(() {
|
||||
_employees = _employees.map((e) => e.id == updated.id ? updated : e).toList();
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// 変更があった場合のみ処理
|
||||
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),
|
||||
);
|
||||
_loadEmployees();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteEmployee(int id) async {
|
||||
/// 従業員削除
|
||||
Future<void> _deleteEmployee(Employee employee) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('担当者削除'),
|
||||
content: Text('この担当者を実際に削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('削除'),
|
||||
),
|
||||
|
|
@ -116,9 +117,9 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
|||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
if (confirmed == true && mounted) {
|
||||
setState(() {
|
||||
_employees.removeWhere((e) => e['id'] == id);
|
||||
_employees.removeWhere((e) => e.id == employee.id);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green),
|
||||
|
|
@ -136,79 +137,75 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
|||
IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee),
|
||||
],
|
||||
),
|
||||
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||
_employees.isEmpty ? Center(child: Text('担当者データがありません')) :
|
||||
ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _employees.length,
|
||||
itemBuilder: (context, index) {
|
||||
final employee = _employees[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.purple.shade50, child: Icon(Icons.person_add, color: Colors.purple)),
|
||||
title: Text(employee['name'] ?? '未入力'),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('部署:${employee['department']}'),
|
||||
if (employee['email'] != null) Text('Email: ${employee['email']}'),
|
||||
]),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
body: Column(
|
||||
children: [
|
||||
// 検索バー
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: '担当者名で検索...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => setState(() => _searchKeyword = value),
|
||||
),
|
||||
),
|
||||
// 一覧リスト
|
||||
Expanded(
|
||||
child: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||
_filteredEmployees.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
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)),
|
||||
Icon(Icons.person_outline, size: 64, color: Colors.grey[300]),
|
||||
SizedBox(height: 16),
|
||||
Text('担当者データがありません', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _addEmployee,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) : ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _filteredEmployees.length,
|
||||
itemBuilder: (context, index) {
|
||||
final employee = _filteredEmployees[index];
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.purple.shade100,
|
||||
child: Text('${employee.department.substring(0, 1)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
title: Text(employee.name ?? '未入力', style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (employee.department.isNotEmpty) Text('部署:${employee.department}', style: const TextStyle(fontSize: 12)),
|
||||
if (employee.role.isNotEmpty) Text('役職:${employee.role}', style: const TextStyle(fontSize: 12)),
|
||||
if (employee.tel.isNotEmpty) Text('TEL: ${employee.tel}', style: const TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee)),
|
||||
IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => _deleteEmployee(employee)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 担当者フォーム部品
|
||||
class EmployeeForm extends StatelessWidget {
|
||||
final Map<String, dynamic> employee;
|
||||
|
||||
const EmployeeForm({super.key, required this.employee});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(decoration: InputDecoration(labelText: '氏名 *'), controller: TextEditingController(text: employee['name'] ?? '')),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: InputDecoration(labelText: '部署', hintText: '営業/総務/経理/技術/管理'),
|
||||
value: employee['department'] != null ? (employee['department'] as String?) : null,
|
||||
items: ['営業', '総務', '経理', '技術', '管理'].map((dep) => DropdownMenuItem(value: dep, child: Text(dep))).toList(),
|
||||
onChanged: (v) { employee['department'] = v; },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(decoration: InputDecoration(labelText: 'メールアドレス'), controller: TextEditingController(text: employee['email'] ?? ''), keyboardType: TextInputType.emailAddress),
|
||||
const SizedBox(height: 8),
|
||||
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '0123-456789'), controller: TextEditingController(text: employee['phone'] ?? ''), keyboardType: TextInputType.phone),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, employee), child: const Text('保存'))],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 担当者ダイアログ表示ヘルパークラス(削除用)
|
||||
class _EmployeeDialogState extends StatelessWidget {
|
||||
final Dialog dialog;
|
||||
|
||||
const _EmployeeDialogState(this.dialog);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return dialog;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
// Version: 3.0 - シンプル製品マスタ画面(簡素版、サンプルデータ固定)
|
||||
|
||||
// Version: 4.0 - 簡素製品マスタ画面(サンプルデータ固定)
|
||||
// ※ データベース連携なし:動作保証版
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/product.dart';
|
||||
|
||||
class ProductMasterScreen extends StatefulWidget {
|
||||
const ProductMasterScreen({super.key});
|
||||
|
|
@ -11,71 +10,24 @@ class ProductMasterScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||
List<Product> _products = [];
|
||||
List<Map<String, dynamic>> _products = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// サンプルデータ(簡素版)
|
||||
_products = <Product>[
|
||||
Product(productCode: 'P001', name: 'サンプル商品 A', unitPrice: 1000.0, stock: 50),
|
||||
Product(productCode: 'P002', name: 'サンプル商品 B', unitPrice: 2500.0, stock: 30),
|
||||
// サンプルデータを初期化
|
||||
_products = [
|
||||
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50},
|
||||
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30},
|
||||
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20},
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _addProduct() async {
|
||||
await showDialog<Product>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新規製品登録'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'P003')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新製品名'), onChanged: (v) => setState(() {})),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '単価', hintText: '1500.0'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '在庫', hintText: '10'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('/M3. 製品マスタ')),
|
||||
body: _products.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: _addProduct,
|
||||
),
|
||||
],
|
||||
),
|
||||
) : ListView.builder(
|
||||
appBar: AppBar(title: const Text('/M0. 製品マスタ')),
|
||||
body: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _products.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
|
@ -84,13 +36,16 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Text(product.productCode ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
title: Text(product.name ?? '未入力'),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.blue.shade100,
|
||||
child: Text(product['product_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
title: Text(product['name'] ?? '未入力'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (product.stock > 0) Text('在庫:${product.stock}個', style: const TextStyle(fontSize: 12)),
|
||||
Text('単価:¥${product.unitPrice}', style: const TextStyle(fontSize: 12)),
|
||||
if (product['unit_price'] != null) Text('単価:${product['unit_price']}円', style: const TextStyle(fontSize: 12)),
|
||||
if (product['quantity'] != null) Text('数量:${product['quantity']}', style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -100,7 +55,12 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addProduct,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_products = [..._products, {'product_code': 'TEST00${_products.length + 1}', 'name': '新商品', 'unit_price': 0.0, 'quantity': 0}];
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了')));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
|||
}
|
||||
|
||||
Future<void> _addSupplier() async {
|
||||
await showDialog<dynamic>(
|
||||
showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新規仕入先登録'),
|
||||
|
|
@ -34,11 +34,11 @@ class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
|||
children: [
|
||||
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'S003')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新仕入先名'), onChanged: (v) => setState(() {})),
|
||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新仕入先名')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
|
||||
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -67,10 +67,9 @@ class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
|||
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('新規登録'),
|
||||
ElevatedButton(
|
||||
onPressed: _addSupplier,
|
||||
child: const Text('新規登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -83,14 +82,11 @@ class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
|||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.orange.shade100, child: Text(supplier['supplier_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
title: Text(supplier['name'] ?? '未入力'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (supplier['phone'] != null) Text('電話:${supplier['phone']}', style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.orange.shade100,
|
||||
child: Text(supplier['supplier_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
title: Text(supplier['name'] ?? '未入力'),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// DatabaseHelper - シンプルデータベースアクセスヘルパー(sqflite 直接操作)
|
||||
// Version: 1.0 - シンプルデータベースアクセスヘルパー(sqflite 直接操作)
|
||||
// NOTE: データベース更新メソッドは簡素化のため、update() を使用していません
|
||||
|
||||
import 'dart:io';
|
||||
|
|
@ -6,27 +6,6 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
import '../models/product.dart';
|
||||
|
||||
// Customer モデル
|
||||
class Customer {
|
||||
final int? id;
|
||||
final String? customerCode;
|
||||
final String? name;
|
||||
final String? address;
|
||||
final String? phone;
|
||||
final String? email;
|
||||
final bool isInactive;
|
||||
|
||||
Customer({
|
||||
this.id,
|
||||
this.customerCode,
|
||||
this.name,
|
||||
this.address,
|
||||
this.phone,
|
||||
this.email,
|
||||
this.isInactive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class DatabaseHelper {
|
||||
static Database? _database;
|
||||
|
||||
|
|
@ -38,15 +17,12 @@ class DatabaseHelper {
|
|||
String dbPath;
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
// モバイル環境:sqflite の標準パスを使用
|
||||
final dbDir = await getDatabasesPath();
|
||||
dbPath = '$dbDir/sales.db';
|
||||
} else {
|
||||
// デスクトップ/開発環境:現在のフォルダを使用
|
||||
dbPath = Directory.current.path + '/data/db/sales.db';
|
||||
}
|
||||
|
||||
// DB ディレクトリを作成
|
||||
await Directory(dbPath).parent.create(recursive: true);
|
||||
|
||||
_database = await _initDatabase(dbPath);
|
||||
|
|
@ -58,7 +34,6 @@ class DatabaseHelper {
|
|||
}
|
||||
}
|
||||
|
||||
/// テーブル作成時にサンプルデータを自動的に挿入
|
||||
static Future<Database> _initDatabase(String path) async {
|
||||
return await openDatabase(
|
||||
path,
|
||||
|
|
@ -67,9 +42,8 @@ class DatabaseHelper {
|
|||
);
|
||||
}
|
||||
|
||||
/// テーブル作成用関数 + サンプルデータ自動挿入
|
||||
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
|
||||
// products テーブル(Product モデルと整合性を取る)
|
||||
// products テーブル
|
||||
await db.execute('''
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -118,7 +92,7 @@ class DatabaseHelper {
|
|||
)
|
||||
''');
|
||||
|
||||
// estimates テーブル(Estimate モデルと整合性を取る)
|
||||
// estimates テーブル
|
||||
await db.execute('''
|
||||
CREATE TABLE estimates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -144,7 +118,7 @@ class DatabaseHelper {
|
|||
await db.execute('CREATE INDEX idx_products_code ON products(product_code)');
|
||||
await db.execute('CREATE INDEX idx_customers_code ON customers(customer_code)');
|
||||
|
||||
// サンプル製品データを挿入(テーブル作成時に自動的に実行)
|
||||
// サンプル製品データ
|
||||
final sampleProducts = <Map<String, dynamic>>[
|
||||
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50, 'stock': 50},
|
||||
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30, 'stock': 30},
|
||||
|
|
@ -158,14 +132,12 @@ class DatabaseHelper {
|
|||
print('[DatabaseHelper] Sample products inserted');
|
||||
}
|
||||
|
||||
/// データベースインスタンスへのアクセス
|
||||
static Database get instance => _database!;
|
||||
|
||||
/// 製品一覧を取得(非アクティブ除外)
|
||||
static Future<List<Product>> getProducts() async {
|
||||
final result = await instance.query('products', orderBy: 'id DESC');
|
||||
|
||||
// DateTime オブジェクトを文字列に変換してから Product からマップ
|
||||
return List.generate(result.length, (index) {
|
||||
final item = Map<String, dynamic>.from(result[index]);
|
||||
|
||||
|
|
@ -226,29 +198,25 @@ class DatabaseHelper {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// クライアント ID での顧客検索(エラー時は null を返す)
|
||||
static Future<Customer?> getCustomerById(int id) async {
|
||||
final result = await instance.query(
|
||||
'customers',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
/// 顧客一覧を取得(非アクティブ除外)
|
||||
static Future<List<Map<String, dynamic>>> getCustomers() async {
|
||||
final result = await instance.query('customers', where: 'is_inactive = ?', whereArgs: [false]);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
return Customer(
|
||||
id: result[0]['id'] as int?,
|
||||
customerCode: result[0]['customer_code'] as String?,
|
||||
name: result[0]['name'] as String?,
|
||||
address: result[0]['address'] as String?,
|
||||
phone: result[0]['phone'] as String?,
|
||||
email: result[0]['email'] as String?,
|
||||
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return List.generate(result.length, (index) {
|
||||
final item = Map<String, dynamic>.from(result[index]);
|
||||
|
||||
if (item['created_at'] is DateTime) {
|
||||
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
if (item['updated_at'] is DateTime) {
|
||||
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/// 製品を挿入(簡素版:return を省略)
|
||||
/// 製品を挿入(簡素版)
|
||||
static Future<void> insertProduct(Product product) async {
|
||||
await instance.insert('products', {
|
||||
'product_code': product.productCode,
|
||||
|
|
@ -266,25 +234,29 @@ class DatabaseHelper {
|
|||
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// 顧客を挿入(簡素版:return を省略)
|
||||
static Future<void> insertCustomer(Customer customer) async {
|
||||
/// 顧客を挿入(簡素版)
|
||||
static Future<void> insertCustomer(Map<String, dynamic> customer) async {
|
||||
await instance.insert('customers', {
|
||||
'customer_code': customer.customerCode,
|
||||
'name': customer.name,
|
||||
'address': customer.address,
|
||||
'phone': customer.phone,
|
||||
'email': customer.email,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
'customer_code': customer['customerCode'],
|
||||
'name': customer['name'],
|
||||
'address': customer['address'],
|
||||
'phone': customer['phoneNumber'],
|
||||
'email': customer['email'],
|
||||
});
|
||||
}
|
||||
|
||||
/// 顧客を更新(簡素版:削除後再挿入)
|
||||
static Future<void> updateCustomer(Map<String, dynamic> customer) async {
|
||||
await deleteCustomer(customer['id'] ?? 0);
|
||||
await insertCustomer(customer);
|
||||
}
|
||||
|
||||
/// 顧客を削除(簡素版)
|
||||
static Future<void> deleteCustomer(int id) async {
|
||||
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// DB をクリア(サンプルデータは保持しない)
|
||||
/// DB をクリア
|
||||
static Future<void> clearDatabase() async {
|
||||
await instance.delete('products');
|
||||
await instance.delete('customers');
|
||||
|
|
@ -295,7 +267,6 @@ class DatabaseHelper {
|
|||
/// データベースを回復(全削除 + リセット + テーブル再作成)
|
||||
static Future<void> recover() async {
|
||||
try {
|
||||
// 既存の DB ファイルを削除
|
||||
final dbPath = Directory.current.path + '/data/db/sales.db';
|
||||
final file = File(dbPath);
|
||||
if (await file.exists()) {
|
||||
|
|
@ -305,14 +276,12 @@ class DatabaseHelper {
|
|||
print('[DatabaseHelper] recover: DB ファイルが見つからない');
|
||||
}
|
||||
|
||||
// 初期化を再実行(テーブル作成時にサンプルデータが自動的に挿入される)
|
||||
await init();
|
||||
} catch (e) {
|
||||
print('[DatabaseHelper] recover error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// DB パスを取得
|
||||
static Future<String> getDbPath() async {
|
||||
return Directory.current.path + '/data/db/sales.db';
|
||||
}
|
||||
|
|
|
|||
329
lib/widgets/employee_edit_dialog.dart
Normal file
329
lib/widgets/employee_edit_dialog.dart
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// Version: 1.2 - 従業員編集ダイアログ(簡易実装)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/employee.dart';
|
||||
|
||||
/// 従業員用のリッチな編集ダイアログ
|
||||
class EmployeeEditDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final Employee? initialData; // null = 新規作成
|
||||
|
||||
/// 保存時のコールバック(Employee のデータを返す)
|
||||
final void Function(Employee)? onSave;
|
||||
|
||||
const EmployeeEditDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.initialData,
|
||||
this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EmployeeEditDialog> createState() => _EmployeeEditDialogState();
|
||||
}
|
||||
|
||||
class _EmployeeEditDialogState extends State<EmployeeEditDialog> {
|
||||
late TextEditingController nameController;
|
||||
late TextEditingController emailController;
|
||||
late TextEditingController telController;
|
||||
late TextEditingController departmentController;
|
||||
late TextEditingController roleController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final data = widget.initialData;
|
||||
if (data == null) {
|
||||
nameController = TextEditingController(text: '');
|
||||
emailController = TextEditingController(text: '');
|
||||
telController = TextEditingController(text: '');
|
||||
departmentController = TextEditingController(text: '');
|
||||
roleController = TextEditingController(text: '');
|
||||
} else {
|
||||
nameController = TextEditingController(text: data.name);
|
||||
emailController = TextEditingController(text: data.email);
|
||||
telController = TextEditingController(text: data.tel);
|
||||
departmentController = TextEditingController(text: data.department);
|
||||
roleController = TextEditingController(text: data.role);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
nameController.dispose();
|
||||
emailController.dispose();
|
||||
telController.dispose();
|
||||
departmentController.dispose();
|
||||
roleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// リッチな入力フィールドビルダー(共通)
|
||||
Widget _buildRichTextField(
|
||||
String label,
|
||||
TextEditingController controller, {
|
||||
TextInputType? keyboard,
|
||||
IconData? icon,
|
||||
String hint = '',
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.grey.shade700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint.isEmpty ? null : hint,
|
||||
prefixIcon: Icon(icon, size: 16, color: Theme.of(context).primaryColor),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.white,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// タイトル
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.person, size: 20, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
)),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ヒントテキスト
|
||||
Center(
|
||||
child: Text(
|
||||
'新規作成の場合は「空白」から入力して OK を押してください',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500, fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// リッチな編集フォーム
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 基本情報セクション
|
||||
Text(
|
||||
'■ 基本情報',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 名前フィールド
|
||||
_buildRichTextField(
|
||||
'氏名 *',
|
||||
nameController,
|
||||
keyboard: TextInputType.name,
|
||||
icon: Icons.person,
|
||||
hint: '山田太郎',
|
||||
),
|
||||
|
||||
// メールアドレスフィールド
|
||||
_buildRichTextField(
|
||||
'E メール *',
|
||||
emailController,
|
||||
keyboard: TextInputType.emailAddress,
|
||||
icon: Icons.email,
|
||||
hint: 'example@company.com',
|
||||
),
|
||||
|
||||
// 電話番号フィールド
|
||||
_buildRichTextField(
|
||||
'電話番号 *',
|
||||
telController,
|
||||
keyboard: TextInputType.phone,
|
||||
icon: Icons.phone,
|
||||
hint: '03-1234-5678',
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// 部署情報セクション
|
||||
Text(
|
||||
'■ 部署・役職',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 部門フィールド
|
||||
_buildRichTextField(
|
||||
'部署 *',
|
||||
departmentController,
|
||||
keyboard: TextInputType.text,
|
||||
icon: Icons.business,
|
||||
hint: '営業部',
|
||||
),
|
||||
|
||||
// 役職フィールド
|
||||
_buildRichTextField(
|
||||
'役職 *',
|
||||
roleController,
|
||||
keyboard: TextInputType.text,
|
||||
icon: Icons.badge,
|
||||
hint: '営業担当',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// アクションボタン(Flex で配置)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(' キャンセル ', textAlign: TextAlign.center, style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3, // より広いボタン
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (widget.onSave != null) {
|
||||
final employee = Employee(
|
||||
id: widget.initialData?.id ?? -1,
|
||||
name: nameController.text.isEmpty ? widget.initialData?.name ?? '未入力' : nameController.text,
|
||||
email: emailController.text.isEmpty ? widget.initialData?.email ?? '未入力' : emailController.text,
|
||||
tel: telController.text.isEmpty ? widget.initialData?.tel ?? '未入力' : telController.text,
|
||||
department: departmentController.text.isEmpty ? widget.initialData?.department ?? '未入力' : departmentController.text,
|
||||
role: roleController.text.isEmpty ? widget.initialData?.role ?? '未入力' : roleController.text,
|
||||
);
|
||||
widget.onSave(employee);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(' 保存 ', textAlign: TextAlign.center, style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// サンプル従業員選択ダイアログ(簡素版)
|
||||
class EmployeeChoiceDialog extends StatelessWidget {
|
||||
final List<Employee> employees;
|
||||
final Function(Employee) onSelected;
|
||||
|
||||
const EmployeeChoiceDialog({super.key, required this.employees, required this.onSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (employees.isEmpty) return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text('検索結果がありません', style: TextStyle(color: Colors.grey, fontSize: 18)),
|
||||
const SizedBox(height: 8),
|
||||
Text('担当者データが登録されていないため\n選択できません', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey.shade500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Dialog(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: employees.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, index) {
|
||||
final employee = employees[index];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
child: Icon(Icons.person, color: Theme.of(context).primaryColor),
|
||||
),
|
||||
title: Text(
|
||||
employee.name ?? '未入力',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (employee.department.isNotEmpty) Text('部署:${employee.department}', style: const TextStyle(fontSize: 12)),
|
||||
if (employee.role.isNotEmpty) Text('役職:${employee.role}', style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
trailing: employee.email.isNotEmpty ? Text(employee.email, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 10)) : null,
|
||||
onTap: () => onSelected(employee),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// Version: 3.0 - リッチマスター編集ダイアログ(簡素版、全てのマスタで共通使用)
|
||||
// Version: 3.5 - リッチマスター編集ダイアログ(改良版)
|
||||
// ※ 汎用性の高いリッチなマスター編集ダイアログ(全てのマスタで共通使用)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/product.dart';
|
||||
import '../services/database_helper.dart';
|
||||
|
||||
/// 汎用性の高いリッチなマスター編集ダイアログ(簡素版)
|
||||
/// 汎用性の高いリッチなマスター編集ダイアログ
|
||||
class MasterEditDialog<T extends Product> extends StatefulWidget {
|
||||
final String title;
|
||||
final T? initialData;
|
||||
|
|
@ -25,65 +25,238 @@ class MasterEditDialog<T extends Product> extends StatefulWidget {
|
|||
class _MasterEditDialogState extends State<MasterEditDialog> {
|
||||
late TextEditingController codeController;
|
||||
late TextEditingController nameController;
|
||||
late TextEditingController addressController;
|
||||
late TextEditingController phoneController;
|
||||
late TextEditingController emailController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final data = widget.initialData;
|
||||
// デフォルトのサンプル値
|
||||
codeController = TextEditingController(text: data?.productCode ?? '');
|
||||
nameController = TextEditingController(text: data?.name ?? '');
|
||||
addressController = TextEditingController(text: data?.address ?? '');
|
||||
phoneController = TextEditingController(text: data?.phone ?? '');
|
||||
emailController = TextEditingController(text: data?.email ?? '');
|
||||
}
|
||||
|
||||
bool showStatusField() => widget.showStatusFields;
|
||||
@override
|
||||
void dispose() {
|
||||
codeController.dispose();
|
||||
nameController.dispose();
|
||||
addressController.dispose();
|
||||
phoneController.dispose();
|
||||
emailController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildEditField(String label, TextEditingController controller) {
|
||||
/// リッチな入力フィールドビルダー
|
||||
Widget _buildRichTextField(
|
||||
String label,
|
||||
TextEditingController controller, {
|
||||
TextInputType? keyboard,
|
||||
IconData? icon,
|
||||
String hint = '',
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextField(controller: controller, decoration: InputDecoration(hintText: '入力をここに', border: OutlineInputBorder())),
|
||||
],),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.grey.shade700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboard,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint.isEmpty ? null : hint,
|
||||
prefixIcon: Icon(icon, size: 16, color: Theme.of(context).primaryColor),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(top: 8)),
|
||||
child: const Text('キャンセル'),
|
||||
return Dialog(
|
||||
backgroundColor: Colors.white,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// タイトル
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.edit, size: 20, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
)),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ヒントテキスト
|
||||
Center(
|
||||
child: Text(
|
||||
'新規作成の場合は「空白」から入力して OK を押してください',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500, fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// リッチな編集フォーム
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 基本情報セクション
|
||||
Text(
|
||||
'■ 基本情報',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// コードフィールド
|
||||
_buildRichTextField(
|
||||
'コード *',
|
||||
codeController,
|
||||
keyboard: TextInputType.text,
|
||||
icon: Icons.code,
|
||||
hint: e.g., 'P001',
|
||||
),
|
||||
|
||||
// 名称フィールド
|
||||
_buildRichTextField(
|
||||
'商品名 / 会社名 *',
|
||||
nameController,
|
||||
keyboard: TextInputType.name,
|
||||
icon: Icons.business,
|
||||
hint: e.g., 'サンプル製品 A',
|
||||
),
|
||||
|
||||
// アドレスフィールド(オプション)
|
||||
_buildRichTextField(
|
||||
'住所',
|
||||
addressController,
|
||||
keyboard: TextInputType.text,
|
||||
icon: Icons.location_on,
|
||||
hint: '省略可',
|
||||
),
|
||||
|
||||
if (widget.showStatusFields) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
const SizedBox(height: 4),
|
||||
// ステータス情報セクション
|
||||
Text(
|
||||
'■ ステータス情報',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_buildRichTextField(
|
||||
'電話番号',
|
||||
phoneController,
|
||||
keyboard: TextInputType.phone,
|
||||
icon: Icons.phone,
|
||||
hint: '03-1234-5678',
|
||||
),
|
||||
|
||||
_buildRichTextField(
|
||||
'E メール',
|
||||
emailController,
|
||||
keyboard: TextInputType.emailAddress,
|
||||
icon: Icons.email,
|
||||
hint: 'example@example.com',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// アクションボタン(Flex で配置)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(' キャンセル ', textAlign: TextAlign.center, style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 3, // より広いボタン
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// TODO: onSave コールバックを実装
|
||||
if (widget.onSave != null) {
|
||||
widget.onSave!(widget.initialData as T);
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(' 保存 ', textAlign: TextAlign.center, style: TextStyle(fontSize: 15)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
_buildEditField('製品コード *', codeController),
|
||||
|
||||
_buildEditField('会社名 *', nameController),
|
||||
|
||||
if (widget.showStatusFields) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300)),
|
||||
child: const Text('ステータス管理(簡素版)」', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, widget.onSave?.call(widget.initialData! as T)),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 参照マスタ選択ダイアログ(簡素版)
|
||||
/// 参照マスタ選択ダイアログ(リッチ版)
|
||||
class SingleChoiceDialog extends StatelessWidget {
|
||||
final List<Product> items;
|
||||
final Function() onCancel;
|
||||
|
|
@ -93,12 +266,48 @@ class SingleChoiceDialog extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const Text('検索結果がありません');
|
||||
if (items.isEmpty) return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 16),
|
||||
Text('検索結果がありません', style: TextStyle(color: Colors.grey, fontSize: 18)),
|
||||
const SizedBox(height: 8),
|
||||
Text('マスタデータが登録されていないため\n参照できません', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey.shade500)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, index) => ListTile(title: Text(items[index].name ?? '未入力'), onTap: () => onSelected(items[index])),
|
||||
return Dialog(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
child: Icon(Icons.inventory_2, color: Theme.of(context).primaryColor),
|
||||
),
|
||||
title: Text(
|
||||
item.name ?? '未入力',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(item.productCode ?? '', style: TextStyle(fontSize: 12)),
|
||||
onTap: () => onSelected(item),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue