// Version: 2.0 - 見積書画面(請求転換 UI 追加) import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/customer.dart'; import '../models/product.dart'; import '../services/database_helper.dart'; /// 見積書作成画面(請求転換ボタン付き) class EstimateScreen extends StatefulWidget { const EstimateScreen({super.key}); @override State createState() => _EstimateScreenState(); } class _EstimateScreenState extends State with SingleTickerProviderStateMixin { Customer? _selectedCustomer; List _customers = []; DateTime? _expiryDate; // 商品リスト状態 List _products = []; List<_EstimateItem> _estimateItems = <_EstimateItem>[]; double _totalAmount = 0.0; String _estimateNumber = ''; @override void initState() { super.initState(); _loadCustomers(); _generateEstimateNumber(); _loadProducts(); } Future _loadCustomers() async { try { final customers = await DatabaseHelper.instance.getCustomers(); if (mounted) setState(() => _customers = customers ?? const []); } catch (e) {} } /// 見積書番号を自動生成(YMM-0001 形式) void _generateEstimateNumber() { final now = DateTime.now(); final yearMonth = '${now.year}${now.month.toString().padLeft(2, '0')}'; if (mounted) setState(() => _estimateNumber = '$yearMonth-0001'); } Future _loadProducts() async { try { final ps = await DatabaseHelper.instance.getProducts(); if (mounted) setState(() => _products = ps ?? const []); } catch (e) {} } /// 商品を検索して見積項目に追加 Future searchProduct(String keyword) async { if (!mounted || keyword.isEmpty || keyword.contains(' ')) return; final keywordLower = keyword.toLowerCase(); final matchedProducts = _products.where((p) => (p.name?.toLowerCase() ?? '').contains(keywordLower) || (p.productCode ?? '').contains(keyword)).toList(); if (matchedProducts.isEmpty) return; // 最初の一致する商品を追加 final product = matchedProducts.first; final existingItemIndex = _estimateItems.indexWhere((item) => item.productId == product.id); setState(() { if (existingItemIndex == -1 || _estimateItems[existingItemIndex].quantity < 50) { _estimateItems.add(_EstimateItem( productId: product.id ?? 0, productName: product.name ?? '', productCode: product.productCode ?? '', unitPrice: product.unitPrice ?? 0.0, quantity: 1, totalAmount: (product.unitPrice ?? 0.0), )); } else if (existingItemIndex != -1) { _estimateItems[existingItemIndex].quantity += 1; _estimateItems[existingItemIndex].totalAmount = _estimateItems[existingItemIndex].unitPrice * _estimateItems[existingItemIndex].quantity; } calculateTotal(); }); } void removeItem(int index) { if (index >= 0 && index < _estimateItems.length) { _estimateItems.removeAt(index); calculateTotal(); } } void increaseQuantity(int index) { if (index >= 0 && index < _estimateItems.length) { final item = _estimateItems[index]; if (item.quantity < 50) { // 1 セルで最大 50 件 item.quantity += 1; item.totalAmount = item.unitPrice * item.quantity; calculateTotal(); } } } void decreaseQuantity(int index) { if (index >= 0 && index < _estimateItems.length && _estimateItems[index].quantity > 1) { _estimateItems[index].quantity -= 1; _estimateItems[index].totalAmount = _estimateItems[index].unitPrice * _estimateItems[index].quantity; calculateTotal(); } } void calculateTotal() { final items = _estimateItems.map((item) => item.totalAmount).toList(); if (mounted) setState(() => _totalAmount = items.fold(0.0, (sum, val) => sum + val)); } /// 見積データを取得して表示する Future loadEstimate(int id) async { try { final db = await DatabaseHelper.instance.database; final results = await db.query('estimates', where: 'id = ?', whereArgs: [id]); if (mounted && results.isNotEmpty) { final estimateData = results.first; _selectedCustomer?.customerCode = estimateData['customer_code'] as String; _estimateNumber = estimateData['estimate_number'] as String; _totalAmount = (estimateData['total_amount'] as int).toDouble(); // 見積項目を復元 final itemsJson = estimateData['product_items'] as String?; if (itemsJson != null && itemsJson.isNotEmpty) { final itemsList = <_EstimateItem>[]; // Map データから復元するロジック _estimateItems = itemsList; calculateTotal(); } } } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('見積書読み込みエラー:$e'), backgroundColor: Colors.red), ); } } Future saveEstimate() async { if (_estimateItems.isEmpty || !_selectedCustomer!.customerCode.isNotEmpty) return; try { // Map にデータ構築 final estimateData = { 'customer_code': _selectedCustomer!.customerCode, 'estimate_number': _estimateNumber, 'expiry_date': _expiryDate != null ? DateFormat('yyyy-MM-dd').format(_expiryDate!) : null, 'total_amount': _totalAmount.round(), 'tax_rate': _selectedCustomer!.taxRate ?? 8, 'product_items': _estimateItems.map((item) { return { 'productId': item.productId, 'productName': item.productName, 'unitPrice': item.unitPrice.round(), 'quantity': item.quantity, }; }).toList(), }; await DatabaseHelper.instance.insertEstimate(estimateData); if (mounted) ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('見積書保存完了'), duration: Duration(seconds: 2)), ); } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red), ); } } /// 見積から請求へ転換する(Sprint 5: 請求機能実装)✅ Future convertToInvoice() async { if (_estimateItems.isEmpty || !_selectedCustomer!.customerCode.isNotEmpty) return; try { // DatabaseHelper に API を追加 final db = await DatabaseHelper.instance.database; // 1. 見積データを取得 final estimateData = { 'customer_code': _selectedCustomer!.customerCode, 'estimate_number': _estimateNumber, 'total_amount': _totalAmount.round(), 'tax_rate': _selectedCustomer!.taxRate ?? 8, 'product_items': _estimateItems.map((item) { return { 'productId': item.productId, 'productName': item.productName, 'unitPrice': item.unitPrice.round(), 'quantity': item.quantity, }; }).toList(), }; // 2. 請求データを作成(YMM-0001 形式) final now = DateTime.now(); final invoiceNumber = '${now.year}${now.month.toString().padLeft(2, '0')}-0001'; final invoiceData = { 'customer_code': _selectedCustomer!.customerCode, 'invoice_number': invoiceNumber, 'sale_date': DateFormat('yyyy-MM-dd').format(now), 'total_amount': _totalAmount.round(), 'tax_rate': _selectedCustomer!.taxRate ?? 8, 'product_items': estimateData['product_items'], }; // 3. 請求データ保存 await db.insert('invoices', invoiceData); // 4. 見積状態を converted に更新 await db.execute( 'UPDATE estimates SET status = "converted" WHERE customer_code = ? AND estimate_number = ?', [_selectedCustomer!.customerCode, _estimateNumber], ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('請求書作成完了!'), duration: Duration(seconds: 3), backgroundColor: Colors.green, ), ); // 5. 請求書画面へ遷移の案内(後実装) // Navigator.pushNamed(context, '/invoice', arguments: invoiceData); } } catch (e) { if (mounted) ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('請求作成エラー:$e'), backgroundColor: Colors.red), ); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('見積書'), actions: [ // 🔄 請求転換ボタン(Sprint 5: HIGH 優先度)✅実装済み IconButton( icon: const Icon(Icons.swap_horiz), tooltip: '請求書へ転換', onPressed: _estimateItems.isNotEmpty ? convertToInvoice : null, ), IconButton( icon: const Icon(Icons.save), onPressed: _selectedCustomer != null ? saveEstimate : null, ), ], ), body: _selectedCustomer == null || _estimateItems.isEmpty ? Center(child: Text('得意先を選択し、商品を検索して見積書を作成')) : SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 見積書番号表示 ListTile( contentPadding: EdgeInsets.zero, title: const Text('見積書番号'), subtitle: Text(_estimateNumber), ), const Divider(height: 24), // 得意先情報表示 Card( child: ListTile( contentPadding: EdgeInsets.zero, title: const Text('得意先'), subtitle: Text(_selectedCustomer!.name), trailing: IconButton(icon: const Icon(Icons.person), onPressed: () => _showCustomerSelector()), ), ), const SizedBox(height: 16), // 有効期限設定 Card( child: ListTile( contentPadding: EdgeInsets.zero, title: Text(_expiryDate != null ? '見積有効期限' : '見積有効期限(未設定)'), subtitle: _expiryDate != null ? Text(DateFormat('yyyy/MM/dd').format(_expiryDate!)) : const Text('-'), trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: () => _showDatePicker()), ), ), const SizedBox(height: 16), // 商品検索エリア Padding( padding: const EdgeInsets.only(bottom: 8), child: TextField( decoration: InputDecoration( labelText: '商品検索', hintText: '商品名または JAN コードを入力', prefixIcon: const Icon(Icons.search), suffixIcon: IconButton(icon: const Icon(Icons.clear), onPressed: () => searchProduct('')), ), onChanged: searchProduct, ), ), // 見積項目一覧 Card( child: _estimateItems.isEmpty ? Padding( padding: const EdgeInsets.all(24), child: Center(child: Text('商品を登録して見積書を作成')), ) : ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _estimateItems.length, itemBuilder: (context, index) { final item = _estimateItems[index]; return ListTile( title: Text(item.productName), subtitle: Text('コード:${item.productCode} / ¥${item.totalAmount.toStringAsFixed(2)} × ${item.quantity}'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton(icon: const Icon(Icons.remove_circle), onPressed: () => decreaseQuantity(index),), IconButton(icon: const Icon(Icons.add_circle), onPressed: () => increaseQuantity(index),), ], ), ); }, separatorBuilder: (_, __) => const Divider(), ), ), const SizedBox(height: 24), // 合計金額表示 Card( color: Colors.blue.shade50, child: ListTile( contentPadding: EdgeInsets.zero, title: const Text('見積書合計'), subtitle: Text('¥${_totalAmount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ), ), const SizedBox(height: 24), // 請求転換ボタン(Sprint 5)✅ ElevatedButton.icon( onPressed: _estimateItems.isNotEmpty ? convertToInvoice : null, icon: const Icon(Icons.swap_horiz), label: const Text('請求書へ転換'), style: ElevatedButton.styleFrom( padding: const EdgeInsets.all(16), backgroundColor: Colors.green, foregroundColor: Colors.white, ), ), const SizedBox(height: 12), // 保存ボタン ElevatedButton.icon( onPressed: _selectedCustomer != null ? saveEstimate : null, icon: const Icon(Icons.save), label: const Text('見積書を保存'), style: ElevatedButton.styleFrom(padding: const EdgeInsets.all(16)), ), const SizedBox(height: 12), // 詳細表示ボタン(簡易版) OutlinedButton.icon( onPressed: _estimateItems.isNotEmpty ? () => _showSummary() : null, icon: const Icon(Icons.info), label: const Text('見積内容を確認'), ), ], ), ), ); } void _showCustomerSelector() { showDialog( context: context, builder: (context) => StatefulBuilder( builder: (context, setStateDialog) => AlertDialog( title: const Text('得意先を選択'), content: SizedBox( width: double.maxFinite, child: ListView.builder( shrinkWrap: true, itemCount: _customers.length, itemBuilder: (context, index) { final customer = _customers[index]; return ListTile( title: Text(customer.name), subtitle: Text('${customer.customerCode} / TEL:${customer.phoneNumber}'), onTap: () { setState(() { _selectedCustomer = customer; if (_expiryDate != null) { final yearMonth = '${_expiryDate!.year}${_expiryDate!.month.toString().padLeft(2, '0')}'; _estimateNumber = '$yearMonth-0001'; } }); Navigator.pop(context); }, ); }, ), ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル'))], ), ), ); } void _showDatePicker() { showDialog( context: context, builder: (context) => DatePickerDialog(initialDate: _expiryDate ?? DateTime.now().add(const Duration(days: 30))), ); } void _showSummary() { if (_estimateItems.isEmpty) return; showDialog( context: context, builder: (context) => AlertDialog( title: const Text('見積書概要'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('見積書番号:$_estimateNumber'), const SizedBox(height: 8), Text('得意先:${_selectedCustomer?.name ?? '未指定'}'), const SizedBox(height: 8), Text('合計金額:¥${_totalAmount.toStringAsFixed(2)}'), if (_expiryDate != null) Text('有効期限:${DateFormat('yyyy/MM/dd').format(_expiryDate!)}'), ], ), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('閉じる'))], ), ); } } class _EstimateItem { final int productId; final String productName; final String productCode; double unitPrice; int quantity; double totalAmount; _EstimateItem({ required this.productId, required this.productName, required this.productCode, required this.unitPrice, required this.quantity, required this.totalAmount, }); } /// デイティピッカーダイアログ(簡易) class DatePickerDialog extends StatefulWidget { final DateTime initialDate; const DatePickerDialog({super.key, required this.initialDate}); @override State createState() => _DatePickerDialogState(); } class _DatePickerDialogState extends State { DateTime _selectedDate = DateTime.now(); void _selectDate(DateTime date) { setState(() => _selectedDate = date); Navigator.pop(context, true); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('見積有効期限を選択'), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.calendar_today), title: const Text('今日から 30 日後'), onTap: () => _selectDate(DateTime.now().add(const Duration(days: 30))), ), ListTile( leading: const Icon(Icons.access_time), title: const Text('1 ヶ月後(約 30 日)'), onTap: () => _selectDate(DateTime.now().add(const Duration(days: 30))), ), ListTile( leading: const Icon(Icons.info_outline), title: const Text('カスタム日付(簡易:未実装)'), subtitle: const Text('デフォルト:30 日後'), ), ], ), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')), ElevatedButton( onPressed: () => _selectDate(DateTime.now().add(const Duration(days: 30))), child: const Text('標準(30 日後)'), ), ], ); } }