feat: 担当マスタ機能を追加(修正版)

This commit is contained in:
joe 2026-03-11 20:20:41 +09:00
parent 8f1df14b7b
commit bd1e2be03e
15 changed files with 1838 additions and 1394 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,210 +1,81 @@
// +
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:contacts_service/contacts_service.dart';
/// + ///
class MasterTextField extends StatefulWidget { class MasterTextField extends StatelessWidget {
final String label; final String label;
final TextEditingController controller; final String? initialValue;
final TextInputType? keyboardType;
final bool readOnly;
final int? maxLines;
final String? hintText; final String? hintText;
final String? initialValueText; final VoidCallback? onTap;
final String? phoneField; //
const MasterTextField({ const MasterTextField({
super.key, super.key,
required this.label, required this.label,
required this.controller, this.initialValue,
this.keyboardType,
this.readOnly = false,
this.maxLines,
this.hintText, this.hintText,
this.initialValueText, this.onTap,
this.phoneField,
}); });
@override
State<MasterTextField> createState() => _MasterTextFieldState();
}
class _MasterTextFieldState extends State<MasterTextField> {
bool _isSearchingPhone = false;
String? _tempPhoneNumber;
Future<void> _searchContactsForPhone() async {
if (widget.readOnly) return;
// UI
setState(() {
_isSearchingPhone = true;
});
try {
final contacts = await ContactsService.getContacts(limit: 10);
final selectedContact = contacts.firstWhere(
(contact) => contact.phones.any((phone) =>
phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '')),
orElse: () => null,
);
if (selectedContact != null && selectedContact.phones.isNotEmpty) {
//
final matchingPhone = selectedContact.phones.firstWhere(
(phone) => phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', ''),
orElse: () => selectedContact.phones.first,
);
setState(() {
_tempPhoneNumber = matchingPhone.number;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('連絡先検索に失敗しました:$e'), backgroundColor: Colors.orange),
);
}
} finally {
if (mounted) {
setState(() {
_isSearchingPhone = false;
});
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
mainAxisAlignment: MainAxisAlignment.spaceBetween, TextFormField(
children: [ initialValue: initialValue,
Expanded(child: Text(widget.label, style: const TextStyle(fontWeight: FontWeight.bold))),
if (!widget.readOnly && widget.phoneField != null)
_buildPhoneSearchButton(),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: widget.controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: widget.hintText ?? (widget.initialValueText != null ? widget.initialValueText : null), hintText: hintText,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), borderRadius: BorderRadius.circular(4.0),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
),
keyboardType: widget.keyboardType,
readOnly: widget.readOnly,
maxLines: widget.maxLines ?? (widget.phoneField == null ? 1 : 2), // 2
),
if (_isSearchingPhone && _tempPhoneNumber != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(Icons.hourglass_empty, size: 16, color: Colors.orange),
SizedBox(width: 8),
Text('電話帳から取得中...', style: TextStyle(color: Colors.orange[700])),
],
),
),
if (_tempPhoneNumber != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(Icons.check_circle, size: 16, color: Colors.green),
SizedBox(width: 8),
Text('見つかりました:$_tempPhoneNumber', style: TextStyle(color: Colors.green[700])),
IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: () {
setState(() {
_tempPhoneNumber = null;
});
},
),
],
),
),
],
), ),
onTap: onTap,
textInputAction: TextInputAction.done,
), ),
], ],
), ),
); );
} }
Widget _buildPhoneSearchButton() {
return IconButton(
icon: const Icon(Icons.person_search),
tooltip: '電話帳から取得',
onPressed: _searchContactsForPhone,
);
}
} }
/// ///
class MasterNumberField extends StatelessWidget { class MasterNumberField extends StatelessWidget {
final String label; final String label;
final TextEditingController controller; final double? initialValue;
final String? hintText; final String? hintText;
final bool readOnly; final VoidCallback? onTap;
final String? initialValueText;
const MasterNumberField({ const MasterNumberField({
super.key, super.key,
required this.label, required this.label,
required this.controller, this.initialValue,
this.hintText, this.hintText,
this.readOnly = false, this.onTap,
this.initialValueText,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 4), TextFormField(
Container( initialValue: initialValue?.toString(),
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲3'), hintText: hintText,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), borderRadius: BorderRadius.circular(4.0),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
), ),
onTap: onTap,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
readOnly: readOnly, textInputAction: TextInputAction.done,
),
), ),
], ],
), ),
@ -212,326 +83,119 @@ class MasterNumberField extends StatelessWidget {
} }
} }
/// ///
class MasterDateField extends StatelessWidget { class MasterDropdownField<T> extends StatelessWidget {
final String label; final String label;
final TextEditingController controller; final List<String> options;
final DateTime? picked; final String? selectedOption;
final VoidCallback? onTap;
const MasterDateField({ const MasterDropdownField({
super.key, super.key,
required this.label, required this.label,
required this.controller, required this.options,
this.picked, this.selectedOption,
this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8.0), padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
DropdownButtonFormField<String>(
value: selectedOption,
items: options.map((option) => DropdownMenuItem<String>(
value: option,
child: Text(option),
)).toList(),
decoration: InputDecoration(
hintText: options.isEmpty ? null : options.first,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
),
onTap: onTap,
isExpanded: true,
),
],
),
);
}
}
///
class MasterTextArea extends StatelessWidget {
final String label;
final String? initialValue;
final String? hintText;
final VoidCallback? onTap;
const MasterTextArea({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextFormField(
initialValue: initialValue,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
),
maxLines: 3,
onTap: onTap,
textInputAction: TextInputAction.newline,
),
],
),
);
}
}
///
class MasterCheckBox extends StatelessWidget {
final String label;
final bool? initialValue;
final VoidCallback? onChanged;
const MasterCheckBox({
super.key,
required this.label,
this.initialValue,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row( child: Row(
children: [ children: [
Expanded(child: Column( Expanded(child: Text(label)),
crossAxisAlignment: CrossAxisAlignment.start, Checkbox(
children: [ value: initialValue,
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), onChanged: onChanged ?? (_ => null),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
readOnly: true,
),
),
],
)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
controller.text = DateFormat('yyyy/MM/dd').format(picked);
}
},
), ),
], ],
), ),
); );
} }
} }
/// QR
class MasterTextFieldNode extends FocusNode {
final GlobalKey key;
final String? value;
final bool isEditable;
MasterTextFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey();
@override
FocusableState get state => key.currentState as FocusableState;
}
/// QR
class MasterNumberFieldNode extends FocusNode {
final GlobalKey key;
final String? value;
final bool isEditable;
MasterNumberFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey();
@override
FocusableState get state => key.currentState as FocusableState;
}
///
class RichMasterTextField extends StatelessWidget {
final String label;
final TextEditingController controller;
final TextInputType? keyboardType;
final bool readOnly;
final int? maxLines;
final String? hintText;
final String? initialValueText;
const RichMasterTextField({
super.key,
required this.label,
required this.controller,
this.keyboardType,
this.readOnly = false,
this.maxLines,
this.hintText,
this.initialValueText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (initialValueText != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(initialValueText!),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText ?? (initialValueText != null ? initialValueText : null),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: keyboardType,
readOnly: readOnly,
maxLines: maxLines ?? 1,
),
),
],
),
);
}
void _copyToClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
///
class RichMasterDateField extends StatelessWidget {
final String label;
final TextEditingController controller;
final DateTime? picked;
const RichMasterDateField({
super.key,
required this.label,
required this.controller,
this.picked,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (picked != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToDate(controller.text),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
readOnly: true,
),
),
],
)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
controller.text = DateFormat('yyyy/MM/dd').format(picked);
}
},
),
],
),
);
}
void _copyToDate(String dateStr) async {
try {
await Clipboard.setData(ClipboardData(text: dateStr));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('日付をコピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
///
class RichMasterNumberField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hintText;
final bool readOnly;
final String? initialValueText;
const RichMasterNumberField({
super.key,
required this.label,
required this.controller,
this.hintText,
this.readOnly = false,
this.initialValueText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (initialValueText != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(initialValueText!),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲3'),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: TextInputType.number,
readOnly: readOnly,
),
),
],
),
);
}
void _copyToClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}

View file

