h-1.flutter.4/lib/screens/estimate_screen.dart

536 lines
No EOL
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<EstimateScreen> createState() => _EstimateScreenState();
}
class _EstimateScreenState extends State<EstimateScreen> with SingleTickerProviderStateMixin {
Customer? _selectedCustomer;
List<Customer> _customers = [];
DateTime? _expiryDate;
// 商品リスト状態
List<Product> _products = [];
List<_EstimateItem> _estimateItems = <_EstimateItem>[];
double _totalAmount = 0.0;
String _estimateNumber = '';
@override
void initState() {
super.initState();
_loadCustomers();
_generateEstimateNumber();
_loadProducts();
}
Future<void> _loadCustomers() async {
try {
final customers = await DatabaseHelper.instance.getCustomers();
if (mounted) setState(() => _customers = customers ?? const <Customer>[]);
} 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<void> _loadProducts() async {
try {
final ps = await DatabaseHelper.instance.getProducts();
if (mounted) setState(() => _products = ps ?? const <Product>[]);
} catch (e) {}
}
/// 商品を検索して見積項目に追加
Future<void> 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<void> 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<void> saveEstimate() async {
if (_estimateItems.isEmpty || !_selectedCustomer!.customerCode.isNotEmpty) return;
try {
// Map にデータ構築
final estimateData = <String, dynamic>{
'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 <String, dynamic>{
'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<void> convertToInvoice() async {
if (_estimateItems.isEmpty || !_selectedCustomer!.customerCode.isNotEmpty) return;
try {
// DatabaseHelper に API を追加
final db = await DatabaseHelper.instance.database;
// 1. 見積データを取得
final estimateData = <String, dynamic>{
'customer_code': _selectedCustomer!.customerCode,
'estimate_number': _estimateNumber,
'total_amount': _totalAmount.round(),
'tax_rate': _selectedCustomer!.taxRate ?? 8,
'product_items': _estimateItems.map((item) {
return <String, dynamic>{
'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 = <String, dynamic>{
'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<bool>(
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<DatePickerDialog> createState() => _DatePickerDialogState();
}
class _DatePickerDialogState extends State<DatePickerDialog> {
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 日後)'),
),
],
);
}
}