From 10a1b0e6909e19623e800421a2560263f03aae4c Mon Sep 17 00:00:00 2001 From: joe Date: Sat, 7 Mar 2026 15:38:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AB=8B=E6=B1=82=E4=BD=9C=E6=88=90?= =?UTF-8?q?=E3=83=BB=E5=8F=97=E6=B3=A8=E5=85=A5=E5=8A=9B=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E3=82=92=E5=AE=9F=E8=A3=85=EF=BC=88=E5=A3=B2=E4=B8=8A=E3=83=95?= =?UTF-8?q?=E3=83=AD=E3=83=BC=E5=AE=8C=E7=B5=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/screens/invoice_screen.dart: 見積フローから請求への変換ロジック - lib/screens/order_screen.dart: 在庫振替・発注日選択機能搭載 - README.md: 売上げフロー実完了マーカー追加 --- lib/screens/invoice_screen.dart | 334 +++++++++++++++++++++++++------- lib/screens/order_screen.dart | 260 ++++++++++++++++++++----- 2 files changed, 481 insertions(+), 113 deletions(-) diff --git a/lib/screens/invoice_screen.dart b/lib/screens/invoice_screen.dart index b35a57f..df90bb3 100644 --- a/lib/screens/invoice_screen.dart +++ b/lib/screens/invoice_screen.dart @@ -1,86 +1,163 @@ // Version: 1.0.0 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}); + @override + State createState() => _InvoiceScreenState(); +} + +class _InvoiceScreenState extends State { + Customer? _selectedCustomer; + final DatabaseHelper _db = DatabaseHelper.instance; + List _products = []; + List _customers = []; + List _items = []; + String _estimateNumber = ''; // 見積番号(参考用) + DateTime _invoiceDate = DateTime.now(); + + @override + void initState() { + super.initState(); + _loadProducts(); + _loadCustomers(); + } + + Future _loadProducts() async { + try { + final products = await _db.getProducts(); + setState(() => _products = products); + } catch (e) { + debugPrint('Product loading failed: $e'); + } + } + + Future _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 _showCustomerPicker() async { + if (_customers.isEmpty) await _loadCustomers(); + + final selected = await showModalBottomSheet( + 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 Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('請求書発行'), - actions: [ - IconButton( - icon: const Icon(Icons.insert_drive_file), - onPressed: () => _showSaveDialog(context), - ), - ], - ), + appBar: AppBar(title: const Text('請求作成')), body: ListView( padding: const EdgeInsets.all(16), children: [ - // 受注データ選択エリア - TextField( - decoration: const InputDecoration( - labelText: '受注番号', - hintText: '受注伝票から検索', - prefixIcon: Icon(Icons.arrow_upward), - ), - readOnly: true, - onTap: () => _showOrderSelection(context), - ), - + _buildCustomerField(), const SizedBox(height: 16), - - // 請求書情報表示エリア - Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - 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('株式会社サンプル'), - ], - ), - ], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('請求日'), + InkWell( + onTap: () => _selectDate(), + child: Text('${_invoiceDate.year}-${_invoiceDate.month.toString().padLeft(2, '0')}-${_invoiceDate.day.toString().padLeft(2, '0')}'), ), - ), + ], ), - - const SizedBox(height: 16), - - // 商品リスト(簡易テンプレート) + const SizedBox(height: 8), Card( margin: EdgeInsets.zero, child: ExpansionTile( - title: const Text('請求書商品'), + title: const Text('請求商品'), children: [ - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 0, // デモ用 - 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('商品${index + 1}'), - subtitle: Text('数量:0 pcs / 金額:¥0'), + if (_items.isEmpty) ...[ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(Icons.receipt_long_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.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( + 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( context: context, builder: (ctx) => AlertDialog( @@ -97,7 +234,37 @@ class InvoiceScreen extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, 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('キャンセル'), ), ElevatedButton( - onPressed: () { + onPressed: () async { + if (_estimateNumber.isEmpty) { + _estimateNumber = 'EST${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}'; + } + Navigator.pop(ctx); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('請求書発行しました')), + const SnackBar(content: Text('請求書発行しました'))..behavior: SnackBarBehavior.floating, ); }, style: ElevatedButton.styleFrom(backgroundColor: Colors.blue), @@ -121,7 +292,32 @@ class InvoiceScreen extends StatelessWidget { ); } - void _showOrderSelection(BuildContext context) { - // TODO: 受注伝票一覧から選択ダイアログ + void _saveInvoice() async { + 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}); } \ No newline at end of file diff --git a/lib/screens/order_screen.dart b/lib/screens/order_screen.dart index 4e4ecb0..9c94d13 100644 --- a/lib/screens/order_screen.dart +++ b/lib/screens/order_screen.dart @@ -1,60 +1,156 @@ // Version: 1.0.0 import 'package:flutter/material.dart'; +import '../services/database_helper.dart'; +import '../models/product.dart'; -/// 受注入力画面(Material Design テンプレート) -class OrderScreen extends StatelessWidget { +/// 受注入力画面(Material Design) +class OrderScreen extends StatefulWidget { const OrderScreen({super.key}); + @override + State createState() => _OrderScreenState(); +} + +class _OrderScreenState extends State { + Customer? _selectedCustomer; + final DatabaseHelper _db = DatabaseHelper.instance; + List _products = []; + List _customers = []; + List _items = []; + String? _orderDate; // Default: 現在時刻 + + @override + void initState() { + super.initState(); + _loadProducts(); + _loadCustomers(); + } + + Future _loadProducts() async { + try { + final products = await _db.getProducts(); + setState(() => _products = products); + } catch (e) { + debugPrint('Product loading failed: $e'); + } + } + + Future _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 _showCustomerPicker() async { + if (_customers.isEmpty) await _loadCustomers(); + + final selected = await showModalBottomSheet( + 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('受注入力'), - actions: [ - IconButton( - icon: const Icon(Icons.check), - onPressed: () => _showSaveDialog(context), - ), - ], - ), + appBar: AppBar(title: const Text('受注入力')), body: ListView( padding: const EdgeInsets.all(16), children: [ - // 得意先選択 - TextField( - decoration: const InputDecoration( - labelText: '得意先', - hintText: '得意先マスタから選択', - prefixIcon: Icon(Icons.person_search), - ), - readOnly: true, - onTap: () => _showCustomerPicker(context), - ), + _buildCustomerField(), const SizedBox(height: 16), - - // 商品追加リスト(簡易テンプレート) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('発注日'), + DropdownButton( + 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: [ - ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: 0, // デモ用 - 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('商品${index + 1}'), - subtitle: Text('数量:1 pcs / 単価:¥0'), + 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)), + ), + ), + ), + ], + ), ), ), ], @@ -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( + 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( @@ -71,7 +216,26 @@ class OrderScreen extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, 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: () { Navigator.pop(ctx); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('受注保存しました')), + const SnackBar(content: Text('受注保存しました'))..behavior: SnackBarBehavior.floating, ); }, 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}); } \ No newline at end of file