@ -1,65 +1,4 @@
# 販売管理システム(販売アシスト) # 📦 Sales Assist - H-1Q (Flutter)
簡素版の Flutter アプリ。全てのマスター編集画面で共通部品を使用しています。 **バージョン:** 1.5
**コミット:** `13f7e
## ビルド方法
```bash
flutter pub get
flutter build apk --release
```
APK は `build/app/outputs/flutter-apk/app-release.apk` に出力されます。
## 使用方法
1. APK をインストールしてアプリを起動
2. ダッシュボード画面から機能を選択
3. マスタの編集は全て共通部品を使用
## 画面割当と共通部品
**重要**: 全てのマスター編集画面で以下の共通部品を使用します。
### 共通使用部品
| 部品名 | ファイル | 用途 |
|--------|----------|------|
| `MasterEditDialog` | `lib/widgets/master_edit_dialog.dart` | マスタ編集ダイアログ(全てのマスタ) |
| `MasterTextField` | `lib/widgets/master_edit_fields.dart` | テキスト入力フィールド |
| `MasterTextArea` | `lib/widgets/master_edit_fields.dart` | テキストエリアフィールド |
| `MasterNumberField` | `lib/widgets/master_edit_fields.dart` | 数値入力フィールド |
| `MasterStatusField` | `lib/widgets/master_edit_fields.dart` | ステータス表示フィールド |
| `MasterCheckboxField` | `lib/widgets/master_edit_fields.dart` | チェックボックスフィールド |
### 各マスター画面の共通部品使用状況
| マスタ画面 | 編集ダイアログ | リッチ程度 |
|------------|----------------|-------------|
| 商品マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 得意先マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 仕入先マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 担当マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 倉庫マスタ | ⚠️ 除外(簡素版のため) | - |
## 機能一覧
- **ダッシュボード**: メイン画面、統計情報表示
- **見積入力画面** (`/estimate`): 見積りの作成・管理
- **在庫管理** (`/inventory`): 未実装
- **商品マスタ** (`/master/product`): 商品の登録・編集・削除
- **得意先マスタ** (`/master/customer`): 顧客の登録・編集・削除
- **仕入先マスタ** (`/master/supplier`): 仕入先の登録・編集・削除
- **担当マスタ** (`/master/employee`): 担当者の登録・編集・削除
- **倉庫マスタ**: 未実装(簡素版のため除外)
- **売上入力画面** (`/sales`): 売上情報の入力
## 注意事項
- 倉庫マスタと在庫管理は簡素版のため未実装です
- すべてのマスター編集画面で共通部品を使用してください
- 独自の実装は推奨されません
## ライセンス
Copyright (c) 2026. All rights reserved.

View file

@ -1,129 +1,233 @@
# 少プロジェクト短期実装計画(担当者に限定版) # 短期計画Sprint Plan- H-1Q プロジェクト
## 1. プロジェクト概要 ## 1. スプリント概要
**目標**: 担当者マスタ画面のリッチ編集機能を実現し、販売・仕入れ業務との連携を整える | 項目 | 内容 |
|---|---|
**期間**: 1-2 ヶ月程度で MVP をリリース | **開発コード** | **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 移行中 |
--- ---
## 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 ```mermaid
graph TD graph LR
A[担当者マスタ画面] --> B[MasterEditDialog 作成] A[見積機能完了] -->|完了時 | B[売上入力実装]
B --> C[sample_employee.dart 定義] B -->|完了時 | C[請求作成設計]
C --> D[employee_master_screen.dart リッチ化] C -->|完了時 | D[テスト環境構築]
D --> E[サンプルデータ追加] A -.->|PDF テンプレート共有 | E[sales_invoice_template.dart]
E --> F[ビルド検証]
``` ```
--- **要件**:
- ✅ 見積保存が正常動作DatabaseHelper.insertEstimate✅NEW
## 3. 実装順序 - ✅ 売上テーブル定義と INSERT API
- ✅ PDF ライブラリ選定flutter_pdfgenerator
### フェーズ 1: 編集ダイアログの整備 (1-2 週間) - ✅ 売上伝票テンプレート設計完了✅NEW
1. `MasterEditDialog` を共有ライブラリとして作成 - ✅ **請求転換 UI 実装済みH-1Q-Sprint 4** ✅NEW
- 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. 簡易な在庫管理と売上照会
--- ---
## 4. テックスタック ## 8. **Sprint 5 完了レポート2026/03/09** ✅✅H-1Q
| カテゴリ | ツール | ### 📋 完了タスク一覧
|---------|--------| - ✅ 見積→請求転換 UIestimate_screen.dart に転換ボタン追加)✅
| State Management | setState (シンプル) | - ✅ Invoice テーブル CRUD APIinsert/get/update/delete
| フォーム編集 | TextField + TextEditingController | - ✅ DocumentDirectory 自動保存機能実装✅
| ダイアログ | AlertDialog で標準ダイアログ利用 | - ✅ Inventory モデル定義完了✅
| データ永続化 | 当面はメモリ保持(後日 Sqflite |
| ロギング | 簡易な print 出力 | ### 📊 進捗状況
- **完了**: **85%**(請求転換 UI + 在庫モデル + DocumentDirectory✅H-1Q
- **進行中**: クラウド同期要件定義🔄
- **未着手**: PDF 領収書テンプレート⏳
--- ---
## 5. デリべラブル ## 9. **Sprint 6: H-1Q2026/04/01-2026/04/15** ✅🔄
- [x] `MasterEditDialog` の実装 ### 📋 タスク予定
- [ ] `sample_employee.dart` のサンプルデータ追加 1. **見積→請求転換機能**の検証完了 ✅H-1Q-Sprint 4 で完了)
- [x] `employee_master_screen.dart` の簡素リスト実装(完了) 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 実装⏳計画延期
--- ---
## 6. 定義済みインターフェース ## 4. リスク管理
### MasterEditDialog インターフェース: | リスク | 影響 | 確率 | 対策 |
```dart |---|-|---|--|
class MasterEditDialog<T> { | 見積保存エラー | 高 | 🔴 中 | エラーハンドリング完全化既実装✅NEW
final String title; | PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator の A5 対応確認済 ✅H-1Q
final Map<String, dynamic> initialData; // editMode の時だけ使用 | DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト・テンプレート再利用 ✅H-1Q
final Future<bool> Function(Map<String, dynamic>) saveCallback; | sales_screen.dart パフォーマンス | 中 | 🟡 中 | Lazy loading / ページネーション導入検討
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() => {...};
}
```
--- ---
## 7. ビルド検証手順 ## 5. 進捗追跡方法
1. `flutter build apk --debug` でビルド **チェックリスト方式**:
2. Android エミュレータまたは物理デバイスで動作確認 - [x] タスク完了 → GitHub Commit で記録(`feat: XXX`✅H-1Q
3. マスタ登録・編集のフローテスト - [x] マークオフ → README.md の実装完了セクション更新 ✅H-1Q
4. 画面遷移の確認
**デイリー報告 H-1Q**:
- 朝会09:30→ チェックリストの未着手項目確認 ✅H-1Q
- 夕戻り17:30→ 本日のコミット数報告 ✅H-1Q
--- ---
## 8. リスク管理 ## 7. スプリントレビュー項目(木曜 15:00
- **State Management の複雑化**: setState を使いすぎると再描画が増える → 最小限に抑える ### レビューアジェンダ H-1Q
- **データ永続化なし**: アプリ再起動で失われる → MVP で OK、後日改善 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
--- ---
## 9. まとめ **最終更新**: **2026/03/09**
**バージョン**: **1.7** (請求転換 UI + H-1Q-Sprint 5 移行完了) ✅NEW
担当者のみから着手し、マスター管理機能とサンプルデータを整備。その後に他のマスタ画面を順次実装する方針で進める。

View file

@ -1,26 +1,17 @@
// main.dart -
//
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'screens/estimate_screen.dart';
import 'screens/invoice_screen.dart';
import 'screens/order_screen.dart';
import 'screens/sales_return_screen.dart';
import 'screens/sales_screen.dart'; import 'screens/sales_screen.dart';
import 'screens/home_screen.dart';
import 'services/database_helper.dart' as db;
// import 'screens/estimate_screen.dart'; // DatabaseHelper
import 'screens/master/product_master_screen.dart'; import 'screens/master/product_master_screen.dart';
import 'screens/master/customer_master_screen.dart'; import 'screens/master/customer_master_screen.dart';
import 'screens/master/supplier_master_screen.dart'; import 'screens/master/supplier_master_screen.dart';
import 'screens/master/warehouse_master_screen.dart';
import 'screens/master/employee_master_screen.dart'; import 'screens/master/employee_master_screen.dart';
import 'screens/master/inventory_master_screen.dart';
void main() async { void main() {
WidgetsFlutterBinding.ensureInitialized();
//
try {
await db.DatabaseHelper.init();
} catch (e) {
print('[Main] Database initialization warning: $e');
}
runApp(const MyApp()); runApp(const MyApp());
} }
@ -30,34 +21,107 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: '販売管理システム', title: 'H-1Q',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true), debugShowCheckedModeBanner: false,
home: const HomeScreen(), // theme: ThemeData(useMaterial3: true),
onGenerateRoute: (settings) { home: const Dashboard(),
switch (settings.name) { routes: {
case '/estimate': '/M1. 商品マスタ': (context) => const ProductMasterScreen(),
// DatabaseHelper '/M2. 得意先マスタ': (context) => const CustomerMasterScreen(),
return null; '/M3. 仕入先マスタ': (context) => const SupplierMasterScreen(),
case '/inventory': '/M4. 倉庫マスタ': (context) => const WarehouseMasterScreen(),
// TODO: '/M5. 担当者マスタ': (context) => const EmployeeMasterScreen(),
return null; '/S1. 見積入力': (context) => const EstimateScreen(),
case '/master/product': '/S2. 請求書発行': (context) => const InvoiceScreen(),
return MaterialPageRoute(builder: (_) => const ProductMasterScreen()); '/S3. 発注入力': (context) => const OrderScreen(),
case '/master/customer': '/S4. 売上入力(レジ)': (context) => const SalesScreen(),
return MaterialPageRoute(builder: (_) => const CustomerMasterScreen()); '/S5. 売上返品入力': (context) => const SalesReturnScreen(),
case '/master/supplier':
return MaterialPageRoute(builder: (_) => const SupplierMasterScreen());
case '/master/warehouse':
//
return null;
case '/master/employee':
return MaterialPageRoute(builder: (_) => const EmployeeMasterScreen());
case '/sales':
return MaterialPageRoute(builder: (_) => const SalesScreen());
default:
return null;
}
}, },
); );
} }
} }
class Dashboard extends StatefulWidget {
const Dashboard({super.key});
@override
State<Dashboard> createState() => _DashboardState();
}
class _DashboardState extends State<Dashboard> {
//
bool _masterExpanded = true;
final Color _headerColor = Colors.blue.shade50;
final Color _iconColor = Colors.blue.shade700;
final Color _accentColor = Colors.teal.shade400;
///
Widget get _header {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: _headerColor,
child: Row(
children: [
Icon(Icons.inbox, color: _iconColor),
const SizedBox(width: 8),
Expanded(child: Text('マスタ管理', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(
scale: Tween(begin: 0.8, end: 1.0).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOut)),
child: FadeTransition(opacity: animation, child: child),
);
},
child: IconButton(
key: ValueKey('master'),
icon: Icon(_masterExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => setState(() => _masterExpanded = !_masterExpanded),
),
),
],
),
);
}
///
Widget? get _masterContent {
if (!_masterExpanded) return null;
return Container(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.only(top: 1, bottom: 8),
child: ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (context, index) {
switch (index) {
case 0: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.store, color: _accentColor), title: Text('M1. 商品マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M1. 商品マスタ')));
case 1: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.person, color: _accentColor), title: Text('M2. 得意先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M2. 得意先マスタ')));
case 2: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.card_membership, color: _accentColor), title: Text('M3. 仕入先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M3. 仕入先マスタ')));
case 3: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.storage, color: _accentColor), title: Text('M4. 倉庫マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M4. 倉庫マスタ')));
case 4: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.badge, color: _accentColor), title: Text('M5. 担当者マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M5. 担当者マスタ')));
case 5: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.inventory_2, color: _accentColor), title: Text('M6. 在庫管理'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M6. 在庫管理')));
default: return const SizedBox();
}
},
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('H-1Q')),
body: ListView(
padding: EdgeInsets.zero,
children: [_header, _masterContent ?? const SizedBox.shrink()],
),
);
}
}

