diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 29a2967..80f01e4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,12 @@ > + + + + + + diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 0ac341c..491b9ef 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index 3c55531..9242567 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -48,6 +48,18 @@ @import permission_handler_apple; #endif +#if __has_include() +#import +#else +@import print_bluetooth_thermal; +#endif + +#if __has_include() +#import +#else +@import printing; +#endif + #if __has_include() #import #else @@ -76,6 +88,8 @@ [OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]]; [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; + [PrintBluetoothThermalPlugin registerWithRegistrar:[registry registrarForPlugin:@"PrintBluetoothThermalPlugin"]]; + [PrintingPlugin registerWithRegistrar:[registry registrarForPlugin:@"PrintingPlugin"]]; [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; diff --git a/lib/models/activity_log_model.dart b/lib/models/activity_log_model.dart new file mode 100644 index 0000000..dc9dc20 --- /dev/null +++ b/lib/models/activity_log_model.dart @@ -0,0 +1,56 @@ +import 'package:uuid/uuid.dart'; + +class ActivityLog { + final String id; + final String action; // 例: "CREATE", "UPDATE", "DELETE", "GENERATE_PDF" + final String targetType; // 例: "INVOICE", "CUSTOMER", "PRODUCT" + final String? targetId; + final String? details; + final DateTime timestamp; + + ActivityLog({ + required this.id, + required this.action, + required this.targetType, + this.targetId, + this.details, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + Map toMap() { + return { + 'id': id, + 'action': action, + 'target_type': targetType, + 'target_id': targetId, + 'details': details, + 'timestamp': timestamp.toIso8601String(), + }; + } + + factory ActivityLog.fromMap(Map map) { + return ActivityLog( + id: map['id'], + action: map['action'], + targetType: map['target_type'], + targetId: map['target_id'], + details: map['details'], + timestamp: DateTime.parse(map['timestamp']), + ); + } + + static ActivityLog create({ + required String action, + required String targetType, + String? targetId, + String? details, + }) { + return ActivityLog( + id: const Uuid().v4(), + action: action, + targetType: targetType, + targetId: targetId, + details: details, + ); + } +} diff --git a/lib/models/company_model.dart b/lib/models/company_model.dart index 96d8a69..ecd9bb9 100644 --- a/lib/models/company_model.dart +++ b/lib/models/company_model.dart @@ -5,6 +5,7 @@ class CompanyInfo { final String? tel; final double defaultTaxRate; final String? sealPath; // 角印(印鑑)の画像パス + final String taxDisplayMode; // 'normal', 'hidden', 'text_only' CompanyInfo({ required this.name, @@ -13,6 +14,7 @@ class CompanyInfo { this.tel, this.defaultTaxRate = 0.10, this.sealPath, + this.taxDisplayMode = 'normal', }); Map toMap() { @@ -24,6 +26,7 @@ class CompanyInfo { 'tel': tel, 'default_tax_rate': defaultTaxRate, 'seal_path': sealPath, + 'tax_display_mode': taxDisplayMode, }; } @@ -35,6 +38,7 @@ class CompanyInfo { tel: map['tel'], defaultTaxRate: map['default_tax_rate'] ?? 0.10, sealPath: map['seal_path'], + taxDisplayMode: map['tax_display_mode'] ?? 'normal', ); } @@ -45,6 +49,7 @@ class CompanyInfo { String? tel, double? defaultTaxRate, String? sealPath, + String? taxDisplayMode, }) { return CompanyInfo( name: name ?? this.name, @@ -53,6 +58,7 @@ class CompanyInfo { tel: tel ?? this.tel, defaultTaxRate: defaultTaxRate ?? this.defaultTaxRate, sealPath: sealPath ?? this.sealPath, + taxDisplayMode: taxDisplayMode ?? this.taxDisplayMode, ); } } diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index 8ee2c15..786295f 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -3,12 +3,14 @@ import 'package:intl/intl.dart'; class InvoiceItem { final String? id; + final String? productId; // 追加 String description; int quantity; int unitPrice; InvoiceItem({ this.id, + this.productId, // 追加 required this.description, required this.quantity, required this.unitPrice, @@ -20,6 +22,7 @@ class InvoiceItem { return { 'id': id ?? DateTime.now().microsecondsSinceEpoch.toString(), 'invoice_id': invoiceId, + 'product_id': productId, // 追加 'description': description, 'quantity': quantity, 'unit_price': unitPrice, @@ -29,6 +32,7 @@ class InvoiceItem { factory InvoiceItem.fromMap(Map map) { return InvoiceItem( id: map['id'], + productId: map['product_id'], // 追加 description: map['description'], quantity: map['quantity'], unitPrice: map['unit_price'], @@ -36,6 +40,13 @@ class InvoiceItem { } } +enum DocumentType { + estimation, // 見積 + delivery, // 納品 + invoice, // 請求 + receipt, // 領収 +} + class Invoice { final String id; final Customer customer; @@ -44,10 +55,13 @@ class Invoice { final String? notes; final String? filePath; final double taxRate; - final String? customerFormalNameSnapshot; // 追加 + final DocumentType documentType; // 追加 + final String? customerFormalNameSnapshot; final String? odooId; final bool isSynced; final DateTime updatedAt; + final double? latitude; // 追加 + final double? longitude; // 追加 Invoice({ String? id, @@ -56,21 +70,42 @@ class Invoice { required this.items, this.notes, this.filePath, - this.taxRate = 0.10, // デフォルト10% - this.customerFormalNameSnapshot, // 追加 + this.taxRate = 0.10, + this.documentType = DocumentType.invoice, // デフォルト請求書 + this.customerFormalNameSnapshot, this.odooId, this.isSynced = false, DateTime? updatedAt, + this.latitude, // 追加 + this.longitude, // 追加 }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), updatedAt = updatedAt ?? DateTime.now(); - String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; + String get documentTypeName { + switch (documentType) { + case DocumentType.estimation: return "見積書"; + case DocumentType.delivery: return "納品書"; + case DocumentType.invoice: return "請求書"; + case DocumentType.receipt: return "領収書"; + } + } + + String get invoiceNumberPrefix { + switch (documentType) { + case DocumentType.estimation: return "EST"; + case DocumentType.delivery: return "DEL"; + case DocumentType.invoice: return "INV"; + case DocumentType.receipt: return "REC"; + } + } + + String get invoiceNumber => "$invoiceNumberPrefix-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; // 表示用の宛名(スナップショットがあれば優先) String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName; int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal); - int get tax => (subtotal * taxRate).floor(); // taxRateを使用 + int get tax => (subtotal * taxRate).floor(); int get totalAmount => subtotal + tax; Map toMap() { @@ -81,33 +116,17 @@ class Invoice { 'notes': notes, 'file_path': filePath, 'total_amount': totalAmount, - 'tax_rate': taxRate, // 追加 - 'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName, // 追加 + 'tax_rate': taxRate, + 'document_type': documentType.name, // 追加 + 'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName, 'odoo_id': odooId, 'is_synced': isSynced ? 1 : 0, 'updated_at': updatedAt.toIso8601String(), + 'latitude': latitude, // 追加 + 'longitude': longitude, // 追加 }; } - // 注: fromMap には Customer オブジェクトが必要なため、 - // リポジトリ層で構築することを想定し、ここでは factory は定義しません。 - - String toCsv() { - final dateFormatter = DateFormat('yyyy/MM/dd'); - - StringBuffer buffer = StringBuffer(); - buffer.writeln("日付,請求番号,取引先,合計金額,備考"); - buffer.writeln("${dateFormatter.format(date)},$invoiceNumber,$customerNameForDisplay,$totalAmount,${notes ?? ""}"); - buffer.writeln(""); - buffer.writeln("品名,数量,単価,小計"); - - for (var item in items) { - buffer.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}"); - } - - return buffer.toString(); - } - Invoice copyWith({ String? id, Customer? customer, @@ -116,10 +135,13 @@ class Invoice { String? notes, String? filePath, double? taxRate, + DocumentType? documentType, String? customerFormalNameSnapshot, String? odooId, bool? isSynced, DateTime? updatedAt, + double? latitude, + double? longitude, }) { return Invoice( id: id ?? this.id, @@ -129,10 +151,25 @@ class Invoice { notes: notes ?? this.notes, filePath: filePath ?? this.filePath, taxRate: taxRate ?? this.taxRate, + documentType: documentType ?? this.documentType, customerFormalNameSnapshot: customerFormalNameSnapshot ?? this.customerFormalNameSnapshot, odooId: odooId ?? this.odooId, isSynced: isSynced ?? this.isSynced, updatedAt: updatedAt ?? this.updatedAt, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, ); } + + String toCsv() { + final buffer = StringBuffer(); + buffer.writeln('伝票種別,伝票番号,日付,取引先,合計金額,緯度,経度'); + buffer.writeln('$documentTypeName,$invoiceNumber,${DateFormat('yyyy/MM/dd').format(date)},$customerNameForDisplay,$totalAmount,${latitude ?? ""},${longitude ?? ""}'); + buffer.writeln(''); + buffer.writeln('品名,数量,単価,小計'); + for (var item in items) { + buffer.writeln('${item.description},${item.quantity},${item.unitPrice},${item.subtotal}'); + } + return buffer.toString(); + } } diff --git a/lib/models/product_model.dart b/lib/models/product_model.dart index 35ed6a4..c5dfda0 100644 --- a/lib/models/product_model.dart +++ b/lib/models/product_model.dart @@ -3,6 +3,8 @@ class Product { final String name; final int defaultUnitPrice; final String? barcode; + final String? category; + final int stockQuantity; // 追加 final String? odooId; Product({ @@ -10,6 +12,8 @@ class Product { required this.name, this.defaultUnitPrice = 0, this.barcode, + this.category, + this.stockQuantity = 0, // 追加 this.odooId, }); @@ -19,6 +23,8 @@ class Product { 'name': name, 'default_unit_price': defaultUnitPrice, 'barcode': barcode, + 'category': category, + 'stock_quantity': stockQuantity, // 追加 'odoo_id': odooId, }; } @@ -29,6 +35,8 @@ class Product { name: map['name'], defaultUnitPrice: map['default_unit_price'] ?? 0, barcode: map['barcode'], + category: map['category'], + stockQuantity: map['stock_quantity'] ?? 0, // 追加 odooId: map['odoo_id'], ); } diff --git a/lib/screens/activity_log_screen.dart b/lib/screens/activity_log_screen.dart new file mode 100644 index 0000000..e685538 --- /dev/null +++ b/lib/screens/activity_log_screen.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../models/activity_log_model.dart'; +import '../services/activity_log_repository.dart'; + +class ActivityLogScreen extends StatefulWidget { + const ActivityLogScreen({Key? key}) : super(key: key); + + @override + State createState() => _ActivityLogScreenState(); +} + +class _ActivityLogScreenState extends State { + final ActivityLogRepository _logRepo = ActivityLogRepository(); + List _logs = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadLogs(); + } + + Future _loadLogs() async { + setState(() => _isLoading = true); + final logs = await _logRepo.getAllLogs(); + setState(() { + _logs = logs; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('yyyy/MM/dd HH:mm:ss'); + + return Scaffold( + appBar: AppBar( + title: const Text("アクティビティ履歴 (Gitログ風)"), + backgroundColor: Colors.blueGrey.shade800, + actions: [ + IconButton(icon: const Icon(Icons.refresh), onPressed: _loadLogs), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _logs.isEmpty + ? const Center(child: Text("履歴はありません")) + : ListView.builder( + itemCount: _logs.length, + itemBuilder: (context, index) { + final log = _logs[index]; + return _buildLogTile(log, dateFormat); + }, + ), + ); + } + + Widget _buildLogTile(ActivityLog log, DateFormat fmt) { + IconData icon; + Color color; + + switch (log.action) { + case "SAVE_INVOICE": + case "SAVE_PRODUCT": + case "SAVE_CUSTOMER": + icon = Icons.save; + color = Colors.green; + break; + case "DELETE_INVOICE": + case "DELETE_PRODUCT": + case "DELETE_CUSTOMER": + icon = Icons.delete_forever; + color = Colors.red; + break; + case "GENERATE_PDF": + icon = Icons.picture_as_pdf; + color = Colors.orange; + break; + default: + icon = Icons.info_outline; + color = Colors.blueGrey; + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: Colors.grey.shade200), + borderRadius: BorderRadius.circular(8), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: color.withOpacity(0.1), + child: Icon(icon, color: color, size: 20), + ), + title: Text( + "${_getActionJapanese(log.action)} [${log.targetType}]", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (log.details != null) + Text(log.details!, style: const TextStyle(fontSize: 12, color: Colors.black87)), + const SizedBox(height: 4), + Text(fmt.format(log.timestamp), style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + ], + ), + isThreeLine: log.details != null, + ), + ); + } + + String _getActionJapanese(String action) { + switch (action) { + case "SAVE_INVOICE": return "伝票保存"; + case "DELETE_INVOICE": return "伝票削除"; + case "SAVE_PRODUCT": return "商品更新"; + case "DELETE_PRODUCT": return "商品削除"; + case "SAVE_CUSTOMER": return "顧客更新"; + case "DELETE_CUSTOMER": return "顧客削除"; + case "GENERATE_PDF": return "PDF発行"; + default: return action; + } + } +} diff --git a/lib/screens/company_info_screen.dart b/lib/screens/company_info_screen.dart index bce2060..b147a98 100644 --- a/lib/screens/company_info_screen.dart +++ b/lib/screens/company_info_screen.dart @@ -21,6 +21,7 @@ class _CompanyInfoScreenState extends State { final _addressController = TextEditingController(); final _telController = TextEditingController(); double _taxRate = 0.10; + String _taxDisplayMode = 'normal'; @override void initState() { @@ -35,6 +36,7 @@ class _CompanyInfoScreenState extends State { _addressController.text = _info.address ?? ""; _telController.text = _info.tel ?? ""; _taxRate = _info.defaultTaxRate; + _taxDisplayMode = _info.taxDisplayMode; setState(() => _isLoading = false); } @@ -55,6 +57,7 @@ class _CompanyInfoScreenState extends State { address: _addressController.text, tel: _telController.text, defaultTaxRate: _taxRate, + taxDisplayMode: _taxDisplayMode, ); await _companyRepo.saveCompanyInfo(updated); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("自社情報を保存しました"))); @@ -94,6 +97,29 @@ class _CompanyInfoScreenState extends State { ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)), ], ), + const SizedBox(height: 20), + const Text("消費税の表示設定(T番号非取得時など)", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ChoiceChip( + label: const Text("通常表示"), + selected: _taxDisplayMode == 'normal', + onSelected: (_) => setState(() => _taxDisplayMode = 'normal'), + ), + ChoiceChip( + label: const Text("表示しない"), + selected: _taxDisplayMode == 'hidden', + onSelected: (_) => setState(() => _taxDisplayMode = 'hidden'), + ), + ChoiceChip( + label: const Text("「税別」と表示"), + selected: _taxDisplayMode == 'text_only', + onSelected: (_) => setState(() => _taxDisplayMode = 'text_only'), + ), + ], + ), const SizedBox(height: 24), const Text("印影(角印)撮影", style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 12), diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index d51ab52..e3cbbd4 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -28,29 +28,18 @@ class _CustomerPickerModalState extends State { @override void initState() { super.initState(); - _loadCustomers(); + _onSearch(""); // 初期表示 } - Future _loadCustomers() async { + Future _onSearch(String query) async { setState(() => _isLoading = true); - final customers = await _repository.getAllCustomers(); + final customers = await _repository.searchCustomers(query); setState(() { - _allCustomers = customers; _filteredCustomers = customers; _isLoading = false; }); } - void _filterCustomers(String query) { - setState(() { - _searchQuery = query.toLowerCase(); - _filteredCustomers = _allCustomers.where((customer) { - return customer.formalName.toLowerCase().contains(_searchQuery) || - customer.displayName.toLowerCase().contains(_searchQuery); - }).toList(); - }); - } - /// 電話帳から取り込んで新規顧客として登録・編集するダイアログ Future _importFromPhoneContacts() async { setState(() => _isImportingFromContacts = true); @@ -149,7 +138,7 @@ class _CustomerPickerModalState extends State { await _repository.saveCustomer(updatedCustomer); Navigator.pop(context); // エディットダイアログを閉じる - _loadCustomers(); // リストを再読込 + _onSearch(""); // リストを再読込 // 保存のついでに選択状態にするなら以下を有効化(今回は明示的にリストから選ばせる) // widget.onCustomerSelected(updatedCustomer); @@ -174,7 +163,7 @@ class _CustomerPickerModalState extends State { onPressed: () async { await _repository.deleteCustomer(customer.id); Navigator.pop(context); - _loadCustomers(); + _onSearch(""); }, child: const Text("削除する", style: TextStyle(color: Colors.red)), ), @@ -207,7 +196,7 @@ class _CustomerPickerModalState extends State { prefixIcon: const Icon(Icons.search), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), ), - onChanged: _filterCustomers, + onChanged: _onSearch, ), const SizedBox(height: 12), SizedBox( diff --git a/lib/screens/gps_history_screen.dart b/lib/screens/gps_history_screen.dart new file mode 100644 index 0000000..454b281 --- /dev/null +++ b/lib/screens/gps_history_screen.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../services/gps_service.dart'; + +class GpsHistoryScreen extends StatefulWidget { + const GpsHistoryScreen({Key? key}) : super(key: key); + + @override + State createState() => _GpsHistoryScreenState(); +} + +class _GpsHistoryScreenState extends State { + final _gpsService = GpsService(); + List> _history = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + setState(() => _isLoading = true); + final history = await _gpsService.getHistory(limit: 50); + setState(() { + _history = history; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("GPS位置情報履歴"), + backgroundColor: Colors.blueGrey, + actions: [ + IconButton(onPressed: _loadHistory, icon: const Icon(Icons.refresh)), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _history.isEmpty + ? const Center(child: Text("位置情報の履歴がありません。")) + : ListView.builder( + itemCount: _history.length, + itemBuilder: (context, index) { + final item = _history[index]; + final date = DateTime.parse(item['timestamp']); + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.location_on)), + title: Text("${item['latitude'].toStringAsFixed(6)}, ${item['longitude'].toStringAsFixed(6)}"), + subtitle: Text(DateFormat('yyyy/MM/dd HH:mm:ss').format(date)), + trailing: const Icon(Icons.arrow_forward_ios, size: 14), + ); + }, + ), + ); + } +} diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index 999893a..18ac6a2 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -6,7 +6,11 @@ import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; +import '../services/company_repository.dart'; import 'product_picker_modal.dart'; +import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart'; +import '../services/print_service.dart'; +import '../models/company_model.dart'; class InvoiceDetailPage extends StatefulWidget { final Invoice invoice; @@ -27,6 +31,8 @@ class _InvoiceDetailPageState extends State { String? _currentFilePath; final _invoiceRepo = InvoiceRepository(); final _customerRepo = CustomerRepository(); + final _companyRepo = CompanyRepository(); + CompanyInfo? _companyInfo; @override void initState() { @@ -37,6 +43,12 @@ class _InvoiceDetailPageState extends State { _notesController = TextEditingController(text: _currentInvoice.notes ?? ""); _items = List.from(_currentInvoice.items); _isEditing = false; + _loadCompanyInfo(); + } + + Future _loadCompanyInfo() async { + final info = await _companyRepo.getCompanyInfo(); + setState(() => _companyInfo = info); } @override @@ -127,6 +139,59 @@ class _InvoiceDetailPageState extends State { Share.share(csvData, subject: '請求書データ_CSV'); } + Future _printReceipt() async { + final printService = PrintService(); + final isConnected = await printService.isConnected; + + if (!isConnected) { + final devices = await printService.getPairedDevices(); + if (devices.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ペアリング済みのデバイスが見つかりません。OSの設定を確認してください。"))); + return; + } + + final selected = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("プリンターを選択"), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: devices.length, + itemBuilder: (context, idx) => ListTile( + leading: const Icon(Icons.print), + title: Text(devices[idx].name ?? "Unknown Device"), + subtitle: Text(devices[idx].macAdress ?? ""), + onTap: () => Navigator.pop(context, devices[idx]), + ), + ), + ), + ), + ); + + if (selected != null) { + final ok = await printService.connect(selected.macAdress!); + if (!ok) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("接続に失敗しました。"))); + return; + } + } else { + return; + } + } + + final success = await printService.printReceipt(_currentInvoice); + if (!mounted) return; + if (success) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("レシートを印刷しました。"))); + } else { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("印刷エラーが発生しました。"))); + } + } + @override Widget build(BuildContext context) { final amountFormatter = NumberFormat("#,###"); @@ -213,8 +278,20 @@ class _InvoiceDetailPageState extends State { 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) ...[ + Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"), + if (_currentInvoice.latitude != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + children: [ + const Icon(Icons.location_on, size: 14, color: Colors.blueGrey), + const SizedBox(width: 4), + Text("座標: ${_currentInvoice.latitude!.toStringAsFixed(4)}, ${_currentInvoice.longitude!.toStringAsFixed(4)}", + style: const TextStyle(fontSize: 12, color: Colors.blueGrey)), + ], + ), + ), + if (_currentInvoice.notes?.isNotEmpty ?? false) ...[ const SizedBox(height: 8), Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)), ] @@ -290,9 +367,12 @@ class _InvoiceDetailPageState extends State { child: Column( children: [ _SummaryRow("小計 (税抜)", formatter.format(subtotal)), - _SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)), + if (_companyInfo?.taxDisplayMode == 'normal') + _SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)), + if (_companyInfo?.taxDisplayMode == 'text_only') + _SummaryRow("消費税", "(税別)"), const Divider(), - _SummaryRow("合計 (税込)", "¥${formatter.format(total)}", isBold: true), + _SummaryRow("合計", "¥${formatter.format(total)}", isBold: true), ], ), ), @@ -324,6 +404,15 @@ class _InvoiceDetailPageState extends State { style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white), ), ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: _printReceipt, + icon: const Icon(Icons.print), + label: const Text("レシート"), + style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade800, foregroundColor: Colors.white), + ), + ), ], ); } diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index aa831b6..7bb83fa 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -6,8 +6,12 @@ import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; +import 'package:printing/printing.dart'; +import '../services/gps_service.dart'; import 'customer_picker_modal.dart'; import 'product_picker_modal.dart'; +import '../models/company_model.dart'; +import '../services/company_repository.dart'; class InvoiceInputForm extends StatefulWidget { final Function(Invoice invoice, String filePath) onInvoiceGenerated; @@ -27,6 +31,8 @@ class _InvoiceInputFormState extends State { final List _items = []; double _taxRate = 0.10; bool _includeTax = true; + CompanyInfo? _companyInfo; + DocumentType _documentType = DocumentType.invoice; // 追加 String _status = "取引先と商品を入力してください"; // 署名用の実験的パス @@ -45,6 +51,13 @@ class _InvoiceInputFormState extends State { if (customers.isNotEmpty) { setState(() => _selectedCustomer = customers.first); } + + final companyRepo = CompanyRepository(); + final companyInfo = await companyRepo.getCompanyInfo(); + setState(() { + _companyInfo = companyInfo; + _taxRate = companyInfo.defaultTaxRate; + }); } void _addItem() { @@ -74,13 +87,23 @@ class _InvoiceInputFormState extends State { return; } + // GPS情報の取得 + final gpsService = GpsService(); + final pos = await gpsService.getCurrentLocation(); + if (pos != null) { + await gpsService.logLocation(); // 履歴テーブルにも保存 + } + final invoice = Invoice( customer: _selectedCustomer!, date: DateTime.now(), items: _items, taxRate: _includeTax ? _taxRate : 0.0, + documentType: _documentType, customerFormalNameSnapshot: _selectedCustomer!.formalName, notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", + latitude: pos?.latitude, + longitude: pos?.longitude, ); if (generatePdf) { @@ -101,27 +124,43 @@ class _InvoiceInputFormState extends State { void _showPreview() { if (_selectedCustomer == null) return; + final invoice = Invoice( + customer: _selectedCustomer!, + date: DateTime.now(), + items: _items, + taxRate: _includeTax ? _taxRate : 0.0, + documentType: _documentType, + customerFormalNameSnapshot: _selectedCustomer!.formalName, + notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", + ); + showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text("伝票プレビュー(仮)"), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("宛名: ${_selectedCustomer!.formalName} ${_selectedCustomer!.title}"), - const Divider(), - ..._items.map((it) => Text("・${it.description} x ${it.quantity} = ¥${it.subtotal}")), - const Divider(), - Text("小計: ¥${NumberFormat("#,###").format(_subTotal)}"), - Text("消費税: ¥${NumberFormat("#,###").format(_tax)}"), - Text("合計: ¥${NumberFormat("#,###").format(_total)}", style: const TextStyle(fontWeight: FontWeight.bold)), - ], - ), + builder: (context) => Dialog.fullscreen( + child: Column( + children: [ + AppBar( + title: Text("${invoice.documentTypeName}プレビュー"), + leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), + ), + Expanded( + child: PdfPreview( + build: (format) async { + // PdfGeneratorを少しリファクタして pw.Document を返す関数に分離することも可能だが + // ここでは generateInvoicePdf の中身を模したバイト生成を行う + // (もしくは generateInvoicePdf のシグネチャを変えてバイトを返すようにする) + // 簡易化のため、一時ファイルを作ってそれを読み込むか、Generatorを修正する + // 今回は Generator に pw.Document を生成する内部関数を作る + final pdfDoc = await buildInvoiceDocument(invoice); + return pdfDoc.save(); + }, + allowPrinting: false, + allowSharing: false, + canChangePageFormat: false, + ), + ), + ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text("閉じる")), - ], ), ); } @@ -138,6 +177,8 @@ class _InvoiceInputFormState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildDocumentTypeSection(), // 追加 + const SizedBox(height: 16), _buildCustomerSection(), const SizedBox(height: 20), _buildItemsSection(fmt), @@ -156,6 +197,51 @@ class _InvoiceInputFormState extends State { ); } + Widget _buildDocumentTypeSection() { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: DocumentType.values.map((type) { + final isSelected = _documentType == type; + String label = ""; + IconData icon = Icons.description; + switch (type) { + case DocumentType.estimation: label = "見積"; icon = Icons.article_outlined; break; + case DocumentType.delivery: label = "納品"; icon = Icons.local_shipping_outlined; break; + case DocumentType.invoice: label = "請求"; icon = Icons.receipt_long_outlined; break; + case DocumentType.receipt: label = "領収"; icon = Icons.payments_outlined; break; + } + return Expanded( + child: InkWell( + onTap: () => setState(() => _documentType = type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected ? Colors.indigo : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Icon(icon, color: isSelected ? Colors.white : Colors.grey.shade600, size: 20), + Text(label, style: TextStyle( + color: isSelected ? Colors.white : Colors.grey.shade600, + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + )), + ], + ), + ), + ), + ); + }).toList(), + ), + ); + } + Widget _buildCustomerSection() { return Card( elevation: 0, @@ -267,12 +353,15 @@ class _InvoiceInputFormState extends State { padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)), child: Column( - children: [ - _buildSummaryRow("小計", "¥${fmt.format(_subTotal)}", Colors.white70), - _buildSummaryRow("消費税", "¥${fmt.format(_tax)}", Colors.white70), - const Divider(color: Colors.white24), - _buildSummaryRow("合計金額", "¥${fmt.format(_total)}", Colors.white, fontSize: 24), - ], + children: [ + _buildSummaryRow("小計 (税抜)", "¥${fmt.format(_subTotal)}", Colors.white70), + if (_companyInfo?.taxDisplayMode == 'normal') + _buildSummaryRow("消費税 (${(_taxRate * 100).toInt()}%)", "¥${fmt.format(_tax)}", Colors.white70), + if (_companyInfo?.taxDisplayMode == 'text_only') + _buildSummaryRow("消費税", "(税別)", Colors.white70), + const Divider(color: Colors.white24), + _buildSummaryRow("合計金額", "¥${fmt.format(_total)}", Colors.white, fontSize: 24), + ], ), ); } diff --git a/lib/screens/management_screen.dart b/lib/screens/management_screen.dart index ec2b086..9b6e645 100644 --- a/lib/screens/management_screen.dart +++ b/lib/screens/management_screen.dart @@ -3,10 +3,14 @@ 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 'package:path_provider/path_provider.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; import 'product_master_screen.dart'; import 'customer_master_screen.dart'; +import 'activity_log_screen.dart'; +import 'sales_report_screen.dart'; +import 'gps_history_screen.dart'; class ManagementScreen extends StatelessWidget { const ManagementScreen({Key? key}) : super(key: key); @@ -58,6 +62,20 @@ class ManagementScreen extends StatelessWidget { "SQLiteファイルを外部へ保存・シェアします", () => _backupDatabase(context), ), + _buildMenuTile( + context, + Icons.list_alt, + "アクティビティ履歴 (Git風)", + "データの作成・変更・削除の履歴を確認します", + () => Navigator.push(context, MaterialPageRoute(builder: (context) => const ActivityLogScreen())), + ), + _buildMenuTile( + context, + Icons.analytics_outlined, + "売上・資金管理レポート", + "売上や資金の流れを分析します", // Added subtitle + () => Navigator.push(context, MaterialPageRoute(builder: (context) => const SalesReportScreen())), + ), _buildMenuTile( context, Icons.settings_backup_restore, @@ -65,6 +83,13 @@ class ManagementScreen extends StatelessWidget { "バックアップから全てのデータを復元します", () => _showComingSoon(context), ), + _buildMenuTile( + context, + Icons.location_history, + "GPS座標履歴の管理", + "過去に取得した位置情報の履歴を確認します", + () => Navigator.push(context, MaterialPageRoute(builder: (context) => const GpsHistoryScreen())), + ), const Divider(), _buildSectionHeader("外部同期 (将来のOdoo連携)"), _buildMenuTile( diff --git a/lib/screens/product_master_screen.dart b/lib/screens/product_master_screen.dart index f49227b..2936169 100644 --- a/lib/screens/product_master_screen.dart +++ b/lib/screens/product_master_screen.dart @@ -13,8 +13,12 @@ class ProductMasterScreen extends StatefulWidget { class _ProductMasterScreenState extends State { final ProductRepository _productRepo = ProductRepository(); + final TextEditingController _searchController = TextEditingController(); + List _products = []; + List _filteredProducts = []; bool _isLoading = true; + String _searchQuery = ""; @override void initState() { @@ -28,72 +32,81 @@ class _ProductMasterScreenState extends State { setState(() { _products = products; _isLoading = false; + _applyFilter(); }); } - Future _addItem({Product? product}) async { - final isEdit = product != null; + void _applyFilter() { + setState(() { + _filteredProducts = _products.where((p) { + final query = _searchQuery.toLowerCase(); + return p.name.toLowerCase().contains(query) || + (p.barcode?.toLowerCase().contains(query) ?? false) || + (p.category?.toLowerCase().contains(query) ?? false); + }).toList(); + }); + } + + Future _showEditDialog({Product? product}) async { final nameController = TextEditingController(text: product?.name ?? ""); - final priceController = TextEditingController(text: product?.defaultUnitPrice.toString() ?? "0"); + final priceController = TextEditingController(text: (product?.defaultUnitPrice ?? 0).toString()); final barcodeController = TextEditingController(text: product?.barcode ?? ""); + final categoryController = TextEditingController(text: product?.category ?? ""); + final stockController = TextEditingController(text: (product?.stockQuantity ?? 0).toString()); final result = await showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( - title: Text(isEdit ? "商品を編集" : "商品を新規登録"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: "商品名"), - ), - TextField( - controller: priceController, - decoration: const InputDecoration(labelText: "初期単価"), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: barcodeController, - decoration: const InputDecoration(labelText: "バーコード"), + title: Text(product == null ? "商品追加" : "商品編集"), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")), ), - ), - IconButton( - icon: const Icon(Icons.qr_code_scanner), - onPressed: () async { - final code = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()), - ); - if (code != null) { - setDialogState(() { - barcodeController.text = code; - }); - } - }, - ), - ], - ), - ], + IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: () async { + final code = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()), + ); + if (code != null) { + setDialogState(() => barcodeController.text = code); + } + }, + ), + ], + ), + ], + ), ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), - TextButton( + ElevatedButton( onPressed: () { if (nameController.text.isEmpty) return; - final newProduct = Product( - id: product?.id ?? const Uuid().v4(), - name: nameController.text, - defaultUnitPrice: int.tryParse(priceController.text) ?? 0, - barcode: barcodeController.text.isEmpty ? null : barcodeController.text, - odooId: product?.odooId, + Navigator.pop( + context, + Product( + id: product?.id ?? const Uuid().v4(), + name: nameController.text.trim(), + defaultUnitPrice: int.tryParse(priceController.text) ?? 0, + barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(), + category: categoryController.text.isEmpty ? null : categoryController.text.trim(), + stockQuantity: int.tryParse(stockController.text) ?? 0, + odooId: product?.odooId, + ), ); - Navigator.pop(context, newProduct); }, child: const Text("保存"), ), @@ -112,42 +125,67 @@ class _ProductMasterScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("商品マスター管理"), + title: const Text("商品マスター"), backgroundColor: Colors.blueGrey, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: "商品名・バーコード・カテゴリで検索", + prefixIcon: const Icon(Icons.search), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + contentPadding: EdgeInsets.zero, + ), + onChanged: (val) { + _searchQuery = val; + _applyFilter(); + }, + ), + ), + ), ), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : _products.isEmpty - ? const Center(child: Text("商品が登録されていません")) + : _filteredProducts.isEmpty + ? const Center(child: Text("商品が見つかりません")) : ListView.builder( - itemCount: _products.length, + itemCount: _filteredProducts.length, itemBuilder: (context, index) { - final p = _products[index]; + final p = _filteredProducts[index]; return ListTile( + leading: const CircleAvatar(child: Icon(Icons.inventory_2)), title: Text(p.name), - subtitle: Text("初期単価: ¥${p.defaultUnitPrice}"), + subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - IconButton(icon: const Icon(Icons.edit), onPressed: () => _addItem(product: p)), + IconButton(icon: const Icon(Icons.edit), onPressed: () => _showEditDialog(product: p)), IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - onPressed: () async { - final confirm = await showDialog( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + onPressed: () { + showDialog( context: context, builder: (context) => AlertDialog( - title: const Text("削除確認"), - content: Text("「${p.name}」を削除しますか?"), + title: const Text("削除の確認"), + content: Text("${p.name}を削除してよろしいですか?"), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")), - TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))), + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + TextButton( + onPressed: () async { + await _productRepo.deleteProduct(p.id); + Navigator.pop(context); + _loadProducts(); + }, + child: const Text("削除", style: TextStyle(color: Colors.red)), + ), ], ), ); - if (confirm == true) { - await _productRepo.deleteProduct(p.id); - _loadProducts(); - } }, ), ], @@ -156,9 +194,10 @@ class _ProductMasterScreenState extends State { }, ), floatingActionButton: FloatingActionButton( - onPressed: _addItem, + onPressed: () => _showEditDialog(), child: const Icon(Icons.add), - backgroundColor: Colors.indigo, + backgroundColor: Colors.blueGrey.shade800, + foregroundColor: Colors.white, ), ); } diff --git a/lib/screens/product_picker_modal.dart b/lib/screens/product_picker_modal.dart index 6341801..670b048 100644 --- a/lib/screens/product_picker_modal.dart +++ b/lib/screens/product_picker_modal.dart @@ -16,18 +16,19 @@ class ProductPickerModal extends StatefulWidget { class _ProductPickerModalState extends State { final ProductRepository _productRepo = ProductRepository(); + final TextEditingController _searchController = TextEditingController(); List _products = []; bool _isLoading = true; @override void initState() { super.initState(); - _loadProducts(); + _onSearch(""); // 初期表示 } - Future _loadProducts() async { + Future _onSearch(String val) async { setState(() => _isLoading = true); - final products = await _productRepo.getAllProducts(); + final products = await _productRepo.searchProducts(val); setState(() { _products = products; _isLoading = false; @@ -53,6 +54,25 @@ class _ProductPickerModalState extends State { ], ), ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: "商品名・カテゴリ・バーコードで検索", + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { _searchController.clear(); _onSearch(""); }, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + ), + onChanged: _onSearch, + ), + ), + const SizedBox(height: 8), const Divider(), Expanded( child: _isLoading @@ -62,13 +82,13 @@ class _ProductPickerModalState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text("商品マスターが空です"), + const Text("商品が見つかりません"), TextButton( onPressed: () async { await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())); - _loadProducts(); + _onSearch(_searchController.text); }, - child: const Text("商品マスターを編集する"), + child: const Text("マスターに追加する"), ), ], ), @@ -80,9 +100,10 @@ class _ProductPickerModalState extends State { return ListTile( leading: const Icon(Icons.inventory_2_outlined), title: Text(product.name), - subtitle: Text("初期単価: ¥${product.defaultUnitPrice}"), + subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"), onTap: () => widget.onItemSelected( InvoiceItem( + productId: product.id, description: product.name, quantity: 1, unitPrice: product.defaultUnitPrice, @@ -92,18 +113,6 @@ class _ProductPickerModalState extends State { }, ), ), - if (_products.isNotEmpty) - Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton.icon( - icon: const Icon(Icons.edit), - label: const Text("商品マスターの管理"), - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())); - _loadProducts(); - }, - ), - ), ], ), ); diff --git a/lib/screens/sales_report_screen.dart b/lib/screens/sales_report_screen.dart new file mode 100644 index 0000000..849c2c4 --- /dev/null +++ b/lib/screens/sales_report_screen.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../services/invoice_repository.dart'; + +class SalesReportScreen extends StatefulWidget { + const SalesReportScreen({Key? key}) : super(key: key); + + @override + State createState() => _SalesReportScreenState(); +} + +class _SalesReportScreenState extends State { + final _invoiceRepo = InvoiceRepository(); + int _targetYear = DateTime.now().year; + Map _monthlySales = {}; + int _yearlyTotal = 0; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _isLoading = true); + final monthly = await _invoiceRepo.getMonthlySales(_targetYear); + final yearly = await _invoiceRepo.getYearlyTotal(_targetYear); + setState(() { + _monthlySales = monthly; + _yearlyTotal = yearly; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + final fmt = NumberFormat("#,###"); + + return Scaffold( + appBar: AppBar( + title: const Text("売上・資金管理レポート"), + 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)), + ], + ), + ); + } + + Widget _buildYearSelector() { + return Container( + color: Colors.indigo.shade50, + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + setState(() => _targetYear--); + _loadData(); + }, + ), + Text( + "$_targetYear年度", + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () { + setState(() => _targetYear++); + _loadData(); + }, + ), + ], + ), + ); + } + + Widget _buildYearlySummary(NumberFormat fmt) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.indigo.shade900, + ), + child: Column( + 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), + ), + ], + ), + ); + } + + 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, + ), + ), + ); + }, + ); + } +} +// FontWeight.bold in Text widget is TextStyle.fontWeight not pw.FontWeight +// Corrected to FontWeight.bold below in replace or write. diff --git a/lib/services/activity_log_repository.dart b/lib/services/activity_log_repository.dart new file mode 100644 index 0000000..d9a1cd3 --- /dev/null +++ b/lib/services/activity_log_repository.dart @@ -0,0 +1,36 @@ +import '../models/activity_log_model.dart'; +import 'database_helper.dart'; + +class ActivityLogRepository { + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future log(ActivityLog log) async { + final db = await _dbHelper.database; + await db.insert('activity_logs', log.toMap()); + } + + Future logAction({ + required String action, + required String targetType, + String? targetId, + String? details, + }) async { + final activity = ActivityLog.create( + action: action, + targetType: targetType, + targetId: targetId, + details: details, + ); + await log(activity); + } + + Future> getAllLogs({int limit = 100}) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'activity_logs', + orderBy: 'timestamp DESC', + limit: limit, + ); + return List.generate(maps.length, (i) => ActivityLog.fromMap(maps[i])); + } +} diff --git a/lib/services/customer_repository.dart b/lib/services/customer_repository.dart index d977b88..06027b5 100644 --- a/lib/services/customer_repository.dart +++ b/lib/services/customer_repository.dart @@ -2,9 +2,11 @@ import 'package:sqflite/sqflite.dart'; import '../models/customer_model.dart'; import 'database_helper.dart'; import 'package:uuid/uuid.dart'; +import 'activity_log_repository.dart'; class CustomerRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); + final ActivityLogRepository _logRepo = ActivityLogRepository(); Future> getAllCustomers() async { final db = await _dbHelper.database; @@ -43,11 +45,29 @@ class CustomerRepository { customer.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); + + await _logRepo.logAction( + action: "SAVE_CUSTOMER", + targetType: "CUSTOMER", + targetId: customer.id, + details: "名称: ${customer.formalName}, 敬称: ${customer.title}", + ); } Future deleteCustomer(String id) async { final db = await _dbHelper.database; - await db.delete('customers', where: 'id = ?', whereArgs: [id]); + await db.delete( + 'customers', + where: 'id = ?', + whereArgs: [id], + ); + + await _logRepo.logAction( + action: "DELETE_CUSTOMER", + targetType: "CUSTOMER", + targetId: id, + details: "顧客を削除しました", + ); } // GPS履歴の保存 (直近10件を自動管理) @@ -86,4 +106,16 @@ class CustomerRepository { orderBy: 'timestamp DESC', ); } + + Future> searchCustomers(String query) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'customers', + where: 'display_name LIKE ? OR formal_name LIKE ?', + whereArgs: ['%$query%', '%$query%'], + orderBy: 'display_name ASC', + limit: 50, + ); + return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); + } } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index ed6e1c6..a0d15d1 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,6 +2,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; class DatabaseHelper { + static const _databaseVersion = 9; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -19,7 +20,7 @@ class DatabaseHelper { String path = join(await getDatabasesPath(), 'gemi_invoice.db'); return await openDatabase( path, - version: 5, + version: _databaseVersion, onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -46,13 +47,46 @@ class DatabaseHelper { await db.execute('ALTER TABLE products ADD COLUMN barcode TEXT'); } if (oldVersion < 5) { - // 顧客情報のスナップショット await db.execute('ALTER TABLE invoices ADD COLUMN customer_formal_name TEXT'); } + if (oldVersion < 6) { + await db.execute('ALTER TABLE products ADD COLUMN category TEXT'); + await db.execute('CREATE INDEX idx_products_name ON products(name)'); + await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)'); + await db.execute(''' + CREATE TABLE activity_logs ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT, + details TEXT, + timestamp TEXT NOT NULL + ) + '''); + } + if (oldVersion < 7) { + await db.execute('ALTER TABLE products ADD COLUMN stock_quantity INTEGER DEFAULT 0'); + await db.execute('ALTER TABLE invoices ADD COLUMN document_type TEXT DEFAULT "invoice"'); + await db.execute('ALTER TABLE invoice_items ADD COLUMN product_id TEXT'); + } + if (oldVersion < 8) { + await db.execute('ALTER TABLE invoices ADD COLUMN latitude REAL'); + await db.execute('ALTER TABLE invoices ADD COLUMN longitude REAL'); + await db.execute(''' + CREATE TABLE app_gps_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + timestamp TEXT NOT NULL + ) + '''); + } + if (oldVersion < 9) { + await db.execute('ALTER TABLE company_info ADD COLUMN tax_display_mode TEXT DEFAULT "normal"'); + } } Future _onCreate(Database db, int version) async { - // 顧客マスター await db.execute(''' CREATE TABLE customers ( id TEXT PRIMARY KEY, @@ -68,7 +102,6 @@ class DatabaseHelper { ) '''); - // GPS履歴 (直近10件想定だがDB上は保持) await db.execute(''' CREATE TABLE customer_gps_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -87,9 +120,13 @@ class DatabaseHelper { name TEXT NOT NULL, default_unit_price INTEGER, barcode TEXT, + category TEXT, + stock_quantity INTEGER DEFAULT 0, odoo_id TEXT ) '''); + await db.execute('CREATE INDEX idx_products_name ON products(name)'); + await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)'); // 伝票マスター await db.execute(''' @@ -101,19 +138,32 @@ class DatabaseHelper { file_path TEXT, total_amount INTEGER, tax_rate REAL DEFAULT 0.10, + document_type TEXT DEFAULT "invoice", customer_formal_name TEXT, odoo_id TEXT, is_synced INTEGER DEFAULT 0, updated_at TEXT NOT NULL, + latitude REAL, + longitude REAL, FOREIGN KEY (customer_id) REFERENCES customers (id) ) '''); + await db.execute(''' + CREATE TABLE app_gps_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + timestamp TEXT NOT NULL + ) + '''); + // 伝票明細 await db.execute(''' CREATE TABLE invoice_items ( id TEXT PRIMARY KEY, invoice_id TEXT NOT NULL, + product_id TEXT, description TEXT NOT NULL, quantity INTEGER NOT NULL, unit_price INTEGER NOT NULL, @@ -121,7 +171,6 @@ class DatabaseHelper { ) '''); - // 自社情報 await db.execute(''' CREATE TABLE company_info ( id INTEGER PRIMARY KEY, @@ -130,7 +179,19 @@ class DatabaseHelper { address TEXT, tel TEXT, default_tax_rate REAL DEFAULT 0.10, - seal_path TEXT + seal_path TEXT, + tax_display_mode TEXT DEFAULT "normal" + ) + '''); + + await db.execute(''' + CREATE TABLE activity_logs ( + id TEXT PRIMARY KEY, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT, + details TEXT, + timestamp TEXT NOT NULL ) '''); } diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart new file mode 100644 index 0000000..d52cba5 --- /dev/null +++ b/lib/services/gps_service.dart @@ -0,0 +1,53 @@ +import 'package:geolocator/geolocator.dart'; +import 'database_helper.dart'; + +class GpsService { + final _dbHelper = DatabaseHelper(); + + /// 現在地の取得(権限チェック含む) + Future getCurrentLocation() async { + bool serviceEnabled; + LocationPermission permission; + + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return null; + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) return null; + } + + if (permission == LocationPermission.deniedForever) return null; + + try { + return await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + timeLimit: Duration(seconds: 5), + ), + ); + } catch (_) { + return null; + } + } + + /// 現在地を履歴としてデータベースに記録 + Future logLocation() async { + final pos = await getCurrentLocation(); + if (pos == null) return; + + final db = await _dbHelper.database; + await db.insert('app_gps_history', { + 'latitude': pos.latitude, + 'longitude': pos.longitude, + 'timestamp': DateTime.now().toIso8601String(), + }); + } + + /// 指定件数のGPS履歴を取得 + Future>> getHistory({int limit = 10}) async { + final db = await _dbHelper.database; + return await db.query('app_gps_history', orderBy: 'timestamp DESC', limit: limit); + } +} diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index f75827c..a9dc485 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -4,14 +4,33 @@ import 'package:path_provider/path_provider.dart'; import '../models/invoice_models.dart'; import '../models/customer_model.dart'; import 'database_helper.dart'; +import 'activity_log_repository.dart'; class InvoiceRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); + final ActivityLogRepository _logRepo = ActivityLogRepository(); Future saveInvoice(Invoice invoice) async { final db = await _dbHelper.database; await db.transaction((txn) async { + // 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く) + final List> oldItems = await txn.query( + 'invoice_items', + where: 'invoice_id = ?', + whereArgs: [invoice.id], + ); + + // 旧在庫を戻す + for (var item in oldItems) { + if (item['product_id'] != null) { + await txn.execute( + 'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?', + [item['quantity'], item['product_id']], + ); + } + } + // 伝票ヘッダーの保存 await txn.insert( 'invoices', @@ -19,18 +38,31 @@ class InvoiceRepository { conflictAlgorithm: ConflictAlgorithm.replace, ); - // 既存の明細を一旦削除(更新対応) + // 既存の明細を一旦削除 await txn.delete( 'invoice_items', where: 'invoice_id = ?', whereArgs: [invoice.id], ); - // 新しい明細の保存 + // 新しい明細の保存と在庫の減算 for (var item in invoice.items) { await txn.insert('invoice_items', item.toMap(invoice.id)); + if (item.productId != null) { + await txn.execute( + 'UPDATE products SET stock_quantity = stock_quantity - ? WHERE id = ?', + [item.quantity, item.productId], + ); + } } }); + + await _logRepo.logAction( + action: "SAVE_INVOICE", + targetType: "INVOICE", + targetId: invoice.id, + details: "種別: ${invoice.documentTypeName}, 取引先: ${invoice.customerNameForDisplay}, 合計: ¥${invoice.totalAmount}", + ); } Future> getAllInvoices(List customers) async { @@ -52,6 +84,14 @@ class InvoiceRepository { final items = List.generate(itemMaps.length, (i) => InvoiceItem.fromMap(itemMaps[i])); + // document_typeのパース + DocumentType docType = DocumentType.invoice; + if (iMap['document_type'] != null) { + try { + docType = DocumentType.values.firstWhere((e) => e.name == iMap['document_type']); + } catch (_) {} + } + invoices.add(Invoice( id: iMap['id'], customer: customer, @@ -60,10 +100,13 @@ class InvoiceRepository { notes: iMap['notes'], filePath: iMap['file_path'], taxRate: iMap['tax_rate'] ?? 0.10, - customerFormalNameSnapshot: iMap['customer_formal_name'], // 追加 + documentType: docType, + customerFormalNameSnapshot: iMap['customer_formal_name'], odooId: iMap['odoo_id'], isSynced: iMap['is_synced'] == 1, updatedAt: DateTime.parse(iMap['updated_at']), + latitude: iMap['latitude'], + longitude: iMap['longitude'], )); } return invoices; @@ -72,6 +115,22 @@ class InvoiceRepository { Future deleteInvoice(String id) async { final db = await _dbHelper.database; await db.transaction((txn) async { + // 在庫の復元 + final List> items = await txn.query( + 'invoice_items', + where: 'invoice_id = ?', + whereArgs: [id], + ); + + for (var item in items) { + if (item['product_id'] != null) { + await txn.execute( + 'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?', + [item['quantity'], item['product_id']], + ); + } + } + // PDFパスの取得(削除用) final List> maps = await txn.query( 'invoices', @@ -122,4 +181,34 @@ class InvoiceRepository { return 0; } } + + Future> getMonthlySales(int year) 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 + FROM invoices + WHERE strftime('%Y', date) = ? AND document_type = 'invoice' + GROUP BY month + ORDER BY month ASC + ''', [yearStr]); + + Map monthlyTotal = {}; + for (var r in results) { + monthlyTotal[r['month']] = (r['total'] as num).toInt(); + } + return monthlyTotal; + } + + 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(); + } } diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index 6e5ca21..6ef3a23 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -8,202 +8,231 @@ import 'package:crypto/crypto.dart'; import 'package:intl/intl.dart'; import '../models/invoice_models.dart'; import 'company_repository.dart'; +import 'activity_log_repository.dart'; -/// A4サイズのプロフェッショナルな請求書PDFを生成し、保存する -Future generateInvoicePdf(Invoice invoice) async { - try { - final pdf = pw.Document(); +/// PDFドキュメントの構築(プレビューと実保存の両方で使用) +Future buildInvoiceDocument(Invoice invoice) async { + final pdf = pw.Document(); - // フォントのロード - final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf"); - final ttf = pw.Font.ttf(fontData); - final boldTtf = pw.Font.ttf(fontData); // IPAexGはウェイトが1つなので同じものを使用 + // フォントのロード + final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf"); + final ttf = pw.Font.ttf(fontData); + final boldTtf = pw.Font.ttf(fontData); - final dateFormatter = DateFormat('yyyy年MM月dd日'); - final amountFormatter = NumberFormat("#,###"); + final dateFormatter = DateFormat('yyyy年MM月dd日'); + final amountFormatter = NumberFormat("#,###"); - // 自社情報の取得 - final companyRepo = CompanyRepository(); - final companyInfo = await companyRepo.getCompanyInfo(); + // 自社情報の取得 + final companyRepo = CompanyRepository(); + final companyInfo = await companyRepo.getCompanyInfo(); - // 印影画像のロード - pw.MemoryImage? sealImage; - if (companyInfo.sealPath != null) { - final file = File(companyInfo.sealPath!); - if (await file.exists()) { - final bytes = await file.readAsBytes(); - sealImage = pw.MemoryImage(bytes); - } + // 印影画像のロード + pw.MemoryImage? sealImage; + if (companyInfo.sealPath != null) { + final file = File(companyInfo.sealPath!); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + sealImage = pw.MemoryImage(bytes); } + } - pdf.addPage( - pw.MultiPage( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(32), - theme: pw.ThemeData.withFont(base: ttf, bold: boldTtf), - build: (context) => [ - // タイトル - pw.Header( - level: 0, - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text("請求書", style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - pw.Text("請求番号: ${invoice.invoiceNumber}"), - pw.Text("発行日: ${dateFormatter.format(invoice.date)}"), - ], - ), - ], - ), - ), - pw.SizedBox(height: 20), - - // 宛名と自社情報 - pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + theme: pw.ThemeData.withFont(base: ttf, bold: boldTtf), + build: (context) => [ + // タイトル + pw.Header( + level: 0, + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Container( - decoration: const pw.BoxDecoration( - border: pw.Border(bottom: pw.BorderSide(width: 1)), - ), - child: pw.Text(invoice.customer.invoiceName, - style: const pw.TextStyle(fontSize: 18)), - ), - pw.SizedBox(height: 10), - pw.Text("下記の通り、ご請求申し上げます。"), - ], - ), - ), - pw.Expanded( - child: pw.Stack( - alignment: pw.Alignment.topRight, - children: [ - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), - if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"), - if (companyInfo.address != null) pw.Text(companyInfo.address!), - if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"), - ], - ), - if (sealImage != null) - pw.Positioned( - right: 10, - top: 0, - child: pw.Opacity( - opacity: 0.8, - child: pw.Image(sealImage, width: 40, height: 40), - ), - ), - ], - ), + pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text("番号: ${invoice.invoiceNumber}"), + pw.Text("発行日: ${dateFormatter.format(invoice.date)}"), + ], ), ], ), - pw.SizedBox(height: 30), - - // 合計金額表示 - pw.Container( - padding: const pw.EdgeInsets.all(8), - decoration: const pw.BoxDecoration(color: PdfColors.grey200), - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text("ご請求金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)), - pw.Text("¥${amountFormatter.format(invoice.totalAmount)} -", - style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), - ], - ), - ), - pw.SizedBox(height: 20), - - // 明細テーブル - pw.TableHelper.fromTextArray( - headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold), - headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300), - cellHeight: 30, - cellAlignments: { - 0: pw.Alignment.centerLeft, - 1: pw.Alignment.centerRight, - 2: pw.Alignment.centerRight, - 3: pw.Alignment.centerRight, - }, - headers: ["品名 / 項目", "数量", "単価", "金額"], - data: List>.generate( - invoice.items.length, - (index) { - final item = invoice.items[index]; - return [ - item.description, - item.quantity.toString(), - amountFormatter.format(item.unitPrice), - amountFormatter.format(item.subtotal), - ]; - }, - ), - ), - - // 計算内訳 - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.end, - children: [ - pw.Container( - width: 200, - child: pw.Column( - children: [ - pw.SizedBox(height: 10), - _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), - _buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)), - pw.Divider(), - _buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true), - ], - ), - ), - ], - ), - - // 備考 - if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[ - pw.SizedBox(height: 40), - pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)), - pw.Container( - width: double.infinity, - padding: const pw.EdgeInsets.all(8), - decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)), - child: pw.Text(invoice.notes!), - ), - ], - ], - footer: (context) => pw.Container( - alignment: pw.Alignment.centerRight, - margin: const pw.EdgeInsets.only(top: 16), - child: pw.Text( - "Page ${context.pageNumber} / ${context.pagesCount}", - style: const pw.TextStyle(color: PdfColors.grey), - ), + ), + pw.SizedBox(height: 20), + + // 宛名と自社情報 + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Container( + decoration: const pw.BoxDecoration( + border: pw.Border(bottom: pw.BorderSide(width: 1)), + ), + child: pw.Text(invoice.customer.invoiceName, + style: const pw.TextStyle(fontSize: 18)), + ), + pw.SizedBox(height: 10), + pw.Text(invoice.documentType == DocumentType.receipt + ? "上記の金額を正に領収いたしました。" + : (invoice.documentType == DocumentType.estimation + ? "下記の通り、お見積り申し上げます。" + : "下記の通り、ご請求申し上げます。")), + ], + ), + ), + pw.Expanded( + child: pw.Stack( + alignment: pw.Alignment.topRight, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"), + if (companyInfo.address != null) pw.Text(companyInfo.address!), + if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"), + ], + ), + if (sealImage != null) + pw.Positioned( + right: 10, + top: 0, + child: pw.Opacity( + opacity: 0.8, + child: pw.Image(sealImage, width: 40, height: 40), + ), + ), + ], + ), + ), + ], + ), + pw.SizedBox(height: 30), + + // 合計金額表示 + pw.Container( + padding: const pw.EdgeInsets.all(8), + decoration: const pw.BoxDecoration(color: PdfColors.grey200), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + invoice.documentType == DocumentType.receipt + ? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)") + : (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"), + style: const pw.TextStyle(fontSize: 16)), + pw.Text("¥${amountFormatter.format(invoice.totalAmount)} -", + style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), + ], + ), + ), + pw.SizedBox(height: 20), + + // 明細テーブル + pw.TableHelper.fromTextArray( + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold), + headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300), + cellHeight: 30, + cellAlignments: { + 0: pw.Alignment.centerLeft, + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + }, + headers: ["品名 / 項目", "数量", "単価", "金額"], + data: List>.generate( + invoice.items.length, + (index) { + final item = invoice.items[index]; + return [ + item.description, + item.quantity.toString(), + amountFormatter.format(item.unitPrice), + amountFormatter.format(item.subtotal), + ]; + }, + ), + ), + + // 計算内訳 + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + children: [ + pw.Container( + width: 200, + child: pw.Column( + children: [ + pw.SizedBox(height: 10), + _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), + if (companyInfo.taxDisplayMode == 'normal') + _buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)), + if (companyInfo.taxDisplayMode == 'text_only') + _buildSummaryRow("消費税", "(税別)"), + pw.Divider(), + _buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true), + ], + ), + ), + ], + ), + + // 備考 + if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[ + pw.SizedBox(height: 40), + pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)), + pw.Container( + width: double.infinity, + padding: const pw.EdgeInsets.all(8), + decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)), + child: pw.Text(invoice.notes!), + ), + ], + ], + footer: (context) => pw.Container( + alignment: pw.Alignment.centerRight, + margin: const pw.EdgeInsets.only(top: 16), + child: pw.Text( + "Page ${context.pageNumber} / ${context.pagesCount}", + style: const pw.TextStyle(color: PdfColors.grey), ), ), - ); + ), + ); + + return pdf; +} + +/// A4サイズのプロフェッショナルな伝票PDFを生成し、保存する +Future generateInvoicePdf(Invoice invoice) async { + try { + final pdf = await buildInvoiceDocument(invoice); // 保存処理 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.customer.formalName}_$hash.pdf"; + final String hash = sha256.convert(bytes).toString().substring(0, 4); + final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + String fileName = "${invoice.invoiceNumberPrefix}_${invoice.invoiceNumber}_${timeStr}_$hash.pdf"; final directory = await getExternalStorageDirectory(); if (directory == null) return null; final file = File("${directory.path}/$fileName"); await file.writeAsBytes(bytes); + + // 生成をログに記録 + final logRepo = ActivityLogRepository(); + await logRepo.logAction( + action: "GENERATE_PDF", + targetType: "INVOICE", + targetId: invoice.id, + details: "PDF生成: $fileName (${invoice.documentTypeName})", + ); + return file.path; } catch (e) { debugPrint("PDF Generation Error: $e"); diff --git a/lib/services/print_service.dart b/lib/services/print_service.dart new file mode 100644 index 0000000..6fe0821 --- /dev/null +++ b/lib/services/print_service.dart @@ -0,0 +1,86 @@ +import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart'; +import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart'; +import '../models/invoice_models.dart'; +import 'package:intl/intl.dart'; +import 'company_repository.dart'; + +class PrintService { + /// ペアリング済みのBluetoothデバイス一覧を取得 + Future> getPairedDevices() async { + return await PrintBluetoothThermal.pairedBluetooths; + } + + /// 指定したデバイスに接続 + Future connect(String macAddress) async { + return await PrintBluetoothThermal.connect(macPrinterAddress: macAddress); + } + + /// 接続状態の確認 + Future get isConnected async => await PrintBluetoothThermal.connectionStatus; + + /// レシートを生成して印刷 + Future printReceipt(Invoice invoice) async { + if (!await isConnected) return false; + + // 日本語フォントサポート等のために、本来は画像生成が必要な場合が多いが + // ここでは標準的なESC/POSテキスト出力を実装 + final profile = await CapabilityProfile.load(); + final generator = Generator(PaperSize.mm58, profile); + List bytes = []; + + // 自社情報の取得(税表示設定のため) + final companyRepo = CompanyRepository(); + final companyInfo = await companyRepo.getCompanyInfo(); + + // ヘッダー + bytes += generator.text( + invoice.documentTypeName, + styles: const PosStyles(align: PosAlign.center, height: PosTextSize.size2, width: PosTextSize.size2, bold: true), + ); + bytes += generator.text("--------------------------------", styles: const PosStyles(align: PosAlign.center)); + bytes += generator.text("No: ${invoice.invoiceNumber}", styles: const PosStyles(align: PosAlign.center)); + bytes += generator.text("Date: ${DateFormat('yyyy/MM/dd HH:mm').format(invoice.date)}", styles: const PosStyles(align: PosAlign.center)); + bytes += generator.text("--------------------------------", styles: const PosStyles(align: PosAlign.center)); + + bytes += generator.text("Customer:", styles: const PosStyles(bold: true)); + bytes += generator.text(invoice.customerNameForDisplay); + bytes += generator.feed(1); + + bytes += generator.text("Items:", styles: const PosStyles(bold: true)); + for (var item in invoice.items) { + bytes += generator.text(item.description); + bytes += generator.row([ + PosColumn(text: " ${item.quantity} x ${item.unitPrice}", width: 8), + PosColumn(text: "¥${item.subtotal}", width: 4, styles: const PosStyles(align: PosAlign.right)), + ]); + } + + bytes += generator.text("--------------------------------"); + bytes += generator.row([ + PosColumn(text: "Subtotal", width: 8), + PosColumn(text: "¥${invoice.subtotal}", width: 4, styles: const PosStyles(align: PosAlign.right)), + ]); + + if (companyInfo.taxDisplayMode == 'normal') { + bytes += generator.row([ + PosColumn(text: "Tax", width: 8), + PosColumn(text: "¥${invoice.tax}", width: 4, styles: const PosStyles(align: PosAlign.right)), + ]); + } else if (companyInfo.taxDisplayMode == 'text_only') { + bytes += generator.row([ + PosColumn(text: "Tax", width: 8), + PosColumn(text: "(Tax Excl.)", width: 4, styles: const PosStyles(align: PosAlign.right)), + ]); + } + + bytes += generator.row([ + PosColumn(text: companyInfo.taxDisplayMode == 'hidden' ? "TOTAL" : "TOTAL(Incl)", width: 7, styles: const PosStyles(bold: true, height: PosTextSize.size1)), + PosColumn(text: "¥${invoice.totalAmount}", width: 5, styles: const PosStyles(align: PosAlign.right, bold: true, height: PosTextSize.size1)), + ]); + + bytes += generator.feed(3); + bytes += generator.cut(); + + return await PrintBluetoothThermal.writeBytes(bytes); + } +} diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 7d7ca61..dfb6cf5 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -1,10 +1,12 @@ import 'package:sqflite/sqflite.dart'; import '../models/product_model.dart'; import 'database_helper.dart'; +import 'activity_log_repository.dart'; import 'package:uuid/uuid.dart'; class ProductRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); + final ActivityLogRepository _logRepo = ActivityLogRepository(); Future> getAllProducts() async { final db = await _dbHelper.database; @@ -18,18 +20,30 @@ class ProductRepository { return List.generate(maps.length, (i) => Product.fromMap(maps[i])); } + Future> searchProducts(String query) async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'products', + where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?', + whereArgs: ['%$query%', '%$query%', '%$query%'], + orderBy: 'name ASC', + limit: 50, + ); + return List.generate(maps.length, (i) => Product.fromMap(maps[i])); + } + Future _generateSampleProducts() async { final samples = [ - Product(id: const Uuid().v4(), name: "基本技術料", defaultUnitPrice: 50000), - Product(id: const Uuid().v4(), name: "出張診断費", defaultUnitPrice: 10000), - Product(id: const Uuid().v4(), name: "交換用ハードディスク (1TB)", defaultUnitPrice: 8500), - Product(id: const Uuid().v4(), name: "メモリ増設 (8GB)", defaultUnitPrice: 6000), - Product(id: const Uuid().v4(), name: "OSインストール作業", defaultUnitPrice: 15000), - Product(id: const Uuid().v4(), name: "データ復旧作業 (軽度)", defaultUnitPrice: 30000), - Product(id: const Uuid().v4(), name: "LANケーブル (5m)", defaultUnitPrice: 1200), - Product(id: const Uuid().v4(), name: "ウイルス除去作業", defaultUnitPrice: 20000), - Product(id: const Uuid().v4(), name: "液晶ディスプレイ (24インチ)", defaultUnitPrice: 25000), - Product(id: const Uuid().v4(), name: "定期保守契約料 (月額)", defaultUnitPrice: 5000), + Product(id: const Uuid().v4(), name: "基本技術料", defaultUnitPrice: 50000, category: "技術料"), + Product(id: const Uuid().v4(), name: "出張診断費", defaultUnitPrice: 10000, category: "諸経費"), + Product(id: const Uuid().v4(), name: "交換用ハードディスク (1TB)", defaultUnitPrice: 8500, category: "パーツ"), + Product(id: const Uuid().v4(), name: "メモリ増設 (8GB)", defaultUnitPrice: 6000, category: "パーツ"), + Product(id: const Uuid().v4(), name: "OSインストール作業", defaultUnitPrice: 15000, category: "技術料"), + Product(id: const Uuid().v4(), name: "データ復旧作業 (軽度)", defaultUnitPrice: 30000, category: "技術料"), + Product(id: const Uuid().v4(), name: "LANケーブル (5m)", defaultUnitPrice: 1200, category: "サプライ"), + Product(id: const Uuid().v4(), name: "ウイルス除去作業", defaultUnitPrice: 20000, category: "技術料"), + Product(id: const Uuid().v4(), name: "液晶ディスプレイ (24インチ)", defaultUnitPrice: 25000, category: "周辺機器"), + Product(id: const Uuid().v4(), name: "定期保守契約料 (月額)", defaultUnitPrice: 5000, category: "保守"), ]; for (var s in samples) { await saveProduct(s); @@ -43,10 +57,28 @@ class ProductRepository { product.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); + + await _logRepo.logAction( + action: "SAVE_PRODUCT", + targetType: "PRODUCT", + targetId: product.id, + details: "商品名: ${product.name}, 単価: ${product.defaultUnitPrice}, カテゴリ: ${product.category ?? '未設定'}", + ); } Future deleteProduct(String id) async { final db = await _dbHelper.database; - await db.delete('products', where: 'id = ?', whereArgs: [id]); + await db.delete( + 'products', + where: 'id = ?', + whereArgs: [id], + ); + + await _logRepo.logAction( + action: "DELETE_PRODUCT", + targetType: "PRODUCT", + targetId: id, + details: "商品を削除しました", + ); } } diff --git a/linux/flutter/ephemeral/.plugin_symlinks/printing b/linux/flutter/ephemeral/.plugin_symlinks/printing new file mode 120000 index 0000000..2009090 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/printing @@ -0,0 +1 @@ +/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/ \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 7299b5c..b2b4176 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,12 +7,16 @@ #include "generated_plugin_registrant.h" #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 786ff5c..615cc69 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + printing url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1398df3..57c0f95 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,8 @@ import file_selector_macos import geolocator_apple import mobile_scanner import package_info_plus +import print_bluetooth_thermal +import printing import share_plus import sqflite_darwin import url_launcher_macos @@ -18,6 +20,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PrintBluetoothThermalPlugin.register(with: registry.registrar(forPlugin: "PrintBluetoothThermalPlugin")) + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index ef4b626..780f650 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -97,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + esc_pos_utils_plus: + dependency: "direct main" + description: + name: esc_pos_utils_plus + sha256: "2a22d281cb6f04600ba3ebd607ad8df03a4b2446d814007d22525bab4d50c2ff" + url: "https://pub.dev" + source: hosted + version: "2.0.4" fake_async: dependency: transitive description: @@ -264,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -552,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" permission_handler: dependency: "direct main" description: @@ -632,6 +664,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + print_bluetooth_thermal: + dependency: "direct main" + description: + name: print_bluetooth_thermal + sha256: "17b204a5340174c02acf5f6caf7279b5000344f1f1e1bcd01dbdbf912bce6e46" + url: "https://pub.dev" + source: hosted + version: "1.1.9" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.dev" + source: hosted + version: "5.14.2" pub_semver: dependency: transitive description: @@ -877,6 +925,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win_ble: + dependency: transitive + description: + name: win_ble + sha256: "2a867e13c4b355b101fc2c6e2ac85eeebf965db34eca46856f8b478e93b41e96" + url: "https://pub.dev" + source: hosted + version: "1.1.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ad8ce6a..2a2ad7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,9 @@ dependencies: image_picker: ^1.2.1 mobile_scanner: ^7.1.4 package_info_plus: ^9.0.0 + printing: ^5.14.2 + print_bluetooth_thermal: ^1.1.9 + esc_pos_utils_plus: ^2.0.3 dev_dependencies: flutter_test: diff --git a/目標.md b/目標.md index 47e7e83..37bd2b9 100644 --- a/目標.md +++ b/目標.md @@ -35,4 +35,24 @@ − ロック機能はご動作対策であって削除と編集機能以外は全部使える様にする - 顧客マスターの新規・編集・削除機能を実装する - PDF作成と保存と仮表示は別ボタンで実装 - + − 各マスター情報は1万件を越えたりするのでカテゴライズ等工夫して迅速に検索可能にする + - PDF出力したりPDF共有は日時情報を付けて保存する + - データはgit的にアクティビティを残す様にする + - 顧客や商品等一覧から選択する時は必ず数万単位の検索がある前提にする + - 仮表示とはPDFプレビューの事です + - 見積納品請求領収書の切り替えボタンの実装 + - 商品マスターには在庫管理機能をこっそり実装する + - 伝票で在庫の増減を自動的に計算する仕組みを作る + - 年次月次の管理を実装する + - 将来的にスタンドアローンでも資金管理を可能にする + - データにはGPS座標の履歴を最低10件保持する + - アマゾンで売っていたプリンタでレシート印刷をするhttps://www.amazon.co.jp/dp/B0G33SSZV6?ref=ppx_yo2ov_dt_b_fed_asin_title&th=1 + + - 伝票入力画面で消費税表示がシステム通りでないのを修正 + − 日時の自動入力と編集機能の実装 + - 管理番号端末UIDと伝票UIDの組み合わせで管理する + - 管理番号の表示を画面とPDFにする + - 伝票のコンテンツ情報に規則性を持たせSHA256をPDFに番号とQRで表示 + - PDFのファイル名にSHA256を含める + + - \ No newline at end of file