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

423 lines
No EOL
15 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: 1.9 - 見積書画面(簡素版)
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> 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),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('見積書'),
actions: [
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),
// 保存ボタン
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 日後)'),
),
],
);
}
}