diff --git a/README.md b/README.md index d4b018b..1c2a7b2 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,16 @@ - Phone ブック取り込みを共通化した `ContactPickerSheet` - 税率・税表示・印影の追加設定 - 90 日寿命チェック(`BuildExpiryInfo`)と期限切れ画面 +- モジュール指向ダッシュボード + - `FeatureModule` / `ModuleRegistry` により各機能を独立カードとして登録 + - A2(伝票履歴)/A1(伝票入力)モジュールと売上管理モジュールを実装済み + - 伝票ロックバーやカード表示は AppConfig の feature flag で制御 +- ダッシュボードと売上モジュールの最新強化 + - `AppConfig.enabledRoutes` にダッシュボード・売上伝票(U1/U2)経路を含め、S1 設定画面で登録したカードが D1 に確実に現れるよう調整 + - ダッシュボードカード登録時に有効モジュールを自動注入し、売上伝票入力(`sales_entries`)カードを SalesManagementModule から直接表示 + - A2(AppBar) の左上ボタンがホームモード設定に追従し、戻る/メニューボタンを正しく出し分け + - U2:売上伝票編集では保存アイコン・テキストボタンを AppBar に追加し、明細フォームのキーボード余白を整理して「せり上がり」を軽減 + - U2 の顧客選択モーダルを `Scaffold + AppBar` 構成に刷新し、タイトル/閉じる操作を統一 - ビルド用スクリプト `scripts/build_with_expiry.sh` - `--dart-define=APP_BUILD_TIMESTAMP` を自動付与し APK を生成 - analyze 実行~APK ビルドのワンステップ化 @@ -37,7 +47,7 @@ - Google Drive への自動バックアップ、容量推定 2. **販売アシスト1号の拡張モジュール化** - 売上(POS)、仕入、在庫、チャット、通知をモジュールとして追加 - - ダッシュボードにモジュールカードを組み込む方式へ刷新 + - ダッシュボードにモジュールカードを組み込む方式へ刷新(初期実装済) 3. **チャット&サポート** - 「順次対応である」旨を明記した問い合わせチャットをローカル実装 - 母艦側で受信・返信・履歴管理ができる仕組みを構築 @@ -83,6 +93,70 @@ - `flutter analyze` → `flutter build apk` を連続実行 3. 実機/エミュレータで起動すると、寿命切れ時には `ExpiredApp` が自動表示されます。 +### 機能フラグ(モジュール) + +アプリは `AppConfig` の dart-define を通じてモジュール単位で有効化できます。 + +| Flag | 既定値 | 説明 | +| --- | --- | --- | +| `ENABLE_BILLING_DOCS` | `true` | 伝票作成/履歴モジュール(A1/A2)の表示を制御 | +| `ENABLE_SALES_MANAGEMENT` | `false` | 売上管理モジュール(年間カード・トップ顧客・月次サマリー)を有効化 | +| `ENABLE_DEBUG_WEBHOOK` | `false` | MatterMost Webhook へノード情報/日時の debug log を送信 | +| `DEBUG_WEBHOOK_URL` | `https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze` | debug 送信先を上書きしたい場合に指定 | + +例: 売上管理と debug ログ送信を同時に試す場合 + +```bash +flutter run \ + --dart-define=ENABLE_SALES_MANAGEMENT=true \ + --dart-define=ENABLE_DEBUG_WEBHOOK=true \ + --dart-define=DEBUG_WEBHOOK_URL=https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze +``` + +`ENABLE_DEBUG_WEBHOOK=false`(既定値)に戻すと MatterMost への送信は行われません。フラグが有効なモジュールは `ModuleRegistry` 経由でダッシュボードカードに自動注入され、debug フラグはアプリ起動時の ping 送信のみを制御します。 + +### 画面IDとナビゲーション指針 + +最新の UI アップデートにより、画面遷移ルールと画面タイトルの表記を統一しました。 + +- **すべての AppBar タイトルは 2 文字の画面ID + コロン + タイトル** で表示します(例: `S1:設定`, `U4:入金編集`)。 +- **ホーム以外の画面は明示的な戻るボタンを左上に表示** します。`Scaffold` の `leading` で `BackButton` を指定し、ユーザーが階層を把握しやすいようにします。 +- **伝票一覧(A2:履歴リスト)がホームモードの場合のみ三本線のメニューボタン** を表示し、ドロワーから各種マスターや設定へ遷移できます。ホームモードでなければ通常通り戻るボタンを表示します。 +- 新規に追加する画面もこの規約に従って ID を採番し、Dashboard 側のカードやメニュー表示名もあわせて更新してください。 +- 売上伝票・入金管理(U1〜U4)など財務関連の新画面にもすでに適用済みです。各機能の AppBar を流用する際は ID だけ差し替えられるようコンポーネント化を検討しています。 + +--- + +## 粗利計算と卸値管理 + +- 商品マスタ(P1)に **仕入値(wholesale_price)** を保持するフィールドを追加しました。新規/既存商品の編集ダイアログで販売単価と合わせて卸値を登録できます。 +- U2/A1 での売上明細は商品選択時に `ProductPickerModal` から卸値を受け取り、明細内部にコストを保持します。仕入値が未登録の明細は **暫定粗利=0** として扱い、仕入確定後に再計算する前提です。 +- S1:設定 に「粗利表示 / 暫定粗利」セクションを追加し、次のスイッチで運用を制御できます。 + 1. **U2/A1で粗利を表示** … 単価−仕入値を計算して行ごとに表示。 + 2. **営業端末に粗利表示スイッチを表示** … 現場ユーザーが粗利の表示/非表示を切り替えられるようにする。 + 3. **暫定粗利(仕入未確定)を合計に含める** … 未入荷・未知商品の粗利=0 を合計に含めるかどうかを制御。 +- これらの設定値は `app_settings` テーブルに保存され、端末再起動後も保持されます。アプリの将来バージョンではロット別の仕入れ管理と合わせて粗利再計算ジョブを提供する予定です。 + +--- + +## Googleカレンダー連携 + +営業オペレーションの ToDo を Google カレンダーへ自動配信します。設定画面(S1:設定 → 「Googleカレンダー連携」セクション)から次の手順で利用できます。 + +1. 「Googleカレンダーと連携する」を ON に切り替え、Google アカウントへサインインします。 +2. 「カレンダー一覧を取得」で同期可能なカレンダーを読み込み、プライマリまたは任意の書き込み権限付きカレンダーを選択します。 +3. 以降、出荷 (`ShipmentService`) と債権 (`ReceivableService`) のイベントが `BusinessCalendarMapper` を経由して自動同期され、Google 側では `shipment-` / `receivable-` という extendedProperties 付きで登録されます。 +4. 必要に応じて「今すぐカレンダー同期を実行」ボタンを押すと、全件再同期+結果サマリ(件数・エラー詳細)が表示されます。手動同期は `CalendarSyncDiagnostics` により実装されています。 + +### 同期対象イベント + +| 区分 | 連携トリガー | 内容 | +| --- | --- | --- | +| 出荷 | 新規作成・更新・ステータス遷移 | 出荷予定/実績日を 9:00〜2h イベントとして登録。顧客名、受注番号、追跡情報などを本文に記載。 | +| 債権 | サマリ取得・入金追加/削除 | 期日を 10:00〜1h イベントとして登録。請求額や残高を本文に記載。 | + +Google 側でカレンダーを変更したい場合は、再度一覧取得→選択を行ってください。サインイン状態が切れた場合は「Googleを再認証」でリフレッシュできます。 + --- ## 母艦「お局様」LAN サーバの起動 @@ -117,7 +191,7 @@ - README は **機能追加・アーキテクチャ変更・モジュール構成の見直し時に必ず更新** します。 - 変更履歴とファイルツリーは必要に応じて追記し、最新状態を反映させます。 -- 設計検討中の内容(母艦 Web UI、チャット、モジュール化など)は本 README の「将来像」節で随時アップデートします。 +- 設計検討中の内容(母艦 Web UI、チャット、モジュール化など)は本 README の「将来像」節で随時アップデートします。現在は売上モジュールが最初の実装例です。 --- diff --git a/assets/icon/Gemini_Generated_Image_zemfu5zemfu5zemf.ico b/assets/icon/Gemini_Generated_Image_zemfu5zemfu5zemf.ico new file mode 100644 index 0000000..2191fdb Binary files /dev/null and b/assets/icon/Gemini_Generated_Image_zemfu5zemfu5zemf.ico differ diff --git a/assets/icon/app_icon.png b/assets/icon/app_icon.png new file mode 100644 index 0000000..92711c4 Binary files /dev/null and b/assets/icon/app_icon.png differ diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index 6298295..549a687 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -24,6 +24,12 @@ @import geolocator_apple; #endif +#if __has_include() +#import +#else +@import google_sign_in_ios; +#endif + #if __has_include() #import #else @@ -90,6 +96,7 @@ [FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]]; [FlutterEmailSenderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterEmailSenderPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; + [FLTGoogleSignInPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleSignInPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]]; [OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]]; diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 718700e..2f904da 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -8,6 +8,12 @@ class AppConfig { /// 機能フラグ(ビルド時に --dart-define で上書き可能)。 static const bool enableBillingDocs = bool.fromEnvironment('ENABLE_BILLING_DOCS', defaultValue: true); static const bool enableSalesManagement = bool.fromEnvironment('ENABLE_SALES_MANAGEMENT', defaultValue: false); + static const bool enableSalesOperations = bool.fromEnvironment('ENABLE_SALES_OPERATIONS', defaultValue: false); + static const bool enableDebugWebhookLogging = bool.fromEnvironment('ENABLE_DEBUG_WEBHOOK', defaultValue: false); + static const String debugWebhookUrl = String.fromEnvironment( + 'DEBUG_WEBHOOK_URL', + defaultValue: 'https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze', + ); /// APIエンドポイント(必要に応じて dart-define で注入)。 static const String apiEndpoint = String.fromEnvironment('API_ENDPOINT', defaultValue: ''); @@ -16,6 +22,8 @@ class AppConfig { static Map get features => { 'enableBillingDocs': enableBillingDocs, 'enableSalesManagement': enableSalesManagement, + 'enableSalesOperations': enableSalesOperations, + 'enableDebugWebhookLogging': enableDebugWebhookLogging, }; /// 機能キーで有効/無効を判定するヘルパー。 @@ -23,12 +31,15 @@ class AppConfig { /// 有効なダッシュボードルート一覧(動的に増える場合はここで管理)。 static Set get enabledRoutes { - final routes = {'settings'}; + final routes = {'settings', 'dashboard'}; if (enableBillingDocs) { routes.addAll({'invoice_history', 'invoice_input', 'master_hub', 'customer_master', 'product_master'}); } if (enableSalesManagement) { - routes.add('sales_management'); + routes.addAll({'sales_management', 'sales_entries'}); + } + if (enableSalesOperations) { + routes.addAll({'sales_orders', 'shipments', 'inventory', 'receivables'}); } return routes; } diff --git a/lib/main.dart b/lib/main.dart index 4b65b56..28fac64 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'services/app_settings_repository.dart'; import 'services/chat_sync_scheduler.dart'; import 'services/mothership_client.dart'; import 'services/theme_controller.dart'; +import 'services/debug_webhook_logger.dart'; import 'utils/build_expiry_info.dart'; void main() async { @@ -44,11 +45,13 @@ class _MyAppState extends State { int _activePointers = 0; final MothershipClient _mothershipClient = MothershipClient(); final ChatSyncScheduler _chatSyncScheduler = ChatSyncScheduler(); + final DebugWebhookLogger _debugLogger = const DebugWebhookLogger(); @override void initState() { super.initState(); _sendHeartbeat(); + _sendDebugPing(); _chatSyncScheduler.start(); } @@ -62,6 +65,10 @@ class _MyAppState extends State { Future.microtask(() => _mothershipClient.sendHeartbeat(widget.expiryInfo)); } + void _sendDebugPing() { + Future.microtask(() => _debugLogger.sendNodePing(note: 'App boot completed')); + } + @override Widget build(BuildContext context) { return ValueListenableBuilder( diff --git a/lib/models/department_model.dart b/lib/models/department_model.dart new file mode 100644 index 0000000..34b12fd --- /dev/null +++ b/lib/models/department_model.dart @@ -0,0 +1,60 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class Department { + const Department({ + required this.id, + required this.name, + this.code, + this.description, + this.isActive = true, + required this.updatedAt, + }); + + final String id; + final String name; + final String? code; + final String? description; + final bool isActive; + final DateTime updatedAt; + + Department copyWith({ + String? id, + String? name, + String? code, + String? description, + bool? isActive, + DateTime? updatedAt, + }) { + return Department( + id: id ?? this.id, + name: name ?? this.name, + code: code ?? this.code, + description: description ?? this.description, + isActive: isActive ?? this.isActive, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + factory Department.fromMap(Map map) { + return Department( + id: map['id'] as String, + name: map['name'] as String? ?? '-', + code: map['code'] as String?, + description: map['description'] as String?, + isActive: (map['is_active'] as int? ?? 1) == 1, + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'code': code, + 'description': description, + 'is_active': isActive ? 1 : 0, + 'updated_at': updatedAt.toIso8601String(), + }; + } +} diff --git a/lib/models/hash_chain_models.dart b/lib/models/hash_chain_models.dart new file mode 100644 index 0000000..8dfea87 --- /dev/null +++ b/lib/models/hash_chain_models.dart @@ -0,0 +1,33 @@ +class HashChainBreak { + const HashChainBreak({ + required this.invoiceId, + this.invoiceNumber, + required this.issue, + this.expectedHash, + this.actualHash, + this.expectedPreviousHash, + this.actualPreviousHash, + }); + + final String invoiceId; + final String? invoiceNumber; + final String issue; + final String? expectedHash; + final String? actualHash; + final String? expectedPreviousHash; + final String? actualPreviousHash; +} + +class HashChainVerificationResult { + const HashChainVerificationResult({ + required this.isHealthy, + required this.checkedCount, + required this.verifiedAt, + required this.breaks, + }); + + final bool isHealthy; + final int checkedCount; + final DateTime verifiedAt; + final List breaks; +} diff --git a/lib/models/inventory_models.dart b/lib/models/inventory_models.dart new file mode 100644 index 0000000..fb84ca1 --- /dev/null +++ b/lib/models/inventory_models.dart @@ -0,0 +1,124 @@ +import 'package:meta/meta.dart'; + +/// 入出庫区分。 +enum InventoryMovementType { + receipt, + issue, + adjustment, +} + +extension InventoryMovementTypeX on InventoryMovementType { + String get displayName { + switch (this) { + case InventoryMovementType.receipt: + return '入庫'; + case InventoryMovementType.issue: + return '出庫'; + case InventoryMovementType.adjustment: + return '棚卸/調整'; + } + } + + /// 在庫増減に与える係数。 + int get deltaSign { + switch (this) { + case InventoryMovementType.receipt: + return 1; + case InventoryMovementType.issue: + return -1; + case InventoryMovementType.adjustment: + return 1; // 調整は quantityDelta をそのまま使うため符号 1 とする + } + } +} + +@immutable +class InventoryMovement { + const InventoryMovement({ + required this.id, + required this.productId, + required this.productNameSnapshot, + required this.type, + required this.quantity, + required this.quantityDelta, + required this.createdAt, + this.reference, + this.notes, + }); + + final String id; + final String productId; + final String productNameSnapshot; + final InventoryMovementType type; + final int quantity; + final int quantityDelta; + final DateTime createdAt; + final String? reference; + final String? notes; + + Map toMap() => { + 'id': id, + 'product_id': productId, + 'product_name_snapshot': productNameSnapshot, + 'movement_type': type.name, + 'quantity': quantity, + 'quantity_delta': quantityDelta, + 'reference': reference, + 'notes': notes, + 'created_at': createdAt.toIso8601String(), + }; + + factory InventoryMovement.fromMap(Map map) { + InventoryMovementType parseType(String? value) { + return InventoryMovementType.values.firstWhere( + (type) => type.name == value, + orElse: () => InventoryMovementType.adjustment, + ); + } + + return InventoryMovement( + id: map['id'] as String, + productId: map['product_id'] as String, + productNameSnapshot: map['product_name_snapshot'] as String? ?? '-', + type: parseType(map['movement_type'] as String?), + quantity: map['quantity'] as int? ?? 0, + quantityDelta: map['quantity_delta'] as int? ?? 0, + reference: map['reference'] as String?, + notes: map['notes'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + ); + } +} + +@immutable +class InventorySummary { + const InventorySummary({ + required this.productId, + required this.productName, + required this.stockQuantity, + this.category, + this.defaultUnitPrice, + this.lastMovementAt, + }); + + final String productId; + final String productName; + final int stockQuantity; + final String? category; + final int? defaultUnitPrice; + final DateTime? lastMovementAt; + + InventorySummary copyWith({ + int? stockQuantity, + DateTime? lastMovementAt, + }) { + return InventorySummary( + productId: productId, + productName: productName, + stockQuantity: stockQuantity ?? this.stockQuantity, + category: category, + defaultUnitPrice: defaultUnitPrice, + lastMovementAt: lastMovementAt ?? this.lastMovementAt, + ); + } +} diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index 25be41d..cc29420 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -65,6 +65,21 @@ enum DocumentType { receipt, // 領収 } +extension DocumentTypeDisplay on DocumentType { + String get displayName { + switch (this) { + case DocumentType.estimation: + return '見積書'; + case DocumentType.delivery: + return '納品書'; + case DocumentType.invoice: + return '請求書'; + case DocumentType.receipt: + return '領収書'; + } + } +} + class Invoice { static const String lockStatement = '正式発行ボタン押下時にこの伝票はロックされ、以後の編集・削除はできません。ロック状態はハッシュチェーンで保護されます。'; @@ -96,6 +111,9 @@ class Invoice { final String? companySealHash; // 追加: 角印画像ハッシュ final String? metaJson; final String? metaHash; + final String? previousChainHash; + final String? chainHash; + final String chainStatus; Invoice({ String? id, @@ -124,6 +142,9 @@ class Invoice { this.companySealHash, this.metaJson, this.metaHash, + this.previousChainHash, + this.chainHash, + this.chainStatus = 'pending', }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), terminalId = terminalId ?? "T1", // デフォルト端末ID updatedAt = updatedAt ?? DateTime.now(); @@ -259,6 +280,9 @@ class Invoice { 'company_seal_hash': companySealHash, 'meta_json': metaJsonValue, 'meta_hash': metaHashValue, + 'previous_chain_hash': previousChainHash, + 'chain_hash': chainHash, + 'chain_status': chainStatus, }; } @@ -289,6 +313,9 @@ class Invoice { String? companySealHash, String? metaJson, String? metaHash, + String? previousChainHash, + String? chainHash, + String? chainStatus, }) { return Invoice( id: id ?? this.id, @@ -317,6 +344,9 @@ class Invoice { companySealHash: companySealHash ?? this.companySealHash, metaJson: metaJson ?? this.metaJson, metaHash: metaHash ?? this.metaHash, + previousChainHash: previousChainHash ?? this.previousChainHash, + chainHash: chainHash ?? this.chainHash, + chainStatus: chainStatus ?? this.chainStatus, ); } diff --git a/lib/models/order_models.dart b/lib/models/order_models.dart new file mode 100644 index 0000000..92a5acb --- /dev/null +++ b/lib/models/order_models.dart @@ -0,0 +1,211 @@ +import 'package:collection/collection.dart'; + +/// 受注ステータスの定義。 +enum SalesOrderStatus { + draft, + confirmed, + picking, + shipped, + closed, + cancelled, +} + +extension SalesOrderStatusX on SalesOrderStatus { + String get displayName { + switch (this) { + case SalesOrderStatus.draft: + return '下書き'; + case SalesOrderStatus.confirmed: + return '確定'; + case SalesOrderStatus.picking: + return '出荷準備中'; + case SalesOrderStatus.shipped: + return '出荷済み'; + case SalesOrderStatus.closed: + return '完了'; + case SalesOrderStatus.cancelled: + return 'キャンセル'; + } + } + + static SalesOrderStatus fromDbValue(String? value) { + return SalesOrderStatus.values.firstWhere( + (status) => status.name == value, + orElse: () => SalesOrderStatus.draft, + ); + } +} + +class SalesOrderItem { + SalesOrderItem({ + required this.id, + required this.orderId, + required this.description, + required this.quantity, + required this.unitPrice, + this.productId, + this.taxRate = 0, + this.sortIndex = 0, + }); + + final String id; + final String orderId; + final String? productId; + final String description; + final int quantity; + final int unitPrice; + final double taxRate; + final int sortIndex; + + int get lineTotal => quantity * unitPrice; + + Map toMap() => { + 'id': id, + 'order_id': orderId, + 'product_id': productId, + 'description': description, + 'quantity': quantity, + 'unit_price': unitPrice, + 'tax_rate': taxRate, + 'sort_index': sortIndex, + }; + + factory SalesOrderItem.fromMap(Map map) => SalesOrderItem( + id: map['id'] as String, + orderId: map['order_id'] as String, + productId: map['product_id'] as String?, + description: map['description'] as String, + quantity: map['quantity'] as int, + unitPrice: map['unit_price'] as int, + taxRate: (map['tax_rate'] as num?)?.toDouble() ?? 0, + sortIndex: map['sort_index'] as int? ?? 0, + ); +} + +class SalesOrder { + SalesOrder({ + required this.id, + required this.customerId, + required this.orderDate, + required this.status, + required this.subtotal, + required this.taxAmount, + required this.totalAmount, + required this.createdAt, + required this.updatedAt, + this.orderNumber, + this.customerNameSnapshot, + this.requestedShipDate, + this.notes, + this.assignedTo, + this.workflowStage, + this.items = const [], + }); + + final String id; + final String customerId; + final String? customerNameSnapshot; + final DateTime orderDate; + final DateTime? requestedShipDate; + final SalesOrderStatus status; + final int subtotal; + final int taxAmount; + final int totalAmount; + final String? orderNumber; + final String? notes; + final String? assignedTo; + final String? workflowStage; + final DateTime createdAt; + final DateTime updatedAt; + final List items; + + SalesOrder copyWith({ + String? id, + String? customerId, + String? customerNameSnapshot, + DateTime? orderDate, + DateTime? requestedShipDate, + SalesOrderStatus? status, + int? subtotal, + int? taxAmount, + int? totalAmount, + String? orderNumber, + String? notes, + String? assignedTo, + String? workflowStage, + DateTime? createdAt, + DateTime? updatedAt, + List? items, + }) { + return SalesOrder( + id: id ?? this.id, + customerId: customerId ?? this.customerId, + customerNameSnapshot: customerNameSnapshot ?? this.customerNameSnapshot, + orderDate: orderDate ?? this.orderDate, + requestedShipDate: requestedShipDate ?? this.requestedShipDate, + status: status ?? this.status, + subtotal: subtotal ?? this.subtotal, + taxAmount: taxAmount ?? this.taxAmount, + totalAmount: totalAmount ?? this.totalAmount, + orderNumber: orderNumber ?? this.orderNumber, + notes: notes ?? this.notes, + assignedTo: assignedTo ?? this.assignedTo, + workflowStage: workflowStage ?? this.workflowStage, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + items: items ?? this.items, + ); + } + + Map toMap() => { + 'id': id, + 'order_number': orderNumber, + 'customer_id': customerId, + 'customer_name_snapshot': customerNameSnapshot, + 'order_date': orderDate.toIso8601String(), + 'requested_ship_date': requestedShipDate?.toIso8601String(), + 'status': status.name, + 'subtotal': subtotal, + 'tax_amount': taxAmount, + 'total_amount': totalAmount, + 'notes': notes, + 'assigned_to': assignedTo, + 'workflow_stage': workflowStage, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory SalesOrder.fromMap(Map map, {List items = const []}) { + return SalesOrder( + id: map['id'] as String, + orderNumber: map['order_number'] as String?, + customerId: map['customer_id'] as String, + customerNameSnapshot: map['customer_name_snapshot'] as String?, + orderDate: DateTime.parse(map['order_date'] as String), + requestedShipDate: (map['requested_ship_date'] as String?) != null + ? DateTime.parse(map['requested_ship_date'] as String) + : null, + status: SalesOrderStatusX.fromDbValue(map['status'] as String?), + subtotal: map['subtotal'] as int? ?? 0, + taxAmount: map['tax_amount'] as int? ?? 0, + totalAmount: map['total_amount'] as int? ?? 0, + notes: map['notes'] as String?, + assignedTo: map['assigned_to'] as String?, + workflowStage: map['workflow_stage'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + items: items, + ); + } + + SalesOrder recalculateTotals({double? defaultTaxRate}) { + final newSubtotal = items.fold(0, (sum, item) => sum + item.lineTotal); + final taxRate = defaultTaxRate ?? items.map((e) => e.taxRate).firstWhereOrNull((rate) => rate > 0) ?? 0; + final newTaxAmount = (newSubtotal * taxRate).round(); + return copyWith( + subtotal: newSubtotal, + taxAmount: newTaxAmount, + totalAmount: newSubtotal + newTaxAmount, + ); + } +} diff --git a/lib/models/product_model.dart b/lib/models/product_model.dart index 229f32c..63c7622 100644 --- a/lib/models/product_model.dart +++ b/lib/models/product_model.dart @@ -2,6 +2,7 @@ class Product { final String id; final String name; final int defaultUnitPrice; + final int wholesalePrice; final String? barcode; final String? category; final int stockQuantity; // 追加 @@ -13,6 +14,7 @@ class Product { required this.id, required this.name, this.defaultUnitPrice = 0, + this.wholesalePrice = 0, this.barcode, this.category, this.stockQuantity = 0, // 追加 @@ -26,6 +28,7 @@ class Product { 'id': id, 'name': name, 'default_unit_price': defaultUnitPrice, + 'wholesale_price': wholesalePrice, 'barcode': barcode, 'category': category, 'stock_quantity': stockQuantity, // 追加 @@ -40,6 +43,7 @@ class Product { id: map['id'], name: map['name'], defaultUnitPrice: map['default_unit_price'] ?? 0, + wholesalePrice: map['wholesale_price'] ?? 0, barcode: map['barcode'], category: map['category'], stockQuantity: map['stock_quantity'] ?? 0, // 追加 @@ -53,6 +57,7 @@ class Product { String? id, String? name, int? defaultUnitPrice, + int? wholesalePrice, String? barcode, String? category, int? stockQuantity, @@ -64,6 +69,7 @@ class Product { id: id ?? this.id, name: name ?? this.name, defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice, + wholesalePrice: wholesalePrice ?? this.wholesalePrice, barcode: barcode ?? this.barcode, category: category ?? this.category, stockQuantity: stockQuantity ?? this.stockQuantity, diff --git a/lib/models/receivable_models.dart b/lib/models/receivable_models.dart new file mode 100644 index 0000000..68fa838 --- /dev/null +++ b/lib/models/receivable_models.dart @@ -0,0 +1,102 @@ +import 'package:meta/meta.dart'; + +enum PaymentMethod { + bankTransfer, + cash, + creditCard, + cheque, + other, +} + +extension PaymentMethodX on PaymentMethod { + String get displayName { + switch (this) { + case PaymentMethod.bankTransfer: + return '銀行振込'; + case PaymentMethod.cash: + return '現金'; + case PaymentMethod.creditCard: + return 'クレジット'; + case PaymentMethod.cheque: + return '小切手'; + case PaymentMethod.other: + return 'その他'; + } + } +} + +@immutable +class ReceivableInvoiceSummary { + const ReceivableInvoiceSummary({ + required this.invoiceId, + required this.invoiceNumber, + required this.customerName, + required this.invoiceDate, + required this.totalAmount, + required this.paidAmount, + required this.dueDate, + }); + + final String invoiceId; + final String invoiceNumber; + final String customerName; + final DateTime invoiceDate; + final DateTime dueDate; + final int totalAmount; + final int paidAmount; + + int get outstandingAmount => totalAmount - paidAmount; + bool get isSettled => outstandingAmount <= 0; + bool get isOverdue => !isSettled && DateTime.now().isAfter(dueDate); + double get collectionProgress => totalAmount == 0 ? 1.0 : paidAmount.clamp(0, totalAmount) / totalAmount; +} + +@immutable +class ReceivablePayment { + const ReceivablePayment({ + required this.id, + required this.invoiceId, + required this.amount, + required this.paymentDate, + required this.method, + required this.createdAt, + this.notes, + }); + + final String id; + final String invoiceId; + final int amount; + final DateTime paymentDate; + final PaymentMethod method; + final String? notes; + final DateTime createdAt; + + Map toMap() => { + 'id': id, + 'invoice_id': invoiceId, + 'amount': amount, + 'payment_date': paymentDate.toIso8601String(), + 'method': method.name, + 'notes': notes, + 'created_at': createdAt.toIso8601String(), + }; + + factory ReceivablePayment.fromMap(Map map) { + PaymentMethod parseMethod(String? value) { + return PaymentMethod.values.firstWhere( + (method) => method.name == value, + orElse: () => PaymentMethod.other, + ); + } + + return ReceivablePayment( + id: map['id'] as String, + invoiceId: map['invoice_id'] as String, + amount: map['amount'] as int? ?? 0, + paymentDate: DateTime.parse(map['payment_date'] as String), + method: parseMethod(map['method'] as String?), + notes: map['notes'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + ); + } +} diff --git a/lib/models/sales_entry_models.dart b/lib/models/sales_entry_models.dart new file mode 100644 index 0000000..f606fcb --- /dev/null +++ b/lib/models/sales_entry_models.dart @@ -0,0 +1,425 @@ +import 'package:meta/meta.dart'; + +import '../models/invoice_models.dart'; + +enum SalesEntryStatus { draft, confirmed, settled } + +@immutable +class SalesInvoiceImportData { + const SalesInvoiceImportData({ + required this.invoiceId, + required this.documentType, + required this.issueDate, + required this.taxRate, + required this.totalAmount, + required this.items, + required this.isLocked, + required this.chainStatus, + required this.contentHash, + this.customerId, + this.customerFormalName, + this.subject, + }); + + final String invoiceId; + final DocumentType documentType; + final DateTime issueDate; + final double taxRate; + final int totalAmount; + final List items; + final bool isLocked; + final String chainStatus; + final String contentHash; + final String? customerId; + final String? customerFormalName; + final String? subject; +} + +extension SalesEntryStatusDisplay on SalesEntryStatus { + String get displayName { + switch (this) { + case SalesEntryStatus.draft: + return '下書き'; + case SalesEntryStatus.confirmed: + return '確定'; + case SalesEntryStatus.settled: + return '入金済み'; + } + } +} + +class SalesReceiptAllocationInput { + const SalesReceiptAllocationInput({required this.salesEntryId, required this.amount}); + + final String salesEntryId; + final int amount; +} + +@immutable +class SalesLineItem { + const SalesLineItem({ + required this.id, + required this.salesEntryId, + required this.description, + required this.quantity, + required this.unitPrice, + required this.lineTotal, + this.productId, + this.taxRate = 0, + this.sourceInvoiceId, + this.sourceInvoiceItemId, + this.costAmount = 0, + this.costIsProvisional = false, + }); + + final String id; + final String salesEntryId; + final String description; + final int quantity; + final int unitPrice; + final int lineTotal; + final String? productId; + final double taxRate; + final String? sourceInvoiceId; + final String? sourceInvoiceItemId; + final int costAmount; + final bool costIsProvisional; + + Map toMap() => { + 'id': id, + 'sales_entry_id': salesEntryId, + 'product_id': productId, + 'description': description, + 'quantity': quantity, + 'unit_price': unitPrice, + 'tax_rate': taxRate, + 'line_total': lineTotal, + 'cost_amount': costAmount, + 'cost_is_provisional': costIsProvisional ? 1 : 0, + 'source_invoice_id': sourceInvoiceId, + 'source_invoice_item_id': sourceInvoiceItemId, + }; + + factory SalesLineItem.fromMap(Map map) => SalesLineItem( + id: map['id'] as String, + salesEntryId: map['sales_entry_id'] as String, + productId: map['product_id'] as String?, + description: map['description'] as String, + quantity: map['quantity'] as int? ?? 0, + unitPrice: map['unit_price'] as int? ?? 0, + taxRate: (map['tax_rate'] as num?)?.toDouble() ?? 0, + lineTotal: map['line_total'] as int? ?? 0, + costAmount: map['cost_amount'] as int? ?? 0, + costIsProvisional: (map['cost_is_provisional'] as int? ?? 0) == 1, + sourceInvoiceId: map['source_invoice_id'] as String?, + sourceInvoiceItemId: map['source_invoice_item_id'] as String?, + ); + SalesLineItem copyWith({ + String? id, + String? salesEntryId, + String? description, + int? quantity, + int? unitPrice, + int? lineTotal, + String? productId, + double? taxRate, + String? sourceInvoiceId, + String? sourceInvoiceItemId, + int? costAmount, + bool? costIsProvisional, + }) { + return SalesLineItem( + id: id ?? this.id, + salesEntryId: salesEntryId ?? this.salesEntryId, + description: description ?? this.description, + quantity: quantity ?? this.quantity, + unitPrice: unitPrice ?? this.unitPrice, + lineTotal: lineTotal ?? this.lineTotal, + productId: productId ?? this.productId, + taxRate: taxRate ?? this.taxRate, + sourceInvoiceId: sourceInvoiceId ?? this.sourceInvoiceId, + sourceInvoiceItemId: sourceInvoiceItemId ?? this.sourceInvoiceItemId, + costAmount: costAmount ?? this.costAmount, + costIsProvisional: costIsProvisional ?? this.costIsProvisional, + ); + } +} + +@immutable +class SalesEntry { + const SalesEntry({ + required this.id, + required this.issueDate, + required this.status, + required this.createdAt, + required this.updatedAt, + this.customerId, + this.customerNameSnapshot, + this.subject, + this.amountTaxExcl = 0, + this.taxAmount = 0, + this.amountTaxIncl = 0, + this.notes, + this.items = const [], + }); + + final String id; + final String? customerId; + final String? customerNameSnapshot; + final String? subject; + final DateTime issueDate; + final SalesEntryStatus status; + final int amountTaxExcl; + final int taxAmount; + final int amountTaxIncl; + final String? notes; + final DateTime createdAt; + final DateTime updatedAt; + final List items; + + SalesEntry copyWith({ + String? id, + String? customerId, + String? customerNameSnapshot, + String? subject, + DateTime? issueDate, + SalesEntryStatus? status, + int? amountTaxExcl, + int? taxAmount, + int? amountTaxIncl, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + List? items, + }) { + return SalesEntry( + id: id ?? this.id, + customerId: customerId ?? this.customerId, + customerNameSnapshot: customerNameSnapshot ?? this.customerNameSnapshot, + subject: subject ?? this.subject, + issueDate: issueDate ?? this.issueDate, + status: status ?? this.status, + amountTaxExcl: amountTaxExcl ?? this.amountTaxExcl, + taxAmount: taxAmount ?? this.taxAmount, + amountTaxIncl: amountTaxIncl ?? this.amountTaxIncl, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + items: items ?? this.items, + ); + } + + Map toMap() => { + 'id': id, + 'customer_id': customerId, + 'customer_name_snapshot': customerNameSnapshot, + 'subject': subject, + 'issue_date': issueDate.toIso8601String(), + 'status': status.name, + 'amount_tax_excl': amountTaxExcl, + 'tax_amount': taxAmount, + 'amount_tax_incl': amountTaxIncl, + 'notes': notes, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory SalesEntry.fromMap(Map map, {List items = const []}) => SalesEntry( + id: map['id'] as String, + customerId: map['customer_id'] as String?, + customerNameSnapshot: map['customer_name_snapshot'] as String?, + subject: map['subject'] as String?, + issueDate: DateTime.parse(map['issue_date'] as String), + status: SalesEntryStatus.values.firstWhere( + (s) => s.name == map['status'], + orElse: () => SalesEntryStatus.draft, + ), + amountTaxExcl: map['amount_tax_excl'] as int? ?? 0, + taxAmount: map['tax_amount'] as int? ?? 0, + amountTaxIncl: map['amount_tax_incl'] as int? ?? 0, + notes: map['notes'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + items: items, + ); + + SalesEntry recalcTotals() { + final subtotal = items.fold(0, (sum, item) => sum + item.lineTotal); + final tax = items.fold(0, (sum, item) => sum + item.lineTotal * item.taxRate).round(); + return copyWith( + amountTaxExcl: subtotal, + taxAmount: tax, + amountTaxIncl: subtotal + tax, + ); + } +} + +@immutable +class SalesReceipt { + const SalesReceipt({ + required this.id, + required this.paymentDate, + required this.amount, + required this.createdAt, + required this.updatedAt, + this.customerId, + this.method, + this.notes, + }); + + final String id; + final String? customerId; + final DateTime paymentDate; + final String? method; + final int amount; + final String? notes; + final DateTime createdAt; + final DateTime updatedAt; + + Map toMap() => { + 'id': id, + 'customer_id': customerId, + 'payment_date': paymentDate.toIso8601String(), + 'method': method, + 'amount': amount, + 'notes': notes, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory SalesReceipt.fromMap(Map map) => SalesReceipt( + id: map['id'] as String, + customerId: map['customer_id'] as String?, + paymentDate: DateTime.parse(map['payment_date'] as String), + method: map['method'] as String?, + amount: map['amount'] as int? ?? 0, + notes: map['notes'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + + SalesReceipt copyWith({ + String? id, + String? customerId, + DateTime? paymentDate, + String? method, + int? amount, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return SalesReceipt( + id: id ?? this.id, + customerId: customerId ?? this.customerId, + paymentDate: paymentDate ?? this.paymentDate, + method: method ?? this.method, + amount: amount ?? this.amount, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +@immutable +class SalesReceiptLink { + const SalesReceiptLink({required this.receiptId, required this.salesEntryId, required this.allocatedAmount}); + + final String receiptId; + final String salesEntryId; + final int allocatedAmount; + + Map toMap() => { + 'receipt_id': receiptId, + 'sales_entry_id': salesEntryId, + 'allocated_amount': allocatedAmount, + }; + + factory SalesReceiptLink.fromMap(Map map) => SalesReceiptLink( + receiptId: map['receipt_id'] as String, + salesEntryId: map['sales_entry_id'] as String, + allocatedAmount: map['allocated_amount'] as int? ?? 0, + ); +} + +@immutable +class SalesEntrySource { + const SalesEntrySource({ + required this.id, + required this.salesEntryId, + required this.invoiceId, + required this.importedAt, + this.invoiceHashSnapshot, + }); + + final String id; + final String salesEntryId; + final String invoiceId; + final DateTime importedAt; + final String? invoiceHashSnapshot; + + Map toMap() => { + 'id': id, + 'sales_entry_id': salesEntryId, + 'invoice_id': invoiceId, + 'imported_at': importedAt.toIso8601String(), + 'invoice_hash_snapshot': invoiceHashSnapshot, + }; + + factory SalesEntrySource.fromMap(Map map) => SalesEntrySource( + id: map['id'] as String, + salesEntryId: map['sales_entry_id'] as String, + invoiceId: map['invoice_id'] as String, + importedAt: DateTime.parse(map['imported_at'] as String), + invoiceHashSnapshot: map['invoice_hash_snapshot'] as String?, + ); +} + +@immutable +class SalesImportCandidate { + const SalesImportCandidate({ + required this.invoiceId, + required this.invoiceNumber, + required this.documentType, + required this.invoiceDate, + required this.customerName, + required this.totalAmount, + required this.isLocked, + required this.chainStatus, + required this.contentHash, + this.subject, + }); + + final String invoiceId; + final String invoiceNumber; + final DocumentType documentType; + final DateTime invoiceDate; + final String customerName; + final int totalAmount; + final bool isLocked; + final String chainStatus; + final String contentHash; + final String? subject; + + String get documentTypeName => documentType.displayName; + + factory SalesImportCandidate.fromMap(Map row) { + final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name; + final documentType = DocumentType.values.firstWhere( + (type) => type.name == docTypeName, + orElse: () => DocumentType.invoice, + ); + return SalesImportCandidate( + invoiceId: row['id'] as String, + invoiceNumber: row['invoice_number'] as String, + documentType: documentType, + invoiceDate: DateTime.parse(row['date'] as String), + customerName: row['customer_name'] as String? ?? '-', + totalAmount: (row['total_amount'] as num?)?.toInt() ?? 0, + isLocked: (row['is_locked'] as int?) == 1, + chainStatus: row['chain_status'] as String? ?? 'pending', + contentHash: row['content_hash'] as String? ?? '', + subject: row['subject'] as String?, + ); + } +} diff --git a/lib/models/sales_summary.dart b/lib/models/sales_summary.dart new file mode 100644 index 0000000..d8114bd --- /dev/null +++ b/lib/models/sales_summary.dart @@ -0,0 +1,32 @@ +import '../models/invoice_models.dart'; + +class SalesCustomerStat { + SalesCustomerStat({required this.customerName, required this.totalAmount}); + + final String customerName; + final int totalAmount; +} + +class SalesSummary { + SalesSummary({ + required this.year, + required this.monthlyTotals, + required this.yearlyTotal, + required this.customerStats, + this.documentType, + }); + + final int year; + final Map monthlyTotals; + final int yearlyTotal; + final List customerStats; + final DocumentType? documentType; + + int get bestMonth => + monthlyTotals.entries.isEmpty ? 0 : monthlyTotals.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + + int get bestMonthTotal => + monthlyTotals.entries.isEmpty ? 0 : monthlyTotals.entries.reduce((a, b) => a.value >= b.value ? a : b).value; + + double get averageMonthly => yearlyTotal / (monthlyTotals.isEmpty ? 1 : 12); +} diff --git a/lib/models/shipment_models.dart b/lib/models/shipment_models.dart new file mode 100644 index 0000000..6d2d4e4 --- /dev/null +++ b/lib/models/shipment_models.dart @@ -0,0 +1,199 @@ +/// 出荷ステータス定義。 +enum ShipmentStatus { + pending, + picking, + ready, + shipped, + delivered, + cancelled, +} + +extension ShipmentStatusX on ShipmentStatus { + String get displayName { + switch (this) { + case ShipmentStatus.pending: + return '未手配'; + case ShipmentStatus.picking: + return 'ピッキング中'; + case ShipmentStatus.ready: + return '出荷待ち'; + case ShipmentStatus.shipped: + return '出荷済み'; + case ShipmentStatus.delivered: + return '納品済み'; + case ShipmentStatus.cancelled: + return 'キャンセル'; + } + } + + static ShipmentStatus fromDbValue(String? value) { + return ShipmentStatus.values.firstWhere( + (status) => status.name == value, + orElse: () => ShipmentStatus.pending, + ); + } +} + +class ShipmentItem { + ShipmentItem({ + required this.id, + required this.shipmentId, + required this.description, + required this.quantity, + this.orderItemId, + }); + + final String id; + final String shipmentId; + final String? orderItemId; + final String description; + final int quantity; + + Map toMap() => { + 'id': id, + 'shipment_id': shipmentId, + 'order_item_id': orderItemId, + 'description': description, + 'quantity': quantity, + }; + + factory ShipmentItem.fromMap(Map map) => ShipmentItem( + id: map['id'] as String, + shipmentId: map['shipment_id'] as String, + orderItemId: map['order_item_id'] as String?, + description: map['description'] as String, + quantity: map['quantity'] as int? ?? 0, + ); +} + +class Shipment { + Shipment({ + required this.id, + required this.status, + required this.createdAt, + required this.updatedAt, + this.orderId, + this.orderNumberSnapshot, + this.customerNameSnapshot, + this.scheduledShipDate, + this.actualShipDate, + this.carrierName, + this.trackingNumber, + this.trackingUrl, + this.labelPdfPath, + this.notes, + this.pickingCompletedAt, + this.items = const [], + }); + + final String id; + final String? orderId; + final String? orderNumberSnapshot; + final String? customerNameSnapshot; + final DateTime? scheduledShipDate; + final DateTime? actualShipDate; + final ShipmentStatus status; + final String? carrierName; + final String? trackingNumber; + final String? trackingUrl; + final String? labelPdfPath; + final String? notes; + final DateTime? pickingCompletedAt; + final DateTime createdAt; + final DateTime updatedAt; + final List items; + + Shipment copyWith({ + String? id, + String? orderId, + String? orderNumberSnapshot, + String? customerNameSnapshot, + DateTime? scheduledShipDate, + DateTime? actualShipDate, + ShipmentStatus? status, + String? carrierName, + String? trackingNumber, + String? trackingUrl, + String? labelPdfPath, + String? notes, + DateTime? pickingCompletedAt, + DateTime? createdAt, + DateTime? updatedAt, + List? items, + }) { + return Shipment( + id: id ?? this.id, + orderId: orderId ?? this.orderId, + orderNumberSnapshot: orderNumberSnapshot ?? this.orderNumberSnapshot, + customerNameSnapshot: customerNameSnapshot ?? this.customerNameSnapshot, + scheduledShipDate: scheduledShipDate ?? this.scheduledShipDate, + actualShipDate: actualShipDate ?? this.actualShipDate, + status: status ?? this.status, + carrierName: carrierName ?? this.carrierName, + trackingNumber: trackingNumber ?? this.trackingNumber, + trackingUrl: trackingUrl ?? this.trackingUrl, + labelPdfPath: labelPdfPath ?? this.labelPdfPath, + notes: notes ?? this.notes, + pickingCompletedAt: pickingCompletedAt ?? this.pickingCompletedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + items: items ?? this.items, + ); + } + + Map toMap() => { + 'id': id, + 'order_id': orderId, + 'order_number_snapshot': orderNumberSnapshot, + 'customer_name_snapshot': customerNameSnapshot, + 'scheduled_ship_date': scheduledShipDate?.toIso8601String(), + 'actual_ship_date': actualShipDate?.toIso8601String(), + 'status': status.name, + 'carrier_name': carrierName, + 'tracking_number': trackingNumber, + 'tracking_url': trackingUrl, + 'label_pdf_path': labelPdfPath, + 'notes': notes, + 'picking_completed_at': pickingCompletedAt?.toIso8601String(), + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory Shipment.fromMap(Map map, {List items = const []}) { + DateTime? parseNullable(String? value) => value == null ? null : DateTime.parse(value); + return Shipment( + id: map['id'] as String, + orderId: map['order_id'] as String?, + orderNumberSnapshot: map['order_number_snapshot'] as String?, + customerNameSnapshot: map['customer_name_snapshot'] as String?, + scheduledShipDate: parseNullable(map['scheduled_ship_date'] as String?), + actualShipDate: parseNullable(map['actual_ship_date'] as String?), + status: ShipmentStatusX.fromDbValue(map['status'] as String?), + carrierName: map['carrier_name'] as String?, + trackingNumber: map['tracking_number'] as String?, + trackingUrl: map['tracking_url'] as String?, + labelPdfPath: map['label_pdf_path'] as String?, + notes: map['notes'] as String?, + pickingCompletedAt: parseNullable(map['picking_completed_at'] as String?), + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + items: items, + ); + } + + ShipmentStatus nextDefaultStatus() { + switch (status) { + case ShipmentStatus.pending: + return ShipmentStatus.picking; + case ShipmentStatus.picking: + return ShipmentStatus.ready; + case ShipmentStatus.ready: + return ShipmentStatus.shipped; + case ShipmentStatus.shipped: + return ShipmentStatus.delivered; + case ShipmentStatus.delivered: + case ShipmentStatus.cancelled: + return status; + } + } +} diff --git a/lib/models/staff_model.dart b/lib/models/staff_model.dart new file mode 100644 index 0000000..2d86516 --- /dev/null +++ b/lib/models/staff_model.dart @@ -0,0 +1,78 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class StaffMember { + const StaffMember({ + required this.id, + required this.name, + this.email, + this.tel, + this.role, + this.departmentId, + this.permissionLevel, + this.isActive = true, + required this.updatedAt, + }); + + final String id; + final String name; + final String? email; + final String? tel; + final String? role; + final String? departmentId; + final String? permissionLevel; + final bool isActive; + final DateTime updatedAt; + + StaffMember copyWith({ + String? id, + String? name, + String? email, + String? tel, + String? role, + String? departmentId, + String? permissionLevel, + bool? isActive, + DateTime? updatedAt, + }) { + return StaffMember( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + tel: tel ?? this.tel, + role: role ?? this.role, + departmentId: departmentId ?? this.departmentId, + permissionLevel: permissionLevel ?? this.permissionLevel, + isActive: isActive ?? this.isActive, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + factory StaffMember.fromMap(Map map) { + return StaffMember( + id: map['id'] as String, + name: map['name'] as String? ?? '-', + email: map['email'] as String?, + tel: map['tel'] as String?, + role: map['role'] as String?, + departmentId: map['department_id'] as String?, + permissionLevel: map['permission_level'] as String?, + isActive: (map['is_active'] as int? ?? 1) == 1, + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'email': email, + 'tel': tel, + 'role': role, + 'department_id': departmentId, + 'permission_level': permissionLevel, + 'is_active': isActive ? 1 : 0, + 'updated_at': updatedAt.toIso8601String(), + }; + } +} diff --git a/lib/models/supplier_model.dart b/lib/models/supplier_model.dart new file mode 100644 index 0000000..561346e --- /dev/null +++ b/lib/models/supplier_model.dart @@ -0,0 +1,90 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class Supplier { + const Supplier({ + required this.id, + required this.name, + this.contactPerson, + this.email, + this.tel, + this.address, + this.closingDay, + this.paymentSiteDays = 30, + this.notes, + this.isHidden = false, + required this.updatedAt, + }); + + final String id; + final String name; + final String? contactPerson; + final String? email; + final String? tel; + final String? address; + final int? closingDay; + final int paymentSiteDays; + final String? notes; + final bool isHidden; + final DateTime updatedAt; + + Supplier copyWith({ + String? id, + String? name, + String? contactPerson, + String? email, + String? tel, + String? address, + int? closingDay, + int? paymentSiteDays, + String? notes, + bool? isHidden, + DateTime? updatedAt, + }) { + return Supplier( + id: id ?? this.id, + name: name ?? this.name, + contactPerson: contactPerson ?? this.contactPerson, + email: email ?? this.email, + tel: tel ?? this.tel, + address: address ?? this.address, + closingDay: closingDay ?? this.closingDay, + paymentSiteDays: paymentSiteDays ?? this.paymentSiteDays, + notes: notes ?? this.notes, + isHidden: isHidden ?? this.isHidden, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + factory Supplier.fromMap(Map map) { + return Supplier( + id: map['id'] as String, + name: map['name'] as String? ?? '-', + contactPerson: map['contact_person'] as String?, + email: map['email'] as String?, + tel: map['tel'] as String?, + address: map['address'] as String?, + closingDay: map['closing_day'] as int?, + paymentSiteDays: map['payment_site_days'] as int? ?? 30, + notes: map['notes'] as String?, + isHidden: (map['is_hidden'] as int? ?? 0) == 1, + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'contact_person': contactPerson, + 'email': email, + 'tel': tel, + 'address': address, + 'closing_day': closingDay, + 'payment_site_days': paymentSiteDays, + 'notes': notes, + 'is_hidden': isHidden ? 1 : 0, + 'updated_at': updatedAt.toIso8601String(), + }; + } +} diff --git a/lib/models/tax_setting_model.dart b/lib/models/tax_setting_model.dart new file mode 100644 index 0000000..045adfc --- /dev/null +++ b/lib/models/tax_setting_model.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class TaxSetting { + const TaxSetting({ + required this.id, + required this.rate, + required this.roundingMode, + required this.updatedAt, + }); + + final String id; + final double rate; + final String roundingMode; + final DateTime updatedAt; + + TaxSetting copyWith({ + String? id, + double? rate, + String? roundingMode, + DateTime? updatedAt, + }) { + return TaxSetting( + id: id ?? this.id, + rate: rate ?? this.rate, + roundingMode: roundingMode ?? this.roundingMode, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + factory TaxSetting.fromMap(Map map) { + return TaxSetting( + id: map['id'] as String, + rate: (map['rate'] as num).toDouble(), + roundingMode: map['rounding_mode'] as String? ?? 'round', + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + } + + Map toMap() { + return { + 'id': id, + 'rate': rate, + 'rounding_mode': roundingMode, + 'updated_at': updatedAt.toIso8601String(), + }; + } +} diff --git a/lib/modules/billing_docs_module.dart b/lib/modules/billing_docs_module.dart new file mode 100644 index 0000000..34273d0 --- /dev/null +++ b/lib/modules/billing_docs_module.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; +import '../models/invoice_models.dart'; +import '../modules/feature_module.dart'; +import '../screens/invoice_detail_page.dart'; +import '../screens/invoice_history_screen.dart'; +import '../screens/invoice_input_screen.dart'; +import '../services/customer_repository.dart'; +import '../services/location_service.dart'; + +class BillingDocsModule extends FeatureModule { + BillingDocsModule(); + + final LocationService _locationService = LocationService(); + final CustomerRepository _customerRepository = CustomerRepository(); + + @override + String get key => 'billing_docs'; + + @override + bool get isEnabled => AppConfig.enableBillingDocs; + + @override + List get dashboardCards => [ + ModuleDashboardCard( + id: 'billing_docs_history', + route: 'invoice_history', + title: '伝票一覧', + description: 'A2: 履歴リストとロック管理', + iconName: 'history', + requiresUnlock: true, + onTap: (context) async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const InvoiceHistoryScreen(initialUnlocked: true)), + ); + }, + ), + ModuleDashboardCard( + id: 'billing_docs_input', + route: 'invoice_input', + title: '伝票新規作成', + description: 'A1: 新しい伝票を登録', + iconName: 'edit_note', + onTap: (context) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InvoiceInputForm( + initialDocumentType: DocumentType.invoice, + onInvoiceGenerated: (invoice, path) async { + final pos = await _locationService.getCurrentLocation(); + if (pos != null) { + await _customerRepository.addGpsHistory(invoice.customer.id, pos.latitude, pos.longitude); + } + if (!context.mounted) return; + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => InvoiceDetailPage(invoice: invoice)), + ); + }, + ), + ), + ); + }, + ), + ]; +} diff --git a/lib/modules/feature_module.dart b/lib/modules/feature_module.dart new file mode 100644 index 0000000..3af7afe --- /dev/null +++ b/lib/modules/feature_module.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +typedef ModuleCardAction = Future Function(BuildContext context); + +class ModuleDashboardCard { + const ModuleDashboardCard({ + required this.id, + required this.route, + required this.title, + required this.description, + required this.iconName, + required this.onTap, + this.requiresUnlock = false, + }); + + final String id; + final String route; + final String title; + final String description; + final String iconName; + final ModuleCardAction onTap; + final bool requiresUnlock; +} + +abstract class FeatureModule { + String get key; + bool get isEnabled; + List get dashboardCards; + + ModuleDashboardCard? cardByRoute(String route) { + try { + return dashboardCards.firstWhere((card) => card.route == route); + } catch (_) { + return null; + } + } +} diff --git a/lib/modules/module_registry.dart b/lib/modules/module_registry.dart new file mode 100644 index 0000000..4e31df5 --- /dev/null +++ b/lib/modules/module_registry.dart @@ -0,0 +1,33 @@ +import 'billing_docs_module.dart'; +import 'feature_module.dart'; +import 'sales_management_module.dart'; +import 'sales_operations_module.dart'; + +class ModuleRegistry { + ModuleRegistry._(); + + static final ModuleRegistry instance = ModuleRegistry._(); + + final List _modules = [ + BillingDocsModule(), + SalesManagementModule(), + SalesOperationsModule(), + ]; + + Iterable get modules => _modules; + + List get enabledCards => _modules.where((m) => m.isEnabled).expand((m) => m.dashboardCards).toList(); + + bool supportsRoute(String route) => enabledCards.any((card) => card.route == route); + + ModuleDashboardCard? cardForRoute(String route) { + for (final module in _modules) { + if (!module.isEnabled) continue; + final card = module.cardByRoute(route); + if (card != null) { + return card; + } + } + return null; + } +} diff --git a/lib/modules/sales_management_module.dart b/lib/modules/sales_management_module.dart new file mode 100644 index 0000000..b7b79ac --- /dev/null +++ b/lib/modules/sales_management_module.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; +import '../modules/feature_module.dart'; +import '../screens/sales_dashboard_screen.dart'; +import '../screens/sales_entries_screen.dart'; + +class SalesManagementModule extends FeatureModule { + SalesManagementModule(); + + @override + String get key => 'sales_management'; + + @override + bool get isEnabled => AppConfig.enableSalesManagement; + + @override + List get dashboardCards => [ + ModuleDashboardCard( + id: 'sales_management_report', + route: 'sales_management', + title: '売上管理', + description: '売上ダッシュボード(ランチャー)', + iconName: 'analytics', + onTap: (context) async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SalesDashboardScreen()), + ); + }, + ), + ModuleDashboardCard( + id: 'sales_entries', + route: 'sales_entries', + title: '売上伝票入力', + description: 'U1:売上伝票の入力・編集', + iconName: 'receipt_long', + onTap: (context) async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => SalesEntriesScreen()), + ); + }, + ), + ]; +} diff --git a/lib/modules/sales_operations_module.dart b/lib/modules/sales_operations_module.dart new file mode 100644 index 0000000..1451483 --- /dev/null +++ b/lib/modules/sales_operations_module.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; +import '../modules/feature_module.dart'; +import '../screens/sales_orders_screen.dart'; + +class SalesOperationsModule extends FeatureModule { + SalesOperationsModule(); + + @override + String get key => 'sales_operations'; + + @override + bool get isEnabled => AppConfig.enableSalesOperations; + + @override + List get dashboardCards => [ + ModuleDashboardCard( + id: 'sales_orders', + route: 'sales_orders', + title: '受注管理', + description: '受注入力と進捗管理', + iconName: 'assignment', + onTap: (context) async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesOrdersScreen())); + }, + ), + ModuleDashboardCard( + id: 'shipments', + route: 'shipments', + title: '出荷管理', + description: 'ピッキング・配送番号登録', + iconName: 'local_shipping', + onTap: (context) async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesShipmentsScreen())); + }, + ), + ModuleDashboardCard( + id: 'inventory', + route: 'inventory', + title: '在庫管理', + description: '残高/入出庫履歴を確認', + iconName: 'inventory_2', + onTap: (context) async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesInventoryScreen())); + }, + ), + ModuleDashboardCard( + id: 'receivables', + route: 'receivables', + title: '回収・入金', + description: '売掛残高と入金状況', + iconName: 'account_balance', + onTap: (context) async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const SalesReceivablesScreen())); + }, + ), + ]; +} diff --git a/lib/screens/activity_log_screen.dart b/lib/screens/activity_log_screen.dart index c3db9f5..b7b1980 100644 --- a/lib/screens/activity_log_screen.dart +++ b/lib/screens/activity_log_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/activity_log_model.dart'; import '../services/activity_log_repository.dart'; +import '../widgets/screen_id_title.dart'; class ActivityLogScreen extends StatefulWidget { const ActivityLogScreen({super.key}); @@ -36,7 +37,8 @@ class _ActivityLogScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text("アクティビティ履歴 (Gitログ風)"), + leading: const BackButton(), + title: const ScreenAppBarTitle(screenId: 'A1', title: 'アクティビティ履歴'), backgroundColor: Colors.blueGrey.shade800, actions: [ IconButton(icon: const Icon(Icons.refresh), onPressed: _loadLogs), diff --git a/lib/screens/barcode_scanner_screen.dart b/lib/screens/barcode_scanner_screen.dart index 221e37e..82e509b 100644 --- a/lib/screens/barcode_scanner_screen.dart +++ b/lib/screens/barcode_scanner_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import '../widgets/screen_id_title.dart'; + class BarcodeScannerScreen extends StatefulWidget { const BarcodeScannerScreen({super.key}); @@ -13,7 +15,8 @@ class _BarcodeScannerScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("バーコードスキャン"), + leading: const BackButton(), + title: const ScreenAppBarTitle(screenId: 'B1', title: 'バーコードスキャン'), backgroundColor: Colors.black, ), body: MobileScanner( diff --git a/lib/screens/business_profile_screen.dart b/lib/screens/business_profile_screen.dart index d8f9927..01a6ea7 100644 --- a/lib/screens/business_profile_screen.dart +++ b/lib/screens/business_profile_screen.dart @@ -218,6 +218,7 @@ class _BusinessProfileScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + leading: const BackButton(), title: const Text('F2:自社情報'), backgroundColor: Colors.indigo, actions: [ diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index e413fa8..47d1db0 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -84,7 +84,8 @@ class _ChatScreenState extends State { final theme = Theme.of(context); return Scaffold( appBar: AppBar( - title: const Text('母艦チャット'), + leading: const BackButton(), + title: const Text('C1:母艦チャット'), actions: [ IconButton( tooltip: '再同期', diff --git a/lib/screens/company_info_screen.dart b/lib/screens/company_info_screen.dart index ff5e60f..11a6de7 100644 --- a/lib/screens/company_info_screen.dart +++ b/lib/screens/company_info_screen.dart @@ -73,6 +73,7 @@ class _CompanyInfoScreenState extends State { return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( + leading: const BackButton(), title: const Text("F1:自社情報"), backgroundColor: Colors.indigo, actions: [ diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index 462d7c6..7459980 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -222,97 +222,100 @@ class _CustomerPickerModalState extends State { @override Widget build(BuildContext context) { - return Material( - child: KeyboardInsetWrapper( - basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16), - extraBottom: 32, - child: CustomScrollView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), - ], + final body = KeyboardInsetWrapper( + basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16), + extraBottom: 32, + child: CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + decoration: InputDecoration( + hintText: "登録済み顧客を検索...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), ), - const SizedBox(height: 12), - TextField( - decoration: InputDecoration( - hintText: "登録済み顧客を検索...", - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - ), - onChanged: _onSearch, + onChanged: _onSearch, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts, + icon: _isImportingFromContacts + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.contact_phone), + label: const Text("電話帳から新規取り込み"), + style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white), ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts, - icon: _isImportingFromContacts - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.contact_phone), - label: const Text("電話帳から新規取り込み"), - style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white), - ), - ), - ], - ), + ), + ], ), ), - const SliverToBoxAdapter(child: Divider(height: 1)), - if (_isLoading) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: CircularProgressIndicator()), - ) - else if (_filteredCustomers.isEmpty) - const SliverFillRemaining( - hasScrollBody: false, - child: Center(child: Text("該当する顧客がいません")), - ) - else - SliverPadding( - padding: const EdgeInsets.only(bottom: 120), - sliver: SliverList.builder( - itemCount: _filteredCustomers.length, - itemBuilder: (context, index) { - final customer = _filteredCustomers[index]; - return ListTile( - leading: const CircleAvatar(child: Icon(Icons.business)), - title: Text(customer.formalName), - subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), - onTap: () => widget.onCustomerSelected(customer), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), - onPressed: () => _showCustomerEditDialog( - displayName: customer.displayName, - initialFormalName: customer.formalName, - existingCustomer: customer, - ), + ), + const SliverToBoxAdapter(child: Divider(height: 1)), + if (_isLoading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (_filteredCustomers.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: Text("該当する顧客がいません")), + ) + else + SliverPadding( + padding: const EdgeInsets.only(bottom: 120), + sliver: SliverList.builder( + itemCount: _filteredCustomers.length, + itemBuilder: (context, index) { + final customer = _filteredCustomers[index]; + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.business)), + title: Text(customer.formalName), + subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), + onTap: () => widget.onCustomerSelected(customer), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), + onPressed: () => _showCustomerEditDialog( + displayName: customer.displayName, + initialFormalName: customer.formalName, + existingCustomer: customer, ), - IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), - onPressed: () => _confirmDelete(customer), - ), - ], - ), - ); - }, - ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), + onPressed: () => _confirmDelete(customer), + ), + ], + ), + ); + }, ), - ], + ), + ], + ), + ); + + return SafeArea( + child: Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + title: const Text('U2:取引先選択'), ), + body: body, ), ); } diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index aa3b3cb..8ab152e 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -1,18 +1,21 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../config/app_config.dart'; +import '../models/invoice_models.dart'; +import '../modules/feature_module.dart'; +import '../modules/module_registry.dart'; import '../services/app_settings_repository.dart'; +import '../services/customer_repository.dart'; +import '../services/location_service.dart'; +import '../widgets/slide_to_unlock.dart'; +import 'customer_master_screen.dart'; +import 'invoice_detail_page.dart'; import 'invoice_history_screen.dart'; import 'invoice_input_screen.dart'; -import 'invoice_detail_page.dart'; -import 'customer_master_screen.dart'; +import 'master_hub_page.dart'; +import 'sales_orders_screen.dart'; import 'product_master_screen.dart'; import 'settings_screen.dart'; -import 'master_hub_page.dart'; -import '../models/invoice_models.dart'; -import '../services/location_service.dart'; -import '../services/customer_repository.dart'; -import '../widgets/slide_to_unlock.dart'; -import '../config/app_config.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -23,11 +26,13 @@ class DashboardScreen extends StatefulWidget { class _DashboardScreenState extends State { final _repo = AppSettingsRepository(); + final _moduleRegistry = ModuleRegistry.instance; bool _loading = true; bool _statusEnabled = true; String _statusText = '工事中'; List _menu = []; bool _historyUnlocked = false; + List _moduleCards = []; @override void initState() { @@ -39,8 +44,9 @@ class _DashboardScreenState extends State { final statusEnabled = await _repo.getDashboardStatusEnabled(); final statusText = await _repo.getDashboardStatusText(); final rawMenu = await _repo.getDashboardMenu(); - final enabledRoutes = AppConfig.enabledRoutes; - final menu = rawMenu.where((m) => enabledRoutes.contains(m.route)).toList(); + final moduleCards = _moduleRegistry.enabledCards.toList(); + final menu = rawMenu.toList(); + _ensureModuleCardsInjected(menu, moduleCards); final unlocked = await _repo.getDashboardHistoryUnlocked(); setState(() { _statusEnabled = statusEnabled; @@ -48,16 +54,53 @@ class _DashboardScreenState extends State { _menu = menu; _loading = false; _historyUnlocked = unlocked; + _moduleCards = moduleCards; }); } + Set _enabledRouteSet() { + final routes = {...AppConfig.enabledRoutes}; + for (final card in _moduleRegistry.enabledCards) { + routes.add(card.route); + } + return routes; + } + + void _ensureModuleCardsInjected(List menu, List cards) { + final existingRoutes = menu.map((m) => m.route).toSet(); + for (final card in cards) { + if (!existingRoutes.contains(card.route)) { + menu.add(DashboardMenuItem(id: card.id, title: card.title, route: card.route, iconName: card.iconName)); + } + } + } + + ModuleDashboardCard? _moduleCardForRoute(String route) { + for (final card in _moduleCards) { + if (card.route == route) { + return card; + } + } + return null; + } + void _navigate(DashboardMenuItem item) async { Widget? target; - final enabledRoutes = AppConfig.enabledRoutes; + final enabledRoutes = _enabledRouteSet(); if (!enabledRoutes.contains(item.route)) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('この機能は現在ご利用いただけません'))); return; } + final moduleCard = _moduleCardForRoute(item.route); + if (moduleCard != null) { + if (moduleCard.requiresUnlock && !_historyUnlocked) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロックを解除してください'))); + return; + } + await moduleCard.onTap(context); + return; + } + switch (item.route) { case 'invoice_history': if (!_historyUnlocked) { @@ -96,6 +139,9 @@ class _DashboardScreenState extends State { case 'settings': target = const SettingsScreen(); break; + case 'sales_operations': + target = const SalesOrdersScreen(); + break; default: target = const InvoiceHistoryScreen(); break; @@ -157,6 +203,10 @@ class _DashboardScreenState extends State { } String _routeLabel(String route) { + final moduleCard = _moduleCardForRoute(route); + if (moduleCard != null) { + return moduleCard.description; + } switch (route) { case 'invoice_history': return 'A2:伝票一覧'; @@ -170,6 +220,8 @@ class _DashboardScreenState extends State { return 'M1:マスター管理'; case 'settings': return 'S1:設定'; + case 'sales_operations': + return 'B1:販売オペレーション'; default: return route; } diff --git a/lib/screens/department_master_screen.dart b/lib/screens/department_master_screen.dart new file mode 100644 index 0000000..50061f0 --- /dev/null +++ b/lib/screens/department_master_screen.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/department_model.dart'; +import '../services/department_repository.dart'; + +class DepartmentMasterScreen extends StatefulWidget { + const DepartmentMasterScreen({super.key}); + + @override + State createState() => _DepartmentMasterScreenState(); +} + +class _DepartmentMasterScreenState extends State { + final DepartmentRepository _repository = DepartmentRepository(); + final Uuid _uuid = const Uuid(); + + bool _isLoading = true; + bool _includeInactive = true; + List _departments = const []; + + @override + void initState() { + super.initState(); + _loadDepartments(); + } + + Future _loadDepartments() async { + setState(() => _isLoading = true); + final list = await _repository.fetchDepartments(includeInactive: _includeInactive); + if (!mounted) return; + setState(() { + _departments = list; + _isLoading = false; + }); + } + + Future _openForm({Department? department}) async { + final result = await showDialog( + context: context, + builder: (ctx) => _DepartmentFormDialog( + department: department, + onSubmit: (data) => Navigator.of(ctx).pop(data), + ), + ); + if (result == null) return; + final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now()); + await _repository.saveDepartment(saving); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('部門を保存しました'))); + _loadDepartments(); + } + + Future _deleteDepartment(Department department) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('部門を削除'), + content: Text('${department.name} を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))), + ], + ), + ); + if (confirmed != true) return; + await _repository.deleteDepartment(department.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('部門を削除しました'))); + _loadDepartments(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('M4:部門マスター'), + actions: [ + SwitchListTile.adaptive( + value: _includeInactive, + onChanged: (value) { + setState(() => _includeInactive = value); + _loadDepartments(); + }, + contentPadding: const EdgeInsets.only(right: 12), + title: const Text('無効を表示'), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _openForm(), + child: const Icon(Icons.add), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _departments.isEmpty + ? const _EmptyState(message: '部門が登録されていません') + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: _departments.length, + separatorBuilder: (context, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final department = _departments[index]; + return Card( + child: ListTile( + title: Text(department.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text([ + if (department.code?.isNotEmpty == true) 'コード: ${department.code}', + department.description ?? '', + department.isActive ? '稼働中' : '無効', + ].where((v) => v.isNotEmpty).join('\n')), + trailing: PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'edit': + _openForm(department: department); + break; + case 'delete': + _deleteDepartment(department); + break; + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('編集')), + PopupMenuItem(value: 'delete', child: Text('削除')), + ], + ), + ), + ); + }, + ), + ); + } +} + +class _DepartmentFormDialog extends StatefulWidget { + const _DepartmentFormDialog({required this.onSubmit, this.department}); + + final Department? department; + final ValueChanged onSubmit; + + @override + State<_DepartmentFormDialog> createState() => _DepartmentFormDialogState(); +} + +class _DepartmentFormDialogState extends State<_DepartmentFormDialog> { + late final TextEditingController _nameController; + late final TextEditingController _codeController; + late final TextEditingController _descriptionController; + bool _isActive = true; + + @override + void initState() { + super.initState(); + final department = widget.department; + _nameController = TextEditingController(text: department?.name ?? ''); + _codeController = TextEditingController(text: department?.code ?? ''); + _descriptionController = TextEditingController(text: department?.description ?? ''); + _isActive = department?.isActive ?? true; + } + + void _submit() { + if (_nameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('部門名は必須です'))); + return; + } + widget.onSubmit( + Department( + id: widget.department?.id ?? '', + name: _nameController.text.trim(), + code: _codeController.text.trim().isEmpty ? null : _codeController.text.trim(), + description: _descriptionController.text.trim().isEmpty ? null : _descriptionController.text.trim(), + isActive: _isActive, + updatedAt: DateTime.now(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.department == null ? '部門を追加' : '部門を編集'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: _nameController, decoration: const InputDecoration(labelText: '部門名 *')), + TextField(controller: _codeController, decoration: const InputDecoration(labelText: '部門コード')), + TextField(controller: _descriptionController, decoration: const InputDecoration(labelText: '説明'), maxLines: 2), + SwitchListTile( + title: const Text('稼働中'), + value: _isActive, + onChanged: (value) => setState(() => _isActive = value), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + FilledButton(onPressed: _submit, child: const Text('保存')), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.view_list, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(message), + ], + ), + ); + } +} diff --git a/lib/screens/email_settings_screen.dart b/lib/screens/email_settings_screen.dart index 0b7b56e..8dcb304 100644 --- a/lib/screens/email_settings_screen.dart +++ b/lib/screens/email_settings_screen.dart @@ -325,6 +325,7 @@ class _EmailSettingsScreenState extends State { final listBottomPadding = 24 + bottomInset; return Scaffold( appBar: AppBar( + leading: const BackButton(), title: const Text('SM:メール設定'), backgroundColor: Colors.indigo, ), diff --git a/lib/screens/gps_history_screen.dart b/lib/screens/gps_history_screen.dart index d7965f9..2834ff6 100644 --- a/lib/screens/gps_history_screen.dart +++ b/lib/screens/gps_history_screen.dart @@ -33,7 +33,8 @@ class _GpsHistoryScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("GPS位置情報履歴"), + leading: const BackButton(), + title: const Text("G1:GPS位置履歴"), backgroundColor: Colors.blueGrey, actions: [ IconButton(onPressed: _loadHistory, icon: const Icon(Icons.refresh)), diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index f923432..a799089 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -10,7 +10,6 @@ import 'customer_master_screen.dart'; import 'invoice_input_screen.dart'; import 'settings_screen.dart'; import 'company_info_screen.dart'; -import 'dashboard_screen.dart'; import '../services/app_settings_repository.dart'; import '../widgets/slide_to_unlock.dart'; // InvoiceFlowScreen import removed; using inline type picker @@ -290,23 +289,13 @@ class _InvoiceHistoryScreenState extends State { appBar: AppBar( automaticallyImplyLeading: false, leading: _useDashboardHome - ? IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const DashboardScreen()), - ); - }, + ? Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(ctx).openDrawer(), + ), ) - : (_isUnlocked - ? Builder( - builder: (ctx) => IconButton( - icon: const Icon(Icons.menu), - onPressed: () => Scaffold.of(ctx).openDrawer(), - ), - ) - : null), + : const BackButton(), title: GestureDetector( onLongPress: () { Navigator.push( diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 185702a..f30cc8b 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -75,6 +75,7 @@ class _InvoiceInputFormState extends State { List _editLogs = []; final FocusNode _subjectFocusNode = FocusNode(); String _lastLoggedSubject = ""; + bool _hasUnsavedChanges = false; String _documentTypeLabel(DocumentType type) { switch (type) { @@ -113,14 +114,73 @@ class _InvoiceInputFormState extends State { return _currentId!; } - void _copyAsNew() { + Future _confirmDiscardChanges() async { + if (!_hasUnsavedChanges || _isSaving) { + return true; + } + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('保存されていない変更があります'), + content: const Text('編集した内容を破棄してもよろしいですか?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('編集を続ける'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('破棄する'), + ), + ], + ), + ); + return result ?? false; + } + + Future _handleBackPressed() async { + final allow = await _confirmDiscardChanges(); + if (!mounted || !allow) return; + Navigator.of(context).maybePop(); + } + + Future _pickCopyDocumentType() { + return showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('コピー後の伝票種別を選択'), + children: DocumentType.values.map((type) { + final isCurrent = type == _documentType; + return SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(type), + child: Row( + children: [ + Icon( + isCurrent ? Icons.check_circle : Icons.circle_outlined, + color: _documentTypeColor(type), + ), + const SizedBox(width: 12), + Text(_documentTypeLabel(type)), + ], + ), + ); + }).toList(), + ), + ); + } + + Future _copyAsNew() async { if (widget.existingInvoice == null && _currentId == null) return; + final selectedType = await _pickCopyDocumentType(); + if (selectedType == null) return; final clonedItems = _cloneItems(_items, resetIds: true); setState(() { _currentId = DateTime.now().millisecondsSinceEpoch.toString(); _isDraft = true; _isLocked = false; _selectedDate = DateTime.now(); + _documentType = selectedType; _items ..clear() ..addAll(clonedItems); @@ -159,6 +219,7 @@ class _InvoiceInputFormState extends State { final savedSummary = await _settingsRepo.getSummaryTheme(); _summaryIsBlue = savedSummary == 'blue'; + _isApplyingSnapshot = true; setState(() { // 既存伝票がある場合は初期値を上書き if (widget.existingInvoice != null) { @@ -182,10 +243,14 @@ class _InvoiceInputFormState extends State { _isLocked = false; } }); + _isApplyingSnapshot = false; _isViewMode = widget.startViewMode; // 指定に従う _showNewBadge = widget.showNewBadge; _showCopyBadge = widget.showCopyBadge; - _pushHistory(clearRedo: true); + _pushHistory(clearRedo: true, markDirty: false); + if (_hasUnsavedChanges) { + setState(() => _hasUnsavedChanges = false); + } _lastLoggedSubject = _subjectController.text; if (_currentId != null) { _loadEditLogs(); @@ -287,7 +352,12 @@ class _InvoiceInputFormState extends State { } await _editLogRepo.addLog(_currentId!, "伝票を保存しました"); await _loadEditLogs(); - if (mounted) setState(() => _isViewMode = true); + if (mounted) { + setState(() { + _isViewMode = true; + _hasUnsavedChanges = false; + }); + } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); @@ -346,7 +416,7 @@ class _InvoiceInputFormState extends State { ); } - void _pushHistory({bool clearRedo = false}) { + void _pushHistory({bool clearRedo = false, bool markDirty = true}) { setState(() { if (_undoStack.length >= 30) _undoStack.removeAt(0); _undoStack.add(_InvoiceSnapshot( @@ -360,6 +430,9 @@ class _InvoiceInputFormState extends State { subject: _subjectController.text, )); if (clearRedo) _redoStack.clear(); + if (markDirty) { + _hasUnsavedChanges = true; + } }); } @@ -432,100 +505,113 @@ class _InvoiceInputFormState extends State { final docColor = _documentTypeColor(_documentType); - return Scaffold( - backgroundColor: themeColor, - resizeToAvoidBottomInset: false, - appBar: AppBar( - backgroundColor: docColor, - leading: const BackButton(), - title: Text("A1:${_documentTypeLabel(_documentType)}"), - actions: [ - if (_isDraft) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: _DraftBadge(), - ), - IconButton( - icon: const Icon(Icons.copy), - tooltip: "コピーして新規", - onPressed: _copyAsNew, + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final allow = await _confirmDiscardChanges(); + if (allow && context.mounted) { + Navigator.of(context).pop(result); + } + }, + child: Scaffold( + backgroundColor: themeColor, + resizeToAvoidBottomInset: false, + appBar: AppBar( + backgroundColor: docColor, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => _handleBackPressed(), ), - if (_isLocked) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.lock, color: Colors.white), - ) - else if (_isViewMode) - IconButton( - icon: const Icon(Icons.edit), - tooltip: "編集モードにする", - onPressed: () => setState(() => _isViewMode = false), - ) - else ...[ - IconButton( - icon: const Icon(Icons.undo), - onPressed: _canUndo ? _undo : null, - tooltip: "元に戻す", - ), - IconButton( - icon: const Icon(Icons.redo), - onPressed: _canRedo ? _redo : null, - tooltip: "やり直す", - ), - if (!_isLocked) - IconButton( - icon: const Icon(Icons.save), - tooltip: "保存", - onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false), + title: Text("A1:${_documentTypeLabel(_documentType)}"), + actions: [ + if (_isDraft) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: _DraftBadge(), ), + IconButton( + icon: const Icon(Icons.copy), + tooltip: "コピーして新規", + onPressed: () => _copyAsNew(), + ), + if (_isLocked) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.lock, color: Colors.white), + ) + else if (_isViewMode) + IconButton( + icon: const Icon(Icons.edit), + tooltip: "編集モードにする", + onPressed: () => setState(() => _isViewMode = false), + ) + else ...[ + IconButton( + icon: const Icon(Icons.undo), + onPressed: _canUndo ? _undo : null, + tooltip: "元に戻す", + ), + IconButton( + icon: const Icon(Icons.redo), + onPressed: _canRedo ? _redo : null, + tooltip: "やり直す", + ), + if (!_isLocked) + IconButton( + icon: const Icon(Icons.save), + tooltip: "保存", + onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false), + ), + ], ], - ], - ), - body: Stack( - children: [ - Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + ), + body: Stack( + children: [ + Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDateSection(), + const SizedBox(height: 16), + _buildCustomerSection(), + const SizedBox(height: 16), + _buildSubjectSection(textColor), + const SizedBox(height: 20), + _buildItemsSection(fmt), + const SizedBox(height: 20), + _buildSummarySection(fmt), + const SizedBox(height: 12), + _buildEditLogsSection(), + const SizedBox(height: 20), + ], + ), + ), + ), + _buildBottomActionBar(), + ], + ), + if (_isSaving) + Container( + color: Colors.black54, + child: const Center( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - _buildDateSection(), - const SizedBox(height: 16), - _buildCustomerSection(), - const SizedBox(height: 16), - _buildSubjectSection(textColor), - const SizedBox(height: 20), - _buildItemsSection(fmt), - const SizedBox(height: 20), - _buildSummarySection(fmt), - const SizedBox(height: 12), - _buildEditLogsSection(), - const SizedBox(height: 20), + CircularProgressIndicator(color: Colors.white), + SizedBox(height: 16), + Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), ], ), ), ), - _buildBottomActionBar(), - ], - ), - if (_isSaving) - Container( - color: Colors.black54, - child: const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(color: Colors.white), - SizedBox(height: 16), - Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ], - ), - ), - ), - ], + ], + ), ), ); } diff --git a/lib/screens/management_screen.dart b/lib/screens/management_screen.dart index 0910a87..044bdf2 100644 --- a/lib/screens/management_screen.dart +++ b/lib/screens/management_screen.dart @@ -1,10 +1,8 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:sqflite/sqflite.dart'; -import 'package:path/path.dart' as p; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; +import '../services/database_maintenance_service.dart'; import 'product_master_screen.dart'; import 'customer_master_screen.dart'; import 'activity_log_screen.dart'; @@ -19,12 +17,13 @@ class ManagementScreen extends StatefulWidget { } class _ManagementScreenState extends State { + final _dbMaintenance = const DatabaseMaintenanceService(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( leading: const BackButton(), - title: const Text("マスター管理・同期"), + title: const Text("M2:マスター管理・同期"), backgroundColor: Colors.blueGrey, ), body: ListView( @@ -157,13 +156,12 @@ class _ManagementScreenState extends State { } Future _backupDatabase(BuildContext context) async { - final dbPath = p.join(await getDatabasesPath(), 'gemi_invoice.db'); - final file = File(dbPath); - if (await file.exists()) { + final file = await _dbMaintenance.getDatabaseFile(); + if (file != null) { await SharePlus.instance.share( ShareParams( text: '販売アシスト1号_DBバックアップ', - files: [XFile(dbPath)], + files: [XFile(file.path)], ), ); } else { diff --git a/lib/screens/master_hub_page.dart b/lib/screens/master_hub_page.dart index 85b50eb..9118e88 100644 --- a/lib/screens/master_hub_page.dart +++ b/lib/screens/master_hub_page.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import 'customer_master_screen.dart'; +import 'department_master_screen.dart'; import 'product_master_screen.dart'; import 'settings_screen.dart'; +import 'staff_master_screen.dart'; +import 'supplier_master_screen.dart'; +import 'tax_setting_screen.dart'; class MasterHubPage extends StatelessWidget { const MasterHubPage({super.key}); @@ -15,12 +19,36 @@ class MasterHubPage extends StatelessWidget { icon: Icons.people, builder: (_) => const CustomerMasterScreen(), ), + MasterEntry( + title: '仕入先マスター', + description: '仕入先・支払条件の管理', + icon: Icons.factory, + builder: (_) => const SupplierMasterScreen(), + ), MasterEntry( title: '商品マスター', description: '商品情報の管理・編集', icon: Icons.inventory_2, builder: (_) => const ProductMasterScreen(), ), + MasterEntry( + title: '部門マスター', + description: '部門・部署構成を管理', + icon: Icons.apartment, + builder: (_) => const DepartmentMasterScreen(), + ), + MasterEntry( + title: '担当者マスター', + description: '社内スタッフと権限を管理', + icon: Icons.badge, + builder: (_) => const StaffMasterScreen(), + ), + MasterEntry( + title: '消費税・端数設定', + description: '税率と端数処理ルールを設定', + icon: Icons.calculate, + builder: (_) => const TaxSettingScreen(), + ), MasterEntry( title: '設定', description: 'アプリ設定・メニュー管理', @@ -31,11 +59,12 @@ class MasterHubPage extends StatelessWidget { return Scaffold( appBar: AppBar( + leading: const BackButton(), title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: const [ - Text('マスター管理'), - Text('ScreenID: 03', style: TextStyle(fontSize: 11, color: Colors.white70)), + Text('M3:マスター管理'), + Text('ScreenID: M3', style: TextStyle(fontSize: 11, color: Colors.white70)), ], ), ), diff --git a/lib/screens/product_master_screen.dart b/lib/screens/product_master_screen.dart index 7055d3f..838af38 100644 --- a/lib/screens/product_master_screen.dart +++ b/lib/screens/product_master_screen.dart @@ -61,6 +61,7 @@ class _ProductMasterScreenState extends State { final nameController = TextEditingController(text: product?.name ?? ""); final priceController = TextEditingController(text: (product?.defaultUnitPrice ?? 0).toString()); final barcodeController = TextEditingController(text: product?.barcode ?? ""); + final wholesaleController = TextEditingController(text: (product?.wholesalePrice ?? 0).toString()); final categoryController = TextEditingController(text: product?.category ?? ""); final stockController = TextEditingController(text: (product?.stockQuantity ?? 0).toString()); @@ -84,6 +85,7 @@ class _ProductMasterScreenState extends State { TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")), TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")), TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number), + TextField(controller: wholesaleController, decoration: const InputDecoration(labelText: "仕入値(卸値)"), keyboardType: TextInputType.number), TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number), const SizedBox(height: 8), Row( @@ -121,6 +123,7 @@ class _ProductMasterScreenState extends State { id: newId, name: nameController.text.trim(), defaultUnitPrice: int.tryParse(priceController.text) ?? 0, + wholesalePrice: int.tryParse(wholesaleController.text) ?? 0, barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(), category: categoryController.text.isEmpty ? null : categoryController.text.trim(), stockQuantity: int.tryParse(stockController.text) ?? 0, @@ -207,7 +210,7 @@ class _ProductMasterScreenState extends State { : (p.isLocked ? Colors.grey : Colors.black87), ), ), - subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"), + subtitle: Text("${p.category ?? '未分類'} - 販売¥${p.defaultUnitPrice} / 仕入¥${p.wholesalePrice} (在庫: ${p.stockQuantity})"), onTap: () { if (widget.selectionMode) { if (p.isHidden) return; // safety: do not return hidden in selection @@ -312,7 +315,8 @@ class _ProductMasterScreenState extends State { ], ), const SizedBox(height: 8), - Text("単価: ¥${p.defaultUnitPrice}"), + Text("販売単価: ¥${p.defaultUnitPrice}"), + Text("仕入値: ¥${p.wholesalePrice}"), Text("在庫: ${p.stockQuantity}"), if (p.barcode != null && p.barcode!.isNotEmpty) Text("バーコード: ${p.barcode}"), const SizedBox(height: 12), diff --git a/lib/screens/product_picker_modal.dart b/lib/screens/product_picker_modal.dart index 328f5f5..7926c00 100644 --- a/lib/screens/product_picker_modal.dart +++ b/lib/screens/product_picker_modal.dart @@ -7,8 +7,9 @@ import 'product_master_screen.dart'; /// 商品マスターから項目を選択するためのモーダル(スタブ実装) class ProductPickerModal extends StatefulWidget { final Function(InvoiceItem) onItemSelected; + final ValueChanged? onProductSelected; - const ProductPickerModal({super.key, required this.onItemSelected}); + const ProductPickerModal({super.key, required this.onItemSelected, this.onProductSelected}); @override State createState() => _ProductPickerModalState(); @@ -105,6 +106,7 @@ class _ProductPickerModalState extends State { title: Text(product.name), subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"), onTap: () { + widget.onProductSelected?.call(product); widget.onItemSelected( InvoiceItem( productId: product.id, diff --git a/lib/screens/sales_dashboard_screen.dart b/lib/screens/sales_dashboard_screen.dart new file mode 100644 index 0000000..5b3dc65 --- /dev/null +++ b/lib/screens/sales_dashboard_screen.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; + +import 'sales_report_screen.dart'; + +class SalesDashboardScreen extends StatelessWidget { + const SalesDashboardScreen({super.key}); + + void _openAnalytics(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SalesReportScreen()), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('R0:売上ダッシュボード'), + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildHeroCard(theme), + const SizedBox(height: 16), + _buildLauncherCard(context, theme), + const SizedBox(height: 16), + _buildComingSoonCard(theme), + ], + ), + ); + } + + Widget _buildHeroCard(ThemeData theme) { + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.indigo.shade600, Colors.indigo.shade300], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.indigo.withValues(alpha: 0.25), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '売上管理モジュール', + style: theme.textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + 'ダッシュボードはランチャーとして機能し、分析モジュールや将来のサブ機能へのエントリーポイントをまとめて提供します。', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70), + ), + ], + ), + ); + } + + Widget _buildLauncherCard(BuildContext context, ThemeData theme) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.analytics_outlined, color: Colors.indigo.shade600), + const SizedBox(width: 8), + const Text( + '売上分析モジュール', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '年間売上やトップ顧客の内訳を詳細に確認したい場合はこちらからアクセスしてください。', + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.check_circle, color: Colors.indigo), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('年度切り替え / ドキュメント種別フィルタ'), + SizedBox(height: 4), + Text('トップ顧客・月次トレンド・ドラフト状況の把握'), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () => _openAnalytics(context), + style: FilledButton.styleFrom( + backgroundColor: Colors.indigo, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + icon: const Icon(Icons.open_in_new), + label: const Text('売上分析モジュールを開く'), + ), + ], + ), + ), + ); + } + + Widget _buildComingSoonCard(ThemeData theme) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(Icons.extension, color: Colors.grey), + SizedBox(width: 8), + Text('近日提供予定のメニュー', style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 12), + const Text('・ 売掛残高の自動集計 / 着地予測'), + const Text('・ 商品カテゴリ別の利益率ダッシュボード'), + const Text('・ 重点顧客へのフォローアップリマインダー'), + const SizedBox(height: 12), + Text( + 'ダッシュボードはランチャーとして、こうした追加モジュールへの入り口を順次揃えていきます。', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/sales_entries_screen.dart b/lib/screens/sales_entries_screen.dart new file mode 100644 index 0000000..f996175 --- /dev/null +++ b/lib/screens/sales_entries_screen.dart @@ -0,0 +1,1252 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/customer_model.dart'; +import '../models/invoice_models.dart'; +import '../models/product_model.dart'; +import '../models/sales_entry_models.dart'; +import '../services/app_settings_repository.dart'; +import '../services/edit_log_repository.dart'; +import '../services/sales_entry_service.dart'; +import '../widgets/line_item_editor.dart'; +import '../widgets/screen_id_title.dart'; +import 'customer_picker_modal.dart'; +import 'product_picker_modal.dart'; + +class SalesEntriesScreen extends StatefulWidget { + const SalesEntriesScreen({super.key}); + + @override + State createState() => _SalesEntriesScreenState(); +} + +class _SalesEntriesScreenState extends State { + final SalesEntryService _service = SalesEntryService(); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + bool _isLoading = true; + bool _isRefreshing = false; + List _entries = const []; + SalesEntryStatus? _filterStatus; + + @override + void initState() { + super.initState(); + _loadEntries(); + } + + Future _loadEntries() async { + if (!_isRefreshing) { + setState(() => _isLoading = true); + } + try { + final entries = await _service.fetchEntries(status: _filterStatus); + if (!mounted) return; + setState(() { + _entries = entries; + _isLoading = false; + _isRefreshing = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _isRefreshing = false; + }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('売上伝票の取得に失敗しました: $e'))); + } + } + + + + Future _handleRefresh() async { + setState(() => _isRefreshing = true); + await _loadEntries(); + } + + Future _openEditor({SalesEntry? entry}) async { + final updated = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => _SalesEntryEditorPage(service: _service, entry: entry)), + ); + if (updated != null) { + await _loadEntries(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('売上伝票を保存しました'))); + } + } + + Future _openImportSheet() async { + final imported = await SalesEntryImportSheet.show(context, _service); + if (imported != null) { + await _loadEntries(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('伝票をインポートしました: ${imported.subject ?? '売上伝票'}'))); + } + } + + Future _handleReimport(SalesEntry entry) async { + try { + final updated = await _service.reimportEntry(entry.id); + if (!mounted) return; + setState(() { + final index = _entries.indexWhere((e) => e.id == entry.id); + if (index != -1) { + _entries = List.of(_entries)..[index] = updated; + } + }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('再インポートが完了しました'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('再インポートに失敗しました: $e'))); + } + } + + Future _confirmDelete(SalesEntry entry) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('伝票を削除'), + content: Text('${entry.subject ?? '無題'}を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')), + ], + ), + ); + if (confirmed != true) return; + try { + await _service.deleteEntry(entry.id); + if (!mounted) return; + setState(() { + _entries = _entries.where((e) => e.id != entry.id).toList(); + }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('伝票を削除しました'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e'))); + } + } + + @override + Widget build(BuildContext context) { + final body = _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _handleRefresh, + child: _entries.isEmpty + ? ListView( + children: const [ + SizedBox(height: 120), + Icon(Icons.description_outlined, size: 64, color: Colors.grey), + SizedBox(height: 12), + Center(child: Text('売上伝票がありません。インポートまたは新規作成してください。')), + ], + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), + itemCount: _entries.length, + separatorBuilder: (_, index) => const SizedBox(height: 12), + itemBuilder: (context, index) => _buildEntryCard(_entries[index]), + ), + ); + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const ScreenAppBarTitle(screenId: 'U1', title: '売上伝票'), + actions: [ + IconButton(onPressed: _openImportSheet, tooltip: 'インポート', icon: const Icon(Icons.download)), + IconButton(onPressed: () => _openEditor(), tooltip: '新規作成', icon: const Icon(Icons.add)), + PopupMenuButton( + tooltip: 'ステータス絞り込み', + icon: const Icon(Icons.filter_alt), + onSelected: (value) { + setState(() => _filterStatus = value); + _loadEntries(); + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: null, child: Text('すべて表示')), + ...SalesEntryStatus.values.map( + (status) => PopupMenuItem(value: status, child: Text(status.displayName)), + ), + ], + ), + ], + ), + body: body, + floatingActionButton: FloatingActionButton.extended( + onPressed: _openImportSheet, + icon: const Icon(Icons.receipt_long), + label: const Text('伝票インポート'), + ), + ); + } + + Widget _buildEntryCard(SalesEntry entry) { + final amountLabel = _currencyFormat.format(entry.amountTaxIncl); + final dateLabel = DateFormat('yyyy/MM/dd').format(entry.issueDate); + final subject = entry.subject?.trim().isNotEmpty == true ? entry.subject!.trim() : '売上伝票'; + final customer = entry.customerNameSnapshot ?? '取引先未設定'; + + Color statusColor(SalesEntryStatus status) { + switch (status) { + case SalesEntryStatus.draft: + return Colors.orange; + case SalesEntryStatus.confirmed: + return Colors.blue; + case SalesEntryStatus.settled: + return Colors.green; + } + } + + final baseColor = statusColor(entry.status); + final statusChip = Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: baseColor.withAlpha((0.15 * 255).round()), + borderRadius: BorderRadius.circular(999), + ), + child: Text(entry.status.displayName, style: TextStyle(color: baseColor, fontSize: 12)), + ); + + return Card( + child: InkWell( + onTap: () => _openEditor(entry: entry), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + subject, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + statusChip, + PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'edit': + _openEditor(entry: entry); + break; + case 'reimport': + _handleReimport(entry); + break; + case 'delete': + _confirmDelete(entry); + break; + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('編集')), + PopupMenuItem(value: 'reimport', child: Text('再インポート')), + PopupMenuItem(value: 'delete', child: Text('削除')), + ], + ), + ], + ), + const SizedBox(height: 8), + Text(customer, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 4), + Row( + children: [ + Text('計上日: $dateLabel'), + const Spacer(), + Text(amountLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + if (entry.notes?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + Text(entry.notes!, style: Theme.of(context).textTheme.bodySmall), + ], + ], + ), + ), + ), + ); + } +} + +class _SalesEntryEditorPage extends StatefulWidget { + const _SalesEntryEditorPage({required this.service, this.entry}); + + final SalesEntryService service; + final SalesEntry? entry; + + @override + State<_SalesEntryEditorPage> createState() => _SalesEntryEditorPageState(); +} + +class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { + final _subjectController = TextEditingController(); + final _notesController = TextEditingController(); + final Uuid _uuid = const Uuid(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + final EditLogRepository _editLogRepo = EditLogRepository(); + final AppSettingsRepository _settingsRepo = AppSettingsRepository(); + + late DateTime _issueDate; + Customer? _selectedCustomer; + String? _customerSnapshot; + SalesEntryStatus _status = SalesEntryStatus.draft; + bool _isSaving = false; + final List _lines = []; + List _editLogs = const []; + String? _entryId; + bool _isLoadingLogs = false; + bool _grossEnabled = true; + bool _grossToggleVisible = true; + bool _grossIncludeProvisional = false; + bool _showGross = true; + bool _cashSaleMode = false; + final String _cashSaleLabel = '現金売上'; + + final List<_EntrySnapshot> _undoStack = []; + final List<_EntrySnapshot> _redoStack = []; + bool _isApplyingSnapshot = false; + Timer? _historyDebounce; + + @override + void initState() { + super.initState(); + final entry = widget.entry; + _issueDate = entry?.issueDate ?? DateTime.now(); + _status = entry?.status ?? SalesEntryStatus.draft; + _customerSnapshot = entry?.customerNameSnapshot; + _subjectController.text = entry?.subject ?? ''; + _notesController.text = entry?.notes ?? ''; + _entryId = entry?.id; + _cashSaleMode = entry == null + ? false + : (entry.customerId == null && entry.customerNameSnapshot == _cashSaleLabel); + + if (entry != null) { + for (final item in entry.items) { + final form = LineItemFormData( + id: item.id, + productId: item.productId, + productName: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + costAmount: item.costAmount, + costIsProvisional: item.costIsProvisional, + ); + _attachLineListeners(form); + _lines.add(form); + } + } + if (_lines.isEmpty) { + final form = LineItemFormData(); + _attachLineListeners(form); + _lines.add(form); + } + if (_entryId != null) { + _loadEditLogs(); + } + _loadGrossSettings(); + _subjectController.addListener(_scheduleHistorySnapshot); + _notesController.addListener(_scheduleHistorySnapshot); + WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory()); + } + + @override + void dispose() { + _historyDebounce?.cancel(); + _subjectController.removeListener(_scheduleHistorySnapshot); + _notesController.removeListener(_scheduleHistorySnapshot); + _subjectController.dispose(); + _notesController.dispose(); + for (final line in _lines) { + line.removeChangeListener(_scheduleHistorySnapshot); + line.dispose(); + } + super.dispose(); + } + + String _ensureEntryId() { + return _entryId ??= widget.entry?.id ?? _uuid.v4(); + } + + void _logEdit(String message) { + final id = _ensureEntryId(); + _editLogRepo.addLog(id, message).then((_) => _loadEditLogs()); + } + + void _initializeHistory() { + _undoStack + ..clear() + ..add(_captureSnapshot()); + _redoStack.clear(); + } + + void _attachLineListeners(LineItemFormData line) { + line.registerChangeListener(_scheduleHistorySnapshot); + } + + void _scheduleHistorySnapshot() { + if (_isApplyingSnapshot) return; + _historyDebounce?.cancel(); + _historyDebounce = Timer(const Duration(milliseconds: 500), () { + _pushHistory(clearRedo: true); + }); + } + + void _pushHistory({bool clearRedo = false}) { + if (_isApplyingSnapshot) return; + final snapshot = _captureSnapshot(); + if (_undoStack.isNotEmpty && _undoStack.last.isSame(snapshot)) { + return; + } + setState(() { + if (_undoStack.length >= 50) { + _undoStack.removeAt(0); + } + _undoStack.add(snapshot); + if (clearRedo) { + _redoStack.clear(); + } + }); + } + + _EntrySnapshot _captureSnapshot() { + return _EntrySnapshot( + customer: _selectedCustomer, + customerSnapshot: _customerSnapshot, + subject: _subjectController.text, + notes: _notesController.text, + issueDate: _issueDate, + status: _status, + cashSaleMode: _cashSaleMode, + lines: _lines.map(_LineDraft.fromForm).toList(growable: false), + ); + } + + void _applySnapshot(_EntrySnapshot snapshot) { + _isApplyingSnapshot = true; + _historyDebounce?.cancel(); + for (final line in _lines) { + line.removeChangeListener(_scheduleHistorySnapshot); + line.dispose(); + } + _lines + ..clear() + ..addAll(snapshot.lines.map((draft) { + final form = draft.toFormData(); + _attachLineListeners(form); + return form; + })); + _selectedCustomer = snapshot.customer; + _customerSnapshot = snapshot.customerSnapshot; + _subjectController.text = snapshot.subject; + _notesController.text = snapshot.notes; + _issueDate = snapshot.issueDate; + _status = snapshot.status; + _cashSaleMode = snapshot.cashSaleMode; + _isApplyingSnapshot = false; + setState(() {}); + } + + bool get _canUndo => _undoStack.length > 1; + bool get _canRedo => _redoStack.isNotEmpty; + + void _undo() { + if (!_canUndo) return; + final current = _captureSnapshot(); + setState(() { + _redoStack.add(current); + _undoStack.removeLast(); + final snapshot = _undoStack.last; + _applySnapshot(snapshot); + }); + } + + void _redo() { + if (!_canRedo) return; + final snapshot = _redoStack.removeLast(); + setState(() { + _undoStack.add(snapshot); + _applySnapshot(snapshot); + }); + } + + Future _loadGrossSettings() async { + final enabled = await _settingsRepo.getGrossProfitEnabled(); + final toggleVisible = await _settingsRepo.getGrossProfitToggleVisible(); + final includeProvisional = await _settingsRepo.getGrossProfitIncludeProvisional(); + if (!mounted) return; + setState(() { + _grossEnabled = enabled; + _grossToggleVisible = toggleVisible; + _grossIncludeProvisional = includeProvisional; + _showGross = enabled; + }); + } + + Future _loadEditLogs() async { + final id = _entryId; + if (id == null) return; + setState(() => _isLoadingLogs = true); + final logs = await _editLogRepo.getLogs(id); + if (!mounted) return; + setState(() { + _editLogs = logs; + _isLoadingLogs = false; + }); + } + + Widget _buildEditLogPanel() { + final hasEntryId = _entryId != null; + return Card( + margin: const EdgeInsets.only(top: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text( + '編集ログ', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + IconButton( + tooltip: '再読込', + icon: const Icon(Icons.refresh), + onPressed: hasEntryId ? _loadEditLogs : null, + ), + ], + ), + const SizedBox(height: 8), + if (!hasEntryId) + const Text( + '保存すると編集ログが表示されます。', + style: TextStyle(color: Colors.grey), + ) + else if (_isLoadingLogs) + const SizedBox( + height: 48, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ) + else if (_editLogs.isEmpty) + const Text( + '編集ログはまだありません。', + style: TextStyle(color: Colors.grey), + ) + else ...[ + ..._editLogs.take(10).map((log) { + final timestamp = DateFormat('yyyy/MM/dd HH:mm').format(log.createdAt); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.circle, size: 6, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + timestamp, + style: const TextStyle(fontSize: 11, color: Colors.black54), + ), + Text(log.message), + ], + ), + ), + ], + ), + ); + }), + if (_editLogs.length > 10) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '最新10件を表示しています。', + style: TextStyle(fontSize: 11, color: Colors.grey), + ), + ), + ], + ], + ), + ), + ); + } + + Future _pickCustomer() async { + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => CustomerPickerModal( + onCustomerSelected: (customer) { + Navigator.pop(ctx, customer); + }, + ), + ); + if (selected == null) return; + setState(() { + _selectedCustomer = selected; + _customerSnapshot = selected.invoiceName; + }); + _logEdit('取引先を「${selected.invoiceName}」に設定'); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _issueDate, + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() => _issueDate = picked); + _logEdit('計上日を${_dateFormat.format(picked)}に更新'); + } + + void _addLine() { + setState(() { + final form = LineItemFormData(quantity: 1); + _attachLineListeners(form); + _lines.add(form); + }); + _pushHistory(clearRedo: true); + _logEdit('明細行を追加しました'); + } + + void _removeLine(int index) { + if (_lines.length <= 1) return; + final removed = _lines[index].descriptionController.text; + final target = _lines.removeAt(index); + target.removeChangeListener(_scheduleHistorySnapshot); + target.dispose(); + setState(() {}); + _pushHistory(clearRedo: true); + _logEdit(removed.isEmpty ? '明細行を削除しました' : '明細「$removed」を削除しました'); + } + + Future _save() async { + if (_isSaving) return; + for (var i = 0; i < _lines.length; i++) { + if (!_lines[i].hasProduct) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('明細${i + 1}の商品を選択してください'))); + return; + } + } + final subject = _subjectController.text.trim(); + final notes = _notesController.text.trim(); + if (_cashSaleMode && (_customerSnapshot == null || _customerSnapshot!.isEmpty)) { + _customerSnapshot = _cashSaleLabel; + } + final entryId = _ensureEntryId(); + final lines = []; + for (final line in _lines) { + final desc = line.descriptionController.text.trim(); + final qty = int.tryParse(line.quantityController.text) ?? 0; + final price = int.tryParse(line.unitPriceController.text) ?? 0; + if (desc.isEmpty || qty <= 0) continue; + final id = line.id ?? _uuid.v4(); + lines.add( + SalesLineItem( + id: id, + salesEntryId: entryId, + productId: line.productId, + description: desc, + quantity: qty, + unitPrice: price, + lineTotal: qty * price, + taxRate: line.taxRate ?? 0.1, + costAmount: line.costAmount, + costIsProvisional: line.costIsProvisional, + ), + ); + } + if (lines.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('明細を1件以上入力してください'))); + return; + } + + final base = widget.entry ?? + SalesEntry( + id: entryId, + customerId: _selectedCustomer?.id, + customerNameSnapshot: _customerSnapshot, + subject: subject.isEmpty ? null : subject, + issueDate: _issueDate, + status: _status, + notes: notes.isEmpty ? null : notes, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + items: lines, + ); + + final updated = base.copyWith( + customerId: _selectedCustomer?.id ?? base.customerId, + customerNameSnapshot: _customerSnapshot ?? base.customerNameSnapshot, + subject: subject.isEmpty ? null : subject, + issueDate: _issueDate, + notes: notes.isEmpty ? null : notes, + status: _status, + items: lines, + updatedAt: DateTime.now(), + ); + + setState(() => _isSaving = true); + try { + final saved = await widget.service.saveEntry(updated); + if (!mounted) return; + _entryId = saved.id; + _logEdit('売上伝票を保存しました'); + Navigator.pop(context, saved); + } catch (e) { + if (!mounted) return; + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); + } + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final keyboardInset = mediaQuery.viewInsets.bottom; + final safeBottom = mediaQuery.padding.bottom; + final scrollPadding = (keyboardInset > 0 ? keyboardInset : 0) + 48.0; + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + leading: const BackButton(), + title: ScreenAppBarTitle( + screenId: 'U2', + title: widget.entry == null ? '売上伝票作成' : '売上伝票編集', + ), + actions: [ + IconButton( + icon: const Icon(Icons.undo), + tooltip: '元に戻す', + onPressed: _canUndo ? _undo : null, + ), + IconButton( + icon: const Icon(Icons.redo), + tooltip: 'やり直す', + onPressed: _canRedo ? _redo : null, + ), + IconButton( + icon: const Icon(Icons.save_outlined), + tooltip: '保存', + onPressed: _isSaving ? null : _save, + ), + TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')), + ], + ), + body: SafeArea( + top: true, + bottom: false, + child: AnimatedPadding( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: EdgeInsets.fromLTRB(16, 16, 16, 32 + safeBottom), + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.only(bottom: scrollPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _subjectController, + decoration: const InputDecoration(labelText: '件名'), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: Text('計上日: ${_dateFormat.format(_issueDate)}')), + TextButton(onPressed: _pickDate, child: const Text('日付を選択')), + ], + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_customerSnapshot ?? '取引先を選択'), + trailing: const Icon(Icons.chevron_right), + onTap: _cashSaleMode ? null : _pickCustomer, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('現金売上モード'), + subtitle: const Text('顧客登録なしで「現金売上」として計上します'), + value: _cashSaleMode, + onChanged: (value) => _toggleCashSaleMode(value), + ), + const Divider(height: 32), + Text('明細', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (_grossEnabled && _grossToggleVisible) + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('粗利を表示'), + subtitle: const Text('仕入値が入っている明細のみ粗利を計算します'), + value: _showGross, + onChanged: (value) => setState(() => _showGross = value), + ), + for (var i = 0; i < _lines.length; i++) + LineItemCard( + data: _lines[i], + onRemove: () => _removeLine(i), + onPickProduct: () => _pickProductForLine(i), + onChanged: _scheduleHistorySnapshot, + meta: _shouldShowGross ? _buildLineMeta(_lines[i]) : null, + footer: _shouldShowGross ? _buildLineFooter(_lines[i]) : null, + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _addLine, + icon: const Icon(Icons.add), + label: const Text('明細を追加'), + ), + ), + const Divider(height: 32), + if (_shouldShowGross) _buildGrossSummary(), + if (_shouldShowGross) const Divider(height: 32), + TextField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'メモ'), + maxLines: 3, + ), + _buildEditLogPanel(), + const SizedBox(height: 80), + ], + ), + ); + }, + ), + ), + ), + ); + } + + void _pickProductForLine(int index) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => FractionallySizedBox( + heightFactor: 0.9, + child: ProductPickerModal( + onProductSelected: (product) { + setState(() { + final line = _lines[index]; + line.applyProduct(product); + }); + _logEdit('明細${index + 1}を商品「${product.name}」に設定'); + _pushHistory(clearRedo: true); + }, + onItemSelected: (item) {}, + ), + ), + ); + } + + void _toggleCashSaleMode(bool enabled) { + setState(() { + _cashSaleMode = enabled; + if (enabled) { + _selectedCustomer = null; + _customerSnapshot = _cashSaleLabel; + } else { + _customerSnapshot = _selectedCustomer?.invoiceName; + } + }); + _logEdit(enabled ? '現金売上モードに切り替え' : '現金売上モードを解除'); + _pushHistory(clearRedo: true); + } + + bool get _shouldShowGross => _grossEnabled && _showGross; + + int _lineQuantity(LineItemFormData line) => int.tryParse(line.quantityController.text) ?? 0; + + int _lineUnitPrice(LineItemFormData line) => int.tryParse(line.unitPriceController.text) ?? 0; + + int _lineRevenue(LineItemFormData line) => _lineQuantity(line) * _lineUnitPrice(line); + + int _lineCost(LineItemFormData line) => _lineQuantity(line) * line.costAmount; + + int _lineGross(LineItemFormData line) => _lineRevenue(line) - _lineCost(line); + + bool _isProvisional(LineItemFormData line) => line.costIsProvisional || line.costAmount <= 0; + + String _formatYen(int value) => _currencyFormat.format(value).replaceAll('.00', ''); + + Widget _buildLineMeta(LineItemFormData line) { + final gross = _lineGross(line); + final provisional = _isProvisional(line); + final color = provisional + ? Colors.orange + : gross >= 0 + ? Colors.green + : Colors.redAccent; + final label = provisional ? '粗利(暫定)' : '粗利'; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Chip( + label: Text('$label ${_formatYen(gross)}'), + backgroundColor: color.withOpacity(0.12), + labelStyle: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600), + ), + ); + } + + Widget _buildLineFooter(LineItemFormData line) { + final cost = _lineCost(line); + final provisional = _isProvisional(line); + final text = provisional + ? '仕入: ${_formatYen(cost)} (暫定0扱い)' + : '仕入: ${_formatYen(cost)}'; + return Align( + alignment: Alignment.centerRight, + child: Text( + text, + style: TextStyle( + fontSize: 12, + color: provisional ? Colors.orange.shade700 : Colors.black54, + ), + ), + ); + } + + int _grossTotal({required bool includeProvisional}) { + var total = 0; + for (final line in _lines) { + if (!includeProvisional && _isProvisional(line)) continue; + total += _lineGross(line); + } + return total; + } + + int _provisionalCount() => _lines.where(_isProvisional).length; + + Widget _buildGrossSummary() { + final total = _grossTotal(includeProvisional: _grossIncludeProvisional); + final excluded = _grossTotal(includeProvisional: false); + final provisionalLines = _provisionalCount(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('粗利サマリ', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _SummaryTile( + label: _grossIncludeProvisional ? '粗利合計(暫定含む)' : '粗利合計', + value: _formatYen(total), + valueColor: total >= 0 ? Colors.green.shade700 : Colors.redAccent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryTile( + label: '暫定を除いた粗利', + value: _formatYen(excluded), + ), + ), + ], + ), + if (provisionalLines > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '暫定粗利の明細: $provisionalLines 件 (設定で合計への含め方を変更できます)', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.orange.shade700), + ), + ), + ], + ); + } +} + +class _SummaryTile extends StatelessWidget { + const _SummaryTile({required this.label, required this.value, this.valueColor}); + + final String label; + final String value; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: theme.colorScheme.surfaceVariant.withOpacity(0.4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodySmall), + const SizedBox(height: 4), + Text( + value, + style: theme.textTheme.titleMedium?.copyWith( + color: valueColor ?? theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} + +class SalesEntryImportSheet extends StatefulWidget { + const SalesEntryImportSheet({required this.service, super.key}); + + final SalesEntryService service; + + static Future show(BuildContext context, SalesEntryService service) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => DraggableScrollableSheet( + expand: false, + initialChildSize: 0.85, + builder: (_, controller) => SalesEntryImportSheet(service: service), + ), + ); + } + + @override + State createState() => _SalesEntryImportSheetState(); +} + +class _SalesEntryImportSheetState extends State { + final TextEditingController _keywordController = TextEditingController(); + final TextEditingController _subjectController = TextEditingController(); + + bool _isLoading = true; + bool _isImporting = false; + List _candidates = const []; + Set _selected = {}; + Set _types = DocumentType.values.toSet(); + DateTime? _startDate; + DateTime? _endDate; + DateTime? _issueDateOverride; + + @override + void initState() { + super.initState(); + _loadCandidates(); + } + + @override + void dispose() { + _keywordController.dispose(); + _subjectController.dispose(); + super.dispose(); + } + + Future _loadCandidates() async { + setState(() => _isLoading = true); + try { + final results = await widget.service.fetchImportCandidates( + keyword: _keywordController.text.trim().isEmpty ? null : _keywordController.text.trim(), + documentTypes: _types, + startDate: _startDate, + endDate: _endDate, + ); + if (!mounted) return; + setState(() { + _candidates = results; + _isLoading = false; + _selected = _selected.where((id) => results.any((c) => c.invoiceId == id)).toSet(); + }); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポート候補の取得に失敗しました: $e'))); + } + } + + Future _pickRange({required bool isStart}) async { + final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now()); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() { + if (isStart) { + _startDate = picked; + } else { + _endDate = picked; + } + }); + _loadCandidates(); + } + + Future _pickIssueDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _issueDateOverride ?? DateTime.now(), + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() => _issueDateOverride = picked); + } + + void _toggleType(DocumentType type) { + setState(() { + if (_types.contains(type)) { + _types.remove(type); + if (_types.isEmpty) { + _types = {type}; + } + } else { + _types.add(type); + } + }); + _loadCandidates(); + } + + void _toggleSelection(String invoiceId) { + setState(() { + if (_selected.contains(invoiceId)) { + _selected.remove(invoiceId); + } else { + _selected.add(invoiceId); + } + }); + } + + Future _importSelected() async { + if (_selected.isEmpty || _isImporting) return; + setState(() => _isImporting = true); + try { + final entry = await widget.service.createEntryFromInvoices( + _selected.toList(), + subject: _subjectController.text.trim().isEmpty ? null : _subjectController.text.trim(), + issueDate: _issueDateOverride, + ); + if (!mounted) return; + Navigator.pop(context, entry); + } catch (e) { + if (!mounted) return; + setState(() => _isImporting = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポートに失敗しました: $e'))); + } + } + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('yyyy/MM/dd'); + return Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded(child: Text('伝票インポート', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), + IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _keywordController, + decoration: InputDecoration( + labelText: 'キーワード (件名/顧客)', + suffixIcon: IconButton(icon: const Icon(Icons.search), onPressed: _loadCandidates), + ), + onSubmitted: (_) => _loadCandidates(), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: DocumentType.values + .map((type) => FilterChip( + label: Text(type.displayName), + selected: _types.contains(type), + onSelected: (_) => _toggleType(type), + )) + .toList(), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: Text('開始日: ${_startDate != null ? dateFormat.format(_startDate!) : '指定なし'}')), + TextButton(onPressed: () => _pickRange(isStart: true), child: const Text('開始日を選択')), + ], + ), + Row( + children: [ + Expanded(child: Text('終了日: ${_endDate != null ? dateFormat.format(_endDate!) : '指定なし'}')), + TextButton(onPressed: () => _pickRange(isStart: false), child: const Text('終了日を選択')), + ], + ), + const Divider(height: 24), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _candidates.isEmpty + ? const Center(child: Text('条件に合致する伝票が見つかりません')) + : ListView.builder( + itemCount: _candidates.length, + itemBuilder: (context, index) { + final candidate = _candidates[index]; + final selected = _selected.contains(candidate.invoiceId); + return CheckboxListTile( + value: selected, + onChanged: (_) => _toggleSelection(candidate.invoiceId), + title: Text(candidate.subject ?? '${candidate.documentTypeName}(${candidate.invoiceNumber})'), + subtitle: Text( + '${candidate.documentTypeName} / ${candidate.customerName}\n${dateFormat.format(candidate.invoiceDate)} / 合計: ${NumberFormat.currency(locale: 'ja_JP', symbol: '¥').format(candidate.totalAmount)}', + ), + ); + }, + ), + ), + const Divider(height: 24), + TextField( + controller: _subjectController, + decoration: const InputDecoration(labelText: '売上伝票の件名 (任意)'), + ), + Row( + children: [ + Expanded(child: Text('売上伝票の日付: ${_issueDateOverride != null ? dateFormat.format(_issueDateOverride!) : '自動設定'}')), + TextButton(onPressed: _pickIssueDate, child: const Text('変更')), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _selected.isEmpty || _isImporting ? null : _importSelected, + icon: _isImporting ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.playlist_add), + label: Text(_isImporting ? 'インポート中...' : '選択(${_selected.length})件をインポート'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/sales_orders_screen.dart b/lib/screens/sales_orders_screen.dart new file mode 100644 index 0000000..9edb8e6 --- /dev/null +++ b/lib/screens/sales_orders_screen.dart @@ -0,0 +1,2362 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../models/customer_model.dart'; +import '../models/hash_chain_models.dart'; +import '../models/inventory_models.dart'; +import '../models/order_models.dart'; +import '../models/receivable_models.dart'; +import '../models/shipment_models.dart'; +import '../services/inventory_service.dart'; +import '../services/order_service.dart'; +import '../services/receivable_service.dart'; +import '../services/shipment_service.dart'; +import '../services/shipping_label_service.dart'; +import 'customer_picker_modal.dart'; + +class SalesOrdersScreen extends StatefulWidget { + const SalesOrdersScreen({super.key}); + + @override + State createState() => _SalesOrdersScreenState(); +} + +class _HashChainVerificationDialog extends StatelessWidget { + const _HashChainVerificationDialog({required this.result}); + + final HashChainVerificationResult result; + + @override + Widget build(BuildContext context) { + final isHealthy = result.isHealthy; + final title = isHealthy ? 'HASHチェーンは正常です' : 'HASHチェーンの破断を検出しました'; + return AlertDialog( + title: Text(title), + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('検証日時: ${DateFormat('yyyy/MM/dd HH:mm').format(result.verifiedAt)}'), + Text('検証件数: ${result.checkedCount} 件'), + Text('破断件数: ${result.breaks.length} 件'), + const SizedBox(height: 12), + if (result.breaks.isNotEmpty) + Expanded( + child: ListView.builder( + shrinkWrap: true, + itemCount: result.breaks.length, + itemBuilder: (ctx, index) { + final item = result.breaks[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(item.invoiceNumber ?? item.invoiceId), + subtitle: Text('${item.issue}\nexpected: ${item.expectedHash ?? '-'}\nactual: ${item.actualHash ?? '-'}'), + ); + }, + ), + ) + else + const Text('全てのハッシュが整合しています。'), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('閉じる')), + ], + ); + } +} + +class ShipmentEditorPage extends StatefulWidget { + const ShipmentEditorPage({super.key, required this.service, this.shipment}); + + final ShipmentService service; + final Shipment? shipment; + + @override + State createState() => _ShipmentEditorPageState(); +} + +class _ShipmentEditorPageState extends State { + final TextEditingController _orderIdController = TextEditingController(); + final TextEditingController _orderNumberController = TextEditingController(); + final TextEditingController _customerNameController = TextEditingController(); + final TextEditingController _carrierController = TextEditingController(); + final TextEditingController _trackingController = TextEditingController(); + final TextEditingController _trackingUrlController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + + DateTime? _scheduledDate; + DateTime? _actualDate; + bool _isSaving = false; + final List<_ShipmentLineFormData> _lines = []; + + @override + void initState() { + super.initState(); + final shipment = widget.shipment; + if (shipment != null) { + _orderIdController.text = shipment.orderId ?? ''; + _orderNumberController.text = shipment.orderNumberSnapshot ?? ''; + _customerNameController.text = shipment.customerNameSnapshot ?? ''; + _carrierController.text = shipment.carrierName ?? ''; + _trackingController.text = shipment.trackingNumber ?? ''; + _trackingUrlController.text = shipment.trackingUrl ?? ''; + _notesController.text = shipment.notes ?? ''; + _scheduledDate = shipment.scheduledShipDate; + _actualDate = shipment.actualShipDate; + for (final item in shipment.items) { + _lines.add(_ShipmentLineFormData(description: item.description, quantity: item.quantity)); + } + } + if (_lines.isEmpty) { + _lines.add(_ShipmentLineFormData()); + } + } + + @override + void dispose() { + _orderIdController.dispose(); + _orderNumberController.dispose(); + _customerNameController.dispose(); + _carrierController.dispose(); + _trackingController.dispose(); + _trackingUrlController.dispose(); + _notesController.dispose(); + for (final line in _lines) { + line.dispose(); + } + super.dispose(); + } + + Future _pickScheduledDate() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _scheduledDate ?? now, + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 2), + ); + if (picked != null) { + setState(() => _scheduledDate = picked); + } + } + + Future _pickActualDate() async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _actualDate ?? now, + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 2), + ); + if (picked != null) { + setState(() => _actualDate = picked); + } + } + + void _addLine() { + setState(() { + _lines.add(_ShipmentLineFormData()); + }); + } + + void _removeLine(int index) { + if (_lines.length == 1) return; + setState(() { + final line = _lines.removeAt(index); + line.dispose(); + }); + } + + Future _save() async { + final orderId = _orderIdController.text.trim().isEmpty ? null : _orderIdController.text.trim(); + final orderNumber = _orderNumberController.text.trim().isEmpty ? null : _orderNumberController.text.trim(); + final customerName = _customerNameController.text.trim().isEmpty ? null : _customerNameController.text.trim(); + + final inputs = []; + for (final line in _lines) { + final desc = line.descriptionController.text.trim(); + final qty = int.tryParse(line.quantityController.text) ?? 0; + if (desc.isEmpty || qty <= 0) continue; + inputs.add(ShipmentLineInput(description: desc, quantity: qty)); + } + if (inputs.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('出荷明細を1件以上入力してください'))); + return; + } + + setState(() => _isSaving = true); + try { + Shipment saved; + if (widget.shipment == null) { + saved = await widget.service.createShipment( + orderId: orderId, + orderNumberSnapshot: orderNumber, + customerNameSnapshot: customerName, + lines: inputs, + scheduledShipDate: _scheduledDate, + actualShipDate: _actualDate, + carrierName: _carrierController.text.trim().isEmpty ? null : _carrierController.text.trim(), + trackingNumber: _trackingController.text.trim().isEmpty ? null : _trackingController.text.trim(), + trackingUrl: _trackingUrlController.text.trim().isEmpty ? null : _trackingUrlController.text.trim(), + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + ); + } else { + saved = await widget.service.updateShipment( + widget.shipment!, + replacedLines: inputs, + scheduledShipDate: _scheduledDate, + actualShipDate: _actualDate, + carrierName: _carrierController.text.trim().isEmpty ? null : _carrierController.text.trim(), + trackingNumber: _trackingController.text.trim().isEmpty ? null : _trackingController.text.trim(), + trackingUrl: _trackingUrlController.text.trim().isEmpty ? null : _trackingUrlController.text.trim(), + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + ); + } + if (!mounted) return; + Navigator.pop(context, saved); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final title = widget.shipment == null ? '出荷指示の作成' : '出荷情報を編集'; + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: Text(title == '出荷指示の作成' ? 'S2:出荷指示作成' : 'S2:出荷情報編集'), + actions: [ + TextButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('保存'), + ), + ], + ), + body: SafeArea( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + TextField( + controller: _orderIdController, + decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _orderNumberController, + decoration: const InputDecoration(labelText: '受注番号スナップショット', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _customerNameController, + decoration: const InputDecoration(labelText: '顧客名スナップショット', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('予定出荷日'), + subtitle: Text(_scheduledDate != null ? _dateFormat.format(_scheduledDate!) : '未設定'), + trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickScheduledDate), + ), + ), + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('実績出荷日'), + subtitle: Text(_actualDate != null ? _dateFormat.format(_actualDate!) : '未設定'), + trailing: IconButton(icon: const Icon(Icons.calendar_month), onPressed: _pickActualDate), + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _carrierController, + decoration: const InputDecoration(labelText: '配送業者', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _trackingController, + decoration: const InputDecoration(labelText: '追跡番号', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _trackingUrlController, + decoration: const InputDecoration(labelText: '追跡URL', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), + ), + const SizedBox(height: 24), + const Text('出荷明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + for (int i = 0; i < _lines.length; i++) _buildLineCard(i), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _addLine, + icon: const Icon(Icons.add), + label: const Text('明細を追加'), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ); + } + + Widget _buildLineCard(int index) { + final line = _lines[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('明細 ${index + 1}', style: const TextStyle(fontWeight: FontWeight.bold)), + if (_lines.length > 1) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _removeLine(index), + ), + ], + ), + TextField( + controller: line.descriptionController, + decoration: const InputDecoration(labelText: '内容', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: line.quantityController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '数量', border: OutlineInputBorder()), + ), + ], + ), + ), + ); + } +} + +class _ShipmentLineFormData { + _ShipmentLineFormData({String description = '', int quantity = 1}) + : descriptionController = TextEditingController(text: description), + quantityController = TextEditingController(text: quantity.toString()); + + final TextEditingController descriptionController; + final TextEditingController quantityController; + + void dispose() { + descriptionController.dispose(); + quantityController.dispose(); + } +} + +class _ShipmentDetailSheet extends StatefulWidget { + const _ShipmentDetailSheet({required this.shipment, required this.service, required this.onEdit}); + + final Shipment shipment; + final ShipmentService service; + final void Function(Shipment shipment) onEdit; + + @override + State<_ShipmentDetailSheet> createState() => _ShipmentDetailSheetState(); +} + +class _ShipmentDetailSheetState extends State<_ShipmentDetailSheet> { + late Shipment _shipment; + bool _isProcessing = false; + bool _isGeneratingLabel = false; + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + final ShippingLabelService _labelService = ShippingLabelService(); + + @override + void initState() { + super.initState(); + _shipment = widget.shipment; + } + + Future _advance() async { + if (_isProcessing) return; + setState(() => _isProcessing = true); + try { + final updated = await widget.service.advanceStatus(_shipment.id); + if (!mounted) return; + setState(() => _shipment = updated); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('ステータス更新に失敗しました: $e'))); + } finally { + if (mounted) setState(() => _isProcessing = false); + } + } + + Future _transitionTo(ShipmentStatus status) async { + if (_isProcessing) return; + setState(() => _isProcessing = true); + try { + final updated = await widget.service.transitionStatus(_shipment.id, status, force: true); + if (!mounted) return; + setState(() => _shipment = updated); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('更新に失敗しました: $e'))); + } finally { + if (mounted) setState(() => _isProcessing = false); + } + } + + Future _generateLabel() async { + if (_isGeneratingLabel) return; + setState(() => _isGeneratingLabel = true); + try { + final path = await _labelService.generateLabel(_shipment); + if (path == null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('送り状PDFの生成に失敗しました'))); + return; + } + final updated = await widget.service.updateShipment(_shipment, labelPdfPath: path); + if (!mounted) return; + setState(() => _shipment = updated); + await OpenFilex.open(path); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('送り状PDF生成エラー: $e'))); + } finally { + if (mounted) setState(() => _isGeneratingLabel = false); + } + } + + Future _openLabel() async { + final path = _shipment.labelPdfPath; + if (path == null || path.isEmpty) { + await _generateLabel(); + return; + } + await OpenFilex.open(path); + } + + Future _openTracking() async { + final url = _shipment.trackingUrl; + if (url == null || url.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('追跡URLが未設定です'))); + return; + } + final uri = Uri.parse(url); + if (!await canLaunchUrl(uri)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('追跡URLを開けませんでした'))); + return; + } + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + String _formatDate(DateTime date) => _dateFormat.format(date); + + Color _statusColor(ShipmentStatus status) { + switch (status) { + case ShipmentStatus.pending: + return Colors.grey.shade500; + case ShipmentStatus.picking: + return Colors.orange; + case ShipmentStatus.ready: + return Colors.teal; + case ShipmentStatus.shipped: + return Colors.blue; + case ShipmentStatus.delivered: + return Colors.green; + case ShipmentStatus.cancelled: + return Colors.redAccent; + } + } + + @override + Widget build(BuildContext context) { + final nextStatuses = widget.service.nextStatuses(_shipment.status); + return DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.6, + maxChildSize: 0.95, + builder: (ctx, controller) { + return Material( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Container( + width: 48, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), + ), + ListTile( + title: Text('出荷指示 ${_shipment.id.substring(0, 6)}', style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${_shipment.customerNameSnapshot ?? '取引先未設定'}\n受注番号: ${_shipment.orderNumberSnapshot ?? '-'}'), + trailing: IconButton( + icon: const Icon(Icons.edit), + onPressed: () => widget.onEdit(_shipment), + ), + ), + Expanded( + child: ListView( + controller: controller, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: ShipmentStatus.values + .map( + (status) => Chip( + label: Text(status.displayName), + backgroundColor: status == _shipment.status ? _statusColor(status) : Colors.grey.shade200, + labelStyle: TextStyle( + color: status == _shipment.status ? Colors.white : Colors.black87, + fontWeight: status == _shipment.status ? FontWeight.bold : FontWeight.normal, + ), + ), + ) + .toList(), + ), + const SizedBox(height: 16), + _InfoRow(label: '予定出荷日', value: _shipment.scheduledShipDate != null ? _formatDate(_shipment.scheduledShipDate!) : '-'), + _InfoRow(label: '出荷日', value: _shipment.actualShipDate != null ? _formatDate(_shipment.actualShipDate!) : '-'), + if (_shipment.carrierName?.isNotEmpty == true) _InfoRow(label: '配送業者', value: _shipment.carrierName!), + if (_shipment.trackingNumber?.isNotEmpty == true) _InfoRow(label: '追跡番号', value: _shipment.trackingNumber!), + if (_shipment.trackingUrl?.isNotEmpty == true) + TextButton.icon( + onPressed: _openTracking, + icon: const Icon(Icons.link), + label: const Text('追跡サイトを開く'), + ), + if (_shipment.notes?.isNotEmpty == true) + Card( + margin: const EdgeInsets.only(top: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text(_shipment.notes!), + ), + ), + const SizedBox(height: 16), + const Text('出荷明細', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ..._shipment.items.map( + (item) => ListTile( + dense: true, + title: Text(item.description), + trailing: Text('数量 ${item.quantity}'), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _isGeneratingLabel ? null : _openLabel, + icon: const Icon(Icons.picture_as_pdf), + label: Text(_shipment.labelPdfPath == null ? '送り状PDFを生成' : '送り状PDFを開く'), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: nextStatuses.isEmpty || _isProcessing ? null : _advance, + icon: const Icon(Icons.local_shipping), + label: Text(nextStatuses.isEmpty ? '完了済み' : '${nextStatuses.first.displayName} へ進める'), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _shipment.status == ShipmentStatus.cancelled || _isProcessing + ? null + : () => _transitionTo(ShipmentStatus.cancelled), + child: const Text('キャンセルに変更'), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SalesOrdersScreenState extends State { + final SalesOrderService _service = SalesOrderService(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + bool _isLoading = false; + List _orders = []; + + @override + void initState() { + super.initState(); + _loadOrders(); + } + + Future _loadOrders() async { + setState(() => _isLoading = true); + final orders = await _service.fetchOrders(); + if (!mounted) return; + setState(() { + _orders = orders; + _isLoading = false; + }); + } + + Future _openOrderEditor({SalesOrder? order}) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => SalesOrderEditorPage(service: _service, order: order), + ), + ); + if (result != null) { + await _loadOrders(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(order == null ? '受注を登録しました' : '受注を更新しました')), + ); + } + } + + Future _openOrderDetails(SalesOrder order) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => _OrderDetailSheet( + order: order, + service: _service, + onEdit: (current) { + Navigator.of(ctx).pop(); + _openOrderEditor(order: current); + }, + ), + ); + if (mounted) { + await _loadOrders(); + } + } + + String _formatDate(DateTime date) => _dateFormat.format(date); + + String _formatCurrency(int amount) => _currencyFormat.format(amount); + + Color _statusColor(SalesOrderStatus status) { + switch (status) { + case SalesOrderStatus.draft: + return Colors.grey.shade500; + case SalesOrderStatus.confirmed: + return Colors.indigo; + case SalesOrderStatus.picking: + return Colors.orange; + case SalesOrderStatus.shipped: + return Colors.blue; + case SalesOrderStatus.closed: + return Colors.green; + case SalesOrderStatus.cancelled: + return Colors.redAccent; + } + } + + Widget _buildOrderTile(SalesOrder order) { + final subtitle = StringBuffer() + ..write(_formatDate(order.orderDate)) + ..write(' ・ ') + ..write(order.orderNumber ?? order.id.substring(0, 6)); + if (order.requestedShipDate != null) { + subtitle.writeln('\n希望出荷日: ${_formatDate(order.requestedShipDate!)}'); + } + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + onTap: () => _openOrderDetails(order), + title: Text(order.customerNameSnapshot ?? '取引先未設定', style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitle.toString()), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Chip( + label: Text(order.status.displayName, style: const TextStyle(color: Colors.white)), + backgroundColor: _statusColor(order.status), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + const SizedBox(height: 8), + Text( + _formatCurrency(order.totalAmount), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + Widget body; + if (_isLoading) { + body = const Center(child: CircularProgressIndicator()); + } else if (_orders.isEmpty) { + body = _EmptyState(onCreate: () => _openOrderEditor()); + } else { + body = RefreshIndicator( + onRefresh: _loadOrders, + child: ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 88), + itemCount: _orders.length, + itemBuilder: (context, index) => _buildOrderTile(_orders[index]), + ), + ); + } + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('S1:受注管理'), + actions: [ + IconButton( + tooltip: '最新の状態に更新', + onPressed: _isLoading ? null : _loadOrders, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: body, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openOrderEditor(), + icon: const Icon(Icons.add), + label: const Text('受注を登録'), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.onCreate}); + + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.assignment_add, size: 64, color: Colors.grey), + const SizedBox(height: 16), + const Text('受注がまだ登録されていません', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('プラスボタンから受注を登録し、進捗を管理できます。', textAlign: TextAlign.center), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add), + label: const Text('受注を登録'), + ), + ], + ), + ), + ); + } +} + +class SalesShipmentsScreen extends StatefulWidget { + const SalesShipmentsScreen({super.key}); + + @override + State createState() => _SalesShipmentsScreenState(); +} + +class _SalesShipmentsScreenState extends State { + final ShipmentService _service = ShipmentService(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + + bool _isLoading = false; + List _shipments = []; + + @override + void initState() { + super.initState(); + _loadShipments(); + } + + Future _loadShipments() async { + setState(() => _isLoading = true); + final shipments = await _service.fetchShipments(); + if (!mounted) return; + setState(() { + _shipments = shipments; + _isLoading = false; + }); + } + + Future _openShipmentEditor({Shipment? shipment}) async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => ShipmentEditorPage(service: _service, shipment: shipment), + ), + ); + if (result != null) { + await _loadShipments(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(shipment == null ? '出荷指示を登録しました' : '出荷情報を更新しました')), + ); + } + } + + Future _openShipmentDetails(Shipment shipment) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => _ShipmentDetailSheet( + shipment: shipment, + service: _service, + onEdit: (current) { + Navigator.of(ctx).pop(); + _openShipmentEditor(shipment: current); + }, + ), + ); + if (mounted) { + await _loadShipments(); + } + } + + Color _statusColor(ShipmentStatus status) { + switch (status) { + case ShipmentStatus.pending: + return Colors.grey.shade500; + case ShipmentStatus.picking: + return Colors.orange; + case ShipmentStatus.ready: + return Colors.teal; + case ShipmentStatus.shipped: + return Colors.blue; + case ShipmentStatus.delivered: + return Colors.green; + case ShipmentStatus.cancelled: + return Colors.redAccent; + } + } + + Widget _buildShipmentTile(Shipment shipment) { + final subtitle = StringBuffer() + ..write(shipment.orderNumberSnapshot ?? '未連携') + ..write(' ・ ') + ..write(shipment.customerNameSnapshot ?? '取引先未設定'); + if (shipment.scheduledShipDate != null) { + subtitle.write('\n予定日: ${_dateFormat.format(shipment.scheduledShipDate!)}'); + } + if (shipment.actualShipDate != null) { + subtitle.write('\n出荷日: ${_dateFormat.format(shipment.actualShipDate!)}'); + } + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + onTap: () => _openShipmentDetails(shipment), + title: Text('出荷指示 ${shipment.id.substring(0, 6)}', style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitle.toString()), + trailing: Chip( + label: Text(shipment.status.displayName, style: const TextStyle(color: Colors.white)), + backgroundColor: _statusColor(shipment.status), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + Widget body; + if (_isLoading) { + body = const Center(child: CircularProgressIndicator()); + } else if (_shipments.isEmpty) { + body = _EmptyState(onCreate: () => _openShipmentEditor()); + } else { + body = RefreshIndicator( + onRefresh: _loadShipments, + child: ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 88), + itemCount: _shipments.length, + itemBuilder: (context, index) => _buildShipmentTile(_shipments[index]), + ), + ); + } + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('S3:出荷管理'), + actions: [ + IconButton( + tooltip: '更新', + onPressed: _isLoading ? null : _loadShipments, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: body, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openShipmentEditor(), + icon: const Icon(Icons.local_shipping), + label: const Text('出荷指示'), + ), + ); + } +} + +class SalesInventoryScreen extends StatefulWidget { + const SalesInventoryScreen({super.key}); + + @override + State createState() => _SalesInventoryScreenState(); +} + +class _SalesInventoryScreenState extends State { + final InventoryService _service = InventoryService(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd HH:mm'); + + bool _isLoading = false; + List _summaries = []; + + @override + void initState() { + super.initState(); + _loadSummaries(); + } + + Future _loadSummaries() async { + setState(() => _isLoading = true); + final data = await _service.fetchSummaries(); + if (!mounted) return; + setState(() { + _summaries = data; + _isLoading = false; + }); + } + + Future _openDetail(InventorySummary summary) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => _InventoryDetailSheet( + summary: summary, + service: _service, + onUpdated: () { + Navigator.of(ctx).pop(); + _loadSummaries(); + }, + ), + ); + } + + Widget _buildTile(InventorySummary summary) { + final subtitle = []; + if (summary.category?.isNotEmpty == true) { + subtitle.add('カテゴリ: ${summary.category}'); + } + if (summary.lastMovementAt != null) { + subtitle.add('最終更新: ${_dateFormat.format(summary.lastMovementAt!)}'); + } + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + onTap: () => _openDetail(summary), + leading: const Icon(Icons.inventory_2_outlined), + title: Text(summary.productName, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: subtitle.isEmpty ? null : Text(subtitle.join('\n')), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('在庫', style: TextStyle(fontSize: 12, color: Colors.grey)), + Text('${summary.stockQuantity}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + Widget body; + if (_isLoading) { + body = const Center(child: CircularProgressIndicator()); + } else if (_summaries.isEmpty) { + body = _InventoryEmptyState(onNavigateToProducts: () => Navigator.of(context).pop()); + } else { + body = RefreshIndicator( + onRefresh: _loadSummaries, + child: ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 88), + itemCount: _summaries.length, + itemBuilder: (context, index) => _buildTile(_summaries[index]), + ), + ); + } + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('S4:在庫管理'), + actions: [ + IconButton( + tooltip: '更新', + onPressed: _isLoading ? null : _loadSummaries, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: body, + ); + } +} + +class _InventoryEmptyState extends StatelessWidget { + const _InventoryEmptyState({required this.onNavigateToProducts}); + + final VoidCallback onNavigateToProducts; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.inventory_2, size: 64, color: Colors.grey), + const SizedBox(height: 16), + const Text('商品マスターに在庫対象がありません', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('商品マスターで商品を登録すると在庫が表示されます。', textAlign: TextAlign.center), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: onNavigateToProducts, + icon: const Icon(Icons.open_in_new), + label: const Text('商品マスターへ移動'), + ), + ], + ), + ), + ); + } +} + +class _InventoryDetailSheet extends StatefulWidget { + const _InventoryDetailSheet({required this.summary, required this.service, required this.onUpdated}); + + final InventorySummary summary; + final InventoryService service; + final VoidCallback onUpdated; + + @override + State<_InventoryDetailSheet> createState() => _InventoryDetailSheetState(); +} + +class _InventoryDetailSheetState extends State<_InventoryDetailSheet> { + late InventorySummary _summary; + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd HH:mm'); + final NumberFormat _numberFormat = NumberFormat.decimalPattern('ja_JP'); + + bool _isLoadingMovements = true; + bool _isRecording = false; + List _movements = []; + + @override + void initState() { + super.initState(); + _summary = widget.summary; + _loadMovements(); + } + + Future _loadMovements() async { + setState(() => _isLoadingMovements = true); + final movements = await widget.service.fetchMovements(_summary.productId, limit: 100); + if (!mounted) return; + setState(() { + _movements = movements; + _isLoadingMovements = false; + }); + } + + Future _recordMovement() async { + final result = await _MovementFormDialog.show(context); + if (result == null) return; + setState(() => _isRecording = true); + try { + final updated = await widget.service.recordManualMovement( + productId: _summary.productId, + type: result.type, + quantity: result.quantity, + reference: result.reference, + notes: result.notes, + ); + if (!mounted) return; + setState(() => _summary = updated); + await _loadMovements(); + widget.onUpdated(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('在庫履歴を登録しました'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登録に失敗しました: $e'))); + } finally { + if (mounted) setState(() => _isRecording = false); + } + } + + Color _movementColor(InventoryMovementType type) { + switch (type) { + case InventoryMovementType.receipt: + return Colors.teal; + case InventoryMovementType.issue: + return Colors.redAccent; + case InventoryMovementType.adjustment: + return Colors.blueGrey; + } + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.6, + maxChildSize: 0.95, + builder: (ctx, controller) { + return Material( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + children: [ + Container( + width: 48, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), + ), + ListTile( + title: Text(_summary.productName, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(_summary.category?.isNotEmpty == true ? _summary.category! : 'カテゴリ未設定'), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('在庫数', style: TextStyle(fontSize: 12, color: Colors.grey)), + Text(_numberFormat.format(_summary.stockQuantity), style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Expanded( + child: Text( + _summary.lastMovementAt != null + ? '最終更新: ${_dateFormat.format(_summary.lastMovementAt!)}' + : '最終更新: -', + style: const TextStyle(color: Colors.grey), + ), + ), + FilledButton.icon( + onPressed: _isRecording ? null : _recordMovement, + icon: _isRecording + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.add), + label: const Text('入出庫を記録'), + ), + ], + ), + ), + const SizedBox(height: 12), + Expanded( + child: _isLoadingMovements + ? const Center(child: CircularProgressIndicator()) + : _movements.isEmpty + ? const Center(child: Text('入出庫履歴がまだありません')) + : ListView.builder( + controller: controller, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + itemCount: _movements.length, + itemBuilder: (context, index) { + final movement = _movements[index]; + final color = _movementColor(movement.type); + final delta = movement.quantityDelta; + final deltaSign = delta > 0 ? '+${movement.quantityDelta}' : movement.quantityDelta.toString(); + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: color.withValues(alpha: 0.15), + child: Icon( + movement.type == InventoryMovementType.receipt + ? Icons.call_received + : movement.type == InventoryMovementType.issue + ? Icons.call_made + : Icons.inventory_outlined, + color: color, + ), + ), + title: Text(movement.type.displayName), + subtitle: Text(_dateFormat.format(movement.createdAt)), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(deltaSign, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)), + if (movement.reference?.isNotEmpty == true) + Text( + movement.reference!, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _MovementFormResult { + _MovementFormResult({ + required this.type, + required this.quantity, + this.reference, + this.notes, + }); + + final InventoryMovementType type; + final int quantity; + final String? reference; + final String? notes; +} + +class _MovementFormDialog extends StatefulWidget { + const _MovementFormDialog(); + + static Future<_MovementFormResult?> show(BuildContext context) { + return showDialog<_MovementFormResult>( + context: context, + builder: (_) => const Dialog(child: _MovementFormDialog()), + ); + } + + @override + State<_MovementFormDialog> createState() => _MovementFormDialogState(); +} + +class _MovementFormDialogState extends State<_MovementFormDialog> { + final TextEditingController _quantityController = TextEditingController(text: '1'); + final TextEditingController _referenceController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + InventoryMovementType _type = InventoryMovementType.receipt; + + @override + void dispose() { + _quantityController.dispose(); + _referenceController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + void _submit() { + final raw = _quantityController.text.trim(); + int? parsed = int.tryParse(raw); + if (parsed == null || parsed == 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('数量を正しく入力してください'))); + return; + } + if (_type != InventoryMovementType.adjustment) { + parsed = parsed.abs(); + } + Navigator.of(context).pop( + _MovementFormResult( + type: _type, + quantity: parsed, + reference: _referenceController.text.trim().isEmpty ? null : _referenceController.text.trim(), + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('入出庫を記録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration(labelText: '区分', border: OutlineInputBorder()), + onChanged: (val) => setState(() => _type = val ?? InventoryMovementType.receipt), + items: InventoryMovementType.values + .map((type) => DropdownMenuItem(value: type, child: Text(type.displayName))) + .toList(), + ), + const SizedBox(height: 12), + TextField( + controller: _quantityController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: _type == InventoryMovementType.adjustment ? '数量差分 (マイナス可)' : '数量', + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _referenceController, + decoration: const InputDecoration(labelText: '参照 (任意)', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 2, + decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')), + const SizedBox(width: 12), + FilledButton(onPressed: _submit, child: const Text('登録')), + ], + ), + ], + ), + ); + } +} + +class SalesReceivablesScreen extends StatefulWidget { + const SalesReceivablesScreen({super.key}); + + @override + State createState() => _SalesReceivablesScreenState(); +} + +class _SalesReceivablesScreenState extends State { + final ReceivableService _service = ReceivableService(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + bool _includeSettled = false; + bool _isLoading = false; + bool _isVerifyingChain = false; + List _summaries = []; + + @override + void initState() { + super.initState(); + _loadSummaries(); + } + + Future _loadSummaries() async { + setState(() => _isLoading = true); + final list = await _service.fetchSummaries(includeSettled: _includeSettled); + if (!mounted) return; + setState(() { + _summaries = list; + _isLoading = false; + }); + } + + void _toggleSettled(bool value) { + setState(() => _includeSettled = value); + _loadSummaries(); + } + + Future _openDetail(ReceivableInvoiceSummary summary) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => _ReceivableDetailSheet( + summary: summary, + service: _service, + onUpdated: () async { + Navigator.of(ctx).pop(); + await _loadSummaries(); + }, + ), + ); + } + + Future _verifyHashChain() async { + setState(() => _isVerifyingChain = true); + try { + final result = await _service.verifyHashChain(); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => _HashChainVerificationDialog(result: result), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('検証に失敗しました: $e'))); + } finally { + if (mounted) setState(() => _isVerifyingChain = false); + } + } + + Color _statusColor(ReceivableInvoiceSummary summary) { + if (summary.isSettled) { + return Colors.green; + } + if (summary.isOverdue) { + return Colors.redAccent; + } + return Colors.orange; + } + + Widget _buildTile(ReceivableInvoiceSummary summary) { + final chipColor = _statusColor(summary); + final statusLabel = summary.isSettled + ? '入金済' + : summary.isOverdue + ? '期限超過' + : '入金待ち'; + final subtitle = [ + '請求日: ${_dateFormat.format(summary.invoiceDate)}', + '期日: ${_dateFormat.format(summary.dueDate)}', + ]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + onTap: () => _openDetail(summary), + title: Text(summary.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${summary.customerName}\n${subtitle.join(' / ')}'), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(_currencyFormat.format(summary.outstandingAmount), style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Chip( + label: Text(statusLabel, style: const TextStyle(color: Colors.white)), + backgroundColor: chipColor, + padding: EdgeInsets.zero, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + Widget body; + if (_isLoading) { + body = const Center(child: CircularProgressIndicator()); + } else if (_summaries.isEmpty) { + body = const _ReceivablesEmptyState(); + } else { + body = RefreshIndicator( + onRefresh: _loadSummaries, + child: ListView.builder( + padding: const EdgeInsets.only(top: 8, bottom: 88), + itemCount: _summaries.length, + itemBuilder: (context, index) => _buildTile(_summaries[index]), + ), + ); + } + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('S5:回収・入金管理'), + actions: [ + IconButton( + tooltip: 'HASHチェーンを検証', + onPressed: _isVerifyingChain ? null : _verifyHashChain, + icon: _isVerifyingChain + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.verified_outlined), + ), + IconButton(onPressed: _isLoading ? null : _loadSummaries, icon: const Icon(Icons.refresh)), + ], + ), + body: Column( + children: [ + SwitchListTile( + title: const Text('入金済みも表示'), + value: _includeSettled, + onChanged: _toggleSettled, + secondary: const Icon(Icons.filter_alt), + ), + const Divider(height: 1), + Expanded(child: body), + ], + ), + ); + } +} + +class _ReceivablesEmptyState extends StatelessWidget { + const _ReceivablesEmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.account_balance_wallet_outlined, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text('請求書データがありません', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 8), + Text('請求書を正式発行すると売掛リストに表示されます。', textAlign: TextAlign.center), + ], + ), + ), + ); + } +} + +class _ReceivableDetailSheet extends StatefulWidget { + const _ReceivableDetailSheet({required this.summary, required this.service, required this.onUpdated}); + + final ReceivableInvoiceSummary summary; + final ReceivableService service; + final VoidCallback onUpdated; + + @override + State<_ReceivableDetailSheet> createState() => _ReceivableDetailSheetState(); +} + +class _ReceivableDetailSheetState extends State<_ReceivableDetailSheet> { + late ReceivableInvoiceSummary _summary; + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + + bool _isLoading = true; + bool _isProcessing = false; + List _payments = []; + + @override + void initState() { + super.initState(); + _summary = widget.summary; + _refreshData(); + } + + Future _refreshData() async { + setState(() => _isLoading = true); + final latestSummary = await widget.service.findSummary(_summary.invoiceId); + final payments = await widget.service.fetchPayments(_summary.invoiceId); + if (!mounted) return; + setState(() { + _summary = latestSummary ?? _summary; + _payments = payments; + _isLoading = false; + }); + } + + Future _addPayment() async { + final result = await _PaymentFormDialog.show(context); + if (result == null) return; + setState(() => _isProcessing = true); + try { + await widget.service.addPayment( + invoiceId: _summary.invoiceId, + amount: result.amount, + paymentDate: result.paymentDate, + method: result.method, + notes: result.notes, + ); + await _refreshData(); + widget.onUpdated(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金を登録しました'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登録に失敗しました: $e'))); + } finally { + if (mounted) setState(() => _isProcessing = false); + } + } + + Future _deletePayment(ReceivablePayment payment) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('入金を削除'), + content: Text('${_currencyFormat.format(payment.amount)} を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))), + ], + ), + ); + if (confirmed != true) return; + setState(() => _isProcessing = true); + await widget.service.deletePayment(payment.id); + await _refreshData(); + widget.onUpdated(); + if (mounted) { + setState(() => _isProcessing = false); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金を削除しました'))); + } + } + + Color _progressColor() { + if (_summary.isSettled) return Colors.green; + if (_summary.isOverdue) return Colors.redAccent; + return Colors.orange; + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.6, + maxChildSize: 0.98, + builder: (ctx, controller) { + return Material( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + children: [ + Container( + width: 48, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), + ), + ListTile( + title: Text(_summary.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(_summary.customerName), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('残高 ${_currencyFormat.format(_summary.outstandingAmount)}'), + const SizedBox(height: 4), + Text('総額 ${_currencyFormat.format(_summary.totalAmount)}', style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('請求日: ${_dateFormat.format(_summary.invoiceDate)}'), + Text('期日: ${_dateFormat.format(_summary.dueDate)}'), + const SizedBox(height: 12), + LinearProgressIndicator( + value: _summary.collectionProgress, + minHeight: 8, + backgroundColor: Colors.grey.shade200, + color: _progressColor(), + ), + const SizedBox(height: 4), + Text('回収率 ${(100 * _summary.collectionProgress).toStringAsFixed(1)}%'), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: FilledButton.icon( + onPressed: _isProcessing ? null : _addPayment, + icon: _isProcessing + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.add), + label: const Text('入金を登録'), + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView.builder( + controller: controller, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + itemCount: _payments.length, + itemBuilder: (context, index) { + final payment = _payments[index]; + return Card( + child: ListTile( + title: Text(_currencyFormat.format(payment.amount), style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${_dateFormat.format(payment.paymentDate)} / ${payment.method.displayName}${payment.notes?.isNotEmpty == true ? '\n${payment.notes}' : ''}'), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + onPressed: _isProcessing ? null : () => _deletePayment(payment), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _PaymentFormResult { + const _PaymentFormResult({ + required this.amount, + required this.paymentDate, + required this.method, + this.notes, + }); + + final int amount; + final DateTime paymentDate; + final PaymentMethod method; + final String? notes; +} + +class _PaymentFormDialog extends StatefulWidget { + const _PaymentFormDialog(); + + static Future<_PaymentFormResult?> show(BuildContext context) { + return showDialog<_PaymentFormResult>( + context: context, + builder: (ctx) => const Dialog(child: _PaymentFormDialog()), + ); + } + + @override + State<_PaymentFormDialog> createState() => _PaymentFormDialogState(); +} + +class _PaymentFormDialogState extends State<_PaymentFormDialog> { + final TextEditingController _amountController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + DateTime _paymentDate = DateTime.now(); + PaymentMethod _method = PaymentMethod.bankTransfer; + + @override + void dispose() { + _amountController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _paymentDate, + firstDate: DateTime(DateTime.now().year - 2), + lastDate: DateTime(DateTime.now().year + 2), + ); + if (picked != null) { + setState(() => _paymentDate = picked); + } + } + + void _submit() { + final amount = int.tryParse(_amountController.text.trim()); + if (amount == null || amount <= 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('金額を入力してください'))); + return; + } + Navigator.of(context).pop( + _PaymentFormResult( + amount: amount, + paymentDate: _paymentDate, + method: _method, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final dateLabel = DateFormat('yyyy/MM/dd').format(_paymentDate); + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('入金を登録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + TextField( + controller: _amountController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('入金日'), + subtitle: Text(dateLabel), + trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickDate), + ), + DropdownButtonFormField( + initialValue: _method, + decoration: const InputDecoration(labelText: '入金方法', border: OutlineInputBorder()), + onChanged: (val) => setState(() => _method = val ?? PaymentMethod.bankTransfer), + items: PaymentMethod.values + .map((method) => DropdownMenuItem(value: method, child: Text(method.displayName))) + .toList(), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 2, + decoration: const InputDecoration(labelText: 'メモ (任意)', border: OutlineInputBorder()), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')), + const SizedBox(width: 12), + FilledButton(onPressed: _submit, child: const Text('登録')), + ], + ), + ], + ), + ); + } +} + +class SalesOrderEditorPage extends StatefulWidget { + const SalesOrderEditorPage({super.key, required this.service, this.order}); + + final SalesOrderService service; + final SalesOrder? order; + + @override + State createState() => _SalesOrderEditorPageState(); +} + +class _SalesOrderEditorPageState extends State { + final TextEditingController _notesController = TextEditingController(); + final TextEditingController _assigneeController = TextEditingController(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + + String? _customerId; + String? _customerName; + DateTime? _requestedShipDate; + bool _isSaving = false; + final List<_OrderLineFormData> _lines = []; + + @override + void initState() { + super.initState(); + final order = widget.order; + if (order != null) { + _customerId = order.customerId; + _customerName = order.customerNameSnapshot; + _requestedShipDate = order.requestedShipDate; + _notesController.text = order.notes ?? ''; + _assigneeController.text = order.assignedTo ?? ''; + for (final item in order.items) { + _lines.add( + _OrderLineFormData( + description: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + ), + ); + } + } + if (_lines.isEmpty) { + _lines.add(_OrderLineFormData()); + } + } + + @override + void dispose() { + _notesController.dispose(); + _assigneeController.dispose(); + for (final line in _lines) { + line.dispose(); + } + super.dispose(); + } + + Future _pickCustomer() async { + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => CustomerPickerModal( + onCustomerSelected: (customer) { + Navigator.pop(ctx, customer); + }, + ), + ); + if (selected != null) { + setState(() { + _customerId = selected.id; + _customerName = selected.formalName; + }); + } + } + + Future _pickDate() async { + final now = DateTime.now(); + final initial = _requestedShipDate ?? now; + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 2), + ); + if (picked != null) { + setState(() => _requestedShipDate = picked); + } + } + + void _addLine() { + setState(() { + _lines.add(_OrderLineFormData()); + }); + } + + void _removeLine(int index) { + if (_lines.length == 1) return; + setState(() { + final line = _lines.removeAt(index); + line.dispose(); + }); + } + + Future _save() async { + if (_customerId == null || _customerName == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('取引先を選択してください'))); + return; + } + + final inputs = []; + for (final line in _lines) { + final desc = line.descriptionController.text.trim(); + final qty = int.tryParse(line.quantityController.text) ?? 0; + final price = int.tryParse(line.unitPriceController.text) ?? 0; + if (desc.isEmpty || qty <= 0) continue; + inputs.add(SalesOrderLineInput(description: desc, quantity: qty, unitPrice: price)); + } + if (inputs.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('明細を1件以上入力してください'))); + return; + } + + setState(() => _isSaving = true); + try { + SalesOrder saved; + if (widget.order == null) { + saved = await widget.service.createOrder( + customerId: _customerId!, + customerName: _customerName!, + lines: inputs, + requestedShipDate: _requestedShipDate, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + assignedTo: _assigneeController.text.trim().isEmpty ? null : _assigneeController.text.trim(), + ); + } else { + saved = await widget.service.updateOrder( + widget.order!, + replacedLines: inputs, + requestedShipDate: _requestedShipDate, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + assignedTo: _assigneeController.text.trim().isEmpty ? null : _assigneeController.text.trim(), + ); + } + if (!mounted) return; + Navigator.pop(context, saved); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); + setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final title = widget.order == null ? '受注の新規登録' : '受注を編集'; + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: Text(title == '受注の新規登録' ? 'S6:受注登録' : 'S6:受注編集'), + actions: [ + TextButton( + onPressed: _isSaving ? null : _save, + child: _isSaving + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('保存'), + ), + ], + ), + body: SafeArea( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('取引先'), + subtitle: Text(_customerName ?? '未選択'), + trailing: OutlinedButton.icon( + onPressed: _pickCustomer, + icon: const Icon(Icons.search), + label: Text(_customerName == null ? '選択' : '変更'), + ), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('希望出荷日'), + subtitle: Text(_requestedShipDate != null ? _dateFormat.format(_requestedShipDate!) : '未設定'), + trailing: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _pickDate, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _assigneeController, + decoration: const InputDecoration(labelText: '担当者 (任意)', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ / 特記事項', border: OutlineInputBorder()), + ), + const SizedBox(height: 24), + const Text('受注明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + for (int i = 0; i < _lines.length; i++) _buildLineCard(i), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _addLine, + icon: const Icon(Icons.add), + label: const Text('明細を追加'), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ); + } + + Widget _buildLineCard(int index) { + final line = _lines[index]; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('明細 ${index + 1}', style: const TextStyle(fontWeight: FontWeight.bold)), + if (_lines.length > 1) + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => _removeLine(index), + ), + ], + ), + TextField( + controller: line.descriptionController, + decoration: const InputDecoration(labelText: '内容', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: line.quantityController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '数量', border: OutlineInputBorder()), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: line.unitPriceController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '単価 (円)', border: OutlineInputBorder()), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _OrderLineFormData { + _OrderLineFormData({String description = '', int quantity = 1, int unitPrice = 0}) + : descriptionController = TextEditingController(text: description), + quantityController = TextEditingController(text: quantity.toString()), + unitPriceController = TextEditingController(text: unitPrice.toString()); + + final TextEditingController descriptionController; + final TextEditingController quantityController; + final TextEditingController unitPriceController; + + void dispose() { + descriptionController.dispose(); + quantityController.dispose(); + unitPriceController.dispose(); + } +} + +class _OrderDetailSheet extends StatefulWidget { + const _OrderDetailSheet({required this.order, required this.service, required this.onEdit}); + + final SalesOrder order; + final SalesOrderService service; + final void Function(SalesOrder order) onEdit; + + @override + State<_OrderDetailSheet> createState() => _OrderDetailSheetState(); +} + +class _OrderDetailSheetState extends State<_OrderDetailSheet> { + late SalesOrder _order; + bool _isProcessing = false; + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + + @override + void initState() { + super.initState(); + _order = widget.order; + } + + Future _advance() async { + if (_isProcessing) return; + setState(() => _isProcessing = true); + try { + final updated = await widget.service.advanceStatus(_order.id); + if (!mounted) return; + setState(() => _order = updated); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('ステータス更新に失敗しました: $e'))); + } finally { + if (mounted) setState(() => _isProcessing = false); + } + } + + Future _cancelOrder() async { + if (_order.status == SalesOrderStatus.cancelled || _isProcessing) return; + setState(() => _isProcessing = true); + try { + final updated = await widget.service.transitionStatus(_order.id, SalesOrderStatus.cancelled, force: true); + if (!mounted) return; + setState(() => _order = updated); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('キャンセルに失敗しました: $e'))); + } finally { + if (mounted) setState(() => _isProcessing = false); + } + } + + String _formatDate(DateTime date) => _dateFormat.format(date); + + Color _statusColor(SalesOrderStatus status) { + switch (status) { + case SalesOrderStatus.draft: + return Colors.grey.shade500; + case SalesOrderStatus.confirmed: + return Colors.indigo; + case SalesOrderStatus.picking: + return Colors.orange; + case SalesOrderStatus.shipped: + return Colors.blue; + case SalesOrderStatus.closed: + return Colors.green; + case SalesOrderStatus.cancelled: + return Colors.redAccent; + } + } + + @override + Widget build(BuildContext context) { + final nextStatuses = widget.service.nextStatuses(_order.status); + return DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.6, + maxChildSize: 0.95, + builder: (ctx, controller) { + return Material( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Container( + width: 48, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration(color: Colors.grey.shade300, borderRadius: BorderRadius.circular(999)), + ), + ListTile( + title: Text(_order.customerNameSnapshot ?? '取引先未設定', style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('受注番号: ${_order.orderNumber ?? _order.id.substring(0, 6)}'), + trailing: IconButton( + icon: const Icon(Icons.edit), + onPressed: () => widget.onEdit(_order), + ), + ), + Expanded( + child: ListView( + controller: controller, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: SalesOrderStatus.values + .map( + (status) => Chip( + label: Text(status.displayName), + backgroundColor: status == _order.status ? _statusColor(status) : Colors.grey.shade200, + labelStyle: TextStyle( + color: status == _order.status ? Colors.white : Colors.black87, + fontWeight: status == _order.status ? FontWeight.bold : FontWeight.normal, + ), + ), + ) + .toList(), + ), + const SizedBox(height: 16), + _InfoRow(label: '受注日', value: _formatDate(_order.orderDate)), + if (_order.requestedShipDate != null) + _InfoRow(label: '希望出荷日', value: _formatDate(_order.requestedShipDate!)), + if (_order.assignedTo?.isNotEmpty == true) _InfoRow(label: '担当者', value: _order.assignedTo!), + if (_order.notes?.isNotEmpty == true) + Card( + margin: const EdgeInsets.only(top: 12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text(_order.notes!), + ), + ), + const SizedBox(height: 16), + const Text('明細', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + ..._order.items.map( + (item) => ListTile( + dense: true, + title: Text(item.description), + subtitle: Text('数量 ${item.quantity} / 単価 ${item.unitPrice} 円'), + trailing: Text('${item.lineTotal} 円'), + ), + ), + const Divider(height: 24), + _InfoRow(label: '小計', value: _currencyFormat.format(_order.subtotal)), + _InfoRow(label: '税額', value: _currencyFormat.format(_order.taxAmount)), + _InfoRow(label: '合計', value: _currencyFormat.format(_order.totalAmount), emphasized: true), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: nextStatuses.isEmpty || _isProcessing ? null : _advance, + icon: const Icon(Icons.playlist_add_check), + label: Text(nextStatuses.isEmpty ? '完了済み' : '${nextStatuses.first.displayName} へ進める'), + ), + const SizedBox(height: 8), + TextButton( + onPressed: + _order.status == SalesOrderStatus.cancelled || _isProcessing ? null : _cancelOrder, + child: const Text('キャンセルに変更'), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value, this.emphasized = false}); + + final String label; + final String value; + final bool emphasized; + + @override + Widget build(BuildContext context) { + final textStyle = emphasized + ? Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold) + : Theme.of(context).textTheme.bodyMedium; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(width: 16), + Expanded( + child: Text( + value, + style: textStyle, + textAlign: TextAlign.end, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/sales_receipts_screen.dart b/lib/screens/sales_receipts_screen.dart new file mode 100644 index 0000000..1b9a47d --- /dev/null +++ b/lib/screens/sales_receipts_screen.dart @@ -0,0 +1,762 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/customer_model.dart'; +import '../models/sales_entry_models.dart'; +import '../services/customer_repository.dart'; +import '../services/sales_entry_service.dart'; +import '../services/sales_receipt_service.dart'; +import 'customer_picker_modal.dart'; + +class SalesReceiptsScreen extends StatefulWidget { + const SalesReceiptsScreen({super.key}); + + @override + State createState() => _SalesReceiptsScreenState(); +} + +class _SalesReceiptsScreenState extends State { + final SalesReceiptService _receiptService = SalesReceiptService(); + final CustomerRepository _customerRepository = CustomerRepository(); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + + bool _isLoading = true; + bool _isRefreshing = false; + List _receipts = const []; + Map _receiptAllocations = const {}; + Map _customerNames = const {}; + DateTime? _startDate; + DateTime? _endDate; + + @override + void initState() { + super.initState(); + _loadReceipts(); + } + + Future _loadReceipts() async { + if (!_isRefreshing) { + setState(() => _isLoading = true); + } + try { + final receipts = await _receiptService.fetchReceipts(startDate: _startDate, endDate: _endDate); + final allocationMap = {}; + for (final receipt in receipts) { + final links = await _receiptService.fetchLinks(receipt.id); + allocationMap[receipt.id] = links.fold(0, (sum, link) => sum + link.allocatedAmount); + } + final customerIds = receipts.map((r) => r.customerId).whereType().toSet(); + final customerNames = Map.from(_customerNames); + for (final id in customerIds) { + if (customerNames.containsKey(id)) continue; + final customer = await _customerRepository.findById(id); + if (customer != null) { + customerNames[id] = customer.invoiceName; + } + } + if (!mounted) return; + setState(() { + _receipts = receipts; + _receiptAllocations = allocationMap; + _customerNames = customerNames; + _isLoading = false; + _isRefreshing = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _isRefreshing = false; + }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('入金データの取得に失敗しました: $e'))); + } + } + + Future _handleRefresh() async { + setState(() => _isRefreshing = true); + await _loadReceipts(); + } + + Future _pickDate({required bool isStart}) async { + final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now()); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() { + if (isStart) { + _startDate = picked; + } else { + _endDate = picked; + } + }); + _loadReceipts(); + } + + void _clearFilters() { + setState(() { + _startDate = null; + _endDate = null; + }); + _loadReceipts(); + } + + Future _openEditor({SalesReceipt? receipt}) async { + final updated = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => SalesReceiptEditorPage(receipt: receipt)), + ); + if (updated != null) { + await _loadReceipts(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金データを保存しました'))); + } + } + + Future _confirmDelete(SalesReceipt receipt) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('入金を削除'), + content: Text('${_dateFormat.format(receipt.paymentDate)}の¥${_currencyFormat.format(receipt.amount).replaceAll('¥¥', '')}を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')), + ], + ), + ); + if (confirmed != true) return; + try { + await _receiptService.deleteReceipt(receipt.id); + if (!mounted) return; + await _loadReceipts(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金を削除しました'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e'))); + } + } + + String _customerLabel(SalesReceipt receipt) { + if (receipt.customerId == null) { + return '取引先未設定'; + } + return _customerNames[receipt.customerId] ?? '顧客読み込み中'; + } + + @override + Widget build(BuildContext context) { + final filterLabel = [ + if (_startDate != null) '開始: ${_dateFormat.format(_startDate!)}', + if (_endDate != null) '終了: ${_dateFormat.format(_endDate!)}', + ].join(' / '); + + final body = _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _handleRefresh, + child: _receipts.isEmpty + ? ListView( + children: const [ + SizedBox(height: 140), + Icon(Icons.account_balance_wallet_outlined, size: 64, color: Colors.grey), + SizedBox(height: 12), + Center(child: Text('入金データがありません。右下のボタンから登録してください。')), + ], + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), + itemCount: _receipts.length, + itemBuilder: (context, index) => _buildReceiptCard(_receipts[index]), + ), + ); + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('U3:入金管理'), + actions: [ + IconButton( + tooltip: '開始日を選択', + icon: const Icon(Icons.calendar_today), + onPressed: () => _pickDate(isStart: true), + ), + IconButton( + tooltip: '終了日を選択', + icon: const Icon(Icons.event), + onPressed: () => _pickDate(isStart: false), + ), + IconButton( + tooltip: 'フィルターをクリア', + icon: const Icon(Icons.filter_alt_off), + onPressed: (_startDate == null && _endDate == null) ? null : _clearFilters, + ), + const SizedBox(width: 4), + ], + bottom: filterLabel.isEmpty + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(32), + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(filterLabel, style: const TextStyle(color: Colors.white70)), + ), + ), + ), + body: body, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openEditor(), + icon: const Icon(Icons.add), + label: const Text('入金を登録'), + ), + ); + } + + Widget _buildReceiptCard(SalesReceipt receipt) { + final allocated = _receiptAllocations[receipt.id] ?? 0; + final allocationRatio = receipt.amount == 0 ? 0.0 : allocated / receipt.amount; + final statusColor = allocationRatio >= 0.999 + ? Colors.green + : allocationRatio <= 0 + ? Colors.orange + : Colors.blue; + final customer = _customerLabel(receipt); + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + onTap: () => _openEditor(receipt: receipt), + title: Text( + _currencyFormat.format(receipt.amount), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(customer), + const SizedBox(height: 4), + Text('割当: ${_currencyFormat.format(allocated)} / ${_currencyFormat.format(receipt.amount)}'), + if (receipt.notes?.isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text(receipt.notes!, style: const TextStyle(fontSize: 12, color: Colors.black87)), + ], + ], + ), + ), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(_dateFormat.format(receipt.paymentDate)), + const SizedBox(height: 4), + Text(receipt.method ?? '未設定', style: const TextStyle(fontSize: 12, color: Colors.black54)), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration(color: statusColor.withAlpha(32), borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Text( + allocationRatio >= 0.999 + ? '全額割当済' + : allocationRatio <= 0 + ? '未割当' + : '一部割当', + style: TextStyle(color: statusColor, fontSize: 12), + ), + ), + ], + ), + isThreeLine: true, + contentPadding: const EdgeInsets.all(16), + tileColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + selectedColor: Theme.of(context).colorScheme.primary, + selectedTileColor: Theme.of(context).colorScheme.primaryContainer, + onLongPress: () => _confirmDelete(receipt), + ), + ); + } +} + +class SalesReceiptEditorPage extends StatefulWidget { + const SalesReceiptEditorPage({super.key, this.receipt}); + + final SalesReceipt? receipt; + + @override + State createState() => _SalesReceiptEditorPageState(); +} + +class _SalesReceiptEditorPageState extends State { + final SalesReceiptService _receiptService = SalesReceiptService(); + final SalesEntryService _entryService = SalesEntryService(); + final CustomerRepository _customerRepository = CustomerRepository(); + final TextEditingController _amountController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + DateTime _paymentDate = DateTime.now(); + String? _customerId; + String? _customerName; + String? _method = '銀行振込'; + bool _isSaving = false; + bool _isInitializing = true; + + List<_AllocationRow> _allocations = []; + List _entries = []; + Map _baseAllocated = {}; + + @override + void initState() { + super.initState(); + final receipt = widget.receipt; + if (receipt != null) { + _paymentDate = receipt.paymentDate; + _amountController.text = receipt.amount.toString(); + _notesController.text = receipt.notes ?? ''; + _method = receipt.method ?? '銀行振込'; + _customerId = receipt.customerId; + if (_customerId != null) { + _loadCustomerName(_customerId!); + } + } else { + _amountController.text = ''; + } + _amountController.addListener(() => setState(() {})); + _loadData(); + } + + @override + void dispose() { + _amountController.dispose(); + _notesController.dispose(); + for (final row in _allocations) { + row.dispose(); + } + super.dispose(); + } + + Future _loadCustomerName(String customerId) async { + final customer = await _customerRepository.findById(customerId); + if (!mounted) return; + setState(() => _customerName = customer?.invoiceName ?? ''); + } + + Future _loadData() async { + try { + final entries = await _entryService.fetchEntries(); + final totals = await _receiptService.fetchAllocatedTotals(entries.map((e) => e.id)); + final allocationRows = <_AllocationRow>[]; + if (widget.receipt != null) { + final links = await _receiptService.fetchLinks(widget.receipt!.id); + for (final link in links) { + final current = totals[link.salesEntryId] ?? 0; + totals[link.salesEntryId] = current - link.allocatedAmount; + var entry = _findEntryById(link.salesEntryId, entries); + entry ??= await _entryService.findById(link.salesEntryId); + if (entry != null && entries.every((e) => e.id != entry!.id)) { + entries.add(entry); + } + if (entry != null) { + allocationRows.add(_AllocationRow(entry: entry, amount: link.allocatedAmount)); + } + } + } + if (!mounted) return; + setState(() { + _entries = entries; + _baseAllocated = totals; + _allocations = allocationRows; + _isInitializing = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _isInitializing = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('入金フォームの読み込みに失敗しました: $e'))); + } + } + + SalesEntry? _findEntryById(String id, List entries) { + for (final entry in entries) { + if (entry.id == id) return entry; + } + return null; + } + + Future _pickCustomer() async { + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => CustomerPickerModal( + onCustomerSelected: (customer) { + Navigator.pop(ctx, customer); + }, + ), + ); + if (selected == null) return; + setState(() { + _customerId = selected.id; + _customerName = selected.invoiceName; + }); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _paymentDate, + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _paymentDate = picked); + } + } + + Future _addAllocation() async { + if (_entries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当対象となる売上伝票がありません'))); + return; + } + final entry = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _SalesEntryPickerSheet( + entries: _entries, + dateFormat: _dateFormat, + currencyFormat: _currencyFormat, + getOutstanding: _availableForEntry, + ), + ); + if (!mounted) return; + if (entry == null) return; + final maxForEntry = _availableForEntry(entry); + if (maxForEntry <= 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('選択した売上伝票には割当余力がありません'))); + return; + } + final receiptAmount = _receiptAmount; + final remainingReceipt = receiptAmount > 0 ? receiptAmount - _sumAllocations : maxForEntry; + final initial = remainingReceipt > 0 ? remainingReceipt.clamp(0, maxForEntry).toInt() : maxForEntry; + setState(() { + _allocations.add(_AllocationRow(entry: entry, amount: initial)); + }); + } + + int get _receiptAmount => int.tryParse(_amountController.text) ?? 0; + + int get _sumAllocations => _allocations.fold(0, (sum, row) => sum + row.amount); + + int _availableForEntry(SalesEntry entry, [_AllocationRow? excluding]) { + final base = _baseAllocated[entry.id] ?? 0; + final others = _allocations.where((row) => row.entry.id == entry.id && row != excluding).fold(0, (sum, row) => sum + row.amount); + return entry.amountTaxIncl - base - others; + } + + int _maxForRow(_AllocationRow row) { + return _availableForEntry(row.entry, row) + row.amount; + } + + void _handleAllocationChanged(_AllocationRow row) { + final value = row.amount; + final max = _maxForRow(row); + if (value > max) { + row.setAmount(max); + } else if (value < 0) { + row.setAmount(0); + } + setState(() {}); + } + + void _removeAllocation(_AllocationRow row) { + setState(() { + _allocations.remove(row); + row.dispose(); + }); + } + + Future _save() async { + if (_isSaving) return; + final amount = _receiptAmount; + if (amount <= 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('入金額を入力してください'))); + return; + } + final totalAlloc = _sumAllocations; + if (totalAlloc > amount) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当総額が入金額を超えています'))); + return; + } + setState(() => _isSaving = true); + try { + SalesReceipt saved; + final allocations = _allocations + .where((row) => row.amount > 0) + .map((row) => SalesReceiptAllocationInput(salesEntryId: row.entry.id, amount: row.amount)) + .toList(); + if (widget.receipt == null) { + saved = await _receiptService.createReceipt( + customerId: _customerId, + paymentDate: _paymentDate, + amount: amount, + method: _method, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + allocations: allocations, + ); + } else { + final updated = widget.receipt!.copyWith( + customerId: _customerId, + paymentDate: _paymentDate, + amount: amount, + method: _method, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + ); + saved = await _receiptService.updateReceipt(receipt: updated, allocations: allocations); + } + if (!mounted) return; + Navigator.pop(context, saved); + } catch (e) { + if (!mounted) return; + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); + } + } + + @override + Widget build(BuildContext context) { + final title = widget.receipt == null ? '入金を登録' : '入金を編集'; + final receiptAmount = _receiptAmount; + final allocSum = _sumAllocations; + final remaining = (receiptAmount - allocSum).clamp(-999999999, 999999999).toInt(); + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: Text(title == '入金を登録' ? 'U4:入金登録' : 'U4:入金編集'), + actions: [ + TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')), + ], + ), + body: _isInitializing + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 24), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _amountController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '入金額 (円)'), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('入金日'), + subtitle: Text(_dateFormat.format(_paymentDate)), + trailing: TextButton(onPressed: _pickDate, child: const Text('変更')), + ), + const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_customerName ?? '取引先を選択'), + trailing: const Icon(Icons.chevron_right), + onTap: _pickCustomer, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _method, + decoration: const InputDecoration(labelText: '入金方法'), + items: const [ + DropdownMenuItem(value: '銀行振込', child: Text('銀行振込')), + DropdownMenuItem(value: '現金', child: Text('現金')), + DropdownMenuItem(value: '振替', child: Text('口座振替')), + DropdownMenuItem(value: 'カード', child: Text('カード決済')), + DropdownMenuItem(value: 'その他', child: Text('その他')), + ], + onChanged: (val) => setState(() => _method = val), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ (任意)'), + ), + const Divider(height: 32), + Row( + children: [ + Text('割当: ${_currencyFormat.format(allocSum)} / ${_currencyFormat.format(receiptAmount)}'), + const Spacer(), + Text( + remaining >= 0 ? '残り: ${_currencyFormat.format(remaining)}' : '超過: ${_currencyFormat.format(remaining.abs())}', + style: TextStyle(color: remaining >= 0 ? Colors.black87 : Colors.red), + ), + ], + ), + const SizedBox(height: 8), + for (final row in _allocations) + Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(row.entry.subject?.isNotEmpty == true ? row.entry.subject! : '売上伝票', + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('${_dateFormat.format(row.entry.issueDate)} / ${_currencyFormat.format(row.entry.amountTaxIncl)}'), + ], + ), + ), + IconButton(onPressed: () => _removeAllocation(row), icon: const Icon(Icons.delete_outline)), + ], + ), + const SizedBox(height: 8), + TextField( + controller: row.controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: '割当額', + helperText: '残余 ${_currencyFormat.format((_maxForRow(row) - row.amount).clamp(0, double.infinity))}', + ), + onChanged: (_) => _handleAllocationChanged(row), + ), + ], + ), + ), + ), + TextButton.icon( + onPressed: _addAllocation, + icon: const Icon(Icons.playlist_add), + label: const Text('売上伝票を割当'), + ), + ], + ), + ), + ), + ); + } +} + +class _AllocationRow { + _AllocationRow({required this.entry, int amount = 0}) : controller = TextEditingController(text: amount > 0 ? amount.toString() : '') { + _amount = amount; + } + + final SalesEntry entry; + final TextEditingController controller; + int _amount = 0; + + int get amount { + final parsed = int.tryParse(controller.text.replaceAll(',', '')); + _amount = parsed ?? 0; + return _amount; + } + + void setAmount(int value) { + _amount = value; + controller + ..text = value.toString() + ..selection = TextSelection.collapsed(offset: controller.text.length); + } + + void dispose() { + controller.dispose(); + } +} + +class _SalesEntryPickerSheet extends StatefulWidget { + const _SalesEntryPickerSheet({required this.entries, required this.dateFormat, required this.currencyFormat, required this.getOutstanding}); + + final List entries; + final DateFormat dateFormat; + final NumberFormat currencyFormat; + final int Function(SalesEntry entry) getOutstanding; + + @override + State<_SalesEntryPickerSheet> createState() => _SalesEntryPickerSheetState(); +} + +class _SalesEntryPickerSheetState extends State<_SalesEntryPickerSheet> { + final TextEditingController _keywordController = TextEditingController(); + String _keyword = ''; + + @override + void dispose() { + _keywordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final filtered = widget.entries.where((entry) { + if (_keyword.isEmpty) return true; + final subject = entry.subject ?? ''; + final customer = entry.customerNameSnapshot ?? ''; + return subject.contains(_keyword) || customer.contains(_keyword); + }).toList(); + + return DraggableScrollableSheet( + initialChildSize: 0.85, + expand: false, + builder: (_, controller) => Material( + color: Theme.of(context).scaffoldBackgroundColor, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: Column( + children: [ + Row( + children: [ + const Expanded(child: Text('売上伝票を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))), + IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), + ], + ), + TextField( + controller: _keywordController, + decoration: InputDecoration( + labelText: 'キーワード (件名/顧客)', + suffixIcon: IconButton(icon: const Icon(Icons.search), onPressed: () => setState(() => _keyword = _keywordController.text.trim())), + ), + onSubmitted: (_) => setState(() => _keyword = _keywordController.text.trim()), + ), + const SizedBox(height: 12), + Expanded( + child: filtered.isEmpty + ? const Center(child: Text('該当する売上伝票がありません')) + : ListView.builder( + controller: controller, + itemCount: filtered.length, + itemBuilder: (context, index) { + final entry = filtered[index]; + final outstanding = widget.getOutstanding(entry); + final disabled = outstanding <= 0; + return ListTile( + enabled: !disabled, + title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '売上伝票'), + subtitle: Text( + '${entry.customerNameSnapshot ?? '取引先未設定'}\n${widget.dateFormat.format(entry.issueDate)} / ${widget.currencyFormat.format(entry.amountTaxIncl)}\n残: ${widget.currencyFormat.format(outstanding)}', + ), + onTap: disabled ? null : () => Navigator.pop(context, entry), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/sales_report_screen.dart b/lib/screens/sales_report_screen.dart index 76a65b5..3e6c197 100644 --- a/lib/screens/sales_report_screen.dart +++ b/lib/screens/sales_report_screen.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; + +import '../models/invoice_models.dart'; +import '../models/sales_summary.dart'; import '../services/invoice_repository.dart'; +import '../widgets/analytics/analytics_summary_card.dart'; +import '../widgets/analytics/empty_state_card.dart'; class SalesReportScreen extends StatefulWidget { const SalesReportScreen({super.key}); @@ -12,8 +17,9 @@ class SalesReportScreen extends StatefulWidget { class _SalesReportScreenState extends State { final _invoiceRepo = InvoiceRepository(); int _targetYear = DateTime.now().year; - Map _monthlySales = {}; - int _yearlyTotal = 0; + DocumentType? _selectedType; + bool _includeDrafts = false; + SalesSummary? _summary; bool _isLoading = true; @override @@ -24,44 +30,60 @@ class _SalesReportScreenState extends State { Future _loadData() async { setState(() => _isLoading = true); - final monthly = await _invoiceRepo.getMonthlySales(_targetYear); - final yearly = await _invoiceRepo.getYearlyTotal(_targetYear); + final summary = await _invoiceRepo.fetchSalesSummary( + year: _targetYear, + documentType: _selectedType, + includeDrafts: _includeDrafts, + topCustomerLimit: 5, + ); setState(() { - _monthlySales = monthly; - _yearlyTotal = yearly; + _summary = summary; _isLoading = false; }); } @override Widget build(BuildContext context) { - final fmt = NumberFormat("#,###"); - return Scaffold( appBar: AppBar( - title: const Text("売上・資金管理レポート"), + leading: const BackButton(), + title: const Text("R1:売上・資金レポート"), backgroundColor: Colors.indigo, foregroundColor: Colors.white, ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : Column( - children: [ - _buildYearSelector(), - _buildYearlySummary(fmt), - const Divider(height: 1), - Expanded(child: _buildMonthlyList(fmt)), - ], - ), + body: RefreshIndicator( + onRefresh: _loadData, + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _summary == null + ? const Center(child: Text('データを取得できませんでした')) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildYearSelector(), + const SizedBox(height: 12), + _buildFilterRow(), + const SizedBox(height: 16), + _buildSummaryCards(), + const SizedBox(height: 16), + _buildTopCustomers(), + const SizedBox(height: 16), + _buildMonthlyList(), + ], + ), + ), ); } Widget _buildYearSelector() { return Container( - color: Colors.indigo.shade50, - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: Colors.indigo.shade50, + borderRadius: BorderRadius.circular(16), + ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( icon: const Icon(Icons.chevron_left), @@ -86,51 +108,188 @@ class _SalesReportScreenState extends State { ); } - Widget _buildYearlySummary(NumberFormat fmt) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.indigo.shade900, + Widget _buildFilterRow() { + final chips = []; + chips.add(_buildFilterChip(label: '全て', isActive: _selectedType == null, onTap: () { + setState(() => _selectedType = null); + _loadData(); + })); + for (final type in DocumentType.values) { + chips.add(_buildFilterChip(label: type.displayName, isActive: _selectedType == type, onTap: () { + setState(() => _selectedType = type); + _loadData(); + })); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: chips, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text('下書きを含める'), + Switch( + value: _includeDrafts, + onChanged: (value) { + setState(() => _includeDrafts = value); + _loadData(); + }, + ), + ], + ), + ], + ); + } + + Widget _buildSummaryCards() { + final summary = _summary!; + final fmt = NumberFormat('#,###'); + final cards = [ + AnalyticsSummaryCard( + title: '年間売上合計', + value: '¥${fmt.format(summary.yearlyTotal)}', + subtitle: summary.documentType == null ? '全ドキュメント種別' : summary.documentType!.displayName, + icon: Icons.ssid_chart, + color: Colors.indigo, ), + AnalyticsSummaryCard( + title: '最高月', + value: summary.bestMonth == 0 ? '-' : '${summary.bestMonth}月', + subtitle: summary.bestMonthTotal > 0 ? '¥${fmt.format(summary.bestMonthTotal)}' : 'データなし', + icon: Icons.emoji_events, + color: Colors.orange, + ), + AnalyticsSummaryCard( + title: '平均月額', + value: '¥${fmt.format(summary.averageMonthly.round())}', + subtitle: '12ヶ月換算', + icon: Icons.stacked_line_chart, + color: Colors.teal, + ), + ]; + + return Column( + children: [ + cards[0], + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: cards[1]), + const SizedBox(width: 12), + Expanded(child: cards[2]), + ], + ), + ], + ); + } + + Widget _buildTopCustomers() { + final summary = _summary!; + if (summary.customerStats.isEmpty) { + return const EmptyStateCard(message: '確定済みの売上データがありません', icon: Icons.person_off); + } + + final total = summary.customerStats.fold(0, (sum, stat) => sum + stat.totalAmount); + final fmt = NumberFormat('#,###'); + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('トップ顧客', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 12), + ...summary.customerStats.map((stat) { + final ratio = total == 0 ? 0.0 : stat.totalAmount / total; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(stat.customerName, style: const TextStyle(fontWeight: FontWeight.w600))), + Text('¥${fmt.format(stat.totalAmount)}'), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: ratio, + minHeight: 8, + color: Colors.indigo, + backgroundColor: Colors.indigo.withValues(alpha: 0.15), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ); + } + + Widget _buildMonthlyList() { + final summary = _summary!; + final fmt = NumberFormat('#,###'); + final months = List.generate(12, (index) => index + 1); + if (summary.monthlyTotals.values.every((value) => value == 0)) { + return const EmptyStateCard(message: 'この年度の売上データがありません'); + } + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("年間売上合計 (請求確定分)", style: TextStyle(color: Colors.white70)), - const SizedBox(height: 8), - Text( - "¥${fmt.format(_yearlyTotal)}", - style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold), + const Padding( + padding: EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Text('月別サマリー', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), ), + const Divider(height: 24), + ...months.map((month) { + final amount = summary.monthlyTotals[month] ?? 0; + final share = summary.yearlyTotal == 0 ? 0.0 : amount / summary.yearlyTotal; + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.indigo.withValues(alpha: 0.1), + foregroundColor: Colors.indigo, + child: Text(month.toString()), + ), + title: Text('$month月の売上'), + subtitle: amount > 0 ? Text('シェア ${(share * 100).toStringAsFixed(1)}%') : const Text('データなし'), + trailing: Text( + '¥${fmt.format(amount)}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: amount > 0 ? Colors.black87 : Colors.grey, + ), + ), + ); + }), ], ), ); } - Widget _buildMonthlyList(NumberFormat fmt) { - return ListView.builder( - itemCount: 12, - itemBuilder: (context, index) { - final month = (index + 1).toString().padLeft(2, '0'); - final amount = _monthlySales[month] ?? 0; - final percentage = _yearlyTotal > 0 ? (amount / _yearlyTotal * 100).toStringAsFixed(1) : "0.0"; - - return ListTile( - leading: CircleAvatar( - backgroundColor: Colors.blueGrey.shade100, - child: Text("${index + 1}", style: const TextStyle(color: Colors.indigo)), - ), - title: Text("${index + 1}月の売上"), - subtitle: amount > 0 ? Text("シェア: $percentage%") : null, - trailing: Text( - "¥${fmt.format(amount)}", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: amount > 0 ? Colors.black87 : Colors.grey, - ), - ), - ); - }, + Widget _buildFilterChip({required String label, required bool isActive, required VoidCallback onTap}) { + return ChoiceChip( + label: Text(label), + selected: isActive, + onSelected: (_) => onTap(), + selectedColor: Colors.indigo, + labelStyle: TextStyle(color: isActive ? Colors.white : Colors.black87), ); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index dabf63f..f1bf9a7 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,14 +1,19 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:googleapis/calendar/v3.dart' as gcal; import 'package:image_picker/image_picker.dart'; import '../services/app_settings_repository.dart'; +import '../services/calendar_sync_diagnostics.dart'; +import '../services/calendar_sync_service.dart'; import '../services/theme_controller.dart'; import 'company_info_screen.dart'; import 'email_settings_screen.dart'; import 'business_profile_screen.dart'; import 'chat_screen.dart'; +import 'dashboard_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -17,6 +22,14 @@ class SettingsScreen extends StatefulWidget { State createState() => _SettingsScreenState(); } +class _CalendarOption { + const _CalendarOption({required this.id, required this.summary, this.detail}); + + final String id; + final String summary; + final String? detail; +} + // シンプルなアイコンマップ(拡張可) const Map kIconsMap = { 'list_alt': Icons.list_alt, @@ -33,6 +46,8 @@ const Map kIconsMap = { class _SettingsScreenState extends State { final _appSettingsRepo = AppSettingsRepository(); + final _calendarSyncService = CalendarSyncService(); + final _calendarDiagnostics = CalendarSyncDiagnostics(); // External sync (母艦システム「お局様」連携) final _externalHostCtrl = TextEditingController(); @@ -54,6 +69,23 @@ class _SettingsScreenState extends State { final _statusTextCtrl = TextEditingController(text: '工事中'); List _menuItems = []; bool _loadingAppSettings = true; + bool _forceDashboardOnExit = false; + + // Google Calendar + bool _calendarEnabled = false; + bool _calendarBusy = false; + bool _loadingCalendars = false; + bool _calendarSyncing = false; + String? _googleAccountEmail; + String? _selectedCalendarId; + List<_CalendarOption> _availableCalendars = []; + String? _lastCalendarSyncStatus; + bool get _supportsCalendarSync => !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + // Gross profit / sales entry options + bool _grossProfitEnabled = true; + bool _grossProfitToggleVisible = true; + bool _grossProfitIncludeProvisional = false; static const _kExternalHost = 'external_host'; static const _kExternalPass = 'external_pass'; @@ -98,6 +130,9 @@ class _SettingsScreenState extends State { _menuItems = menu; _loadingAppSettings = false; }); + + await _loadCalendarSettings(); + await _loadGrossProfitSettings(); } @override @@ -116,12 +151,29 @@ class _SettingsScreenState extends State { await _appSettingsRepo.setDashboardStatusText(_statusTextCtrl.text.trim().isEmpty ? '工事中' : _statusTextCtrl.text.trim()); await _appSettingsRepo.setDashboardMenu(_menuItems); _showSnackbar('ホーム/ダッシュボード設定を保存しました'); + setState(() { + _forceDashboardOnExit = _homeDashboard; + }); } Future _persistMenu() async { await _appSettingsRepo.setDashboardMenu(_menuItems); } + void _handleExit() { + if (!mounted) return; + if (_forceDashboardOnExit) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const DashboardScreen()), + (route) => false, + ); + } else { + if (Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + void _addMenuItem() async { final titleCtrl = TextEditingController(); String route = 'invoice_history'; @@ -129,72 +181,78 @@ class _SettingsScreenState extends State { String? customIconPath; await showDialog( context: context, - builder: (ctx) => AlertDialog( - title: const Text('メニューを追加'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: 'タイトル')), - DropdownButtonFormField( - initialValue: route, - decoration: const InputDecoration(labelText: '遷移先'), - items: const [ - DropdownMenuItem(value: 'invoice_history', child: Text('A2:伝票一覧')), - DropdownMenuItem(value: 'invoice_input', child: Text('A1:伝票入力')), - DropdownMenuItem(value: 'customer_master', child: Text('C1:顧客マスター')), - DropdownMenuItem(value: 'product_master', child: Text('P1:商品マスター')), - DropdownMenuItem(value: 'master_hub', child: Text('M1:マスター管理')), - DropdownMenuItem(value: 'settings', child: Text('S1:設定')), - ], - onChanged: (v) => route = v ?? 'invoice_history', - ), - TextField(controller: iconCtrl, decoration: const InputDecoration(labelText: 'Materialアイコン名 (例: list_alt)')), - const SizedBox(height: 8), - Row( - children: [ - Expanded(child: Text(customIconPath ?? 'カスタムアイコン: 未選択', style: const TextStyle(fontSize: 12))), - IconButton( - icon: const Icon(Icons.image_search), - tooltip: 'ギャラリーから選択', - onPressed: () async { - final picker = ImagePicker(); - final picked = await picker.pickImage(source: ImageSource.gallery); - if (picked != null) { - setState(() { - customIconPath = picked.path; - }); - } - }, - ), - ], - ), - ], + builder: (ctx) => AnimatedPadding( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom), + child: AlertDialog( + title: const Text('メニューを追加'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: 'タイトル')), + DropdownButtonFormField( + initialValue: route, + decoration: const InputDecoration(labelText: '遷移先'), + items: const [ + DropdownMenuItem(value: 'invoice_history', child: Text('A2:伝票一覧')), + DropdownMenuItem(value: 'invoice_input', child: Text('A1:伝票入力')), + DropdownMenuItem(value: 'customer_master', child: Text('C1:顧客マスター')), + DropdownMenuItem(value: 'product_master', child: Text('P1:商品マスター')), + DropdownMenuItem(value: 'master_hub', child: Text('M1:マスター管理')), + DropdownMenuItem(value: 'sales_operations', child: Text('B1:販売オペレーション')), + DropdownMenuItem(value: 'settings', child: Text('S1:設定')), + ], + onChanged: (v) => route = v ?? 'invoice_history', + ), + TextField(controller: iconCtrl, decoration: const InputDecoration(labelText: 'Materialアイコン名 (例: list_alt)')), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: Text(customIconPath ?? 'カスタムアイコン: 未選択', style: const TextStyle(fontSize: 12))), + IconButton( + icon: const Icon(Icons.image_search), + tooltip: 'ギャラリーから選択', + onPressed: () async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery); + if (picked != null) { + setState(() { + customIconPath = picked.path; + }); + } + }, + ), + ], + ), + ], + ), ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), + ElevatedButton( + onPressed: () { + if (titleCtrl.text.trim().isEmpty) return; + setState(() { + _menuItems = [ + ..._menuItems, + DashboardMenuItem( + id: DateTime.now().millisecondsSinceEpoch.toString(), + title: titleCtrl.text.trim(), + route: route, + iconName: iconCtrl.text.trim().isEmpty ? 'list_alt' : iconCtrl.text.trim(), + customIconPath: customIconPath, + ), + ]; + }); + _persistMenu(); + Navigator.pop(ctx); + }, + child: const Text('追加'), + ), + ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')), - ElevatedButton( - onPressed: () { - if (titleCtrl.text.trim().isEmpty) return; - setState(() { - _menuItems = [ - ..._menuItems, - DashboardMenuItem( - id: DateTime.now().millisecondsSinceEpoch.toString(), - title: titleCtrl.text.trim(), - route: route, - iconName: iconCtrl.text.trim().isEmpty ? 'list_alt' : iconCtrl.text.trim(), - customIconPath: customIconPath, - ), - ]; - }); - _persistMenu(); - Navigator.pop(ctx); - }, - child: const Text('追加'), - ), - ], ), ); } @@ -227,6 +285,8 @@ class _SettingsScreenState extends State { return 'P1:商品マスター'; case 'master_hub': return 'M1:マスター管理'; + case 'sales_operations': + return 'B1:販売オペレーション'; case 'settings': return 'S1:設定'; default: @@ -258,6 +318,21 @@ class _SettingsScreenState extends State { void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装'); + Future _setGrossProfitEnabled(bool value) async { + setState(() => _grossProfitEnabled = value); + await _appSettingsRepo.setGrossProfitEnabled(value); + } + + Future _setGrossProfitToggleVisible(bool value) async { + setState(() => _grossProfitToggleVisible = value); + await _appSettingsRepo.setGrossProfitToggleVisible(value); + } + + Future _setGrossProfitIncludeProvisional(bool value) async { + setState(() => _grossProfitIncludeProvisional = value); + await _appSettingsRepo.setGrossProfitIncludeProvisional(value); + } + Future _loadKanaMap() async { final json = await _appSettingsRepo.getString('customKanaMap'); if (json != null && json.isNotEmpty) { @@ -275,293 +350,628 @@ class _SettingsScreenState extends State { _showSnackbar('かなインデックスを保存しました'); } + Future _loadCalendarSettings() async { + final enabled = await _appSettingsRepo.getGoogleCalendarEnabled(); + final calendarId = await _appSettingsRepo.getGoogleCalendarId(); + final account = await _appSettingsRepo.getGoogleCalendarAccountEmail(); + if (!mounted) return; + setState(() { + _calendarEnabled = enabled; + _selectedCalendarId = calendarId; + _googleAccountEmail = account; + }); + } + + Future _loadGrossProfitSettings() async { + final enabled = await _appSettingsRepo.getGrossProfitEnabled(); + final toggleVisible = await _appSettingsRepo.getGrossProfitToggleVisible(); + final includeProvisional = await _appSettingsRepo.getGrossProfitIncludeProvisional(); + if (!mounted) return; + setState(() { + _grossProfitEnabled = enabled; + _grossProfitToggleVisible = toggleVisible; + _grossProfitIncludeProvisional = includeProvisional; + }); + } + + Future _handleCalendarEnabledChanged(bool enabled) async { + if (_calendarBusy) return; + setState(() => _calendarBusy = true); + try { + if (enabled) { + final success = await _calendarSyncService.ensureSignedIn(); + if (!success) { + if (!mounted) return; + setState(() { + _calendarEnabled = false; + }); + _showSnackbar('Googleサインインに失敗しました'); + return; + } + await _appSettingsRepo.setGoogleCalendarEnabled(true); + final email = await _appSettingsRepo.getGoogleCalendarAccountEmail(); + if (!mounted) return; + setState(() { + _calendarEnabled = true; + _googleAccountEmail = email; + }); + await _refreshCalendarList(); + _showSnackbar('Googleカレンダー連携を有効化しました'); + } else { + await _calendarSyncService.signOut(); + await _appSettingsRepo.setGoogleCalendarEnabled(false); + if (!mounted) return; + setState(() { + _calendarEnabled = false; + _googleAccountEmail = null; + _selectedCalendarId = null; + _availableCalendars = []; + }); + _showSnackbar('Googleカレンダー連携を無効化しました'); + } + } finally { + if (mounted) { + setState(() => _calendarBusy = false); + } + } + } + + Future _refreshCalendarList() async { + if (_loadingCalendars) return; + if (!_calendarEnabled) { + _showSnackbar('まずは連携スイッチをONにしてください'); + return; + } + setState(() => _loadingCalendars = true); + try { + final ready = await _calendarSyncService.ensureSignedIn(); + if (!ready) { + _showSnackbar('Googleアカウントの認証が必要です'); + return; + } + final List calendars = await _calendarSyncService.fetchCalendars(); + final options = calendars + .where((entry) => (entry.id ?? '').isNotEmpty) + .map((entry) => _CalendarOption( + id: entry.id!, + summary: entry.summary ?? entry.id!, + detail: entry.primary == true ? 'プライマリ' : entry.accessRole, + )) + .toList(); + final hasPrimary = options.any((o) => o.id == 'primary'); + if (!hasPrimary) { + options.insert(0, const _CalendarOption(id: 'primary', summary: 'デフォルト(プライマリ)')); + } + if (!mounted) return; + setState(() { + _availableCalendars = options; + if (_selectedCalendarId == null || !_availableCalendars.any((o) => o.id == _selectedCalendarId)) { + _selectedCalendarId = options.isNotEmpty ? options.first.id : null; + } + }); + if (_selectedCalendarId != null) { + await _appSettingsRepo.setGoogleCalendarId(_selectedCalendarId); + } + } catch (e) { + _showSnackbar('カレンダー一覧の取得に失敗しました'); + } finally { + if (mounted) { + setState(() => _loadingCalendars = false); + } + } + } + + Future _handleCalendarSelection(String? calendarId) async { + if (calendarId == null) return; + setState(() => _selectedCalendarId = calendarId); + await _appSettingsRepo.setGoogleCalendarId(calendarId); + _showSnackbar('同期先カレンダーを保存しました'); + } + + Future _reauthenticateCalendarAccount() async { + if (_calendarBusy) return; + setState(() => _calendarBusy = true); + try { + final success = await _calendarSyncService.ensureSignedIn(); + if (!success) { + _showSnackbar('Googleサインインに失敗しました'); + return; + } + final email = await _appSettingsRepo.getGoogleCalendarAccountEmail(); + if (!mounted) return; + setState(() => _googleAccountEmail = email); + _showSnackbar('Googleアカウントを更新しました'); + } finally { + if (mounted) setState(() => _calendarBusy = false); + } + } + + Future _runCalendarDiagnostics() async { + if (_calendarSyncing) return; + if (!_calendarEnabled) { + _showSnackbar('まずはカレンダー連携を有効にしてください'); + return; + } + setState(() => _calendarSyncing = true); + try { + final result = await _calendarDiagnostics.runFullSync(); + if (!mounted) return; + final now = DateTime.now(); + final timestamp = + '${now.year}/${now.month.toString().padLeft(2, '0')}/${now.day.toString().padLeft(2, '0')} ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; + final summary = '出荷${result.shipmentsSynced}件 / 債権${result.receivablesSynced}件'; + setState(() { + _lastCalendarSyncStatus = '最終同期: $timestamp ($summary)'; + }); + if (result.hasErrors) { + _showSnackbar('同期は完了しましたが一部でエラーが発生しました'); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('同期時のエラー'), + content: Text(result.errors.join('\n')), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK')), + ], + ), + ); + } else { + _showSnackbar('Googleカレンダー同期を実行しました ($summary)'); + } + } catch (e) { + debugPrint('Manual calendar sync failed: $e'); + if (mounted) { + _showSnackbar('Googleカレンダー同期に失敗しました: $e'); + } + } finally { + if (mounted) { + setState(() => _calendarSyncing = false); + } + } + } + @override Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; + final route = ModalRoute.of(context); + final bool isCurrentRoute = route?.isCurrent ?? true; + final bottomInset = isCurrentRoute ? MediaQuery.of(context).viewInsets.bottom : 0.0; final listBottomPadding = 24 + bottomInset; - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar( - title: const Text('S1:設定'), - backgroundColor: Colors.indigo, - actions: [ - IconButton( - icon: const Icon(Icons.info_outline), - onPressed: () => _showSnackbar('設定はテンプレ実装です。実際の保存は未実装'), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + _handleExit(); + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: const Text('S1:設定'), + backgroundColor: Colors.indigo, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: _handleExit, ), - ], - ), - body: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), - child: ListView( - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - physics: const AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.only(bottom: listBottomPadding), - children: [ - _section( - title: 'ホームモード / ダッシュボード', - subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SwitchListTile( - title: const Text('ホームをダッシュボードにする'), - value: _homeDashboard, - onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v), - ), - SwitchListTile( - title: const Text('ステータスを表示する'), - value: _statusEnabled, - onChanged: _loadingAppSettings ? null : (v) => setState(() => _statusEnabled = v), - ), - TextField( - controller: _statusTextCtrl, - enabled: !_loadingAppSettings && _statusEnabled, - decoration: const InputDecoration(labelText: 'ステータス文言', hintText: '例: 工事中'), - ), - const SizedBox(height: 8), - Row( - children: [ - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text('メニューを追加'), - onPressed: _loadingAppSettings ? null : _addMenuItem, - ), - const SizedBox(width: 12), - Text('ドラッグで並べ替え / ゴミ箱で削除', style: Theme.of(context).textTheme.bodySmall), - ], - ), - const SizedBox(height: 8), - _loadingAppSettings - ? const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator())) - : ReorderableListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _menuItems.length, - onReorder: _reorderMenu, - itemBuilder: (ctx, index) { - final item = _menuItems[index]; - return ListTile( - key: ValueKey(item.id), - leading: _menuLeading(item), - title: Text(item.title), - subtitle: Text(_routeLabel(item.route)), - trailing: IconButton( - icon: const Icon(Icons.delete_forever, color: Colors.redAccent), - onPressed: () => _removeMenuItem(item.id), - ), - ); - }, - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('ホーム設定を保存'), - onPressed: _loadingAppSettings ? null : _saveAppSettings, - ), - ), - ], - ), - ), - _section( - title: '自社情報', - subtitle: '会社・担当者・振込口座・電話帳取り込み', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('自社/担当者情報、振込口座設定、メールフッタをまとめて編集できます。'), - const SizedBox(height: 12), - Row( - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.info_outline), - label: const Text('旧画面 (税率/印影)'), - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); - }, - ), - const SizedBox(width: 8), - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.business), - label: const Text('自社情報ページを開く'), - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const BusinessProfileScreen())); - }, - ), - ), - ], - ), - ], - ), - ), - _section( - title: 'メール設定(SM画面へ)', - subtitle: 'SMTP・端末メーラー・BCC必須・ログ閲覧など', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('メール送信に関する設定は専用画面でまとめて編集できます。'), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: ElevatedButton.icon( - icon: const Icon(Icons.mail_outline), - label: const Text('メール設定を開く'), - onPressed: () async { - await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const EmailSettingsScreen()), - ); - }, - ), - ), - ], - ), - ), - _section( - title: '外部同期(母艦システム「お局様」連携)', - subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。', - child: Column( - children: [ - TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')), - TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), - const SizedBox(height: 8), - Row( - children: [ - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveExternalSync, - ), - const SizedBox(width: 12), - OutlinedButton.icon( - icon: const Icon(Icons.chat_bubble_outline), - label: const Text('チャットを開く'), - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (_) => const ChatScreen())); - }, - ), - ], - ), - ], - ), - ), - _section( - title: 'バックアップドライブ', - subtitle: 'バックアップ先のクラウド/ローカル', - child: Column( - children: [ - TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')), - const SizedBox(height: 8), - Row( - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.folder_open), - label: const Text('参照'), - onPressed: _pickBackupPath, - ), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveBackup, - ), - ], - ), - ], - ), - ), - _section( - title: 'テーマ選択', - subtitle: '配色や見た目を切り替え(テンプレ)', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownButtonFormField( - initialValue: _theme, - decoration: const InputDecoration(labelText: 'テーマを選択'), - items: const [ - DropdownMenuItem(value: 'light', child: Text('ライト')), - DropdownMenuItem(value: 'dark', child: Text('ダーク')), - DropdownMenuItem(value: 'system', child: Text('システムに従う')), - ], - onChanged: (v) => setState(() => _theme = v ?? 'system'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: () async { - await _appSettingsRepo.setTheme(_theme); - await AppThemeController.instance.setTheme(_theme); - if (!mounted) return; - _showSnackbar('テーマ設定を保存しました'); - }, - ), - ], - ), - ), - _section( - title: 'かなインデックス追加', - subtitle: '漢字→行(1文字ずつ)を追加して索引を補強', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: TextField( - controller: _kanaKeyCtrl, - maxLength: 1, - decoration: const InputDecoration(labelText: '漢字1文字', counterText: ''), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _kanaValCtrl, - maxLength: 1, - decoration: const InputDecoration(labelText: '行(例: さ)', counterText: ''), - ), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: () { - final k = _kanaKeyCtrl.text.trim(); - final v = _kanaValCtrl.text.trim(); - if (k.isEmpty || v.isEmpty) return; - setState(() { - _customKanaMap[k] = v; - }); - }, - child: const Text('追加'), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 6, - children: _customKanaMap.entries - .map((e) => Chip( - label: Text('${e.key}: ${e.value}'), - onDeleted: () => setState(() => _customKanaMap.remove(e.key)), - )) - .toList(), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveKanaMap, - ), - ], - ), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () => _showSnackbar('設定はテンプレ実装です。実際の保存は未実装'), ), ], ), + body: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.only(bottom: listBottomPadding), + children: [ + _section( + title: 'ホームモード / ダッシュボード', + subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: const Text('ホームをダッシュボードにする'), + value: _homeDashboard, + onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v), + ), + SwitchListTile( + title: const Text('ステータスを表示する'), + value: _statusEnabled, + onChanged: _loadingAppSettings ? null : (v) => setState(() => _statusEnabled = v), + ), + TextField( + controller: _statusTextCtrl, + enabled: !_loadingAppSettings && _statusEnabled, + decoration: const InputDecoration(labelText: 'ステータス文言', hintText: '例: 工事中'), + ), + const SizedBox(height: 8), + Row( + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('メニューを追加'), + onPressed: _loadingAppSettings ? null : _addMenuItem, + ), + const SizedBox(width: 12), + Text('ドラッグで並べ替え / ゴミ箱で削除', style: Theme.of(context).textTheme.bodySmall), + ], + ), + const SizedBox(height: 8), + _loadingAppSettings + ? const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator())) + : ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _menuItems.length, + onReorder: _reorderMenu, + itemBuilder: (ctx, index) { + final item = _menuItems[index]; + return ListTile( + key: ValueKey(item.id), + leading: _menuLeading(item), + title: Text(item.title), + subtitle: Text(_routeLabel(item.route)), + trailing: IconButton( + icon: const Icon(Icons.delete_forever, color: Colors.redAccent), + onPressed: () => _removeMenuItem(item.id), + ), + ); + }, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('ホーム設定を保存'), + onPressed: _loadingAppSettings ? null : _saveAppSettings, + ), + ), + ], + ), + ), + if (_supportsCalendarSync) + _section( + title: 'Googleカレンダー連携', + subtitle: '出荷追跡や集金・入金予定をGoogleカレンダーへ自動登録', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: const Text('Googleカレンダーと連携する'), + value: _calendarEnabled, + onChanged: _calendarBusy ? null : _handleCalendarEnabledChanged, + ), + const SizedBox(height: 8), + if (_calendarEnabled) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('接続アカウント'), + subtitle: Text(_googleAccountEmail ?? '未サインイン'), + trailing: _calendarBusy + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) + : TextButton.icon( + onPressed: () => _handleCalendarEnabledChanged(false), + icon: const Icon(Icons.logout), + label: const Text('切断'), + ), + ), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('カレンダー一覧を取得'), + onPressed: _loadingCalendars ? null : _refreshCalendarList, + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.verified_user), + label: const Text('Googleを再認証'), + onPressed: _calendarBusy ? null : _reauthenticateCalendarAccount, + ), + ), + if (_loadingCalendars) + const Padding( + padding: EdgeInsets.only(left: 12), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), + ), + ], + ), + const SizedBox(height: 12), + if (_availableCalendars.isEmpty) + Text( + 'まだ同期先カレンダーが選ばれていません。「カレンダー一覧を取得」を押して選択してください。', + style: Theme.of(context).textTheme.bodySmall, + ) + else + DropdownButtonFormField( + key: ValueKey(_selectedCalendarId ?? 'none'), + initialValue: _selectedCalendarId, + decoration: const InputDecoration(labelText: '同期先カレンダー'), + items: _availableCalendars + .map((option) => DropdownMenuItem( + value: option.id, + child: Text(option.detail == null ? option.summary : '${option.summary} (${option.detail})'), + )) + .toList(), + onChanged: _loadingCalendars ? null : _handleCalendarSelection, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + icon: _calendarSyncing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + label: Text(_calendarSyncing ? '同期を実行中…' : '今すぐカレンダー同期を実行'), + onPressed: _calendarSyncing ? null : _runCalendarDiagnostics, + ), + if (_lastCalendarSyncStatus != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _lastCalendarSyncStatus!, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ) + else + Text( + 'カレンダー連携を有効化するとGoogleアカウント認証と同期先の選択が行えます。', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ) + else + _section( + title: 'Googleカレンダー連携', + subtitle: 'この設定はAndroid/iOS版のみ対応しています', + child: Text( + 'デスクトップ版ではGoogleカレンダー連携を利用できません。モバイル端末から設定してください。', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + _section( + title: '粗利表示 / 暫定粗利', + subtitle: '売上伝票で卸値にもとづく粗利を表示し、未入荷商品の扱いを制御します', + child: Column( + children: [ + SwitchListTile.adaptive( + title: const Text('U2/A1で粗利を表示'), + subtitle: const Text('単価-仕入値を明細ごとに計算して表示します'), + value: _grossProfitEnabled, + onChanged: _setGrossProfitEnabled, + ), + SwitchListTile.adaptive( + title: const Text('営業端末に粗利表示スイッチを表示'), + subtitle: const Text('現場ユーザーが粗利の表示/非表示を切り替えられるようにします'), + value: _grossProfitToggleVisible, + onChanged: _setGrossProfitToggleVisible, + ), + SwitchListTile.adaptive( + title: const Text('暫定粗利(仕入未確定)を合計に含める'), + subtitle: const Text('仕入値が未登録の明細は粗利=0で仮計上し、合計に含めるかを制御します'), + value: _grossProfitIncludeProvisional, + onChanged: _setGrossProfitIncludeProvisional, + ), + const SizedBox(height: 8), + Text( + '仕入値未登録の明細は暫定0円として扱い、仕入確定後に再計算できます。', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + _section( + title: '自社情報', + subtitle: '会社・担当者・振込口座・電話帳取り込み', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('自社/担当者情報、振込口座設定、メールフッタをまとめて編集できます。'), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.info_outline), + label: const Text('旧画面 (税率/印影)'), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); + }, + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.business), + label: const Text('自社情報ページを開く'), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const BusinessProfileScreen())); + }, + ), + ), + ], + ), + ], + ), + ), + _section( + title: 'メール設定(SM画面へ)', + subtitle: 'SMTP・端末メーラー・BCC必須・ログ閲覧など', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('メール送信に関する設定は専用画面でまとめて編集できます。'), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + icon: const Icon(Icons.mail_outline), + label: const Text('メール設定を開く'), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const EmailSettingsScreen()), + ); + }, + ), + ), + ], + ), + ), + _section( + title: '外部同期(母艦システム「お局様」連携)', + subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。', + child: Column( + children: [ + TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')), + TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), + const SizedBox(height: 8), + Row( + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveExternalSync, + ), + const SizedBox(width: 12), + OutlinedButton.icon( + icon: const Icon(Icons.chat_bubble_outline), + label: const Text('チャットを開く'), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const ChatScreen())); + }, + ), + ], + ), + ], + ), + ), + _section( + title: 'バックアップドライブ', + subtitle: 'バックアップ先のクラウド/ローカル', + child: Column( + children: [ + TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.folder_open), + label: const Text('参照'), + onPressed: _pickBackupPath, + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveBackup, + ), + ], + ), + ], + ), + ), + _section( + title: 'テーマ選択', + subtitle: '配色や見た目を切り替え(テンプレ)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: _theme, + decoration: const InputDecoration(labelText: 'テーマを選択'), + items: const [ + DropdownMenuItem(value: 'light', child: Text('ライト')), + DropdownMenuItem(value: 'dark', child: Text('ダーク')), + DropdownMenuItem(value: 'system', child: Text('システムに従う')), + ], + onChanged: (v) => setState(() => _theme = v ?? 'system'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: () async { + await _appSettingsRepo.setTheme(_theme); + await AppThemeController.instance.setTheme(_theme); + if (!mounted) return; + _showSnackbar('テーマ設定を保存しました'); + }, + ), + ], + ), + ), + _section( + title: 'かなインデックス追加', + subtitle: '漢字→行(1文字ずつ)を追加して索引を補強', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _kanaKeyCtrl, + maxLength: 1, + decoration: const InputDecoration(labelText: '漢字1文字', counterText: ''), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _kanaValCtrl, + maxLength: 1, + decoration: const InputDecoration(labelText: '行(例: さ)', counterText: ''), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + final k = _kanaKeyCtrl.text.trim(); + final v = _kanaValCtrl.text.trim(); + if (k.isEmpty || v.isEmpty) return; + setState(() { + _customKanaMap[k] = v; + }); + }, + child: const Text('追加'), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + children: _customKanaMap.entries + .map((e) => Chip( + label: Text('${e.key}: ${e.value}'), + onDeleted: () => setState(() => _customKanaMap.remove(e.key)), + )) + .toList(), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveKanaMap, + ), + ], + ), + ), + ], + ), + ), ), ); } diff --git a/lib/screens/staff_master_screen.dart b/lib/screens/staff_master_screen.dart new file mode 100644 index 0000000..486d095 --- /dev/null +++ b/lib/screens/staff_master_screen.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/department_model.dart'; +import '../models/staff_model.dart'; +import '../services/department_repository.dart'; +import '../services/staff_repository.dart'; + +class StaffMasterScreen extends StatefulWidget { + const StaffMasterScreen({super.key}); + + @override + State createState() => _StaffMasterScreenState(); +} + +class _StaffMasterScreenState extends State { + final StaffRepository _staffRepository = StaffRepository(); + final DepartmentRepository _departmentRepository = DepartmentRepository(); + final Uuid _uuid = const Uuid(); + + bool _isLoading = true; + bool _includeInactive = false; + List _staff = const []; + List _departments = const []; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _isLoading = true); + final results = await Future.wait([ + _staffRepository.fetchStaff(includeInactive: _includeInactive), + _departmentRepository.fetchDepartments(includeInactive: true), + ]); + if (!mounted) return; + setState(() { + _staff = results.first as List; + _departments = results.last as List; + _isLoading = false; + }); + } + + Future _openForm({StaffMember? staff}) async { + final result = await showDialog( + context: context, + builder: (ctx) => _StaffFormDialog( + staff: staff, + departments: _departments, + onSubmit: (member) => Navigator.of(ctx).pop(member), + ), + ); + if (result == null) return; + final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now()); + await _staffRepository.saveStaff(saving); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('担当者を保存しました'))); + _loadData(); + } + + Future _deleteStaff(StaffMember staff) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('担当者を削除'), + content: Text('${staff.name} を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))), + ], + ), + ); + if (confirmed != true) return; + await _staffRepository.deleteStaff(staff.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('担当者を削除しました'))); + _loadData(); + } + + String _departmentName(String? departmentId) { + if (departmentId == null) return '部門未設定'; + return _departments.firstWhere( + (d) => d.id == departmentId, + orElse: () => Department(id: departmentId, name: '不明部門', updatedAt: DateTime.now()), + ).name; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('M6:担当者マスター'), + actions: [ + SwitchListTile.adaptive( + value: _includeInactive, + onChanged: (value) { + setState(() => _includeInactive = value); + _loadData(); + }, + title: const Text('退職者も表示'), + contentPadding: const EdgeInsets.only(right: 12), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _openForm(), + child: const Icon(Icons.add), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _staff.isEmpty + ? const _EmptyState(message: '担当者が登録されていません') + : RefreshIndicator( + onRefresh: _loadData, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _staff.length, + itemBuilder: (context, index) { + final staff = _staff[index]; + final subtitleLines = [ + _departmentName(staff.departmentId), + if (staff.email?.isNotEmpty == true) 'メール: ${staff.email}', + if (staff.tel?.isNotEmpty == true) 'TEL: ${staff.tel}', + if (staff.role?.isNotEmpty == true) '役割: ${staff.role}', + if (staff.permissionLevel?.isNotEmpty == true) '権限: ${staff.permissionLevel}', + staff.isActive ? '稼働中' : '退職/無効', + ]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: ListTile( + title: Text(staff.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(subtitleLines.where((line) => line.isNotEmpty).join('\n')), + trailing: PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'edit': + _openForm(staff: staff); + break; + case 'delete': + _deleteStaff(staff); + break; + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('編集')), + PopupMenuItem(value: 'delete', child: Text('削除')), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +class _StaffFormDialog extends StatefulWidget { + const _StaffFormDialog({required this.onSubmit, required this.departments, this.staff}); + + final StaffMember? staff; + final List departments; + final ValueChanged onSubmit; + + @override + State<_StaffFormDialog> createState() => _StaffFormDialogState(); +} + +class _StaffFormDialogState extends State<_StaffFormDialog> { + late final TextEditingController _nameController; + late final TextEditingController _emailController; + late final TextEditingController _telController; + late final TextEditingController _roleController; + late final TextEditingController _permissionController; + String? _departmentId; + bool _isActive = true; + + @override + void initState() { + super.initState(); + final staff = widget.staff; + _nameController = TextEditingController(text: staff?.name ?? ''); + _emailController = TextEditingController(text: staff?.email ?? ''); + _telController = TextEditingController(text: staff?.tel ?? ''); + _roleController = TextEditingController(text: staff?.role ?? ''); + _permissionController = TextEditingController(text: staff?.permissionLevel ?? ''); + _departmentId = staff?.departmentId; + _isActive = staff?.isActive ?? true; + } + + void _submit() { + if (_nameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('氏名は必須です'))); + return; + } + widget.onSubmit( + StaffMember( + id: widget.staff?.id ?? '', + name: _nameController.text.trim(), + email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), + tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(), + role: _roleController.text.trim().isEmpty ? null : _roleController.text.trim(), + departmentId: _departmentId, + permissionLevel: _permissionController.text.trim().isEmpty ? null : _permissionController.text.trim(), + isActive: _isActive, + updatedAt: DateTime.now(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.staff == null ? '担当者を追加' : '担当者を編集'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: _nameController, decoration: const InputDecoration(labelText: '氏名 *')), + TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール'), keyboardType: TextInputType.emailAddress), + TextField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号'), keyboardType: TextInputType.phone), + DropdownButtonFormField( + initialValue: _departmentId, + decoration: const InputDecoration(labelText: '所属部門'), + items: [ + const DropdownMenuItem(value: null, child: Text('未設定')), + ...widget.departments.map((dept) => DropdownMenuItem(value: dept.id, child: Text(dept.name))), + ], + onChanged: (value) => setState(() => _departmentId = value), + ), + TextField(controller: _roleController, decoration: const InputDecoration(labelText: '役割/職位')), + TextField(controller: _permissionController, decoration: const InputDecoration(labelText: '権限レベル')), + SwitchListTile( + title: const Text('稼働中'), + value: _isActive, + onChanged: (value) => setState(() => _isActive = value), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + FilledButton(onPressed: _submit, child: const Text('保存')), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.person_off, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(message), + ], + ), + ); + } +} diff --git a/lib/screens/supplier_master_screen.dart b/lib/screens/supplier_master_screen.dart new file mode 100644 index 0000000..3cee676 --- /dev/null +++ b/lib/screens/supplier_master_screen.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/supplier_model.dart'; +import '../services/supplier_repository.dart'; + +class SupplierMasterScreen extends StatefulWidget { + const SupplierMasterScreen({super.key}); + + @override + State createState() => _SupplierMasterScreenState(); +} + +class _SupplierMasterScreenState extends State { + final SupplierRepository _repository = SupplierRepository(); + final Uuid _uuid = const Uuid(); + + bool _isLoading = true; + bool _showHidden = false; + List _suppliers = const []; + + @override + void initState() { + super.initState(); + _loadSuppliers(); + } + + Future _loadSuppliers() async { + setState(() => _isLoading = true); + final suppliers = await _repository.fetchSuppliers(includeHidden: _showHidden); + if (!mounted) return; + setState(() { + _suppliers = suppliers; + _isLoading = false; + }); + } + + Future _openForm({Supplier? supplier}) async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _SupplierFormDialog( + supplier: supplier, + onSubmit: (data) => Navigator.of(ctx).pop(data), + ), + ); + if (result == null) return; + final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now()); + await _repository.saveSupplier(saving); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を保存しました'))); + _loadSuppliers(); + } + + Future _deleteSupplier(Supplier supplier) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('仕入先を削除'), + content: Text('${supplier.name} を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除', style: TextStyle(color: Colors.red))), + ], + ), + ); + if (confirmed != true) return; + await _repository.deleteSupplier(supplier.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を削除しました'))); + _loadSuppliers(); + } + + String _closingLabel(Supplier supplier) { + if (supplier.closingDay == null) return '締日未設定'; + return '毎月${supplier.closingDay}日締め / ${supplier.paymentSiteDays}日サイト'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('M5:仕入先マスター'), + actions: [ + Switch.adaptive( + value: _showHidden, + onChanged: (value) { + setState(() => _showHidden = value); + _loadSuppliers(); + }, + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _openForm(), + child: const Icon(Icons.add), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _suppliers.isEmpty + ? const _EmptyState(message: '仕入先が登録されていません') + : RefreshIndicator( + onRefresh: _loadSuppliers, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _suppliers.length, + itemBuilder: (context, index) { + final supplier = _suppliers[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + title: Text(supplier.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text([ + if (supplier.contactPerson?.isNotEmpty == true) '担当: ${supplier.contactPerson}', + if (supplier.tel?.isNotEmpty == true) 'TEL: ${supplier.tel}', + _closingLabel(supplier), + ].where((e) => e.isNotEmpty).join('\n')), + trailing: PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'edit': + _openForm(supplier: supplier); + break; + case 'delete': + _deleteSupplier(supplier); + break; + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('編集')), + PopupMenuItem(value: 'delete', child: Text('削除')), + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +class _SupplierFormDialog extends StatefulWidget { + const _SupplierFormDialog({required this.onSubmit, this.supplier}); + + final Supplier? supplier; + final ValueChanged onSubmit; + + @override + State<_SupplierFormDialog> createState() => _SupplierFormDialogState(); +} + +class _SupplierFormDialogState extends State<_SupplierFormDialog> { + late final TextEditingController _nameController; + late final TextEditingController _contactController; + late final TextEditingController _emailController; + late final TextEditingController _telController; + late final TextEditingController _addressController; + late final TextEditingController _paymentSiteController; + late final TextEditingController _notesController; + int? _closingDay; + bool _isHidden = false; + + @override + void initState() { + super.initState(); + final supplier = widget.supplier; + _nameController = TextEditingController(text: supplier?.name ?? ''); + _contactController = TextEditingController(text: supplier?.contactPerson ?? ''); + _emailController = TextEditingController(text: supplier?.email ?? ''); + _telController = TextEditingController(text: supplier?.tel ?? ''); + _addressController = TextEditingController(text: supplier?.address ?? ''); + _paymentSiteController = TextEditingController(text: (supplier?.paymentSiteDays ?? 30).toString()); + _notesController = TextEditingController(text: supplier?.notes ?? ''); + _closingDay = supplier?.closingDay; + _isHidden = supplier?.isHidden ?? false; + } + + void _submit() { + if (_nameController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先名は必須です'))); + return; + } + final paymentSite = int.tryParse(_paymentSiteController.text) ?? 30; + widget.onSubmit( + Supplier( + id: widget.supplier?.id ?? '', + name: _nameController.text.trim(), + contactPerson: _contactController.text.trim().isEmpty ? null : _contactController.text.trim(), + email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), + tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(), + address: _addressController.text.trim().isEmpty ? null : _addressController.text.trim(), + closingDay: _closingDay, + paymentSiteDays: paymentSite, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + isHidden: _isHidden, + updatedAt: DateTime.now(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final closingOptions = [null, ...List.generate(31, (index) => index + 1)]; + return AlertDialog( + title: Text(widget.supplier == null ? '仕入先を追加' : '仕入先を編集'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: _nameController, decoration: const InputDecoration(labelText: '仕入先名 *')), + TextField(controller: _contactController, decoration: const InputDecoration(labelText: '担当者')), + TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール'), keyboardType: TextInputType.emailAddress), + TextField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号'), keyboardType: TextInputType.phone), + TextField(controller: _addressController, decoration: const InputDecoration(labelText: '住所')), + DropdownButtonFormField( + initialValue: _closingDay, + items: closingOptions + .map((day) => DropdownMenuItem( + value: day, + child: Text(day == null ? '締日未設定' : '$day日締め'), + )) + .toList(), + onChanged: (val) => setState(() => _closingDay = val), + decoration: const InputDecoration(labelText: '締日'), + ), + TextField( + controller: _paymentSiteController, + decoration: const InputDecoration(labelText: '支払サイト(日)'), + keyboardType: TextInputType.number, + ), + TextField(controller: _notesController, decoration: const InputDecoration(labelText: '備考'), maxLines: 2), + SwitchListTile( + title: const Text('非表示にする'), + value: _isHidden, + onChanged: (val) => setState(() => _isHidden = val), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + FilledButton(onPressed: _submit, child: const Text('保存')), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.inbox, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(message), + ], + ), + ); + } +} diff --git a/lib/screens/tax_setting_screen.dart b/lib/screens/tax_setting_screen.dart new file mode 100644 index 0000000..5907052 --- /dev/null +++ b/lib/screens/tax_setting_screen.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/tax_setting_model.dart'; +import '../services/tax_setting_repository.dart'; + +class TaxSettingScreen extends StatefulWidget { + const TaxSettingScreen({super.key}); + + @override + State createState() => _TaxSettingScreenState(); +} + +class _TaxSettingScreenState extends State { + final TaxSettingRepository _repository = TaxSettingRepository(); + final TextEditingController _rateController = TextEditingController(); + String _roundingMode = 'round'; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _isLoading = true); + final setting = await _repository.fetchCurrentSetting(); + if (!mounted) return; + setState(() { + _rateController.text = (setting.rate * 100).toStringAsFixed(1); + _roundingMode = setting.roundingMode; + _isLoading = false; + }); + } + + Future _save() async { + final ratePercent = double.tryParse(_rateController.text); + if (ratePercent == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('税込率は数値で入力してください'))); + return; + } + final setting = TaxSetting( + id: const Uuid().v4(), + rate: ratePercent / 100, + roundingMode: _roundingMode, + updatedAt: DateTime.now(), + ); + await _repository.saveSetting(setting); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('税設定を保存しました'))); + _load(); + } + + @override + void dispose() { + _rateController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('M7:消費税・端数設定'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _rateController, + decoration: const InputDecoration(labelText: '税率 (%)', suffixText: '%'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _roundingMode, + decoration: const InputDecoration(labelText: '端数処理'), + items: const [ + DropdownMenuItem(value: 'round', child: Text('四捨五入')), + DropdownMenuItem(value: 'ceil', child: Text('切り上げ')), + DropdownMenuItem(value: 'floor', child: Text('切り捨て')), + ], + onChanged: (value) => setState(() => _roundingMode = value ?? 'round'), + ), + const Spacer(), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + icon: const Icon(Icons.save), + onPressed: _save, + label: const Text('保存'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/app_settings_repository.dart b/lib/services/app_settings_repository.dart index 3ecbe42..cec0f1f 100644 --- a/lib/services/app_settings_repository.dart +++ b/lib/services/app_settings_repository.dart @@ -10,6 +10,12 @@ class AppSettingsRepository { static const _kDashboardHistoryUnlocked = 'dashboard_history_unlocked'; static const _kTheme = 'app_theme'; // light / dark / system static const _kSummaryTheme = 'summary_theme'; // 'white' or 'blue' + static const _kGoogleCalendarEnabled = 'google_calendar_enabled'; + static const _kGoogleCalendarId = 'google_calendar_id'; + static const _kGoogleCalendarAccount = 'google_calendar_account'; + static const _kGrossProfitEnabled = 'gross_profit_enabled'; + static const _kGrossProfitToggleVisible = 'gross_profit_toggle_visible'; + static const _kGrossProfitIncludeProvisional = 'gross_profit_include_provisional'; final DatabaseHelper _dbHelper = DatabaseHelper(); @@ -78,9 +84,68 @@ class AppSettingsRepository { Future getSummaryTheme() async => await getString(_kSummaryTheme) ?? 'white'; Future setSummaryTheme(String theme) async => setString(_kSummaryTheme, theme); + Future getGoogleCalendarEnabled({bool defaultValue = false}) async { + return getBool(_kGoogleCalendarEnabled, defaultValue: defaultValue); + } + + Future setGoogleCalendarEnabled(bool enabled) async { + await setBool(_kGoogleCalendarEnabled, enabled); + } + + Future getGoogleCalendarId() async => getString(_kGoogleCalendarId); + + Future setGoogleCalendarId(String? calendarId) async { + if (calendarId == null || calendarId.isEmpty) { + await deleteKey(_kGoogleCalendarId); + } else { + await setString(_kGoogleCalendarId, calendarId); + } + } + + Future getGoogleCalendarAccountEmail() async => getString(_kGoogleCalendarAccount); + + Future setGoogleCalendarAccountEmail(String? email) async { + if (email == null || email.isEmpty) { + await deleteKey(_kGoogleCalendarAccount); + } else { + await setString(_kGoogleCalendarAccount, email); + } + } + + Future clearGoogleCalendarSettings() async { + await setGoogleCalendarEnabled(false); + await setGoogleCalendarId(null); + await setGoogleCalendarAccountEmail(null); + } + + Future getGrossProfitEnabled({bool defaultValue = true}) async { + return getBool(_kGrossProfitEnabled, defaultValue: defaultValue); + } + + Future setGrossProfitEnabled(bool enabled) async { + await setBool(_kGrossProfitEnabled, enabled); + } + + Future getGrossProfitToggleVisible({bool defaultValue = true}) async { + return getBool(_kGrossProfitToggleVisible, defaultValue: defaultValue); + } + + Future setGrossProfitToggleVisible(bool visible) async { + await setBool(_kGrossProfitToggleVisible, visible); + } + + Future getGrossProfitIncludeProvisional({bool defaultValue = false}) async { + return getBool(_kGrossProfitIncludeProvisional, defaultValue: defaultValue); + } + + Future setGrossProfitIncludeProvisional(bool include) async { + await setBool(_kGrossProfitIncludeProvisional, include); + } + // Generic helpers Future getString(String key) async => _getValue(key); Future setString(String key, String value) async => _setValue(key, value); + Future deleteKey(String key) async => _deleteValue(key); Future getBool(String key, {bool defaultValue = false}) async { final v = await _getValue(key); @@ -103,6 +168,12 @@ class AppSettingsRepository { final db = await _dbHelper.database; await db.insert('app_settings', {'key': key, 'value': value}, conflictAlgorithm: ConflictAlgorithm.replace); } + + Future _deleteValue(String key) async { + await _ensureTable(); + final db = await _dbHelper.database; + await db.delete('app_settings', where: 'key = ?', whereArgs: [key]); + } } class DashboardMenuItem { diff --git a/lib/services/business_calendar_mapper.dart b/lib/services/business_calendar_mapper.dart new file mode 100644 index 0000000..9648201 --- /dev/null +++ b/lib/services/business_calendar_mapper.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; + +import '../models/receivable_models.dart'; +import '../models/shipment_models.dart'; +import 'app_settings_repository.dart'; +import 'calendar_sync_service.dart'; + +/// Maps domain entities (shipments, receivables, etc.) to Google Calendar events. +class BusinessCalendarMapper { + BusinessCalendarMapper({ + CalendarSyncService? calendarSyncService, + AppSettingsRepository? settingsRepository, + }) : _calendarSyncService = calendarSyncService ?? CalendarSyncService(), + _settingsRepository = settingsRepository ?? AppSettingsRepository(); + + final CalendarSyncService _calendarSyncService; + final AppSettingsRepository _settingsRepository; + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + Future _ensureReady() async { + final enabled = await _settingsRepository.getGoogleCalendarEnabled(); + if (!enabled) return false; + final ready = await _calendarSyncService.ensureSignedIn(); + return ready; + } + + Future syncShipments(List shipments) async { + if (shipments.isEmpty) return; + if (!await _ensureReady()) return; + for (final shipment in shipments) { + await _syncShipment(shipment); + } + } + + Future syncShipment(Shipment shipment) async { + if (!await _ensureReady()) return; + await _syncShipment(shipment); + } + + Future syncReceivables(List summaries) async { + if (summaries.isEmpty) return; + if (!await _ensureReady()) return; + for (final summary in summaries) { + await _syncReceivable(summary); + } + } + + Future syncReceivable(ReceivableInvoiceSummary summary) async { + if (!await _ensureReady()) return; + await _syncReceivable(summary); + } + + Future _syncShipment(Shipment shipment) async { + final date = shipment.scheduledShipDate ?? shipment.actualShipDate; + if (date == null) return; + final start = DateTime(date.year, date.month, date.day, 9); + final end = start.add(const Duration(hours: 2)); + final summary = '[出荷] ${(shipment.customerNameSnapshot ?? '取引先未設定')}'; + final buffer = StringBuffer() + ..writeln('受注番号: ${shipment.orderNumberSnapshot ?? '-'}') + ..writeln('ステータス: ${shipment.status.displayName}') + ..writeln('配送業者: ${shipment.carrierName ?? '-'}') + ..writeln('追跡番号: ${shipment.trackingNumber ?? '-'}'); + if (shipment.trackingUrl?.isNotEmpty == true) { + buffer.writeln('トラッキングURL: ${shipment.trackingUrl}'); + } + try { + await _calendarSyncService.createOrUpdateEvent( + eventId: 'shipment-${shipment.id}', + summary: summary, + description: buffer.toString(), + start: start, + end: end, + extendedProperties: { + 'type': 'shipment', + 'shipmentId': shipment.id, + if (shipment.orderNumberSnapshot != null) 'orderNumber': shipment.orderNumberSnapshot!, + if (shipment.customerNameSnapshot != null) 'customer': shipment.customerNameSnapshot!, + }, + ); + } catch (e, stack) { + debugPrint('Failed to sync shipment ${shipment.id} to calendar: $e\n$stack'); + } + } + + Future _syncReceivable(ReceivableInvoiceSummary summary) async { + final dueDate = summary.dueDate; + final start = DateTime(dueDate.year, dueDate.month, dueDate.day, 10); + final end = start.add(const Duration(hours: 1)); + final title = '[入金予定] ${summary.customerName}'; + final description = StringBuffer() + ..writeln('請求書番号: ${summary.invoiceNumber}') + ..writeln('請求日: ${DateFormat('yyyy/MM/dd').format(summary.invoiceDate)}') + ..writeln('期日: ${DateFormat('yyyy/MM/dd').format(summary.dueDate)}') + ..writeln('請求額: ${_currencyFormat.format(summary.totalAmount)}') + ..writeln('入金済み: ${_currencyFormat.format(summary.paidAmount)}') + ..writeln('残高: ${_currencyFormat.format(summary.outstandingAmount)}'); + try { + await _calendarSyncService.createOrUpdateEvent( + eventId: 'receivable-${summary.invoiceId}', + summary: title, + description: description.toString(), + start: start, + end: end, + extendedProperties: { + 'type': 'receivable', + 'invoiceId': summary.invoiceId, + 'invoiceNumber': summary.invoiceNumber, + }, + ); + } catch (e, stack) { + debugPrint('Failed to sync receivable ${summary.invoiceId} to calendar: $e\n$stack'); + } + } +} diff --git a/lib/services/calendar_sync_diagnostics.dart b/lib/services/calendar_sync_diagnostics.dart new file mode 100644 index 0000000..b8ab665 --- /dev/null +++ b/lib/services/calendar_sync_diagnostics.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +import '../models/receivable_models.dart'; +import '../models/shipment_models.dart'; +import 'business_calendar_mapper.dart'; +import 'receivable_repository.dart'; +import 'shipment_repository.dart'; + +/// Result object describing a manual Google Calendar sync run. +class CalendarSyncDiagnosticsResult { + const CalendarSyncDiagnosticsResult({ + required this.shipmentsSynced, + required this.receivablesSynced, + required this.errors, + }); + + final int shipmentsSynced; + final int receivablesSynced; + final List errors; + + bool get hasErrors => errors.isNotEmpty; +} + +/// Utility helper to trigger a full calendar sync for diagnostics or manual refresh. +class CalendarSyncDiagnostics { + CalendarSyncDiagnostics({ + ShipmentRepository? shipmentRepository, + ReceivableRepository? receivableRepository, + BusinessCalendarMapper? calendarMapper, + }) : _shipmentRepository = shipmentRepository ?? ShipmentRepository(), + _receivableRepository = receivableRepository ?? ReceivableRepository(), + _calendarMapper = calendarMapper ?? BusinessCalendarMapper(); + + final ShipmentRepository _shipmentRepository; + final ReceivableRepository _receivableRepository; + final BusinessCalendarMapper _calendarMapper; + + Future runFullSync({bool includeSettledReceivables = true}) async { + final errors = []; + List shipments = const []; + List receivables = const []; + + try { + shipments = await _shipmentRepository.fetchShipments(); + await _calendarMapper.syncShipments(shipments); + } catch (e, stack) { + debugPrint('Calendar diagnostics: shipment sync failed: $e\n$stack'); + errors.add('出荷イベント同期に失敗: $e'); + } + + try { + receivables = await _receivableRepository.fetchSummaries(includeSettled: includeSettledReceivables); + await _calendarMapper.syncReceivables(receivables); + } catch (e, stack) { + debugPrint('Calendar diagnostics: receivable sync failed: $e\n$stack'); + errors.add('債権イベント同期に失敗: $e'); + } + + return CalendarSyncDiagnosticsResult( + shipmentsSynced: shipments.length, + receivablesSynced: receivables.length, + errors: errors, + ); + } +} diff --git a/lib/services/calendar_sync_service.dart b/lib/services/calendar_sync_service.dart new file mode 100644 index 0000000..006036b --- /dev/null +++ b/lib/services/calendar_sync_service.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:googleapis/calendar/v3.dart' as gcal; +import 'package:http/http.dart' as http; +import 'package:http/retry.dart'; + +import 'app_settings_repository.dart'; + +/// Googleカレンダーとの認証・イベント同期を担当するサービス。 +class CalendarSyncService { + CalendarSyncService({AppSettingsRepository? settingsRepository}) + : _settingsRepository = settingsRepository ?? AppSettingsRepository(); + + final AppSettingsRepository _settingsRepository; + GoogleSignInAccount? _currentAccount; + gcal.CalendarApi? _calendarApi; + static const List _scopes = [gcal.CalendarApi.calendarScope]; + + GoogleSignIn get _googleSignIn => GoogleSignIn(scopes: _scopes); + + Future ensureSignedIn() async { + final enabled = await _settingsRepository.getGoogleCalendarEnabled(); + if (!enabled) return false; + _currentAccount = await _googleSignIn.signInSilently(); + _currentAccount ??= await _googleSignIn.signIn(); + if (_currentAccount == null) { + await _settingsRepository.clearGoogleCalendarSettings(); + return false; + } + await _initializeCalendarApi(); + await _settingsRepository.setGoogleCalendarAccountEmail(_currentAccount!.email); + return true; + } + + Future signOut() async { + await _googleSignIn.disconnect(); + await _settingsRepository.clearGoogleCalendarSettings(); + _calendarApi = null; + } + + Future _initializeCalendarApi() async { + final account = _currentAccount; + if (account == null) return; + final authHeaders = await account.authHeaders; + final authClient = _AuthClientDecorator(authHeaders, http.Client()); + final retryClient = RetryClient(authClient, retries: 3); + _calendarApi = gcal.CalendarApi(retryClient); + } + + Future> fetchCalendars() async { + if (_calendarApi == null) { + final ready = await ensureSignedIn(); + if (!ready) return []; + } + final list = await _calendarApi!.calendarList.list(showHidden: false, minAccessRole: 'writer'); + return list.items ?? []; + } + + Future createOrUpdateEvent({ + required String eventId, + required String summary, + String? description, + DateTime? start, + DateTime? end, + String? calendarId, + Map? extendedProperties, + }) async { + if (_calendarApi == null) { + final ready = await ensureSignedIn(); + if (!ready) return; + } + final targetCalendarId = calendarId ?? (await _settingsRepository.getGoogleCalendarId()) ?? 'primary'; + final event = gcal.Event() + ..id = eventId + ..summary = summary + ..description = description + ..start = start != null ? _timeFromDate(start) : null + ..end = end != null ? _timeFromDate(end) : null + ..extendedProperties = extendedProperties == null + ? null + : gcal.EventExtendedProperties(private: extendedProperties); + + try { + await _calendarApi!.events.patch(event, targetCalendarId, eventId); + } on gcal.DetailedApiRequestError catch (e) { + if (e.status == 404) { + await _calendarApi!.events.insert(event, targetCalendarId); + } else { + rethrow; + } + } + } + + Future deleteEvent(String eventId, {String? calendarId}) async { + if (_calendarApi == null) { + final ready = await ensureSignedIn(); + if (!ready) return; + } + final targetCalendarId = calendarId ?? (await _settingsRepository.getGoogleCalendarId()) ?? 'primary'; + await _calendarApi!.events.delete(targetCalendarId, eventId); + } + + gcal.EventDateTime _timeFromDate(DateTime date) { + return gcal.EventDateTime(dateTime: date, timeZone: date.timeZoneName); + } +} + +class _AuthClientDecorator extends http.BaseClient { + _AuthClientDecorator(this._headers, this._inner); + + final Map _headers; + final http.Client _inner; + + @override + Future send(http.BaseRequest request) { + request.headers.addAll(_headers); + return _inner.send(request); + } + + @override + void close() { + _inner.close(); + super.close(); + } +} diff --git a/lib/services/customer_repository.dart b/lib/services/customer_repository.dart index c268c3c..0ef2d7d 100644 --- a/lib/services/customer_repository.dart +++ b/lib/services/customer_repository.dart @@ -36,6 +36,13 @@ class CustomerRepository { return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); } + Future findById(String id) async { + final db = await _dbHelper.database; + final rows = await db.query('customers', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + return Customer.fromMap(rows.first); + } + Future ensureCustomerColumns() async { final db = await _dbHelper.database; // best-effort, ignore errors if columns already exist diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index fc55a85..4b9e4e1 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; class DatabaseHelper { - static const _databaseVersion = 26; + static const _databaseVersion = 35; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -210,6 +210,41 @@ class DatabaseHelper { '''); await db.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at)'); } + if (oldVersion < 27) { + await _createSalesOrderTables(db); + } + if (oldVersion < 28) { + await _createShipmentTables(db); + } + if (oldVersion < 29) { + await _createInventoryTables(db); + } + if (oldVersion < 30) { + await _createReceivableTables(db); + } + if (oldVersion < 31) { + await _safeAddColumn(db, 'invoices', 'previous_chain_hash TEXT'); + await _safeAddColumn(db, 'invoices', 'chain_hash TEXT'); + await _safeAddColumn(db, 'invoices', "chain_status TEXT DEFAULT 'pending'"); + } + if (oldVersion < 32) { + await _createSupplierTables(db); + await _createDepartmentTables(db); + await _createStaffTables(db); + await _createTaxSettingsTable(db); + } + if (oldVersion < 33) { + await _safeAddColumn(db, 'shipments', 'tracking_url TEXT'); + await _safeAddColumn(db, 'shipments', 'label_pdf_path TEXT'); + } + if (oldVersion < 34) { + await _createSalesEntryTables(db); + } + if (oldVersion < 35) { + await _safeAddColumn(db, 'products', 'wholesale_price INTEGER DEFAULT 0'); + await _safeAddColumn(db, 'sales_line_items', 'cost_amount INTEGER DEFAULT 0'); + await _safeAddColumn(db, 'sales_line_items', 'cost_is_provisional INTEGER DEFAULT 0'); + } } Future _onCreate(Database db, int version) async { @@ -266,6 +301,7 @@ class DatabaseHelper { id TEXT PRIMARY KEY, name TEXT NOT NULL, default_unit_price INTEGER, + wholesale_price INTEGER DEFAULT 0, barcode TEXT, category TEXT, stock_quantity INTEGER DEFAULT 0, @@ -287,6 +323,11 @@ class DatabaseHelper { '''); await db.execute('CREATE INDEX idx_master_hidden_type ON master_hidden(master_type)'); + await _createSupplierTables(db); + await _createDepartmentTables(db); + await _createStaffTables(db); + await _createTaxSettingsTable(db); + // 伝票マスター await db.execute(''' CREATE TABLE invoices ( @@ -388,6 +429,14 @@ class DatabaseHelper { ) '''); await db.execute('CREATE INDEX idx_chat_messages_created_at ON chat_messages(created_at)'); + await _createSalesOrderTables(db); + await _createShipmentTables(db); + await _createInventoryTables(db); + await _createReceivableTables(db); + await _createSalesEntryTables(db); + await _safeAddColumn(db, 'invoices', 'previous_chain_hash TEXT'); + await _safeAddColumn(db, 'invoices', 'chain_hash TEXT'); + await _safeAddColumn(db, 'invoices', "chain_status TEXT DEFAULT 'pending'"); } Future _safeAddColumn(Database db, String table, String columnDef) async { @@ -397,4 +446,270 @@ class DatabaseHelper { // Ignore if the column already exists. } } + + Future _createSalesOrderTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS sales_orders ( + id TEXT PRIMARY KEY, + order_number TEXT, + customer_id TEXT NOT NULL, + customer_name_snapshot TEXT, + order_date TEXT NOT NULL, + requested_ship_date TEXT, + status TEXT NOT NULL, + subtotal INTEGER DEFAULT 0, + tax_amount INTEGER DEFAULT 0, + total_amount INTEGER DEFAULT 0, + notes TEXT, + assigned_to TEXT, + workflow_stage TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(customer_id) REFERENCES customers(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_orders_customer ON sales_orders(customer_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_orders_status ON sales_orders(status)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_orders_date ON sales_orders(order_date)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS sales_order_items ( + id TEXT PRIMARY KEY, + order_id TEXT NOT NULL, + product_id TEXT, + description TEXT NOT NULL, + quantity INTEGER NOT NULL, + unit_price INTEGER NOT NULL, + tax_rate REAL DEFAULT 0, + sort_index INTEGER DEFAULT 0, + FOREIGN KEY(order_id) REFERENCES sales_orders(id) ON DELETE CASCADE + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_order_items_order ON sales_order_items(order_id)'); + } + + Future _createShipmentTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS shipments ( + id TEXT PRIMARY KEY, + order_id TEXT, + order_number_snapshot TEXT, + customer_name_snapshot TEXT, + scheduled_ship_date TEXT, + actual_ship_date TEXT, + status TEXT NOT NULL, + carrier_name TEXT, + tracking_number TEXT, + tracking_url TEXT, + label_pdf_path TEXT, + notes TEXT, + picking_completed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(order_id) REFERENCES sales_orders(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_shipments_status ON shipments(status)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_shipments_order ON shipments(order_id)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS shipment_items ( + id TEXT PRIMARY KEY, + shipment_id TEXT NOT NULL, + order_item_id TEXT, + description TEXT NOT NULL, + quantity INTEGER NOT NULL, + FOREIGN KEY(shipment_id) REFERENCES shipments(id) ON DELETE CASCADE, + FOREIGN KEY(order_item_id) REFERENCES sales_order_items(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_shipment_items_shipment ON shipment_items(shipment_id)'); + } + + Future _createInventoryTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS inventory_movements ( + id TEXT PRIMARY KEY, + product_id TEXT NOT NULL, + product_name_snapshot TEXT, + movement_type TEXT NOT NULL, + quantity INTEGER NOT NULL, + quantity_delta INTEGER NOT NULL, + reference TEXT, + notes TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(product_id) REFERENCES products(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_inventory_movements_product ON inventory_movements(product_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_inventory_movements_created ON inventory_movements(created_at)'); + } + + Future _createReceivableTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS receivable_payments ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL, + amount INTEGER NOT NULL, + payment_date TEXT NOT NULL, + method TEXT NOT NULL, + notes TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(invoice_id) REFERENCES invoices(id) ON DELETE CASCADE + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_receivable_payments_invoice ON receivable_payments(invoice_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_receivable_payments_date ON receivable_payments(payment_date)'); + } + + Future _createSupplierTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS suppliers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + contact_person TEXT, + email TEXT, + tel TEXT, + address TEXT, + closing_day INTEGER, + payment_site_days INTEGER DEFAULT 30, + notes TEXT, + is_hidden INTEGER DEFAULT 0, + updated_at TEXT NOT NULL + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_suppliers_name ON suppliers(name)'); + } + + Future _createDepartmentTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS departments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + code TEXT, + description TEXT, + is_active INTEGER DEFAULT 1, + updated_at TEXT NOT NULL + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_departments_name ON departments(name)'); + } + + Future _createStaffTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS staff_members ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + tel TEXT, + role TEXT, + department_id TEXT, + permission_level TEXT, + is_active INTEGER DEFAULT 1, + updated_at TEXT NOT NULL, + FOREIGN KEY(department_id) REFERENCES departments(id) ON DELETE SET NULL + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_staff_department ON staff_members(department_id)'); + } + + Future _createTaxSettingsTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS tax_settings ( + id TEXT PRIMARY KEY, + rate REAL NOT NULL, + rounding_mode TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + '''); + final existing = await db.query('tax_settings', limit: 1); + if (existing.isEmpty) { + await db.insert('tax_settings', { + 'id': 'default', + 'rate': 0.1, + 'rounding_mode': 'round', + 'updated_at': DateTime.now().toIso8601String(), + }); + } + } + + Future _createSalesEntryTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS sales_entries ( + id TEXT PRIMARY KEY, + customer_id TEXT, + customer_name_snapshot TEXT, + subject TEXT, + issue_date TEXT NOT NULL, + status TEXT NOT NULL, + amount_tax_excl INTEGER DEFAULT 0, + tax_amount INTEGER DEFAULT 0, + amount_tax_incl INTEGER DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(customer_id) REFERENCES customers(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_entries_date ON sales_entries(issue_date)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_entries_status ON sales_entries(status)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS sales_line_items ( + id TEXT PRIMARY KEY, + sales_entry_id TEXT NOT NULL, + product_id TEXT, + description TEXT NOT NULL, + quantity INTEGER NOT NULL, + unit_price INTEGER NOT NULL, + tax_rate REAL DEFAULT 0, + line_total INTEGER NOT NULL, + cost_amount INTEGER DEFAULT 0, + cost_is_provisional INTEGER DEFAULT 0, + source_invoice_id TEXT, + source_invoice_item_id TEXT, + FOREIGN KEY(sales_entry_id) REFERENCES sales_entries(id) ON DELETE CASCADE + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_line_items_entry ON sales_line_items(sales_entry_id)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS sales_entry_sources ( + id TEXT PRIMARY KEY, + sales_entry_id TEXT NOT NULL, + invoice_id TEXT NOT NULL, + imported_at TEXT NOT NULL, + invoice_hash_snapshot TEXT, + FOREIGN KEY(sales_entry_id) REFERENCES sales_entries(id) ON DELETE CASCADE, + FOREIGN KEY(invoice_id) REFERENCES invoices(id) + ) + '''); + await db.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_entry_sources_unique ON sales_entry_sources(sales_entry_id, invoice_id)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS sales_receipts ( + id TEXT PRIMARY KEY, + customer_id TEXT, + payment_date TEXT NOT NULL, + method TEXT, + amount INTEGER NOT NULL, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(customer_id) REFERENCES customers(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sales_receipts_date ON sales_receipts(payment_date)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS sales_receipt_links ( + receipt_id TEXT NOT NULL, + sales_entry_id TEXT NOT NULL, + allocated_amount INTEGER NOT NULL, + PRIMARY KEY(receipt_id, sales_entry_id), + FOREIGN KEY(receipt_id) REFERENCES sales_receipts(id) ON DELETE CASCADE, + FOREIGN KEY(sales_entry_id) REFERENCES sales_entries(id) ON DELETE CASCADE + ) + '''); + } } diff --git a/lib/services/database_maintenance_service.dart b/lib/services/database_maintenance_service.dart new file mode 100644 index 0000000..148356d --- /dev/null +++ b/lib/services/database_maintenance_service.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:sqflite/sqflite.dart'; + +/// Helper that encapsulates direct SQLite file access so UI layers remain decoupled +/// from sqflite APIs. Use this when exporting or backing up the on-device DB file. +class DatabaseMaintenanceService { + const DatabaseMaintenanceService(); + + /// Returns the absolute path to the primary application database file. + Future databasePath() async { + final dbDir = await getDatabasesPath(); + return p.join(dbDir, 'gemi_invoice.db'); + } + + /// Returns the database file if it currently exists on disk. + Future getDatabaseFile() async { + final path = await databasePath(); + final file = File(path); + if (await file.exists()) { + return file; + } + return null; + } + + /// Ensures the database directory exists (useful before copying / restoring). + Future ensureDatabaseDirectory() async { + final dbDir = await getDatabasesPath(); + final dir = Directory(dbDir); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + /// Copies the current database file to the provided destination path. + Future copyDatabaseTo(String destinationPath) async { + final file = await getDatabaseFile(); + if (file == null) return null; + final destFile = await File(destinationPath).create(recursive: true); + return file.copy(destFile.path); + } + + /// Replaces the current database file with the provided source file. + Future replaceDatabaseWith(File source) async { + final dir = await ensureDatabaseDirectory(); + final destPath = p.join(dir.path, 'gemi_invoice.db'); + await source.copy(destPath); + } +} diff --git a/lib/services/debug_webhook_logger.dart b/lib/services/debug_webhook_logger.dart new file mode 100644 index 0000000..70c74a4 --- /dev/null +++ b/lib/services/debug_webhook_logger.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../config/app_config.dart'; + +class DebugWebhookLogger { + const DebugWebhookLogger({http.Client? httpClient}) : _httpClient = httpClient; + + final http.Client? _httpClient; + + bool get _isEnabled => AppConfig.enableDebugWebhookLogging && AppConfig.debugWebhookUrl.isNotEmpty; + + Future sendNodePing({String? note}) async { + if (!_isEnabled) return; + final client = _httpClient ?? http.Client(); + try { + final hostname = Platform.localHostname; + final os = Platform.operatingSystem; + final osVersion = Platform.operatingSystemVersion; + final timestamp = DateTime.now().toIso8601String(); + final buffer = StringBuffer() + ..writeln(':mag: **販売アシスト1号 Debug**') + ..writeln('- Timestamp: $timestamp') + ..writeln('- Node: $hostname') + ..writeln('- OS: $os') + ..writeln('- OS Version: $osVersion') + ..writeln('- App Version: ${AppConfig.version}'); + if (note != null && note.isNotEmpty) { + buffer.writeln('- Note: $note'); + } + final payload = jsonEncode({'text': buffer.toString()}); + final response = await client.post( + Uri.parse(AppConfig.debugWebhookUrl), + headers: {HttpHeaders.contentTypeHeader: 'application/json'}, + body: payload, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + debugPrint('[DebugWebhook] Failed ${response.statusCode}: ${response.body}'); + } + } catch (err, stack) { + debugPrint('[DebugWebhook] Error: $err'); + debugPrint('$stack'); + } finally { + if (_httpClient == null) { + client.close(); + } + } + } +} diff --git a/lib/services/department_repository.dart b/lib/services/department_repository.dart new file mode 100644 index 0000000..0c7cdf4 --- /dev/null +++ b/lib/services/department_repository.dart @@ -0,0 +1,34 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/department_model.dart'; +import 'database_helper.dart'; + +class DepartmentRepository { + DepartmentRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future> fetchDepartments({bool includeInactive = true}) async { + final db = await _dbHelper.database; + final rows = await db.query( + 'departments', + where: includeInactive ? null : 'is_active = 1', + orderBy: 'name COLLATE NOCASE ASC', + ); + return rows.map(Department.fromMap).toList(); + } + + Future saveDepartment(Department department) async { + final db = await _dbHelper.database; + await db.insert( + 'departments', + department.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future deleteDepartment(String departmentId) async { + final db = await _dbHelper.database; + await db.delete('departments', where: 'id = ?', whereArgs: [departmentId]); + } +} diff --git a/lib/services/inventory_repository.dart b/lib/services/inventory_repository.dart new file mode 100644 index 0000000..52191c8 --- /dev/null +++ b/lib/services/inventory_repository.dart @@ -0,0 +1,101 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/inventory_models.dart'; +import 'database_helper.dart'; + +class InventoryRepository { + InventoryRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + final Uuid _uuid = const Uuid(); + + Future> fetchSummaries({bool includeHidden = false}) async { + final db = await _dbHelper.database; + final whereClauses = []; + if (!includeHidden) { + whereClauses.add('COALESCE(mh.is_hidden, p.is_hidden, 0) = 0'); + } + final whereSql = whereClauses.isEmpty ? '' : 'WHERE ${whereClauses.join(' AND ')}'; + + final rows = await db.rawQuery(''' + SELECT p.id, p.name, p.category, p.default_unit_price, p.stock_quantity, + MAX(m.created_at) AS last_movement_at + FROM products p + LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id + LEFT JOIN inventory_movements m ON m.product_id = p.id + $whereSql + GROUP BY p.id + ORDER BY p.name COLLATE NOCASE ASC + '''); + + return rows.map((row) { + return InventorySummary( + productId: row['id'] as String, + productName: row['name'] as String? ?? '-', + stockQuantity: row['stock_quantity'] as int? ?? 0, + category: row['category'] as String?, + defaultUnitPrice: row['default_unit_price'] as int?, + lastMovementAt: row['last_movement_at'] != null ? DateTime.parse(row['last_movement_at'] as String) : null, + ); + }).toList(); + } + + Future> fetchMovements(String productId, {int limit = 50}) async { + final db = await _dbHelper.database; + final rows = await db.query( + 'inventory_movements', + where: 'product_id = ?', + whereArgs: [productId], + orderBy: 'created_at DESC', + limit: limit, + ); + return rows.map(InventoryMovement.fromMap).toList(); + } + + Future recordMovement({ + required String productId, + required InventoryMovementType type, + required int quantity, + required int quantityDelta, + String? reference, + String? notes, + }) async { + final db = await _dbHelper.database; + late InventorySummary summary; + await db.transaction((txn) async { + final productRows = await txn.query('products', where: 'id = ?', whereArgs: [productId], limit: 1); + if (productRows.isEmpty) { + throw StateError('product not found: $productId'); + } + final product = productRows.first; + final currentStock = product['stock_quantity'] as int? ?? 0; + final nextStock = currentStock + quantityDelta; + final now = DateTime.now(); + final movement = InventoryMovement( + id: _uuid.v4(), + productId: productId, + productNameSnapshot: product['name'] as String? ?? '-', + type: type, + quantity: quantity, + quantityDelta: quantityDelta, + reference: reference, + notes: notes, + createdAt: now, + ); + + await txn.insert('inventory_movements', movement.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + await txn.update('products', {'stock_quantity': nextStock}, where: 'id = ?', whereArgs: [productId]); + + summary = InventorySummary( + productId: productId, + productName: product['name'] as String? ?? '-', + stockQuantity: nextStock, + category: product['category'] as String?, + defaultUnitPrice: product['default_unit_price'] as int?, + lastMovementAt: now, + ); + }); + return summary; + } +} diff --git a/lib/services/inventory_service.dart b/lib/services/inventory_service.dart new file mode 100644 index 0000000..2e80755 --- /dev/null +++ b/lib/services/inventory_service.dart @@ -0,0 +1,51 @@ +import '../models/inventory_models.dart'; +import 'inventory_repository.dart'; + +class InventoryService { + InventoryService({InventoryRepository? repository}) : _repository = repository ?? InventoryRepository(); + + final InventoryRepository _repository; + + Future> fetchSummaries({bool includeHidden = false}) { + return _repository.fetchSummaries(includeHidden: includeHidden); + } + + Future> fetchMovements(String productId, {int limit = 50}) { + return _repository.fetchMovements(productId, limit: limit); + } + + Future recordManualMovement({ + required String productId, + required InventoryMovementType type, + required int quantity, + String? reference, + String? notes, + }) { + if (quantity == 0 && type != InventoryMovementType.adjustment) { + throw ArgumentError('quantity must be non-zero'); + } + final normalizedQuantity = quantity.abs(); + final delta = _calculateDelta(type, quantity); + final recordedQuantity = type == InventoryMovementType.adjustment ? normalizedQuantity : normalizedQuantity; + + return _repository.recordMovement( + productId: productId, + type: type, + quantity: recordedQuantity, + quantityDelta: delta, + reference: reference, + notes: notes, + ); + } + + int _calculateDelta(InventoryMovementType type, int quantity) { + switch (type) { + case InventoryMovementType.receipt: + return quantity.abs(); + case InventoryMovementType.issue: + return -quantity.abs(); + case InventoryMovementType.adjustment: + return quantity; + } + } +} diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index c3c8bcd..9846b3d 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -3,9 +3,12 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:intl/intl.dart'; +import '../models/hash_chain_models.dart'; import '../models/invoice_models.dart'; import '../models/customer_model.dart'; import '../models/customer_contact.dart'; +import '../models/sales_summary.dart'; import 'database_helper.dart'; import 'activity_log_repository.dart'; import 'company_repository.dart'; @@ -60,6 +63,29 @@ class InvoiceRepository { metaHash: null, ); + Invoice savingWithChain = savingWithContact; + if (!savingWithContact.isDraft) { + final previousEntry = await _fetchLatestChainEntry(txn); + final previousHash = previousEntry?['chain_hash'] as String?; + final computedHash = _computeChainHash( + previousHash, + savingWithContact.contentHash, + savingWithContact.updatedAt, + savingWithContact.id, + ); + savingWithChain = savingWithContact.copyWith( + previousChainHash: previousHash, + chainHash: computedHash, + chainStatus: 'pending', + ); + } else { + savingWithChain = savingWithContact.copyWith( + previousChainHash: null, + chainHash: null, + chainStatus: 'draft', + ); + } + // 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く) final List> oldItems = await txn.query( 'invoice_items', @@ -80,7 +106,7 @@ class InvoiceRepository { // 伝票ヘッダーの保存 await txn.insert( 'invoices', - savingWithContact.toMap(), + savingWithChain.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); @@ -183,6 +209,9 @@ class InvoiceRepository { companySealHash: iMap['company_seal_hash'], metaJson: iMap['meta_json'], metaHash: iMap['meta_hash'], + previousChainHash: iMap['previous_chain_hash'], + chainHash: iMap['chain_hash'], + chainStatus: iMap['chain_status'] ?? 'pending', )); } return invoices; @@ -296,33 +325,240 @@ class InvoiceRepository { return verifyInvoiceMeta(target); } - Future> getMonthlySales(int year) async { + Future verifyHashChain() async { final db = await _dbHelper.database; - final String yearStr = year.toString(); - final List> results = await db.rawQuery(''' - SELECT strftime('%m', date) as month, SUM(total_amount) as total + final now = DateTime.now(); + return await db.transaction((txn) async { + final rows = await txn.query( + 'invoices', + columns: [ + 'id', + 'updated_at', + 'content_hash', + 'previous_chain_hash', + 'chain_hash', + 'document_type', + 'terminal_id', + 'date', + ], + where: 'COALESCE(is_draft, 0) = 0', + orderBy: 'updated_at ASC, id ASC', + ); + + String? expectedPreviousHash; + final breaks = []; + + for (final row in rows) { + final invoiceId = row['id'] as String; + final invoiceNumber = _buildInvoiceNumberFromRow(row); + final updatedAtStr = row['updated_at'] as String; + final updatedAt = DateTime.parse(updatedAtStr); + final contentHash = row['content_hash'] as String? ?? ''; + final actualPrev = row['previous_chain_hash'] as String?; + final actualHash = row['chain_hash'] as String?; + final expectedHash = _computeChainHash(expectedPreviousHash, contentHash, updatedAt, invoiceId); + + bool broken = false; + if ((expectedPreviousHash ?? '') != (actualPrev ?? '')) { + final info = HashChainBreak( + invoiceId: invoiceId, + invoiceNumber: invoiceNumber, + issue: 'previous_hash_mismatch', + expectedHash: expectedHash, + actualHash: actualHash, + expectedPreviousHash: expectedPreviousHash, + actualPreviousHash: actualPrev, + ); + breaks.add(info); + await _logChainBreak(info); + broken = true; + } + + if (actualHash == null || actualHash != expectedHash) { + final info = HashChainBreak( + invoiceId: invoiceId, + invoiceNumber: invoiceNumber, + issue: actualHash == null ? 'hash_missing' : 'hash_mismatch', + expectedHash: expectedHash, + actualHash: actualHash, + expectedPreviousHash: expectedPreviousHash, + actualPreviousHash: actualPrev, + ); + breaks.add(info); + await _logChainBreak(info); + broken = true; + } + + await txn.update( + 'invoices', + {'chain_status': broken ? 'broken' : 'healthy'}, + where: 'id = ?', + whereArgs: [invoiceId], + ); + + expectedPreviousHash = expectedHash; + } + + await _logRepo.logAction( + action: 'HASH_CHAIN_VERIFY', + targetType: 'INVOICE', + details: jsonEncode({ + 'checkedCount': rows.length, + 'breakCount': breaks.length, + 'verifiedAt': now.toIso8601String(), + }), + ); + + return HashChainVerificationResult( + isHealthy: breaks.isEmpty, + checkedCount: rows.length, + verifiedAt: now, + breaks: breaks, + ); + }); + } + + Future?> _fetchLatestChainEntry(DatabaseExecutor txn) async { + final rows = await txn.query( + 'invoices', + columns: ['chain_hash'], + where: 'COALESCE(is_draft, 0) = 0 AND chain_hash IS NOT NULL', + orderBy: 'updated_at DESC, id DESC', + limit: 1, + ); + if (rows.isEmpty) return null; + return rows.first; + } + + String _computeChainHash(String? previousHash, String contentHash, DateTime updatedAt, String id) { + final payload = '${previousHash ?? ''}|$contentHash|${updatedAt.toIso8601String()}|$id'; + return sha256.convert(utf8.encode(payload)).toString(); + } + + Future _logChainBreak(HashChainBreak info) async { + await _logRepo.logAction( + action: 'HASH_CHAIN_BREAK', + targetType: 'INVOICE', + targetId: info.invoiceId, + details: jsonEncode({ + 'issue': info.issue, + 'invoiceNumber': info.invoiceNumber, + 'expectedHash': info.expectedHash, + 'actualHash': info.actualHash, + 'expectedPreviousHash': info.expectedPreviousHash, + 'actualPreviousHash': info.actualPreviousHash, + }), + ); + } + + String _buildInvoiceNumberFromRow(Map row) { + final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name; + DocumentType docType; + try { + docType = DocumentType.values.firstWhere((e) => e.name == docTypeName); + } catch (_) { + docType = DocumentType.invoice; + } + final prefix = _documentPrefix(docType); + final terminalId = row['terminal_id'] as String? ?? 'T1'; + final dateStr = row['date'] as String? ?? row['updated_at'] as String; + final date = DateTime.tryParse(dateStr) ?? DateTime.now(); + final id = row['id'] as String; + final suffix = id.length >= 4 ? id.substring(id.length - 4) : id; + final formatter = DateFormat('yyyyMMdd'); + return '$prefix-$terminalId-${formatter.format(date)}-$suffix'; + } + + String _documentPrefix(DocumentType type) { + switch (type) { + case DocumentType.estimation: + return 'EST'; + case DocumentType.delivery: + return 'DEL'; + case DocumentType.invoice: + return 'INV'; + case DocumentType.receipt: + return 'REC'; + } + } + + Future fetchSalesSummary({ + required int year, + DocumentType? documentType, + bool includeDrafts = false, + int topCustomerLimit = 5, + }) async { + final db = await _dbHelper.database; + final baseArgs = [year.toString()]; + final whereBuffer = StringBuffer("strftime('%Y', date) = ?"); + if (documentType != null) { + whereBuffer.write(" AND document_type = ?"); + baseArgs.add(documentType.name); + } + if (!includeDrafts) { + whereBuffer.write(" AND COALESCE(is_draft, 0) = 0"); + } + final whereClause = whereBuffer.toString(); + + final monthlyRows = await db.rawQuery( + ''' + SELECT CAST(strftime('%m', date) AS INTEGER) as month, SUM(total_amount) as total FROM invoices - WHERE strftime('%Y', date) = ? AND document_type = 'invoice' + WHERE $whereClause GROUP BY month ORDER BY month ASC - ''', [yearStr]); + '''. + trim(), + List.from(baseArgs), + ); - Map monthlyTotal = {}; - for (var r in results) { - monthlyTotal[r['month']] = (r['total'] as num).toInt(); + final monthlyTotals = {}; + for (final row in monthlyRows) { + if (row['month'] == null || row['total'] == null) continue; + monthlyTotals[(row['month'] as num).toInt()] = (row['total'] as num).toInt(); } - return monthlyTotal; + + final yearlyTotal = monthlyTotals.values.fold(0, (sum, value) => sum + value); + + final customerRows = await db.rawQuery( + ''' + SELECT COALESCE(customer_formal_name, customer_id) as customer_name, SUM(total_amount) as total + FROM invoices + WHERE $whereClause + GROUP BY customer_name + ORDER BY total DESC + LIMIT ? + '''. + trim(), + [...baseArgs, topCustomerLimit], + ); + + final customerStats = customerRows + .where((row) => row['customer_name'] != null && row['total'] != null) + .map( + (row) => SalesCustomerStat( + customerName: row['customer_name'] as String, + totalAmount: (row['total'] as num).toInt(), + ), + ) + .toList(); + + return SalesSummary( + year: year, + documentType: documentType, + monthlyTotals: monthlyTotals, + yearlyTotal: yearlyTotal, + customerStats: customerStats, + ); + } + + Future> getMonthlySales(int year) async { + final summary = await fetchSalesSummary(year: year); + return summary.monthlyTotals.map((key, value) => MapEntry(key.toString().padLeft(2, '0'), value)); } Future getYearlyTotal(int year) async { - final db = await _dbHelper.database; - final List> results = await db.rawQuery(''' - SELECT SUM(total_amount) as total - FROM invoices - WHERE strftime('%Y', date) = ? AND document_type = 'invoice' - ''', [year.toString()]); - - if (results.isEmpty || results.first['total'] == null) return 0; - return (results.first['total'] as num).toInt(); + final summary = await fetchSalesSummary(year: year); + return summary.yearlyTotal; } } diff --git a/lib/services/order_service.dart b/lib/services/order_service.dart new file mode 100644 index 0000000..4653f8f --- /dev/null +++ b/lib/services/order_service.dart @@ -0,0 +1,183 @@ +import 'package:uuid/uuid.dart'; + +import '../models/order_models.dart'; +import 'company_profile_service.dart'; +import 'sales_order_repository.dart'; + +class SalesOrderLineInput { + const SalesOrderLineInput({ + required this.description, + required this.quantity, + required this.unitPrice, + this.productId, + this.taxRate, + }); + + final String description; + final int quantity; + final int unitPrice; + final String? productId; + final double? taxRate; +} + +class SalesOrderService { + SalesOrderService({ + SalesOrderRepository? repository, + CompanyProfileService? companyProfileService, + }) : _repository = repository ?? SalesOrderRepository(), + _companyProfileService = companyProfileService ?? CompanyProfileService(); + + final SalesOrderRepository _repository; + final CompanyProfileService _companyProfileService; + final Uuid _uuid = const Uuid(); + + static const Map> _transitions = { + SalesOrderStatus.draft: [SalesOrderStatus.confirmed, SalesOrderStatus.cancelled], + SalesOrderStatus.confirmed: [SalesOrderStatus.picking, SalesOrderStatus.cancelled], + SalesOrderStatus.picking: [SalesOrderStatus.shipped, SalesOrderStatus.cancelled], + SalesOrderStatus.shipped: [SalesOrderStatus.closed], + SalesOrderStatus.closed: [], + SalesOrderStatus.cancelled: [], + }; + + Future> fetchOrders({SalesOrderStatus? status}) { + return _repository.fetchOrders(status: status); + } + + Future createOrder({ + required String customerId, + required String customerName, + List lines = const [], + DateTime? requestedShipDate, + String? notes, + String? assignedTo, + }) async { + final profile = await _companyProfileService.loadProfile(); + final now = DateTime.now(); + final orderId = _uuid.v4(); + final lineItems = _buildItems(orderId, lines); + final order = SalesOrder( + id: orderId, + orderNumber: _generateOrderNumber(now), + customerId: customerId, + customerNameSnapshot: customerName, + orderDate: now, + requestedShipDate: requestedShipDate, + status: SalesOrderStatus.draft, + subtotal: 0, + taxAmount: 0, + totalAmount: 0, + notes: notes, + assignedTo: assignedTo, + workflowStage: _workflowStage(SalesOrderStatus.draft), + createdAt: now, + updatedAt: now, + items: lineItems, + ).recalculateTotals(defaultTaxRate: profile.taxRate); + + await _repository.upsertOrder(order); + return order; + } + + Future updateOrder( + SalesOrder order, { + List? replacedLines, + DateTime? requestedShipDate, + String? notes, + String? assignedTo, + }) async { + final profile = await _companyProfileService.loadProfile(); + final now = DateTime.now(); + final nextItems = replacedLines != null ? _buildItems(order.id, replacedLines) : order.items; + final updated = order + .copyWith( + requestedShipDate: requestedShipDate ?? order.requestedShipDate, + notes: notes ?? order.notes, + assignedTo: assignedTo ?? order.assignedTo, + updatedAt: now, + items: nextItems, + ) + .recalculateTotals(defaultTaxRate: profile.taxRate); + await _repository.upsertOrder(updated); + return updated; + } + + Future transitionStatus(String orderId, SalesOrderStatus nextStatus, {bool force = false}) async { + final order = await _repository.findById(orderId); + if (order == null) { + throw StateError('order not found: $orderId'); + } + if (!force && !_canTransition(order.status, nextStatus)) { + throw StateError('invalid transition ${order.status.name} -> ${nextStatus.name}'); + } + final now = DateTime.now(); + final updated = order.copyWith( + status: nextStatus, + workflowStage: _workflowStage(nextStatus), + updatedAt: now, + ); + await _repository.upsertOrder(updated); + return updated; + } + + Future advanceStatus(String orderId) async { + final order = await _repository.findById(orderId); + if (order == null) { + throw StateError('order not found: $orderId'); + } + final candidates = _transitions[order.status]; + if (candidates == null || candidates.isEmpty) { + return order; + } + return transitionStatus(orderId, candidates.first); + } + + bool _canTransition(SalesOrderStatus current, SalesOrderStatus next) { + final allowed = _transitions[current]; + return allowed?.contains(next) ?? false; + } + + List nextStatuses(SalesOrderStatus current) { + return List.unmodifiable(_transitions[current] ?? const []); + } + + List _buildItems(String orderId, List lines) { + return lines.asMap().entries.map((entry) { + final index = entry.key; + final line = entry.value; + return SalesOrderItem( + id: _uuid.v4(), + orderId: orderId, + productId: line.productId, + description: line.description, + quantity: line.quantity, + unitPrice: line.unitPrice, + taxRate: line.taxRate ?? 0, + sortIndex: index, + ); + }).toList(); + } + + String _generateOrderNumber(DateTime timestamp) { + final datePart = '${timestamp.year}${timestamp.month.toString().padLeft(2, '0')}${timestamp.day.toString().padLeft(2, '0')}'; + final timePart = '${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}'; + return 'SO$datePart-$timePart-${timestamp.millisecondsSinceEpoch % 1000}'.toUpperCase(); + } + + String _workflowStage(SalesOrderStatus status) { + switch (status) { + case SalesOrderStatus.draft: + return 'order'; + case SalesOrderStatus.confirmed: + return 'ready'; + case SalesOrderStatus.picking: + return 'picking'; + case SalesOrderStatus.shipped: + return 'shipping'; + case SalesOrderStatus.closed: + return 'closed'; + case SalesOrderStatus.cancelled: + return 'cancelled'; + } + } +} diff --git a/lib/services/receivable_repository.dart b/lib/services/receivable_repository.dart new file mode 100644 index 0000000..3eb8968 --- /dev/null +++ b/lib/services/receivable_repository.dart @@ -0,0 +1,126 @@ +import 'package:intl/intl.dart'; + +import '../models/invoice_models.dart'; +import '../models/receivable_models.dart'; +import 'database_helper.dart'; + +class ReceivableRepository { + ReceivableRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + final DateFormat _invoiceNumberDateFormat = DateFormat('yyyyMMdd'); + + Future> fetchSummaries({bool includeSettled = false}) async { + final db = await _dbHelper.database; + final rows = await db.rawQuery(''' + SELECT i.id, i.customer_formal_name, i.date, i.total_amount, i.document_type, i.terminal_id, + COALESCE(SUM(p.amount), 0) AS paid_amount + FROM invoices i + LEFT JOIN receivable_payments p ON p.invoice_id = i.id + WHERE i.document_type = ? AND COALESCE(i.is_draft, 0) = 0 + GROUP BY i.id + HAVING (? = 1) OR (i.total_amount - COALESCE(SUM(p.amount), 0)) > 0 + ORDER BY i.date DESC + ''', [DocumentType.invoice.name, includeSettled ? 1 : 0]); + return rows.map(_mapToSummary).toList(); + } + + Future findSummaryById(String invoiceId) async { + final db = await _dbHelper.database; + final rows = await db.rawQuery(''' + SELECT i.id, i.customer_formal_name, i.date, i.total_amount, i.document_type, i.terminal_id, + COALESCE(SUM(p.amount), 0) AS paid_amount + FROM invoices i + LEFT JOIN receivable_payments p ON p.invoice_id = i.id + WHERE i.id = ? + GROUP BY i.id + LIMIT 1 + ''', [invoiceId]); + if (rows.isEmpty) return null; + return _mapToSummary(rows.first); + } + + Future> fetchPayments(String invoiceId) async { + final db = await _dbHelper.database; + final rows = await db.query( + 'receivable_payments', + where: 'invoice_id = ?', + whereArgs: [invoiceId], + orderBy: 'payment_date DESC, created_at DESC', + ); + return rows.map(ReceivablePayment.fromMap).toList(); + } + + Future findPaymentById(String paymentId) async { + final db = await _dbHelper.database; + final rows = await db.query( + 'receivable_payments', + where: 'id = ?', + whereArgs: [paymentId], + limit: 1, + ); + if (rows.isEmpty) return null; + return ReceivablePayment.fromMap(rows.first); + } + + Future insertPayment(ReceivablePayment payment) async { + final db = await _dbHelper.database; + await db.insert('receivable_payments', payment.toMap()); + } + + Future deletePayment(String paymentId) async { + final db = await _dbHelper.database; + await db.delete('receivable_payments', where: 'id = ?', whereArgs: [paymentId]); + } + + ReceivableInvoiceSummary _mapToSummary(Map row) { + final invoiceDate = DateTime.parse(row['date'] as String); + final dueDate = invoiceDate.add(const Duration(days: 30)); + final totalAmount = row['total_amount'] as int? ?? 0; + final paidAmount = row['paid_amount'] as int? ?? 0; + final documentType = DocumentType.values.firstWhere( + (type) => type.name == row['document_type'], + orElse: () => DocumentType.invoice, + ); + final invoiceNumber = _buildInvoiceNumber( + prefix: _documentPrefix(documentType), + terminalId: row['terminal_id'] as String? ?? 'T1', + invoiceDate: invoiceDate, + id: row['id'] as String, + ); + + return ReceivableInvoiceSummary( + invoiceId: row['id'] as String, + invoiceNumber: invoiceNumber, + customerName: row['customer_formal_name'] as String? ?? '-', + invoiceDate: invoiceDate, + dueDate: dueDate, + totalAmount: totalAmount, + paidAmount: paidAmount, + ); + } + + String _buildInvoiceNumber({ + required String prefix, + required String terminalId, + required DateTime invoiceDate, + required String id, + }) { + final suffix = id.length >= 4 ? id.substring(id.length - 4) : id; + final datePart = _invoiceNumberDateFormat.format(invoiceDate); + return '$prefix-$terminalId-$datePart-$suffix'; + } + + String _documentPrefix(DocumentType type) { + switch (type) { + case DocumentType.estimation: + return 'EST'; + case DocumentType.delivery: + return 'DEL'; + case DocumentType.invoice: + return 'INV'; + case DocumentType.receipt: + return 'REC'; + } + } +} diff --git a/lib/services/receivable_service.dart b/lib/services/receivable_service.dart new file mode 100644 index 0000000..dfa9d22 --- /dev/null +++ b/lib/services/receivable_service.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:uuid/uuid.dart'; + +import '../models/hash_chain_models.dart'; +import '../models/receivable_models.dart'; +import 'business_calendar_mapper.dart'; +import 'invoice_repository.dart'; +import 'receivable_repository.dart'; + +class ReceivableService { + ReceivableService({ReceivableRepository? repository, BusinessCalendarMapper? calendarMapper}) + : _repository = repository ?? ReceivableRepository(), + _calendarMapper = calendarMapper ?? BusinessCalendarMapper(); + + final ReceivableRepository _repository; + final InvoiceRepository _invoiceRepository = InvoiceRepository(); + final BusinessCalendarMapper _calendarMapper; + final Uuid _uuid = const Uuid(); + + Future> fetchSummaries({bool includeSettled = false}) async { + final summaries = await _repository.fetchSummaries(includeSettled: includeSettled); + unawaited(_calendarMapper.syncReceivables(summaries)); + return summaries; + } + + Future findSummary(String invoiceId) async { + final summary = await _repository.findSummaryById(invoiceId); + if (summary != null) { + unawaited(_calendarMapper.syncReceivable(summary)); + } + return summary; + } + + Future> fetchPayments(String invoiceId) { + return _repository.fetchPayments(invoiceId); + } + + Future addPayment({ + required String invoiceId, + required int amount, + required DateTime paymentDate, + required PaymentMethod method, + String? notes, + }) async { + if (amount <= 0) { + throw ArgumentError('amount must be greater than 0'); + } + final payment = ReceivablePayment( + id: _uuid.v4(), + invoiceId: invoiceId, + amount: amount, + paymentDate: paymentDate, + method: method, + notes: notes, + createdAt: DateTime.now(), + ); + await _repository.insertPayment(payment); + final summary = await _repository.findSummaryById(invoiceId); + if (summary != null) { + unawaited(_calendarMapper.syncReceivable(summary)); + } + } + + Future deletePayment(String paymentId) async { + final payment = await _repository.findPaymentById(paymentId); + await _repository.deletePayment(paymentId); + if (payment != null) { + final summary = await _repository.findSummaryById(payment.invoiceId); + if (summary != null) { + unawaited(_calendarMapper.syncReceivable(summary)); + } + } + } + + Future verifyHashChain() { + return _invoiceRepository.verifyHashChain(); + } +} diff --git a/lib/services/sales_entry_repository.dart b/lib/services/sales_entry_repository.dart new file mode 100644 index 0000000..0374dd1 --- /dev/null +++ b/lib/services/sales_entry_repository.dart @@ -0,0 +1,83 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/sales_entry_models.dart'; +import 'database_helper.dart'; + +class SalesEntryRepository { + SalesEntryRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future upsertEntry(SalesEntry entry) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.insert('sales_entries', entry.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + await txn.delete('sales_line_items', where: 'sales_entry_id = ?', whereArgs: [entry.id]); + for (final item in entry.items) { + await txn.insert('sales_line_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future findById(String id) async { + final db = await _dbHelper.database; + final rows = await db.query('sales_entries', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + final items = await _fetchItems(db, id); + return SalesEntry.fromMap(rows.first, items: items); + } + + Future> fetchEntries({SalesEntryStatus? status, int? limit}) async { + final db = await _dbHelper.database; + final whereClauses = []; + final whereArgs = []; + if (status != null) { + whereClauses.add('status = ?'); + whereArgs.add(status.name); + } + final rows = await db.query( + 'sales_entries', + where: whereClauses.isNotEmpty ? whereClauses.join(' AND ') : null, + whereArgs: whereClauses.isNotEmpty ? whereArgs : null, + orderBy: 'issue_date DESC, updated_at DESC', + limit: limit, + ); + final result = []; + for (final row in rows) { + final items = await _fetchItems(db, row['id'] as String); + result.add(SalesEntry.fromMap(row, items: items)); + } + return result; + } + + Future deleteEntry(String id) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.delete('sales_line_items', where: 'sales_entry_id = ?', whereArgs: [id]); + await txn.delete('sales_entry_sources', where: 'sales_entry_id = ?', whereArgs: [id]); + await txn.delete('sales_receipt_links', where: 'sales_entry_id = ?', whereArgs: [id]); + await txn.delete('sales_entries', where: 'id = ?', whereArgs: [id]); + }); + } + + Future upsertSources(String salesEntryId, List sources) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.delete('sales_entry_sources', where: 'sales_entry_id = ?', whereArgs: [salesEntryId]); + for (final source in sources) { + await txn.insert('sales_entry_sources', source.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future> fetchSources(String salesEntryId) async { + final db = await _dbHelper.database; + final rows = await db.query('sales_entry_sources', where: 'sales_entry_id = ?', whereArgs: [salesEntryId], orderBy: 'imported_at DESC'); + return rows.map(SalesEntrySource.fromMap).toList(); + } + + Future> _fetchItems(DatabaseExecutor db, String entryId) async { + final rows = await db.query('sales_line_items', where: 'sales_entry_id = ?', whereArgs: [entryId]); + return rows.map(SalesLineItem.fromMap).toList(); + } +} diff --git a/lib/services/sales_entry_service.dart b/lib/services/sales_entry_service.dart new file mode 100644 index 0000000..b888527 --- /dev/null +++ b/lib/services/sales_entry_service.dart @@ -0,0 +1,330 @@ +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/invoice_models.dart'; +import '../models/sales_entry_models.dart'; +import 'database_helper.dart'; +import 'sales_entry_repository.dart'; + +class SalesEntryService { + SalesEntryService({ + SalesEntryRepository? salesEntryRepository, + DatabaseHelper? databaseHelper, + }) : _salesEntryRepository = salesEntryRepository ?? SalesEntryRepository(), + _dbHelper = databaseHelper ?? DatabaseHelper(); + + final SalesEntryRepository _salesEntryRepository; + final DatabaseHelper _dbHelper; + final Uuid _uuid = const Uuid(); + final DateFormat _numberDateFormat = DateFormat('yyyyMMdd'); + + Future> fetchEntries({SalesEntryStatus? status, int? limit}) { + return _salesEntryRepository.fetchEntries(status: status, limit: limit); + } + + Future findById(String id) { + return _salesEntryRepository.findById(id); + } + + Future deleteEntry(String id) { + return _salesEntryRepository.deleteEntry(id); + } + + Future saveEntry(SalesEntry entry) async { + final recalculated = entry.recalcTotals().copyWith(updatedAt: DateTime.now()); + await _salesEntryRepository.upsertEntry(recalculated); + return recalculated; + } + + Future> fetchImportCandidates({ + String? keyword, + Set? documentTypes, + DateTime? startDate, + DateTime? endDate, + bool includeDrafts = false, + }) async { + final db = await _dbHelper.database; + final whereClauses = []; + final args = []; + + if (!includeDrafts) { + whereClauses.add('COALESCE(is_draft, 0) = 0'); + } + if (keyword != null && keyword.trim().isNotEmpty) { + whereClauses.add('(customer_formal_name LIKE ? OR subject LIKE ?)'); + final like = '%${keyword.trim()}%'; + args..add(like)..add(like); + } + if (startDate != null) { + whereClauses.add('date >= ?'); + args.add(startDate.toIso8601String()); + } + if (endDate != null) { + whereClauses.add('date <= ?'); + args.add(endDate.toIso8601String()); + } + if (documentTypes != null && documentTypes.isNotEmpty) { + final placeholders = List.filled(documentTypes.length, '?').join(','); + whereClauses.add('document_type IN ($placeholders)'); + args.addAll(documentTypes.map((d) => d.name)); + } + + final whereSql = whereClauses.isEmpty ? '' : 'WHERE ${whereClauses.join(' AND ')}'; + final rows = await db.rawQuery(''' + SELECT id, customer_formal_name, date, total_amount, document_type, terminal_id, + subject, is_locked, chain_status, content_hash + FROM invoices + $whereSql + ORDER BY date DESC, id DESC + LIMIT 200 + ''', args); + + return rows.map((row) { + final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name; + final documentType = DocumentType.values.firstWhere( + (type) => type.name == docTypeName, + orElse: () => DocumentType.invoice, + ); + final invoiceNumber = _buildInvoiceNumber( + documentType, + row['terminal_id'] as String? ?? 'T1', + DateTime.parse(row['date'] as String), + row['id'] as String, + ); + final map = { + 'id': row['id'], + 'invoice_number': invoiceNumber, + 'document_type': documentType.name, + 'date': row['date'], + 'customer_name': row['customer_formal_name'], + 'total_amount': row['total_amount'], + 'is_locked': row['is_locked'], + 'chain_status': row['chain_status'], + 'content_hash': row['content_hash'], + 'subject': row['subject'], + }; + return SalesImportCandidate.fromMap(map); + }).toList(); + } + + Future createEntryFromInvoices(List invoiceIds, {String? subject, DateTime? issueDate}) async { + if (invoiceIds.isEmpty) { + throw ArgumentError('invoiceIds must not be empty'); + } + final invoices = await _loadInvoiceData(invoiceIds); + if (invoices.isEmpty) { + throw StateError('指定された伝票が見つかりませんでした'); + } + final now = DateTime.now(); + final entryId = _uuid.v4(); + final built = _buildEntryFromInvoices( + invoices: invoices, + entryId: entryId, + baseEntry: null, + subjectOverride: subject, + issueDateOverride: issueDate, + now: now, + ); + await _salesEntryRepository.upsertEntry(built.entry); + await _salesEntryRepository.upsertSources(built.entry.id, built.sources); + return built.entry; + } + + Future reimportEntry(String salesEntryId) async { + final existing = await _salesEntryRepository.findById(salesEntryId); + if (existing == null) { + throw StateError('sales entry not found: $salesEntryId'); + } + final sources = await _salesEntryRepository.fetchSources(salesEntryId); + if (sources.isEmpty) { + throw StateError('再インポート対象の元伝票が登録されていません'); + } + final invoiceIds = sources.map((s) => s.invoiceId).toList(); + final invoices = await _loadInvoiceData(invoiceIds); + if (invoices.isEmpty) { + throw StateError('元伝票が見つかりませんでした'); + } + final now = DateTime.now(); + final built = _buildEntryFromInvoices( + invoices: invoices, + entryId: existing.id, + baseEntry: existing, + now: now, + ); + await _salesEntryRepository.upsertEntry(built.entry); + await _salesEntryRepository.upsertSources(existing.id, built.sources); + return built.entry; + } + + _ImportBuildResult _buildEntryFromInvoices({ + required List invoices, + required String entryId, + SalesEntry? baseEntry, + String? subjectOverride, + DateTime? issueDateOverride, + required DateTime now, + }) { + final items = []; + for (final invoice in invoices) { + for (final line in invoice.items) { + items.add( + SalesLineItem( + id: _uuid.v4(), + salesEntryId: entryId, + description: '[${invoice.documentType.displayName}] ${line.description}', + quantity: line.quantity, + unitPrice: line.unitPrice, + lineTotal: line.subtotal, + productId: line.productId, + taxRate: invoice.taxRate, + sourceInvoiceId: invoice.invoiceId, + sourceInvoiceItemId: line.id, + ), + ); + } + } + final issueDate = issueDateOverride ?? _latestDate(invoices) ?? baseEntry?.issueDate ?? now; + final subject = subjectOverride ?? baseEntry?.subject ?? _deriveSubject(invoices); + final customerId = _commonCustomerId(invoices) ?? baseEntry?.customerId; + final customerNameSnapshot = _deriveCustomerSnapshot(invoices) ?? baseEntry?.customerNameSnapshot; + + final entry = (baseEntry ?? + SalesEntry( + id: entryId, + customerId: customerId, + customerNameSnapshot: customerNameSnapshot, + subject: subject, + issueDate: issueDate, + status: SalesEntryStatus.draft, + notes: baseEntry?.notes, + createdAt: baseEntry?.createdAt ?? now, + updatedAt: now, + items: items, + )) + .copyWith( + customerId: customerId, + customerNameSnapshot: customerNameSnapshot, + subject: subject, + issueDate: issueDate, + status: baseEntry?.status ?? SalesEntryStatus.draft, + items: items, + updatedAt: now, + ) + .recalcTotals(); + + final sources = invoices + .map( + (inv) => SalesEntrySource( + id: _uuid.v4(), + salesEntryId: entry.id, + invoiceId: inv.invoiceId, + importedAt: now, + invoiceHashSnapshot: inv.contentHash, + ), + ) + .toList(); + + return _ImportBuildResult(entry: entry, sources: sources); + } + + Future> _loadInvoiceData(List invoiceIds) async { + if (invoiceIds.isEmpty) return []; + final db = await _dbHelper.database; + final placeholders = List.filled(invoiceIds.length, '?').join(','); + final invoiceRows = await db.rawQuery(''' + SELECT id, customer_id, customer_formal_name, date, tax_rate, total_amount, + document_type, subject, is_locked, chain_status, content_hash + FROM invoices + WHERE id IN ($placeholders) + ''', invoiceIds); + if (invoiceRows.isEmpty) return []; + + final itemRows = await db.query( + 'invoice_items', + where: 'invoice_id IN ($placeholders)', + whereArgs: invoiceIds, + ); + final itemsByInvoice = >{}; + for (final row in itemRows) { + final invoiceId = row['invoice_id'] as String; + final list = itemsByInvoice.putIfAbsent(invoiceId, () => []); + list.add(InvoiceItem.fromMap(row)); + } + + return invoiceRows.map((row) { + final docTypeName = row['document_type'] as String? ?? DocumentType.invoice.name; + final documentType = DocumentType.values.firstWhere( + (type) => type.name == docTypeName, + orElse: () => DocumentType.invoice, + ); + final invoiceId = row['id'] as String; + return SalesInvoiceImportData( + invoiceId: invoiceId, + documentType: documentType, + issueDate: DateTime.parse(row['date'] as String), + taxRate: (row['tax_rate'] as num?)?.toDouble() ?? 0.1, + totalAmount: (row['total_amount'] as num?)?.toInt() ?? 0, + items: itemsByInvoice[invoiceId] ?? const [], + isLocked: (row['is_locked'] as int?) == 1, + chainStatus: row['chain_status'] as String? ?? 'pending', + contentHash: row['content_hash'] as String? ?? '', + customerId: row['customer_id'] as String?, + customerFormalName: row['customer_formal_name'] as String?, + subject: row['subject'] as String?, + ); + }).toList(); + } + + String? _commonCustomerId(List invoices) { + final ids = invoices.map((e) => e.customerId).whereType().toSet(); + if (ids.length == 1) return ids.first; + return null; + } + + String? _deriveCustomerSnapshot(List invoices) { + final names = invoices.map((e) => e.customerFormalName).whereType().toSet(); + if (names.isEmpty) return null; + if (names.length == 1) return names.first; + return '複数取引先'; + } + + DateTime? _latestDate(List invoices) { + if (invoices.isEmpty) return null; + return invoices.map((e) => e.issueDate).reduce((a, b) => a.isAfter(b) ? a : b); + } + + String _deriveSubject(List invoices) { + if (invoices.isEmpty) return '売上伝票'; + if (invoices.length == 1) { + return invoices.first.subject ?? '${invoices.first.documentType.displayName}取込'; + } + return '複数伝票取込(${invoices.length}件)'; + } + + String _buildInvoiceNumber(DocumentType type, String terminalId, DateTime date, String id) { + final suffix = id.length >= 4 ? id.substring(id.length - 4) : id; + final datePart = _numberDateFormat.format(date); + final prefix = _documentPrefix(type); + return '$prefix-$terminalId-$datePart-$suffix'; + } + + String _documentPrefix(DocumentType type) { + switch (type) { + case DocumentType.estimation: + return 'EST'; + case DocumentType.delivery: + return 'DEL'; + case DocumentType.invoice: + return 'INV'; + case DocumentType.receipt: + return 'REC'; + } + } +} + +class _ImportBuildResult { + const _ImportBuildResult({required this.entry, required this.sources}); + + final SalesEntry entry; + final List sources; +} diff --git a/lib/services/sales_order_repository.dart b/lib/services/sales_order_repository.dart new file mode 100644 index 0000000..de4efa9 --- /dev/null +++ b/lib/services/sales_order_repository.dart @@ -0,0 +1,84 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/order_models.dart'; +import 'database_helper.dart'; + +class SalesOrderRepository { + SalesOrderRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future upsertOrder(SalesOrder order) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.insert('sales_orders', order.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + await txn.delete('sales_order_items', where: 'order_id = ?', whereArgs: [order.id]); + for (final item in order.items) { + await txn.insert('sales_order_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future> fetchOrders({SalesOrderStatus? status, int? limit}) async { + final db = await _dbHelper.database; + final whereClauses = []; + final whereArgs = []; + if (status != null) { + whereClauses.add('status = ?'); + whereArgs.add(status.name); + } + final orders = await db.query( + 'sales_orders', + where: whereClauses.isNotEmpty ? whereClauses.join(' AND ') : null, + whereArgs: whereClauses.isNotEmpty ? whereArgs : null, + orderBy: 'order_date DESC', + limit: limit, + ); + + final result = []; + for (final row in orders) { + final items = await _fetchItemsByOrderId(db, row['id'] as String); + result.add(SalesOrder.fromMap(row, items: items)); + } + return result; + } + + Future findById(String id) async { + final db = await _dbHelper.database; + final rows = await db.query('sales_orders', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + final items = await _fetchItemsByOrderId(db, id); + return SalesOrder.fromMap(rows.first, items: items); + } + + Future deleteOrder(String id) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.delete('sales_order_items', where: 'order_id = ?', whereArgs: [id]); + await txn.delete('sales_orders', where: 'id = ?', whereArgs: [id]); + }); + } + + Future updateStatus({required String orderId, required SalesOrderStatus status}) async { + final db = await _dbHelper.database; + await db.update( + 'sales_orders', + { + 'status': status.name, + 'updated_at': DateTime.now().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [orderId], + ); + } + + Future> _fetchItemsByOrderId(DatabaseExecutor db, String orderId) async { + final rows = await db.query( + 'sales_order_items', + where: 'order_id = ?', + whereArgs: [orderId], + orderBy: 'sort_index ASC, rowid ASC', + ); + return rows.map(SalesOrderItem.fromMap).toList(); + } +} diff --git a/lib/services/sales_receipt_repository.dart b/lib/services/sales_receipt_repository.dart new file mode 100644 index 0000000..da80d5d --- /dev/null +++ b/lib/services/sales_receipt_repository.dart @@ -0,0 +1,81 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/sales_entry_models.dart'; +import 'database_helper.dart'; + +class SalesReceiptRepository { + SalesReceiptRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future upsertReceipt(SalesReceipt receipt, List links) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.insert('sales_receipts', receipt.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + await txn.delete('sales_receipt_links', where: 'receipt_id = ?', whereArgs: [receipt.id]); + for (final link in links) { + await txn.insert('sales_receipt_links', link.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future> fetchReceipts({DateTime? startDate, DateTime? endDate}) async { + final db = await _dbHelper.database; + final whereClauses = []; + final args = []; + if (startDate != null) { + whereClauses.add('payment_date >= ?'); + args.add(startDate.toIso8601String()); + } + if (endDate != null) { + whereClauses.add('payment_date <= ?'); + args.add(endDate.toIso8601String()); + } + final rows = await db.query( + 'sales_receipts', + where: whereClauses.isEmpty ? null : whereClauses.join(' AND '), + whereArgs: whereClauses.isEmpty ? null : args, + orderBy: 'payment_date DESC, updated_at DESC', + ); + return rows.map(SalesReceipt.fromMap).toList(); + } + + Future findById(String id) async { + final db = await _dbHelper.database; + final rows = await db.query('sales_receipts', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + return SalesReceipt.fromMap(rows.first); + } + + Future> fetchLinks(String receiptId) async { + final db = await _dbHelper.database; + final rows = await db.query('sales_receipt_links', where: 'receipt_id = ?', whereArgs: [receiptId]); + return rows.map(SalesReceiptLink.fromMap).toList(); + } + + Future deleteReceipt(String id) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.delete('sales_receipt_links', where: 'receipt_id = ?', whereArgs: [id]); + await txn.delete('sales_receipts', where: 'id = ?', whereArgs: [id]); + }); + } + + Future> fetchAllocatedTotals(Iterable entryIds) async { + final ids = entryIds.where((id) => id.isNotEmpty).toSet().toList(); + if (ids.isEmpty) return {}; + final db = await _dbHelper.database; + final placeholders = List.filled(ids.length, '?').join(','); + final rows = await db.rawQuery( + 'SELECT sales_entry_id, SUM(allocated_amount) AS total FROM sales_receipt_links WHERE sales_entry_id IN ($placeholders) GROUP BY sales_entry_id', + ids, + ); + final result = {}; + for (final row in rows) { + final entryId = row['sales_entry_id'] as String?; + if (entryId == null) continue; + result[entryId] = (row['total'] as num?)?.toInt() ?? 0; + } + return result; + } +} diff --git a/lib/services/sales_receipt_service.dart b/lib/services/sales_receipt_service.dart new file mode 100644 index 0000000..9d1c0c7 --- /dev/null +++ b/lib/services/sales_receipt_service.dart @@ -0,0 +1,135 @@ +import 'package:uuid/uuid.dart'; + +import '../models/sales_entry_models.dart'; +import 'sales_entry_repository.dart'; +import 'sales_receipt_repository.dart'; + +class SalesReceiptService { + SalesReceiptService({ + SalesReceiptRepository? receiptRepository, + SalesEntryRepository? entryRepository, + }) : _receiptRepository = receiptRepository ?? SalesReceiptRepository(), + _entryRepository = entryRepository ?? SalesEntryRepository(); + + final SalesReceiptRepository _receiptRepository; + final SalesEntryRepository _entryRepository; + final Uuid _uuid = const Uuid(); + + Future> fetchReceipts({DateTime? startDate, DateTime? endDate}) { + return _receiptRepository.fetchReceipts(startDate: startDate, endDate: endDate); + } + + Future> fetchAllocatedTotals(Iterable entryIds) { + return _receiptRepository.fetchAllocatedTotals(entryIds); + } + + Future> fetchLinks(String receiptId) { + return _receiptRepository.fetchLinks(receiptId); + } + + Future findById(String id) { + return _receiptRepository.findById(id); + } + + Future deleteReceipt(String id) { + return _receiptRepository.deleteReceipt(id); + } + + Future createReceipt({ + String? customerId, + required DateTime paymentDate, + required int amount, + String? method, + String? notes, + List allocations = const [], + }) async { + if (amount <= 0) { + throw ArgumentError('amount must be greater than 0'); + } + final receipt = SalesReceipt( + id: _uuid.v4(), + customerId: customerId, + paymentDate: paymentDate, + method: method, + amount: amount, + notes: notes, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + return _saveReceipt(receipt: receipt, allocations: allocations); + } + + Future updateReceipt({ + required SalesReceipt receipt, + List allocations = const [], + }) { + final updated = receipt.copyWith(updatedAt: DateTime.now()); + return _saveReceipt(receipt: updated, allocations: allocations); + } + + Future _saveReceipt({ + required SalesReceipt receipt, + required List allocations, + }) async { + final entries = await _loadEntries(allocations.map((a) => a.salesEntryId)); + final allocatedTotals = await _receiptRepository.fetchAllocatedTotals(entries.keys); + + final links = []; + for (final allocation in allocations) { + final entry = entries[allocation.salesEntryId]; + if (entry == null) { + throw StateError('売上伝票が見つかりません: ${allocation.salesEntryId}'); + } + final currentAllocated = allocatedTotals[entry.id] ?? 0; + final outstanding = entry.amountTaxIncl - currentAllocated; + if (allocation.amount > outstanding) { + throw StateError('割当額が未収残を超えています: ${entry.id}'); + } + links.add( + SalesReceiptLink( + receiptId: receipt.id, + salesEntryId: entry.id, + allocatedAmount: allocation.amount, + ), + ); + allocatedTotals[entry.id] = currentAllocated + allocation.amount; + } + + final totalAllocated = links.fold(0, (sum, link) => sum + link.allocatedAmount); + if (totalAllocated > receipt.amount) { + throw StateError('割当総額が入金額を超えています'); + } + + await _receiptRepository.upsertReceipt(receipt, links); + await _updateEntryStatuses(entries.values, allocatedTotals); + return receipt; + } + + Future _updateEntryStatuses(Iterable entries, Map allocatedTotals) async { + for (final entry in entries) { + final allocated = allocatedTotals[entry.id] ?? 0; + SalesEntryStatus newStatus; + if (allocated >= entry.amountTaxIncl) { + newStatus = SalesEntryStatus.settled; + } else if (allocated > 0) { + newStatus = SalesEntryStatus.confirmed; + } else { + newStatus = entry.status; + } + if (newStatus != entry.status) { + await _entryRepository.upsertEntry(entry.copyWith(status: newStatus, updatedAt: DateTime.now())); + } + } + } + + Future> _loadEntries(Iterable entryIds) async { + final map = {}; + for (final id in entryIds) { + final entry = await _entryRepository.findById(id); + if (entry != null) { + map[id] = entry; + } + } + return map; + } +} diff --git a/lib/services/shipment_repository.dart b/lib/services/shipment_repository.dart new file mode 100644 index 0000000..798df18 --- /dev/null +++ b/lib/services/shipment_repository.dart @@ -0,0 +1,82 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/shipment_models.dart'; +import 'database_helper.dart'; + +class ShipmentRepository { + ShipmentRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future upsertShipment(Shipment shipment) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.insert('shipments', shipment.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + await txn.delete('shipment_items', where: 'shipment_id = ?', whereArgs: [shipment.id]); + for (final item in shipment.items) { + await txn.insert('shipment_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future> fetchShipments({ShipmentStatus? status, int? limit}) async { + final db = await _dbHelper.database; + final whereClauses = []; + final whereArgs = []; + if (status != null) { + whereClauses.add('status = ?'); + whereArgs.add(status.name); + } + final rows = await db.query( + 'shipments', + where: whereClauses.isNotEmpty ? whereClauses.join(' AND ') : null, + whereArgs: whereClauses.isNotEmpty ? whereArgs : null, + orderBy: 'scheduled_ship_date IS NULL, scheduled_ship_date ASC, updated_at DESC', + limit: limit, + ); + return Future.wait(rows.map((row) async { + final items = await _fetchItemsByShipmentId(db, row['id'] as String); + return Shipment.fromMap(row, items: items); + })); + } + + Future findById(String id) async { + final db = await _dbHelper.database; + final rows = await db.query('shipments', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + final items = await _fetchItemsByShipmentId(db, id); + return Shipment.fromMap(rows.first, items: items); + } + + Future deleteShipment(String id) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.delete('shipment_items', where: 'shipment_id = ?', whereArgs: [id]); + await txn.delete('shipments', where: 'id = ?', whereArgs: [id]); + }); + } + + Future updateStatus({required String shipmentId, required ShipmentStatus status, DateTime? actualShipDate}) async { + final db = await _dbHelper.database; + await db.update( + 'shipments', + { + 'status': status.name, + 'actual_ship_date': actualShipDate?.toIso8601String(), + 'updated_at': DateTime.now().toIso8601String(), + }, + where: 'id = ?', + whereArgs: [shipmentId], + ); + } + + Future> _fetchItemsByShipmentId(DatabaseExecutor db, String shipmentId) async { + final rows = await db.query( + 'shipment_items', + where: 'shipment_id = ?', + whereArgs: [shipmentId], + orderBy: 'rowid ASC', + ); + return rows.map(ShipmentItem.fromMap).toList(); + } +} diff --git a/lib/services/shipment_service.dart b/lib/services/shipment_service.dart new file mode 100644 index 0000000..d24966b --- /dev/null +++ b/lib/services/shipment_service.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:uuid/uuid.dart'; + +import '../models/shipment_models.dart'; +import 'business_calendar_mapper.dart'; +import 'sales_order_repository.dart'; +import 'shipment_repository.dart'; + +class ShipmentLineInput { + const ShipmentLineInput({ + required this.description, + required this.quantity, + this.orderItemId, + }); + + final String description; + final int quantity; + final String? orderItemId; +} + +class ShipmentService { + ShipmentService({ + ShipmentRepository? shipmentRepository, + SalesOrderRepository? orderRepository, + BusinessCalendarMapper? calendarMapper, + }) : _shipmentRepository = shipmentRepository ?? ShipmentRepository(), + _orderRepository = orderRepository ?? SalesOrderRepository(), + _calendarMapper = calendarMapper ?? BusinessCalendarMapper(); + + final ShipmentRepository _shipmentRepository; + final SalesOrderRepository _orderRepository; + final BusinessCalendarMapper _calendarMapper; + final Uuid _uuid = const Uuid(); + + static const Map> _transitions = { + ShipmentStatus.pending: [ShipmentStatus.picking, ShipmentStatus.cancelled], + ShipmentStatus.picking: [ShipmentStatus.ready, ShipmentStatus.cancelled], + ShipmentStatus.ready: [ShipmentStatus.shipped, ShipmentStatus.cancelled], + ShipmentStatus.shipped: [ShipmentStatus.delivered], + ShipmentStatus.delivered: [], + ShipmentStatus.cancelled: [], + }; + + Future> fetchShipments({ShipmentStatus? status}) { + return _shipmentRepository.fetchShipments(status: status); + } + + Future createShipment({ + String? orderId, + String? orderNumberSnapshot, + String? customerNameSnapshot, + List lines = const [], + DateTime? scheduledShipDate, + DateTime? actualShipDate, + String? carrierName, + String? trackingNumber, + String? trackingUrl, + String? labelPdfPath, + String? notes, + }) async { + String? resolvedOrderId = orderId; + String? resolvedOrderNumber = orderNumberSnapshot; + String? resolvedCustomerName = customerNameSnapshot; + + if (resolvedOrderId != null && (resolvedOrderNumber == null || resolvedCustomerName == null)) { + final order = await _orderRepository.findById(resolvedOrderId); + if (order != null) { + resolvedOrderNumber = order.orderNumber ?? order.id.substring(0, 6); + resolvedCustomerName = order.customerNameSnapshot; + } + } + + resolvedCustomerName ??= '未設定'; + + final now = DateTime.now(); + final shipmentId = _uuid.v4(); + final shipment = Shipment( + id: shipmentId, + orderId: resolvedOrderId, + orderNumberSnapshot: resolvedOrderNumber, + customerNameSnapshot: resolvedCustomerName, + scheduledShipDate: scheduledShipDate, + actualShipDate: actualShipDate, + status: ShipmentStatus.pending, + carrierName: carrierName, + trackingNumber: trackingNumber, + trackingUrl: trackingUrl, + labelPdfPath: labelPdfPath, + notes: notes, + pickingCompletedAt: null, + createdAt: now, + updatedAt: now, + items: _buildItems(shipmentId, lines), + ); + await _shipmentRepository.upsertShipment(shipment); + unawaited(_calendarMapper.syncShipment(shipment)); + return shipment; + } + + Future updateShipment( + Shipment shipment, { + List? replacedLines, + DateTime? scheduledShipDate, + DateTime? actualShipDate, + String? carrierName, + String? trackingNumber, + String? trackingUrl, + String? labelPdfPath, + String? notes, + }) async { + final updated = shipment.copyWith( + scheduledShipDate: scheduledShipDate ?? shipment.scheduledShipDate, + actualShipDate: actualShipDate ?? shipment.actualShipDate, + carrierName: carrierName ?? shipment.carrierName, + trackingNumber: trackingNumber ?? shipment.trackingNumber, + trackingUrl: trackingUrl ?? shipment.trackingUrl, + labelPdfPath: labelPdfPath ?? shipment.labelPdfPath, + notes: notes ?? shipment.notes, + updatedAt: DateTime.now(), + items: replacedLines != null ? _buildItems(shipment.id, replacedLines) : shipment.items, + ); + await _shipmentRepository.upsertShipment(updated); + unawaited(_calendarMapper.syncShipment(updated)); + return updated; + } + + Future transitionStatus(String shipmentId, ShipmentStatus nextStatus, {bool force = false}) async { + final shipment = await _shipmentRepository.findById(shipmentId); + if (shipment == null) { + throw StateError('shipment not found: $shipmentId'); + } + if (!force && !_canTransition(shipment.status, nextStatus)) { + throw StateError('invalid transition ${shipment.status.name} -> ${nextStatus.name}'); + } + final now = DateTime.now(); + final updated = shipment.copyWith( + status: nextStatus, + updatedAt: now, + actualShipDate: nextStatus == ShipmentStatus.shipped || nextStatus == ShipmentStatus.delivered + ? (shipment.actualShipDate ?? now) + : shipment.actualShipDate, + pickingCompletedAt: nextStatus == ShipmentStatus.ready ? (shipment.pickingCompletedAt ?? now) : shipment.pickingCompletedAt, + ); + await _shipmentRepository.upsertShipment(updated); + unawaited(_calendarMapper.syncShipment(updated)); + return updated; + } + + Future advanceStatus(String shipmentId) async { + final shipment = await _shipmentRepository.findById(shipmentId); + if (shipment == null) { + throw StateError('shipment not found: $shipmentId'); + } + final candidates = _transitions[shipment.status]; + if (candidates == null || candidates.isEmpty) { + return shipment; + } + return transitionStatus(shipmentId, candidates.first); + } + + List nextStatuses(ShipmentStatus current) { + return List.unmodifiable(_transitions[current] ?? const []); + } + + bool _canTransition(ShipmentStatus current, ShipmentStatus next) { + final allowed = _transitions[current]; + return allowed?.contains(next) ?? false; + } + + List _buildItems(String shipmentId, List lines) { + return lines.asMap().entries.map((entry) { + final line = entry.value; + return ShipmentItem( + id: _uuid.v4(), + shipmentId: shipmentId, + orderItemId: line.orderItemId, + description: line.description, + quantity: line.quantity, + ); + }).toList(); + } +} diff --git a/lib/services/shipping_label_service.dart b/lib/services/shipping_label_service.dart new file mode 100644 index 0000000..ec69d14 --- /dev/null +++ b/lib/services/shipping_label_service.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:intl/intl.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +import '../models/shipment_models.dart'; +import 'company_repository.dart'; + +class ShippingLabelService { + ShippingLabelService({CompanyRepository? companyRepository}) + : _companyRepository = companyRepository ?? CompanyRepository(); + + final CompanyRepository _companyRepository; + + Future generateLabel(Shipment shipment) async { + try { + final company = await _companyRepository.getCompanyInfo(); + final fontData = await rootBundle.load('assets/fonts/ipaexg.ttf'); + final ipaex = pw.Font.ttf(fontData); + final dateFormat = DateFormat('yyyy/MM/dd'); + + final pdf = pw.Document(); + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a5, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('出荷ラベル', style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold, font: ipaex)), + pw.SizedBox(height: 8), + pw.Text('発行日: ${dateFormat.format(DateTime.now())}', style: pw.TextStyle(font: ipaex)), + pw.Divider(), + pw.Text('差出人', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)), + pw.Text(company.name, style: pw.TextStyle(font: ipaex)), + if (company.address != null) pw.Text(company.address!, style: pw.TextStyle(font: ipaex)), + if (company.tel != null) pw.Text('TEL: ${company.tel}', style: pw.TextStyle(font: ipaex)), + pw.SizedBox(height: 12), + pw.Text('宛先', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)), + pw.Text(shipment.customerNameSnapshot ?? '取引先未設定', style: pw.TextStyle(fontSize: 16, font: ipaex)), + if (shipment.orderNumberSnapshot != null) + pw.Text('受注番号: ${shipment.orderNumberSnapshot}', style: pw.TextStyle(font: ipaex)), + pw.SizedBox(height: 12), + pw.Text('配送情報', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)), + pw.Text('配送業者: ${shipment.carrierName ?? '-'}', style: pw.TextStyle(font: ipaex)), + pw.Text('追跡番号: ${shipment.trackingNumber ?? '-'}', style: pw.TextStyle(font: ipaex)), + if (shipment.trackingUrl != null && shipment.trackingUrl!.isNotEmpty) + pw.Text('追跡URL: ${shipment.trackingUrl}', style: pw.TextStyle(font: ipaex)), + pw.SizedBox(height: 12), + pw.Text('出荷明細', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)), + pw.SizedBox(height: 6), + if (shipment.items.isEmpty) + pw.Text('明細登録なし', style: pw.TextStyle(font: ipaex)) + else + pw.TableHelper.fromTextArray( + headers: const ['品目', '数量'], + data: shipment.items + .map( + (item) => [ + item.description, + item.quantity.toString(), + ], + ) + .toList(), + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex), + cellStyle: pw.TextStyle(font: ipaex), + columnWidths: const { + 0: pw.FlexColumnWidth(3), + 1: pw.FlexColumnWidth(1), + }, + ), + if (shipment.notes?.isNotEmpty == true) ...[ + pw.SizedBox(height: 12), + pw.Text('備考', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex)), + pw.Text(shipment.notes!, style: pw.TextStyle(font: ipaex)), + ], + ], + ); + }, + ), + ); + + final directory = await getApplicationDocumentsDirectory(); + final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + final orderFragment = shipment.orderNumberSnapshot ?? shipment.id.substring(0, 6); + final fileName = 'shipping_label_${orderFragment}_$timestamp.pdf'; + final file = File(p.join(directory.path, fileName)); + await file.writeAsBytes(await pdf.save()); + return file.path; + } catch (e) { + debugPrint('Shipping label generation failed: $e'); + return null; + } + } +} diff --git a/lib/services/staff_repository.dart b/lib/services/staff_repository.dart new file mode 100644 index 0000000..3e353e1 --- /dev/null +++ b/lib/services/staff_repository.dart @@ -0,0 +1,34 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/staff_model.dart'; +import 'database_helper.dart'; + +class StaffRepository { + StaffRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future> fetchStaff({bool includeInactive = true}) async { + final db = await _dbHelper.database; + final rows = await db.query( + 'staff_members', + where: includeInactive ? null : 'is_active = 1', + orderBy: 'name COLLATE NOCASE ASC', + ); + return rows.map(StaffMember.fromMap).toList(); + } + + Future saveStaff(StaffMember staff) async { + final db = await _dbHelper.database; + await db.insert( + 'staff_members', + staff.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future deleteStaff(String staffId) async { + final db = await _dbHelper.database; + await db.delete('staff_members', where: 'id = ?', whereArgs: [staffId]); + } +} diff --git a/lib/services/supplier_repository.dart b/lib/services/supplier_repository.dart new file mode 100644 index 0000000..ffcffba --- /dev/null +++ b/lib/services/supplier_repository.dart @@ -0,0 +1,34 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/supplier_model.dart'; +import 'database_helper.dart'; + +class SupplierRepository { + SupplierRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future> fetchSuppliers({bool includeHidden = false}) async { + final db = await _dbHelper.database; + final rows = await db.query( + 'suppliers', + where: includeHidden ? null : 'is_hidden = 0', + orderBy: 'name COLLATE NOCASE ASC', + ); + return rows.map((row) => Supplier.fromMap(row)).toList(); + } + + Future saveSupplier(Supplier supplier) async { + final db = await _dbHelper.database; + await db.insert( + 'suppliers', + supplier.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future deleteSupplier(String supplierId) async { + final db = await _dbHelper.database; + await db.delete('suppliers', where: 'id = ?', whereArgs: [supplierId]); + } +} diff --git a/lib/services/tax_setting_repository.dart b/lib/services/tax_setting_repository.dart new file mode 100644 index 0000000..ab32125 --- /dev/null +++ b/lib/services/tax_setting_repository.dart @@ -0,0 +1,27 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/tax_setting_model.dart'; +import 'database_helper.dart'; + +class TaxSettingRepository { + TaxSettingRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future fetchCurrentSetting() async { + final db = await _dbHelper.database; + final rows = await db.query('tax_settings', orderBy: 'updated_at DESC', limit: 1); + if (rows.isEmpty) { + final now = DateTime.now(); + final defaultSetting = TaxSetting(id: 'default', rate: 0.1, roundingMode: 'round', updatedAt: now); + await db.insert('tax_settings', defaultSetting.toMap()); + return defaultSetting; + } + return TaxSetting.fromMap(rows.first); + } + + Future saveSetting(TaxSetting setting) async { + final db = await _dbHelper.database; + await db.insert('tax_settings', setting.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } +} diff --git a/lib/widgets/analytics/analytics_summary_card.dart b/lib/widgets/analytics/analytics_summary_card.dart new file mode 100644 index 0000000..f89434d --- /dev/null +++ b/lib/widgets/analytics/analytics_summary_card.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class AnalyticsSummaryCard extends StatelessWidget { + const AnalyticsSummaryCard({ + super.key, + required this.title, + required this.value, + this.subtitle, + this.icon, + this.color, + }); + + final String title; + final String value; + final String? subtitle; + final IconData? icon; + final Color? color; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final baseColor = color ?? theme.colorScheme.primary; + final bgColor = baseColor.withValues(alpha: 0.1); + final fgColor = baseColor; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Container( + width: 48, + height: 48, + decoration: BoxDecoration(color: fgColor, borderRadius: BorderRadius.circular(12)), + child: Icon(icon, color: Colors.white), + ), + if (icon != null) const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.labelLarge?.copyWith(color: fgColor)), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: fgColor)), + if (subtitle != null) ...[ + const SizedBox(height: 6), + Text(subtitle!, style: theme.textTheme.bodySmall?.copyWith(color: fgColor.withValues(alpha: 0.8))), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/analytics/empty_state_card.dart b/lib/widgets/analytics/empty_state_card.dart new file mode 100644 index 0000000..180af3b --- /dev/null +++ b/lib/widgets/analytics/empty_state_card.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class EmptyStateCard extends StatelessWidget { + const EmptyStateCard({super.key, required this.message, this.icon}); + + final String message; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: theme.dividerColor.withValues(alpha: 0.2)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon ?? Icons.analytics_outlined, color: theme.colorScheme.onSurface.withValues(alpha: 0.4), size: 48), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/invoice_form/invoice_form_variant.dart b/lib/widgets/invoice_form/invoice_form_variant.dart new file mode 100644 index 0000000..fe3be0f --- /dev/null +++ b/lib/widgets/invoice_form/invoice_form_variant.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../../models/invoice_models.dart'; + +class InvoiceFormVariant { + const InvoiceFormVariant({ + required this.id, + required this.title, + required this.defaultDocumentType, + this.heroDescription, + this.heroIcon, + }); + + final String id; + final String title; + final DocumentType defaultDocumentType; + final String? heroDescription; + final IconData? heroIcon; + + static const InvoiceFormVariant billingDocs = InvoiceFormVariant( + id: 'billing_docs', + title: 'A1:伝票入力', + defaultDocumentType: DocumentType.invoice, + heroDescription: '既存のA1伝票(見積/納品/請求/領収)を作成できます', + heroIcon: Icons.receipt_long, + ); + + static const InvoiceFormVariant salesSlip = InvoiceFormVariant( + id: 'sales_slip', + title: '売上伝票入力', + defaultDocumentType: DocumentType.invoice, + heroDescription: '売上伝票として販売実績を登録します', + heroIcon: Icons.point_of_sale, + ); +} diff --git a/lib/widgets/keyboard_inset_wrapper.dart b/lib/widgets/keyboard_inset_wrapper.dart index 776f6a7..d05de7b 100644 --- a/lib/widgets/keyboard_inset_wrapper.dart +++ b/lib/widgets/keyboard_inset_wrapper.dart @@ -20,13 +20,18 @@ class KeyboardInsetWrapper extends StatelessWidget { @override Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return SafeArea( - child: AnimatedPadding( - duration: duration, - curve: curve, - padding: basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom)), - child: child, + final mediaQuery = MediaQuery.of(context); + final bottomInset = mediaQuery.viewInsets.bottom; + final padding = basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom)); + return MediaQuery( + data: mediaQuery.removeViewInsets(removeBottom: true), + child: SafeArea( + child: AnimatedPadding( + duration: duration, + curve: curve, + padding: padding, + child: child, + ), ), ); } diff --git a/lib/widgets/line_item_editor.dart b/lib/widgets/line_item_editor.dart new file mode 100644 index 0000000..e09aa26 --- /dev/null +++ b/lib/widgets/line_item_editor.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +import '../models/product_model.dart'; + +/// 可変な明細行データを保持するフォームモデル。 +class LineItemFormData { + LineItemFormData({ + this.id, + this.productId, + String? productName, + int? quantity, + int? unitPrice, + this.taxRate, + int? costAmount, + bool? costIsProvisional, + }) : descriptionController = TextEditingController(text: productName ?? ''), + quantityController = TextEditingController(text: quantity?.toString() ?? ''), + unitPriceController = TextEditingController(text: unitPrice?.toString() ?? ''), + costAmount = costAmount ?? 0, + costIsProvisional = costIsProvisional ?? true; + + final String? id; + String? productId; + final TextEditingController descriptionController; + final TextEditingController quantityController; + final TextEditingController unitPriceController; + double? taxRate; + int costAmount; + bool costIsProvisional; + + bool get hasProduct => productId != null && productId!.isNotEmpty; + String get description => descriptionController.text; + int get quantityValue => int.tryParse(quantityController.text) ?? 0; + int get unitPriceValue => int.tryParse(unitPriceController.text) ?? 0; + + void applyProduct(Product product) { + productId = product.id; + descriptionController.text = product.name; + if (quantityController.text.trim().isEmpty || quantityController.text.trim() == '0') { + quantityController.text = '1'; + } + unitPriceController.text = product.defaultUnitPrice.toString(); + costAmount = product.wholesalePrice; + costIsProvisional = product.wholesalePrice <= 0; + } + + void registerChangeListener(VoidCallback listener) { + descriptionController.addListener(listener); + quantityController.addListener(listener); + unitPriceController.addListener(listener); + } + + void removeChangeListener(VoidCallback listener) { + descriptionController.removeListener(listener); + quantityController.removeListener(listener); + unitPriceController.removeListener(listener); + } + + void dispose() { + descriptionController.dispose(); + quantityController.dispose(); + unitPriceController.dispose(); + } +} + +/// 明細1行分を編集するカード。仕入/売上どちらの画面でも流用できるよう +/// 追加のメタ情報やフッターを挿入できるようにしている。 +class LineItemCard extends StatelessWidget { + const LineItemCard({ + super.key, + required this.data, + required this.onPickProduct, + required this.onRemove, + this.meta, + this.footer, + }); + + final LineItemFormData data; + final VoidCallback onPickProduct; + final VoidCallback onRemove; + final Widget? meta; + final Widget? footer; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + data.descriptionController.text.isEmpty ? '商品を選択' : data.descriptionController.text, + style: theme.textTheme.titleMedium, + ), + subtitle: data.hasProduct + ? null + : const Text( + '商品マスタから選択してください', + style: TextStyle(color: Colors.redAccent), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (meta != null) meta!, + const Icon(Icons.chevron_right), + ], + ), + onTap: onPickProduct, + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: TextField( + controller: data.quantityController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '数量'), + scrollPadding: const EdgeInsets.only(bottom: 160), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: data.unitPriceController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '単価(税抜)'), + scrollPadding: const EdgeInsets.only(bottom: 160), + ), + ), + IconButton(onPressed: onRemove, icon: const Icon(Icons.close)), + ], + ), + if (footer != null) ...[ + const SizedBox(height: 8), + footer!, + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/screen_id_title.dart b/lib/widgets/screen_id_title.dart new file mode 100644 index 0000000..1349485 --- /dev/null +++ b/lib/widgets/screen_id_title.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +/// AppBar 用の画面ID表示ウィジェット。 +/// 必ず2桁の ScreenID と正式タイトルをセットで表示し、 +/// サポート時に下段へ `ScreenID: XX` を出す。 +class ScreenAppBarTitle extends StatelessWidget { + const ScreenAppBarTitle({ + super.key, + required this.screenId, + required this.title, + this.caption, + }); + + final String screenId; + final String title; + final String? caption; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final primaryStyle = theme.appBarTheme.titleTextStyle ?? + theme.textTheme.titleMedium?.copyWith(color: Colors.white, fontWeight: FontWeight.w600); + final secondaryStyle = theme.textTheme.bodySmall?.copyWith(color: Colors.white70, fontSize: 11); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$screenId:$title', + style: primaryStyle, + ), + Text( + 'ScreenID: $screenId', + style: secondaryStyle, + ), + if (caption != null) + Text( + caption!, + style: theme.textTheme.bodySmall?.copyWith(color: Colors.white70), + ), + ], + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6bde8d8..b26fa74 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import file_selector_macos import geolocator_apple +import google_sign_in_ios import mobile_scanner import package_info_plus import printing @@ -18,6 +19,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 347a328..4bc392e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + sha256: "113c4100b90a5b70a983541782431b82168b3cae166ab130649c36eb3559d498" + url: "https://pub.dev" + source: hosted + version: "1.0.7" archive: dependency: transitive description: @@ -74,7 +82,7 @@ packages: source: hosted version: "1.0.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -296,6 +304,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" + googleapis: + dependency: "direct main" + description: + name: googleapis + sha256: "864f222aed3f2ff00b816c675edf00a39e2aaf373d728d8abec30b37bee1a81c" + url: "https://pub.dev" + source: hosted + version: "13.2.0" gsettings: dependency: transitive description: @@ -481,7 +545,7 @@ packages: source: hosted version: "0.13.0" meta: - dependency: transitive + dependency: "direct main" description: name: meta sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" diff --git a/pubspec.yaml b/pubspec.yaml index 78d10f4..42e0b7f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.5.09+154 +version: 1.5.10+155 environment: sdk: ^3.10.7 @@ -57,6 +57,10 @@ dependencies: http: ^1.2.2 shelf: ^1.4.1 shelf_router: ^1.1.4 + collection: ^1.18.0 + meta: ^1.17.0 + google_sign_in: ^6.2.1 + googleapis: ^13.2.0 dev_dependencies: flutter_test: @@ -80,9 +84,8 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/icon/app_icon.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images