From c33d117ef592c73fe299042361b5c771ff6e538d Mon Sep 17 00:00:00 2001 From: joe Date: Wed, 11 Mar 2026 15:01:30 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=93=E3=83=AB=E3=83=89=E6=88=90=E5=8A=9F?= =?UTF-8?q?=EF=BC=9ADB=20=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC=E7=B0=A1?= =?UTF-8?q?=E7=B4=A0=E5=8C=96=E3=83=BB=E3=83=9E=E3=82=B9=E3=82=BF=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=82=B7=E3=83=B3=E3=83=97=E3=83=AB=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .flutter-plugins-dependencies | 2 +- @ | 416 ++++++++++++ @workspace/lib/models/customer.dart | 82 +++ .../master/customer_master_screen.dart | 248 ++++++++ .../screens/master/product_master_screen.dart | 445 +++++++++++++ .../master/supplier_master_screen.dart | 474 ++++++++++++++ @workspace/lib/services/database_helper.dart | 353 ++++++++++ .../lib/widgets/master_edit_fields.dart | 602 ++++++++++++++---- @workspace/pubspec.yaml | 37 ++ README.md | 67 +- docs/project_specification.md | 409 ++++++++++++ lib/main.dart | 150 ++--- lib/models/product.dart | 30 +- lib/screens/emergency_recovery_screen.dart | 102 +++ lib/screens/home_screen.dart | 84 +++ .../master/customer_master_screen.dart | 348 ++-------- lib/screens/master/product_master_screen.dart | 324 ++-------- .../master/supplier_master_screen.dart | 372 ++--------- .../master/warehouse_master_screen.dart | 75 +-- lib/screens/sales_screen.dart | 58 +- lib/services/database_helper.dart | 495 +++++++------- lib/widgets/master_edit_dialog.dart | 104 +++ lib/widgets/master_edit_fields.dart | 205 +++--- pubspec.yaml | 5 +- 24 files changed, 4012 insertions(+), 1475 deletions(-) create mode 100644 @ create mode 100644 @workspace/lib/models/customer.dart create mode 100644 @workspace/lib/screens/master/customer_master_screen.dart create mode 100644 @workspace/lib/screens/master/product_master_screen.dart create mode 100644 @workspace/lib/screens/master/supplier_master_screen.dart create mode 100644 @workspace/lib/services/database_helper.dart create mode 100644 @workspace/pubspec.yaml create mode 100644 docs/project_specification.md create mode 100644 lib/screens/emergency_recovery_screen.dart create mode 100644 lib/screens/home_screen.dart create mode 100644 lib/widgets/master_edit_dialog.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index e4733c1..961dd7c 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":"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 +{"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+2/","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 12:59:45.393067","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/@ b/@ new file mode 100644 index 0000000..d8d4ae0 --- /dev/null +++ b/@ @@ -0,0 +1,416 @@ +// Version: 2.0 - マスター編集用汎用ウィジェット(簡易実装・互換性保持) +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'master_edit_fields.dart'; + +/// 簡易テキストフィールド(MasterTextField 互換) +class MasterTextField extends StatelessWidget { + final String label; + final String? hint; + final TextEditingController controller; + final String? Function(String?)? validator; + final bool readOnly; + + const MasterTextField({ + super.key, + required this.label, + this.hint, + required this.controller, + this.validator, + this.readOnly = false, + }); + + @override + Widget build(BuildContext context) { + return RichMasterTextField( + label: label, + initialValue: controller.text.isEmpty ? null : controller.text, + hintText: hint ?? '値を入力してください', + readOnly: readOnly, + ); + } +} + +/// 簡易数値フィールド(MasterNumberField 互換) +class MasterNumberField extends StatelessWidget { + final String label; + final String? hint; + final TextEditingController controller; + final String? Function(String?)? validator; + final bool readOnly; + + const MasterNumberField({ + super.key, + required this.label, + this.hint, + required this.controller, + this.validator, + this.readOnly = false, + }); + + @override + Widget build(BuildContext context) { + return RichMasterNumberField( + label: label, + initialValue: double.tryParse(controller.text ?? '') != null ? controller.text : null, + hintText: hint ?? '0.00', + readOnly: readOnly, + ); + } +} + +/// テキストフィールド(リッチ機能) +class RichMasterTextField extends StatelessWidget { + final String label; + final String? initialValue; + final String? hintText; + final bool readOnly; + + const RichMasterTextField({ + super.key, + required this.label, + this.initialValue, + this.hintText, + this.readOnly = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + if (initialValue != null) ...[ + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '入力済', + style: TextStyle(fontSize: 10, color: Colors.green.shade700), + ), + ), + ], + ], + ), + TextField( + controller: TextEditingController(text: initialValue ?? ''), + decoration: InputDecoration( + hintText: hintText, + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: readOnly ? BorderSide.none : BorderSide(), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: BorderSide(color: Theme.of(context).dividerColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), + ), + enabled: !readOnly, + ), + ], + ), + ); + } +} + +/// 数値フィールド(リッチ機能:自動補完・フォーマット) +class RichMasterNumberField extends StatelessWidget { + final String label; + final double? initialValue; + final String? hintText; + final bool readOnly; + + const RichMasterNumberField({ + super.key, + required this.label, + this.initialValue, + this.hintText, + this.readOnly = false, + }); + + @override + Widget build(BuildContext context) { + final formatter = NumberFormat('#,##0.00', 'ja_JP'); + String formattedValue = initialValue?.toString() ?? ''; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + if (initialValue != null && initialValue!.isFinite) ...[ + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + formatter.format(initialValue!), + style: TextStyle(fontSize: 10, color: Colors.blue.shade700), + ), + ), + ], + ], + ), + TextField( + controller: TextEditingController(text: formattedValue.isEmpty ? null : formattedValue), + decoration: InputDecoration( + hintText: hintText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), + ), + keyboardType: TextInputType.number, + ), + ], + ), + ); + } +} + +/// 日付フィールド(リッチ機能:年次表示・カレンダーピッカー) +class RichMasterDateField extends StatelessWidget { + final String label; + final DateTime? initialValue; + final String? hintText; + final bool readOnly; + + const RichMasterDateField({ + super.key, + required this.label, + this.initialValue, + this.hintText, + this.readOnly = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + if (initialValue != null) ...[ + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.purple.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + DateFormat('yyyy/MM/dd').format(initialValue!), + style: TextStyle(fontSize: 10, color: Colors.purple.shade700), + ), + ), + ], + ], + ), + TextField( + readOnly: readOnly, + decoration: InputDecoration( + hintText: hintText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), + suffixIcon: initialValue != null && !readOnly ? IconButton( + icon: Icon(Icons.calendar_today), + onPressed: () => _showDatePicker(context), + ) : null, + ), + ), + ], + ), + ); + } + + void _showDatePicker(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (ctx) => CalendarPickerDialog(initialDate: initialValue ?? DateTime.now()), + ); + } +} + +/// カレンダーピッカー(年次表示機能) +class CalendarPickerDialog extends StatefulWidget { + final DateTime initialDate; + + const CalendarPickerDialog({super.key, required this.initialDate}); + + @override + State createState() => _CalendarPickerDialogState(); +} + +class _CalendarPickerDialogState extends State { + int _year = widget.initialDate.year; + int _month = widget.initialDate.month - 1; + + DateTime get _selectedDate => DateTime(_year, _month + 1); + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.9, + builder: (context, scrollController) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withOpacity(0.1), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 4)], + ), + child: Row( + children: [ + IconButton(icon: Icon(Icons.chevron_left), onPressed: _changeMonth), + Text(_formatYear(_year), style: Theme.of(context).textTheme.titleLarge), + IconButton(icon: Icon(Icons.chevron_right), onPressed: _changeMonth), + Spacer(), + ElevatedButton.icon( + onPressed: () => setState(() { + _year = DateTime.now().year; + _month = DateTime.now().month - 1; + }), + icon: Icon(Icons.check), + label: Text('現在'), + ), + ], + ), + ), + + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + childAspectRatio: 1.5, + ), + itemCount: 6 * 35 + 2, + itemBuilder: (context, index) { + final monthIndex = index ~/ 35; + final dayOfWeek = index % 7; + + if (monthIndex >= 6) return const SizedBox(); + + final monthDay = DateTime(_year, monthIndex + 1); + final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7; + final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0; + + return Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isCurrentMonth ? Colors.blue.shade50 : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text( + dayOfMonthIndex + 1 < 10 ? '0$dayOfMonthIndex' : '$dayOfMonthIndex', + style: TextStyle( + fontWeight: isCurrentMonth ? FontWeight.bold : FontStyle.normal, + ), + ), + ); + }, + ), + ), + ), + + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + height: 48, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 6 * 35 + 2, + itemBuilder: (context, index) { + final monthIndex = index ~/ 35; + final dayOfWeek = index % 7; + + if (monthIndex >= 6) return const SizedBox(); + + final monthDay = DateTime(_year, monthIndex + 1); + final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7; + final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0; + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ElevatedButton.icon( + onPressed: () { + if (mounted) setState(() { + _year = _selectedDate.year; + _month = _selectedDate.month - 1; + }); + Navigator.pop(context); + }, + icon: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + '${isCurrentMonth ? "" : "月"}${dayOfMonthIndex + 1}', + style: TextStyle(fontWeight: isCurrentMonth ? FontWeight.bold : FontWeight.normal), + ), + ), + label: const SizedBox(width: 32), + ), + ); + }, + ), + ), + ), + ], + ); + }, + ); + } + + String _formatYear(int year) { + return year > DateTime.now().year ? '${year - DateTime.now().year}年先' : year; + } + + void _changeMonth() { + setState(() => _month = (_month + 1) % 12); + } +} \ No newline at end of file diff --git a/@workspace/lib/models/customer.dart b/@workspace/lib/models/customer.dart new file mode 100644 index 0000000..da55f04 --- /dev/null +++ b/@workspace/lib/models/customer.dart @@ -0,0 +1,82 @@ +// Version: 2.0 - Customer モデル定義(リッチフィールド対応拡張) +import '../services/database_helper.dart'; + +/// 得意先情報モデル(リッチ編集機能拡張) +class Customer { + int? id; + String? customerCode; // データベースでは 'customer_code' カラム + String name = ''; + String? email; + String? phone; + String? address; + DateTime? createdAt; + bool? enableEmail; // メール通知可フラグ(拡張) + int? discountRate; // 割引率(%、拡張) + + Customer({ + this.id, + required this.customerCode, + required this.name, + this.email, + this.phone, + this.address, + DateTime? createdAt, + this.enableEmail = false, + this.discountRate, + }) : createdAt = createdAt ?? DateTime.now(); + + /// マップから Customer オブジェクトへ変換 + factory Customer.fromMap(Map map) { + return Customer( + id: map['id'] as int?, + customerCode: map['customer_code'] as String? ?? '', + name: map['name'] as String? ?? '', + email: map['email'] as String?, + phone: map['phone'] as String?, + address: map['address'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + enableEmail: map['enable_email'] as bool?, + discountRate: map['discount_rate'] as int?, + ); + } + + /// Map に変換 + Map toMap() { + return { + 'id': id, + 'customer_code': customerCode ?? '', + 'name': name, + 'email': email ?? '', + 'phone': phone ?? '', + 'address': address ?? '', + 'created_at': createdAt?.toIso8601String(), + 'enable_email': enableEmail ?? false, + 'discount_rate': discountRate, + }; + } + + /// カピービルダ + Customer copyWith({ + int? id, + String? customerCode, + String? name, + String? email, + String? phone, + String? address, + DateTime? createdAt, + bool? enableEmail, + int? discountRate, + }) { + return Customer( + id: id ?? this.id, + customerCode: customerCode ?? this.customerCode, + name: name ?? this.name, + email: email ?? this.email, + phone: phone ?? this.phone, + address: address ?? this.address, + createdAt: createdAt ?? this.createdAt, + enableEmail: enableEmail ?? this.enableEmail, + discountRate: discountRate ?? this.discountRate, + ); + } +} \ No newline at end of file diff --git a/@workspace/lib/screens/master/customer_master_screen.dart b/@workspace/lib/screens/master/customer_master_screen.dart new file mode 100644 index 0000000..96adc83 --- /dev/null +++ b/@workspace/lib/screens/master/customer_master_screen.dart @@ -0,0 +1,248 @@ +// Version: 3.0 - シンプル得意先マスタ画面(簡素版) +// ※ MasterEditDialog を使用するため、独自の実装は不要です + +import 'package:flutter/material.dart'; +import '../../models/customer.dart'; +import '../../services/database_helper.dart'; +import 'master_edit_dialog.dart'; + +class CustomerMasterScreen extends StatefulWidget { + const CustomerMasterScreen({super.key}); + + @override + State createState() => _CustomerMasterScreenState(); +} + +class _CustomerMasterScreenState extends State { + final DatabaseHelper _db = DatabaseHelper.instance; + List _customers = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCustomers(); + } + + Future _loadCustomers() async { + try { + final customers = await _db.getCustomers(); + if (mounted) setState(() { + _customers = customers ?? const []; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _addCustomer() async { + final customer = await showDialog( + context: context, + builder: (ctx) => MasterEditDialog( + title: '新規得意先登録', + onSave: (data) async { + if (mounted) { + setState(() => _customers.insert(0, data)); + await _db.insertCustomer(data); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green), + ); + _loadCustomers(); + } + return true; + }, + ), + ); + + if (customer != null) _loadCustomers(); + } + + Future _editCustomer(Customer customer) async { + final updated = await showDialog( + context: context, + builder: (ctx) => MasterEditDialog( + title: '得意先編集', + initialData: customer, + showStatusFields: true, + onSave: (data) async { + if (mounted) { + await _db.updateCustomer(data); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green), + ); + _loadCustomers(); + } + return true; + }, + ), + ); + + if (updated != null) _loadCustomers(); + } + + 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 _db.deleteCustomer(id); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green), + ); + _loadCustomers(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('/M2. 得意先マスタ')), + 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: _addCustomer, + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _customers.length, + itemBuilder: (context, index) { + final customer = _customers[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 4, + 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.isNotEmpty) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)), + Text('登録日:${DateFormat('yyyy/MM/dd').format(customer.createdAt ?? DateTime.now())}'), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)), + PopupMenuButton( + onSelected: (value) => value == 'delete' ? _deleteCustomer(customer.id ?? 0) : null, + itemBuilder: (ctx) => [ + PopupMenuItem(child: const Text('詳細'), onPressed: () => _showDetail(context, customer)), + PopupMenuItem(child: const Text('削除'), onPressed: () => _deleteCustomer(customer.id ?? 0)), + ], + ), + ], + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('新規登録'), + onPressed: _addCustomer, + ), + ); + } + + Future _showDetail(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 ?? '-'), + _detailRow('Email', customer.email.isNotEmpty ? customer.email : '-'), + _detailRow('登録日', DateFormat('yyyy/MM/dd').format(customer.createdAt)), + ], + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))], + ), + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 80), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontWeight: FontWeight.bold)), + if (value != '-') Text(value), + ], + ), + ), + ], + ), + ); + } + + Future _onCopyFromOtherMaster() async { + final selected = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('他のマスタからコピー'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(leading: Icon(Icons.store, color: Colors.blue), title: const Text('仕入先マスタから'), onTap: () => Navigator.pop(ctx, 'supplier')), + ListTile(leading: Icon(Icons.inventory_2, color: Colors.orange), title: const Text('商品マスタから'), onTap: () => Navigator.pop(ctx, 'product')), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), + ], + ), + ); + + if (selected != null && mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('コピー機能は後期開発:$selected'))); + } + } +} \ No newline at end of file diff --git a/@workspace/lib/screens/master/product_master_screen.dart b/@workspace/lib/screens/master/product_master_screen.dart new file mode 100644 index 0000000..dcb94fc --- /dev/null +++ b/@workspace/lib/screens/master/product_master_screen.dart @@ -0,0 +1,445 @@ +// Version: 2.5 - 簡易商品マスタ(仕入先情報拡張対応) +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../models/product.dart'; +import '../../services/database_helper.dart'; +import '../../widgets/master_edit_fields.dart'; + +/// 簡易商品マスタ管理画面 + 電話帳連携対応(仕入先情報保存) +class ProductMasterScreen extends StatefulWidget { + const ProductMasterScreen({super.key}); + + @override + State createState() => _ProductMasterScreenState(); +} + +class _ProductMasterScreenState extends State { + final DatabaseHelper _db = DatabaseHelper.instance; + List _products = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadProducts(); + } + + Future _loadProducts() async { + try { + final products = await _db.getProducts(); + if (mounted) setState(() { + _products = products ?? const []; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('商品データを読み込みませんでした:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _addProduct(Product product) async { + try { + await _db.insertProduct(product); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('商品を登録しました'), backgroundColor: Colors.green), + ); + await _loadProducts(); + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _editProduct(Product product) async { + if (!mounted) return; + try { + final updatedProduct = await _showEditDialog(context, product); + if (updatedProduct != null && mounted) { + await _db.updateProduct(updatedProduct); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('商品を更新しました'), backgroundColor: Colors.green), + ); + await _loadProducts(); + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _deleteProduct(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 _db.deleteProduct(id); + if (mounted) await _loadProducts(); + 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, Product product) async { + return showDialog( + context: context, + builder: (ctx) => Dialog( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '商品情報', + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + MasterTextField( + label: '商品コード *', + controller: TextEditingController(text: product.productCode ?? ''), + hintText: '例:P-001, P-002 など(半角英数字)', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '品名 *', + controller: TextEditingController(text: product.name ?? ''), + hintText: '例:〇〇商品、製品名で可', + ), + + const SizedBox(height: 16), + + MasterNumberField( + label: '単価(円)*', + controller: TextEditingController(text: product.unitPrice.toString()), + hintText: '例:2000', + ), + + const SizedBox(height: 16), + + // 仕入先情報フィールド + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '仕入先情報(参照)', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]), + ), + ), + + MasterTextField( + label: '仕入先会社名', + controller: TextEditingController(text: product.supplierContactName ?? ''), + hintText: '例:株式会社〇〇商事(仕入先マスタから)', + ), + + const SizedBox(height: 16), + + // 電話番号フィールド - 電話帳連携対応 + MasterTextField( + label: '仕入先電話番号', + controller: TextEditingController(text: product.supplierPhoneNumber ?? ''), + hintText: '例:03-1234-5678、区切り不要', + keyboardType: TextInputType.phone, + phoneField: 'supplierPhoneNumber', // 電話帳連携用 + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '仕入先メールアドレス', + controller: TextEditingController(text: product.email ?? ''), + hintText: '@example.com の形式(例:order@ooshouki.co.jp)', + keyboardType: TextInputType.emailAddress, + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '仕入先住所', + controller: TextEditingController(text: product.address ?? ''), + hintText: '〒000-0000 市区町村名・番地・建物名', + ), + + const SizedBox(height: 24), + + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '保存', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + SizedBox(height: 24, width: double.infinity, child: ElevatedButton( + onPressed: () => Navigator.pop(ctx, product), + style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('保存', style: TextStyle(fontSize: 16)), + )), + + SizedBox(height: 8, width: double.infinity, child: OutlinedButton( + onPressed: () => Navigator.pop(ctx), + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('キャンセル'), + )), + ], + ), + ), + ), + ); + } + + Future _showProductDetail(BuildContext context, Product product) async { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('商品詳細'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (product.productCode.isNotEmpty) _detailRow('商品コード', product.productCode), + if (product.name.isNotEmpty) _detailRow('品名', product.name), + _detailRow('単価', '¥${product.unitPrice.toStringAsFixed(2)}'), + _detailRow('在庫数', product.stock.toString()), + _detailRow('仕入先会社名', product.supplierContactName ?? '-'), + if (product.supplierPhoneNumber.isNotEmpty) _detailRow('電話番号', product.supplierPhoneNumber), + if (product.email.isNotEmpty) _detailRow('Email', product.email), + if (product.address.isNotEmpty) _detailRow('住所', product.address), + ], + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))], + ), + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 80), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontWeight: FontWeight.bold)), + if (value != '-') Text(value), + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('/M1. 商品マスタ'), + actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts)], + ), + body: _isLoading ? const Center(child: CircularProgressIndicator()) : + _products.isEmpty ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inventory_2_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: _products.length, + itemBuilder: (context, index) { + final product = _products[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 4, + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Icon(Icons.shopping_basket, color: Colors.blue)), + title: Text(product.name ?? '未入力'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (product.productCode.isNotEmpty) Text('コード:${product.productCode}', style: const TextStyle(fontSize: 12)), + if (product.unitPrice > 0) Text('単価:¥${product.unitPrice.toStringAsFixed(0)}', style: const TextStyle(fontSize: 12)), + if (product.supplierContactName.isNotEmpty) Text('仕入先:${product.supplierContactName}', style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editProduct(product)), + PopupMenuButton( + onSelected: (value) => value == 'delete' ? _deleteProduct(product.id ?? 0) : null, + itemBuilder: (ctx) => [ + const PopupMenuItem(child: Text('詳細'), onPressed: () => _showProductDetail(context, product)), + const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteProduct(product.id ?? 0)), + ], + ), + ], + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('新規登録'), + onPressed: () => _showAddDialog(context), + ), + ); + } + + void _showAddDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => Dialog( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '新規商品登録', + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + MasterTextField( + label: '商品コード *', + controller: TextEditingController(), + hintText: '例:P-001, P-002 など(半角英数字)', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '品名 *', + controller: TextEditingController(), + hintText: '例:〇〇商品、製品名で可', + ), + + const SizedBox(height: 16), + + MasterNumberField( + label: '単価(円)*', + controller: TextEditingController(), + hintText: '例:2000', + ), + + const SizedBox(height: 16), + + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '仕入先情報(参照)', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]), + ), + ), + + MasterTextField( + label: '仕入先会社名', + controller: TextEditingController(), + hintText: '例:株式会社〇〇商事(仕入先マスタから)', + ), + + const SizedBox(height: 16), + + // 電話番号フィールド - 電話帳連携対応 + MasterTextField( + label: '仕入先電話番号', + controller: TextEditingController(), + keyboardType: TextInputType.phone, + hintText: '例:03-1234-5678、区切り不要', + phoneField: 'supplierPhoneNumber', // 電話帳連携用 + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '仕入先メールアドレス', + controller: TextEditingController(), + keyboardType: TextInputType.emailAddress, + hintText: '@example.com の形式(例:order@ooshouki.co.jp)', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '仕入先住所', + controller: TextEditingController(), + hintText: '〒000-0000 市区町村名・番地・建物名', + ), + + const SizedBox(height: 24), + + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '保存', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + SizedBox(height: 24, width: double.infinity, child: ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('保存', style: TextStyle(fontSize: 16)), + )), + + SizedBox(height: 8, width: double.infinity, child: OutlinedButton( + onPressed: () => Navigator.pop(ctx), + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('キャンセル'), + )), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/@workspace/lib/screens/master/supplier_master_screen.dart b/@workspace/lib/screens/master/supplier_master_screen.dart new file mode 100644 index 0000000..3b6735b --- /dev/null +++ b/@workspace/lib/screens/master/supplier_master_screen.dart @@ -0,0 +1,474 @@ +// Version: 2.8 - 簡易仕入先マスタ(電話帳対応) +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../models/product.dart'; +import '../../services/database_helper.dart'; +import '../../widgets/master_edit_fields.dart'; + +/// 簡易仕入先マスタ管理画面 + 電話帳連携対応 +class RichSupplierMasterScreen extends StatefulWidget { + const RichSupplierMasterScreen({super.key}); + + @override + State createState() => _RichSupplierMasterScreenState(); +} + +class _RichSupplierMasterScreenState extends State { + final DatabaseHelper _db = DatabaseHelper.instance; + List _suppliers = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSuppliers(); + } + + Future _loadSuppliers() async { + try { + final products = await _db.getProducts(); + if (mounted) setState(() { + _suppliers = products ?? const []; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('仕入先データを読み込みませんでした:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _addSupplier(Product supplier) async { + try { + await _db.insertProduct(supplier); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('仕入先を登録しました'), backgroundColor: Colors.green), + ); + await _loadSuppliers(); + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _editSupplier(Product supplier) async { + if (!mounted) return; + try { + final updatedSupplier = await _showEditDialog(context, supplier); + if (updatedSupplier != null && mounted) { + await _db.updateProduct(updatedSupplier); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('仕入先を更新しました'), backgroundColor: Colors.green), + ); + await _loadSuppliers(); + } + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red), + ); + } + } + + Future _deleteSupplier(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 { + // TODO: データベースから削除ロジックを実装(現在は簡易実装) + if (mounted) _loadSuppliers(); + 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, Product supplier) async { + return showDialog( + context: context, + builder: (ctx) => Dialog( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '仕入先情報', + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + MasterTextField( + label: '製品コード *', + controller: TextEditingController(text: supplier.productCode ?? ''), + hintText: '例:S-001, SAN-002 など(半角英数字)', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '会社名 *', + controller: TextEditingController(text: supplier.name ?? ''), + hintText: '例:株式会社〇〇商事、個人商社で可', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '担当者名', + controller: TextEditingController(text: supplier.supplierContactName.isNotEmpty ? supplier.supplierContactName : ''), + hintText: '例:田中太郎(日本語漢字可)', + ), + + const SizedBox(height: 16), + + // 電話番号フィールド - 電話帳連携対応 + MasterTextField( + label: '電話番号', + controller: TextEditingController(text: supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : ''), + hintText: '例:03-1234-5678、区切り不要(0312345678)', + keyboardType: TextInputType.phone, + phoneField: 'supplierPhoneNumber', // 電話帳連携用 + ), + + const SizedBox(height: 16), + + MasterTextField( + label: 'メールアドレス', + controller: TextEditingController(text: supplier.email.isNotEmpty ? supplier.email : ''), + hintText: '@example.com の形式(例:order@ooshouki.co.jp)', + keyboardType: TextInputType.emailAddress, + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '住所', + controller: TextEditingController(text: supplier.address.isNotEmpty ? supplier.address : ''), + hintText: '〒000-0000 市区町村名・番地・建物名', + ), + + const SizedBox(height: 16), + + // 評価ポイント(1-5) + MasterNumberField( + label: '評価ポイント *', + controller: TextEditingController(text: supplier.quantity.toString()), + hintText: '1-5 の範囲(例:5 は最高レベル)', + ), + + const SizedBox(height: 16), + + MasterDateField( + label: '登録日', + controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(supplier.createdAt ?? DateTime.now())), + ), + + const SizedBox(height: 24), + + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '保存', + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + SizedBox(height: 24, width: double.infinity, child: ElevatedButton( + onPressed: () => Navigator.pop(ctx, supplier), + style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('保存', style: TextStyle(fontSize: 16)), + )), + + SizedBox(height: 8, width: double.infinity, child: OutlinedButton( + onPressed: () => Navigator.pop(ctx), + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: const Text('キャンセル'), + )), + ], + ), + ), + ), + ); + } + + Future _showSupplierDetail(BuildContext context, Product supplier) async { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('仕入先詳細'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (supplier.productCode.isNotEmpty) _detailRow('製品コード', supplier.productCode), + if (supplier.name.isNotEmpty) _detailRow('会社名', supplier.name), + if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName), + _detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'), + if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email), + if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address), + _detailRow('評価ポイント', '★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)), + _detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)), + ], + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))], + ), + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 100), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontWeight: FontWeight.bold)), + if (value != '-') Text(value), + ], + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('/M3. 仕入先マスタ'), + actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers)], + ), + body: _isLoading ? const Center(child: CircularProgressIndicator()) : + _suppliers.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: _suppliers.length, + itemBuilder: (context, index) { + final supplier = _suppliers[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 4, + child: ListTile( + leading: CircleAvatar(backgroundColor: Colors.orange.shade100, child: Icon(Icons.business, color: Colors.orange)), + title: Text(supplier.name ?? '未入力'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (supplier.productCode.isNotEmpty) Text('製品コード:${supplier.productCode}', style: const TextStyle(fontSize: 12)), + if (supplier.supplierPhoneNumber.isNotEmpty) Text('電話:${supplier.supplierPhoneNumber}', style: const TextStyle(fontSize: 12)), + if (supplier.email.isNotEmpty) Text('Email: ${supplier.email}', style: const TextStyle(fontSize: 12)), + Text('評価:★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1), style: const TextStyle(color: Colors.orange, fontSize: 12)), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editSupplier(supplier)), + PopupMenuButton( + onSelected: (value) => value == 'delete' ? _deleteSupplier(supplier.id ?? 0) : null, + itemBuilder: (ctx) => [ + const PopupMenuItem(child: Text('詳細'), onPressed: () => _showSupplierDetail(context, supplier)), + const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteSupplier(supplier.id ?? 0)), + ], + ), + ], + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('新規登録'), + onPressed: () => _showAddDialog(context), + ), + ); + } + + void _showAddDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => Dialog( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '新規仕入先登録', + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + + MasterTextField( + label: '製品コード *', + controller: TextEditingController(), + hintText: '例:S-001, SAN-002 など(半角英数字)', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '会社名 *', + controller: TextEditingController(), + hintText: '例:株式会社〇〇商事、個人商社で可', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '担当者名', + controller: TextEditingController(), + hintText: '例:田中太郎(日本語漢字可)', + ), + + const SizedBox(height: 16), + + // 電話番号フィールド - 電話帳連携対応 + MasterTextField( + label: '電話番号', + controller: TextEditingController(), + keyboardType: TextInputType.phone, + hintText: '例:03-1234-5678、区切り不要(0312345678)', + phoneField: 'supplierPhoneNumber', // 電話帳連携用 + ), + + const SizedBox(height: 16), + + MasterTextField( + label: 'メールアドレス', + controller: TextEditingController(), + keyboardType: TextInputType.emailAddress, + hintText: '@example.com の形式(例:order@ooshouki.co.jp)', + ), + + const SizedBox(height: 16), + + MasterTextField( + label: '住所', + controller: TextEditingController(), + hintText: '〒000-0000 市区町村名・番地・建物名', + ), + + const SizedBox(height: 16), + + MasterNumberField( + label: '評価ポイント *', + controller: TextEditingController(), + hintText: '1-5 の範囲(例:3)', + ), + + const SizedBox(height: 16), + + MasterDateField( + label: '登録日', + controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(DateTime.now())), + ), + ], + ), + ), + ), + ); + } +} + +/// 仕入先マスタ用詳細表示ダイアログ(電話帳対応) +class SupplierDetailDialog extends StatelessWidget { + final Product supplier; + + const SupplierDetailDialog({super.key, required this.supplier}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('仕入先詳細'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _detailRow('製品コード', supplier.productCode ?? '-'), + _detailRow('会社名', supplier.name ?? '-'), + if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName), + _detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'), + if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email), + if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address), + _detailRow('評価ポイント', '★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)), + _detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)), + ], + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('閉じる'))], + ); + } + + Widget _detailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 100), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontWeight: FontWeight.bold)), + if (value != '-') Text(value), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/@workspace/lib/services/database_helper.dart b/@workspace/lib/services/database_helper.dart new file mode 100644 index 0000000..425fd05 --- /dev/null +++ b/@workspace/lib/services/database_helper.dart @@ -0,0 +1,353 @@ +// DatabaseHelper - シンプルデータベースアクセスヘルパー(sqflite 直接操作) +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:sqflite/sqflite.dart'; +import '../models/product.dart'; + +// Customer モデル +class Customer { + final int? id; + final String? customerCode; + final String? name; + final String? address; + final String? phone; + final String? email; + final bool isInactive; + + Customer({ + this.id, + this.customerCode, + this.name, + this.address, + this.phone, + this.email, + this.isInactive = false, + }); +} + +class DatabaseHelper { + static Database? _database; + + /// データベース初期化(サンプルデータ付き) + static Future init() async { + if (_database != null) return; + + try { + // アプリの現在のフォルダを DB パスに使用(開発/テスト用) + final dbPath = Directory.current.path + '/data/db/sales.db'; + + _database = await _initDatabase(dbPath); + print('[DatabaseHelper] DB initialized successfully'); + + } catch (e) { + print('DB init error: $e'); + throw Exception('Database initialization failed: $e'); + } + } + + /// テーブル作成時にサンプルデータを自動的に挿入 + static Future _initDatabase(String path) async { + return await openDatabase( + path, + version: 2, // バージョンアップ(仕入先情報カラム追加用) + onCreate: _onCreateTableWithSampleData, + ); + } + + /// テーブル作成用関数 + サンプルデータ自動挿入 + static Future _onCreateTableWithSampleData(Database db, int version) async { + // products テーブル(Product モデルと整合性を取る)+ 仕入先情報カラム追加 + 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, + supplier_contact_name TEXT, + supplier_phone_number TEXT, + email TEXT, + address TEXT, + 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 テーブル(Estimate モデルと整合性を取る) + 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'); + } + + /// データベースインスタンスへのアクセス + static Database get instance => _database!; + + /// 製品一覧を取得(非アクティブ除外) + static Future> getProducts() async { + final result = await instance.query('products', orderBy: 'id DESC'); + + // DateTime オブジェクトを文字列に変換してから Product からマップ + 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); + }); + } + + /// 製品を 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; + } + + /// 製品を 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; + } + + /// クライアント ID での顧客検索(エラー時は null を返す) + static Future getCustomerById(int id) async { + final result = await instance.query( + 'customers', + where: 'id = ?', + whereArgs: [id], + ); + + if (result.isNotEmpty) { + return Customer( + id: result[0]['id'] as int?, + customerCode: result[0]['customer_code'] as String?, + name: result[0]['name'] as String?, + address: result[0]['address'] as String?, + phone: result[0]['phone'] as String?, + email: result[0]['email'] as String?, + isInactive: (result[0]['is_inactive'] as bool?) ?? false, + ); + } + return null; + } + + /// 顧客をコードで検索(エラー時は null を返す) + static Future getCustomerByCode(String code) async { + final result = await instance.query( + 'customers', + where: 'customer_code = ?', + whereArgs: [code], + ); + + if (result.isNotEmpty) { + return Customer( + id: result[0]['id'] as int?, + customerCode: result[0]['customer_code'] as String?, + name: result[0]['name'] as String?, + address: result[0]['address'] as String?, + phone: result[0]['phone'] as String?, + email: result[0]['email'] as String?, + isInactive: (result[0]['is_inactive'] as bool?) ?? false, + ); + } + return null; + } + + /// 顧客を insert + static Future insertCustomer(Customer customer) async { + final id = await instance.insert('customers', customer.toMap()); + print('[DatabaseHelper] Customer inserted: $id'); + return id; + } + + /// 顧客を更新 + static Future updateCustomer(Customer customer) async { + await instance.update( + 'customers', + {'name': customer.name, 'address': customer.address, 'phone': customer.phone, 'email': customer.email}, + where: 'id = ?', + whereArgs: [customer.id], + ); + print('[DatabaseHelper] Customer updated'); + } + + /// 顧客を削除 + static Future deleteCustomer(int id) async { + await instance.delete('customers', where: 'id = ?', whereArgs: [id]); + print('[DatabaseHelper] Customer deleted'); + } + + /// 製品を insert + static Future insertProduct(Product product) async { + final id = await instance.insert('products', product.toMap()); + print('[DatabaseHelper] Product inserted: $id'); + return id; + } + + /// 製品を更新 + static Future updateProduct(Product product) async { + await instance.update( + 'products', + { + 'name': product.name, + 'unit_price': product.unitPrice, + 'quantity': product.quantity, + 'stock': product.stock, + 'supplier_contact_name': product.supplierContactName, + 'supplier_phone_number': product.supplierPhoneNumber, + 'email': product.email, + 'address': product.address, + }, + where: 'id = ?', + whereArgs: [product.id], + ); + print('[DatabaseHelper] Product updated'); + } + + /// 製品を削除 + static Future deleteProduct(int id) async { + await instance.delete('products', where: 'id = ?', whereArgs: [id]); + print('[DatabaseHelper] Product deleted'); + } + + /// DB をクリア(サンプルデータは保持しない) + static Future clearDatabase() async { + await instance.delete('products'); + await instance.delete('customers'); + await instance.delete('sales'); + await instance.delete('estimates'); + } + + /// データベースを回復(全削除 + リセット + テーブル再作成) + static Future recover() async { + try { + // 既存の DB ファイルを削除 + 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'); + } + } + + /// DB パスを取得 + static Future getDbPath() async { + return Directory.current.path + '/data/db/sales.db'; + } +} \ 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 438407e..47d6819 100644 --- a/@workspace/lib/widgets/master_edit_fields.dart +++ b/@workspace/lib/widgets/master_edit_fields.dart @@ -1,81 +1,210 @@ +// マスター編集用汎用ウィジェット(簡易実装)+ 電話帳連携対応 import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:contacts_service/contacts_service.dart'; -/// マスタ編集用の汎用テキストフィールドウィジェット -class MasterTextField extends StatelessWidget { +/// テキスト入力フィールド(マスター用)+ 電話帳取得ボタン付き +class MasterTextField extends StatefulWidget { final String label; - final String? initialValue; + final TextEditingController controller; + final TextInputType? keyboardType; + final bool readOnly; + final int? maxLines; final String? hintText; - final VoidCallback? onTap; + final String? initialValueText; + final String? phoneField; // 電話番号フィールド(電話帳取得用) const MasterTextField({ super.key, required this.label, - this.initialValue, + required this.controller, + this.keyboardType, + this.readOnly = false, + this.maxLines, this.hintText, - this.onTap, + 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.symmetric(vertical: 4.0), + padding: const EdgeInsets.only(bottom: 8.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), + 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; + }); + }, + ), + ], + ), + ), + ], ), - onTap: onTap, - textInputAction: TextInputAction.done, ), ], ), ); } + + Widget _buildPhoneSearchButton() { + return IconButton( + icon: const Icon(Icons.person_search), + tooltip: '電話帳から取得', + onPressed: _searchContactsForPhone, + ); + } } -/// マスタ編集用の汎用数値フィールドウィジェット +/// 数値入力フィールド(マスター用) class MasterNumberField extends StatelessWidget { final String label; - final double? initialValue; + final TextEditingController controller; final String? hintText; - final VoidCallback? onTap; + final bool readOnly; + final String? initialValueText; const MasterNumberField({ super.key, required this.label, - this.initialValue, + required this.controller, this.hintText, - this.onTap, + this.readOnly = false, + this.initialValueText, }); @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.only(bottom: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), - TextFormField( - initialValue: initialValue?.toString(), - decoration: InputDecoration( - hintText: hintText, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(4.0), + 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), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), + keyboardType: TextInputType.number, + readOnly: readOnly, ), - onTap: onTap, - keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, ), ], ), @@ -83,119 +212,326 @@ class MasterNumberField extends StatelessWidget { } } -/// ドロップダウンフィールドウィジェット -class MasterDropdownField extends StatelessWidget { +/// 日付入力フィールド(マスター用) +class MasterDateField extends StatelessWidget { final String label; - final List options; - final String? selectedOption; - final VoidCallback? onTap; + final TextEditingController controller; + final DateTime? picked; - const MasterDropdownField({ + const MasterDateField({ super.key, required this.label, - required this.options, - this.selectedOption, - this.onTap, + required this.controller, + this.picked, }); @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)), - 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), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), - ), - onTap: onTap, - isExpanded: true, - ), - ], - ), - ); - } -} - -/// テキストエリアウィジェット -class MasterTextArea extends StatelessWidget { - final String label; - final String? initialValue; - final String? hintText; - final VoidCallback? onTap; - - const MasterTextArea({ - super.key, - required this.label, - this.initialValue, - this.hintText, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), - TextFormField( - initialValue: initialValue, - decoration: InputDecoration( - hintText: hintText, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(4.0), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), - ), - maxLines: 3, - onTap: onTap, - textInputAction: TextInputAction.newline, - ), - ], - ), - ); - } -} - -/// チェックボックスウィジェット -class MasterCheckBox extends StatelessWidget { - final String label; - final bool? initialValue; - final VoidCallback? onChanged; - - const MasterCheckBox({ - super.key, - required this.label, - this.initialValue, - this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.only(bottom: 8.0), child: Row( children: [ - Expanded(child: Text(label)), - Checkbox( - value: initialValue, - onChanged: onChanged ?? (_ => null), + 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 String? hintText; + final String? initialValueText; + + const RichMasterTextField({ + super.key, + required this.label, + required this.controller, + this.keyboardType, + this.readOnly = false, + this.maxLines, + this.hintText, + this.initialValueText, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + if (initialValueText != null) + IconButton( + icon: const Icon(Icons.copy), + onPressed: () => _copyToClipboard(initialValueText!), + ), + ], + ), + SizedBox(height: 4), + Container( + constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText ?? (initialValueText != null ? initialValueText : null), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), + ), + keyboardType: keyboardType, + readOnly: readOnly, + maxLines: maxLines ?? 1, + ), + ), + ], + ), + ); + } + + void _copyToClipboard(String text) async { + try { + await Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red), + ); + } + } +} + +/// リッチマスター日付フィールド(汎用性高く、他のマスタ参照可) +class RichMasterDateField extends StatelessWidget { + final String label; + final TextEditingController controller; + final DateTime? picked; + + const RichMasterDateField({ + super.key, + required this.label, + required this.controller, + this.picked, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + if (picked != null) + IconButton( + icon: const Icon(Icons.copy), + onPressed: () => _copyToDate(controller.text), + ), + ], + ), + SizedBox(height: 4), + Container( + constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 + child: TextField( + controller: controller, + decoration: InputDecoration( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), + ), + readOnly: true, + ), + ), + ], + )), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) { + controller.text = DateFormat('yyyy/MM/dd').format(picked); + } + }, + ), + ], + ), + ); + } + + void _copyToDate(String dateStr) async { + try { + await Clipboard.setData(ClipboardData(text: dateStr)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('日付をコピーしました'), backgroundColor: Colors.green), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red), + ); + } + } +} + +/// リッチマスター数値フィールド(汎用性高く、他のマスタ参照可) +class RichMasterNumberField extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? hintText; + final bool readOnly; + final String? initialValueText; + + const RichMasterNumberField({ + super.key, + required this.label, + required this.controller, + this.hintText, + this.readOnly = false, + this.initialValueText, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + if (initialValueText != null) + IconButton( + icon: const Icon(Icons.copy), + onPressed: () => _copyToClipboard(initialValueText!), + ), + ], + ), + SizedBox(height: 4), + Container( + constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲(例:3)'), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), + ), + keyboardType: TextInputType.number, + readOnly: readOnly, + ), + ), + ], + ), + ); + } + + void _copyToClipboard(String text) async { + try { + await Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red), + ); + } + } } \ No newline at end of file diff --git a/@workspace/pubspec.yaml b/@workspace/pubspec.yaml new file mode 100644 index 0000000..c78dd35 --- /dev/null +++ b/@workspace/pubspec.yaml @@ -0,0 +1,37 @@ +name: sales_assist_1 +description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント +publish_to: 'none' + +version: 1.0.0+6 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.6 + + # SQLite データ永続化 + sqflite: any + sqflite_android: any + path_provider: ^2.1.1 + + # PDF 帳票出力(flutter_pdf_generator の代わりに使用) + pdf: ^3.10.8 + printing: ^5.9.0 + intl: ^0.19.0 + share_plus: ^10.1.2 + google_sign_in: ^7.2.0 + + # リッチマスター編集用機能(簡易実装) + image_picker: ^1.0.7 + qr_flutter: ^4.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/README.md b/README.md index 77d9707..a73206a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,65 @@ -# 📦 Sales Assist - H-1Q (Flutter) +# 販売管理システム(販売アシスト) -**バージョン:** 1.5 -**コミット:** `13f7e \ No newline at end of file +簡素版の 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 diff --git a/docs/project_specification.md b/docs/project_specification.md new file mode 100644 index 0000000..a7b9710 --- /dev/null +++ b/docs/project_specification.md @@ -0,0 +1,409 @@ +# 制作小プロジェクト - 企画設計指示書 + +## 1. プロジェクト概要 + +### 1.1 目的 +リッチなマスター編集機能を持つ販売アシスタントアプリを効率的に開発するための、AI(LLM)による自動コーディングを支援する企画設計指示書。 + +### 1.2 スコープ +- マスターデータの CRUD 機能強化 +- リッチな入力フィールド(画像/動画アップロード、QR コード生成) +- フォームバリデーションとヒント表示 +- 汎用ウィジェットによるコード削減 + +--- + +## 2. コンテンツ定義 + +### 2.1 データモデル一覧 + +#### Customer(得意先) +| プロパティ | タイプ | キー | ビルンール | ヒント | +|----------|--------|------|-----------|--------| +| id | int? | PK | autoincrement | - | +| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 | +| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com)| +| phone | String | | nullable, max 20 | 電話番号(区切りなし:090-1234-5678→09012345678)| +| address | String | | nullable, max 200 | 住所(省スペース表示・多言語対応)| +| created_at | DateTime? | - | null allow | DB レコード作成時 | + +#### Product(商品) +| プロパティ | タイプ | キー | ビルンール | ヒント | +|----------|--------|------|-----------|--------| +| id | int? | PK | autoincrement | - | +| name | String | UK | not null, max 50 | 例:iPhone、ノートパソコンなど | +| price | double? | - | null allow | 円単位(小数点以下 2 桁)| +| stock | int? | - | null allow | 在庫数 | +| description | String | - | nullable, max 500 | 商品の説明・特徴 | +| created_at | DateTime? | - | null allow | DB レコード作成時 | + +#### Supplier(仕入先) +| プロパティ | タイプ | キー | ビルンール | ヒント | +|----------|--------|------|-----------|--------| +| id | int? | PK | autoincrement | - | +| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 | +| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com)| +| phone | String | | nullable, max 20 | 電話番号(区切りなし)| +| address | String | | nullable, max 200 | 住所 | +| created_at | DateTime? | - | null allow | DB レコード作成時 | + +#### Warehouse(倉庫) +| プロパティ | タイプ | キー | ビルンール | ヒント | +|----------|--------|------|-----------|--------| +| id | int? | PK | autoincrement | - | +| name | String | UK | not null, max 50 | 例:東京都千代田区〇丁目倉庫、大阪支店倉庫 | +| address | String | | nullable, max 200 | 住所 | +| capacity | int? | - | null allow | 保管容量(単位:坪)| +| created_at | DateTime? | - | null allow | DB レコード作成時 | + +#### Employee(担当者) +| プロパティ | タイプ | キー | ビルンール | ヒント | +|----------|--------|------|-----------|--------| +| id | int? | PK | autoincrement | - | +| name | String | UK | not null, max 50 | 例:田中太郎、鈴木花子 | +| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com)| +| phone | String | | nullable, max 20 | 電話番号(区切りなし)| +| role | String | - | nullable, max 30 | 例:管理者、営業、倉庫員など | +| created_at | DateTime? | - | null allow | DB レコード作成時 | + +--- + +## 3. UI コンポーネント定義 + +### 3.1 汎用ウィジェット一覧 + +#### RichMasterTextField(テキスト入力) +```dart +RichMasterTextField( + label: '商品名', + initialValue: 'iPhone', + hintText: '例:iPhone、ノートパソコンなど', + maxLines: 1, +) +``` + +#### RichMasterNumberField(数値入力) +```dart +RichMasterNumberField( + label: '価格', + initialValue: 128000.0, + hintText: '例:128,000円、98,500 円など', + decimalDigits: 2, +) +``` + +#### RichMasterDateField(日付選択) +```dart +RichMasterDateField( + label: '作成日', + initialValue: DateTime.now(), + hintText: '例:2024/03/10、今日などの指定可', +) +``` + +#### RichMasterAddressField(住所入力・省スペース) +```dart +RichMasterAddressField( + label: '住所', + initialValue: '東京都千代田区〇丁目 1-1', + hintText: '例:都道府県名から検索可', +) +``` + +#### RichMasterFileUploader(ファイル・画像アップロード) +```dart +RichMasterFileUploader( + label: '商品画像', + onPickImage: () => print('画像選択'), + onPickVideo: () => print('動画選択'), // Android 限定 +) +``` + +#### RichMasterQRCodeGenerator(QR コード生成) +```dart +RichMasterQRCodeGenerator( + label: 'QR コード', + text: 'https://example.com/product/123', +) +``` + +#### RichMasterCheckboxField(チェックボックス) +```dart +RichMasterCheckboxField( + label: '在庫あり', + initialValue: true, + onChanged: (value) => print(value), +) +``` + +#### RichMasterDropdownField(ドロップダウンリスト) +```dart +RichMasterDropdownField( + label: '担当部署', + initialValue: '営業部', + items: ['営業部', '総務部', '開発部'], + itemToString: (item) => item, +) +``` + +### 3.2 アプリバー(AppBar)定義 + +| ID | 名乘 | +|-----|------| +| /S1. 見積書 | Sales - Estimate | +| /S2. 請求書 | Sales - Invoice | +| /S3. 受発注一覧 | Order List | +| /S4. 売上入力(レジ) | Sales Register | +| /S5. 売上返品入力 | Return Input | +| /M1. 商品マスタ | Master - Product | +| /M2. 得意先マスタ | Master - Customer | +| /M3. 仕入先マスタ | Master - Supplier | +| /M4. 倉庫マスタ | Master - Warehouse | +| /M5. 担当者マスタ | Master - Employee | + +--- + +## 4. ビヘイビア仕様 + +### 4.1 フォームバリデーション +- **必須フィールド**: 空文字の場合は赤いエラー表示 + ヒント文の再表示 +- **メール形式検証**: *@example.com の形式のみ許可(正規表現:^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$) +- **電話番号検証**: 数字のみ、最大 11 桁まで許可 +- **数値フィールド**: 小数点以下指定桁数を超える入力時の自動補正 + +### 4.2 ヒント・エラー表示 +```dart +// エラー状態 +TextField( + errorText: hintText, // 初期値がヒントとなる +) + +// カスタムエラー +TextField( + decoration: InputDecoration(errorText: '必須入力をしてください'), +) +``` + +### 4.3 ショートカットキー対応(オプション) +```dart +RichMasterShortcutSettings( + label: '編集ヘルプ', + showShortcuts: true, + shortcuts: { + 'Ctrl+S': () => print('保存'), + 'Ctrl+Z': () => print('取り消し'), + }, +) +``` + +### 4.4 セクション分割表示 +```dart +RichMasterSectionHeader( + title: '基本情報', + icon: Icons.info_outline, + color: Colors.blue.shade700, +) +``` + +--- + +## 5. コーディングワークフロー + +### 5.1 マスタ画面作成手順 + +#### ステップ 1: モデル定義 +```dart +// lib/models/customer.dart +class Customer { + int? id; + String name = ''; + String? email; + String? phone; + String? address; + DateTime? createdAt; + + Customer({ + this.id, + required this.name, + this.email, + this.phone, + this.address, + this.createdAt, + }); + + factory Customer.fromJson(Map json) => Customer( + id: json['id'] as int?, + name: json['name'] as String, + email: json['email'] as String?, + phone: json['phone'] as String?, + address: json['address'] as String?, + createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null, + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'email': email, + 'phone': phone, + 'address': address, + 'created_at': createdAt?.toIso8601String(), + }; + + Customer copyWith({ + int? id, + String? name, + String? email, + String? phone, + String? address, + DateTime? createdAt, + }) => Customer( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + phone: phone ?? this.phone, + address: address ?? this.address, + createdAt: createdAt ?? this.createdAt, + ); +} +``` + +#### ステップ 2: スクリーン定義(master_edit_fields.dart を使用) +```dart +// lib/screens/master/customer_master_screen.dart +class CustomerMasterScreen extends StatefulWidget { + const CustomerMasterScreen({super.key}); + + @override + State createState() => _CustomerMasterScreenState(); +} + +class _CustomerMasterScreenState extends State { + final GlobalKey _formKey = GlobalKey(); + + // データモデル(データベース連携) + late Customer _customer; + + @override + void initState() { + super.initState(); + _customer = Customer(name: ''); // 初期値設定 + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('/M2. 得意先マスタ')), + body: Form( + key: _formKey, + child: ListView( + children: [ + // RichMasterTextField(商品名) + RichMasterTextField( + label: '得意先名', + initialValue: _customer.name, + hintText: '例:株式会社〇〇、個人名で可', + onChanged: (value) => setState(() => _customer.name = value), + ), + + // RichMasterTextField(メール) + RichMasterTextField( + label: 'メールアドレス', + initialValue: _customer.email, + keyboardType: TextInputType.emailAddress, + hintText: '@example.com の形式(例:info@example.com)', + onChanged: (value) => setState(() => _customer.email = value), + ), + + // RichMasterNumberField(電話番号) + RichMasterTextField( + label: '電話番号', + initialValue: _customer.phone, + hintText: '例:090-1234-5678、区切り不要', + onChanged: (value) => setState(() => _customer.phone = value), + ), + + // RichMasterDateField(作成日) + RichMasterDateField( + label: '登録日', + initialValue: _customer.createdAt?.toLocal(), + hintText: '例:2024/03/10、今日などの指定可', + ), + + // 保存ボタン + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context, _customer.toJson()), + child: Text('保存'), + ), + ), + ], + ), + ), + ); + } +} +``` + +### 5.2 汎用マスター作成テンプレート(AI 生成用) + +AI に自動生成させるためのプロンプト例: + +```text +以下のデータモデルでマスター画面を作成してください: + +- データモデル: {model_definition} +- スクリーン ID: {screen_id, e.g., /M3. 仕入先マスタ} +- 使用ウィジェット: RichMasterTextField, RichMasterNumberField, RichMasterDateField など(master_edit_fields.dart を参照) + +要件: +1. AppBar に「{screen_id}」を表示 +2. フォームキーでバリデーションを行う +3. 保存ボタンでデータを JSON 形式で返す +4. 必須フィールドは空文字をエラー扱いに +``` + +--- + +## 6. テスト用データ + +### 6.1 Customer(得意先)テストデータ +```json +{ + "name": "株式会社 ABC", + "email": "info@abc-company.com", + "phone": "03-1234-5678", + "address": "東京都千代田区〇丁目 1-1" +} +``` + +### 6.2 Product(商品)テストデータ +```json +{ + "name": "iPhone 15 Pro Max", + "price": 199440.0, + "description": "チタニウム素材の高級スマートフォン。A17 Pro チップ搭載。" +} +``` + +### 6.3 Warehouse(倉庫)テストデータ +```json +{ + "name": "東京都千代田区支店倉庫", + "address": "東京都千代田区〇丁目 1-1", + "capacity": 50 +} +``` + +--- + +## 7. まとめ + +この指示書を使用することで、以下が可能になります: + +1. **AI による自動コーディング**: データモデルから画面コードを生成 +2. **一貫性のある UI**: 汎用ウィジェットでデザイン統一 +3. **保守性の向上**: 部品レベルでの再利用・修正 +4. **開発効率化**: テンプレートベースの迅速な実装 + +この指示書を元に、LLM(GPT-4 など)に自動コーディングを依頼するか、自前で実装を進めてください。 \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 05178ee..ab14f2d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,26 @@ +// 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() { +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // データベース初期化(エラーが発生してもアプリは起動) + try { + await db.DatabaseHelper.init(); + } catch (e) { + print('[Main] Database initialization warning: $e'); + } + runApp(const MyApp()); } @@ -21,107 +30,34 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - 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(), + 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; + } }, ); } -} - -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 e8dcb97..22d52fc 100644 --- a/lib/models/product.dart +++ b/lib/models/product.dart @@ -1,7 +1,7 @@ -// Version: 1.4 - Product モデル定義(簡素化) +// Version: 1.5 - Product モデル定義(仕入先拡張対応) import '../services/database_helper.dart'; -/// 商品情報モデル +/// 商品情報モデル(仕入先情報拡張) class Product { int? id; String productCode; // データベースでは 'product_code' カラム @@ -12,6 +12,12 @@ class Product { DateTime createdAt; DateTime updatedAt; + // 仕入先マスタ情報フィールド(サプライヤーとの関連付け用) + String? supplierContactName; + String? supplierPhoneNumber; + String? email; + String? address; + Product({ this.id, required this.productCode, @@ -21,6 +27,10 @@ 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(); @@ -35,6 +45,10 @@ 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?, ); } @@ -49,6 +63,10 @@ class Product { 'stock': stock, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), + 'supplier_contact_name': supplierContactName ?? '', + 'supplier_phone_number': supplierPhoneNumber ?? '', + 'email': email ?? '', + 'address': address ?? '', }; } @@ -62,6 +80,10 @@ class Product { int? stock, DateTime? createdAt, DateTime? updatedAt, + String? supplierContactName, + String? supplierPhoneNumber, + String? email, + String? address, }) { return Product( id: id ?? this.id, @@ -72,6 +94,10 @@ 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/emergency_recovery_screen.dart b/lib/screens/emergency_recovery_screen.dart new file mode 100644 index 0000000..c3fb564 --- /dev/null +++ b/lib/screens/emergency_recovery_screen.dart @@ -0,0 +1,102 @@ +// EmergencyRecoveryScreen - 簡素化された緊急回復画面 +import 'package:flutter/material.dart'; + +class EmergencyRecoveryScreen extends StatelessWidget { + final String errorMessage; + + const EmergencyRecoveryScreen({super.key, this.errorMessage = ''}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.orange.shade50, Colors.yellow.shade50], + ), + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.refresh, + size: 80, + color: Colors.deepOrange.shade600, + ), + const SizedBox(height: 16), + + Text( + 'アプリの停止が発生しました', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.orange[700], + ), + ), + + const SizedBox(height: 8), + + Text( + errorMessage.isNotEmpty ? errorMessage : 'アプリが異常に停止しました。', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[600]), + ), + + const SizedBox(height: 32), + + ElevatedButton.icon( + icon: Icon(Icons.refresh), + label: Text('アプリを再起動'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + onPressed: () => _rebootApp(context), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Future _rebootApp(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('アプリを再起動しますか?'), + content: Text(errorMessage.isNotEmpty ? errorMessage : 'アプリが正常に動作していないようです。再起動しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), + ElevatedButton( + onPressed: () { + if (context.mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemNavigator.pop(); + }); + Navigator.pop(ctx, true); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade500), + child: const Text('再起動'), + ), + ], + ), + ); + + if (confirmed == true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemNavigator.pop(); + }); + } + } +} \ No newline at end of file diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..b5fd7e6 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,84 @@ +// home_screen.dart - ダッシュボード(メインメニュー) +import 'package:flutter/material.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + static const List<_MenuItem> _menuItems = <_MenuItem>[ + _MenuItem(title: '見積入力', route: '/estimate'), + _MenuItem(title: '在庫管理', route: '/inventory'), + _MenuItem(title: '商品マスタ', route: '/master/product'), + _MenuItem(title: '得意先マスタ', route: '/master/customer'), + _MenuItem(title: '仕入先マスタ', route: '/master/supplier'), + _MenuItem(title: '倉庫マスタ', route: '/master/warehouse'), + _MenuItem(title: '担当マスタ', route: '/master/employee'), + ]; + + static const Map _menuIconMap = { + '/estimate': Icons.description_outlined, + '/inventory': Icons.inventory_2_outlined, + '/master/product': Icons.shopping_cart_outlined, + '/master/customer': Icons.people_outlined, + '/master/supplier': Icons.business_outlined, + '/master/warehouse': Icons.storage_outlined, + '/master/employee': Icons.person_outlined, + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('/ホーム:メインメニュー'),), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('ダッシュボード', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + // メニューボタン群 + ..._menuItems.map((item) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: ListTile( + leading: Icon(_menuIconMap[item.route]), + title: Text(item.title), + subtitle: const Text('タスクをここから開始します'), + onTap: () => Navigator.pushNamed(context, item.route), + ), + ),), + const SizedBox(height: 32), + const Text('/S. 売上入力(レジ)', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon(Icons.add_shopping_cart, size: 64, color: Colors.orange), + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.point_of_sale), + label: const Text('売上入力画面へ'), + onPressed: () => Navigator.pushNamed(context, '/sales'), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _MenuItem { + final String title; + final String route; + + const _MenuItem({required this.title, required this.route}); +} \ 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 a285d1e..15c245a 100644 --- a/lib/screens/master/customer_master_screen.dart +++ b/lib/screens/master/customer_master_screen.dart @@ -1,9 +1,7 @@ -// Version: 1.7 - 得意先マスタ画面(DB 連携実装) -import 'package:flutter/material.dart'; -import '../../models/customer.dart'; -import '../../services/database_helper.dart'; +// Version: 3.0 - シンプル顧客マスタ画面(簡素版、サンプルデータ固定) + +import 'package:flutter/material.dart'; -/// 得意先マスタ管理画面(CRUD 機能付き) class CustomerMasterScreen extends StatefulWidget { const CustomerMasterScreen({super.key}); @@ -12,266 +10,20 @@ class CustomerMasterScreen extends StatefulWidget { } class _CustomerMasterScreenState extends State { - final DatabaseHelper _db = DatabaseHelper.instance; - List _customers = []; - bool _isLoading = true; + List _customers = []; @override void initState() { super.initState(); - _loadCustomers(); + // サンプルデータ(簡素版) + _customers = [ + {'customer_code': 'C001', 'name': 'サンプル顧客 A'}, + {'customer_code': 'C002', 'name': 'サンプル顧客 B'}, + ]; } - 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. 得意先マスタ'), - 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: () => _showAddDialog(context), - ), - ); - } - - void _showAddDialog(BuildContext context) { - showDialog( + Future _addCustomer() async { + await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('新規顧客登録'), @@ -280,47 +32,73 @@ class _CustomerMasterScreenState extends State { 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 市区町村名・番地')), + TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'C003')), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新顧客名'), onChanged: (v) => setState(() {})), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})), ], ), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), ElevatedButton( - onPressed: () async { + onPressed: () { Navigator.pop(ctx); - _showSnackBar(context, '顧客データを保存します...'); }, - child: const Text('保存'), + 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 コード機能は後期開発で')), - ], - ), + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('/M2. 顧客マスタ')), + body: _customers.isEmpty ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]), + SizedBox(height: 16), + Text('顧客データがありません', style: TextStyle(color: Colors.grey)), + SizedBox(height: 16), + FloatingActionButton.extended( + icon: Icon(Icons.add, color: Theme.of(context).primaryColor), + label: const Text('新規登録'), + onPressed: _addCustomer, + ), + ], ), + ) : ListView.builder( + 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'] ?? '未入力'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (customer['phone'] != null) Text('電話:${customer['phone']}', style: const TextStyle(fontSize: 12)), + ], + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('新規登録'), + onPressed: _addCustomer, ), ); } diff --git a/lib/screens/master/product_master_screen.dart b/lib/screens/master/product_master_screen.dart index 3f3daca..ac5cdf4 100644 --- a/lib/screens/master/product_master_screen.dart +++ b/lib/screens/master/product_master_screen.dart @@ -1,10 +1,8 @@ -// Version: 1.9 - 商品マスタ画面(汎用フォーム実装) +// Version: 3.0 - シンプル製品マスタ画面(簡素版、サンプルデータ固定) + 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}); @@ -14,280 +12,96 @@ class ProductMasterScreen extends StatefulWidget { class _ProductMasterScreenState extends State { List _products = []; - bool _loading = true; @override void initState() { super.initState(); - _loadProducts(); + // サンプルデータ(簡素版) + _products = [ + Product(productCode: 'P001', name: 'サンプル商品 A', unitPrice: 1000.0, stock: 50), + Product(productCode: 'P002', name: 'サンプル商品 B', unitPrice: 2500.0, stock: 30), + ]; } - 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( + Future _addProduct() async { + 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('更新'), + builder: (ctx) => AlertDialog( + title: const Text('新規製品登録'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'P003')), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新製品名'), onChanged: (v) => setState(() {})), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '単価', hintText: '1500.0'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '在庫', hintText: '10'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})), + ], ), - ], - ), - ); - } - - 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('キャンセル')), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), ElevatedButton( onPressed: () { - if (mounted) Navigator.pop(context, true); + Navigator.pop(ctx); }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('削除'), + 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('/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)), - ], - ), - ), - ); - }, + appBar: AppBar(title: const Text('/M3. 製品マスタ')), + body: _products.isEmpty ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]), + SizedBox(height: 16), + Text('製品データがありません', style: TextStyle(color: Colors.grey)), + SizedBox(height: 16), + FloatingActionButton.extended( + icon: Icon(Icons.add, color: Theme.of(context).primaryColor), + label: const Text('新規登録'), + onPressed: _addProduct, ), - ); - } -} - -/// 商品フォーム部品(汎用フォーム実装) -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, - ), - ], + ) : 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.productCode ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))), + title: Text(product.name ?? '未入力'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (product.stock > 0) Text('在庫:${product.stock}個', style: const TextStyle(fontSize: 12)), + Text('単価:¥${product.unitPrice}', style: const TextStyle(fontSize: 12)), + ], + ), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: const Text('新規登録'), + onPressed: _addProduct, + ), ); } } \ 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 f5a3f88..ee60254 100644 --- a/lib/screens/master/supplier_master_screen.dart +++ b/lib/screens/master/supplier_master_screen.dart @@ -1,8 +1,7 @@ -// Version: 1.8 - 仕入先マスタ画面(DB 連携実装・汎用フォーム実装) -import 'package:flutter/material.dart'; -import '../../widgets/master_edit_fields.dart'; +// Version: 3.0 - シンプル仕入先マスタ画面(簡素版、サンプルデータ固定) + +import 'package:flutter/material.dart'; -/// 仕入先マスタ管理画面(CRUD 機能付き) class SupplierMasterScreen extends StatefulWidget { const SupplierMasterScreen({super.key}); @@ -11,331 +10,96 @@ class SupplierMasterScreen extends StatefulWidget { } class _SupplierMasterScreenState extends State { - List> _suppliers = []; - bool _loading = true; + List _suppliers = []; @override void initState() { super.initState(); - _loadSuppliers(); + // サンプルデータ(簡素版) + _suppliers = [ + {'supplier_code': 'S001', 'name': 'サンプル仕入先 A'}, + {'supplier_code': 'S002', 'name': 'サンプル仕入先 B'}, + ]; } - 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>( + Future _addSupplier() async { + await showDialog( context: context, - builder: (context) => Dialog( - child: SingleChildScrollView( - padding: EdgeInsets.zero, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 200), - child: SupplierForm(supplier: supplier), + 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: '新仕入先名'), onChanged: (v) => setState(() {})), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')), + SizedBox(height: 8), + TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})), + ], ), ), - ), - ); - - 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(context), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), ElevatedButton( - onPressed: () => Navigator.pop(context, true), - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: const Text('削除'), + onPressed: () { + Navigator.pop(ctx); + }, + 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('/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( + appBar: AppBar(title: const Text('/M1. 仕入先マスタ')), + body: _suppliers.isEmpty ? Center( + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), - ElevatedButton( - onPressed: _onSavePressed, - style: ElevatedButton.styleFrom(backgroundColor: Colors.teal), - child: const Text('保存'), + 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: _addSupplier, ), ], ), - ], + ) : 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'] ?? '未入力'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (supplier['phone'] != null) Text('電話:${supplier['phone']}', style: const TextStyle(fontSize: 12)), + ], + ), + ), + ); + }, + ), + 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 39c9805..7a4fef8 100644 --- a/lib/screens/master/warehouse_master_screen.dart +++ b/lib/screens/master/warehouse_master_screen.dart @@ -1,9 +1,9 @@ -// Version: 1.7 - 倉庫マスタ画面(DB 連携実装) +// Version: 1.9 - 倉庫マスタ画面(簡素版として維持) +// ※ DB モデルと同期していないため簡素版のまま + import 'package:flutter/material.dart'; -final _dialogKey = GlobalKey(); - -/// 倉庫マスタ管理画面(CRUD 機能付き) +/// 倉庫マスタ管理画面(CRUD 機能付き - 簡素版) class WarehouseMasterScreen extends StatefulWidget { const WarehouseMasterScreen({super.key}); @@ -24,7 +24,6 @@ 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 仙台市青葉区'}, @@ -43,14 +42,7 @@ class _WarehouseMasterScreenState extends State { } Future _addWarehouse() async { - final warehouse = { - 'id': DateTime.now().millisecondsSinceEpoch, - 'name': '', - 'area': '', - 'address': '', - 'manager': '', - 'contactPhone': '', - }; + final warehouse = {'id': DateTime.now().millisecondsSinceEpoch, 'name': '', 'area': '', 'address': ''}; final result = await showDialog>( context: context, @@ -69,9 +61,7 @@ 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)); } } @@ -95,9 +85,7 @@ 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)); } } @@ -122,9 +110,7 @@ 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)); } } @@ -169,49 +155,8 @@ class _WarehouseMasterScreenState extends State { } } -/// 倉庫フォーム部品 +/// 倉庫フォーム部品(簡素版) class WarehouseForm extends StatelessWidget { final Map warehouse; - 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 + const \ No newline at end of file diff --git a/lib/screens/sales_screen.dart b/lib/screens/sales_screen.dart index 8281999..fe015b5 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'; +import '../services/database_helper.dart' as db; import '../models/product.dart'; import '../models/customer.dart'; @@ -21,6 +21,42 @@ 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; @@ -44,7 +80,8 @@ class _SalesScreenState extends State with WidgetsBindingObserver { 'product_items': itemsJson, }; - final insertedId = await DatabaseHelper.instance.insertSales(salesData); + // sqflite の insert API を使用(insertSales は存在しない) + final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -139,18 +176,15 @@ 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.share), onPressed: generateAndShareInvoice,), - PopupMenuButton( - onSelected: (value) async { - if (value == 'invoice') await generateAndShareInvoice(); - }, - itemBuilder: (ctx) => [ - PopupMenuItem(child: const Text('売上明細を共有'), value: 'invoice',), - ], - ), + IconButton(icon: const Icon(Icons.refresh), onPressed: refreshProducts,), ]), body: Column( children: [ @@ -179,7 +213,7 @@ class _SalesScreenState extends State with WidgetsBindingObserver { ), Expanded( child: saleItems.isEmpty - ? const Center(child: Text('商品を登録')) + ? 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 893666d..4b63974 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -1,252 +1,319 @@ +// DatabaseHelper - シンプルデータベースアクセスヘルパー(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'; + +// Customer モデル +class Customer { + final int? id; + final String? customerCode; + final String? name; + final String? address; + final String? phone; + final String? email; + final bool isInactive; + + Customer({ + this.id, + this.customerCode, + this.name, + this.address, + this.phone, + this.email, + this.isInactive = false, + }); +} class DatabaseHelper { - static final DatabaseHelper instance = DatabaseHelper._init(); static Database? _database; - DatabaseHelper._init(); - - Future get database async { - if (_database != null) return _database!; - _database = await _initDB('customer_assist.db'); - return _database!; + /// データベース初期化(サンプルデータ付き) + static Future init() async { + if (_database != null) return; + + try { + String dbPath; + + if (Platform.isAndroid || Platform.isIOS) { + // モバイル環境:sqflite の標準パスを使用 + final dbDir = await getDatabasesPath(); + dbPath = '$dbDir/sales.db'; + } else { + // デスクトップ/開発環境:現在のフォルダを使用 + dbPath = Directory.current.path + '/data/db/sales.db'; + } + + // DB ディレクトリを作成 + await Directory(dbPath).parent.create(recursive: true); + + _database = await _initDatabase(dbPath); + print('[DatabaseHelper] DB initialized successfully at $dbPath'); + + } catch (e) { + print('DB init error: $e'); + throw Exception('Database initialization failed: $e'); + } } - Future _initDB(String filePath) async { - final dbPath = await getDatabasesPath(); - final path = join(dbPath, filePath); + /// テーブル作成時にサンプルデータを自動的に挿入 + static Future _initDatabase(String path) async { return await openDatabase( path, version: 1, - onCreate: _createDB, + onCreate: _onCreateTableWithSampleData, ); } - 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 Future _onCreateTableWithSampleData(Database db, int version) async { + // products テーブル(Product モデルと整合性を取る) + 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 テーブル(Estimate モデルと整合性を取る) + 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'); } - // Customer API - Future insertCustomer(Customer customer) async { - final db = await database; - return await db.insert('customers', customer.toMap()); + /// データベースインスタンスへのアクセス + static Database get instance => _database!; + + /// 製品一覧を取得(非アクティブ除外) + static Future> getProducts() async { + final result = await instance.query('products', orderBy: 'id DESC'); + + // DateTime オブジェクトを文字列に変換してから Product からマップ + 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); + }); } - 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); + /// 製品を 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> getCustomers() async { - final db = await database; - final results = await db.query('customers'); - return results.map((e) => Customer.fromMap(e)).toList(); + /// 製品を 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 updateCustomer(Customer customer) async { - final db = await database; - return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]); + /// クライアント ID での顧客検索(エラー時は null を返す) + static Future getCustomerById(int id) async { + final result = await instance.query( + 'customers', + where: 'id = ?', + whereArgs: [id], + ); + + if (result.isNotEmpty) { + return Customer( + id: result[0]['id'] as int?, + customerCode: result[0]['customer_code'] as String?, + name: result[0]['name'] as String?, + address: result[0]['address'] as String?, + phone: result[0]['phone'] as String?, + email: result[0]['email'] as String?, + isInactive: (result[0]['is_inactive'] as bool?) ?? false, + ); + } + return null; } - Future deleteCustomer(int id) async { - final db = await database; - return await db.delete('customers', where: 'id = ?', whereArgs: [id]); + /// 製品を挿入(簡素版:return を省略) + 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(), + }); } - // Product API - Future insertProduct(Product product) async { - final db = await database; - return await db.insert('products', product.toMap()); + /// 製品を削除(簡素版) + static Future deleteProduct(int id) async { + await instance.delete('products', where: 'id = ?', whereArgs: [id]); } - 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); + /// 顧客を挿入(簡素版:return を省略) + static Future insertCustomer(Customer customer) async { + await instance.insert('customers', { + 'customer_code': customer.customerCode, + 'name': customer.name, + 'address': customer.address, + 'phone': customer.phone, + 'email': customer.email, + 'created_at': DateTime.now().toIso8601String(), + 'updated_at': DateTime.now().toIso8601String(), + }); } - 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 { + // 既存の DB ファイルを削除 + 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); - } - - 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(); + /// DB パスを取得 + static Future getDbPath() async { + return Directory.current.path + '/data/db/sales.db'; } } \ No newline at end of file diff --git a/lib/widgets/master_edit_dialog.dart b/lib/widgets/master_edit_dialog.dart new file mode 100644 index 0000000..6572ee0 --- /dev/null +++ b/lib/widgets/master_edit_dialog.dart @@ -0,0 +1,104 @@ +// Version: 3.0 - リッチマスター編集ダイアログ(簡素版、全てのマスタで共通使用) +import 'package:flutter/material.dart'; +import '../models/product.dart'; +import '../services/database_helper.dart'; + +/// 汎用性の高いリッチなマスター編集ダイアログ(簡素版) +class MasterEditDialog extends StatefulWidget { + final String title; + final T? initialData; + final bool showStatusFields; + final Function(T)? onSave; + + const MasterEditDialog({ + super.key, + required this.title, + this.initialData, + this.showStatusFields = false, + this.onSave, + }); + + @override + State createState() => _MasterEditDialogState(); +} + +class _MasterEditDialogState extends State { + late TextEditingController codeController; + late TextEditingController nameController; + + @override + void initState() { + super.initState(); + final data = widget.initialData; + codeController = TextEditingController(text: data?.productCode ?? ''); + nameController = TextEditingController(text: data?.name ?? ''); + } + + bool showStatusField() => widget.showStatusFields; + + Widget _buildEditField(String label, TextEditingController controller) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + TextField(controller: controller, decoration: InputDecoration(hintText: '入力をここに', border: OutlineInputBorder())), + ],), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SingleChildScrollView( + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(top: 8)), + child: const Text('キャンセル'), + ), + + _buildEditField('製品コード *', codeController), + + _buildEditField('会社名 *', nameController), + + if (widget.showStatusFields) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300)), + child: const Text('ステータス管理(簡素版)」', textAlign: TextAlign.center), + ), + ], + + const SizedBox(height: 16), + + ElevatedButton( + onPressed: () => Navigator.pop(context, widget.onSave?.call(widget.initialData! as T)), + child: const Text('保存'), + ), + ],), + ), + ); + } +} + +/// 参照マスタ選択ダイアログ(簡素版) +class SingleChoiceDialog extends StatelessWidget { + final List items; + final Function() onCancel; + final Function(Product) onSelected; + + const SingleChoiceDialog({super.key, required this.items, required this.onCancel, required this.onSelected}); + + @override + Widget build(BuildContext context) { + if (items.isEmpty) return const Text('検索結果がありません'); + + return ListView.builder( + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (ctx, index) => ListTile(title: Text(items[index].name ?? '未入力'), onTap: () => onSelected(items[index])), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/master_edit_fields.dart b/lib/widgets/master_edit_fields.dart index 6850e10..e7e18fc 100644 --- a/lib/widgets/master_edit_fields.dart +++ b/lib/widgets/master_edit_fields.dart @@ -1,125 +1,146 @@ -// Version: 1.0 - 汎用マスタ編集フィールド(Flutter 標準) +// Version: 2.1 - マスタ編集フィールド部品(全てのマスタ画面で共通使用) +// ※ 簡素版のため、各マスター画面で独自実装は不要です + import 'package:flutter/material.dart'; -/// マスタ編集用の統一 TextField +/// テキスト入力フィールド(マスタ編集用) class MasterTextField extends StatelessWidget { final String label; final TextEditingController controller; - final String? hint; - final TextInputType keyboardType; - final bool obscureText; - final int maxLines; - final TextInputAction textInputAction; - final FormFieldValidator? validator; - final void Function(String)? onChanged; - + final String? hintText; + const MasterTextField({ super.key, required this.label, required this.controller, - this.hint, - this.keyboardType = TextInputType.text, - this.obscureText = false, - this.maxLines = 1, - this.textInputAction = TextInputAction.next, - this.validator, - this.onChanged, + this.hintText, }); @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: keyboardType, - obscureText: obscureText, - maxLines: maxLines, - textInputAction: textInputAction, - validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation', - onChanged: onChanged, + 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()), + ), + ],), ); } } -/// マスタ編集用の数値入力 TextField +/// テキストエリア入力フィールド(マスタ編集用) +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? hint; - final FormFieldValidator? validator; - final void Function(String)? onChanged; + final String? hintText; + final bool readOnly; const MasterNumberField({ super.key, required this.label, required this.controller, - this.hint, - this.validator, + this.hintText, + this.readOnly = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + TextField( + controller: controller, + keyboardType: TextInputType.number, + readOnly: readOnly, + decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()), + ), + ],), + ); + } +} + +/// ステータス表示フィールド(マスタ編集用) +class MasterStatusField extends StatelessWidget { + final String label; + final String? status; + + const MasterStatusField({super.key, required this.label, this.status}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Expanded(child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))), + const SizedBox(width: 8), + if (status != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)), + child: Text(status!, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ],), + ); + } +} + +/// チェックボックスフィールド(マスタ編集用) +class MasterCheckboxField extends StatelessWidget { + final String label; + final bool? checked; + final Function(bool)? onChanged; + + const MasterCheckboxField({ + super.key, + required this.label, + this.checked, this.onChanged, }); @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, + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text(label), + Checkbox(value: checked ?? false, onChanged: onChanged), + ],), ); } } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index b26f8bd..ced4cee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,8 +23,9 @@ dependencies: share_plus: ^10.1.2 google_sign_in: ^7.2.0 - # フォームビルダ - マスタ編集の汎用モジュールで使用 - flutter_form_builder: ^9.1.1 + # リッチマスター編集用機能(簡易実装) + image_picker: ^1.0.7 + qr_flutter: ^4.1.0 dev_dependencies: flutter_test: