249 lines
No EOL
7.5 KiB
Dart
249 lines
No EOL
7.5 KiB
Dart
// Version: 1.0.0 - EstimateScreen 見積入力画面
|
||
import 'package:flutter/material.dart';
|
||
import '../models/estimate.dart';
|
||
import '../services/database_helper.dart';
|
||
import '../models/product.dart';
|
||
|
||
/// 見積入力画面(Material Design テンプレート)
|
||
class EstimateScreen extends StatefulWidget {
|
||
const EstimateScreen({super.key});
|
||
|
||
@override
|
||
State<EstimateScreen> createState() => _EstimateScreenState();
|
||
}
|
||
|
||
class _EstimateScreenState extends State<EstimateScreen> {
|
||
Customer? _selectedCustomer;
|
||
final DatabaseHelper _db = DatabaseHelper.instance;
|
||
List<Product> _products = [];
|
||
List<Customer> _customers = [];
|
||
List<LineItem> _items = [];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadProducts();
|
||
_loadCustomers();
|
||
}
|
||
|
||
Future<void> _loadProducts() async {
|
||
try {
|
||
final products = await _db.getProducts();
|
||
setState(() => _products = products);
|
||
} catch (e) {
|
||
debugPrint('Product loading failed: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _loadCustomers() async {
|
||
try {
|
||
final customers = await _db.getCustomers();
|
||
setState(() => _customers = customers.where((c) => c.isDeleted == 0).toList());
|
||
} catch (e) {
|
||
debugPrint('Customer loading failed: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _showCustomerPicker() async {
|
||
if (_customers.isEmpty) await _loadCustomers();
|
||
|
||
final selected = await showModalBottomSheet<Customer>(
|
||
context: context,
|
||
builder: (ctx) => SizedBox(
|
||
height: MediaQuery.of(context).size.height * 0.4,
|
||
child: ListView.builder(
|
||
padding: const EdgeInsets.all(8),
|
||
itemCount: _customers.length,
|
||
itemBuilder: (ctx, index) => ListTile(
|
||
title: Text(_customers[index].name),
|
||
subtitle: Text('コード:${_customers[index].customerCode}'),
|
||
onTap: () => Navigator.pop(ctx, _customers[index]),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
if (selected is Customer && selected.id != _selectedCustomer?.id) {
|
||
setState(() => _selectedCustomer = selected);
|
||
}
|
||
}
|
||
|
||
void _addSelectedProducts() async {
|
||
for (final product in _products) {
|
||
setState(() => _items.add(LineItem(
|
||
productId: product.id,
|
||
productName: product.name,
|
||
unitPrice: product.price,
|
||
quantity: 1,
|
||
total: product.price,
|
||
)));
|
||
}
|
||
_showAddDialog();
|
||
}
|
||
|
||
void _removeLineItem(int index) {
|
||
setState(() => _items.removeAt(index));
|
||
}
|
||
|
||
Future<void> _saveEstimate() async {
|
||
if (_items.isEmpty) return;
|
||
|
||
// データベースへの保存
|
||
try {
|
||
final estimatedNo = 'EST-${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}-${_items.length + 1}';
|
||
|
||
await _db.insertEstimate(
|
||
estimateNo: estimatedNo,
|
||
customerName: _selectedCustomer?.name ?? '未指定',
|
||
date: DateTime.now(),
|
||
items: _items,
|
||
);
|
||
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('見積を保存しました'))..behavior: SnackBarBehavior.floating,
|
||
);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('保存に失敗:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
int _calculateTotal() {
|
||
return _items.fold(0, (sum, item) => sum + item.total);
|
||
}
|
||
|
||
Widget _buildEmptyState() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.receipt_long, size: 64, color: Colors.grey.shade400),
|
||
const SizedBox(height: 16),
|
||
Text('見積商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('見積入力'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.save),
|
||
onPressed: _saveEstimate,
|
||
),
|
||
],
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
_buildCustomerField(),
|
||
const SizedBox(height: 16),
|
||
Card(
|
||
margin: EdgeInsets.zero,
|
||
child: ExpansionTile(
|
||
title: const Text('見積商品'),
|
||
children: [
|
||
if (_items.isEmpty) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: _buildEmptyState(),
|
||
),
|
||
] else ...[
|
||
ListView.builder(
|
||
shrinkWrap: true,
|
||
padding: EdgeInsets.zero,
|
||
itemCount: _items.length,
|
||
itemBuilder: (context, index) => Card(
|
||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||
child: ListTile(
|
||
leading: CircleAvatar(
|
||
backgroundColor: Colors.blue.shade100,
|
||
child: Icon(Icons.receipt, color: Colors.blue),
|
||
),
|
||
title: Text(_items[index].productName),
|
||
subtitle: Text('単価:¥${_items[index].unitPrice}'),
|
||
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_selectedCustomer != null) ...[
|
||
const SizedBox(height: 16),
|
||
Card(
|
||
child: ListTile(
|
||
title: const Text('得意先'),
|
||
subtitle: Text(_selectedCustomer!.name),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
floatingActionButton: FloatingActionButton.extended(
|
||
icon: const Icon(Icons.add_shopping_cart),
|
||
label: const Text('商品追加'),
|
||
onPressed: () => _showAddDialog(),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCustomerField() {
|
||
return TextField(
|
||
decoration: InputDecoration(
|
||
labelText: '得意先',
|
||
hintText: _selectedCustomer != null ? _selectedCustomer.name : '得意先マスタから選択',
|
||
prefixIcon: Icon(Icons.person_search),
|
||
isReadOnly: true,
|
||
),
|
||
onTap: () => _showCustomerPicker(),
|
||
);
|
||
}
|
||
|
||
void _showAddDialog() async {
|
||
final selected = await showModalBottomSheet<Product>(
|
||
context: context,
|
||
builder: (ctx) => ListView.builder(
|
||
padding: EdgeInsets.zero,
|
||
itemCount: _products.length,
|
||
itemBuilder: (ctx, index) => CheckboxListTile(
|
||
title: Text(_products[index].name),
|
||
subtitle: Text('¥${_products[index].price} / 在庫:${_products[index].stock}${_products[index].unit ?? ''}'),
|
||
value: _items.any((i) => i.productId == _products[index].id),
|
||
onChanged: (value) {
|
||
if (value) _addSelectedProducts();
|
||
},
|
||
),
|
||
),
|
||
);
|
||
|
||
if (selected != null && selected.id != null && !_items.any((i) => i.productId == selected.id)) {
|
||
setState(() => _items.add(LineItem(
|
||
productId: selected.id,
|
||
productName: selected.name,
|
||
unitPrice: selected.price,
|
||
quantity: 1,
|
||
total: selected.price,
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 見積行モデル
|
||
class LineItem {
|
||
final int? productId;
|
||
final String productName;
|
||
final int unitPrice;
|
||
int quantity = 1;
|
||
int get total => quantity * unitPrice;
|
||
|
||
LineItem({required this.productId, required this.productName, required this.unitPrice, this.quantity = 1});
|
||
} |