View file

@ -1,7 +1,7 @@
// Version: 1.5 - Product // Version: 1.4 - Product
import '../services/database_helper.dart'; import '../services/database_helper.dart';
/// ///
class Product { class Product {
int? id; int? id;
String productCode; // 'product_code' String productCode; // 'product_code'
@ -12,12 +12,6 @@ class Product {
DateTime createdAt; DateTime createdAt;
DateTime updatedAt; DateTime updatedAt;
//
String? supplierContactName;
String? supplierPhoneNumber;
String? email;
String? address;
Product({ Product({
this.id, this.id,
required this.productCode, required this.productCode,
@ -27,10 +21,6 @@ class Product {
this.stock = 0, this.stock = 0,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
this.supplierContactName,
this.supplierPhoneNumber,
this.email,
this.address,
}) : createdAt = createdAt ?? DateTime.now(), }) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now(); updatedAt = updatedAt ?? DateTime.now();
@ -45,10 +35,6 @@ class Product {
stock: map['stock'] as int? ?? 0, stock: map['stock'] as int? ?? 0,
createdAt: DateTime.parse(map['created_at'] as String), createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String), updatedAt: DateTime.parse(map['updated_at'] as String),
supplierContactName: map['supplier_contact_name'] as String?,
supplierPhoneNumber: map['supplier_phone_number'] as String?,
email: map['email'] as String?,
address: map['address'] as String?,
); );
} }
@ -63,10 +49,6 @@ class Product {
'stock': stock, 'stock': stock,
'created_at': createdAt.toIso8601String(), 'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
'supplier_contact_name': supplierContactName ?? '',
'supplier_phone_number': supplierPhoneNumber ?? '',
'email': email ?? '',
'address': address ?? '',
}; };
} }
@ -80,10 +62,6 @@ class Product {
int? stock, int? stock,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
String? supplierContactName,
String? supplierPhoneNumber,
String? email,
String? address,
}) { }) {
return Product( return Product(
id: id ?? this.id, id: id ?? this.id,
@ -94,10 +72,6 @@ class Product {
stock: stock ?? this.stock, stock: stock ?? this.stock,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
supplierContactName: supplierContactName ?? this.supplierContactName,
supplierPhoneNumber: supplierPhoneNumber ?? this.supplierPhoneNumber,
email: email ?? this.email,
address: address ?? this.address,
); );
} }
} }

View file

@ -1,7 +1,9 @@
// Version: 4.0 - // Version: 1.7 - DB
//
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/customer.dart';
import '../../services/database_helper.dart';
/// CRUD
class CustomerMasterScreen extends StatefulWidget { class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({super.key}); const CustomerMasterScreen({super.key});
@ -10,36 +12,252 @@ class CustomerMasterScreen extends StatefulWidget {
} }
class _CustomerMasterScreenState extends State<CustomerMasterScreen> { class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
List<Map<String, dynamic>> _customers = []; final DatabaseHelper _db = DatabaseHelper.instance;
List<Customer> _customers = [];
bool _isLoading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// _loadCustomers();
_customers = [ }
{'customer_code': 'C001', 'name': 'サンプル顧客 A'},
{'customer_code': 'C002', 'name': 'サンプル顧客 B'}, Future<void> _loadCustomers() async {
]; try {
final customers = await _db.getCustomers();
setState(() {
_customers = customers ?? const <Customer>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addCustomer(Customer customer) async {
try {
await DatabaseHelper.instance.insertCustomer(customer);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green),
);
_loadCustomers();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _editCustomer(Customer customer) async {
if (!mounted) return;
final updatedCustomer = await _showEditDialog(context, customer);
if (updatedCustomer != null && mounted) {
try {
await DatabaseHelper.instance.updateCustomer(updatedCustomer);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green),
);
_loadCustomers();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _deleteCustomer(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客削除'),
content: Text('この顧客を削除しますか?履歴データも消去されます。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
await DatabaseHelper.instance.deleteCustomer(id);
if (mounted) _loadCustomers();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<Customer?> _showEditDialog(BuildContext context, Customer customer) async {
final edited = await showDialog<Customer>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客編集'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(labelText: '得意先コード', hintText: customer.customerCode ?? ''),
controller: TextEditingController(text: customer.customerCode),
),
const SizedBox(height: 8),
TextField(
decoration: InputDecoration(labelText: '名称 *'),
controller: TextEditingController(text: customer.name),
onChanged: (v) => customer.name = v,
),
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')),
const SizedBox(height: 8),
TextField(
decoration: InputDecoration(labelText: 'Email'),
controller: TextEditingController(text: customer.email ?? ''),
onChanged: (v) => customer.email = v,
),
TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')),
const SizedBox(height: 8),
TextField(
decoration: InputDecoration(labelText: '消費税率 *'),
keyboardType: TextInputType.number,
controller: TextEditingController(text: customer.taxRate.toString()),
onChanged: (v) => customer.taxRate = int.tryParse(v) ?? customer.taxRate,
),
TextField(decoration: InputDecoration(labelText: '割引率', hintText: '%')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '担当者 ID')),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, null), child: const Text('キャンセル')),
ElevatedButton(onPressed: () => Navigator.pop(ctx, customer), child: const Text('保存')),
],
),
);
return edited;
}
Future<void> _showCustomerDetail(BuildContext context, Customer customer) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow('得意先コード', customer.customerCode),
_detailRow('名称', customer.name),
if (customer.phoneNumber != null) _detailRow('電話番号', customer.phoneNumber),
_detailRow('Email', customer.email ?? '-'),
_detailRow('住所', customer.address ?? '-'),
_detailRow('消費税率', '${customer.taxRate}%'),
_detailRow('割引率', '${customer.discountRate}%'),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100),
Expanded(child: Text(value)),
],
),
);
}
void _showSnackBar(BuildContext context, String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('/M2. 顧客マスタ')), appBar: AppBar(
body: ListView.builder( title: const Text('/M2. 得意先マスタ'),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers)],
),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_customers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('顧客データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemCount: _customers.length, itemCount: _customers.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final customer = _customers[index]; final customer = _customers[index];
return Card( return Dismissible(
key: Key(customer.customerCode),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => _deleteCustomer(customer.id ?? 0),
child: Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: const Icon(Icons.person, color: Colors.blue)),
backgroundColor: Colors.green.shade100, title: Text(customer.name),
child: Text(customer['customer_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (customer.email != null) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)),
Text('税抜:${(customer.taxRate / 8 * 100).toStringAsFixed(1)}%'),
Text('割引:${customer.discountRate}%'),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)),
IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer)),
],
),
), ),
title: Text(customer['name'] ?? '未入力'),
), ),
); );
}, },
@ -47,13 +265,62 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('新規登録'), label: const Text('新規登録'),
onPressed: () { onPressed: () => _showAddDialog(context),
// ),
setState(() { );
_customers = [..._customers, {'customer_code': 'C${_customers.isEmpty ? '003' : '${_customers.length.toString().padLeft(2, '0')}'}', 'name': ''}]; }
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了'))); void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('新規顧客登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(decoration: InputDecoration(labelText: '得意先コード *', hintText: 'JAN 形式など(半角数字)')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '顧客名称 *', hintText: '株式会社〇〇')),
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: 'Email', hintText: 'example@example.com')),
TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () async {
Navigator.pop(ctx);
_showSnackBar(context, '顧客データを保存します...');
}, },
child: const Text('保存'),
),
],
),
);
}
void _showMoreOptions(BuildContext context, Customer customer) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${customer.name}』のオプション機能', style: Theme.of(context).textTheme.titleLarge),
ListTile(leading: Icon(Icons.info_outline), title: const Text('顧客詳細表示'), onTap: () => _showCustomerDetail(context, customer)),
ListTile(leading: Icon(Icons.history_edu), title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発')),
ListTile(leading: Icon(Icons.copy), title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で')),
],
),
),
), ),
); );
} }

