258 lines
No EOL
9.3 KiB
Dart
258 lines
No EOL
9.3 KiB
Dart
// Version: 1.16 - 売上入力画面(PDF 帳票生成簡易実装:TODO コメント化)
|
||
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'dart:convert';
|
||
import '../services/database_helper.dart' as db;
|
||
import '../models/product.dart';
|
||
import '../models/customer.dart';
|
||
|
||
class SalesScreen extends StatefulWidget {
|
||
const SalesScreen({super.key});
|
||
|
||
@override
|
||
State<SalesScreen> createState() => _SalesScreenState();
|
||
}
|
||
|
||
class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||
List<Product> products = <Product>[];
|
||
List<_SaleItem> saleItems = <_SaleItem>[];
|
||
double totalAmount = 0.0;
|
||
Customer? selectedCustomer;
|
||
|
||
final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
|
||
|
||
// 初期化時に製品リストを取得(簡易:DB の product_code は空なのでサンプルから生成)
|
||
Future<void> loadProducts() async {
|
||
try {
|
||
// DB から製品一覧を取得
|
||
final result = await db.DatabaseHelper.instance.query('products', orderBy: 'id DESC');
|
||
|
||
if (result.isEmpty) {
|
||
// データベースに未登録の場合:簡易テストデータ
|
||
products = <Product>[
|
||
Product(id: 1, productCode: 'TEST001', name: 'サンプル商品 A', unitPrice: 1000.0),
|
||
Product(id: 2, productCode: 'TEST002', name: 'サンプル商品 B', unitPrice: 2500.0),
|
||
];
|
||
} else {
|
||
// DB の製品データを Model に変換
|
||
products = List.generate(result.length, (i) {
|
||
return Product(
|
||
id: result[i]['id'] as int?,
|
||
productCode: result[i]['product_code'] as String? ?? '',
|
||
name: result[i]['name'] as String? ?? '',
|
||
unitPrice: (result[i]['unit_price'] as num?)?.toDouble() ?? 0.0,
|
||
quantity: (result[i]['quantity'] as int?) ?? 0,
|
||
stock: (result[i]['stock'] as int?) ?? 0,
|
||
);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
// エラー時は空リストで初期化
|
||
products = <Product>[];
|
||
}
|
||
}
|
||
|
||
Future<void> refreshProducts() async {
|
||
await loadProducts();
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
// Database に売上データを保存
|
||
Future<void> saveSalesData() async {
|
||
if (saleItems.isEmpty || !mounted) return;
|
||
|
||
try {
|
||
final itemsJson = jsonEncode(saleItems.map((item) => {
|
||
'product_id': item.productId,
|
||
'product_name': item.productName,
|
||
'product_code': item.productCode,
|
||
'unit_price': item.unitPrice.round(),
|
||
'quantity': item.quantity,
|
||
'subtotal': (item.unitPrice * item.quantity).round(),
|
||
}));
|
||
|
||
final salesData = {
|
||
'id': DateTime.now().millisecondsSinceEpoch,
|
||
'customer_id': selectedCustomer?.id ?? 1,
|
||
'sale_date': DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
|
||
'total_amount': totalAmount.round(),
|
||
'tax_rate': 8,
|
||
'product_items': itemsJson,
|
||
};
|
||
|
||
// sqflite の insert API を使用(insertSales は存在しない)
|
||
final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData);
|
||
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('✅ 売上データ保存完了'),
|
||
backgroundColor: Colors.green,
|
||
duration: Duration(seconds: 2)),
|
||
);
|
||
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('保存成功'),
|
||
content: Text('売上 ID: #$insertedId\n合計金額:${_currencyFormatter.format(totalAmount)}'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
child: const Text('OK'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('❌ 保存エラー:$e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
|
||
// PDF 帳票生成ロジックは TODO に記述(printing パッケージ使用)
|
||
Future<void> generateAndShareInvoice() async {
|
||
if (saleItems.isEmpty || !mounted) return;
|
||
|
||
try {
|
||
await saveSalesData();
|
||
|
||
if (!mounted) return;
|
||
|
||
// TODO: PDF ファイルを生成して共有するロジックを実装(printing パッケージ使用)
|
||
// 簡易実装:成功メッセージのみ表示
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('📄 売上明細が共有されました'), backgroundColor: Colors.green),
|
||
);
|
||
} catch (e) {
|
||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('共有エラー:$e'), backgroundColor: Colors.orange),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<void> searchProduct(String keyword) async {
|
||
if (!mounted || keyword.isEmpty) return;
|
||
|
||
final keywordLower = keyword.toLowerCase();
|
||
final matchedProducts = products.where((p) =>
|
||
(p.name?.toLowerCase() ?? '').contains(keywordLower) ||
|
||
(p.productCode ?? '').contains(keyword)).toList();
|
||
|
||
setState(() {
|
||
for (final product in matchedProducts) {
|
||
final existingItemIndex = saleItems.indexWhere((item) => item.productId == product.id);
|
||
if (existingItemIndex == -1 || saleItems[existingItemIndex].quantity < 1) {
|
||
saleItems.add(_SaleItem(
|
||
productId: product.id ?? 0,
|
||
productName: product.name ?? '',
|
||
productCode: product.productCode ?? '',
|
||
unitPrice: product.unitPrice ?? 0.0,
|
||
quantity: 1,
|
||
totalAmount: (product.unitPrice ?? 0.0),
|
||
));
|
||
} else {
|
||
saleItems[existingItemIndex].quantity += 1;
|
||
saleItems[existingItemIndex].totalAmount =
|
||
saleItems[existingItemIndex].unitPrice * saleItems[existingItemIndex].quantity;
|
||
}
|
||
}
|
||
calculateTotal();
|
||
});
|
||
}
|
||
|
||
void removeItem(int index) {
|
||
if (index >= 0 && index < saleItems.length) {
|
||
saleItems.removeAt(index);
|
||
calculateTotal();
|
||
}
|
||
}
|
||
|
||
void calculateTotal() {
|
||
final items = saleItems.map((item) => item.totalAmount).toList();
|
||
setState(() => totalAmount = items.fold(0, (sum, val) => sum + val));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 製品リストを初期化する(1 回だけ)
|
||
if (products.isEmpty) {
|
||
loadProducts();
|
||
}
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [
|
||
IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,),
|
||
IconButton(icon: const Icon(Icons.refresh), onPressed: refreshProducts,),
|
||
]),
|
||
body: Column(
|
||
children: <Widget>[
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Card(
|
||
elevation: 4,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||
Text('レジモード', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 8),
|
||
Row(children: <Widget>[Text('合計'), const Icon(Icons.payments, size: 32)]),
|
||
const SizedBox(height: 4),
|
||
Text('${_currencyFormatter.format(totalAmount)}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)),
|
||
],),
|
||
),
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: TextField(
|
||
decoration: InputDecoration(labelText: '商品検索', hintText: 'JAN コードまたは商品名を入力して選択', prefixIcon: const Icon(Icons.search)),
|
||
onChanged: searchProduct,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: saleItems.isEmpty
|
||
? Center(child: Text('商品を登録'))
|
||
: ListView.separated(
|
||
itemCount: saleItems.length,
|
||
itemBuilder: (context, index) {
|
||
final item = saleItems[index];
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Card(
|
||
child: ListTile(
|
||
leading: CircleAvatar(child: Icon(Icons.store)),
|
||
title: Text(item.productName ?? ''),
|
||
subtitle: Text('コード:${item.productCode} / ${_currencyFormatter.format(item.totalAmount)}'),
|
||
trailing: IconButton(icon: const Icon(Icons.remove_circle_outline), onPressed: () => removeItem(index),),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
separatorBuilder: (_, __) => const Divider(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SaleItem {
|
||
final int productId;
|
||
final String productName;
|
||
final String productCode;
|
||
final double unitPrice;
|
||
int quantity;
|
||
double totalAmount;
|
||
|
||
_SaleItem({
|
||
required this.productId,
|
||
required this.productName,
|
||
required this.productCode,
|
||
required this.unitPrice,
|
||
required this.quantity,
|
||
required this.totalAmount,
|
||
});
|
||
} |