diff --git a/gemi_invoice/lib/data/product_master.dart b/gemi_invoice/lib/data/product_master.dart new file mode 100644 index 0000000..0fa8b9d --- /dev/null +++ b/gemi_invoice/lib/data/product_master.dart @@ -0,0 +1,99 @@ +import '../models/invoice_models.dart'; + +/// 商品情報を管理するモデル +/// 将来的な Odoo 同期を見据えて、外部ID(odooId)を保持できるように設計 +class Product { + final String id; // ローカル管理用のID + final int? odooId; // Odoo上の product.product ID (nullの場合は未同期) + final String name; // 商品名 + final int defaultUnitPrice; // 標準単価 + final String? category; // カテゴリ + + const Product({ + required this.id, + this.odooId, + required this.name, + required this.defaultUnitPrice, + this.category, + }); + + /// InvoiceItem への変換 + InvoiceItem toInvoiceItem({int quantity = 1}) { + return InvoiceItem( + description: name, + quantity: quantity, + unitPrice: defaultUnitPrice, + ); + } + + /// 状態更新のためのコピーメソッド + Product copyWith({ + String? id, + int? odooId, + String? name, + int? defaultUnitPrice, + String? category, + }) { + return Product( + id: id ?? this.id, + odooId: odooId ?? this.odooId, + name: name ?? this.name, + defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice, + category: category ?? this.category, + ); + } + + /// JSON変換 (ローカル保存・Odoo同期用) + Map toJson() { + return { + 'id': id, + 'odoo_id': odooId, + 'name': name, + 'default_unit_price': defaultUnitPrice, + 'category': category, + }; + } + + /// JSONからモデルを生成 + factory Product.fromJson(Map json) { + return Product( + id: json['id'], + odooId: json['odoo_id'], + name: json['name'], + defaultUnitPrice: json['default_unit_price'], + category: json['category'], + ); + } +} + +/// 商品マスターのテンプレートデータ +class ProductMaster { + static const List products = [ + Product(id: 'S001', name: 'システム開発費', defaultUnitPrice: 500000, category: '開発'), + Product(id: 'S002', name: '保守・メンテナンス費', defaultUnitPrice: 50000, category: '運用'), + Product(id: 'S003', name: '技術コンサルティング', defaultUnitPrice: 100000, category: '開発'), + Product(id: 'G001', name: 'ライセンス料 (Pro)', defaultUnitPrice: 15000, category: '製品'), + Product(id: 'G002', name: '初期導入セットアップ', defaultUnitPrice: 30000, category: '製品'), + Product(id: 'M001', name: 'ハードウェア一式', defaultUnitPrice: 250000, category: '物品'), + Product(id: 'Z001', name: '諸経費', defaultUnitPrice: 5000, category: 'その他'), + ]; + + /// カテゴリ一覧の取得 + static List get categories { + return products.map((p) => p.category ?? 'その他').toSet().toList(); + } + + /// カテゴリ別の商品取得 + static List getProductsByCategory(String category) { + return products.where((p) => (p.category ?? 'その他') == category).toList(); + } + + /// 名前またはIDで検索 + static List search(String query) { + final q = query.toLowerCase(); + return products.where((p) => + p.name.toLowerCase().contains(q) || + p.id.toLowerCase().contains(q) + ).toList(); + } +} diff --git a/gemi_invoice/lib/models/customer_model.dart b/gemi_invoice/lib/models/customer_model.dart new file mode 100644 index 0000000..2197344 --- /dev/null +++ b/gemi_invoice/lib/models/customer_model.dart @@ -0,0 +1,87 @@ +import 'package:intl/intl.dart'; + +/// 顧客情報を管理するモデル +/// 将来的な Odoo 同期を見据えて、外部ID(odooId)を保持できるように設計 +class Customer { + final String id; // ローカル管理用のID + final int? odooId; // Odoo上の res.partner ID (nullの場合は未同期) + final String displayName; // 電話帳からの表示名(検索用バッファ) + final String formalName; // 請求書に記載する正式名称(株式会社〜 など) + final String? zipCode; // 郵便番号 + final String? address; // 住所 + final String? department; // 部署名 + final String? title; // 敬称 (様、御中など。デフォルトは御中) + final DateTime lastUpdatedAt; // 最終更新日時 + + Customer({ + required this.id, + this.odooId, + required this.displayName, + required this.formalName, + this.zipCode, + this.address, + this.department, + this.title = '御中', + DateTime? lastUpdatedAt, + }) : this.lastUpdatedAt = lastUpdatedAt ?? DateTime.now(); + + /// 請求書表示用のフルネームを取得 + String get invoiceName => department != null && department!.isNotEmpty + ? "$formalName\n$department $title" + : "$formalName $title"; + + /// 状態更新のためのコピーメソッド + Customer copyWith({ + String? id, + int? odooId, + String? displayName, + String? formalName, + String? zipCode, + String? address, + String? department, + String? title, + DateTime? lastUpdatedAt, + }) { + return Customer( + id: id ?? this.id, + odooId: odooId ?? this.odooId, + displayName: displayName ?? this.displayName, + formalName: formalName ?? this.formalName, + zipCode: zipCode ?? this.zipCode, + address: address ?? this.address, + department: department ?? this.department, + title: title ?? this.title, + lastUpdatedAt: lastUpdatedAt ?? DateTime.now(), + ); + } + + /// JSON変換 (ローカル保存・Odoo同期用) + Map toJson() { + return { + 'id': id, + 'odoo_id': odooId, + 'display_name': displayName, + 'formal_name': formalName, + 'zip_code': zipCode, + 'address': address, + 'department': department, + 'title': title, + 'last_updated_at': lastUpdatedAt.toIso8601String(), + }; + } + + /// JSONからモデルを生成 + factory Customer.fromJson(Map json) { + return Customer( + id: json['id'], + odooId: json['odoo_id'], + displayName: json['display_name'], + formalName: json['formal_name'], + zipCode: json['zip_code'], + address: json['address'], + department: json['department'], + title: json['title'] ?? '御中', + lastUpdatedAt: DateTime.parse(json['last_updated_at']), + ); + } +} diff --git a/gemi_invoice/lib/models/invoice_models.dart b/gemi_invoice/lib/models/invoice_models.dart index a672128..3989c70 100644 --- a/gemi_invoice/lib/models/invoice_models.dart +++ b/gemi_invoice/lib/models/invoice_models.dart @@ -1,4 +1,5 @@ import 'package:intl/intl.dart'; +import 'customer_model.dart'; /// 請求書の各明細行を表すモデル class InvoiceItem { @@ -27,11 +28,29 @@ class InvoiceItem { unitPrice: unitPrice ?? this.unitPrice, ); } + + // JSON変換 + Map toJson() { + return { + 'description': description, + 'quantity': quantity, + 'unit_price': unitPrice, + }; + } + + // JSONから復元 + factory InvoiceItem.fromJson(Map json) { + return InvoiceItem( + description: json['description'] as String, + quantity: json['quantity'] as int, + unitPrice: json['unit_price'] as int, + ); + } } /// 請求書全体を管理するモデル class Invoice { - String clientName; + Customer customer; // 顧客情報 DateTime date; List items; String? filePath; // 保存されたPDFのパス @@ -39,7 +58,7 @@ class Invoice { String? notes; // 備考 Invoice({ - required this.clientName, + required this.customer, required this.date, required this.items, this.filePath, @@ -47,6 +66,9 @@ class Invoice { this.notes, }) : invoiceNumber = invoiceNumber ?? DateFormat('yyyyMMdd-HHmm').format(date); + // 互換性のためのゲッター + String get clientName => customer.formalName; + // 税抜合計金額 int get subtotal { return items.fold(0, (sum, item) => sum + item.subtotal); @@ -64,7 +86,7 @@ class Invoice { // 状態更新のためのコピーメソッド Invoice copyWith({ - String? clientName, + Customer? customer, DateTime? date, List? items, String? filePath, @@ -72,7 +94,7 @@ class Invoice { String? notes, }) { return Invoice( - clientName: clientName ?? this.clientName, + customer: customer ?? this.customer, date: date ?? this.date, items: items ?? this.items, filePath: filePath ?? this.filePath, @@ -81,13 +103,43 @@ class Invoice { ); } - // CSV形式への変換 (将来的なCSV編集用) + // CSV形式への変換 String toCsv() { StringBuffer sb = StringBuffer(); + sb.writeln("Customer,${customer.formalName}"); + sb.writeln("Invoice Number,$invoiceNumber"); + sb.writeln("Date,${DateFormat('yyyy/MM/dd').format(date)}"); + sb.writeln(""); sb.writeln("Description,Quantity,UnitPrice,Subtotal"); for (var item in items) { sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}"); } return sb.toString(); } + + // JSON変換 (データベース保存用) + Map toJson() { + return { + 'customer': customer.toJson(), + 'date': date.toIso8601String(), + 'items': items.map((item) => item.toJson()).toList(), + 'file_path': filePath, + 'invoice_number': invoiceNumber, + 'notes': notes, + }; + } + + // JSONから復元 (データベース読み込み用) + factory Invoice.fromJson(Map json) { + return Invoice( + customer: Customer.fromJson(json['customer'] as Map), + date: DateTime.parse(json['date'] as String), + items: (json['items'] as List) + .map((i) => InvoiceItem.fromJson(i as Map)) + .toList(), + filePath: json['file_path'] as String?, + invoiceNumber: json['invoice_number'] as String, + notes: json['notes'] as String?, + ); + } } diff --git a/gemi_invoice/lib/screens/customer_picker_modal.dart b/gemi_invoice/lib/screens/customer_picker_modal.dart new file mode 100644 index 0000000..5f37e3c --- /dev/null +++ b/gemi_invoice/lib/screens/customer_picker_modal.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:uuid/uuid.dart'; +import '../models/customer_model.dart'; + +/// 顧客マスターからの選択、登録、編集、削除を行うモーダル +class CustomerPickerModal extends StatefulWidget { + final List existingCustomers; + final Function(Customer) onCustomerSelected; + final Function(Customer)? onCustomerDeleted; // 削除通知用(オプション) + + const CustomerPickerModal({ + Key? key, + required this.existingCustomers, + required this.onCustomerSelected, + this.onCustomerDeleted, + }) : super(key: key); + + @override + State createState() => _CustomerPickerModalState(); +} + +class _CustomerPickerModalState extends State { + String _searchQuery = ""; + List _filteredCustomers = []; + bool _isImportingFromContacts = false; + + @override + void initState() { + super.initState(); + _filteredCustomers = widget.existingCustomers; + } + + void _filterCustomers(String query) { + setState(() { + _searchQuery = query.toLowerCase(); + _filteredCustomers = widget.existingCustomers.where((customer) { + return customer.formalName.toLowerCase().contains(_searchQuery) || + customer.displayName.toLowerCase().contains(_searchQuery); + }).toList(); + }); + } + + /// 電話帳から取り込んで新規顧客として登録・編集するダイアログ + Future _importFromPhoneContacts() async { + setState(() => _isImportingFromContacts = true); + try { + if (await FlutterContacts.requestPermission(readonly: true)) { + final contacts = await FlutterContacts.getContacts(); + if (!mounted) return; + setState(() => _isImportingFromContacts = false); + + final Contact? selectedContact = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _PhoneContactListSelector(contacts: contacts), + ); + + if (selectedContact != null) { + _showCustomerEditDialog( + displayName: selectedContact.displayName, + initialFormalName: selectedContact.displayName, + ); + } + } + } catch (e) { + setState(() => _isImportingFromContacts = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("電話帳の取得に失敗しました: $e")), + ); + } + } + + /// 顧客情報の編集・登録ダイアログ + void _showCustomerEditDialog({ + required String displayName, + required String initialFormalName, + Customer? existingCustomer, + }) { + final formalNameController = TextEditingController(text: initialFormalName); + final departmentController = TextEditingController(text: existingCustomer?.department ?? ""); + final addressController = TextEditingController(text: existingCustomer?.address ?? ""); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(existingCustomer == null ? "顧客の新規登録" : "顧客情報の編集"), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("電話帳名: $displayName", style: const TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 16), + TextField( + controller: formalNameController, + decoration: const InputDecoration( + labelText: "請求書用 正式名称", + hintText: "株式会社 〇〇 など", + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: departmentController, + decoration: const InputDecoration( + labelText: "部署名", + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: addressController, + decoration: const InputDecoration( + labelText: "住所", + border: OutlineInputBorder(), + ), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + ElevatedButton( + onPressed: () { + final updatedCustomer = existingCustomer?.copyWith( + formalName: formalNameController.text.trim(), + department: departmentController.text.trim(), + address: addressController.text.trim(), + ) ?? + Customer( + id: const Uuid().v4(), + displayName: displayName, + formalName: formalNameController.text.trim(), + department: departmentController.text.trim(), + address: addressController.text.trim(), + ); + Navigator.pop(context); + widget.onCustomerSelected(updatedCustomer); + }, + child: const Text("保存して確定"), + ), + ], + ), + ); + } + + /// 削除確認ダイアログ + void _confirmDelete(Customer customer) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("顧客の削除"), + content: Text("「${customer.formalName}」をマスターから削除しますか?\n(過去の請求書ファイルは削除されません)"), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + TextButton( + onPressed: () { + Navigator.pop(context); + if (widget.onCustomerDeleted != null) { + widget.onCustomerDeleted!(customer); + setState(() { + _filterCustomers(_searchQuery); // リスト更新 + }); + } + }, + child: const Text("削除する", style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Column( + children: [ + 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)), + ], + ), + const SizedBox(height: 12), + TextField( + decoration: InputDecoration( + hintText: "登録済み顧客を検索...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + onChanged: _filterCustomers, + ), + 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 Divider(), + Expanded( + child: _filteredCustomers.isEmpty + ? const Center(child: Text("該当する顧客がいません")) + : ListView.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), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// 電話帳から一人選ぶための内部ウィジェット +class _PhoneContactListSelector extends StatefulWidget { + final List contacts; + const _PhoneContactListSelector({required this.contacts}); + + @override + State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState(); +} + +class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> { + List _filtered = []; + final _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _filtered = widget.contacts; + } + + void _onSearch(String q) { + setState(() { + _filtered = widget.contacts + .where((c) => c.displayName.toLowerCase().contains(q.toLowerCase())) + .toList(); + }); + } + + @override + Widget build(BuildContext context) { + return FractionallySizedBox( + heightFactor: 0.8, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)), + onChanged: _onSearch, + ), + ), + Expanded( + child: ListView.builder( + itemCount: _filtered.length, + itemBuilder: (context, index) => ListTile( + title: Text(_filtered[index].displayName), + onTap: () => Navigator.pop(context, _filtered[index]), + ), + ), + ), + ], + ), + ); + } +} diff --git a/gemi_invoice/lib/screens/invoice_detail_page.dart b/gemi_invoice/lib/screens/invoice_detail_page.dart index d475869..8b4f3fc 100644 --- a/gemi_invoice/lib/screens/invoice_detail_page.dart +++ b/gemi_invoice/lib/screens/invoice_detail_page.dart @@ -4,7 +4,9 @@ import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; import 'package:open_filex/open_filex.dart'; import '../models/invoice_models.dart'; +import '../models/customer_model.dart'; import '../services/pdf_generator.dart'; +import 'product_picker_modal.dart'; class InvoiceDetailPage extends StatefulWidget { final Invoice invoice; @@ -16,7 +18,7 @@ class InvoiceDetailPage extends StatefulWidget { } class _InvoiceDetailPageState extends State { - late TextEditingController _clientController; + late TextEditingController _formalNameController; late TextEditingController _notesController; late List _items; late bool _isEditing; @@ -28,7 +30,7 @@ class _InvoiceDetailPageState extends State { super.initState(); _currentInvoice = widget.invoice; _currentFilePath = widget.invoice.filePath; - _clientController = TextEditingController(text: _currentInvoice.clientName); + _formalNameController = TextEditingController(text: _currentInvoice.customer.formalName); _notesController = TextEditingController(text: _currentInvoice.notes ?? ""); _items = List.from(_currentInvoice.items); _isEditing = false; @@ -36,7 +38,7 @@ class _InvoiceDetailPageState extends State { @override void dispose() { - _clientController.dispose(); + _formalNameController.dispose(); _notesController.dispose(); super.dispose(); } @@ -53,17 +55,41 @@ class _InvoiceDetailPageState extends State { }); } + void _pickFromMaster() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => FractionallySizedBox( + heightFactor: 0.9, + child: ProductPickerModal( + onItemSelected: (item) { + setState(() { + _items.add(item); + }); + Navigator.pop(context); + }, + ), + ), + ); + } + Future _saveChanges() async { - final String clientName = _clientController.text.trim(); - if (clientName.isEmpty) { + final String formalName = _formalNameController.text.trim(); + if (formalName.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('取引先名を入力してください')), + const SnackBar(content: Text('取引先の正式名称を入力してください')), ); return; } + // 顧客情報を更新 + final updatedCustomer = _currentInvoice.customer.copyWith( + formalName: formalName, + ); + final updatedInvoice = _currentInvoice.copyWith( - clientName: clientName, + customer: updatedCustomer, items: _items, notes: _notesController.text, ); @@ -84,7 +110,6 @@ class _InvoiceDetailPageState extends State { void _exportCsv() { final csvData = _currentInvoice.toCsv(); - // 実際にはファイル保存ダイアログなどを出すのが望ましいが、ここでは簡易的に共有 Share.share(csvData, subject: '請求書データ_CSV'); } @@ -119,10 +144,25 @@ class _InvoiceDetailPageState extends State { if (_isEditing) Padding( padding: const EdgeInsets.only(top: 8.0), - child: ElevatedButton.icon( - onPressed: _addItem, - icon: const Icon(Icons.add), - label: const Text("行を追加"), + child: Wrap( + spacing: 12, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add), + label: const Text("空の行を追加"), + ), + ElevatedButton.icon( + onPressed: _pickFromMaster, + icon: const Icon(Icons.list_alt), + label: const Text("マスターから選択"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueGrey.shade700, + foregroundColor: Colors.white, + ), + ), + ], ), ), const SizedBox(height: 24), @@ -136,13 +176,14 @@ class _InvoiceDetailPageState extends State { } Widget _buildHeaderSection() { + final dateFormatter = DateFormat('yyyy年MM月dd日'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isEditing) ...[ TextField( - controller: _clientController, - decoration: const InputDecoration(labelText: "取引先名", border: OutlineInputBorder()), + controller: _formalNameController, + decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()), ), const SizedBox(height: 12), TextField( @@ -151,9 +192,13 @@ class _InvoiceDetailPageState extends State { decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()), ), ] else ...[ - Text("宛名: ${_currentInvoice.clientName} 御中", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}", + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) + Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)), const SizedBox(height: 4), Text("請求番号: ${_currentInvoice.invoiceNumber}"), + Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"), if (_currentInvoice.notes?.isNotEmpty ?? false) ...[ const SizedBox(height: 8), Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)), diff --git a/gemi_invoice/lib/screens/invoice_input_screen.dart b/gemi_invoice/lib/screens/invoice_input_screen.dart index 6537722..0259d29 100644 --- a/gemi_invoice/lib/screens/invoice_input_screen.dart +++ b/gemi_invoice/lib/screens/invoice_input_screen.dart @@ -1,8 +1,10 @@ -// lib/screens/invoice_input_screen.dart import 'package:flutter/material.dart'; -import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:uuid/uuid.dart'; +import '../models/customer_model.dart'; import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; +import '../services/invoice_repository.dart'; +import 'customer_picker_modal.dart'; /// 請求書の初期入力(ヘッダー部分)を管理するウィジェット class InvoiceInputForm extends StatefulWidget { @@ -18,9 +20,32 @@ class InvoiceInputForm extends StatefulWidget { } class _InvoiceInputFormState extends State { - final _clientController = TextEditingController(text: "佐々木製作所"); + final _clientController = TextEditingController(); final _amountController = TextEditingController(text: "250000"); - String _status = "取引先と基本金額を入力してPDFを生成してください"; + final _repository = InvoiceRepository(); + String _status = "取引先を選択してPDFを生成してください"; + + List _customerBuffer = []; + Customer? _selectedCustomer; + + @override + void initState() { + super.initState(); + _selectedCustomer = Customer( + id: const Uuid().v4(), + displayName: "佐々木製作所", + formalName: "株式会社 佐々木製作所", + ); + _customerBuffer.add(_selectedCustomer!); + _clientController.text = _selectedCustomer!.formalName; + + // 起動時に不要なPDFを掃除する + _repository.cleanupOrphanedPdfs().then((count) { + if (count > 0) { + debugPrint('Cleaned up $count orphaned PDF files.'); + } + }); + } @override void dispose() { @@ -29,64 +54,43 @@ class _InvoiceInputFormState extends State { super.dispose(); } - // 連絡先を選択する処理 - Future _pickContact() async { - setState(() => _status = "連絡先をスキャン中..."); - try { - if (await FlutterContacts.requestPermission(readonly: true)) { - final List contacts = await FlutterContacts.getContacts( - withProperties: false, - withThumbnail: false, - ); + Future _openCustomerPicker() async { + setState(() => _status = "顧客マスターを開いています..."); - if (!mounted) return; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => FractionallySizedBox( + heightFactor: 0.9, + child: CustomerPickerModal( + existingCustomers: _customerBuffer, + onCustomerSelected: (customer) { + setState(() { + bool exists = _customerBuffer.any((c) => c.id == customer.id); + if (!exists) { + _customerBuffer.add(customer); + } - if (contacts.isEmpty) { - setState(() => _status = "連絡先が空、または取得できませんでした。"); - return; - } - - contacts.sort((a, b) => a.displayName.compareTo(b.displayName)); - - final Contact? selected = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (BuildContext modalContext) => FractionallySizedBox( - heightFactor: 0.8, - child: ContactPickerModal( - contacts: contacts, - onContactSelected: (selectedContact) { - Navigator.pop(modalContext, selectedContact); - }, - ), - ), - ); - - if (selected != null) { - setState(() { - _clientController.text = selected.displayName; - _status = "「${selected.displayName}」をセットしました"; - }); - } - } else { - setState(() => _status = "電話帳の権限が拒否されています。"); - } - } catch (e) { - setState(() => _status = "エラーが発生しました: $e"); - } + _selectedCustomer = customer; + _clientController.text = customer.formalName; + _status = "「${customer.formalName}」を選択しました"; + }); + Navigator.pop(context); + }, + ), + ), + ); } - // 初期PDFを生成して保存する処理(ここから詳細ページへ遷移する) Future _handleInitialGenerate() async { - final clientName = _clientController.text.trim(); - final unitPrice = int.tryParse(_amountController.text) ?? 0; - - if (clientName.isEmpty) { - setState(() => _status = "取引先名を入力してください"); + if (_selectedCustomer == null) { + setState(() => _status = "取引先を選択してください"); return; } - // 初期の1行明細を作成 + final unitPrice = int.tryParse(_amountController.text) ?? 0; + final initialItems = [ InvoiceItem( description: "ご請求分", @@ -96,7 +100,7 @@ class _InvoiceInputFormState extends State { ]; final invoice = Invoice( - clientName: clientName, + customer: _selectedCustomer!, date: DateTime.now(), items: initialItems, ); @@ -106,8 +110,12 @@ class _InvoiceInputFormState extends State { if (path != null) { final updatedInvoice = invoice.copyWith(filePath: path); + + // オリジナルDBに保存 + await _repository.saveInvoice(updatedInvoice); + widget.onInvoiceGenerated(updatedInvoice, path); - setState(() => _status = "PDFを生成しました。詳細ページで表編集が可能です。"); + setState(() => _status = "PDFを生成しDBに登録しました。"); } else { setState(() => _status = "PDFの生成に失敗しました"); } @@ -119,21 +127,30 @@ class _InvoiceInputFormState extends State { padding: const EdgeInsets.all(16.0), child: SingleChildScrollView( child: Column(children: [ + const Text( + "ステップ1: 宛先と基本金額の設定", + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), + ), + const SizedBox(height: 16), Row(children: [ Expanded( child: TextField( controller: _clientController, + readOnly: true, + onTap: _openCustomerPicker, decoration: const InputDecoration( - labelText: "取引先名", - hintText: "会社名や個人名", + labelText: "取引先名 (タップして選択)", + hintText: "電話帳から取り込むか、マスターから選択", + prefixIcon: Icon(Icons.business), border: OutlineInputBorder(), ), ), ), const SizedBox(width: 8), IconButton( - icon: const Icon(Icons.person_search, color: Colors.blue, size: 40), - onPressed: _pickContact, + icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40), + onPressed: _openCustomerPicker, + tooltip: "顧客を選択・登録", ), ]), const SizedBox(height: 16), @@ -142,7 +159,8 @@ class _InvoiceInputFormState extends State { keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: "基本金額 (税抜)", - hintText: "後で詳細ページで変更・追加できます", + hintText: "明細の1行目として登録されます", + prefixIcon: Icon(Icons.currency_yen), border: OutlineInputBorder(), ), ), @@ -155,6 +173,8 @@ class _InvoiceInputFormState extends State { minimumSize: const Size(double.infinity, 60), backgroundColor: Colors.indigo, foregroundColor: Colors.white, + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), const SizedBox(height: 24), @@ -164,6 +184,7 @@ class _InvoiceInputFormState extends State { decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), ), child: Text( _status, @@ -176,79 +197,3 @@ class _InvoiceInputFormState extends State { ); } } - -// 連絡先選択用のモーダルウィジェット -class ContactPickerModal extends StatefulWidget { - final List contacts; - final Function(Contact) onContactSelected; - - const ContactPickerModal({ - Key? key, - required this.contacts, - required this.onContactSelected, - }) : super(key: key); - - @override - State createState() => _ContactPickerModalState(); -} - -class _ContactPickerModalState extends State { - String _searchQuery = ""; - List _filteredContacts = []; - - @override - void initState() { - super.initState(); - _filteredContacts = widget.contacts; - } - - void _filterContacts(String query) { - setState(() { - _searchQuery = query.toLowerCase(); - _filteredContacts = widget.contacts - .where((c) => c.displayName.toLowerCase().contains(_searchQuery)) - .toList(); - }); - } - - @override - Widget build(BuildContext context) { - return Material( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "取引先を選択 (${_filteredContacts.length}件)", - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 12), - TextField( - decoration: InputDecoration( - hintText: "名前で検索...", - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)), - ), - onChanged: _filterContacts, - ), - ], - ), - ), - Expanded( - child: ListView.builder( - itemCount: _filteredContacts.length, - itemBuilder: (c, i) => ListTile( - leading: const CircleAvatar(child: Icon(Icons.person)), - title: Text(_filteredContacts[i].displayName), - onTap: () => widget.onContactSelected(_filteredContacts[i]), - ), - ), - ), - ], - ), - ); - } -} diff --git a/gemi_invoice/lib/screens/product_picker_modal.dart b/gemi_invoice/lib/screens/product_picker_modal.dart new file mode 100644 index 0000000..19f4c63 --- /dev/null +++ b/gemi_invoice/lib/screens/product_picker_modal.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import '../data/product_master.dart'; +import '../models/invoice_models.dart'; + +/// 商品マスターの選択・登録・編集・削除を行うモーダル +class ProductPickerModal extends StatefulWidget { + final Function(InvoiceItem) onItemSelected; + + const ProductPickerModal({ + Key? key, + required this.onItemSelected, + }) : super(key: key); + + @override + State createState() => _ProductPickerModalState(); +} + +class _ProductPickerModalState extends State { + String _searchQuery = ""; + List _masterProducts = []; + List _filteredProducts = []; + String _selectedCategory = "すべて"; + + @override + void initState() { + super.initState(); + // 本来は永続化層から取得するが、現在はProductMasterの初期データを使用 + _masterProducts = List.from(ProductMaster.products); + _filterProducts(); + } + + void _filterProducts() { + setState(() { + _filteredProducts = _masterProducts.where((product) { + final matchesQuery = product.name.toLowerCase().contains(_searchQuery.toLowerCase()) || + product.id.toLowerCase().contains(_searchQuery.toLowerCase()); + final matchesCategory = _selectedCategory == "すべて" || (product.category == _selectedCategory); + return matchesQuery && matchesCategory; + }).toList(); + }); + } + + /// 商品の編集・新規登録用ダイアログ + void _showProductEditDialog({Product? existingProduct}) { + final idController = TextEditingController(text: existingProduct?.id ?? ""); + final nameController = TextEditingController(text: existingProduct?.name ?? ""); + final priceController = TextEditingController(text: existingProduct?.defaultUnitPrice.toString() ?? ""); + final categoryController = TextEditingController(text: existingProduct?.category ?? ""); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(existingProduct == null ? "新規商品の登録" : "商品情報の編集"), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (existingProduct == null) + TextField( + controller: idController, + decoration: const InputDecoration(labelText: "商品コード (例: S001)", border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: "商品名", border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: priceController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: "標準単価", border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: categoryController, + decoration: const InputDecoration(labelText: "カテゴリ (任意)", border: OutlineInputBorder()), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + ElevatedButton( + onPressed: () { + final String name = nameController.text.trim(); + final int price = int.tryParse(priceController.text) ?? 0; + if (name.isEmpty) return; + + setState(() { + if (existingProduct != null) { + // 更新 + final index = _masterProducts.indexWhere((p) => p.id == existingProduct.id); + if (index != -1) { + _masterProducts[index] = existingProduct.copyWith( + name: name, + defaultUnitPrice: price, + category: categoryController.text.trim(), + ); + } + } else { + // 新規追加 + _masterProducts.add(Product( + id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text, + name: name, + defaultUnitPrice: price, + category: categoryController.text.trim(), + )); + } + _filterProducts(); + }); + Navigator.pop(context); + }, + child: const Text("保存"), + ), + ], + ), + ); + } + + /// 削除確認 + void _confirmDelete(Product product) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("商品の削除"), + content: Text("「${product.name}」をマスターから削除しますか?"), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + TextButton( + onPressed: () { + setState(() { + _masterProducts.removeWhere((p) => p.id == product.id); + _filterProducts(); + }); + Navigator.pop(context); + }, + child: const Text("削除する", style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + // マスター内のカテゴリを動的に取得 + final dynamicCategories = ["すべて", ..._masterProducts.map((p) => p.category ?? 'その他').toSet().toList()]; + + return Material( + color: Colors.white, + child: Column( + children: [ + 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)), + ], + ), + const SizedBox(height: 12), + TextField( + decoration: InputDecoration( + hintText: "商品名やコードで検索...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + filled: true, + fillColor: Colors.grey.shade50, + ), + onChanged: (val) { + _searchQuery = val; + _filterProducts(); + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: dynamicCategories.map((cat) { + final isSelected = _selectedCategory == cat; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + label: Text(cat), + selected: isSelected, + onSelected: (s) { + if (s) { + setState(() { + _selectedCategory = cat; + _filterProducts(); + }); + } + }, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: () => _showProductEditDialog(), + icon: const Icon(Icons.add), + tooltip: "新規商品を追加", + ), + ], + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: _filteredProducts.isEmpty + ? const Center(child: Text("該当する商品がありません")) + : ListView.separated( + itemCount: _filteredProducts.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final product = _filteredProducts[index]; + return ListTile( + leading: const Icon(Icons.inventory_2, color: Colors.blueGrey), + title: Text(product.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text("${product.id} | ¥${product.defaultUnitPrice}"), + onTap: () => widget.onItemSelected(product.toInvoiceItem()), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined, size: 20, color: Colors.blueGrey), + onPressed: () => _showProductEditDialog(existingProduct: product), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), + onPressed: () => _confirmDelete(product), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/gemi_invoice/lib/services/invoice_repository.dart b/gemi_invoice/lib/services/invoice_repository.dart new file mode 100644 index 0000000..46b6c07 --- /dev/null +++ b/gemi_invoice/lib/services/invoice_repository.dart @@ -0,0 +1,107 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; +import '../models/invoice_models.dart'; + +/// 請求書のオリジナルデータを管理するリポジトリ(簡易DB) +/// PDFファイルとデータの整合性を保つための機能を提供します +class InvoiceRepository { + static const String _dbFileName = 'invoices_db.json'; + + /// データベースファイルのパスを取得 + Future _getDbFile() async { + final directory = await getApplicationDocumentsDirectory(); + return File('${directory.path}/$_dbFileName'); + } + + /// 全ての請求書データを読み込む + Future> getAllInvoices() async { + try { + final file = await _getDbFile(); + if (!await file.exists()) return []; + + final String content = await file.readAsString(); + final List jsonList = json.decode(content); + + return jsonList.map((json) => Invoice.fromJson(json)).toList() + ..sort((a, b) => b.date.compareTo(a.date)); // 新しい順にソート + } catch (e) { + print('DB Loading Error: $e'); + return []; + } + } + + /// 請求書データを保存・更新する + Future saveInvoice(Invoice invoice) async { + final List all = await getAllInvoices(); + + // 同じ請求番号があれば差し替え、なければ追加 + final index = all.indexWhere((i) => i.invoiceNumber == invoice.invoiceNumber); + if (index != -1) { + // 古いファイルが存在し、かつ新しいパスと異なる場合は古いファイルを削除(無駄なPDFの掃除) + final oldPath = all[index].filePath; + if (oldPath != null && oldPath != invoice.filePath) { + await _deletePhysicalFile(oldPath); + } + all[index] = invoice; + } else { + all.add(invoice); + } + + final file = await _getDbFile(); + await file.writeAsString(json.encode(all.map((i) => i.toJson()).toList())); + } + + /// 請求書データを削除する + Future deleteInvoice(Invoice invoice) async { + final List all = await getAllInvoices(); + all.removeWhere((i) => i.invoiceNumber == invoice.invoiceNumber); + + // 物理ファイルも削除 + if (invoice.filePath != null) { + await _deletePhysicalFile(invoice.filePath!); + } + + final file = await _getDbFile(); + await file.writeAsString(json.encode(all.map((i) => i.toJson()).toList())); + } + + /// 実際のPDFファイルをストレージから削除する + Future _deletePhysicalFile(String path) async { + try { + final file = File(path); + if (await file.exists()) { + await file.delete(); + print('Physical file deleted: $path'); + } + } catch (e) { + print('File Deletion Error: $path, $e'); + } + } + + /// DBに登録されていない「浮いたPDFファイル」をスキャンして掃除する + Future cleanupOrphanedPdfs() async { + final List all = await getAllInvoices(); + final Set registeredPaths = all + .where((i) => i.filePath != null) + .map((i) => i.filePath!) + .toSet(); + + final directory = await getExternalStorageDirectory(); + if (directory == null) return 0; + + int deletedCount = 0; + final List files = directory.listSync(); + + for (var entity in files) { + if (entity is File && entity.path.endsWith('.pdf')) { + // DBに登録されていないPDFは削除(無駄なゴミ) + if (!registeredPaths.contains(entity.path)) { + await entity.delete(); + deletedCount++; + } + } + } + return deletedCount; + } +} diff --git a/gemi_invoice/lib/services/pdf_generator.dart b/gemi_invoice/lib/services/pdf_generator.dart index 9fdd670..78d059a 100644 --- a/gemi_invoice/lib/services/pdf_generator.dart +++ b/gemi_invoice/lib/services/pdf_generator.dart @@ -59,7 +59,7 @@ Future generateInvoicePdf(Invoice invoice) async { decoration: const pw.BoxDecoration( border: pw.Border(bottom: pw.BorderSide(width: 1)), ), - child: pw.Text("${invoice.clientName} 御中", + child: pw.Text(invoice.customer.invoiceName, style: const pw.TextStyle(fontSize: 18)), ), pw.SizedBox(height: 10), @@ -169,7 +169,7 @@ Future generateInvoicePdf(Invoice invoice) async { final Uint8List bytes = await pdf.save(); final String hash = sha256.convert(bytes).toString().substring(0, 8); final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date); - String fileName = "Invoice_${dateFileStr}_${invoice.clientName}_$hash.pdf"; + String fileName = "Invoice_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf"; final directory = await getExternalStorageDirectory(); if (directory == null) return null;