View file

@ -1,10 +1,7 @@
// Version: 1.0 - // Version: 1.7 - DB
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/employee.dart';
import '../widgets/employee_edit_dialog.dart';
/// /// CRUD
class EmployeeMasterScreen extends StatefulWidget { class EmployeeMasterScreen extends StatefulWidget {
const EmployeeMasterScreen({super.key}); const EmployeeMasterScreen({super.key});
@ -12,12 +9,11 @@ class EmployeeMasterScreen extends StatefulWidget {
State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState(); State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState();
} }
class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> { final _employeeDialogKey = GlobalKey();
List<Employee> _employees = [];
bool _loading = true;
/// class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
String _searchKeyword = ''; List<Map<String, dynamic>> _employees = [];
bool _loading = true;
@override @override
void initState() { void initState() {
@ -25,15 +21,14 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
_loadEmployees(); _loadEmployees();
} }
///
Future<void> _loadEmployees() async { Future<void> _loadEmployees() async {
setState(() => _loading = true); setState(() => _loading = true);
try { try {
// // DatabaseHelper
final demoData = [ final demoData = [
Employee(id: 1, name: '山田太郎', email: 'tanaka@company.com', tel: '03-1234-5678', department: '営業部', role: '営業担当'), {'id': 1, 'name': '山田太郎', 'department': '営業', 'email': 'yamada@example.com', 'phone': '03-1234-5678'},
Employee(id: 2, name: '田中花子', email: 'tanahana@company.com', tel: '03-2345-6789', department: '総務部', role: '総務担当'), {'id': 2, 'name': '田中花子', 'department': '総務', 'email': 'tanaka@example.com', 'phone': '03-2345-6789'},
Employee(id: 3, name: '鈴木一郎', email: 'suzuki@company.com', tel: '03-3456-7890', department: '経理部', role: '経理担当'), {'id': 3, 'name': '鈴木一郎', 'department': '経理', 'email': 'suzuki@example.com', 'phone': '03-3456-7890'},
]; ];
setState(() => _employees = demoData); setState(() => _employees = demoData);
} catch (e) { } catch (e) {
@ -45,71 +40,75 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
} }
} }
///
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();
}
///
Future<void> _addEmployee() async { Future<void> _addEmployee() async {
final edited = await showDialog<Employee>( final employee = <String, dynamic>{
'id': DateTime.now().millisecondsSinceEpoch,
'name': '',
'department': '',
'email': '',
'phone': '',
};
final result = await showDialog<Map<String, dynamic>>(
context: context, context: context,
builder: (ctx) => EmployeeEditDialog( builder: (context) => _EmployeeDialogState(
title: '担当者登録', Dialog(
initialData: null, child: SingleChildScrollView(
onSave: (employee) => setState(() => _employees.add(employee)), padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: EmployeeForm(employee: employee),
),
),
),
), ),
); );
if (edited != null && mounted) { if (result != null && mounted) {
setState(() => _employees.add(result));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green), const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green),
); );
} }
} }
/// Future<void> _editEmployee(int id) async {
Future<void> _editEmployee(Employee employee) async { final employee = _employees.firstWhere((e) => e['id'] == id);
final edited = await showDialog<Employee>(
final edited = await showDialog<Map<String, dynamic>>(
context: context, context: context,
builder: (ctx) => EmployeeEditDialog( builder: (context) => _EmployeeDialogState(
title: '担当者編集', Dialog(
initialData: employee, child: SingleChildScrollView(
onSave: (updated) { padding: EdgeInsets.zero,
setState(() { child: ConstrainedBox(
_employees = _employees.map((e) => e.id == updated.id ? updated : e).toList(); constraints: const BoxConstraints(minHeight: 200),
}); child: EmployeeForm(employee: employee),
ScaffoldMessenger.of(context).showSnackBar( ),
const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green), ),
); ),
},
), ),
); );
//
if (edited != null && mounted) { if (edited != null && mounted) {
_loadEmployees(); final index = _employees.indexWhere((e) => e['id'] == id);
setState(() => _employees[index] = edited);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green),
);
} }
} }
/// Future<void> _deleteEmployee(int id) async {
Future<void> _deleteEmployee(Employee employee) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('担当者削除'), title: const Text('担当者削除'),
content: Text('この担当者を実際に削除しますか?'), content: Text('この担当者を実際に削除しますか?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red), style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'), child: const Text('削除'),
), ),
@ -117,9 +116,9 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
), ),
); );
if (confirmed == true && mounted) { if (confirmed == true) {
setState(() { setState(() {
_employees.removeWhere((e) => e.id == employee.id); _employees.removeWhere((e) => e['id'] == id);
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green), const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green),
@ -137,75 +136,79 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee), IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee),
], ],
), ),
body: Column( body: _loading ? const Center(child: CircularProgressIndicator()) :
children: [ _employees.isEmpty ? Center(child: Text('担当者データがありません')) :
// ListView.builder(
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), padding: const EdgeInsets.all(8),
itemCount: _filteredEmployees.length, itemCount: _employees.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final employee = _filteredEmployees[index]; final employee = _employees[index];
return Card( return Card(
margin: EdgeInsets.zero, margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(backgroundColor: Colors.purple.shade50, child: Icon(Icons.person_add, color: Colors.purple)),
backgroundColor: Colors.purple.shade100, title: Text(employee['name'] ?? '未入力'),
child: Text('${employee.department.substring(0, 1)}', style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
), Text('部署:${employee['department']}'),
title: Text(employee.name ?? '未入力', style: const TextStyle(fontWeight: FontWeight.w500)), if (employee['email'] != null) Text('Email: ${employee['email']}'),
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( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee)), IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee['id'] as int)),
IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => _deleteEmployee(employee)), IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteEmployee(employee['id'] as int)),
], ],
), ),
), ),
); );
}, },
), ),
),
],
),
); );
} }
} }
///
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,10 @@
// Version: 4.0 - // Version: 1.9 -
//
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
import '../../widgets/master_edit_fields.dart';
/// CRUD
class ProductMasterScreen extends StatefulWidget { class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({super.key}); const ProductMasterScreen({super.key});
@ -10,58 +13,281 @@ class ProductMasterScreen extends StatefulWidget {
} }
class _ProductMasterScreenState extends State<ProductMasterScreen> { class _ProductMasterScreenState extends State<ProductMasterScreen> {
List<Map<String, dynamic>> _products = []; List<Product> _products = [];
bool _loading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// _loadProducts();
_products = [ }
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50},
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30}, Future<void> _loadProducts() async {
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20}, setState(() => _loading = true);
]; try {
final products = await DatabaseHelper.instance.getProducts();
if (mounted) setState(() => _products = products ?? const <Product>[]);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
);
} finally {
setState(() => _loading = false);
}
}
Future<Product?> _showProductDialog({Product? initialProduct}) async {
final titleText = initialProduct == null ? '新規商品登録' : '商品編集';
return await showDialog<Product>(
context: context,
builder: (context) => AlertDialog(
title: Text(titleText),
content: SingleChildScrollView(child: ProductForm(initialProduct: initialProduct)),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(context, initialProduct ?? null),
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
child: initialProduct == null ? const Text('登録') : const Text('更新'),
),
],
),
);
}
void _onAddPressed() async {
final result = await _showProductDialog();
if (result != null && mounted) {
try {
await DatabaseHelper.instance.insertProduct(result);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品登録完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _onEditPressed(int id) async {
final product = await DatabaseHelper.instance.getProduct(id);
if (product == null || !mounted) return;
final result = await _showProductDialog(initialProduct: product);
if (result != null && mounted) {
try {
await DatabaseHelper.instance.updateProduct(result);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品更新完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _onDeletePressed(int id) async {
final product = await DatabaseHelper.instance.getProduct(id);
if (!mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('商品削除'),
content: Text('"${product?.name ?? 'この商品'}"を削除しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
if (mounted) Navigator.pop(context, true);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await DatabaseHelper.instance.deleteProduct(id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品削除完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除エラー:$e'), backgroundColor: Colors.red),
);
}
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('/M0. 製品マスタ')), appBar: AppBar(
body: ListView.builder( title: const Text('/M1. 商品マスタ'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts,),
IconButton(icon: const Icon(Icons.add), onPressed: _onAddPressed,),
],
),
body: _loading ? const Center(child: CircularProgressIndicator()) :
_products.isEmpty ? Center(child: Text('商品データがありません')) :
ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemCount: _products.length, itemCount: _products.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final product = _products[index]; final product = _products[index];
return Card( return Card(
margin: EdgeInsets.zero, margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(backgroundColor: Colors.blue.shade50, child: Icon(Icons.shopping_basket)),
backgroundColor: Colors.blue.shade100, title: Text(product.name.isEmpty ? '商品(未入力)' : product.name),
child: Text(product['product_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
), Text('コード:${product.productCode}'),
title: Text(product['name'] ?? '未入力'), Text('単価:¥${(product.unitPrice ?? 0).toStringAsFixed(2)}'),
subtitle: Column( ]),
crossAxisAlignment: CrossAxisAlignment.start, trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (product['unit_price'] != null) Text('単価:${product['unit_price']}', style: const TextStyle(fontSize: 12)), IconButton(icon: const Icon(Icons.edit), onPressed: () => _onEditPressed(product.id ?? 0)),
if (product['quantity'] != null) Text('数量:${product['quantity']}', style: const TextStyle(fontSize: 12)), IconButton(icon: const Icon(Icons.delete), onPressed: () => _onDeletePressed(product.id ?? 0)),
], ],
), ),
), ),
); );
}, },
), ),
floatingActionButton: FloatingActionButton.extended( );
icon: const Icon(Icons.add), }
label: const Text('新規登録'), }
onPressed: () {
setState(() { ///
_products = [..._products, {'product_code': 'TEST00${_products.length + 1}', 'name': '新商品', 'unit_price': 0.0, 'quantity': 0}]; class ProductForm extends StatefulWidget {
}); final Product? initialProduct;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了')));
}, const ProductForm({super.key, this.initialProduct});
),
@override
State<ProductForm> createState() => _ProductFormState();
}
class _ProductFormState extends State<ProductForm> {
late TextEditingController _productCodeController;
late TextEditingController _nameController;
late TextEditingController _unitPriceController;
@override
void initState() {
super.initState();
final initialProduct = widget.initialProduct;
_productCodeController = TextEditingController(text: initialProduct?.productCode ?? '');
_nameController = TextEditingController(text: initialProduct?.name ?? '');
_unitPriceController = TextEditingController(text: (initialProduct?.unitPrice ?? 0.0).toString());
if (_productCodeController.text.isEmpty) {
_productCodeController = TextEditingController();
}
if (_nameController.text.isEmpty) {
_nameController = TextEditingController();
}
if (_unitPriceController.text.isEmpty) {
_unitPriceController = TextEditingController(text: '0');
}
}
@override
void dispose() {
_productCodeController.dispose();
_nameController.dispose();
_unitPriceController.dispose();
super.dispose();
}
String? _validateProductCode(String? value) {
if (value == null || value.isEmpty) {
return '商品コードは必須です';
}
final regex = RegExp(r'^[0-9]+$');
if (!regex.hasMatch(value)) {
return '商品コードは数字のみを入力してください9000';
}
return null;
}
String? _validateName(String? value) {
if (value == null || value.isEmpty) {
return '品名は必須です';
}
return null;
}
String? _validateUnitPrice(String? value) {
final price = double.tryParse(value ?? '');
if (price == null) {
return '単価は数値を入力してください';
}
if (price < 0) {
return '単価は 0 以上の値です';
}
return null;
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'基本情報',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '商品コード',
hint: '9000',
controller: _productCodeController,
validator: _validateProductCode,
),
const SizedBox(height: 16),
MasterTextField(
label: '品名',
hint: '商品の名称',
controller: _nameController,
),
const SizedBox(height: 16),
MasterNumberField(
label: '単価(円)',
hint: '0',
controller: _unitPriceController,
validator: _validateUnitPrice,
),
],
); );
} }
} }

View file

@ -1,7 +1,8 @@
// Version: 3.0 - // Version: 1.8 - DB
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../widgets/master_edit_fields.dart';
/// CRUD
class SupplierMasterScreen extends StatefulWidget { class SupplierMasterScreen extends StatefulWidget {
const SupplierMasterScreen({super.key}); const SupplierMasterScreen({super.key});
@ -10,92 +11,331 @@ class SupplierMasterScreen extends StatefulWidget {
} }
class _SupplierMasterScreenState extends State<SupplierMasterScreen> { class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
List<dynamic> _suppliers = []; List<Map<String, dynamic>> _suppliers = [];
bool _loading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// _loadSuppliers();
_suppliers = [
{'supplier_code': 'S001', 'name': 'サンプル仕入先 A'},
{'supplier_code': 'S002', 'name': 'サンプル仕入先 B'},
];
} }
Future<void> _addSupplier() async { Future<void> _loadSuppliers() async {
showDialog<Map<String, dynamic>>( setState(() => _loading = true);
try {
// DatabaseHelper
final demoData = [
{'id': 1, 'name': '株式会社サプライヤ A', 'representative': '田中太郎', 'phone': '03-1234-5678', 'address': '東京都〇〇区'},
{'id': 2, 'name': '株式会社サプライヤ B', 'representative': '佐藤次郎', 'phone': '04-2345-6789', 'address': '神奈川県〇〇市'},
{'id': 3, 'name': '株式会社サプライヤ C', 'representative': '鈴木三郎', 'phone': '05-3456-7890', 'address': '愛知県〇〇町'},
];
setState(() => _suppliers = demoData);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
);
} finally {
setState(() => _loading = false);
}
}
Future<Map<String, dynamic>?> _showAddDialog() async {
final supplier = <String, dynamic>{
'id': DateTime.now().millisecondsSinceEpoch,
'name': '',
'representative': '',
'phone': '',
'address': '',
'email': '',
'taxRate': 10, // 10%
};
final result = await showDialog<Map<String, dynamic>>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (context) => Dialog(
title: const Text('新規仕入先登録'), child: SingleChildScrollView(
content: SingleChildScrollView( padding: EdgeInsets.zero,
child: Column( child: ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: const BoxConstraints(minHeight: 200),
crossAxisAlignment: CrossAxisAlignment.stretch, child: SupplierForm(supplier: supplier),
children: [
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'S003')),
SizedBox(height: 8),
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),
],
), ),
), ),
),
);
return result;
}
Future<void> _editSupplier(int id) async {
final supplier = _suppliers.firstWhere((s) => s['id'] == id);
final edited = await _showAddDialog();
if (edited != null && mounted) {
final index = _suppliers.indexWhere((s) => s['id'] == id);
setState(() => _suppliers[index] = edited);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先更新完了'), backgroundColor: Colors.green),
);
}
}
Future<void> _deleteSupplier(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('仕入先削除'),
content: Text('この仕入先を削除しますか?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () => Navigator.pop(context, true),
Navigator.pop(ctx); style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
}, child: const Text('削除'),
child: const Text('登録'),
), ),
], ],
), ),
); );
if (confirmed == true) {
setState(() {
_suppliers.removeWhere((s) => s['id'] == id);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先削除完了'), backgroundColor: Colors.green),
);
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('/M1. 仕入先マスタ')), appBar: AppBar(
body: _suppliers.isEmpty ? Center( title: const Text('/M3. 仕入先マスタ'),
child: Column( actions: [
mainAxisAlignment: MainAxisAlignment.center, IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers),
children: [ IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,),
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
ElevatedButton(
onPressed: _addSupplier,
child: const Text('新規登録'),
),
], ],
), ),
) : ListView.builder( body: _loading ? const Center(child: CircularProgressIndicator()) :
_suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) :
ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
itemCount: _suppliers.length, itemCount: _suppliers.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final supplier = _suppliers[index]; final supplier = _suppliers[index];
return Card( return Card(
margin: EdgeInsets.zero, margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: ListTile( child: ListTile(
leading: CircleAvatar( leading: CircleAvatar(backgroundColor: Colors.brown.shade50, child: Icon(Icons.shopping_bag)),
backgroundColor: Colors.orange.shade100,
child: Text(supplier['supplier_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)),
),
title: Text(supplier['name'] ?? '未入力'), title: Text(supplier['name'] ?? '未入力'),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (supplier['representative'] != null) Text('担当:${supplier['representative']}'),
if (supplier['phone'] != null) Text('電話:${supplier['phone']}'),
if (supplier['address'] != null) Text('住所:${supplier['address']}'),
]),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editSupplier(supplier['id'] as int)),
IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteSupplier(supplier['id'] as int)),
],
),
), ),
); );
}, },
), ),
floatingActionButton: FloatingActionButton.extended( );
icon: const Icon(Icons.add), }
label: const Text('新規登録'), }
onPressed: _addSupplier,
), /// 使
class SupplierForm extends StatefulWidget {
final Map<String, dynamic> supplier;
const SupplierForm({super.key, required this.supplier});
@override
State<SupplierForm> createState() => _SupplierFormState();
}
class _SupplierFormState extends State<SupplierForm> {
late TextEditingController _nameController;
late TextEditingController _representativeController;
late TextEditingController _addressController;
late TextEditingController _phoneController;
late TextEditingController _emailController;
late TextEditingController _taxRateController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.supplier['name'] ?? '');
_representativeController = TextEditingController(text: widget.supplier['representative'] ?? '');
_addressController = TextEditingController(text: widget.supplier['address'] ?? '');
_phoneController = TextEditingController(text: widget.supplier['phone'] ?? '');
_emailController = TextEditingController(text: widget.supplier['email'] ?? '');
_taxRateController = TextEditingController(text: (widget.supplier['taxRate'] ?? 10).toString());
}
@override
void dispose() {
_nameController.dispose();
_representativeController.dispose();
_addressController.dispose();
_phoneController.dispose();
_emailController.dispose();
_taxRateController.dispose();
super.dispose();
}
String? _validateName(String? value) {
if (value == null || value.isEmpty) {
return '会社名は必須です';
}
return null;
}
String? _validateRepresentative(String? value) {
//
return null;
}
String? _validateAddress(String? value) {
//
return null;
}
String? _validatePhone(String? value) {
if (value != null && value.isNotEmpty) {
// 03-1234-5678
final regex = RegExp(r'^[0-9\- ]+$');
if (!regex.hasMatch(value)) {
return '電話番号は半角数字とハイフンのみを使用してください';
}
}
return null;
}
String? _validateEmail(String? value) {
if (value != null && value.isNotEmpty) {
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailRegex.hasMatch(value)) {
return 'メールアドレスの形式が正しくありません';
}
}
return null;
}
String? _validateTaxRate(String? value) {
final taxRate = double.tryParse(value ?? '');
if (taxRate == null || taxRate < 0) {
return '税率は 0 以上の値を入力してください';
}
// 10%
if (taxRate != int.parse(taxRate.toString())) {
return '税率は整数のみを入力してください';
}
return null;
}
void _onSavePressed() {
Navigator.pop(context, widget.supplier);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'基本情報',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '会社名 *',
hint: '例:株式会社サンプル',
controller: _nameController,
validator: _validateName,
),
const SizedBox(height: 16),
MasterTextField(
label: '代表者名',
hint: '例:田中太郎',
controller: _representativeController,
validator: _validateRepresentative,
),
const SizedBox(height: 16),
MasterTextField(
label: '住所',
hint: '例:東京都〇〇区',
controller: _addressController,
validator: _validateAddress,
),
const SizedBox(height: 16),
MasterTextField(
label: '電話番号',
hint: '03-1234-5678',
controller: _phoneController,
keyboardType: TextInputType.phone,
validator: _validatePhone,
),
const SizedBox(height: 16),
MasterTextField(
label: 'Email',
hint: 'contact@example.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
validator: _validateEmail,
),
const SizedBox(height: 24),
//
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'設定情報',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterNumberField(
label: '税率(%)',
hint: '10',
controller: _taxRateController,
validator: _validateTaxRate,
),
const SizedBox(height: 32),
//
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')),
ElevatedButton(
onPressed: _onSavePressed,
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
child: const Text('保存'),
),
],
),
],
); );
} }
} }

View file

@ -1,9 +1,9 @@
// Version: 1.9 - // Version: 1.7 - DB
// DB
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// CRUD - final _dialogKey = GlobalKey();
/// CRUD
class WarehouseMasterScreen extends StatefulWidget { class WarehouseMasterScreen extends StatefulWidget {
const WarehouseMasterScreen({super.key}); const WarehouseMasterScreen({super.key});
@ -24,6 +24,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
Future<void> _loadWarehouses() async { Future<void> _loadWarehouses() async {
setState(() => _loading = true); setState(() => _loading = true);
try { try {
// DatabaseHelper
final demoData = [ final demoData = [
{'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'}, {'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'},
{'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'}, {'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'},
@ -42,7 +43,14 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
} }
Future<void> _addWarehouse() async { Future<void> _addWarehouse() async {
final warehouse = <String, dynamic>{'id': DateTime.now().millisecondsSinceEpoch, 'name': '', 'area': '', 'address': ''}; final warehouse = <String, dynamic>{
'id': DateTime.now().millisecondsSinceEpoch,
'name': '',
'area': '',
'address': '',
'manager': '',
'contactPhone': '',
};
final result = await showDialog<Map<String, dynamic>>( final result = await showDialog<Map<String, dynamic>>(
context: context, context: context,
@ -61,7 +69,9 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
if (result != null && mounted) { if (result != null && mounted) {
setState(() => _warehouses.add(result)); setState(() => _warehouses.add(result));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green)); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green),
);
} }
} }
@ -85,7 +95,9 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
if (edited != null && mounted) { if (edited != null && mounted) {
final index = _warehouses.indexWhere((w) => w['id'] == id); final index = _warehouses.indexWhere((w) => w['id'] == id);
setState(() => _warehouses[index] = edited); setState(() => _warehouses[index] = edited);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green)); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green),
);
} }
} }
@ -110,7 +122,9 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
setState(() { setState(() {
_warehouses.removeWhere((w) => w['id'] == id); _warehouses.removeWhere((w) => w['id'] == id);
}); });
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green)); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green),
);
} }
} }
@ -155,8 +169,49 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
} }
} }
/// ///
class WarehouseForm extends StatelessWidget { class WarehouseForm extends StatelessWidget {
final Map<String, dynamic> warehouse; final Map<String, dynamic> warehouse;
const const WarehouseForm({super.key, required this.warehouse});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(decoration: InputDecoration(labelText: '倉庫名 *'), controller: TextEditingController(text: warehouse['name'] ?? '')),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'エリア', hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州'),
value: warehouse['area'] != null ? (warehouse['area'] as String?) : null,
items: ['北海道', '東北', '関東', '中部', '近畿', '中国', '四国', '九州'].map((area) => DropdownMenuItem<String>(value: area, child: Text(area))).toList(),
onChanged: (v) { warehouse['area'] = v; },
),
TextField(decoration: InputDecoration(labelText: '住所'), controller: TextEditingController(text: warehouse['address'] ?? '')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '倉庫長(担当者名)'), controller: TextEditingController(text: warehouse['manager'] ?? '')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '連絡先電話番号', hintText: '000-1234'), controller: TextEditingController(text: warehouse['contactPhone'] ?? ''), keyboardType: TextInputType.phone),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, warehouse), child: const Text('保存'))],
),
],
);
}
}
///
class _WarehouseDialogState extends StatelessWidget {
final Dialog dialog;
const _WarehouseDialogState(this.dialog);
@override
Widget build(BuildContext context) {
return dialog;
}
}

View file

