feat: Introduce line items and customizable tax rate for invoice generation.
This commit is contained in:
parent
191711803b
commit
70c902885a
14 changed files with 764 additions and 252 deletions
|
|
@ -43,6 +43,7 @@ class Invoice {
|
||||||
final List<InvoiceItem> items;
|
final List<InvoiceItem> items;
|
||||||
final String? notes;
|
final String? notes;
|
||||||
final String? filePath;
|
final String? filePath;
|
||||||
|
final double taxRate; // 追加
|
||||||
final String? odooId;
|
final String? odooId;
|
||||||
final bool isSynced;
|
final bool isSynced;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
|
|
@ -54,6 +55,7 @@ class Invoice {
|
||||||
required this.items,
|
required this.items,
|
||||||
this.notes,
|
this.notes,
|
||||||
this.filePath,
|
this.filePath,
|
||||||
|
this.taxRate = 0.10, // デフォルト10%
|
||||||
this.odooId,
|
this.odooId,
|
||||||
this.isSynced = false,
|
this.isSynced = false,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
|
@ -63,7 +65,7 @@ class Invoice {
|
||||||
String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
||||||
|
|
||||||
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
|
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
|
||||||
int get tax => (subtotal * 0.1).floor();
|
int get tax => (subtotal * taxRate).floor(); // taxRateを使用
|
||||||
int get totalAmount => subtotal + tax;
|
int get totalAmount => subtotal + tax;
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
|
|
@ -74,6 +76,7 @@ class Invoice {
|
||||||
'notes': notes,
|
'notes': notes,
|
||||||
'file_path': filePath,
|
'file_path': filePath,
|
||||||
'total_amount': totalAmount,
|
'total_amount': totalAmount,
|
||||||
|
'tax_rate': taxRate, // 追加
|
||||||
'odoo_id': odooId,
|
'odoo_id': odooId,
|
||||||
'is_synced': isSynced ? 1 : 0,
|
'is_synced': isSynced ? 1 : 0,
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
|
|
@ -106,6 +109,7 @@ class Invoice {
|
||||||
List<InvoiceItem>? items,
|
List<InvoiceItem>? items,
|
||||||
String? notes,
|
String? notes,
|
||||||
String? filePath,
|
String? filePath,
|
||||||
|
double? taxRate,
|
||||||
String? odooId,
|
String? odooId,
|
||||||
bool? isSynced,
|
bool? isSynced,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
|
@ -117,6 +121,7 @@ class Invoice {
|
||||||
items: items ?? List.from(this.items),
|
items: items ?? List.from(this.items),
|
||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
filePath: filePath ?? this.filePath,
|
filePath: filePath ?? this.filePath,
|
||||||
|
taxRate: taxRate ?? this.taxRate,
|
||||||
odooId: odooId ?? this.odooId,
|
odooId: odooId ?? this.odooId,
|
||||||
isSynced: isSynced ?? this.isSynced,
|
isSynced: isSynced ?? this.isSynced,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
|
|
||||||
45
lib/models/product_model.dart
Normal file
45
lib/models/product_model.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
class Product {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final int defaultUnitPrice;
|
||||||
|
final String? odooId;
|
||||||
|
|
||||||
|
Product({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.defaultUnitPrice = 0,
|
||||||
|
this.odooId,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'default_unit_price': defaultUnitPrice,
|
||||||
|
'odoo_id': odooId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Product.fromMap(Map<String, dynamic> map) {
|
||||||
|
return Product(
|
||||||
|
id: map['id'],
|
||||||
|
name: map['name'],
|
||||||
|
defaultUnitPrice: map['default_unit_price'] ?? 0,
|
||||||
|
odooId: map['odoo_id'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Product copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
int? defaultUnitPrice,
|
||||||
|
String? odooId,
|
||||||
|
}) {
|
||||||
|
return Product(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
|
||||||
|
odooId: odooId ?? this.odooId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -276,16 +276,21 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSummarySection(NumberFormat formatter) {
|
Widget _buildSummarySection(NumberFormat formatter) {
|
||||||
|
final double currentTaxRate = _isEditing ? _currentInvoice.taxRate : _currentInvoice.taxRate; // 編集時も元の税率を維持
|
||||||
|
final int subtotal = _isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal;
|
||||||
|
final int tax = (subtotal * currentTaxRate).floor();
|
||||||
|
final int total = subtotal + tax;
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: 200,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)),
|
_SummaryRow("小計 (税抜)", formatter.format(subtotal)),
|
||||||
_SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)),
|
_SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SummaryRow("合計 (税込)", "¥${formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount)}", isBold: true),
|
_SummaryRow("合計 (税込)", "¥${formatter.format(total)}", isBold: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import '../services/invoice_repository.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
import 'invoice_detail_page.dart';
|
import 'invoice_detail_page.dart';
|
||||||
import 'management_screen.dart';
|
import 'management_screen.dart';
|
||||||
|
import '../widgets/slide_to_unlock.dart';
|
||||||
import '../main.dart'; // InvoiceFlowScreen 用
|
import '../main.dart'; // InvoiceFlowScreen 用
|
||||||
|
|
||||||
class InvoiceHistoryScreen extends StatefulWidget {
|
class InvoiceHistoryScreen extends StatefulWidget {
|
||||||
|
|
@ -73,9 +74,11 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isUnlocked = !_isUnlocked;
|
_isUnlocked = !_isUnlocked;
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (!_isUnlocked) {
|
||||||
SnackBar(content: Text(_isUnlocked ? "編集プロテクトを解除しました" : "編集プロテクトを有効にしました")),
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
);
|
const SnackBar(content: Text("編集プロテクトを有効にしました")),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -88,11 +91,12 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
title: const Text("伝票マスター一覧"),
|
title: const Text("伝票マスター一覧"),
|
||||||
backgroundColor: _isUnlocked ? Colors.blueGrey : Colors.blueGrey.shade800,
|
backgroundColor: _isUnlocked ? Colors.blueGrey : Colors.blueGrey.shade800,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
if (_isUnlocked)
|
||||||
icon: Icon(_isUnlocked ? Icons.lock_open : Icons.lock, color: _isUnlocked ? Colors.orangeAccent : Colors.white70),
|
IconButton(
|
||||||
onPressed: _toggleUnlock,
|
icon: const Icon(Icons.lock_open, color: Colors.orangeAccent),
|
||||||
tooltip: _isUnlocked ? "プロテクトする" : "アンロックする",
|
onPressed: _toggleUnlock,
|
||||||
),
|
tooltip: "再度プロテクトする",
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.sort),
|
icon: const Icon(Icons.sort),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
@ -206,86 +210,97 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: Column(
|
||||||
? const Center(child: CircularProgressIndicator())
|
children: [
|
||||||
: _filteredInvoices.isEmpty
|
SlideToUnlock(
|
||||||
? Center(
|
isLocked: !_isUnlocked,
|
||||||
child: Column(
|
onUnlocked: _toggleUnlock,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
text: "スライドして編集モード解除",
|
||||||
children: [
|
),
|
||||||
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
Expanded(
|
||||||
const SizedBox(height: 16),
|
child: _isLoading
|
||||||
Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"),
|
? const Center(child: CircularProgressIndicator())
|
||||||
],
|
: _filteredInvoices.isEmpty
|
||||||
),
|
? Center(
|
||||||
)
|
child: Column(
|
||||||
: ListView.builder(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
itemCount: _filteredInvoices.length,
|
children: [
|
||||||
itemBuilder: (context, index) {
|
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
||||||
final invoice = _filteredInvoices[index];
|
const SizedBox(height: 16),
|
||||||
return ListTile(
|
Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"),
|
||||||
leading: CircleAvatar(
|
],
|
||||||
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200,
|
),
|
||||||
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey),
|
)
|
||||||
),
|
: ListView.builder(
|
||||||
title: Text(invoice.customer.formalName),
|
itemCount: _filteredInvoices.length,
|
||||||
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
|
itemBuilder: (context, index) {
|
||||||
trailing: Column(
|
final invoice = _filteredInvoices[index];
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
return ListTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
leading: CircleAvatar(
|
||||||
children: [
|
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200,
|
||||||
Text("¥${amountFormatter.format(invoice.totalAmount)}",
|
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold)),
|
),
|
||||||
if (invoice.isSynced)
|
title: Text(invoice.customer.formalName),
|
||||||
const Icon(Icons.sync, size: 16, color: Colors.green)
|
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
|
||||||
else
|
trailing: Column(
|
||||||
const Icon(Icons.sync_disabled, size: 16, color: Colors.orange),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
],
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
),
|
children: [
|
||||||
onTap: () async {
|
Text("¥${amountFormatter.format(invoice.totalAmount)}",
|
||||||
if (!_isUnlocked) {
|
style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (invoice.isSynced)
|
||||||
const SnackBar(content: Text("詳細の閲覧・編集にはアンロックが必要です"), duration: Duration(seconds: 1)),
|
const Icon(Icons.sync, size: 16, color: Colors.green)
|
||||||
|
else
|
||||||
|
const Icon(Icons.sync_disabled, size: 16, color: Colors.orange),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
if (!_isUnlocked) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("詳細の閲覧・編集にはアンロックが必要です"), duration: Duration(seconds: 1)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => InvoiceDetailPage(invoice: invoice),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadData(); // 戻ってきたら再読込
|
||||||
|
},
|
||||||
|
onLongPress: () async {
|
||||||
|
if (!_isUnlocked) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text("削除するにはアンロックが必要です")),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("伝票の削除"),
|
||||||
|
content: Text("「${invoice.customer.formalName}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text("削除", style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
await _invoiceRepo.deleteInvoice(invoice.id);
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return;
|
},
|
||||||
}
|
),
|
||||||
await Navigator.push(
|
),
|
||||||
context,
|
],
|
||||||
MaterialPageRoute(
|
),
|
||||||
builder: (context) => InvoiceDetailPage(invoice: invoice),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
_loadData(); // 戻ってきたら再読込
|
|
||||||
},
|
|
||||||
onLongPress: () async {
|
|
||||||
if (!_isUnlocked) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("削除するにはアンロックが必要です")),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final confirm = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text("伝票の削除"),
|
|
||||||
content: Text("「${invoice.customer.formalName}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: const Text("削除", style: TextStyle(color: Colors.red)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (confirm == true) {
|
|
||||||
await _invoiceRepo.deleteInvoice(invoice.id);
|
|
||||||
_loadData();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import '../models/customer_model.dart';
|
import '../models/customer_model.dart';
|
||||||
import '../models/invoice_models.dart';
|
import '../models/invoice_models.dart';
|
||||||
import '../services/pdf_generator.dart';
|
import '../services/pdf_generator.dart';
|
||||||
import '../services/invoice_repository.dart';
|
import '../services/invoice_repository.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
import 'customer_picker_modal.dart';
|
import 'customer_picker_modal.dart';
|
||||||
|
import 'product_picker_modal.dart';
|
||||||
|
|
||||||
/// 請求書の初期入力(ヘッダー部分)を管理するウィジェット
|
|
||||||
class InvoiceInputForm extends StatefulWidget {
|
class InvoiceInputForm extends StatefulWidget {
|
||||||
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
|
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
|
||||||
|
|
||||||
|
|
@ -21,12 +22,15 @@ class InvoiceInputForm extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
final _clientController = TextEditingController();
|
|
||||||
final _amountController = TextEditingController(text: "250000");
|
|
||||||
final _repository = InvoiceRepository();
|
final _repository = InvoiceRepository();
|
||||||
String _status = "取引先を選択してPDFを生成してください";
|
|
||||||
|
|
||||||
Customer? _selectedCustomer;
|
Customer? _selectedCustomer;
|
||||||
|
final List<InvoiceItem> _items = [];
|
||||||
|
double _taxRate = 0.10;
|
||||||
|
bool _includeTax = true;
|
||||||
|
String _status = "取引先と商品を入力してください";
|
||||||
|
|
||||||
|
// 署名用の実験的パス
|
||||||
|
List<Offset?> _signaturePath = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -35,176 +39,299 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadInitialData() async {
|
Future<void> _loadInitialData() async {
|
||||||
// 起動時に不要なPDFを掃除する
|
_repository.cleanupOrphanedPdfs();
|
||||||
_repository.cleanupOrphanedPdfs().then((count) {
|
|
||||||
if (count > 0) {
|
|
||||||
debugPrint('Cleaned up $count orphaned PDF files.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
final customerRepo = CustomerRepository();
|
final customerRepo = CustomerRepository();
|
||||||
final customers = await customerRepo.getAllCustomers();
|
final customers = await customerRepo.getAllCustomers();
|
||||||
if (customers.isNotEmpty) {
|
if (customers.isNotEmpty) {
|
||||||
setState(() {
|
setState(() => _selectedCustomer = customers.first);
|
||||||
_selectedCustomer = customers.first;
|
|
||||||
_clientController.text = _selectedCustomer!.formalName;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// マスターが空の場合は、デフォルトのサンプルを登録しておく
|
|
||||||
final defaultCustomer = Customer(
|
|
||||||
id: const Uuid().v4(),
|
|
||||||
displayName: "佐々木製作所",
|
|
||||||
formalName: "株式会社 佐々木製作所",
|
|
||||||
);
|
|
||||||
await customerRepo.saveCustomer(defaultCustomer);
|
|
||||||
setState(() {
|
|
||||||
_selectedCustomer = defaultCustomer;
|
|
||||||
_clientController.text = _selectedCustomer!.formalName;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _addItem() {
|
||||||
void dispose() {
|
showModalBottomSheet(
|
||||||
_clientController.dispose();
|
|
||||||
_amountController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openCustomerPicker() async {
|
|
||||||
setState(() => _status = "顧客マスターを開いています...");
|
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
builder: (context) => ProductPickerModal(
|
||||||
builder: (context) => FractionallySizedBox(
|
onItemSelected: (item) {
|
||||||
heightFactor: 0.9,
|
setState(() => _items.add(item));
|
||||||
child: CustomerPickerModal(
|
Navigator.pop(context);
|
||||||
onCustomerSelected: (customer) {
|
},
|
||||||
setState(() {
|
|
||||||
_selectedCustomer = customer;
|
|
||||||
_clientController.text = customer.formalName;
|
|
||||||
_status = "「${customer.formalName}」を選択しました";
|
|
||||||
});
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleInitialGenerate() async {
|
int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity));
|
||||||
|
int get _tax => _includeTax ? (_subTotal * _taxRate).round() : 0;
|
||||||
|
int get _total => _subTotal + _tax;
|
||||||
|
|
||||||
|
Future<void> _handleGenerate() async {
|
||||||
if (_selectedCustomer == null) {
|
if (_selectedCustomer == null) {
|
||||||
setState(() => _status = "取引先を選択してください");
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_items.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final unitPrice = int.tryParse(_amountController.text) ?? 0;
|
|
||||||
|
|
||||||
final initialItems = [
|
|
||||||
InvoiceItem(
|
|
||||||
description: "ご請求分",
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: unitPrice,
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
final invoice = Invoice(
|
final invoice = Invoice(
|
||||||
customer: _selectedCustomer!,
|
customer: _selectedCustomer!,
|
||||||
date: DateTime.now(),
|
date: DateTime.now(),
|
||||||
items: initialItems,
|
items: _items,
|
||||||
|
taxRate: _includeTax ? _taxRate : 0.0, // 追加
|
||||||
|
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() => _status = "A4請求書を生成中...");
|
setState(() => _status = "PDFを生成中...");
|
||||||
final path = await generateInvoicePdf(invoice);
|
final path = await generateInvoicePdf(invoice);
|
||||||
|
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
final updatedInvoice = invoice.copyWith(filePath: path);
|
final updatedInvoice = invoice.copyWith(filePath: path);
|
||||||
|
|
||||||
// オリジナルDBに保存
|
|
||||||
await _repository.saveInvoice(updatedInvoice);
|
await _repository.saveInvoice(updatedInvoice);
|
||||||
|
|
||||||
widget.onInvoiceGenerated(updatedInvoice, path);
|
widget.onInvoiceGenerated(updatedInvoice, path);
|
||||||
setState(() => _status = "PDFを生成しDBに登録しました。");
|
|
||||||
} else {
|
|
||||||
setState(() => _status = "PDFの生成に失敗しました");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final fmt = NumberFormat("#,###");
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: SingleChildScrollView(
|
return Column(
|
||||||
child: Column(children: [
|
children: [
|
||||||
const Text(
|
Expanded(
|
||||||
"ステップ1: 宛先と基本金額の設定",
|
child: SingleChildScrollView(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey),
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildCustomerSection(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildItemsSection(fmt),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildExperimentalSection(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSummarySection(fmt),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSignatureSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
Row(children: [
|
_buildBottomActionBar(),
|
||||||
Expanded(
|
],
|
||||||
child: TextField(
|
);
|
||||||
controller: _clientController,
|
}
|
||||||
readOnly: true,
|
|
||||||
onTap: _openCustomerPicker,
|
Widget _buildCustomerSection() {
|
||||||
decoration: const InputDecoration(
|
return Card(
|
||||||
labelText: "取引先名 (タップして選択)",
|
elevation: 0,
|
||||||
hintText: "電話帳から取り込むか、マスターから選択",
|
color: Colors.blueGrey.shade50,
|
||||||
prefixIcon: Icon(Icons.business),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
border: OutlineInputBorder(),
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.business, color: Colors.blueGrey),
|
||||||
|
title: Text(_selectedCustomer?.formalName ?? "取引先を選択してください",
|
||||||
|
style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: const Text("請求先マスターから選択"),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => FractionallySizedBox(
|
||||||
|
heightFactor: 0.9,
|
||||||
|
child: CustomerPickerModal(onCustomerSelected: (c) {
|
||||||
|
setState(() => _selectedCustomer = c);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItemsSection(NumberFormat fmt) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_items.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
..._items.asMap().entries.map((entry) {
|
||||||
|
final idx = entry.key;
|
||||||
|
final item = entry.value;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(item.description),
|
||||||
|
subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}"),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent),
|
||||||
|
onPressed: () => setState(() => _items.removeAt(idx)),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(width: 8),
|
}),
|
||||||
IconButton(
|
],
|
||||||
icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40),
|
);
|
||||||
onPressed: _openCustomerPicker,
|
}
|
||||||
tooltip: "顧客を選択・登録",
|
|
||||||
),
|
Widget _buildExperimentalSection() {
|
||||||
]),
|
return Container(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.all(12),
|
||||||
TextField(
|
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(12)),
|
||||||
controller: _amountController,
|
child: Column(
|
||||||
keyboardType: TextInputType.number,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: const InputDecoration(
|
children: [
|
||||||
labelText: "基本金額 (税抜)",
|
const Text("実験的オプション", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
|
||||||
hintText: "明細の1行目として登録されます",
|
const SizedBox(height: 8),
|
||||||
prefixIcon: Icon(Icons.currency_yen),
|
Row(
|
||||||
border: OutlineInputBorder(),
|
children: [
|
||||||
|
const Text("消費税: "),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text("10%"),
|
||||||
|
selected: _taxRate == 0.10,
|
||||||
|
onSelected: (val) => setState(() => _taxRate = 0.10),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ChoiceChip(
|
||||||
|
label: const Text("8%"),
|
||||||
|
selected: _taxRate == 0.08,
|
||||||
|
onSelected: (val) => setState(() => _taxRate = 0.08),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Switch(
|
||||||
|
value: _includeTax,
|
||||||
|
onChanged: (val) => setState(() => _includeTax = val),
|
||||||
|
),
|
||||||
|
Text(_includeTax ? "税込表示" : "非課税"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummarySection(NumberFormat fmt) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildSummaryRow("小計", "¥${fmt.format(_subTotal)}", Colors.white70),
|
||||||
|
_buildSummaryRow("消費税", "¥${fmt.format(_tax)}", Colors.white70),
|
||||||
|
const Divider(color: Colors.white24),
|
||||||
|
_buildSummaryRow("合計金額", "¥${fmt.format(_total)}", Colors.white, fontSize: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSummaryRow(String label, String value, Color color, {double fontSize = 16}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(color: color, fontSize: fontSize)),
|
||||||
|
Text(value, style: TextStyle(color: color, fontSize: fontSize, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSignatureSection() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text("手書き署名 (実験的)", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
TextButton(onPressed: () => setState(() => _signaturePath.clear()), child: const Text("クリア")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 150,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: GestureDetector(
|
||||||
|
onPanUpdate: (details) {
|
||||||
|
setState(() {
|
||||||
|
RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||||
|
_signaturePath.add(renderBox.globalToLocal(details.globalPosition));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPanEnd: (details) => _signaturePath.add(null),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: SignaturePainter(_signaturePath),
|
||||||
|
size: Size.infinite,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
ElevatedButton.icon(
|
],
|
||||||
onPressed: _handleInitialGenerate,
|
);
|
||||||
icon: const Icon(Icons.description),
|
}
|
||||||
label: const Text("A4請求書を作成して詳細編集へ"),
|
|
||||||
style: ElevatedButton.styleFrom(
|
Widget _buildBottomActionBar() {
|
||||||
minimumSize: const Size(double.infinity, 60),
|
return Container(
|
||||||
backgroundColor: Colors.indigo,
|
padding: const EdgeInsets.all(16),
|
||||||
foregroundColor: Colors.white,
|
decoration: BoxDecoration(
|
||||||
elevation: 4,
|
color: Colors.white,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
|
||||||
),
|
),
|
||||||
),
|
child: ElevatedButton.icon(
|
||||||
const SizedBox(height: 24),
|
onPressed: _handleGenerate,
|
||||||
Container(
|
icon: const Icon(Icons.picture_as_pdf),
|
||||||
width: double.infinity,
|
label: const Text("伝票を確定してPDF生成"),
|
||||||
padding: const EdgeInsets.all(12),
|
style: ElevatedButton.styleFrom(
|
||||||
decoration: BoxDecoration(
|
minimumSize: const Size(double.infinity, 60),
|
||||||
color: Colors.grey[100],
|
backgroundColor: Colors.indigo,
|
||||||
borderRadius: BorderRadius.circular(8),
|
foregroundColor: Colors.white,
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
),
|
),
|
||||||
child: Text(
|
|
||||||
_status,
|
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.black54),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SignaturePainter extends CustomPainter {
|
||||||
|
final List<Offset?> points;
|
||||||
|
SignaturePainter(this.points);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
Paint paint = Paint()
|
||||||
|
..color = Colors.black
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeWidth = 3.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < points.length - 1; i++) {
|
||||||
|
if (points[i] != null && points[i + 1] != null) {
|
||||||
|
canvas.drawLine(points[i]!, points[i + 1]!, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(SignaturePainter oldDelegate) => true;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
|
||||||
import '../models/invoice_models.dart';
|
import '../models/invoice_models.dart';
|
||||||
import '../services/invoice_repository.dart';
|
import '../services/invoice_repository.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
|
import 'product_master_screen.dart';
|
||||||
|
|
||||||
class ManagementScreen extends StatelessWidget {
|
class ManagementScreen extends StatelessWidget {
|
||||||
const ManagementScreen({Key? key}) : super(key: key);
|
const ManagementScreen({Key? key}) : super(key: key);
|
||||||
|
|
@ -20,6 +21,13 @@ class ManagementScreen extends StatelessWidget {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildSectionHeader("データ入出力"),
|
_buildSectionHeader("データ入出力"),
|
||||||
|
_buildMenuTile(
|
||||||
|
context,
|
||||||
|
Icons.inventory_2,
|
||||||
|
"商品マスター管理",
|
||||||
|
"販売商品の名称や単価を管理します",
|
||||||
|
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())),
|
||||||
|
),
|
||||||
_buildMenuTile(
|
_buildMenuTile(
|
||||||
context,
|
context,
|
||||||
Icons.upload_file,
|
Icons.upload_file,
|
||||||
|
|
|
||||||
135
lib/screens/product_master_screen.dart
Normal file
135
lib/screens/product_master_screen.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/product_model.dart';
|
||||||
|
import '../services/product_repository.dart';
|
||||||
|
|
||||||
|
class ProductMasterScreen extends StatefulWidget {
|
||||||
|
const ProductMasterScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
|
final ProductRepository _productRepo = ProductRepository();
|
||||||
|
List<Product> _products = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProducts() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final products = await _productRepo.getAllProducts();
|
||||||
|
setState(() {
|
||||||
|
_products = products;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addItem({Product? product}) async {
|
||||||
|
final isEdit = product != null;
|
||||||
|
final nameController = TextEditingController(text: product?.name ?? "");
|
||||||
|
final priceController = TextEditingController(text: product?.defaultUnitPrice.toString() ?? "0");
|
||||||
|
|
||||||
|
final result = await showDialog<Product>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(isEdit ? "商品を編集" : "商品を新規登録"),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
decoration: const InputDecoration(labelText: "商品名"),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: priceController,
|
||||||
|
decoration: const InputDecoration(labelText: "初期単価"),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (nameController.text.isEmpty) return;
|
||||||
|
final newProduct = Product(
|
||||||
|
id: product?.id ?? const Uuid().v4(),
|
||||||
|
name: nameController.text,
|
||||||
|
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
|
||||||
|
odooId: product?.odooId,
|
||||||
|
);
|
||||||
|
Navigator.pop(context, newProduct);
|
||||||
|
},
|
||||||
|
child: const Text("保存"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
await _productRepo.saveProduct(result);
|
||||||
|
_loadProducts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text("商品マスター管理"),
|
||||||
|
backgroundColor: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _products.isEmpty
|
||||||
|
? const Center(child: Text("商品が登録されていません"))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _products.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final p = _products[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(p.name),
|
||||||
|
subtitle: Text("初期単価: ¥${p.defaultUnitPrice}"),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(icon: const Icon(Icons.edit), onPressed: () => _addItem(product: p)),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("削除確認"),
|
||||||
|
content: Text("「${p.name}」を削除しますか?"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
await _productRepo.deleteProduct(p.id);
|
||||||
|
_loadProducts();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _addItem,
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
backgroundColor: Colors.indigo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/invoice_models.dart';
|
import '../models/invoice_models.dart';
|
||||||
|
import '../models/product_model.dart';
|
||||||
|
import '../services/product_repository.dart';
|
||||||
|
import 'product_master_screen.dart';
|
||||||
|
|
||||||
/// 商品マスターから項目を選択するためのモーダル(スタブ実装)
|
/// 商品マスターから項目を選択するためのモーダル(スタブ実装)
|
||||||
class ProductPickerModal extends StatefulWidget {
|
class ProductPickerModal extends StatefulWidget {
|
||||||
|
|
@ -12,14 +15,24 @@ class ProductPickerModal extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProductPickerModalState extends State<ProductPickerModal> {
|
class _ProductPickerModalState extends State<ProductPickerModal> {
|
||||||
// 本来はデータベースから取得しますが、現時点ではスタブデータを表示します
|
final ProductRepository _productRepo = ProductRepository();
|
||||||
final List<InvoiceItem> _masterProducts = [
|
List<Product> _products = [];
|
||||||
InvoiceItem(description: "技術料", quantity: 1, unitPrice: 50000),
|
bool _isLoading = true;
|
||||||
InvoiceItem(description: "部品代 A", quantity: 1, unitPrice: 15000),
|
|
||||||
InvoiceItem(description: "部品代 B", quantity: 1, unitPrice: 3000),
|
@override
|
||||||
InvoiceItem(description: "出張費", quantity: 1, unitPrice: 10000),
|
void initState() {
|
||||||
InvoiceItem(description: "諸経費", quantity: 1, unitPrice: 5000),
|
super.initState();
|
||||||
];
|
_loadProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProducts() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final products = await _productRepo.getAllProducts();
|
||||||
|
setState(() {
|
||||||
|
_products = products;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -42,19 +55,55 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: _isLoading
|
||||||
itemCount: _masterProducts.length,
|
? const Center(child: CircularProgressIndicator())
|
||||||
itemBuilder: (context, index) {
|
: _products.isEmpty
|
||||||
final product = _masterProducts[index];
|
? Center(
|
||||||
return ListTile(
|
child: Column(
|
||||||
leading: const Icon(Icons.inventory_2_outlined),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
title: Text(product.description),
|
children: [
|
||||||
subtitle: Text("単価: ¥${product.unitPrice}"),
|
const Text("商品マスターが空です"),
|
||||||
onTap: () => widget.onItemSelected(product),
|
TextButton(
|
||||||
);
|
onPressed: () async {
|
||||||
},
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
|
||||||
),
|
_loadProducts();
|
||||||
|
},
|
||||||
|
child: const Text("商品マスターを編集する"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _products.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = _products[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.inventory_2_outlined),
|
||||||
|
title: Text(product.name),
|
||||||
|
subtitle: Text("初期単価: ¥${product.defaultUnitPrice}"),
|
||||||
|
onTap: () => widget.onItemSelected(
|
||||||
|
InvoiceItem(
|
||||||
|
description: product.name,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: product.defaultUnitPrice,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (_products.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: TextButton.icon(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text("商品マスターの管理"),
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
|
||||||
|
_loadProducts();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,18 @@ class DatabaseHelper {
|
||||||
String path = join(await getDatabasesPath(), 'gemi_invoice.db');
|
String path = join(await getDatabasesPath(), 'gemi_invoice.db');
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 1,
|
version: 2,
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
await db.execute('ALTER TABLE invoices ADD COLUMN tax_rate REAL DEFAULT 0.10');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
// 顧客マスター
|
// 顧客マスター
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
|
|
@ -72,6 +79,7 @@ class DatabaseHelper {
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
file_path TEXT,
|
file_path TEXT,
|
||||||
total_amount INTEGER,
|
total_amount INTEGER,
|
||||||
|
tax_rate REAL DEFAULT 0.10,
|
||||||
odoo_id TEXT,
|
odoo_id TEXT,
|
||||||
is_synced INTEGER DEFAULT 0,
|
is_synced INTEGER DEFAULT 0,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ class InvoiceRepository {
|
||||||
items: items,
|
items: items,
|
||||||
notes: iMap['notes'],
|
notes: iMap['notes'],
|
||||||
filePath: iMap['file_path'],
|
filePath: iMap['file_path'],
|
||||||
|
taxRate: iMap['tax_rate'] ?? 0.10, // 追加
|
||||||
odooId: iMap['odoo_id'],
|
odooId: iMap['odoo_id'],
|
||||||
isSynced: iMap['is_synced'] == 1,
|
isSynced: iMap['is_synced'] == 1,
|
||||||
updatedAt: DateTime.parse(iMap['updated_at']),
|
updatedAt: DateTime.parse(iMap['updated_at']),
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
||||||
children: [
|
children: [
|
||||||
pw.SizedBox(height: 10),
|
pw.SizedBox(height: 10),
|
||||||
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
|
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
|
||||||
_buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)),
|
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
|
||||||
pw.Divider(),
|
pw.Divider(),
|
||||||
_buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true),
|
_buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
27
lib/services/product_repository.dart
Normal file
27
lib/services/product_repository.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import '../models/product_model.dart';
|
||||||
|
import 'database_helper.dart';
|
||||||
|
|
||||||
|
class ProductRepository {
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
|
|
||||||
|
Future<List<Product>> getAllProducts() async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'name ASC');
|
||||||
|
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveProduct(Product product) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.insert(
|
||||||
|
'products',
|
||||||
|
product.toMap(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteProduct(String id) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
lib/widgets/slide_to_unlock.dart
Normal file
85
lib/widgets/slide_to_unlock.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SlideToUnlock extends StatefulWidget {
|
||||||
|
final VoidCallback onUnlocked;
|
||||||
|
final String text;
|
||||||
|
final bool isLocked;
|
||||||
|
|
||||||
|
const SlideToUnlock({
|
||||||
|
Key? key,
|
||||||
|
required this.onUnlocked,
|
||||||
|
this.text = "スライドして解除",
|
||||||
|
this.isLocked = true,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SlideToUnlock> createState() => _SlideToUnlockState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||||
|
double _position = 0.0;
|
||||||
|
final double _thumbSize = 50.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!widget.isLocked) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final double maxWidth = constraints.maxWidth;
|
||||||
|
final double trackWidth = maxWidth - _thumbSize;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 60,
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueGrey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
widget.text,
|
||||||
|
style: TextStyle(color: Colors.blueGrey.shade700, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: _position,
|
||||||
|
child: GestureDetector(
|
||||||
|
onHorizontalDragUpdate: (details) {
|
||||||
|
setState(() {
|
||||||
|
_position += details.delta.dx;
|
||||||
|
if (_position < 0) _position = 0;
|
||||||
|
if (_position > trackWidth) _position = trackWidth;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onHorizontalDragEnd: (details) {
|
||||||
|
if (_position >= trackWidth * 0.9) {
|
||||||
|
widget.onUnlocked();
|
||||||
|
setState(() => _position = 0); // 念のためリセット
|
||||||
|
} else {
|
||||||
|
setState(() => _position = 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: _thumbSize,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orangeAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(2, 2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.arrow_forward_ios, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
目標.md
4
目標.md
|
|
@ -22,4 +22,6 @@
|
||||||
- 商品マスター管理画面の実装
|
- 商品マスター管理画面の実装
|
||||||
- 伝票入力画面の実装
|
- 伝票入力画面の実装
|
||||||
- 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装
|
- 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装
|
||||||
|
− 商品マスター編集画面の実装
|
||||||
|
- 顧客マスター編集画面の実装
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue