顧客マスター

This commit is contained in:
joe 2026-01-31 23:00:53 +09:00
parent 3128ab5579
commit 37f66413fe
9 changed files with 1057 additions and 159 deletions

View file

@ -0,0 +1,99 @@
import '../models/invoice_models.dart';
///
/// Odoo IDodooId
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();
}
}

View file

@ -0,0 +1,87 @@
import 'package:intl/intl.dart';
///
/// Odoo IDodooId
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']),
);
}
}

View file

@ -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?,
);
}
}

View 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]),
),
),
),
],
),
);
}
}

View file

@ -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(
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text("行を追加"),
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)),

View file

@ -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;
if (contacts.isEmpty) {
setState(() => _status = "連絡先が空、または取得できませんでした。");
return;
}
contacts.sort((a, b) => a.displayName.compareTo(b.displayName));
final Contact? selected = await showModalBottomSheet<Contact>(
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (BuildContext modalContext) => FractionallySizedBox(
heightFactor: 0.8,
child: ContactPickerModal(
contacts: contacts,
onContactSelected: (selectedContact) {
Navigator.pop(modalContext, selectedContact);
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);
}
_selectedCustomer = customer;
_clientController.text = customer.formalName;
_status = "${customer.formalName}」を選択しました";
});
Navigator.pop(context);
},
),
),
);
if (selected != null) {
setState(() {
_clientController.text = selected.displayName;
_status = "${selected.displayName}」をセットしました";
});
}
} else {
setState(() => _status = "電話帳の権限が拒否されています。");
}
} catch (e) {
setState(() => _status = "エラーが発生しました: $e");
}
}
// 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]),
),
),
),
],
),
);
}
}

View 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),
),
],
),
);
},
),
),
],
),
);
}
}

View 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;
}
}

View file

@ -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;