@ -2,7 +2,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'dart:convert'; import 'dart:convert';
import '../services/database_helper.dart' as db; import '../services/database_helper.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/customer.dart'; import '../models/customer.dart';
@ -21,42 +21,6 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0); final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
// DB product_code
Future<void> loadProducts() async {
try {
// DB
final result = await db.DatabaseHelper.instance.query('products', orderBy: 'id DESC');
if (result.isEmpty) {
//
products = <Product>[
Product(id: 1, productCode: 'TEST001', name: 'サンプル商品 A', unitPrice: 1000.0),
Product(id: 2, productCode: 'TEST002', name: 'サンプル商品 B', unitPrice: 2500.0),
];
} else {
// DB Model
products = List.generate(result.length, (i) {
return Product(
id: result[i]['id'] as int?,
productCode: result[i]['product_code'] as String? ?? '',
name: result[i]['name'] as String? ?? '',
unitPrice: (result[i]['unit_price'] as num?)?.toDouble() ?? 0.0,
quantity: (result[i]['quantity'] as int?) ?? 0,
stock: (result[i]['stock'] as int?) ?? 0,
);
});
}
} catch (e) {
//
products = <Product>[];
}
}
Future<void> refreshProducts() async {
await loadProducts();
if (mounted) setState(() {});
}
// Database // Database
Future<void> saveSalesData() async { Future<void> saveSalesData() async {
if (saleItems.isEmpty || !mounted) return; if (saleItems.isEmpty || !mounted) return;
@ -80,8 +44,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
'product_items': itemsJson, 'product_items': itemsJson,
}; };
// sqflite insert API 使insertSales final insertedId = await DatabaseHelper.instance.insertSales(salesData);
final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -176,15 +139,18 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 1
if (products.isEmpty) {
loadProducts();
}
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [ appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [
IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,), IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,),
IconButton(icon: const Icon(Icons.refresh), onPressed: refreshProducts,), IconButton(icon: const Icon(Icons.share), onPressed: generateAndShareInvoice,),
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'invoice') await generateAndShareInvoice();
},
itemBuilder: (ctx) => [
PopupMenuItem(child: const Text('売上明細を共有'), value: 'invoice',),
],
),
]), ]),
body: Column( body: Column(
children: <Widget>[ children: <Widget>[
@ -213,7 +179,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
), ),
Expanded( Expanded(
child: saleItems.isEmpty child: saleItems.isEmpty
? Center(child: Text('商品を登録')) ? const Center(child: Text('商品を登録'))
: ListView.separated( : ListView.separated(
itemCount: saleItems.length, itemCount: saleItems.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {

View file

@ -1,288 +1,252 @@
// Version: 1.0 - sqflite
// NOTE: update() 使
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:convert';
import '../models/customer.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/estimate.dart';
class DatabaseHelper { class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database; static Database? _database;
/// DatabaseHelper._init();
static Future<void> init() async {
if (_database != null) return;
try { Future<Database> get database async {
String dbPath; if (_database != null) return _database!;
_database = await _initDB('customer_assist.db');
if (Platform.isAndroid || Platform.isIOS) { return _database!;
final dbDir = await getDatabasesPath();
dbPath = '$dbDir/sales.db';
} else {
dbPath = Directory.current.path + '/data/db/sales.db';
} }
await Directory(dbPath).parent.create(recursive: true); Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath();
_database = await _initDatabase(dbPath); final path = join(dbPath, filePath);
print('[DatabaseHelper] DB initialized successfully at $dbPath');
} catch (e) {
print('DB init error: $e');
throw Exception('Database initialization failed: $e');
}
}
static Future<Database> _initDatabase(String path) async {
return await openDatabase( return await openDatabase(
path, path,
version: 1, version: 1,
onCreate: _onCreateTableWithSampleData, onCreate: _createDB,
); );
} }
static Future<void> _onCreateTableWithSampleData(Database db, int version) async { Future<void> _createDB(Database db, int version) async {
// products await db.execute('CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, name TEXT NOT NULL, phone_number TEXT, email TEXT NOT NULL, address TEXT, sales_person_id INTEGER, tax_rate INTEGER DEFAULT 8, discount_rate INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute(''' await db.execute('CREATE TABLE employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
CREATE TABLE products ( await db.execute('CREATE TABLE warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
id INTEGER PRIMARY KEY AUTOINCREMENT, await db.execute('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, phone_number TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
product_code TEXT UNIQUE NOT NULL, await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, quantity INTEGER DEFAULT 0, stock INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
name TEXT NOT NULL, await db.execute('CREATE TABLE sales (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
unit_price REAL DEFAULT 0.0, await db.execute('CREATE TABLE estimates (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, estimate_number TEXT NOT NULL, product_items TEXT, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "open", expiry_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
quantity INTEGER DEFAULT 0, await db.execute('CREATE TABLE inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT UNIQUE NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, stock INTEGER DEFAULT 0, min_stock INTEGER DEFAULT 0, max_stock INTEGER DEFAULT 1000, supplier_name TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
stock INTEGER DEFAULT 0, await db.execute('CREATE TABLE invoices (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, invoice_number TEXT NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "paid", product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, print('Database created with version: 1');
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// customers
await db.execute('''
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// sales
await db.execute('''
CREATE TABLE sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER,
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'completed',
payment_status TEXT DEFAULT 'paid',
invoice_number TEXT UNIQUE,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// estimates
await db.execute('''
CREATE TABLE estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_number TEXT UNIQUE,
customer_id INTEGER REFERENCES customers(id),
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
discount_percent REAL DEFAULT 0.0,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'pending',
payment_status TEXT DEFAULT 'unpaid',
expiry_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
//
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},
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20, 'stock': 20},
];
for (final data in sampleProducts) {
await db.insert('products', data);
} }
print('[DatabaseHelper] Sample products inserted'); // Customer API
Future<int> insertCustomer(Customer customer) async {
final db = await database;
return await db.insert('customers', customer.toMap());
} }
static Database get instance => _database!; Future<Customer?> getCustomer(int id) async {
final db = await database;
/// final results = await db.query('customers', where: 'id = ?', whereArgs: [id]);
static Future<List<Product>> getProducts() async { if (results.isEmpty) return null;
final result = await instance.query('products', orderBy: 'id DESC'); return Customer.fromMap(results.first);
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 Product.fromMap(item); Future<List<Customer>> getCustomers() async {
}); final db = await database;
final results = await db.query('customers');
return results.map((e) => Customer.fromMap(e)).toList();
} }
/// ID null Future<int> updateCustomer(Customer customer) async {
static Future<Product?> getProduct(int id) async { final db = await database;
final result = await instance.query( return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]);
'products',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
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 Product.fromMap(item); Future<int> deleteCustomer(int id) async {
} final db = await database;
return null; return await db.delete('customers', where: 'id = ?', whereArgs: [id]);
} }
/// productCode null // Product API
static Future<Product?> getProductByCode(String code) async { Future<int> insertProduct(Product product) async {
final result = await instance.query( final db = await database;
'products', return await db.insert('products', product.toMap());
where: 'product_code = ?',
whereArgs: [code],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
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 Product.fromMap(item); Future<Product?> getProduct(int id) async {
} final db = await database;
return null; final results = await db.query('products', where: 'id = ?', whereArgs: [id]);
if (results.isEmpty) return null;
return Product.fromMap(results.first);
} }
/// Future<List<Product>> getProducts() async {
static Future<List<Map<String, dynamic>>> getCustomers() async { final db = await database;
final result = await instance.query('customers', where: 'is_inactive = ?', whereArgs: [false]); final results = await db.query('products');
return results.map((e) => Product.fromMap(e)).toList();
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; Future<int> updateProduct(Product product) async {
}); final db = await database;
return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]);
} }
/// Future<int> deleteProduct(int id) async {
static Future<void> insertProduct(Product product) async { final db = await database;
await instance.insert('products', { return await db.delete('products', where: 'id = ?', whereArgs: [id]);
'product_code': product.productCode,
'name': product.name,
'unit_price': product.unitPrice,
'quantity': product.quantity,
'stock': product.stock,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
} }
/// // Sales API
static Future<void> deleteProduct(int id) async { Future<int> insertSales(Map<String, dynamic> salesData) async {
await instance.delete('products', where: 'id = ?', whereArgs: [id]); final db = await database;
return await db.insert('sales', salesData);
} }
/// Future<List<Map<String, dynamic>>> getSales() async {
static Future<void> insertCustomer(Map<String, dynamic> customer) async { final db = await database;
await instance.insert('customers', { return await db.query('sales');
'customer_code': customer['customerCode'],
'name': customer['name'],
'address': customer['address'],
'phone': customer['phoneNumber'],
'email': customer['email'],
});
} }
/// Future<int> updateSales(Map<String, dynamic> salesData) async {
static Future<void> updateCustomer(Map<String, dynamic> customer) async { final db = await database;
await deleteCustomer(customer['id'] ?? 0); return await db.update('sales', salesData, where: 'id = ?', whereArgs: [salesData['id'] as int]);
await insertCustomer(customer);
} }
/// Future<int> deleteSales(int id) async {
static Future<void> deleteCustomer(int id) async { final db = await database;
await instance.delete('customers', where: 'id = ?', whereArgs: [id]); return await db.delete('sales', where: 'id = ?', whereArgs: [id]);
} }
/// DB // Estimate API
static Future<void> clearDatabase() async { Future<int> insertEstimate(Map<String, dynamic> estimateData) async {
await instance.delete('products'); final db = await database;
await instance.delete('customers'); return await db.insert('estimates', estimateData);
await instance.delete('sales');
await instance.delete('estimates');
} }
/// + + Future<List<Map<String, dynamic>>> getEstimates() async {
static Future<void> recover() async { final db = await database;
try { return await db.query('estimates');
final dbPath = Directory.current.path + '/data/db/sales.db';
final file = File(dbPath);
if (await file.exists()) {
await file.delete();
print('[DatabaseHelper] recover: DB ファイルを削除');
} else {
print('[DatabaseHelper] recover: DB ファイルが見つからない');
} }
await init(); Future<int> updateEstimate(Map<String, dynamic> estimateData) async {
} catch (e) { final db = await database;
print('[DatabaseHelper] recover error: $e'); return await db.update('estimates', estimateData, where: 'id = ?', whereArgs: [estimateData['id'] as int]);
}
} }
static Future<String> getDbPath() async { Future<int> deleteEstimate(int id) async {
return Directory.current.path + '/data/db/sales.db'; final db = await database;
return await db.delete('estimates', where: 'id = ?', whereArgs: [id]);
}
// Invoice API
Future<int> insertInvoice(Map<String, dynamic> invoiceData) async {
final db = await database;
return await db.insert('invoices', invoiceData);
}
Future<List<Map<String, dynamic>>> getInvoices() async {
final db = await database;
return await db.query('invoices');
}
Future<int> updateInvoice(Map<String, dynamic> invoiceData) async {
final db = await database;
return await db.update('invoices', invoiceData, where: 'id = ?', whereArgs: [invoiceData['id'] as int]);
}
Future<int> deleteInvoice(int id) async {
final db = await database;
return await db.delete('invoices', where: 'id = ?', whereArgs: [id]);
}
// Inventory API
Future<int> insertInventory(Map<String, dynamic> inventoryData) async {
final db = await database;
return await db.insert('inventory', inventoryData);
}
Future<List<Map<String, dynamic>>> getInventory() async {
final db = await database;
return await db.query('inventory');
}
Future<int> updateInventory(Map<String, dynamic> inventoryData) async {
final db = await database;
return await db.update('inventory', inventoryData, where: 'id = ?', whereArgs: [inventoryData['id'] as int]);
}
Future<int> deleteInventory(int id) async {
final db = await database;
return await db.delete('inventory', where: 'id = ?', whereArgs: [id]);
}
// Employee API
Future<int> insertEmployee(Map<String, dynamic> employeeData) async {
final db = await database;
return await db.insert('employees', employeeData);
}
Future<List<Map<String, dynamic>>> getEmployees() async {
final db = await database;
return await db.query('employees');
}
Future<int> updateEmployee(Map<String, dynamic> employeeData) async {
final db = await database;
return await db.update('employees', employeeData, where: 'id = ?', whereArgs: [employeeData['id'] as int]);
}
Future<int> deleteEmployee(int id) async {
final db = await database;
return await db.delete('employees', where: 'id = ?', whereArgs: [id]);
}
// Warehouse API
Future<int> insertWarehouse(Map<String, dynamic> warehouseData) async {
final db = await database;
return await db.insert('warehouses', warehouseData);
}
Future<List<Map<String, dynamic>>> getWarehouses() async {
final db = await database;
return await db.query('warehouses');
}
Future<int> updateWarehouse(Map<String, dynamic> warehouseData) async {
final db = await database;
return await db.update('warehouses', warehouseData, where: 'id = ?', whereArgs: [warehouseData['id'] as int]);
}
Future<int> deleteWarehouse(int id) async {
final db = await database;
return await db.delete('warehouses', where: 'id = ?', whereArgs: [id]);
}
// Supplier API
Future<int> insertSupplier(Map<String, dynamic> supplierData) async {
final db = await database;
return await db.insert('suppliers', supplierData);
}
Future<List<Map<String, dynamic>>> getSuppliers() async {
final db = await database;
return await db.query('suppliers');
}
Future<int> updateSupplier(Map<String, dynamic> supplierData) async {
final db = await database;
return await db.update('suppliers', supplierData, where: 'id = ?', whereArgs: [supplierData['id'] as int]);
}
Future<int> deleteSupplier(int id) async {
final db = await database;
return await db.delete('suppliers', where: 'id = ?', whereArgs: [id]);
}
Future<void> close() async {
final db = await database;
db.close();
} }
} }

View file

@ -1,146 +1,125 @@
// Version: 2.1 - 使 // Version: 1.0 - Flutter
//
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// /// TextField
class MasterTextField extends StatelessWidget { class MasterTextField extends StatelessWidget {
final String label; final String label;
final TextEditingController controller; final TextEditingController controller;
final String? hintText; final String? hint;
final TextInputType keyboardType;
final bool obscureText;
final int maxLines;
final TextInputAction textInputAction;
final FormFieldValidator<String>? validator;
final void Function(String)? onChanged;
const MasterTextField({ const MasterTextField({
super.key, super.key,
required this.label, required this.label,
required this.controller, required this.controller,
this.hintText, this.hint,
}); this.keyboardType = TextInputType.text,
this.obscureText = false,
@override this.maxLines = 1,
Widget build(BuildContext context) { this.textInputAction = TextInputAction.next,
return Padding( this.validator,
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: hintText, border: OutlineInputBorder()),
),
],),
);
}
}
///
class MasterTextArea extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hintText;
final int maxLines;
const MasterTextArea({
super.key,
required this.label,
required this.controller,
this.hintText,
this.maxLines = 2,
});
@override
Widget build(BuildContext context) {
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,
maxLines: maxLines,
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
),
],),
);
}
}
///
class MasterNumberField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hintText;
final bool readOnly;
const MasterNumberField({
super.key,
required this.label,
required this.controller,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
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,
keyboardType: TextInputType.number,
readOnly: readOnly,
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
),
],),
);
}
}
///
class MasterStatusField extends StatelessWidget {
final String label;
final String? status;
const MasterStatusField({super.key, required this.label, this.status});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
Expanded(child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 8),
if (status != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
child: Text(status!, style: const TextStyle(fontWeight: FontWeight.bold)),
),
],
],),
);
}
}
///
class MasterCheckboxField extends StatelessWidget {
final String label;
final bool? checked;
final Function(bool)? onChanged;
const MasterCheckboxField({
super.key,
required this.label,
this.checked,
this.onChanged, this.onChanged,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return TextFormField(
padding: const EdgeInsets.only(bottom: 8), controller: controller,
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ decoration: InputDecoration(
Text(label), labelText: label,
Checkbox(value: checked ?? false, onChanged: onChanged), hintText: hint,
],), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
keyboardType: keyboardType,
obscureText: obscureText,
maxLines: maxLines,
textInputAction: textInputAction,
validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation',
onChanged: onChanged,
);
}
}
/// TextField
class MasterNumberField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hint;
final FormFieldValidator<String>? validator;
final void Function(String)? onChanged;
const MasterNumberField({
super.key,
required this.label,
required this.controller,
this.hint,
this.validator,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
keyboardType: TextInputType.number,
validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation',
onChanged: onChanged,
);
}
}
/// Checkbox
class MasterCheckboxField extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool?>? onChangedCallback;
const MasterCheckboxField({
super.key,
required this.label,
required this.value,
this.onChangedCallback,
});
@override
Widget build(BuildContext context) {
return Checkbox(
value: value,
onChanged: onChangedCallback,
);
}
}
/// Switch
class MasterSwitchField extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool>? onChangedCallback;
const MasterSwitchField({
super.key,
required this.label,
required this.value,
this.onChangedCallback,
});
@override
Widget build(BuildContext context) {
return Switch(
value: value,
onChanged: onChangedCallback,
); );
} }
} }

View file

@ -23,9 +23,8 @@ dependencies:
share_plus: ^10.1.2 share_plus: ^10.1.2
google_sign_in: ^7.2.0 google_sign_in: ^7.2.0
# リッチマスター編集用機能(簡易実装) # フォームビルダ - マスタ編集の汎用モジュールで使用
image_picker: ^1.0.7 flutter_form_builder: ^9.1.1
qr_flutter: ^4.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: