536 lines
No EOL
20 KiB
Dart
536 lines
No EOL
20 KiB
Dart
// 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 日後)'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
} |