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:
joe 2026-03-11 20:01:36 +09:00
parent c33d117ef5
commit 8f1df14b7b
13 changed files with 1466 additions and 574 deletions

File diff suppressed because one or more lines are too long

View 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)),
],
),
),
);
},
),
),
],
),
);
}
}

View 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)),
),
),
],
),
],
),
),
),
);
}
}

View file

@ -1,233 +1,129 @@
# 短期計画Sprint Plan- H-1Q プロジェクト
# 少プロジェクト短期実装計画(担当者に限定版)
## 1. スプリント概要
## 1. プロジェクト概要
| 項目 | 内容 |
|---|---|
| **開発コード** | **H-1Q販売アシスト 1 号)**✅NEW |
| **スプリント期間** | **2026/03/09 - 2026/03/23 → Sprint 5H-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
#### 🎯 **見積→請求転換 UIH-1Q-Sprint 4実装** ✅✅NEW
- [x] estimate_screen.dart に転換ボタン追加✅NEW
- [x] DatabaseHelper.insertInvoice API の重複チェック実装✅NEW
- [x] Estimate から Invoice へのデータ転換ロジック実装✅NEW
- [x] UI: 転換完了通知 + 請求書画面遷移案内✅NEW
**担当**: Estimate チーム
**工期**: **2026/03/09H-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. テックスタック
### 📋 完了タスク一覧
- ✅ 見積→請求転換 UIestimate_screen.dart に転換ボタン追加)✅
- ✅ Invoice テーブル CRUD APIinsert/get/update/delete
- ✅ DocumentDirectory 自動保存機能実装✅
- ✅ Inventory モデル定義完了✅
### 📊 進捗状況
- **完了**: **85%**(請求転換 UI + 在庫モデル + DocumentDirectory✅H-1Q
- **進行中**: クラウド同期要件定義🔄
- **未着手**: PDF 領収書テンプレート⏳
| カテゴリ | ツール |
|---------|--------|
| State Management | setState (シンプル) |
| フォーム編集 | TextField + TextEditingController |
| ダイアログ | AlertDialog で標準ダイアログ利用 |
| データ永続化 | 当面はメモリ保持(後日 Sqflite |
| ロギング | 簡易な print 出力 |
---
## 9. **Sprint 6: H-1Q2026/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.mdM1-M3 マイルストーン記録✅H-1Q
- test/widget_test.dartテストカバレッジレポート
- sales_invoice_template.dartPDF テンプレート設計書✅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
View 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)';
}

View 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)';
}

View file

@ -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('登録完了')));
},
),
);
}

View file

@ -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;
}
}

View file

@ -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('登録完了')));
},
),
);
}

View file

@ -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'] ?? '未入力'),
),
);
},

View file

@ -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';
}

View 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),
);
},
),
),
);
}
}

View file

@ -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),
);
},
),
),
);
}
}