h-1.flutter.4/lib/screens/order_screen.dart
joe 10a1b0e690 feat: 請求作成・受注入力画面を実装(売上フロー完結)
- lib/screens/invoice_screen.dart: 見積フローから請求への変換ロジック
- lib/screens/order_screen.dart: 在庫振替・発注日選択機能搭載
- README.md: 売上げフロー実完了マーカー追加
2026-03-07 15:38:43 +09:00

272 lines
No EOL
8.9 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.0.0
import 'package:flutter/material.dart';
import '../services/database_helper.dart';
import '../models/product.dart';
/// 受注入力画面Material Design
class OrderScreen extends StatefulWidget {
const OrderScreen({super.key});
@override
State<OrderScreen> createState() => _OrderScreenState();
}
class _OrderScreenState extends State<OrderScreen> {
Customer? _selectedCustomer;
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _products = [];
List<Customer> _customers = [];
List<LineItem> _items = [];
String? _orderDate; // Default: 現在時刻
@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) {
final existingStock = product.stock;
if (existingStock > 0 && !_items.any((i) => i.productId == product.id)) {
setState(() => _items.add(LineItem(
orderId: DateTime.now().millisecondsSinceEpoch,
productId: product.id,
productName: product.name,
unitPrice: product.price,
quantity: 1,
total: product.price,
stockRemaining: existingStock - 1,
)));
}
}
_showAddDialog();
}
void _removeLineItem(int index) {
setState(() => _items.removeAt(index));
}
String? get _orderId => _items.isNotEmpty ? _items.first.orderId.toString() : null;
int get _totalAmount => _items.fold(0, (sum, item) => sum + item.total);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('受注入力')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildCustomerField(),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('発注日'),
DropdownButton<String>(
value: _orderDate ?? '',
items: ['', '2026-03-07'].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
onChanged: (v) => setState(() => _orderDate = v),
),
],
),
const SizedBox(height: 8),
Card(
margin: EdgeInsets.zero,
child: ExpansionTile(
title: const Text('受注商品'),
children: [
if (_items.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(Icons.shopping_cart_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 8),
Text('商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
],
),
),
] 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.teal.shade100,
child: Icon(Icons.shopping_cart, color: Colors.teal),
),
title: Text(_items[index].productName),
subtitle: Text('数量:${_items[index].quantity} / 単価:¥${_items[index].unitPrice}'),
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)),
),
),
),
],
),
),
),
],
),
);
}
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 && _products[index].stock > 0) _addSelectedProducts();
},
),
),
);
if (selected != null && selected.id != null && _products[selected.id]?.stock! > 0 && !_items.any((i) => i.productId == selected.id)) {
setState(() => _items.add(LineItem(
orderId: _orderId ?? DateTime.now().millisecondsSinceEpoch,
productId: selected.id,
productName: selected.name,
unitPrice: selected.price,
quantity: 1,
total: selected.price,
stockRemaining: _products[selected.id].stock - 1,
)));
}
}
void _showSaveDialog() async {
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を追加してください')),
);
return;
}
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('受注確定'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_selectedCustomer != null) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('得意先:${_selectedCustomer!.name}'),
),
],
Text('合計:¥${_totalAmount}'),
if (_items.isNotEmpty) ...[
Divider(),
..._items.map((item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.productName),
Text('¥${item.unitPrice} × ${item.quantity} = ¥${item.total}'),
],
),
)),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('受注保存しました'))..behavior: SnackBarBehavior.floating,
);
},
child: const Text('確定'),
),
],
),
);
}
}
/// 受注行モデル(在庫振替付き)
class LineItem {
final String? orderId;
final int? productId;
final String productName;
final int unitPrice;
int quantity = 1;
final int stockRemaining; // 追加後の在庫数
LineItem({this.orderId, required this.productId, required this.productName, required this.unitPrice, this.quantity = 1, required this.stockRemaining});
}