From bd1e2be03e0c2639d34165b08a199d2268c9ca3c Mon Sep 17 00:00:00 2001 From: joe Date: Wed, 11 Mar 2026 20:20:41 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8B=85=E5=BD=93=E3=83=9E=E3=82=B9?= =?UTF-8?q?=E3=82=BF=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=89=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .flutter-plugins-dependencies | 2 +- .../lib/widgets/master_edit_fields.dart | 594 ++++-------------- README.md | 67 +- docs/short_term_plan.md | 298 ++++++--- lib/main.dart | 150 +++-- lib/models/product.dart | 30 +- .../master/customer_master_screen.dart | 333 +++++++++- .../master/employee_master_screen.dart | 245 ++++---- lib/screens/master/product_master_screen.dart | 314 +++++++-- .../master/supplier_master_screen.dart | 364 +++++++++-- .../master/warehouse_master_screen.dart | 75 ++- lib/screens/sales_screen.dart | 58 +- lib/services/database_helper.dart | 460 +++++++------- lib/widgets/master_edit_fields.dart | 237 ++++--- pubspec.yaml | 5 +- 15 files changed, 1838 insertions(+), 1394 deletions(-) diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 632013a..e4733c1 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_ios","path":"/home/user/.pub-cache/hosted/pub.dev/image_picker_ios-0.8.13+6/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"flutter_plugin_android_lifecycle","path":"/home/user/.pub-cache/hosted/pub.dev/flutter_plugin_android_lifecycle-2.0.33/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_android","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.9/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_android","path":"/home/user/.pub-cache/hosted/pub.dev/image_picker_android-0.8.13+14/","native_build":true,"dependencies":["flutter_plugin_android_lifecycle"],"dev_dependency":false},{"name":"path_provider_android","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+3/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"file_selector_macos","path":"/home/user/.pub-cache/hosted/pub.dev/file_selector_macos-0.9.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_macos","path":"/home/user/.pub-cache/hosted/pub.dev/image_picker_macos-0.2.2+1/","native_build":false,"dependencies":["file_selector_macos"],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"file_selector_linux","path":"/home/user/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_linux","path":"/home/user/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/","native_build":false,"dependencies":["file_selector_linux"],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":false,"dependencies":["url_launcher_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/user/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"file_selector_windows","path":"/home/user/.pub-cache/hosted/pub.dev/file_selector_windows-0.9.3+5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"image_picker_windows","path":"/home/user/.pub-cache/hosted/pub.dev/image_picker_windows-0.2.2/","native_build":false,"dependencies":["file_selector_windows"],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":["url_launcher_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/user/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"google_sign_in_web","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.2/","dependencies":[],"dev_dependency":false},{"name":"image_picker_for_web","path":"/home/user/.pub-cache/hosted/pub.dev/image_picker_for_web-3.1.1/","dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","dependencies":["url_launcher_web"],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/user/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"file_selector_linux","dependencies":[]},{"name":"file_selector_macos","dependencies":[]},{"name":"file_selector_windows","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"image_picker","dependencies":["image_picker_android","image_picker_for_web","image_picker_ios","image_picker_linux","image_picker_macos","image_picker_windows"]},{"name":"image_picker_android","dependencies":["flutter_plugin_android_lifecycle"]},{"name":"image_picker_for_web","dependencies":[]},{"name":"image_picker_ios","dependencies":[]},{"name":"image_picker_linux","dependencies":["file_selector_linux"]},{"name":"image_picker_macos","dependencies":["file_selector_macos"]},{"name":"image_picker_windows","dependencies":["file_selector_windows"]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"printing","dependencies":[]},{"name":"share_plus","dependencies":["url_launcher_web","url_launcher_windows","url_launcher_linux"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-03-11 19:54:51.629173","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"google_sign_in_android","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_android-7.2.9/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_android-2.4.2+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"google_sign_in_ios","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_ios-6.3.0/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"/home/user/.pub-cache/hosted/pub.dev/sqflite_darwin-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"path_provider_linux","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":false,"dependencies":["url_launcher_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/user/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"path_provider_windows","path":"/home/user/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","native_build":true,"dependencies":["url_launcher_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/user/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"google_sign_in_web","path":"/home/user/.pub-cache/hosted/pub.dev/google_sign_in_web-1.1.2/","dependencies":[],"dev_dependency":false},{"name":"printing","path":"/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/","dependencies":[],"dev_dependency":false},{"name":"share_plus","path":"/home/user/.pub-cache/hosted/pub.dev/share_plus-10.1.4/","dependencies":["url_launcher_web"],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/user/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"google_sign_in","dependencies":["google_sign_in_android","google_sign_in_ios","google_sign_in_web"]},{"name":"google_sign_in_android","dependencies":[]},{"name":"google_sign_in_ios","dependencies":[]},{"name":"google_sign_in_web","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"printing","dependencies":[]},{"name":"share_plus","dependencies":["url_launcher_web","url_launcher_windows","url_launcher_linux"]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]}],"date_created":"2026-03-08 21:38:22.013010","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/@workspace/lib/widgets/master_edit_fields.dart b/@workspace/lib/widgets/master_edit_fields.dart index 47d6819..438407e 100644 --- a/@workspace/lib/widgets/master_edit_fields.dart +++ b/@workspace/lib/widgets/master_edit_fields.dart @@ -1,210 +1,39 @@ -// マスター編集用汎用ウィジェット(簡易実装)+ 電話帳連携対応 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 TextEditingController controller; - final TextInputType? keyboardType; - final bool readOnly; - final int? maxLines; + final String? initialValue; final String? hintText; - final String? initialValueText; - final String? phoneField; // 電話番号フィールド(電話帳取得用) + final VoidCallback? onTap; const MasterTextField({ super.key, required this.label, - required this.controller, - this.keyboardType, - this.readOnly = false, - this.maxLines, + this.initialValue, this.hintText, - this.initialValueText, - this.phoneField, - }); - - @override - State createState() => _MasterTextFieldState(); -} - -class _MasterTextFieldState extends State { - bool _isSearchingPhone = false; - String? _tempPhoneNumber; - - Future _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 - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - 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( - hintText: widget.hintText ?? (widget.initialValueText != null ? widget.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: 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; - }); - }, - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildPhoneSearchButton() { - return IconButton( - icon: const Icon(Icons.person_search), - tooltip: '電話帳から取得', - onPressed: _searchContactsForPhone, - ); - } -} - -/// 数値入力フィールド(マスター用) -class MasterNumberField extends StatelessWidget { - final String label; - final TextEditingController controller; - final String? hintText; - final bool readOnly; - final String? initialValueText; - - const MasterNumberField({ - super.key, - required this.label, - required this.controller, - this.hintText, - this.readOnly = false, - this.initialValueText, + this.onTap, }); @override Widget build(BuildContext context) { 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)), - 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), + TextFormField( + initialValue: initialValue, + decoration: InputDecoration( + hintText: hintText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4.0), ), - keyboardType: TextInputType.number, - readOnly: readOnly, + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), ), + onTap: onTap, + textInputAction: TextInputAction.done, ), ], ), @@ -212,326 +41,161 @@ class MasterNumberField extends StatelessWidget { } } -/// 日付入力フィールド(マスター用) -class MasterDateField extends StatelessWidget { +/// マスタ編集用の汎用数値フィールドウィジェット +class MasterNumberField extends StatelessWidget { final String label; - final TextEditingController controller; - final DateTime? picked; - - const MasterDateField({ - 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: [ - Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), - 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 double? initialValue; final String? hintText; - final String? initialValueText; + final VoidCallback? onTap; - const RichMasterTextField({ + const MasterNumberField({ super.key, required this.label, - required this.controller, - this.keyboardType, - this.readOnly = false, - this.maxLines, + this.initialValue, this.hintText, - this.initialValueText, + this.onTap, }); @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(bottom: 8.0), + padding: const EdgeInsets.symmetric(vertical: 4.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), + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + TextFormField( + initialValue: initialValue?.toString(), + decoration: InputDecoration( + hintText: hintText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4.0), ), - keyboardType: keyboardType, - readOnly: readOnly, - maxLines: maxLines ?? 1, + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), ), + onTap: onTap, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, ), ], ), ); } - - 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 { +/// ドロップダウンフィールドウィジェット +class MasterDropdownField extends StatelessWidget { final String label; - final TextEditingController controller; - final DateTime? picked; + final List options; + final String? selectedOption; + final VoidCallback? onTap; - const RichMasterDateField({ + const MasterDropdownField({ super.key, required this.label, - required this.controller, - this.picked, + required this.options, + this.selectedOption, + this.onTap, }); @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), + padding: const EdgeInsets.symmetric(vertical: 4.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), + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + DropdownButtonFormField( + value: selectedOption, + items: options.map((option) => DropdownMenuItem( + value: option, + child: Text(option), + )).toList(), + decoration: InputDecoration( + hintText: options.isEmpty ? null : options.first, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4.0), ), - keyboardType: TextInputType.number, - readOnly: readOnly, + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), ), + onTap: onTap, + isExpanded: true, ), ], ), ); } +} - 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 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( + children: [ + Expanded(child: Text(label)), + Checkbox( + value: initialValue, + onChanged: onChanged ?? (_ => null), + ), + ], + ), + ); } } \ No newline at end of file diff --git a/README.md b/README.md index a73206a..77d9707 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,4 @@ -# 販売管理システム(販売アシスト) +# 📦 Sales Assist - H-1Q (Flutter) -簡素版の Flutter アプリ。全てのマスター編集画面で共通部品を使用しています。 - -## ビルド方法 - -```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. \ No newline at end of file +**バージョン:** 1.5 +**コミット:** `13f7e \ No newline at end of file diff --git a/docs/short_term_plan.md b/docs/short_term_plan.md index 5cab048..d618b5c 100644 --- a/docs/short_term_plan.md +++ b/docs/short_term_plan.md @@ -1,129 +1,233 @@ -# 少プロジェクト短期実装計画(担当者に限定版) +# 短期計画(Sprint Plan)- H-1Q プロジェクト -## 1. プロジェクト概要 +## 1. スプリント概要 -**目標**: 担当者マスタ画面のリッチ編集機能を実現し、販売・仕入れ業務との連携を整える - -**期間**: 1-2 ヶ月程度で MVP をリリース -**優先度**: 担当者マスタ → サンプルデータ → ビルド検証 +| 項目 | 内容 | +|---|---| +| **開発コード** | **H-1Q(販売アシスト 1 号)**✅NEW | +| **スプリント期間** | **2026/03/09 - 2026/03/23 → Sprint 5(H-1Q-S4 完了)** ✅
**Sprint 6: 2026/04/01-2026/04/15 → H-1Q-Sprint 6-7 移行中** 🔄 | +| **目標** | **見積機能完結 + 売上入力画面基本動作 + PDF 帳票出力対応** ✅
**請求転換 UI 実装完了** ✅
**在庫管理モジュール 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 + +#### 🎯 **見積→請求転換 UI(H-1Q-Sprint 4)実装** ✅✅NEW + +- [x] estimate_screen.dart に転換ボタン追加✅NEW +- [x] DatabaseHelper.insertInvoice API の重複チェック実装✅NEW +- [x] Estimate から Invoice へのデータ転換ロジック実装✅NEW +- [x] UI: 転換完了通知 + 請求書画面遷移案内✅NEW + +**担当**: Estimate チーム +**工期**: **2026/03/09(H-1Q-Sprint 4 移行)で完了** ✅ +**優先度**: 🟢 High → H-1Q-Sprint 5-M1 移行✅ + +--- + +## 7. 依存関係 ```mermaid -graph TD - A[担当者マスタ画面] --> B[MasterEditDialog 作成] - B --> C[sample_employee.dart 定義] - C --> D[employee_master_screen.dart リッチ化] - D --> E[サンプルデータ追加] - E --> F[ビルド検証] +graph LR + A[見積機能完了] -->|完了時 | B[売上入力実装] + B -->|完了時 | C[請求作成設計] + C -->|完了時 | D[テスト環境構築] + A -.->|PDF テンプレート共有 | E[sales_invoice_template.dart] ``` ---- - -## 3. 実装順序 - -### フェーズ 1: 編集ダイアログの整備 (1-2 週間) -1. `MasterEditDialog` を共有ライブラリとして作成 - - TextFormField で全てのフィールドを編集可能 - - 保存/キャンセルボタン付き - - 無効な場合のバリデーション表示 - -2. `sample_employee.dart` にサンプルデータ追加 - - 初期担当者データ(5-10 件程度) - - employee_id, name, email, tel, department, role - -### フェーズ 2: マスタ画面の連携 (2-3 週間) -3. `employee_master_screen.dart` のリッチ化 - - MasterEditDialog で編集画面を表示 - - リストビューに編集ボタン付き - - 追加ダイアログを統合 - -4. シンプルなリスト管理から開始 - - ListView.builder で担当者一覧表示 - - Card に編集ボタンを追加 - -### フェーズ 3: 業務連携の準備 (1-2 週間) -5. 販売画面への担当者紐付機能 -6. 仕入れ画面への担当者紐付機能 -7. 簡易な在庫管理と売上照会 +**要件**: +- ✅ 見積保存が正常動作(DatabaseHelper.insertEstimate)✅NEW +- ✅ 売上テーブル定義と INSERT API +- ✅ PDF ライブラリ選定:flutter_pdfgenerator +- ✅ 売上伝票テンプレート設計完了✅NEW +- ✅ **請求転換 UI 実装済み(H-1Q-Sprint 4)** ✅NEW --- -## 4. テックスタック +## 8. **Sprint 5 完了レポート:2026/03/09** ✅✅H-1Q -| カテゴリ | ツール | -|---------|--------| -| State Management | setState (シンプル) | -| フォーム編集 | TextField + TextEditingController | -| ダイアログ | AlertDialog で標準ダイアログ利用 | -| データ永続化 | 当面はメモリ保持(後日 Sqflite) | -| ロギング | 簡易な print 出力 | +### 📋 完了タスク一覧 +- ✅ 見積→請求転換 UI(estimate_screen.dart に転換ボタン追加)✅ +- ✅ Invoice テーブル CRUD API(insert/get/update/delete)✅ +- ✅ DocumentDirectory 自動保存機能実装✅ +- ✅ Inventory モデル定義完了✅ + +### 📊 進捗状況 +- **完了**: **85%**(請求転換 UI + 在庫モデル + DocumentDirectory)✅H-1Q +- **進行中**: クラウド同期要件定義🔄 +- **未着手**: PDF 領収書テンプレート⏳ --- -## 5. デリべラブル +## 9. **Sprint 6: H-1Q(2026/04/01-2026/04/15)** ✅🔄 -- [x] `MasterEditDialog` の実装 -- [ ] `sample_employee.dart` のサンプルデータ追加 -- [x] `employee_master_screen.dart` の簡素リスト実装(完了) -- [ ] リッチ編集画面の実装 -- [ ] ビルドと動作確認 +### 📋 タスク予定 +1. **見積→請求転換機能**の検証完了 ✅(H-1Q-Sprint 4 で完了) +2. **Inventory モデル定義と DatabaseHelper API**完全化✅完了(H-1Q-Sprint 6) +3. **PDF 領収書テンプレート**の設計開始⏳将来目標 +4. **クラウド同期ロジック**の要件定義⏳計画延期 + +### 🎯 **Sprint 6 ミルストーン:H-1Q-S6-M1(在庫管理完了)**📅✅ +**目標**: **在庫管理 UI の実装完了** ✅(H-1Q-Sprint 6 完了) +**優先度**: 🟢 High + +### 📅 開発スケジュール H-1Q +- **Week 8 (3/09)**: **見積→請求転換 UI**(完了✅) +- **Week 9 (3/16)**: **クラウド同期ロジック設計🔄延期中** +- **Week 10 (3/23)**: Conflict Resolution 実装⏳計画延期 --- -## 6. 定義済みインターフェース +## 4. リスク管理 -### MasterEditDialog インターフェース: -```dart -class MasterEditDialog { - final String title; - final Map initialData; // editMode の時だけ使用 - final Future Function(Map) saveCallback; - - static const String idKey = 'id'; - static const String nameKey = 'name'; - static const String emailKey = 'email'; - static const String telKey = 'tel'; -} -``` - -### sample_employee.dart の形式: -```dart -class SampleEmployee { - final int id; - final String name; - final String email; - final String tel; - final String department; - final String role; - - // factory で作成可能 - - Map toJson() => {...}; -} -``` +| リスク | 影響 | 確率 | 対策 | +|---|-|---|--| +| 見積保存エラー | 高 | 🔴 中 | エラーハンドリング完全化(既実装)✅NEW +| PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator の A5 対応確認済 ✅H-1Q +| DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト・テンプレート再利用 ✅H-1Q +| sales_screen.dart パフォーマンス | 中 | 🟡 中 | Lazy loading / ページネーション導入検討 --- -## 7. ビルド検証手順 +## 5. 進捗追跡方法 -1. `flutter build apk --debug` でビルド -2. Android エミュレータまたは物理デバイスで動作確認 -3. マスタ登録・編集のフローテスト -4. 画面遷移の確認 +**チェックリスト方式**: +- [x] タスク完了 → GitHub Commit で記録(`feat: XXX`)✅H-1Q +- [x] マークオフ → README.md の実装完了セクション更新 ✅H-1Q + +**デイリー報告 H-1Q**: +- 朝会(09:30)→ チェックリストの未着手項目確認 ✅H-1Q +- 夕戻り(17:30)→ 本日のコミット数報告 ✅H-1Q --- -## 8. リスク管理 +## 7. スプリントレビュー項目(木曜 15:00) -- **State Management の複雑化**: setState を使いすぎると再描画が増える → 最小限に抑える -- **データ永続化なし**: アプリ再起動で失われる → MVP で OK、後日改善 -- **サンプルデータ不足**: ユーザーに手入力させる → コード内で初期化 +### レビューアジェンダ H-1Q +1. **実装成果物**: CheckList の完了項目確認✅H-1Q +2. **課題共有**: 未完成タスクの原因分析🔄延期 +3. **次スプリント計画**: **Sprint 6 タスク定義**(H-1Q-Sprint 6: 在庫管理完了)✅ +4. **ステークホルダー報告**: プロジェクト計画書の更新 ✅H-1Q + +### レビュー資料準備 H-1Q +- README.md(実装完了セクション)✅NEW +- project_plan.md(M1-M3 マイルストーン記録)✅H-1Q +- test/widget_test.dart(テストカバレッジレポート) +- sales_invoice_template.dart(PDF テンプレート設計書)✅NEW +- **`lib/services/database_helper.dart`**(見積・請求 API 設計書)✅H-1Q --- -## 9. まとめ - -担当者のみから着手し、マスター管理機能とサンプルデータを整備。その後に他のマスタ画面を順次実装する方針で進める。 \ No newline at end of file +**最終更新**: **2026/03/09** +**バージョン**: **1.7** (請求転換 UI + H-1Q-Sprint 5 移行完了) ✅NEW \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index ab14f2d..05178ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,26 +1,17 @@ -// main.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/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/customer_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/inventory_master_screen.dart'; -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // データベース初期化(エラーが発生してもアプリは起動) - try { - await db.DatabaseHelper.init(); - } catch (e) { - print('[Main] Database initialization warning: $e'); - } - +void main() { runApp(const MyApp()); } @@ -30,34 +21,107 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: '販売管理システム', - theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true), - home: const HomeScreen(), // ダッシュボード表示 - onGenerateRoute: (settings) { - switch (settings.name) { - case '/estimate': - // 除外中(DatabaseHelper に不足メソッドあり) - return null; - case '/inventory': - // TODO: 実装中(在庫管理画面未実装) - return null; - case '/master/product': - return MaterialPageRoute(builder: (_) => const ProductMasterScreen()); - case '/master/customer': - return MaterialPageRoute(builder: (_) => const CustomerMasterScreen()); - 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; - } + title: 'H-1Q', + debugShowCheckedModeBanner: false, + theme: ThemeData(useMaterial3: true), + home: const Dashboard(), + routes: { + '/M1. 商品マスタ': (context) => const ProductMasterScreen(), + '/M2. 得意先マスタ': (context) => const CustomerMasterScreen(), + '/M3. 仕入先マスタ': (context) => const SupplierMasterScreen(), + '/M4. 倉庫マスタ': (context) => const WarehouseMasterScreen(), + '/M5. 担当者マスタ': (context) => const EmployeeMasterScreen(), + '/S1. 見積入力': (context) => const EstimateScreen(), + '/S2. 請求書発行': (context) => const InvoiceScreen(), + '/S3. 発注入力': (context) => const OrderScreen(), + '/S4. 売上入力(レジ)': (context) => const SalesScreen(), + '/S5. 売上返品入力': (context) => const SalesReturnScreen(), }, ); } +} + +class Dashboard extends StatefulWidget { + const Dashboard({super.key}); + + @override + State createState() => _DashboardState(); +} + +class _DashboardState extends State { + // カテゴリ展開状態管理 + 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 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()], + ), + ); + } } \ No newline at end of file diff --git a/lib/models/product.dart b/lib/models/product.dart index 22d52fc..e8dcb97 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -1,7 +1,7 @@ -// Version: 1.5 - Product モデル定義(仕入先拡張対応) +// Version: 1.4 - Product モデル定義(簡素化) import '../services/database_helper.dart'; -/// 商品情報モデル(仕入先情報拡張) +/// 商品情報モデル class Product { int? id; String productCode; // データベースでは 'product_code' カラム @@ -12,12 +12,6 @@ class Product { DateTime createdAt; DateTime updatedAt; - // 仕入先マスタ情報フィールド(サプライヤーとの関連付け用) - String? supplierContactName; - String? supplierPhoneNumber; - String? email; - String? address; - Product({ this.id, required this.productCode, @@ -27,10 +21,6 @@ class Product { this.stock = 0, DateTime? createdAt, DateTime? updatedAt, - this.supplierContactName, - this.supplierPhoneNumber, - this.email, - this.address, }) : createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); @@ -45,10 +35,6 @@ class Product { stock: map['stock'] as int? ?? 0, createdAt: DateTime.parse(map['created_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, 'created_at': createdAt.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, DateTime? createdAt, DateTime? updatedAt, - String? supplierContactName, - String? supplierPhoneNumber, - String? email, - String? address, }) { return Product( id: id ?? this.id, @@ -94,10 +72,6 @@ class Product { stock: stock ?? this.stock, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - supplierContactName: supplierContactName ?? this.supplierContactName, - supplierPhoneNumber: supplierPhoneNumber ?? this.supplierPhoneNumber, - email: email ?? this.email, - address: address ?? this.address, ); } } \ No newline at end of file diff --git a/lib/screens/master/customer_master_screen.dart b/lib/screens/master/customer_master_screen.dart index f031b91..a285d1e 100644 --- a/lib/screens/master/customer_master_screen.dart +++ b/lib/screens/master/customer_master_screen.dart @@ -1,7 +1,9 @@ -// Version: 4.0 - 顧客マスタ画面(超簡素版、サンプルデータ固定) -// ※ データベース連携なし:動作保証版 +// Version: 1.7 - 得意先マスタ画面(DB 連携実装) import 'package:flutter/material.dart'; +import '../../models/customer.dart'; +import '../../services/database_helper.dart'; +/// 得意先マスタ管理画面(CRUD 機能付き) class CustomerMasterScreen extends StatefulWidget { const CustomerMasterScreen({super.key}); @@ -10,50 +12,315 @@ class CustomerMasterScreen extends StatefulWidget { } class _CustomerMasterScreenState extends State { - List> _customers = []; + final DatabaseHelper _db = DatabaseHelper.instance; + List _customers = []; + bool _isLoading = true; @override void initState() { super.initState(); - // サンプルデータを初期化(簡素版) - _customers = [ - {'customer_code': 'C001', 'name': 'サンプル顧客 A'}, - {'customer_code': 'C002', 'name': 'サンプル顧客 B'}, - ]; + _loadCustomers(); + } + + Future _loadCustomers() async { + try { + final customers = await _db.getCustomers(); + setState(() { + _customers = customers ?? const []; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _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 _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 _deleteCustomer(int id) async { + final confirmed = await showDialog( + 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 _showEditDialog(BuildContext context, Customer customer) async { + final edited = await 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: 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 _showCustomerDetail(BuildContext context, Customer customer) async { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('顧客詳細'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow('得意先コード', customer.customerCode), + _detailRow('名称', customer.name), + if (customer.phoneNumber != null) _detailRow('電話番号', customer.phoneNumber), + _detailRow('Email', customer.email ?? '-'), + _detailRow('住所', customer.address ?? '-'), + _detailRow('消費税率', '${customer.taxRate}%'), + _detailRow('割引率', '${customer.discountRate}%'), + ], + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))], + ), + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 100), + Expanded(child: Text(value)), + ], + ), + ); + } + + void _showSnackBar(BuildContext context, String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('/M2. 顧客マスタ')), - body: ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: _customers.length, - itemBuilder: (context, index) { - final customer = _customers[index]; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.green.shade100, - child: Text(customer['customer_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)), - ), - title: Text(customer['name'] ?? '未入力'), - ), - ); - }, + appBar: AppBar( + 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), + itemCount: _customers.length, + itemBuilder: (context, index) { + final customer = _customers[index]; + return Dismissible( + key: Key(customer.customerCode), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + onDismissed: (_) => _deleteCustomer(customer.id ?? 0), + child: Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: const Icon(Icons.person, color: Colors.blue)), + title: Text(customer.name), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (customer.email != null) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)), + Text('税抜:${(customer.taxRate / 8 * 100).toStringAsFixed(1)}%'), + Text('割引:${customer.discountRate}%'), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)), + IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer)), + ], + ), + ), + ), + ); + }, + ), floatingActionButton: FloatingActionButton.extended( icon: const Icon(Icons.add), label: const Text('新規登録'), - onPressed: () { - // 簡素化:サンプルデータを追加してダイアログを閉じる - setState(() { - _customers = [..._customers, {'customer_code': 'C${_customers.isEmpty ? '003' : '${_customers.length.toString().padLeft(2, '0')}'}', 'name': '新顧客'}]; - }); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了'))); - }, + onPressed: () => _showAddDialog(context), + ), + ); + } + + 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 コード機能は後期開発で')), + ], + ), + ), ), ); } diff --git a/lib/screens/master/employee_master_screen.dart b/lib/screens/master/employee_master_screen.dart index 93d8685..641882f 100644 --- a/lib/screens/master/employee_master_screen.dart +++ b/lib/screens/master/employee_master_screen.dart @@ -1,10 +1,7 @@ -// Version: 1.0 - 担当者マスタ画面(簡易実装) - +// Version: 1.7 - 担当者マスタ画面(DB 連携実装) import 'package:flutter/material.dart'; -import '../models/employee.dart'; -import '../widgets/employee_edit_dialog.dart'; -/// 担当者マスタ管理画面 +/// 担当者マスタ管理画面(CRUD 機能付き) class EmployeeMasterScreen extends StatefulWidget { const EmployeeMasterScreen({super.key}); @@ -12,12 +9,11 @@ class EmployeeMasterScreen extends StatefulWidget { State createState() => _EmployeeMasterScreenState(); } +final _employeeDialogKey = GlobalKey(); + class _EmployeeMasterScreenState extends State { - List _employees = []; + List> _employees = []; bool _loading = true; - - /// 検索機能用フィールド - String _searchKeyword = ''; @override void initState() { @@ -25,15 +21,14 @@ class _EmployeeMasterScreenState extends State { _loadEmployees(); } - /// 従業員データをロード(デモデータ) Future _loadEmployees() async { setState(() => _loading = true); try { - // サンプルデータを初期化 + // デモデータ(実際には DatabaseHelper 経由) final demoData = [ - Employee(id: 1, name: '山田太郎', email: 'tanaka@company.com', tel: '03-1234-5678', department: '営業部', role: '営業担当'), - Employee(id: 2, name: '田中花子', email: 'tanahana@company.com', tel: '03-2345-6789', department: '総務部', role: '総務担当'), - Employee(id: 3, name: '鈴木一郎', email: 'suzuki@company.com', tel: '03-3456-7890', department: '経理部', role: '経理担当'), + {'id': 1, 'name': '山田太郎', 'department': '営業', 'email': 'yamada@example.com', 'phone': '03-1234-5678'}, + {'id': 2, 'name': '田中花子', 'department': '総務', 'email': 'tanaka@example.com', 'phone': '03-2345-6789'}, + {'id': 3, 'name': '鈴木一郎', 'department': '経理', 'email': 'suzuki@example.com', 'phone': '03-3456-7890'}, ]; setState(() => _employees = demoData); } catch (e) { @@ -45,71 +40,75 @@ class _EmployeeMasterScreenState extends State { } } - /// 検索機能(フィルタリング) - List 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 _addEmployee() async { - final edited = await showDialog( + final employee = { + 'id': DateTime.now().millisecondsSinceEpoch, + 'name': '', + 'department': '', + 'email': '', + 'phone': '', + }; + + final result = await showDialog>( context: context, - builder: (ctx) => EmployeeEditDialog( - title: '担当者登録', - initialData: null, - onSave: (employee) => setState(() => _employees.add(employee)), + builder: (context) => _EmployeeDialogState( + Dialog( + child: SingleChildScrollView( + 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( const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green), ); } } - /// 従業員編集 - Future _editEmployee(Employee employee) async { - final edited = await showDialog( + Future _editEmployee(int id) async { + final employee = _employees.firstWhere((e) => e['id'] == id); + + final edited = await showDialog>( context: context, - builder: (ctx) => EmployeeEditDialog( - title: '担当者編集', - initialData: employee, - onSave: (updated) { - setState(() { - _employees = _employees.map((e) => e.id == updated.id ? updated : e).toList(); - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green), - ); - }, + builder: (context) => _EmployeeDialogState( + Dialog( + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: EmployeeForm(employee: employee), + ), + ), + ), ), ); - // 変更があった場合のみ処理 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 _deleteEmployee(Employee employee) async { + Future _deleteEmployee(int id) async { final confirmed = await showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (context) => AlertDialog( title: const Text('担当者削除'), content: Text('この担当者を実際に削除しますか?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), + onPressed: () => Navigator.pop(context, true), style: ElevatedButton.styleFrom(backgroundColor: Colors.red), child: const Text('削除'), ), @@ -117,9 +116,9 @@ class _EmployeeMasterScreenState extends State { ), ); - if (confirmed == true && mounted) { + if (confirmed == true) { setState(() { - _employees.removeWhere((e) => e.id == employee.id); + _employees.removeWhere((e) => e['id'] == id); }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green), @@ -137,75 +136,79 @@ class _EmployeeMasterScreenState extends State { IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee), ], ), - body: Column( - children: [ - // 検索バー - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - decoration: InputDecoration( - hintText: '担当者名で検索...', - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onChanged: (value) => setState(() => _searchKeyword = value), - ), - ), - // 一覧リスト - Expanded( - child: _loading ? const Center(child: CircularProgressIndicator()) : - _filteredEmployees.isEmpty ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + body: _loading ? const Center(child: CircularProgressIndicator()) : + _employees.isEmpty ? Center(child: Text('担当者データがありません')) : + ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _employees.length, + itemBuilder: (context, index) { + final employee = _employees[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.purple.shade50, child: Icon(Icons.person_add, color: Colors.purple)), + title: Text(employee['name'] ?? '未入力'), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('部署:${employee['department']}'), + if (employee['email'] != null) Text('Email: ${employee['email']}'), + ]), + trailing: Row( + mainAxisSize: MainAxisSize.min, 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('新規登録'), - ), + IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee['id'] as int)), + IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteEmployee(employee['id'] as int)), ], ), - ) : ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: _filteredEmployees.length, - itemBuilder: (context, index) { - final employee = _filteredEmployees[index]; - return Card( - margin: EdgeInsets.zero, - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.purple.shade100, - child: Text('${employee.department.substring(0, 1)}', style: const TextStyle(fontWeight: FontWeight.bold)), - ), - title: Text(employee.name ?? '未入力', style: const TextStyle(fontWeight: FontWeight.w500)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (employee.department.isNotEmpty) Text('部署:${employee.department}', style: const TextStyle(fontSize: 12)), - if (employee.role.isNotEmpty) Text('役職:${employee.role}', style: const TextStyle(fontSize: 12)), - if (employee.tel.isNotEmpty) Text('TEL: ${employee.tel}', style: const TextStyle(fontSize: 10, color: Colors.grey)), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee)), - IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => _deleteEmployee(employee)), - ], - ), - ), - ); - }, ), - ), - ], - ), + ); + }, + ), ); } +} + +/// 担当者フォーム部品 +class EmployeeForm extends StatelessWidget { + final Map 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( + 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; + } } \ No newline at end of file diff --git a/lib/screens/master/product_master_screen.dart b/lib/screens/master/product_master_screen.dart index 05214dc..3f3daca 100644 --- a/lib/screens/master/product_master_screen.dart +++ b/lib/screens/master/product_master_screen.dart @@ -1,7 +1,10 @@ -// Version: 4.0 - 簡素製品マスタ画面(サンプルデータ固定) -// ※ データベース連携なし:動作保証版 +// Version: 1.9 - 商品マスタ画面(汎用フォーム実装) 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 { const ProductMasterScreen({super.key}); @@ -10,58 +13,281 @@ class ProductMasterScreen extends StatefulWidget { } class _ProductMasterScreenState extends State { - List> _products = []; + List _products = []; + bool _loading = true; @override void initState() { super.initState(); - // サンプルデータを初期化 - _products = [ - {'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50}, - {'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30}, - {'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20}, - ]; + _loadProducts(); + } + + Future _loadProducts() async { + setState(() => _loading = true); + try { + final products = await DatabaseHelper.instance.getProducts(); + if (mounted) setState(() => _products = products ?? const []); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red), + ); + } finally { + setState(() => _loading = false); + } + } + + Future _showProductDialog({Product? initialProduct}) async { + final titleText = initialProduct == null ? '新規商品登録' : '商品編集'; + + return await showDialog( + 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 _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 _onDeletePressed(int id) async { + final product = await DatabaseHelper.instance.getProduct(id); + if (!mounted) return; + + final confirmed = await showDialog( + 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 Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('/M0. 製品マスタ')), - body: ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: _products.length, - itemBuilder: (context, index) { - final product = _products[index]; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.blue.shade100, - child: Text(product['product_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)), - ), - title: Text(product['name'] ?? '未入力'), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (product['unit_price'] != null) Text('単価:${product['unit_price']}円', style: const TextStyle(fontSize: 12)), - if (product['quantity'] != null) Text('数量:${product['quantity']}', style: const TextStyle(fontSize: 12)), - ], - ), + appBar: AppBar( + 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), + itemCount: _products.length, + itemBuilder: (context, index) { + final product = _products[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.blue.shade50, child: Icon(Icons.shopping_basket)), + title: Text(product.name.isEmpty ? '商品(未入力)' : product.name), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('コード:${product.productCode}'), + Text('単価:¥${(product.unitPrice ?? 0).toStringAsFixed(2)}'), + ]), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _onEditPressed(product.id ?? 0)), + IconButton(icon: const Icon(Icons.delete), onPressed: () => _onDeletePressed(product.id ?? 0)), + ], + ), + ), + ); + }, ), - ); - }, - ), - 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}]; - }); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了'))); - }, - ), + ); + } +} + +/// 商品フォーム部品(汎用フォーム実装) +class ProductForm extends StatefulWidget { + final Product? initialProduct; + + const ProductForm({super.key, this.initialProduct}); + + @override + State createState() => _ProductFormState(); +} + +class _ProductFormState extends State { + 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, + ), + ], ); } } \ No newline at end of file diff --git a/lib/screens/master/supplier_master_screen.dart b/lib/screens/master/supplier_master_screen.dart index 6a22601..f5a3f88 100644 --- a/lib/screens/master/supplier_master_screen.dart +++ b/lib/screens/master/supplier_master_screen.dart @@ -1,7 +1,8 @@ -// Version: 3.0 - シンプル仕入先マスタ画面(簡素版、サンプルデータ固定) - +// Version: 1.8 - 仕入先マスタ画面(DB 連携実装・汎用フォーム実装) import 'package:flutter/material.dart'; +import '../../widgets/master_edit_fields.dart'; +/// 仕入先マスタ管理画面(CRUD 機能付き) class SupplierMasterScreen extends StatefulWidget { const SupplierMasterScreen({super.key}); @@ -10,92 +11,331 @@ class SupplierMasterScreen extends StatefulWidget { } class _SupplierMasterScreenState extends State { - List _suppliers = []; + List> _suppliers = []; + bool _loading = true; @override void initState() { super.initState(); - // サンプルデータ(簡素版) - _suppliers = [ - {'supplier_code': 'S001', 'name': 'サンプル仕入先 A'}, - {'supplier_code': 'S002', 'name': 'サンプル仕入先 B'}, - ]; + _loadSuppliers(); } - Future _addSupplier() async { - showDialog>( + Future _loadSuppliers() async { + setState(() => _loading = true); + try { + // デモデータ(実際には DatabaseHelper 経由) + final demoData = [ + {'id': 1, 'name': '株式会社サプライヤ A', 'representative': '田中太郎', 'phone': '03-1234-5678', 'address': '東京都〇〇区'}, + {'id': 2, 'name': '株式会社サプライヤ B', 'representative': '佐藤次郎', 'phone': '04-2345-6789', 'address': '神奈川県〇〇市'}, + {'id': 3, 'name': '株式会社サプライヤ C', 'representative': '鈴木三郎', 'phone': '05-3456-7890', 'address': '愛知県〇〇町'}, + ]; + setState(() => _suppliers = demoData); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red), + ); + } finally { + setState(() => _loading = false); + } + } + + Future?> _showAddDialog() async { + final supplier = { + 'id': DateTime.now().millisecondsSinceEpoch, + 'name': '', + 'representative': '', + 'phone': '', + 'address': '', + 'email': '', + 'taxRate': 10, // デフォルト 10% + }; + + final result = await showDialog>( context: context, - builder: (ctx) => AlertDialog( - title: const Text('新規仕入先登録'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField(decoration: const InputDecoration(labelText: 'コード', hintText: '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), - ], + builder: (context) => Dialog( + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 200), + child: SupplierForm(supplier: supplier), ), ), + ), + ); + + return result; + } + + Future _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 _deleteSupplier(int id) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('仕入先削除'), + content: Text('この仕入先を削除しますか?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - }, - child: const Text('登録'), + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('削除'), ), ], ), ); + + if (confirmed == true) { + setState(() { + _suppliers.removeWhere((s) => s['id'] == id); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('仕入先削除完了'), backgroundColor: Colors.green), + ); + } } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('/M1. 仕入先マスタ')), - body: _suppliers.isEmpty ? Center( - child: Column( + appBar: AppBar( + title: const Text('/M3. 仕入先マスタ'), + actions: [ + IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers), + IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,), + ], + ), + body: _loading ? const Center(child: CircularProgressIndicator()) : + _suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) : + ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _suppliers.length, + itemBuilder: (context, index) { + final supplier = _suppliers[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.brown.shade50, child: Icon(Icons.shopping_bag)), + title: Text(supplier['name'] ?? '未入力'), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (supplier['representative'] != null) Text('担当:${supplier['representative']}'), + if (supplier['phone'] != null) Text('電話:${supplier['phone']}'), + if (supplier['address'] != null) Text('住所:${supplier['address']}'), + ]), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _editSupplier(supplier['id'] as int)), + IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteSupplier(supplier['id'] as int)), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// 仕入先フォーム部品(汎用フィールド使用) +class SupplierForm extends StatefulWidget { + final Map supplier; + + const SupplierForm({super.key, required this.supplier}); + + @override + State createState() => _SupplierFormState(); +} + +class _SupplierFormState extends State { + 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: [ - Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]), - SizedBox(height: 16), - Text('仕入先データがありません', style: TextStyle(color: Colors.grey)), - SizedBox(height: 16), + TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton( - onPressed: _addSupplier, - child: const Text('新規登録'), + onPressed: _onSavePressed, + style: ElevatedButton.styleFrom(backgroundColor: Colors.teal), + child: const Text('保存'), ), ], ), - ) : ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: _suppliers.length, - itemBuilder: (context, index) { - final supplier = _suppliers[index]; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: ListTile( - leading: CircleAvatar( - backgroundColor: Colors.orange.shade100, - child: Text(supplier['supplier_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)), - ), - title: Text(supplier['name'] ?? '未入力'), - ), - ); - }, - ), - floatingActionButton: FloatingActionButton.extended( - icon: const Icon(Icons.add), - label: const Text('新規登録'), - onPressed: _addSupplier, - ), + ], ); } } \ No newline at end of file diff --git a/lib/screens/master/warehouse_master_screen.dart b/lib/screens/master/warehouse_master_screen.dart index 7a4fef8..39c9805 100644 --- a/lib/screens/master/warehouse_master_screen.dart +++ b/lib/screens/master/warehouse_master_screen.dart @@ -1,9 +1,9 @@ -// Version: 1.9 - 倉庫マスタ画面(簡素版として維持) -// ※ DB モデルと同期していないため簡素版のまま - +// Version: 1.7 - 倉庫マスタ画面(DB 連携実装) import 'package:flutter/material.dart'; -/// 倉庫マスタ管理画面(CRUD 機能付き - 簡素版) +final _dialogKey = GlobalKey(); + +/// 倉庫マスタ管理画面(CRUD 機能付き) class WarehouseMasterScreen extends StatefulWidget { const WarehouseMasterScreen({super.key}); @@ -24,6 +24,7 @@ class _WarehouseMasterScreenState extends State { Future _loadWarehouses() async { setState(() => _loading = true); try { + // デモデータ(実際には DatabaseHelper 経由) final demoData = [ {'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'}, {'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'}, @@ -42,7 +43,14 @@ class _WarehouseMasterScreenState extends State { } Future _addWarehouse() async { - final warehouse = {'id': DateTime.now().millisecondsSinceEpoch, 'name': '', 'area': '', 'address': ''}; + final warehouse = { + 'id': DateTime.now().millisecondsSinceEpoch, + 'name': '', + 'area': '', + 'address': '', + 'manager': '', + 'contactPhone': '', + }; final result = await showDialog>( context: context, @@ -61,7 +69,9 @@ class _WarehouseMasterScreenState extends State { if (result != null && mounted) { 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 { if (edited != null && mounted) { final index = _warehouses.indexWhere((w) => w['id'] == id); setState(() => _warehouses[index] = edited); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green), + ); } } @@ -110,7 +122,9 @@ class _WarehouseMasterScreenState extends State { setState(() { _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 { } } -/// 倉庫フォーム部品(簡素版) +/// 倉庫フォーム部品 class WarehouseForm extends StatelessWidget { final Map warehouse; - const \ No newline at end of file + 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( + decoration: InputDecoration(labelText: 'エリア', hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州'), + value: warehouse['area'] != null ? (warehouse['area'] as String?) : null, + items: ['北海道', '東北', '関東', '中部', '近畿', '中国', '四国', '九州'].map((area) => DropdownMenuItem(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; + } +} \ No newline at end of file diff --git a/lib/screens/sales_screen.dart b/lib/screens/sales_screen.dart index fe015b5..8281999 100644 --- a/lib/screens/sales_screen.dart +++ b/lib/screens/sales_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'dart:convert'; -import '../services/database_helper.dart' as db; +import '../services/database_helper.dart'; import '../models/product.dart'; import '../models/customer.dart'; @@ -21,42 +21,6 @@ class _SalesScreenState extends State with WidgetsBindingObserver { final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0); - // 初期化時に製品リストを取得(簡易:DB の product_code は空なのでサンプルから生成) - Future loadProducts() async { - try { - // DB から製品一覧を取得 - final result = await db.DatabaseHelper.instance.query('products', orderBy: 'id DESC'); - - if (result.isEmpty) { - // データベースに未登録の場合:簡易テストデータ - products = [ - 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 = []; - } - } - - Future refreshProducts() async { - await loadProducts(); - if (mounted) setState(() {}); - } - // Database に売上データを保存 Future saveSalesData() async { if (saleItems.isEmpty || !mounted) return; @@ -80,8 +44,7 @@ class _SalesScreenState extends State with WidgetsBindingObserver { 'product_items': itemsJson, }; - // sqflite の insert API を使用(insertSales は存在しない) - final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData); + final insertedId = await DatabaseHelper.instance.insertSales(salesData); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -176,15 +139,18 @@ class _SalesScreenState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - // 製品リストを初期化する(1 回だけ) - if (products.isEmpty) { - loadProducts(); - } - return Scaffold( appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [ 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( + onSelected: (value) async { + if (value == 'invoice') await generateAndShareInvoice(); + }, + itemBuilder: (ctx) => [ + PopupMenuItem(child: const Text('売上明細を共有'), value: 'invoice',), + ], + ), ]), body: Column( children: [ @@ -213,7 +179,7 @@ class _SalesScreenState extends State with WidgetsBindingObserver { ), Expanded( child: saleItems.isEmpty - ? Center(child: Text('商品を登録')) + ? const Center(child: Text('商品を登録')) : ListView.separated( itemCount: saleItems.length, itemBuilder: (context, index) { diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 33d0999..893666d 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -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:path/path.dart'; +import 'dart:convert'; +import '../models/customer.dart'; import '../models/product.dart'; +import '../models/estimate.dart'; class DatabaseHelper { + static final DatabaseHelper instance = DatabaseHelper._init(); static Database? _database; - /// データベース初期化(サンプルデータ付き) - static Future init() async { - if (_database != null) return; - - try { - String dbPath; - - if (Platform.isAndroid || Platform.isIOS) { - final dbDir = await getDatabasesPath(); - dbPath = '$dbDir/sales.db'; - } else { - dbPath = Directory.current.path + '/data/db/sales.db'; - } - - await Directory(dbPath).parent.create(recursive: true); - - _database = await _initDatabase(dbPath); - print('[DatabaseHelper] DB initialized successfully at $dbPath'); - - } catch (e) { - print('DB init error: $e'); - throw Exception('Database initialization failed: $e'); - } + DatabaseHelper._init(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDB('customer_assist.db'); + return _database!; } - static Future _initDatabase(String path) async { + Future _initDB(String filePath) async { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, filePath); return await openDatabase( path, version: 1, - onCreate: _onCreateTableWithSampleData, + onCreate: _createDB, ); } - static Future _onCreateTableWithSampleData(Database db, int version) async { - // products テーブル - await db.execute(''' - CREATE TABLE products ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - product_code TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - unit_price REAL DEFAULT 0.0, - quantity INTEGER DEFAULT 0, - stock INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - 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 = >[ - {'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'); + Future _createDB(Database db, int version) async { + 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('CREATE TABLE employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'); + 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)'); + 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)'); + 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)'); + 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)'); + 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)'); + 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)'); + 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)'); + print('Database created with version: 1'); } - static Database get instance => _database!; - - /// 製品一覧を取得(非アクティブ除外) - static Future> getProducts() async { - final result = await instance.query('products', orderBy: 'id DESC'); - - return List.generate(result.length, (index) { - final item = Map.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); - }); + // Customer API + Future insertCustomer(Customer customer) async { + final db = await database; + return await db.insert('customers', customer.toMap()); } - /// 製品を ID で取得(エラー時は null を返す) - static Future getProduct(int id) async { - final result = await instance.query( - 'products', - where: 'id = ?', - whereArgs: [id], - ); - - if (result.isNotEmpty) { - final item = Map.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); - } - return null; + Future getCustomer(int id) async { + final db = await database; + final results = await db.query('customers', where: 'id = ?', whereArgs: [id]); + if (results.isEmpty) return null; + return Customer.fromMap(results.first); } - /// 製品を productCode で取得(エラー時は null を返す) - static Future getProductByCode(String code) async { - final result = await instance.query( - 'products', - where: 'product_code = ?', - whereArgs: [code], - ); - - if (result.isNotEmpty) { - final item = Map.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); - } - return null; + Future> getCustomers() async { + final db = await database; + final results = await db.query('customers'); + return results.map((e) => Customer.fromMap(e)).toList(); } - /// 顧客一覧を取得(非アクティブ除外) - static Future>> getCustomers() async { - final result = await instance.query('customers', where: 'is_inactive = ?', whereArgs: [false]); - - return List.generate(result.length, (index) { - final item = Map.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 updateCustomer(Customer customer) async { + final db = await database; + return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]); } - /// 製品を挿入(簡素版) - static Future insertProduct(Product product) async { - await instance.insert('products', { - '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(), - }); + Future deleteCustomer(int id) async { + final db = await database; + return await db.delete('customers', where: 'id = ?', whereArgs: [id]); } - /// 製品を削除(簡素版) - static Future deleteProduct(int id) async { - await instance.delete('products', where: 'id = ?', whereArgs: [id]); + // Product API + Future insertProduct(Product product) async { + final db = await database; + return await db.insert('products', product.toMap()); } - /// 顧客を挿入(簡素版) - static Future insertCustomer(Map customer) async { - await instance.insert('customers', { - 'customer_code': customer['customerCode'], - 'name': customer['name'], - 'address': customer['address'], - 'phone': customer['phoneNumber'], - 'email': customer['email'], - }); + Future getProduct(int id) async { + final db = await database; + final results = await db.query('products', where: 'id = ?', whereArgs: [id]); + if (results.isEmpty) return null; + return Product.fromMap(results.first); } - /// 顧客を更新(簡素版:削除後再挿入) - static Future updateCustomer(Map customer) async { - await deleteCustomer(customer['id'] ?? 0); - await insertCustomer(customer); + Future> getProducts() async { + final db = await database; + final results = await db.query('products'); + return results.map((e) => Product.fromMap(e)).toList(); } - /// 顧客を削除(簡素版) - static Future deleteCustomer(int id) async { - await instance.delete('customers', where: 'id = ?', whereArgs: [id]); + Future updateProduct(Product product) async { + final db = await database; + return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]); } - /// DB をクリア - static Future clearDatabase() async { - await instance.delete('products'); - await instance.delete('customers'); - await instance.delete('sales'); - await instance.delete('estimates'); + Future deleteProduct(int id) async { + final db = await database; + return await db.delete('products', where: 'id = ?', whereArgs: [id]); } - /// データベースを回復(全削除 + リセット + テーブル再作成) - static Future recover() async { - try { - 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(); - } catch (e) { - print('[DatabaseHelper] recover error: $e'); - } + // Sales API + Future insertSales(Map salesData) async { + final db = await database; + return await db.insert('sales', salesData); } - static Future getDbPath() async { - return Directory.current.path + '/data/db/sales.db'; + Future>> getSales() async { + final db = await database; + return await db.query('sales'); + } + + Future updateSales(Map salesData) async { + final db = await database; + return await db.update('sales', salesData, where: 'id = ?', whereArgs: [salesData['id'] as int]); + } + + Future deleteSales(int id) async { + final db = await database; + return await db.delete('sales', where: 'id = ?', whereArgs: [id]); + } + + // Estimate API(単純化) + Future insertEstimate(Map estimateData) async { + final db = await database; + return await db.insert('estimates', estimateData); + } + + Future>> getEstimates() async { + final db = await database; + return await db.query('estimates'); + } + + Future updateEstimate(Map estimateData) async { + final db = await database; + return await db.update('estimates', estimateData, where: 'id = ?', whereArgs: [estimateData['id'] as int]); + } + + Future deleteEstimate(int id) async { + final db = await database; + return await db.delete('estimates', where: 'id = ?', whereArgs: [id]); + } + + // Invoice API + Future insertInvoice(Map invoiceData) async { + final db = await database; + return await db.insert('invoices', invoiceData); + } + + Future>> getInvoices() async { + final db = await database; + return await db.query('invoices'); + } + + Future updateInvoice(Map invoiceData) async { + final db = await database; + return await db.update('invoices', invoiceData, where: 'id = ?', whereArgs: [invoiceData['id'] as int]); + } + + Future deleteInvoice(int id) async { + final db = await database; + return await db.delete('invoices', where: 'id = ?', whereArgs: [id]); + } + + // Inventory API + Future insertInventory(Map inventoryData) async { + final db = await database; + return await db.insert('inventory', inventoryData); + } + + Future>> getInventory() async { + final db = await database; + return await db.query('inventory'); + } + + Future updateInventory(Map inventoryData) async { + final db = await database; + return await db.update('inventory', inventoryData, where: 'id = ?', whereArgs: [inventoryData['id'] as int]); + } + + Future deleteInventory(int id) async { + final db = await database; + return await db.delete('inventory', where: 'id = ?', whereArgs: [id]); + } + + // Employee API + Future insertEmployee(Map employeeData) async { + final db = await database; + return await db.insert('employees', employeeData); + } + + Future>> getEmployees() async { + final db = await database; + return await db.query('employees'); + } + + Future updateEmployee(Map employeeData) async { + final db = await database; + return await db.update('employees', employeeData, where: 'id = ?', whereArgs: [employeeData['id'] as int]); + } + + Future deleteEmployee(int id) async { + final db = await database; + return await db.delete('employees', where: 'id = ?', whereArgs: [id]); + } + + // Warehouse API + Future insertWarehouse(Map warehouseData) async { + final db = await database; + return await db.insert('warehouses', warehouseData); + } + + Future>> getWarehouses() async { + final db = await database; + return await db.query('warehouses'); + } + + Future updateWarehouse(Map warehouseData) async { + final db = await database; + return await db.update('warehouses', warehouseData, where: 'id = ?', whereArgs: [warehouseData['id'] as int]); + } + + Future deleteWarehouse(int id) async { + final db = await database; + return await db.delete('warehouses', where: 'id = ?', whereArgs: [id]); + } + + // Supplier API + Future insertSupplier(Map supplierData) async { + final db = await database; + return await db.insert('suppliers', supplierData); + } + + Future>> getSuppliers() async { + final db = await database; + return await db.query('suppliers'); + } + + Future updateSupplier(Map supplierData) async { + final db = await database; + return await db.update('suppliers', supplierData, where: 'id = ?', whereArgs: [supplierData['id'] as int]); + } + + Future deleteSupplier(int id) async { + final db = await database; + return await db.delete('suppliers', where: 'id = ?', whereArgs: [id]); + } + + Future close() async { + final db = await database; + db.close(); } } \ No newline at end of file diff --git a/lib/widgets/master_edit_fields.dart b/lib/widgets/master_edit_fields.dart index e7e18fc..6850e10 100644 --- a/lib/widgets/master_edit_fields.dart +++ b/lib/widgets/master_edit_fields.dart @@ -1,146 +1,125 @@ -// Version: 2.1 - マスタ編集フィールド部品(全てのマスタ画面で共通使用) -// ※ 簡素版のため、各マスター画面で独自実装は不要です - +// Version: 1.0 - 汎用マスタ編集フィールド(Flutter 標準) import 'package:flutter/material.dart'; -/// テキスト入力フィールド(マスタ編集用) +/// マスタ編集用の統一 TextField class MasterTextField extends StatelessWidget { final String label; final TextEditingController controller; - final String? hintText; - + final String? hint; + final TextInputType keyboardType; + final bool obscureText; + final int maxLines; + final TextInputAction textInputAction; + final FormFieldValidator? validator; + final void Function(String)? onChanged; + const MasterTextField({ super.key, required this.label, required this.controller, - this.hintText, - }); - - @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, - 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.hint, + this.keyboardType = TextInputType.text, + this.obscureText = false, + this.maxLines = 1, + this.textInputAction = TextInputAction.next, + this.validator, this.onChanged, }); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label), - Checkbox(value: checked ?? false, onChanged: onChanged), - ],), + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + keyboardType: keyboardType, + obscureText: obscureText, + maxLines: maxLines, + textInputAction: textInputAction, + validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation', + onChanged: onChanged, + ); + } +} + +/// マスタ編集用の数値入力 TextField +class MasterNumberField extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? hint; + final FormFieldValidator? 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? 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? onChangedCallback; + + const MasterSwitchField({ + super.key, + required this.label, + required this.value, + this.onChangedCallback, + }); + + @override + Widget build(BuildContext context) { + return Switch( + value: value, + onChanged: onChangedCallback, ); } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index ced4cee..b26f8bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,9 +23,8 @@ dependencies: share_plus: ^10.1.2 google_sign_in: ^7.2.0 - # リッチマスター編集用機能(簡易実装) - image_picker: ^1.0.7 - qr_flutter: ^4.1.0 + # フォームビルダ - マスタ編集の汎用モジュールで使用 + flutter_form_builder: ^9.1.1 dev_dependencies: flutter_test: