h-1.flutter.4/lib/screens/invoice_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

323 lines
No EOL
11 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';
/// 請求作成画面(見積フローから連携)
class InvoiceScreen extends StatefulWidget {
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
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('請求日'),
InkWell(
onTap: () => _selectDate(),
child: Text('${_invoiceDate.year}-${_invoiceDate.month.toString().padLeft(2, '0')}-${_invoiceDate.day.toString().padLeft(2, '0')}'),
),
],
),
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.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)),
),
),
),
],
),
),
),
],
),
);
}
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(
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('請求 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}'),
],
),
)),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
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('請求書発行しました'))..behavior: SnackBarBehavior.floating,
);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
child: const Text('発行'),
),
],
),
);
}
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});
}