feat: 請求作成・受注入力画面を実装(売上フロー完結)
- lib/screens/invoice_screen.dart: 見積フローから請求への変換ロジック - lib/screens/order_screen.dart: 在庫振替・発注日選択機能搭載 - README.md: 売上げフロー実完了マーカー追加
This commit is contained in:
parent
57f1898656
commit
10a1b0e690
2 changed files with 481 additions and 113 deletions
|
|
@ -1,86 +1,163 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.0.0
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/database_helper.dart';
|
||||||
|
import '../models/product.dart';
|
||||||
|
|
||||||
/// 請求書発行画面(Material Design テンプレート)
|
/// 請求作成画面(見積フローから連携)
|
||||||
class InvoiceScreen extends StatelessWidget {
|
class InvoiceScreen extends StatefulWidget {
|
||||||
const InvoiceScreen({super.key});
|
const InvoiceScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<InvoiceScreen> createState() => _InvoiceScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||||
|
Customer? _selectedCustomer;
|
||||||
|
final DatabaseHelper _db = DatabaseHelper.instance;
|
||||||
|
List<Product> _products = [];
|
||||||
|
List<Customer> _customers = [];
|
||||||
|
List<LineItem> _items = [];
|
||||||
|
String _estimateNumber = ''; // 見積番号(参考用)
|
||||||
|
DateTime _invoiceDate = DateTime.now();
|
||||||
|
|
||||||
|
@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(
|
||||||
|
invoiceId: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
estimateNumber: 'EST${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}',
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
unitPrice: product.price,
|
||||||
|
quantity: 1,
|
||||||
|
total: product.price,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_showAddDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeLineItem(int index) {
|
||||||
|
setState(() => _items.removeAt(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _invoiceId => _items.isNotEmpty ? 'INV${_items.first.invoiceId.toString()}' : '';
|
||||||
|
int get _totalAmount => _items.fold(0, (sum, item) => sum + item.total);
|
||||||
|
int get _taxRate => _selectedCustomer?.taxRate ?? 8; // Default 10%
|
||||||
|
int get _discountRate => _selectedCustomer?.discountRate ?? 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('請求作成')),
|
||||||
title: const Text('請求書発行'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.insert_drive_file),
|
|
||||||
onPressed: () => _showSaveDialog(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// 受注データ選択エリア
|
_buildCustomerField(),
|
||||||
TextField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '受注番号',
|
|
||||||
hintText: '受注伝票から検索',
|
|
||||||
prefixIcon: Icon(Icons.arrow_upward),
|
|
||||||
),
|
|
||||||
readOnly: true,
|
|
||||||
onTap: () => _showOrderSelection(context),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
// 請求書情報表示エリア
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Card(
|
children: [
|
||||||
margin: EdgeInsets.zero,
|
const Text('請求日'),
|
||||||
child: Padding(
|
InkWell(
|
||||||
padding: const EdgeInsets.all(16),
|
onTap: () => _selectDate(),
|
||||||
child: Column(
|
child: Text('${_invoiceDate.year}-${_invoiceDate.month.toString().padLeft(2, '0')}-${_invoiceDate.day.toString().padLeft(2, '0')}'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text('請求書総額', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
Text('¥0', style: const TextStyle(fontSize: 24, color: Colors.green)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text('請求元:'),
|
|
||||||
Text('株式会社サンプル'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 商品リスト(簡易テンプレート)
|
|
||||||
Card(
|
Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
title: const Text('請求書商品'),
|
title: const Text('請求商品'),
|
||||||
children: [
|
children: [
|
||||||
ListView.builder(
|
if (_items.isEmpty) ...[
|
||||||
shrinkWrap: true,
|
Padding(
|
||||||
padding: EdgeInsets.zero,
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: 0, // デモ用
|
child: Column(
|
||||||
itemBuilder: (context, index) => Card(
|
children: [
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
Icon(Icons.receipt_long_outlined, size: 48, color: Colors.grey.shade400),
|
||||||
child: ListTile(
|
const SizedBox(height: 8),
|
||||||
leading: CircleAvatar(
|
Text('商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
|
||||||
backgroundColor: Colors.purple.shade100,
|
],
|
||||||
child: Icon(Icons.receipt_long, color: Colors.purple),
|
|
||||||
),
|
|
||||||
title: Text('商品${index + 1}'),
|
|
||||||
subtitle: Text('数量:0 pcs / 金額:¥0'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
] 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.purple.shade100,
|
||||||
|
child: Icon(Icons.receipt_long, color: Colors.purple),
|
||||||
|
),
|
||||||
|
title: Text(_items[index].productName),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('単価:¥${_items[index].unitPrice}'),
|
||||||
|
Text('数量:${_items[index].quantity} pcs'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -88,7 +165,67 @@ class InvoiceScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSaveDialog(BuildContext context) {
|
Widget _buildCustomerField() {
|
||||||
|
return TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '得意先',
|
||||||
|
hintText: _selectedCustomer != null ? _selectedCustomer.name : '得意先マスタから選択',
|
||||||
|
prefixIcon: Icon(Icons.person_search),
|
||||||
|
isReadOnly: true,
|
||||||
|
),
|
||||||
|
onTap: () => _showCustomerPicker(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectDate() async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _invoiceDate,
|
||||||
|
firstDate: DateTime(2026),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) setState(() => _invoiceDate = picked);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
invoiceId: _invoiceId,
|
||||||
|
estimateNumber: 'EST${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}',
|
||||||
|
productId: selected.id,
|
||||||
|
productName: selected.name,
|
||||||
|
unitPrice: selected.price,
|
||||||
|
quantity: 1,
|
||||||
|
total: selected.price,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSaveDialog() async {
|
||||||
|
if (_items.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('商品を追加してください')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -97,7 +234,37 @@ class InvoiceScreen extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('請求書を発行しますか?'),
|
if (_selectedCustomer != null) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text('得意先:${_selectedCustomer!.name}'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Text('請求 ID: ${_items.isNotEmpty ? _invoiceId : ''}'),
|
||||||
|
Text('見積番号:${_estimateNumber.isEmpty ? '(新規作成)' : _estimateNumber}'),
|
||||||
|
Text('請求日:${_invoiceDate.toLocal()}'),
|
||||||
|
Text('税率:${_taxRate}% / 割引率:${_discountRate}%'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('合計金額(税込)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
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}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -107,10 +274,14 @@ class InvoiceScreen extends StatelessWidget {
|
||||||
child: const Text('キャンセル'),
|
child: const Text('キャンセル'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
|
if (_estimateNumber.isEmpty) {
|
||||||
|
_estimateNumber = 'EST${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('請求書発行しました')),
|
const SnackBar(content: Text('請求書発行しました'))..behavior: SnackBarBehavior.floating,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
|
||||||
|
|
@ -121,7 +292,32 @@ class InvoiceScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showOrderSelection(BuildContext context) {
|
void _saveInvoice() async {
|
||||||
// TODO: 受注伝票一覧から選択ダイアログ
|
if (_items.isEmpty) return;
|
||||||
|
// TODO: DB に請求書データを保存
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('請求書 ${_invoiceId} をデータベースに保存')),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatAmount(int amount) {
|
||||||
|
const symbol = '¥';
|
||||||
|
final integerPart = amount ~/ 100; // 円部分
|
||||||
|
final centPart = amount % 100; // 銭部分
|
||||||
|
|
||||||
|
return '$symbol${integerPart.toString().padLeft(2, '0')}.${centPart.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 請求行モデル(見積番号参照)
|
||||||
|
class LineItem {
|
||||||
|
final String? invoiceId;
|
||||||
|
final String estimateNumber; // 見積番号(関連付け用)
|
||||||
|
final int? productId;
|
||||||
|
final String productName;
|
||||||
|
final int unitPrice;
|
||||||
|
int quantity = 1;
|
||||||
|
int get total => quantity * unitPrice;
|
||||||
|
|
||||||
|
LineItem({this.invoiceId, this.estimateNumber = '', this.productId, required this.productName, required this.unitPrice, this.quantity = 1});
|
||||||
}
|
}
|
||||||
|
|
@ -1,60 +1,156 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.0.0
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/database_helper.dart';
|
||||||
|
import '../models/product.dart';
|
||||||
|
|
||||||
/// 受注入力画面(Material Design テンプレート)
|
/// 受注入力画面(Material Design)
|
||||||
class OrderScreen extends StatelessWidget {
|
class OrderScreen extends StatefulWidget {
|
||||||
const OrderScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('受注入力')),
|
||||||
title: const Text('受注入力'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
onPressed: () => _showSaveDialog(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// 得意先選択
|
_buildCustomerField(),
|
||||||
TextField(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '得意先',
|
|
||||||
hintText: '得意先マスタから選択',
|
|
||||||
prefixIcon: Icon(Icons.person_search),
|
|
||||||
),
|
|
||||||
readOnly: true,
|
|
||||||
onTap: () => _showCustomerPicker(context),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
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(
|
Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
title: const Text('受注商品'),
|
title: const Text('受注商品'),
|
||||||
children: [
|
children: [
|
||||||
ListView.builder(
|
if (_items.isEmpty) ...[
|
||||||
shrinkWrap: true,
|
Padding(
|
||||||
padding: EdgeInsets.zero,
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: 0, // デモ用
|
child: Column(
|
||||||
itemBuilder: (context, index) => Card(
|
children: [
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
Icon(Icons.shopping_cart_outlined, size: 48, color: Colors.grey.shade400),
|
||||||
child: ListTile(
|
const SizedBox(height: 8),
|
||||||
leading: CircleAvatar(
|
Text('商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
|
||||||
backgroundColor: Colors.teal.shade100,
|
],
|
||||||
child: Icon(Icons.shopping_cart, color: Colors.teal),
|
|
||||||
),
|
|
||||||
title: Text('商品${index + 1}'),
|
|
||||||
subtitle: Text('数量:1 pcs / 単価:¥0'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
] 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -62,7 +158,56 @@ class OrderScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSaveDialog(BuildContext context) {
|
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(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
|
@ -71,7 +216,26 @@ class OrderScreen extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('受注データを保存しますか?'),
|
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}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -84,7 +248,7 @@ class OrderScreen extends StatelessWidget {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('受注保存しました')),
|
const SnackBar(content: Text('受注保存しました'))..behavior: SnackBarBehavior.floating,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('確定'),
|
child: const Text('確定'),
|
||||||
|
|
@ -93,8 +257,16 @@ class OrderScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showCustomerPicker(BuildContext context) {
|
/// 受注行モデル(在庫振替付き)
|
||||||
// TODO: CustomerPickerModal を再利用して実装
|
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});
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue