顧客マスター
This commit is contained in:
parent
3128ab5579
commit
37f66413fe
9 changed files with 1057 additions and 159 deletions
99
gemi_invoice/lib/data/product_master.dart
Normal file
99
gemi_invoice/lib/data/product_master.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import '../models/invoice_models.dart';
|
||||
|
||||
/// 商品情報を管理するモデル
|
||||
/// 将来的な Odoo 同期を見据えて、外部ID(odooId)を保持できるように設計
|
||||
class Product {
|
||||
final String id; // ローカル管理用のID
|
||||
final int? odooId; // Odoo上の product.product ID (nullの場合は未同期)
|
||||
final String name; // 商品名
|
||||
final int defaultUnitPrice; // 標準単価
|
||||
final String? category; // カテゴリ
|
||||
|
||||
const Product({
|
||||
required this.id,
|
||||
this.odooId,
|
||||
required this.name,
|
||||
required this.defaultUnitPrice,
|
||||
this.category,
|
||||
});
|
||||
|
||||
/// InvoiceItem への変換
|
||||
InvoiceItem toInvoiceItem({int quantity = 1}) {
|
||||
return InvoiceItem(
|
||||
description: name,
|
||||
quantity: quantity,
|
||||
unitPrice: defaultUnitPrice,
|
||||
);
|
||||
}
|
||||
|
||||
/// 状態更新のためのコピーメソッド
|
||||
Product copyWith({
|
||||
String? id,
|
||||
int? odooId,
|
||||
String? name,
|
||||
int? defaultUnitPrice,
|
||||
String? category,
|
||||
}) {
|
||||
return Product(
|
||||
id: id ?? this.id,
|
||||
odooId: odooId ?? this.odooId,
|
||||
name: name ?? this.name,
|
||||
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
|
||||
category: category ?? this.category,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON変換 (ローカル保存・Odoo同期用)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'odoo_id': odooId,
|
||||
'name': name,
|
||||
'default_unit_price': defaultUnitPrice,
|
||||
'category': category,
|
||||
};
|
||||
}
|
||||
|
||||
/// JSONからモデルを生成
|
||||
factory Product.fromJson(Map<String, dynamic> json) {
|
||||
return Product(
|
||||
id: json['id'],
|
||||
odooId: json['odoo_id'],
|
||||
name: json['name'],
|
||||
defaultUnitPrice: json['default_unit_price'],
|
||||
category: json['category'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 商品マスターのテンプレートデータ
|
||||
class ProductMaster {
|
||||
static const List<Product> products = [
|
||||
Product(id: 'S001', name: 'システム開発費', defaultUnitPrice: 500000, category: '開発'),
|
||||
Product(id: 'S002', name: '保守・メンテナンス費', defaultUnitPrice: 50000, category: '運用'),
|
||||
Product(id: 'S003', name: '技術コンサルティング', defaultUnitPrice: 100000, category: '開発'),
|
||||
Product(id: 'G001', name: 'ライセンス料 (Pro)', defaultUnitPrice: 15000, category: '製品'),
|
||||
Product(id: 'G002', name: '初期導入セットアップ', defaultUnitPrice: 30000, category: '製品'),
|
||||
Product(id: 'M001', name: 'ハードウェア一式', defaultUnitPrice: 250000, category: '物品'),
|
||||
Product(id: 'Z001', name: '諸経費', defaultUnitPrice: 5000, category: 'その他'),
|
||||
];
|
||||
|
||||
/// カテゴリ一覧の取得
|
||||
static List<String> get categories {
|
||||
return products.map((p) => p.category ?? 'その他').toSet().toList();
|
||||
}
|
||||
|
||||
/// カテゴリ別の商品取得
|
||||
static List<Product> getProductsByCategory(String category) {
|
||||
return products.where((p) => (p.category ?? 'その他') == category).toList();
|
||||
}
|
||||
|
||||
/// 名前またはIDで検索
|
||||
static List<Product> search(String query) {
|
||||
final q = query.toLowerCase();
|
||||
return products.where((p) =>
|
||||
p.name.toLowerCase().contains(q) ||
|
||||
p.id.toLowerCase().contains(q)
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
87
gemi_invoice/lib/models/customer_model.dart
Normal file
87
gemi_invoice/lib/models/customer_model.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 顧客情報を管理するモデル
|
||||
/// 将来的な Odoo 同期を見据えて、外部ID(odooId)を保持できるように設計
|
||||
class Customer {
|
||||
final String id; // ローカル管理用のID
|
||||
final int? odooId; // Odoo上の res.partner ID (nullの場合は未同期)
|
||||
final String displayName; // 電話帳からの表示名(検索用バッファ)
|
||||
final String formalName; // 請求書に記載する正式名称(株式会社〜 など)
|
||||
final String? zipCode; // 郵便番号
|
||||
final String? address; // 住所
|
||||
final String? department; // 部署名
|
||||
final String? title; // 敬称 (様、御中など。デフォルトは御中)
|
||||
final DateTime lastUpdatedAt; // 最終更新日時
|
||||
|
||||
Customer({
|
||||
required this.id,
|
||||
this.odooId,
|
||||
required this.displayName,
|
||||
required this.formalName,
|
||||
this.zipCode,
|
||||
this.address,
|
||||
this.department,
|
||||
this.title = '御中',
|
||||
DateTime? lastUpdatedAt,
|
||||
}) : this.lastUpdatedAt = lastUpdatedAt ?? DateTime.now();
|
||||
|
||||
/// 請求書表示用のフルネームを取得
|
||||
String get invoiceName => department != null && department!.isNotEmpty
|
||||
? "$formalName\n$department $title"
|
||||
: "$formalName $title";
|
||||
|
||||
/// 状態更新のためのコピーメソッド
|
||||
Customer copyWith({
|
||||
String? id,
|
||||
int? odooId,
|
||||
String? displayName,
|
||||
String? formalName,
|
||||
String? zipCode,
|
||||
String? address,
|
||||
String? department,
|
||||
String? title,
|
||||
DateTime? lastUpdatedAt,
|
||||
}) {
|
||||
return Customer(
|
||||
id: id ?? this.id,
|
||||
odooId: odooId ?? this.odooId,
|
||||
displayName: displayName ?? this.displayName,
|
||||
formalName: formalName ?? this.formalName,
|
||||
zipCode: zipCode ?? this.zipCode,
|
||||
address: address ?? this.address,
|
||||
department: department ?? this.department,
|
||||
title: title ?? this.title,
|
||||
lastUpdatedAt: lastUpdatedAt ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON変換 (ローカル保存・Odoo同期用)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'odoo_id': odooId,
|
||||
'display_name': displayName,
|
||||
'formal_name': formalName,
|
||||
'zip_code': zipCode,
|
||||
'address': address,
|
||||
'department': department,
|
||||
'title': title,
|
||||
'last_updated_at': lastUpdatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// JSONからモデルを生成
|
||||
factory Customer.fromJson(Map<String, dynamic> json) {
|
||||
return Customer(
|
||||
id: json['id'],
|
||||
odooId: json['odoo_id'],
|
||||
displayName: json['display_name'],
|
||||
formalName: json['formal_name'],
|
||||
zipCode: json['zip_code'],
|
||||
address: json['address'],
|
||||
department: json['department'],
|
||||
title: json['title'] ?? '御中',
|
||||
lastUpdatedAt: DateTime.parse(json['last_updated_at']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:intl/intl.dart';
|
||||
import 'customer_model.dart';
|
||||
|
||||
/// 請求書の各明細行を表すモデル
|
||||
class InvoiceItem {
|
||||
|
|
@ -27,11 +28,29 @@ class InvoiceItem {
|
|||
unitPrice: unitPrice ?? this.unitPrice,
|
||||
);
|
||||
}
|
||||
|
||||
// JSON変換
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
'unit_price': unitPrice,
|
||||
};
|
||||
}
|
||||
|
||||
// JSONから復元
|
||||
factory InvoiceItem.fromJson(Map<String, dynamic> json) {
|
||||
return InvoiceItem(
|
||||
description: json['description'] as String,
|
||||
quantity: json['quantity'] as int,
|
||||
unitPrice: json['unit_price'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 請求書全体を管理するモデル
|
||||
class Invoice {
|
||||
String clientName;
|
||||
Customer customer; // 顧客情報
|
||||
DateTime date;
|
||||
List<InvoiceItem> items;
|
||||
String? filePath; // 保存されたPDFのパス
|
||||
|
|
@ -39,7 +58,7 @@ class Invoice {
|
|||
String? notes; // 備考
|
||||
|
||||
Invoice({
|
||||
required this.clientName,
|
||||
required this.customer,
|
||||
required this.date,
|
||||
required this.items,
|
||||
this.filePath,
|
||||
|
|
@ -47,6 +66,9 @@ class Invoice {
|
|||
this.notes,
|
||||
}) : invoiceNumber = invoiceNumber ?? DateFormat('yyyyMMdd-HHmm').format(date);
|
||||
|
||||
// 互換性のためのゲッター
|
||||
String get clientName => customer.formalName;
|
||||
|
||||
// 税抜合計金額
|
||||
int get subtotal {
|
||||
return items.fold(0, (sum, item) => sum + item.subtotal);
|
||||
|
|
@ -64,7 +86,7 @@ class Invoice {
|
|||
|
||||
// 状態更新のためのコピーメソッド
|
||||
Invoice copyWith({
|
||||
String? clientName,
|
||||
Customer? customer,
|
||||
DateTime? date,
|
||||
List<InvoiceItem>? items,
|
||||
String? filePath,
|
||||
|
|
@ -72,7 +94,7 @@ class Invoice {
|
|||
String? notes,
|
||||
}) {
|
||||
return Invoice(
|
||||
clientName: clientName ?? this.clientName,
|
||||
customer: customer ?? this.customer,
|
||||
date: date ?? this.date,
|
||||
items: items ?? this.items,
|
||||
filePath: filePath ?? this.filePath,
|
||||
|
|
@ -81,13 +103,43 @@ class Invoice {
|
|||
);
|
||||
}
|
||||
|
||||
// CSV形式への変換 (将来的なCSV編集用)
|
||||
// CSV形式への変換
|
||||
String toCsv() {
|
||||
StringBuffer sb = StringBuffer();
|
||||
sb.writeln("Customer,${customer.formalName}");
|
||||
sb.writeln("Invoice Number,$invoiceNumber");
|
||||
sb.writeln("Date,${DateFormat('yyyy/MM/dd').format(date)}");
|
||||
sb.writeln("");
|
||||
sb.writeln("Description,Quantity,UnitPrice,Subtotal");
|
||||
for (var item in items) {
|
||||
sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// JSON変換 (データベース保存用)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'customer': customer.toJson(),
|
||||
'date': date.toIso8601String(),
|
||||
'items': items.map((item) => item.toJson()).toList(),
|
||||
'file_path': filePath,
|
||||
'invoice_number': invoiceNumber,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
|
||||
// JSONから復元 (データベース読み込み用)
|
||||
factory Invoice.fromJson(Map<String, dynamic> json) {
|
||||
return Invoice(
|
||||
customer: Customer.fromJson(json['customer'] as Map<String, dynamic>),
|
||||
date: DateTime.parse(json['date'] as String),
|
||||
items: (json['items'] as List)
|
||||
.map((i) => InvoiceItem.fromJson(i as Map<String, dynamic>))
|
||||
.toList(),
|
||||
filePath: json['file_path'] as String?,
|
||||
invoiceNumber: json['invoice_number'] as String,
|
||||
notes: json['notes'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
308
gemi_invoice/lib/screens/customer_picker_modal.dart
Normal file
308
gemi_invoice/lib/screens/customer_picker_modal.dart
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/customer_model.dart';
|
||||
|
||||
/// 顧客マスターからの選択、登録、編集、削除を行うモーダル
|
||||
class CustomerPickerModal extends StatefulWidget {
|
||||
final List<Customer> existingCustomers;
|
||||
final Function(Customer) onCustomerSelected;
|
||||
final Function(Customer)? onCustomerDeleted; // 削除通知用(オプション)
|
||||
|
||||
const CustomerPickerModal({
|
||||
Key? key,
|
||||
required this.existingCustomers,
|
||||
required this.onCustomerSelected,
|
||||
this.onCustomerDeleted,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CustomerPickerModal> createState() => _CustomerPickerModalState();
|
||||
}
|
||||
|
||||
class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
||||
String _searchQuery = "";
|
||||
List<Customer> _filteredCustomers = [];
|
||||
bool _isImportingFromContacts = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filteredCustomers = widget.existingCustomers;
|
||||
}
|
||||
|
||||
void _filterCustomers(String query) {
|
||||
setState(() {
|
||||
_searchQuery = query.toLowerCase();
|
||||
_filteredCustomers = widget.existingCustomers.where((customer) {
|
||||
return customer.formalName.toLowerCase().contains(_searchQuery) ||
|
||||
customer.displayName.toLowerCase().contains(_searchQuery);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// 電話帳から取り込んで新規顧客として登録・編集するダイアログ
|
||||
Future<void> _importFromPhoneContacts() async {
|
||||
setState(() => _isImportingFromContacts = true);
|
||||
try {
|
||||
if (await FlutterContacts.requestPermission(readonly: true)) {
|
||||
final contacts = await FlutterContacts.getContacts();
|
||||
if (!mounted) return;
|
||||
setState(() => _isImportingFromContacts = false);
|
||||
|
||||
final Contact? selectedContact = await showModalBottomSheet<Contact>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _PhoneContactListSelector(contacts: contacts),
|
||||
);
|
||||
|
||||
if (selectedContact != null) {
|
||||
_showCustomerEditDialog(
|
||||
displayName: selectedContact.displayName,
|
||||
initialFormalName: selectedContact.displayName,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isImportingFromContacts = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("電話帳の取得に失敗しました: $e")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顧客情報の編集・登録ダイアログ
|
||||
void _showCustomerEditDialog({
|
||||
required String displayName,
|
||||
required String initialFormalName,
|
||||
Customer? existingCustomer,
|
||||
}) {
|
||||
final formalNameController = TextEditingController(text: initialFormalName);
|
||||
final departmentController = TextEditingController(text: existingCustomer?.department ?? "");
|
||||
final addressController = TextEditingController(text: existingCustomer?.address ?? "");
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(existingCustomer == null ? "顧客の新規登録" : "顧客情報の編集"),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("電話帳名: $displayName", style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: formalNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "請求書用 正式名称",
|
||||
hintText: "株式会社 〇〇 など",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: departmentController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "部署名",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: addressController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "住所",
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final updatedCustomer = existingCustomer?.copyWith(
|
||||
formalName: formalNameController.text.trim(),
|
||||
department: departmentController.text.trim(),
|
||||
address: addressController.text.trim(),
|
||||
) ??
|
||||
Customer(
|
||||
id: const Uuid().v4(),
|
||||
displayName: displayName,
|
||||
formalName: formalNameController.text.trim(),
|
||||
department: departmentController.text.trim(),
|
||||
address: addressController.text.trim(),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
widget.onCustomerSelected(updatedCustomer);
|
||||
},
|
||||
child: const Text("保存して確定"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 削除確認ダイアログ
|
||||
void _confirmDelete(Customer customer) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("顧客の削除"),
|
||||
content: Text("「${customer.formalName}」をマスターから削除しますか?\n(過去の請求書ファイルは削除されません)"),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
if (widget.onCustomerDeleted != null) {
|
||||
widget.onCustomerDeleted!(customer);
|
||||
setState(() {
|
||||
_filterCustomers(_searchQuery); // リスト更新
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text("削除する", style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "登録済み顧客を検索...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onChanged: _filterCustomers,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
|
||||
icon: _isImportingFromContacts
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.contact_phone),
|
||||
label: const Text("電話帳から新規取り込み"),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: _filteredCustomers.isEmpty
|
||||
? const Center(child: Text("該当する顧客がいません"))
|
||||
: ListView.builder(
|
||||
itemCount: _filteredCustomers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = _filteredCustomers[index];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.business)),
|
||||
title: Text(customer.formalName),
|
||||
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
|
||||
onTap: () => widget.onCustomerSelected(customer),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
|
||||
onPressed: () => _showCustomerEditDialog(
|
||||
displayName: customer.displayName,
|
||||
initialFormalName: customer.formalName,
|
||||
existingCustomer: customer,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||||
onPressed: () => _confirmDelete(customer),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 電話帳から一人選ぶための内部ウィジェット
|
||||
class _PhoneContactListSelector extends StatefulWidget {
|
||||
final List<Contact> contacts;
|
||||
const _PhoneContactListSelector({required this.contacts});
|
||||
|
||||
@override
|
||||
State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState();
|
||||
}
|
||||
|
||||
class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> {
|
||||
List<Contact> _filtered = [];
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filtered = widget.contacts;
|
||||
}
|
||||
|
||||
void _onSearch(String q) {
|
||||
setState(() {
|
||||
_filtered = widget.contacts
|
||||
.where((c) => c.displayName.toLowerCase().contains(q.toLowerCase()))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.8,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)),
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
title: Text(_filtered[index].displayName),
|
||||
onTap: () => Navigator.pop(context, _filtered[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@ import 'package:intl/intl.dart';
|
|||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../models/customer_model.dart';
|
||||
import '../services/pdf_generator.dart';
|
||||
import 'product_picker_modal.dart';
|
||||
|
||||
class InvoiceDetailPage extends StatefulWidget {
|
||||
final Invoice invoice;
|
||||
|
|
@ -16,7 +18,7 @@ class InvoiceDetailPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||
late TextEditingController _clientController;
|
||||
late TextEditingController _formalNameController;
|
||||
late TextEditingController _notesController;
|
||||
late List<InvoiceItem> _items;
|
||||
late bool _isEditing;
|
||||
|
|
@ -28,7 +30,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
super.initState();
|
||||
_currentInvoice = widget.invoice;
|
||||
_currentFilePath = widget.invoice.filePath;
|
||||
_clientController = TextEditingController(text: _currentInvoice.clientName);
|
||||
_formalNameController = TextEditingController(text: _currentInvoice.customer.formalName);
|
||||
_notesController = TextEditingController(text: _currentInvoice.notes ?? "");
|
||||
_items = List.from(_currentInvoice.items);
|
||||
_isEditing = false;
|
||||
|
|
@ -36,7 +38,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_clientController.dispose();
|
||||
_formalNameController.dispose();
|
||||
_notesController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -53,17 +55,41 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
});
|
||||
}
|
||||
|
||||
void _pickFromMaster() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => FractionallySizedBox(
|
||||
heightFactor: 0.9,
|
||||
child: ProductPickerModal(
|
||||
onItemSelected: (item) {
|
||||
setState(() {
|
||||
_items.add(item);
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveChanges() async {
|
||||
final String clientName = _clientController.text.trim();
|
||||
if (clientName.isEmpty) {
|
||||
final String formalName = _formalNameController.text.trim();
|
||||
if (formalName.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('取引先名を入力してください')),
|
||||
const SnackBar(content: Text('取引先の正式名称を入力してください')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 顧客情報を更新
|
||||
final updatedCustomer = _currentInvoice.customer.copyWith(
|
||||
formalName: formalName,
|
||||
);
|
||||
|
||||
final updatedInvoice = _currentInvoice.copyWith(
|
||||
clientName: clientName,
|
||||
customer: updatedCustomer,
|
||||
items: _items,
|
||||
notes: _notesController.text,
|
||||
);
|
||||
|
|
@ -84,7 +110,6 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
|
||||
void _exportCsv() {
|
||||
final csvData = _currentInvoice.toCsv();
|
||||
// 実際にはファイル保存ダイアログなどを出すのが望ましいが、ここでは簡易的に共有
|
||||
Share.share(csvData, subject: '請求書データ_CSV');
|
||||
}
|
||||
|
||||
|
|
@ -119,10 +144,25 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
if (_isEditing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _addItem,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("行を追加"),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _addItem,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("空の行を追加"),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _pickFromMaster,
|
||||
icon: const Icon(Icons.list_alt),
|
||||
label: const Text("マスターから選択"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueGrey.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
|
@ -136,13 +176,14 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
}
|
||||
|
||||
Widget _buildHeaderSection() {
|
||||
final dateFormatter = DateFormat('yyyy年MM月dd日');
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_isEditing) ...[
|
||||
TextField(
|
||||
controller: _clientController,
|
||||
decoration: const InputDecoration(labelText: "取引先名", border: OutlineInputBorder()),
|
||||
controller: _formalNameController,
|
||||
decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
|
|
@ -151,9 +192,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()),
|
||||
),
|
||||
] else ...[
|
||||
Text("宛名: ${_currentInvoice.clientName} 御中", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}",
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
||||
Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
Text("請求番号: ${_currentInvoice.invoiceNumber}"),
|
||||
Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"),
|
||||
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
// lib/screens/invoice_input_screen.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/customer_model.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../services/pdf_generator.dart';
|
||||
import '../services/invoice_repository.dart';
|
||||
import 'customer_picker_modal.dart';
|
||||
|
||||
/// 請求書の初期入力(ヘッダー部分)を管理するウィジェット
|
||||
class InvoiceInputForm extends StatefulWidget {
|
||||
|
|
@ -18,9 +20,32 @@ class InvoiceInputForm extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||
final _clientController = TextEditingController(text: "佐々木製作所");
|
||||
final _clientController = TextEditingController();
|
||||
final _amountController = TextEditingController(text: "250000");
|
||||
String _status = "取引先と基本金額を入力してPDFを生成してください";
|
||||
final _repository = InvoiceRepository();
|
||||
String _status = "取引先を選択してPDFを生成してください";
|
||||
|
||||
List<Customer> _customerBuffer = [];
|
||||
Customer? _selectedCustomer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedCustomer = Customer(
|
||||
id: const Uuid().v4(),
|
||||
displayName: "佐々木製作所",
|
||||
formalName: "株式会社 佐々木製作所",
|
||||
);
|
||||
_customerBuffer.add(_selectedCustomer!);
|
||||
_clientController.text = _selectedCustomer!.formalName;
|
||||
|
||||
// 起動時に不要なPDFを掃除する
|
||||
_repository.cleanupOrphanedPdfs().then((count) {
|
||||
if (count > 0) {
|
||||
debugPrint('Cleaned up $count orphaned PDF files.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -29,64 +54,43 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
// 連絡先を選択する処理
|
||||
Future<void> _pickContact() async {
|
||||
setState(() => _status = "連絡先をスキャン中...");
|
||||
try {
|
||||
if (await FlutterContacts.requestPermission(readonly: true)) {
|
||||
final List<Contact> contacts = await FlutterContacts.getContacts(
|
||||
withProperties: false,
|
||||
withThumbnail: false,
|
||||
);
|
||||
Future<void> _openCustomerPicker() async {
|
||||
setState(() => _status = "顧客マスターを開いています...");
|
||||
|
||||
if (!mounted) return;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => FractionallySizedBox(
|
||||
heightFactor: 0.9,
|
||||
child: CustomerPickerModal(
|
||||
existingCustomers: _customerBuffer,
|
||||
onCustomerSelected: (customer) {
|
||||
setState(() {
|
||||
bool exists = _customerBuffer.any((c) => c.id == customer.id);
|
||||
if (!exists) {
|
||||
_customerBuffer.add(customer);
|
||||
}
|
||||
|
||||
if (contacts.isEmpty) {
|
||||
setState(() => _status = "連絡先が空、または取得できませんでした。");
|
||||
return;
|
||||
}
|
||||
|
||||
contacts.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
final Contact? selected = await showModalBottomSheet<Contact>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext modalContext) => FractionallySizedBox(
|
||||
heightFactor: 0.8,
|
||||
child: ContactPickerModal(
|
||||
contacts: contacts,
|
||||
onContactSelected: (selectedContact) {
|
||||
Navigator.pop(modalContext, selectedContact);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
setState(() {
|
||||
_clientController.text = selected.displayName;
|
||||
_status = "「${selected.displayName}」をセットしました";
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() => _status = "電話帳の権限が拒否されています。");
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _status = "エラーが発生しました: $e");
|
||||
}
|
||||
_selectedCustomer = customer;
|
||||
_clientController.text = customer.formalName;
|
||||
_status = "「${customer.formalName}」を選択しました";
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 初期PDFを生成して保存する処理(ここから詳細ページへ遷移する)
|
||||
Future<void> _handleInitialGenerate() async {
|
||||
final clientName = _clientController.text.trim();
|
||||
final unitPrice = int.tryParse(_amountController.text) ?? 0;
|
||||
|
||||
if (clientName.isEmpty) {
|
||||
setState(() => _status = "取引先名を入力してください");
|
||||
if (_selectedCustomer == null) {
|
||||
setState(() => _status = "取引先を選択してください");
|
||||
return;
|
||||
}
|
||||
|
||||
// 初期の1行明細を作成
|
||||
final unitPrice = int.tryParse(_amountController.text) ?? 0;
|
||||
|
||||
final initialItems = [
|
||||
InvoiceItem(
|
||||
description: "ご請求分",
|
||||
|
|
@ -96,7 +100,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
];
|
||||
|
||||
final invoice = Invoice(
|
||||
clientName: clientName,
|
||||
customer: _selectedCustomer!,
|
||||
date: DateTime.now(),
|
||||
items: initialItems,
|
||||
);
|
||||
|
|
@ -106,8 +110,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
|
||||
if (path != null) {
|
||||
final updatedInvoice = invoice.copyWith(filePath: path);
|
||||
|
||||
// オリジナルDBに保存
|
||||
await _repository.saveInvoice(updatedInvoice);
|
||||
|
||||
widget.onInvoiceGenerated(updatedInvoice, path);
|
||||
setState(() => _status = "PDFを生成しました。詳細ページで表編集が可能です。");
|
||||
setState(() => _status = "PDFを生成しDBに登録しました。");
|
||||
} else {
|
||||
setState(() => _status = "PDFの生成に失敗しました");
|
||||
}
|
||||
|
|
@ -119,21 +127,30 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
padding: const EdgeInsets.all(16.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(children: [
|
||||
const Text(
|
||||
"ステップ1: 宛先と基本金額の設定",
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _clientController,
|
||||
readOnly: true,
|
||||
onTap: _openCustomerPicker,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "取引先名",
|
||||
hintText: "会社名や個人名",
|
||||
labelText: "取引先名 (タップして選択)",
|
||||
hintText: "電話帳から取り込むか、マスターから選択",
|
||||
prefixIcon: Icon(Icons.business),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search, color: Colors.blue, size: 40),
|
||||
onPressed: _pickContact,
|
||||
icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40),
|
||||
onPressed: _openCustomerPicker,
|
||||
tooltip: "顧客を選択・登録",
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
|
|
@ -142,7 +159,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "基本金額 (税抜)",
|
||||
hintText: "後で詳細ページで変更・追加できます",
|
||||
hintText: "明細の1行目として登録されます",
|
||||
prefixIcon: Icon(Icons.currency_yen),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
|
|
@ -155,6 +173,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
minimumSize: const Size(double.infinity, 60),
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
|
@ -164,6 +184,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Text(
|
||||
_status,
|
||||
|
|
@ -176,79 +197,3 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 連絡先選択用のモーダルウィジェット
|
||||
class ContactPickerModal extends StatefulWidget {
|
||||
final List<Contact> contacts;
|
||||
final Function(Contact) onContactSelected;
|
||||
|
||||
const ContactPickerModal({
|
||||
Key? key,
|
||||
required this.contacts,
|
||||
required this.onContactSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ContactPickerModal> createState() => _ContactPickerModalState();
|
||||
}
|
||||
|
||||
class _ContactPickerModalState extends State<ContactPickerModal> {
|
||||
String _searchQuery = "";
|
||||
List<Contact> _filteredContacts = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filteredContacts = widget.contacts;
|
||||
}
|
||||
|
||||
void _filterContacts(String query) {
|
||||
setState(() {
|
||||
_searchQuery = query.toLowerCase();
|
||||
_filteredContacts = widget.contacts
|
||||
.where((c) => c.displayName.toLowerCase().contains(_searchQuery))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"取引先を選択 (${_filteredContacts.length}件)",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "名前で検索...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8.0)),
|
||||
),
|
||||
onChanged: _filterContacts,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _filteredContacts.length,
|
||||
itemBuilder: (c, i) => ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.person)),
|
||||
title: Text(_filteredContacts[i].displayName),
|
||||
onTap: () => widget.onContactSelected(_filteredContacts[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
255
gemi_invoice/lib/screens/product_picker_modal.dart
Normal file
255
gemi_invoice/lib/screens/product_picker_modal.dart
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../data/product_master.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
|
||||
/// 商品マスターの選択・登録・編集・削除を行うモーダル
|
||||
class ProductPickerModal extends StatefulWidget {
|
||||
final Function(InvoiceItem) onItemSelected;
|
||||
|
||||
const ProductPickerModal({
|
||||
Key? key,
|
||||
required this.onItemSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ProductPickerModal> createState() => _ProductPickerModalState();
|
||||
}
|
||||
|
||||
class _ProductPickerModalState extends State<ProductPickerModal> {
|
||||
String _searchQuery = "";
|
||||
List<Product> _masterProducts = [];
|
||||
List<Product> _filteredProducts = [];
|
||||
String _selectedCategory = "すべて";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 本来は永続化層から取得するが、現在はProductMasterの初期データを使用
|
||||
_masterProducts = List.from(ProductMaster.products);
|
||||
_filterProducts();
|
||||
}
|
||||
|
||||
void _filterProducts() {
|
||||
setState(() {
|
||||
_filteredProducts = _masterProducts.where((product) {
|
||||
final matchesQuery = product.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
||||
product.id.toLowerCase().contains(_searchQuery.toLowerCase());
|
||||
final matchesCategory = _selectedCategory == "すべて" || (product.category == _selectedCategory);
|
||||
return matchesQuery && matchesCategory;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// 商品の編集・新規登録用ダイアログ
|
||||
void _showProductEditDialog({Product? existingProduct}) {
|
||||
final idController = TextEditingController(text: existingProduct?.id ?? "");
|
||||
final nameController = TextEditingController(text: existingProduct?.name ?? "");
|
||||
final priceController = TextEditingController(text: existingProduct?.defaultUnitPrice.toString() ?? "");
|
||||
final categoryController = TextEditingController(text: existingProduct?.category ?? "");
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(existingProduct == null ? "新規商品の登録" : "商品情報の編集"),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (existingProduct == null)
|
||||
TextField(
|
||||
controller: idController,
|
||||
decoration: const InputDecoration(labelText: "商品コード (例: S001)", border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: "商品名", border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: priceController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: "標準単価", border: OutlineInputBorder()),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: categoryController,
|
||||
decoration: const InputDecoration(labelText: "カテゴリ (任意)", border: OutlineInputBorder()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final String name = nameController.text.trim();
|
||||
final int price = int.tryParse(priceController.text) ?? 0;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
if (existingProduct != null) {
|
||||
// 更新
|
||||
final index = _masterProducts.indexWhere((p) => p.id == existingProduct.id);
|
||||
if (index != -1) {
|
||||
_masterProducts[index] = existingProduct.copyWith(
|
||||
name: name,
|
||||
defaultUnitPrice: price,
|
||||
category: categoryController.text.trim(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 新規追加
|
||||
_masterProducts.add(Product(
|
||||
id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text,
|
||||
name: name,
|
||||
defaultUnitPrice: price,
|
||||
category: categoryController.text.trim(),
|
||||
));
|
||||
}
|
||||
_filterProducts();
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text("保存"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 削除確認
|
||||
void _confirmDelete(Product product) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("商品の削除"),
|
||||
content: Text("「${product.name}」をマスターから削除しますか?"),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_masterProducts.removeWhere((p) => p.id == product.id);
|
||||
_filterProducts();
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text("削除する", style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// マスター内のカテゴリを動的に取得
|
||||
final dynamicCategories = ["すべて", ..._masterProducts.map((p) => p.category ?? 'その他').toSet().toList()];
|
||||
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("商品マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "商品名やコードで検索...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
),
|
||||
onChanged: (val) {
|
||||
_searchQuery = val;
|
||||
_filterProducts();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: dynamicCategories.map((cat) {
|
||||
final isSelected = _selectedCategory == cat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ChoiceChip(
|
||||
label: Text(cat),
|
||||
selected: isSelected,
|
||||
onSelected: (s) {
|
||||
if (s) {
|
||||
setState(() {
|
||||
_selectedCategory = cat;
|
||||
_filterProducts();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
onPressed: () => _showProductEditDialog(),
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: "新規商品を追加",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: _filteredProducts.isEmpty
|
||||
? const Center(child: Text("該当する商品がありません"))
|
||||
: ListView.separated(
|
||||
itemCount: _filteredProducts.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final product = _filteredProducts[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.inventory_2, color: Colors.blueGrey),
|
||||
title: Text(product.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text("${product.id} | ¥${product.defaultUnitPrice}"),
|
||||
onTap: () => widget.onItemSelected(product.toInvoiceItem()),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined, size: 20, color: Colors.blueGrey),
|
||||
onPressed: () => _showProductEditDialog(existingProduct: product),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent),
|
||||
onPressed: () => _confirmDelete(product),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
gemi_invoice/lib/services/invoice_repository.dart
Normal file
107
gemi_invoice/lib/services/invoice_repository.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
|
||||
/// 請求書のオリジナルデータを管理するリポジトリ(簡易DB)
|
||||
/// PDFファイルとデータの整合性を保つための機能を提供します
|
||||
class InvoiceRepository {
|
||||
static const String _dbFileName = 'invoices_db.json';
|
||||
|
||||
/// データベースファイルのパスを取得
|
||||
Future<File> _getDbFile() async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
return File('${directory.path}/$_dbFileName');
|
||||
}
|
||||
|
||||
/// 全ての請求書データを読み込む
|
||||
Future<List<Invoice>> getAllInvoices() async {
|
||||
try {
|
||||
final file = await _getDbFile();
|
||||
if (!await file.exists()) return [];
|
||||
|
||||
final String content = await file.readAsString();
|
||||
final List<dynamic> jsonList = json.decode(content);
|
||||
|
||||
return jsonList.map((json) => Invoice.fromJson(json)).toList()
|
||||
..sort((a, b) => b.date.compareTo(a.date)); // 新しい順にソート
|
||||
} catch (e) {
|
||||
print('DB Loading Error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 請求書データを保存・更新する
|
||||
Future<void> saveInvoice(Invoice invoice) async {
|
||||
final List<Invoice> all = await getAllInvoices();
|
||||
|
||||
// 同じ請求番号があれば差し替え、なければ追加
|
||||
final index = all.indexWhere((i) => i.invoiceNumber == invoice.invoiceNumber);
|
||||
if (index != -1) {
|
||||
// 古いファイルが存在し、かつ新しいパスと異なる場合は古いファイルを削除(無駄なPDFの掃除)
|
||||
final oldPath = all[index].filePath;
|
||||
if (oldPath != null && oldPath != invoice.filePath) {
|
||||
await _deletePhysicalFile(oldPath);
|
||||
}
|
||||
all[index] = invoice;
|
||||
} else {
|
||||
all.add(invoice);
|
||||
}
|
||||
|
||||
final file = await _getDbFile();
|
||||
await file.writeAsString(json.encode(all.map((i) => i.toJson()).toList()));
|
||||
}
|
||||
|
||||
/// 請求書データを削除する
|
||||
Future<void> deleteInvoice(Invoice invoice) async {
|
||||
final List<Invoice> all = await getAllInvoices();
|
||||
all.removeWhere((i) => i.invoiceNumber == invoice.invoiceNumber);
|
||||
|
||||
// 物理ファイルも削除
|
||||
if (invoice.filePath != null) {
|
||||
await _deletePhysicalFile(invoice.filePath!);
|
||||
}
|
||||
|
||||
final file = await _getDbFile();
|
||||
await file.writeAsString(json.encode(all.map((i) => i.toJson()).toList()));
|
||||
}
|
||||
|
||||
/// 実際のPDFファイルをストレージから削除する
|
||||
Future<void> _deletePhysicalFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
print('Physical file deleted: $path');
|
||||
}
|
||||
} catch (e) {
|
||||
print('File Deletion Error: $path, $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// DBに登録されていない「浮いたPDFファイル」をスキャンして掃除する
|
||||
Future<int> cleanupOrphanedPdfs() async {
|
||||
final List<Invoice> all = await getAllInvoices();
|
||||
final Set<String> registeredPaths = all
|
||||
.where((i) => i.filePath != null)
|
||||
.map((i) => i.filePath!)
|
||||
.toSet();
|
||||
|
||||
final directory = await getExternalStorageDirectory();
|
||||
if (directory == null) return 0;
|
||||
|
||||
int deletedCount = 0;
|
||||
final List<FileSystemEntity> files = directory.listSync();
|
||||
|
||||
for (var entity in files) {
|
||||
if (entity is File && entity.path.endsWith('.pdf')) {
|
||||
// DBに登録されていないPDFは削除(無駄なゴミ)
|
||||
if (!registeredPaths.contains(entity.path)) {
|
||||
await entity.delete();
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
decoration: const pw.BoxDecoration(
|
||||
border: pw.Border(bottom: pw.BorderSide(width: 1)),
|
||||
),
|
||||
child: pw.Text("${invoice.clientName} 御中",
|
||||
child: pw.Text(invoice.customer.invoiceName,
|
||||
style: const pw.TextStyle(fontSize: 18)),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
|
|
@ -169,7 +169,7 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
final Uint8List bytes = await pdf.save();
|
||||
final String hash = sha256.convert(bytes).toString().substring(0, 8);
|
||||
final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date);
|
||||
String fileName = "Invoice_${dateFileStr}_${invoice.clientName}_$hash.pdf";
|
||||
String fileName = "Invoice_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf";
|
||||
|
||||
final directory = await getExternalStorageDirectory();
|
||||
if (directory == null) return null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue