Work in progress before switching to main
This commit is contained in:
parent
bab533fccb
commit
421bcc01f6
18 changed files with 1106 additions and 413 deletions
3
gitremotepush.sh
Normal file
3
gitremotepush.sh
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
git remote add origin git@git.cyberius.biz:joe/h-1.flutter.0.git
|
||||||
|
git push -u origin main
|
||||||
|
|
||||||
|
|
@ -4,8 +4,8 @@ FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0
|
||||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||||
FLUTTER_TARGET=lib/main.dart
|
FLUTTER_TARGET=lib/main.dart
|
||||||
FLUTTER_BUILD_DIR=build
|
FLUTTER_BUILD_DIR=build
|
||||||
FLUTTER_BUILD_NAME=1.5.0
|
FLUTTER_BUILD_NAME=1.5.06
|
||||||
FLUTTER_BUILD_NUMBER=150
|
FLUTTER_BUILD_NUMBER=151
|
||||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
||||||
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
||||||
DART_OBFUSCATION=false
|
DART_OBFUSCATION=false
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0"
|
||||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||||
export "FLUTTER_TARGET=lib/main.dart"
|
export "FLUTTER_TARGET=lib/main.dart"
|
||||||
export "FLUTTER_BUILD_DIR=build"
|
export "FLUTTER_BUILD_DIR=build"
|
||||||
export "FLUTTER_BUILD_NAME=1.5.0"
|
export "FLUTTER_BUILD_NAME=1.5.06"
|
||||||
export "FLUTTER_BUILD_NUMBER=150"
|
export "FLUTTER_BUILD_NUMBER=151"
|
||||||
export "DART_OBFUSCATION=false"
|
export "DART_OBFUSCATION=false"
|
||||||
export "TRACK_WIDGET_CREATION=true"
|
export "TRACK_WIDGET_CREATION=true"
|
||||||
export "TREE_SHAKE_ICONS=false"
|
export "TREE_SHAKE_ICONS=false"
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,15 @@ class MyApp extends StatelessWidget {
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
fontFamily: 'IPAexGothic',
|
fontFamily: 'IPAexGothic',
|
||||||
),
|
),
|
||||||
|
builder: (context, child) {
|
||||||
|
return InteractiveViewer(
|
||||||
|
panEnabled: false,
|
||||||
|
scaleEnabled: true,
|
||||||
|
minScale: 0.8,
|
||||||
|
maxScale: 2.0,
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
},
|
||||||
home: const InvoiceHistoryScreen(),
|
home: const InvoiceHistoryScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
lib/models/customer_contact.dart
Normal file
47
lib/models/customer_contact.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
class CustomerContact {
|
||||||
|
final String id;
|
||||||
|
final String customerId;
|
||||||
|
final String? email;
|
||||||
|
final String? tel;
|
||||||
|
final String? address;
|
||||||
|
final int version;
|
||||||
|
final bool isActive;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
CustomerContact({
|
||||||
|
required this.id,
|
||||||
|
required this.customerId,
|
||||||
|
this.email,
|
||||||
|
this.tel,
|
||||||
|
this.address,
|
||||||
|
required this.version,
|
||||||
|
this.isActive = true,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) : createdAt = createdAt ?? DateTime.now();
|
||||||
|
|
||||||
|
factory CustomerContact.fromMap(Map<String, dynamic> map) {
|
||||||
|
return CustomerContact(
|
||||||
|
id: map['id'],
|
||||||
|
customerId: map['customer_id'],
|
||||||
|
email: map['email'],
|
||||||
|
tel: map['tel'],
|
||||||
|
address: map['address'],
|
||||||
|
version: map['version'],
|
||||||
|
isActive: (map['is_active'] ?? 0) == 1,
|
||||||
|
createdAt: DateTime.parse(map['created_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'customer_id': customerId,
|
||||||
|
'email': email,
|
||||||
|
'tel': tel,
|
||||||
|
'address': address,
|
||||||
|
'version': version,
|
||||||
|
'is_active': isActive ? 1 : 0,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,10 @@ class Customer {
|
||||||
final String formalName; // 請求書用正式名称
|
final String formalName; // 請求書用正式名称
|
||||||
final String title; // 敬称(様、殿など)
|
final String title; // 敬称(様、殿など)
|
||||||
final String? department; // 部署名
|
final String? department; // 部署名
|
||||||
final String? address; // 住所
|
final String? address; // 住所(最新連絡先)
|
||||||
final String? tel; // 電話番号
|
final String? tel; // 電話番号(最新連絡先)
|
||||||
|
final String? email; // メール(最新連絡先)
|
||||||
|
final int? contactVersionId; // 連絡先バージョン
|
||||||
final String? odooId; // Odoo側のID
|
final String? odooId; // Odoo側のID
|
||||||
final bool isSynced; // 同期フラグ
|
final bool isSynced; // 同期フラグ
|
||||||
final DateTime updatedAt; // 最終更新日時
|
final DateTime updatedAt; // 最終更新日時
|
||||||
|
|
@ -20,6 +22,8 @@ class Customer {
|
||||||
this.department,
|
this.department,
|
||||||
this.address,
|
this.address,
|
||||||
this.tel,
|
this.tel,
|
||||||
|
this.email,
|
||||||
|
this.contactVersionId,
|
||||||
this.odooId,
|
this.odooId,
|
||||||
this.isSynced = false,
|
this.isSynced = false,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
|
@ -43,6 +47,7 @@ class Customer {
|
||||||
'department': department,
|
'department': department,
|
||||||
'address': address,
|
'address': address,
|
||||||
'tel': tel,
|
'tel': tel,
|
||||||
|
'contact_version_id': contactVersionId,
|
||||||
'odoo_id': odooId,
|
'odoo_id': odooId,
|
||||||
'is_locked': isLocked ? 1 : 0,
|
'is_locked': isLocked ? 1 : 0,
|
||||||
'is_synced': isSynced ? 1 : 0,
|
'is_synced': isSynced ? 1 : 0,
|
||||||
|
|
@ -57,8 +62,10 @@ class Customer {
|
||||||
formalName: map['formal_name'],
|
formalName: map['formal_name'],
|
||||||
title: map['title'] ?? "様",
|
title: map['title'] ?? "様",
|
||||||
department: map['department'],
|
department: map['department'],
|
||||||
address: map['address'],
|
address: map['contact_address'] ?? map['address'],
|
||||||
tel: map['tel'],
|
tel: map['contact_tel'] ?? map['tel'],
|
||||||
|
email: map['contact_email'],
|
||||||
|
contactVersionId: map['contact_version_id'],
|
||||||
odooId: map['odoo_id'],
|
odooId: map['odoo_id'],
|
||||||
isLocked: (map['is_locked'] ?? 0) == 1,
|
isLocked: (map['is_locked'] ?? 0) == 1,
|
||||||
isSynced: map['is_synced'] == 1,
|
isSynced: map['is_synced'] == 1,
|
||||||
|
|
@ -78,6 +85,8 @@ class Customer {
|
||||||
bool? isSynced,
|
bool? isSynced,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
bool? isLocked,
|
bool? isLocked,
|
||||||
|
String? email,
|
||||||
|
int? contactVersionId,
|
||||||
}) {
|
}) {
|
||||||
return Customer(
|
return Customer(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -87,6 +96,8 @@ class Customer {
|
||||||
department: department ?? this.department,
|
department: department ?? this.department,
|
||||||
address: address ?? this.address,
|
address: address ?? this.address,
|
||||||
tel: tel ?? this.tel,
|
tel: tel ?? this.tel,
|
||||||
|
email: email ?? this.email,
|
||||||
|
contactVersionId: contactVersionId ?? this.contactVersionId,
|
||||||
odooId: odooId ?? this.odooId,
|
odooId: odooId ?? this.odooId,
|
||||||
isSynced: isSynced ?? this.isSynced,
|
isSynced: isSynced ?? this.isSynced,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,10 @@ class Invoice {
|
||||||
final bool isDraft; // 追加: 下書きフラグ
|
final bool isDraft; // 追加: 下書きフラグ
|
||||||
final String? subject; // 追加: 案件名
|
final String? subject; // 追加: 案件名
|
||||||
final bool isLocked; // 追加: ロック
|
final bool isLocked; // 追加: ロック
|
||||||
|
final int? contactVersionId; // 追加: 連絡先バージョン
|
||||||
|
final String? contactEmailSnapshot;
|
||||||
|
final String? contactTelSnapshot;
|
||||||
|
final String? contactAddressSnapshot;
|
||||||
|
|
||||||
Invoice({
|
Invoice({
|
||||||
String? id,
|
String? id,
|
||||||
|
|
@ -104,6 +108,10 @@ class Invoice {
|
||||||
this.isDraft = false, // 追加: デフォルトは通常
|
this.isDraft = false, // 追加: デフォルトは通常
|
||||||
this.subject, // 追加: 案件
|
this.subject, // 追加: 案件
|
||||||
this.isLocked = false,
|
this.isLocked = false,
|
||||||
|
this.contactVersionId,
|
||||||
|
this.contactEmailSnapshot,
|
||||||
|
this.contactTelSnapshot,
|
||||||
|
this.contactAddressSnapshot,
|
||||||
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
||||||
updatedAt = updatedAt ?? DateTime.now();
|
updatedAt = updatedAt ?? DateTime.now();
|
||||||
|
|
@ -163,6 +171,10 @@ class Invoice {
|
||||||
'is_draft': isDraft ? 1 : 0, // 追加
|
'is_draft': isDraft ? 1 : 0, // 追加
|
||||||
'subject': subject, // 追加
|
'subject': subject, // 追加
|
||||||
'is_locked': isLocked ? 1 : 0,
|
'is_locked': isLocked ? 1 : 0,
|
||||||
|
'contact_version_id': contactVersionId,
|
||||||
|
'contact_email_snapshot': contactEmailSnapshot,
|
||||||
|
'contact_tel_snapshot': contactTelSnapshot,
|
||||||
|
'contact_address_snapshot': contactAddressSnapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,6 +197,10 @@ class Invoice {
|
||||||
bool? isDraft,
|
bool? isDraft,
|
||||||
String? subject,
|
String? subject,
|
||||||
bool? isLocked,
|
bool? isLocked,
|
||||||
|
int? contactVersionId,
|
||||||
|
String? contactEmailSnapshot,
|
||||||
|
String? contactTelSnapshot,
|
||||||
|
String? contactAddressSnapshot,
|
||||||
}) {
|
}) {
|
||||||
return Invoice(
|
return Invoice(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -205,6 +221,10 @@ class Invoice {
|
||||||
isDraft: isDraft ?? this.isDraft,
|
isDraft: isDraft ?? this.isDraft,
|
||||||
subject: subject ?? this.subject,
|
subject: subject ?? this.subject,
|
||||||
isLocked: isLocked ?? this.isLocked,
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
contactVersionId: contactVersionId ?? this.contactVersionId,
|
||||||
|
contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot,
|
||||||
|
contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot,
|
||||||
|
contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,47 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
_loadCustomers();
|
_loadCustomers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showContactUpdateDialog(Customer customer) async {
|
||||||
|
final emailController = TextEditingController(text: customer.email ?? "");
|
||||||
|
final telController = TextEditingController(text: customer.tel ?? "");
|
||||||
|
final addressController = TextEditingController(text: customer.address ?? "");
|
||||||
|
|
||||||
|
final updated = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('連絡先を更新'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(controller: emailController, decoration: const InputDecoration(labelText: 'メール')),
|
||||||
|
TextField(controller: telController, decoration: const InputDecoration(labelText: '電話番号'), keyboardType: TextInputType.phone),
|
||||||
|
TextField(controller: addressController, decoration: const InputDecoration(labelText: '住所')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await _customerRepo.updateContact(
|
||||||
|
customerId: customer.id,
|
||||||
|
email: emailController.text.isEmpty ? null : emailController.text,
|
||||||
|
tel: telController.text.isEmpty ? null : telController.text,
|
||||||
|
address: addressController.text.isEmpty ? null : addressController.text,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
},
|
||||||
|
child: const Text('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updated == true) {
|
||||||
|
_loadCustomers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadCustomers() async {
|
Future<void> _loadCustomers() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final customers = await _customerRepo.getAllCustomers();
|
final customers = await _customerRepo.getAllCustomers();
|
||||||
|
|
@ -130,6 +171,165 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showPhonebookImport() async {
|
||||||
|
// 疑似電話帳データ(会社名/氏名/複数住所)
|
||||||
|
final phonebook = [
|
||||||
|
{
|
||||||
|
'company': '佐々木製作所',
|
||||||
|
'person': '佐々木 太郎',
|
||||||
|
'addresses': ['大阪府大阪市北区1-1-1', '東京都千代田区丸の内2-2-2'],
|
||||||
|
'tel': '06-1234-5678',
|
||||||
|
'emails': ['info@sasaki.co.jp', 'taro@sasaki.co.jp'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'company': 'Gemini Solutions',
|
||||||
|
'person': 'John Smith',
|
||||||
|
'addresses': ['1 Infinite Loop, CA', '1600 Amphitheatre Pkwy, CA'],
|
||||||
|
'tel': '03-9876-5432',
|
||||||
|
'emails': ['contact@gemini.com', 'john.smith@gemini.com'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
String selectedEntryId = '0';
|
||||||
|
String selectedNameSource = 'company';
|
||||||
|
int selectedAddressIndex = 0;
|
||||||
|
int selectedEmailIndex = 0;
|
||||||
|
|
||||||
|
final imported = await showDialog<Customer>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) {
|
||||||
|
final entry = phonebook[int.parse(selectedEntryId)];
|
||||||
|
final addresses = (entry['addresses'] as List<String>);
|
||||||
|
final emails = (entry['emails'] as List<String>);
|
||||||
|
final displayName = selectedNameSource == 'company' ? entry['company'] as String : entry['person'] as String;
|
||||||
|
final formalName = selectedNameSource == 'company'
|
||||||
|
? '株式会社 ${entry['company']}'
|
||||||
|
: '${entry['person']} 様';
|
||||||
|
final addressText = addresses[selectedAddressIndex];
|
||||||
|
final emailText = emails.isNotEmpty ? emails[selectedEmailIndex] : '';
|
||||||
|
|
||||||
|
final displayController = TextEditingController(text: displayName);
|
||||||
|
final formalController = TextEditingController(text: formalName);
|
||||||
|
final addressController = TextEditingController(text: addressText);
|
||||||
|
final emailController = TextEditingController(text: emailText);
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('電話帳から取り込む'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: selectedEntryId,
|
||||||
|
decoration: const InputDecoration(labelText: '電話帳エントリ'),
|
||||||
|
items: phonebook
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) => DropdownMenuItem(value: e.key.toString(), child: Text(e.value['company'] as String)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) {
|
||||||
|
setDialogState(() {
|
||||||
|
selectedEntryId = v ?? '0';
|
||||||
|
selectedAddressIndex = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('顧客名の取り込み元'),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<String>(
|
||||||
|
dense: true,
|
||||||
|
title: const Text('会社名'),
|
||||||
|
value: 'company',
|
||||||
|
groupValue: selectedNameSource,
|
||||||
|
onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'company'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<String>(
|
||||||
|
dense: true,
|
||||||
|
title: const Text('氏名'),
|
||||||
|
value: 'person',
|
||||||
|
groupValue: selectedNameSource,
|
||||||
|
onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'person'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
value: selectedAddressIndex,
|
||||||
|
decoration: const InputDecoration(labelText: '住所を選択'),
|
||||||
|
items: addresses
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setDialogState(() => selectedAddressIndex = v ?? 0),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<int>(
|
||||||
|
value: selectedEmailIndex,
|
||||||
|
decoration: const InputDecoration(labelText: 'メールを選択'),
|
||||||
|
items: emails
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setDialogState(() => selectedEmailIndex = v ?? 0),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: displayController,
|
||||||
|
decoration: const InputDecoration(labelText: '表示名(編集可)'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: formalController,
|
||||||
|
decoration: const InputDecoration(labelText: '正式名称(編集可)'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: addressController,
|
||||||
|
decoration: const InputDecoration(labelText: '住所(編集可)'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: emailController,
|
||||||
|
decoration: const InputDecoration(labelText: 'メール(編集可)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final newCustomer = Customer(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
displayName: displayController.text,
|
||||||
|
formalName: formalController.text,
|
||||||
|
title: selectedNameSource == 'company' ? '御中' : '様',
|
||||||
|
address: addressController.text,
|
||||||
|
tel: entry['tel'] as String?,
|
||||||
|
email: emailController.text.isEmpty ? null : emailController.text,
|
||||||
|
isSynced: false,
|
||||||
|
);
|
||||||
|
Navigator.pop(context, newCustomer);
|
||||||
|
},
|
||||||
|
child: const Text('取り込む'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imported != null) {
|
||||||
|
await _customerRepo.saveCustomer(imported);
|
||||||
|
_loadCustomers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -210,10 +410,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => _addOrEditCustomer(),
|
onPressed: _showPhonebookImport,
|
||||||
child: const Icon(Icons.person_add),
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('電話帳から取り込む'),
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: Colors.indigo,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -250,19 +452,31 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (c.address != null) Text("住所: ${c.address}") else const SizedBox.shrink(),
|
if (c.address != null) Text("住所: ${c.address}") else const SizedBox.shrink(),
|
||||||
if (c.tel != null) Text("TEL: ${c.tel}") else const SizedBox.shrink(),
|
if (c.tel != null) Text("TEL: ${c.tel}") else const SizedBox.shrink(),
|
||||||
|
if (c.email != null) Text("メール: ${c.email}") else const SizedBox.shrink(),
|
||||||
Text("敬称: ${c.title}"),
|
Text("敬称: ${c.title}"),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: c.isLocked
|
||||||
Navigator.pop(context);
|
? null
|
||||||
_addOrEditCustomer(customer: c);
|
: () {
|
||||||
},
|
Navigator.pop(context);
|
||||||
|
_addOrEditCustomer(customer: c);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
label: const Text("編集"),
|
label: const Text("編集"),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showContactUpdateDialog(c);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.contact_mail),
|
||||||
|
label: const Text("連絡先を更新"),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
if (!c.isLocked)
|
if (!c.isLocked)
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import 'invoice_input_screen.dart'; // Add this line
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import '../widgets/invoice_pdf_preview_page.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';
|
||||||
|
|
@ -34,6 +37,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
final _customerRepo = CustomerRepository();
|
final _customerRepo = CustomerRepository();
|
||||||
final _companyRepo = CompanyRepository();
|
final _companyRepo = CompanyRepository();
|
||||||
CompanyInfo? _companyInfo;
|
CompanyInfo? _companyInfo;
|
||||||
|
bool _showFormalWarning = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -147,8 +151,8 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fmt = NumberFormat("#,###");
|
final fmt = NumberFormat("#,###");
|
||||||
final isDraft = _currentInvoice.isDraft;
|
final isDraft = _currentInvoice.isDraft;
|
||||||
final themeColor = isDraft ? Colors.blueGrey.shade800 : Colors.white;
|
final themeColor = Colors.white; // 常に明色
|
||||||
final textColor = isDraft ? Colors.white : Colors.black87;
|
final textColor = Colors.black87;
|
||||||
|
|
||||||
final locked = _currentInvoice.isLocked;
|
final locked = _currentInvoice.isLocked;
|
||||||
|
|
||||||
|
|
@ -156,24 +160,37 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
backgroundColor: themeColor,
|
backgroundColor: themeColor,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const BackButton(), // 常に表示
|
leading: const BackButton(), // 常に表示
|
||||||
title: Text(isDraft ? "伝票詳細 (下書き)" : "販売アシスト1号 伝票詳細"),
|
title: Row(
|
||||||
backgroundColor: isDraft ? Colors.black87 : Colors.blueGrey,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
isDraft ? "伝票詳細" : "販売アシスト1号 伝票詳細",
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isDraft)
|
||||||
|
Chip(
|
||||||
|
label: const Text("下書き", style: TextStyle(color: Colors.white)),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.indigo.shade700,
|
||||||
actions: [
|
actions: [
|
||||||
if (locked)
|
if (locked)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
child: Chip(
|
child: Chip(
|
||||||
label: const Text("ロック中", style: TextStyle(color: Colors.white)),
|
label: const Text("確定済み", style: TextStyle(color: Colors.white)),
|
||||||
avatar: const Icon(Icons.lock, size: 16, color: Colors.white),
|
avatar: const Icon(Icons.lock, size: 16, color: Colors.white),
|
||||||
backgroundColor: Colors.redAccent,
|
backgroundColor: Colors.redAccent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isDraft && !_isEditing)
|
|
||||||
TextButton.icon(
|
|
||||||
icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent),
|
|
||||||
label: const Text("正式発行", style: TextStyle(color: Colors.orangeAccent)),
|
|
||||||
onPressed: _showPromoteDialog,
|
|
||||||
),
|
|
||||||
if (!_isEditing) ...[
|
if (!_isEditing) ...[
|
||||||
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
|
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
|
||||||
if (widget.isUnlocked && !locked)
|
if (widget.isUnlocked && !locked)
|
||||||
|
|
@ -198,34 +215,31 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (widget.isUnlocked && !locked)
|
IconButton(
|
||||||
IconButton(
|
icon: const Icon(Icons.edit_note, color: Colors.white),
|
||||||
icon: const Icon(Icons.edit_note),
|
tooltip: locked
|
||||||
tooltip: "詳細編集",
|
? "ロック中"
|
||||||
onPressed: () async {
|
: (widget.isUnlocked ? "詳細編集" : "アンロックして編集"),
|
||||||
await Navigator.push(
|
onPressed: (locked || !widget.isUnlocked)
|
||||||
context,
|
? null
|
||||||
MaterialPageRoute(
|
: () async {
|
||||||
builder: (context) => InvoiceInputForm(
|
await Navigator.push(
|
||||||
onInvoiceGenerated: (inv, path) {},
|
context,
|
||||||
existingInvoice: _currentInvoice,
|
MaterialPageRoute(
|
||||||
),
|
builder: (context) => InvoiceInputForm(
|
||||||
),
|
onInvoiceGenerated: (inv, path) {},
|
||||||
);
|
existingInvoice: _currentInvoice,
|
||||||
final repo = InvoiceRepository();
|
),
|
||||||
final customerRepo = CustomerRepository();
|
),
|
||||||
final customers = await customerRepo.getAllCustomers();
|
);
|
||||||
final updated = (await repo.getAllInvoices(customers)).firstWhere((i) => i.id == _currentInvoice.id, orElse: () => _currentInvoice);
|
final repo = InvoiceRepository();
|
||||||
setState(() => _currentInvoice = updated);
|
final customerRepo = CustomerRepository();
|
||||||
},
|
final customers = await customerRepo.getAllCustomers();
|
||||||
),
|
final updated = (await repo.getAllInvoices(customers)).firstWhere((i) => i.id == _currentInvoice.id, orElse: () => _currentInvoice);
|
||||||
|
setState(() => _currentInvoice = updated);
|
||||||
|
},
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (isDraft)
|
|
||||||
TextButton.icon(
|
|
||||||
icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent),
|
|
||||||
label: const Text("正式発行", style: TextStyle(color: Colors.orangeAccent)),
|
|
||||||
onPressed: _showPromoteDialog,
|
|
||||||
),
|
|
||||||
IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges),
|
IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges),
|
||||||
IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)),
|
IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)),
|
||||||
]
|
]
|
||||||
|
|
@ -236,6 +250,29 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (isDraft)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.orange.shade200),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.edit_note, color: Colors.orange),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
"下書き: 未確定・PDFは正式発行で確定",
|
||||||
|
style: TextStyle(color: Colors.orange),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
_buildHeaderSection(textColor),
|
_buildHeaderSection(textColor),
|
||||||
if (_isEditing) ...[
|
if (_isEditing) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -243,7 +280,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildExperimentalSection(isDraft),
|
_buildExperimentalSection(isDraft),
|
||||||
],
|
],
|
||||||
Divider(height: 32, color: isDraft ? Colors.white70 : Colors.grey),
|
Divider(height: 32, color: Colors.grey.shade400),
|
||||||
Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildItemTable(fmt, textColor, isDraft),
|
_buildItemTable(fmt, textColor, isDraft),
|
||||||
|
|
@ -333,22 +370,12 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
],
|
],
|
||||||
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
||||||
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)),
|
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)),
|
||||||
if (_currentInvoice.latitude != null) ...[
|
if ((_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address) != null)
|
||||||
const SizedBox(height: 4),
|
Text("住所: ${_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address}", style: TextStyle(color: textColor)),
|
||||||
Padding(
|
if ((_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel) != null)
|
||||||
padding: const EdgeInsets.only(top: 4.0),
|
Text("TEL: ${_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel}", style: TextStyle(color: textColor)),
|
||||||
child: Row(
|
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
|
||||||
children: [
|
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
|
||||||
const Icon(Icons.location_on, size: 14, color: Colors.blueGrey),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
"座標: ${_currentInvoice.latitude!.toStringAsFixed(4)}, ${_currentInvoice.longitude!.toStringAsFixed(4)}",
|
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.blueGrey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
|
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text("備考: ${_currentInvoice.notes}", style: TextStyle(color: textColor.withOpacity(0.9))),
|
Text("備考: ${_currentInvoice.notes}", style: TextStyle(color: textColor.withOpacity(0.9))),
|
||||||
|
|
@ -498,12 +525,20 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFooterActions() {
|
Widget _buildFooterActions() {
|
||||||
if (_isEditing || _currentFilePath == null) return const SizedBox();
|
if (_isEditing) return const SizedBox();
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _openPdf,
|
onPressed: _previewPdf,
|
||||||
|
icon: const Icon(Icons.picture_as_pdf),
|
||||||
|
label: const Text("PDFプレビュー"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _currentFilePath != null ? _openPdf : null,
|
||||||
icon: const Icon(Icons.launch),
|
icon: const Icon(Icons.launch),
|
||||||
label: const Text("PDFを開く"),
|
label: const Text("PDFを開く"),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
|
||||||
|
|
@ -512,7 +547,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _sharePdf,
|
onPressed: _currentFilePath != null ? _sharePdf : null,
|
||||||
icon: const Icon(Icons.share),
|
icon: const Icon(Icons.share),
|
||||||
label: const Text("共有"),
|
label: const Text("共有"),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
|
||||||
|
|
@ -523,19 +558,54 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showPromoteDialog() async {
|
Future<void> _showPromoteDialog() async {
|
||||||
|
bool showWarning = _showFormalWarning;
|
||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => StatefulBuilder(
|
||||||
title: const Text("正式発行"),
|
builder: (context, setStateDialog) {
|
||||||
content: const Text("この下書き伝票を「確定」として正式に発行しますか?\n(下書きモードが解除され、通常の背景に戻ります)"),
|
return AlertDialog(
|
||||||
actions: [
|
title: const Text("正式発行"),
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
content: Column(
|
||||||
ElevatedButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () => Navigator.pop(context, true),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
|
children: [
|
||||||
child: const Text("正式発行する"),
|
const Text("この下書き伝票を「確定」として正式に発行しますか?"),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
],
|
if (showWarning)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.redAccent, width: 1),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
"確定すると暗号チェーンシステムに組み込まれ、二度と編集できません。内容を最終確認のうえ実行してください。",
|
||||||
|
style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text("警告文を表示"),
|
||||||
|
value: showWarning,
|
||||||
|
onChanged: (val) {
|
||||||
|
setStateDialog(() => showWarning = val);
|
||||||
|
setState(() => _showFormalWarning = val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
|
||||||
|
child: const Text("正式発行する"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -583,6 +653,32 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付');
|
await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> _buildPdfBytes() async {
|
||||||
|
final doc = await buildInvoiceDocument(_currentInvoice);
|
||||||
|
return Uint8List.fromList(await doc.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _previewPdf() async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => InvoicePdfPreviewPage(
|
||||||
|
invoice: _currentInvoice,
|
||||||
|
isUnlocked: widget.isUnlocked,
|
||||||
|
isLocked: _currentInvoice.isLocked,
|
||||||
|
allowFormalIssue: true,
|
||||||
|
onFormalIssue: () async {
|
||||||
|
await _showPromoteDialog();
|
||||||
|
return !_currentInvoice.isDraft;
|
||||||
|
},
|
||||||
|
showShare: true,
|
||||||
|
showEmail: true,
|
||||||
|
showPrint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TableCell extends StatelessWidget {
|
class _TableCell extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,19 @@ import '../models/invoice_models.dart';
|
||||||
import '../models/customer_model.dart';
|
import '../models/customer_model.dart';
|
||||||
import '../services/invoice_repository.dart';
|
import '../services/invoice_repository.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
|
import '../services/pdf_generator.dart';
|
||||||
import 'invoice_detail_page.dart';
|
import 'invoice_detail_page.dart';
|
||||||
import 'management_screen.dart';
|
import 'management_screen.dart';
|
||||||
import 'product_master_screen.dart';
|
import 'product_master_screen.dart';
|
||||||
import 'customer_master_screen.dart';
|
import 'customer_master_screen.dart';
|
||||||
|
import 'invoice_input_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'company_info_screen.dart';
|
import 'company_info_screen.dart';
|
||||||
import '../widgets/slide_to_unlock.dart';
|
import '../widgets/slide_to_unlock.dart';
|
||||||
import '../main.dart'; // InvoiceFlowScreen 用
|
import '../main.dart'; // InvoiceFlowScreen 用
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import '../widgets/invoice_pdf_preview_page.dart';
|
||||||
|
|
||||||
class InvoiceHistoryScreen extends StatefulWidget {
|
class InvoiceHistoryScreen extends StatefulWidget {
|
||||||
const InvoiceHistoryScreen({Key? key}) : super(key: key);
|
const InvoiceHistoryScreen({Key? key}) : super(key: key);
|
||||||
|
|
@ -41,6 +45,96 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
_loadVersion();
|
_loadVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showInvoiceActions(Invoice invoice) async {
|
||||||
|
if (invoice.isLocked) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ロック中の伝票は操作できません")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_isUnlocked) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("操作するにはアンロックが必要です")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.picture_as_pdf),
|
||||||
|
title: const Text("PDFプレビュー"),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => InvoicePdfPreviewPage(
|
||||||
|
invoice: invoice,
|
||||||
|
isUnlocked: _isUnlocked,
|
||||||
|
isLocked: invoice.isLocked,
|
||||||
|
allowFormalIssue: !invoice.isLocked,
|
||||||
|
onFormalIssue: () async {
|
||||||
|
final repo = InvoiceRepository();
|
||||||
|
final promoted = invoice.copyWith(isDraft: false);
|
||||||
|
await repo.updateInvoice(promoted);
|
||||||
|
_loadData();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
showShare: true,
|
||||||
|
showEmail: true,
|
||||||
|
showPrint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadData();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit),
|
||||||
|
title: const Text("編集"),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => InvoiceInputForm(
|
||||||
|
existingInvoice: invoice,
|
||||||
|
onInvoiceGenerated: (inv, path) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadData();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete, color: Colors.redAccent),
|
||||||
|
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("伝票の削除"),
|
||||||
|
content: Text("「${invoice.customerNameForDisplay}」の伝票(${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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadVersion() async {
|
Future<void> _loadVersion() async {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -223,45 +317,6 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
drawer: Drawer(
|
|
||||||
child: ListView(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
children: [
|
|
||||||
const DrawerHeader(
|
|
||||||
decoration: BoxDecoration(color: Colors.blueGrey),
|
|
||||||
child: Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 24)),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.history),
|
|
||||||
title: const Text("伝票マスター一覧"),
|
|
||||||
onTap: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.add_task),
|
|
||||||
title: const Text("新規伝票作成"),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => InvoiceFlowScreen(onComplete: _loadData)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.admin_panel_settings),
|
|
||||||
title: const Text("マスター管理・同期"),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const ManagementScreen()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -327,17 +382,43 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
|
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
|
||||||
trailing: Column(
|
trailing: SizedBox(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
height: 56,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
Text("¥${amountFormatter.format(invoice.totalAmount)}",
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
if (invoice.isSynced)
|
children: [
|
||||||
const Icon(Icons.sync, size: 16, color: Colors.green)
|
Text("¥${amountFormatter.format(invoice.totalAmount)}",
|
||||||
else
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
|
||||||
const Icon(Icons.sync_disabled, size: 16, color: Colors.orange),
|
const SizedBox(height: 2),
|
||||||
],
|
if (invoice.isSynced)
|
||||||
|
const Icon(Icons.sync, size: 14, color: Colors.green)
|
||||||
|
else
|
||||||
|
const Icon(Icons.sync_disabled, size: 14, color: Colors.orange),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints.tightFor(width: 32, height: 28),
|
||||||
|
icon: const Icon(Icons.edit, size: 18),
|
||||||
|
tooltip: invoice.isLocked ? "ロック中" : (_isUnlocked ? "編集" : "アンロックして編集"),
|
||||||
|
onPressed: (invoice.isLocked || !_isUnlocked)
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => InvoiceInputForm(
|
||||||
|
existingInvoice: invoice,
|
||||||
|
onInvoiceGenerated: (inv, path) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadData();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
|
|
@ -351,36 +432,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
);
|
);
|
||||||
_loadData();
|
_loadData();
|
||||||
},
|
},
|
||||||
onLongPress: () async {
|
onLongPress: () => _showInvoiceActions(invoice),
|
||||||
if (invoice.isLocked) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ロック中の伝票は削除できません")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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.customerNameForDisplay}」の伝票(${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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ 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 'package:printing/printing.dart';
|
import '../widgets/invoice_pdf_preview_page.dart';
|
||||||
|
import 'invoice_detail_page.dart';
|
||||||
import '../services/gps_service.dart';
|
import '../services/gps_service.dart';
|
||||||
import 'customer_picker_modal.dart';
|
import 'customer_picker_modal.dart';
|
||||||
import 'product_picker_modal.dart';
|
import 'product_picker_modal.dart';
|
||||||
|
|
@ -170,32 +171,39 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||||||
);
|
);
|
||||||
|
|
||||||
showDialog(
|
Navigator.push(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) => Dialog.fullscreen(
|
MaterialPageRoute(
|
||||||
child: Column(
|
builder: (context) => InvoicePdfPreviewPage(
|
||||||
children: [
|
invoice: invoice,
|
||||||
AppBar(
|
isUnlocked: true,
|
||||||
title: Text("${invoice.documentTypeName}プレビュー"),
|
isLocked: false,
|
||||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
allowFormalIssue: widget.existingInvoice != null && !(widget.existingInvoice?.isLocked ?? false),
|
||||||
),
|
onFormalIssue: (widget.existingInvoice != null)
|
||||||
Expanded(
|
? () async {
|
||||||
child: PdfPreview(
|
final promoted = invoice.copyWith(isDraft: false);
|
||||||
build: (format) async {
|
await _invoiceRepo.saveInvoice(promoted);
|
||||||
// PdfGeneratorを少しリファクタして pw.Document を返す関数に分離することも可能だが
|
final newPath = await generateInvoicePdf(promoted);
|
||||||
// ここでは generateInvoicePdf の中身を模したバイト生成を行う
|
final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted;
|
||||||
// (もしくは generateInvoicePdf のシグネチャを変えてバイトを返すようにする)
|
await _invoiceRepo.saveInvoice(saved);
|
||||||
// 簡易化のため、一時ファイルを作ってそれを読み込むか、Generatorを修正する
|
if (!mounted) return false;
|
||||||
// 今回は Generator に pw.Document を生成する内部関数を作る
|
Navigator.pop(context); // close preview
|
||||||
final pdfDoc = await buildInvoiceDocument(invoice);
|
Navigator.pop(context); // exit edit screen
|
||||||
return pdfDoc.save();
|
await Navigator.push(
|
||||||
},
|
context,
|
||||||
allowPrinting: false,
|
MaterialPageRoute(
|
||||||
allowSharing: false,
|
builder: (_) => InvoiceDetailPage(
|
||||||
canChangePageFormat: false,
|
invoice: saved,
|
||||||
),
|
isUnlocked: true,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
showShare: false,
|
||||||
|
showEmail: false,
|
||||||
|
showPrint: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -223,8 +231,6 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildDocumentTypeSection(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildDateSection(),
|
_buildDateSection(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildCustomerSection(),
|
_buildCustomerSection(),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import '../models/customer_model.dart';
|
||||||
import 'database_helper.dart';
|
import 'database_helper.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'activity_log_repository.dart';
|
import 'activity_log_repository.dart';
|
||||||
|
import '../models/customer_contact.dart';
|
||||||
|
|
||||||
class CustomerRepository {
|
class CustomerRepository {
|
||||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
|
|
@ -10,7 +11,12 @@ class CustomerRepository {
|
||||||
|
|
||||||
Future<List<Customer>> getAllCustomers() async {
|
Future<List<Customer>> getAllCustomers() async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
final List<Map<String, dynamic>> maps = await db.query('customers', orderBy: 'display_name ASC');
|
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||||
|
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
||||||
|
FROM customers c
|
||||||
|
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||||
|
ORDER BY c.display_name ASC
|
||||||
|
''');
|
||||||
|
|
||||||
if (maps.isEmpty) {
|
if (maps.isEmpty) {
|
||||||
await _generateSampleCustomers();
|
await _generateSampleCustomers();
|
||||||
|
|
@ -40,11 +46,14 @@ class CustomerRepository {
|
||||||
|
|
||||||
Future<void> saveCustomer(Customer customer) async {
|
Future<void> saveCustomer(Customer customer) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
await db.insert(
|
await db.transaction((txn) async {
|
||||||
'customers',
|
await txn.insert(
|
||||||
customer.toMap(),
|
'customers',
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
customer.toMap(),
|
||||||
);
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
await _upsertActiveContact(txn, customer);
|
||||||
|
});
|
||||||
|
|
||||||
await _logRepo.logAction(
|
await _logRepo.logAction(
|
||||||
action: "SAVE_CUSTOMER",
|
action: "SAVE_CUSTOMER",
|
||||||
|
|
@ -109,13 +118,67 @@ class CustomerRepository {
|
||||||
|
|
||||||
Future<List<Customer>> searchCustomers(String query) async {
|
Future<List<Customer>> searchCustomers(String query) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
final List<Map<String, dynamic>> maps = await db.query(
|
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||||
'customers',
|
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
||||||
where: 'display_name LIKE ? OR formal_name LIKE ?',
|
FROM customers c
|
||||||
whereArgs: ['%$query%', '%$query%'],
|
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||||
orderBy: 'display_name ASC',
|
WHERE c.display_name LIKE ? OR c.formal_name LIKE ?
|
||||||
limit: 50,
|
ORDER BY c.display_name ASC
|
||||||
);
|
LIMIT 50
|
||||||
|
''', ['%$query%', '%$query%']);
|
||||||
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateContact({required String customerId, String? email, String? tel, String? address}) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
final nextVersion = await _nextContactVersion(txn, customerId);
|
||||||
|
await txn.update('customer_contacts', {'is_active': 0}, where: 'customer_id = ?', whereArgs: [customerId]);
|
||||||
|
await txn.insert('customer_contacts', {
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
'customer_id': customerId,
|
||||||
|
'email': email,
|
||||||
|
'tel': tel,
|
||||||
|
'address': address,
|
||||||
|
'version': nextVersion,
|
||||||
|
'is_active': 1,
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await _logRepo.logAction(
|
||||||
|
action: "UPDATE_CUSTOMER_CONTACT",
|
||||||
|
targetType: "CUSTOMER",
|
||||||
|
targetId: customerId,
|
||||||
|
details: "連絡先を更新 (version up)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CustomerContact?> getActiveContact(String customerId) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final rows = await db.query('customer_contacts', where: 'customer_id = ? AND is_active = 1', whereArgs: [customerId], limit: 1);
|
||||||
|
if (rows.isEmpty) return null;
|
||||||
|
return CustomerContact.fromMap(rows.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async {
|
||||||
|
final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]);
|
||||||
|
final current = res.first['v'] as int?;
|
||||||
|
return (current ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _upsertActiveContact(DatabaseExecutor txn, Customer customer) async {
|
||||||
|
final nextVersion = await _nextContactVersion(txn, customer.id);
|
||||||
|
await txn.update('customer_contacts', {'is_active': 0}, where: 'customer_id = ?', whereArgs: [customer.id]);
|
||||||
|
await txn.insert('customer_contacts', {
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
'customer_id': customer.id,
|
||||||
|
'email': customer.email,
|
||||||
|
'tel': customer.tel,
|
||||||
|
'address': customer.address,
|
||||||
|
'version': nextVersion,
|
||||||
|
'is_active': 1,
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static const _databaseVersion = 15;
|
static const _databaseVersion = 17;
|
||||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
|
|
@ -105,6 +105,45 @@ class DatabaseHelper {
|
||||||
await _safeAddColumn(db, 'customers', 'is_locked INTEGER DEFAULT 0');
|
await _safeAddColumn(db, 'customers', 'is_locked INTEGER DEFAULT 0');
|
||||||
await _safeAddColumn(db, 'products', 'is_locked INTEGER DEFAULT 0');
|
await _safeAddColumn(db, 'products', 'is_locked INTEGER DEFAULT 0');
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 16) {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE customer_contacts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
tel TEXT,
|
||||||
|
address TEXT,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('CREATE INDEX idx_customer_contacts_cust ON customer_contacts(customer_id)');
|
||||||
|
|
||||||
|
// 既存顧客の連絡先を初期バージョンとしてコピー
|
||||||
|
final existing = await db.query('customers');
|
||||||
|
final now = DateTime.now().toIso8601String();
|
||||||
|
for (final row in existing) {
|
||||||
|
final contactId = "${row['id']}_v1";
|
||||||
|
await db.insert('customer_contacts', {
|
||||||
|
'id': contactId,
|
||||||
|
'customer_id': row['id'],
|
||||||
|
'email': null,
|
||||||
|
'tel': row['tel'],
|
||||||
|
'address': row['address'],
|
||||||
|
'version': 1,
|
||||||
|
'is_active': 1,
|
||||||
|
'created_at': now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldVersion < 17) {
|
||||||
|
await _safeAddColumn(db, 'invoices', 'contact_version_id INTEGER');
|
||||||
|
await _safeAddColumn(db, 'invoices', 'contact_email_snapshot TEXT');
|
||||||
|
await _safeAddColumn(db, 'invoices', 'contact_tel_snapshot TEXT');
|
||||||
|
await _safeAddColumn(db, 'invoices', 'contact_address_snapshot TEXT');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
|
|
@ -135,6 +174,21 @@ class DatabaseHelper {
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE customer_contacts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
tel TEXT,
|
||||||
|
address TEXT,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('CREATE INDEX idx_customer_contacts_cust ON customer_contacts(customer_id)');
|
||||||
|
|
||||||
// 商品マスター
|
// 商品マスター
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE products (
|
CREATE TABLE products (
|
||||||
|
|
@ -173,6 +227,10 @@ class DatabaseHelper {
|
||||||
content_hash TEXT,
|
content_hash TEXT,
|
||||||
is_draft INTEGER DEFAULT 0,
|
is_draft INTEGER DEFAULT 0,
|
||||||
is_locked INTEGER DEFAULT 0,
|
is_locked INTEGER DEFAULT 0,
|
||||||
|
contact_version_id INTEGER,
|
||||||
|
contact_email_snapshot TEXT,
|
||||||
|
contact_tel_snapshot TEXT,
|
||||||
|
contact_address_snapshot TEXT,
|
||||||
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import '../models/invoice_models.dart';
|
import '../models/invoice_models.dart';
|
||||||
import '../models/customer_model.dart';
|
import '../models/customer_model.dart';
|
||||||
|
import '../models/customer_contact.dart';
|
||||||
import 'database_helper.dart';
|
import 'database_helper.dart';
|
||||||
import 'activity_log_repository.dart';
|
import 'activity_log_repository.dart';
|
||||||
|
|
||||||
|
|
@ -17,6 +18,19 @@ class InvoiceRepository {
|
||||||
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
|
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
|
||||||
|
|
||||||
await db.transaction((txn) async {
|
await db.transaction((txn) async {
|
||||||
|
// 最新の連絡先をスナップショットする(なければ空)
|
||||||
|
CustomerContact? activeContact;
|
||||||
|
final contactRows = await txn.query('customer_contacts', where: 'customer_id = ? AND is_active = 1', whereArgs: [invoice.customer.id]);
|
||||||
|
if (contactRows.isNotEmpty) {
|
||||||
|
activeContact = CustomerContact.fromMap(contactRows.first);
|
||||||
|
}
|
||||||
|
final Invoice savingWithContact = toSave.copyWith(
|
||||||
|
contactVersionId: activeContact?.version,
|
||||||
|
contactEmailSnapshot: activeContact?.email,
|
||||||
|
contactTelSnapshot: activeContact?.tel,
|
||||||
|
contactAddressSnapshot: activeContact?.address,
|
||||||
|
);
|
||||||
|
|
||||||
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
|
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
|
||||||
final List<Map<String, dynamic>> oldItems = await txn.query(
|
final List<Map<String, dynamic>> oldItems = await txn.query(
|
||||||
'invoice_items',
|
'invoice_items',
|
||||||
|
|
@ -37,7 +51,7 @@ class InvoiceRepository {
|
||||||
// 伝票ヘッダーの保存
|
// 伝票ヘッダーの保存
|
||||||
await txn.insert(
|
await txn.insert(
|
||||||
'invoices',
|
'invoices',
|
||||||
toSave.toMap(),
|
savingWithContact.toMap(),
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -126,6 +140,10 @@ class InvoiceRepository {
|
||||||
isDraft: (iMap['is_draft'] ?? 0) == 1,
|
isDraft: (iMap['is_draft'] ?? 0) == 1,
|
||||||
subject: iMap['subject'],
|
subject: iMap['subject'],
|
||||||
isLocked: (iMap['is_locked'] ?? 0) == 1,
|
isLocked: (iMap['is_locked'] ?? 0) == 1,
|
||||||
|
contactVersionId: iMap['contact_version_id'],
|
||||||
|
contactEmailSnapshot: iMap['contact_email_snapshot'],
|
||||||
|
contactTelSnapshot: iMap['contact_tel_snapshot'],
|
||||||
|
contactAddressSnapshot: iMap['contact_address_snapshot'],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return invoices;
|
return invoices;
|
||||||
|
|
|
||||||
|
|
@ -14,238 +14,217 @@ import 'activity_log_repository.dart';
|
||||||
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||||
final pdf = pw.Document();
|
final pdf = pw.Document();
|
||||||
|
|
||||||
// フォントのロード
|
|
||||||
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
||||||
final ipaex = pw.Font.ttf(fontData);
|
final ipaex = pw.Font.ttf(fontData);
|
||||||
|
|
||||||
final dateFormatter = DateFormat('yyyy年MM月dd日');
|
final dateFormatter = DateFormat('yyyy年MM月dd日');
|
||||||
final amountFormatter = NumberFormat("#,###");
|
final amountFormatter = NumberFormat("#,###");
|
||||||
|
|
||||||
// 自社情報の取得
|
|
||||||
final companyRepo = CompanyRepository();
|
final companyRepo = CompanyRepository();
|
||||||
final companyInfo = await companyRepo.getCompanyInfo();
|
final companyInfo = await companyRepo.getCompanyInfo();
|
||||||
|
|
||||||
// 印影画像のロード
|
|
||||||
pw.MemoryImage? sealImage;
|
pw.MemoryImage? sealImage;
|
||||||
if (companyInfo.sealPath != null) {
|
if (companyInfo.sealPath != null) {
|
||||||
final file = File(companyInfo.sealPath!);
|
final file = File(companyInfo.sealPath!);
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
final bytes = await file.readAsBytes();
|
sealImage = pw.MemoryImage(await file.readAsBytes());
|
||||||
sealImage = pw.MemoryImage(bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.addPage(
|
pdf.addPage(
|
||||||
pw.MultiPage(
|
pw.MultiPage(
|
||||||
pageFormat: PdfPageFormat.a4,
|
pageTheme: pw.PageTheme(
|
||||||
margin: const pw.EdgeInsets.all(32),
|
pageFormat: PdfPageFormat.a4,
|
||||||
theme: pw.ThemeData.withFont(
|
margin: const pw.EdgeInsets.all(32),
|
||||||
base: ipaex,
|
theme: pw.ThemeData.withFont(
|
||||||
bold: ipaex,
|
base: ipaex,
|
||||||
italic: ipaex,
|
bold: ipaex,
|
||||||
boldItalic: ipaex,
|
italic: ipaex,
|
||||||
).copyWith(
|
boldItalic: ipaex,
|
||||||
defaultTextStyle: pw.TextStyle(fontFallback: [ipaex]),
|
).copyWith(defaultTextStyle: pw.TextStyle(fontFallback: [ipaex])),
|
||||||
|
buildBackground: (context) {
|
||||||
|
if (!invoice.isDraft) return pw.SizedBox();
|
||||||
|
return pw.Center(
|
||||||
|
child: pw.Transform.rotate(
|
||||||
|
angle: -0.5,
|
||||||
|
child: pw.Opacity(
|
||||||
|
opacity: 0.18,
|
||||||
|
child: pw.Text(
|
||||||
|
'下書き',
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: 120,
|
||||||
|
fontWeight: pw.FontWeight.bold,
|
||||||
|
color: PdfColors.grey600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
build: (context) => [
|
build: (context) {
|
||||||
// タイトル
|
final content = <pw.Widget>[
|
||||||
pw.Header(
|
pw.Header(
|
||||||
level: 0,
|
level: 0,
|
||||||
child: pw.Row(
|
child: pw.Row(
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
|
||||||
|
pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
pw.Text("番号: ${invoice.invoiceNumber}"),
|
||||||
|
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 20),
|
||||||
|
pw.Row(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
|
pw.Expanded(
|
||||||
pw.Column(
|
child: pw.Column(
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
pw.Text("番号: ${invoice.invoiceNumber}"),
|
pw.Container(
|
||||||
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
|
decoration: const pw.BoxDecoration(border: pw.Border(bottom: pw.BorderSide(width: 1))),
|
||||||
],
|
child: pw.Text(invoice.customer.invoiceName, style: const pw.TextStyle(fontSize: 18)),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 6),
|
||||||
|
if ((invoice.contactAddressSnapshot ?? invoice.customer.address) != null)
|
||||||
|
pw.Text(invoice.contactAddressSnapshot ?? invoice.customer.address!, style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
if ((invoice.contactTelSnapshot ?? invoice.customer.tel) != null)
|
||||||
|
pw.Text("TEL: ${invoice.contactTelSnapshot ?? invoice.customer.tel}", style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
if (invoice.contactEmailSnapshot != null)
|
||||||
|
pw.Text("MAIL: ${invoice.contactEmailSnapshot}", style: const pw.TextStyle(fontSize: 12)),
|
||||||
|
pw.SizedBox(height: 10),
|
||||||
|
pw.Text(
|
||||||
|
invoice.documentType == DocumentType.receipt
|
||||||
|
? "上記の金額を正に領収いたしました。"
|
||||||
|
: (invoice.documentType == DocumentType.estimation
|
||||||
|
? "下記の通り、お見積り申し上げます。"
|
||||||
|
: "下記の通り、ご請求申し上げます。"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Expanded(
|
||||||
|
child: pw.Stack(
|
||||||
|
alignment: pw.Alignment.topRight,
|
||||||
|
children: [
|
||||||
|
pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
||||||
|
if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"),
|
||||||
|
if (companyInfo.address != null) pw.Text(companyInfo.address!),
|
||||||
|
if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"),
|
||||||
|
if (companyInfo.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty)
|
||||||
|
pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (sealImage != null)
|
||||||
|
pw.Positioned(
|
||||||
|
right: 10,
|
||||||
|
top: 0,
|
||||||
|
child: pw.Opacity(opacity: 0.8, child: pw.Image(sealImage, width: 40, height: 40)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
pw.SizedBox(height: 30),
|
||||||
pw.SizedBox(height: 20),
|
pw.Container(
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
// 宛名と自社情報
|
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
||||||
pw.Row(
|
child: pw.Row(
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
pw.Expanded(
|
pw.Text(
|
||||||
child: pw.Column(
|
invoice.documentType == DocumentType.receipt
|
||||||
|
? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)")
|
||||||
|
: (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"),
|
||||||
|
style: const pw.TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
pw.Text("¥${amountFormatter.format(invoice.totalAmount)} -", style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 20),
|
||||||
|
pw.Table.fromTextArray(
|
||||||
|
headers: const ["品名", "数量", "単価", "金額"],
|
||||||
|
data: invoice.items
|
||||||
|
.map((item) => [
|
||||||
|
item.description,
|
||||||
|
item.quantity.toString(),
|
||||||
|
amountFormatter.format(item.unitPrice),
|
||||||
|
amountFormatter.format(item.subtotal),
|
||||||
|
])
|
||||||
|
.toList(),
|
||||||
|
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex),
|
||||||
|
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
||||||
|
cellAlignment: pw.Alignment.centerLeft,
|
||||||
|
columnWidths: const {0: pw.FlexColumnWidth(3), 1: pw.FlexColumnWidth(1), 2: pw.FlexColumnWidth(2), 3: pw.FlexColumnWidth(2)},
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 20),
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
pw.Container(
|
||||||
|
width: 200,
|
||||||
|
child: pw.Column(
|
||||||
|
children: [
|
||||||
|
pw.SizedBox(height: 10),
|
||||||
|
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
|
||||||
|
if (companyInfo.taxDisplayMode == 'normal')
|
||||||
|
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
|
||||||
|
if (companyInfo.taxDisplayMode == 'text_only') _buildSummaryRow("消費税", "(税別)"),
|
||||||
|
pw.Divider(),
|
||||||
|
_buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
|
||||||
|
pw.SizedBox(height: 10),
|
||||||
|
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||||
|
pw.Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const pw.EdgeInsets.all(8),
|
||||||
|
decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)),
|
||||||
|
child: pw.Text(invoice.notes!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
pw.SizedBox(height: 20),
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
pw.Column(
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
pw.Container(
|
pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
|
||||||
decoration: const pw.BoxDecoration(
|
pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
|
||||||
border: pw.Border(bottom: pw.BorderSide(width: 1)),
|
|
||||||
),
|
|
||||||
child: pw.Text(invoice.customer.invoiceName,
|
|
||||||
style: const pw.TextStyle(fontSize: 18)),
|
|
||||||
),
|
|
||||||
pw.SizedBox(height: 10),
|
|
||||||
pw.Text(invoice.documentType == DocumentType.receipt
|
|
||||||
? "上記の金額を正に領収いたしました。"
|
|
||||||
: (invoice.documentType == DocumentType.estimation
|
|
||||||
? "下記の通り、お見積り申し上げます。"
|
|
||||||
: "下記の通り、ご請求申し上げます。")),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
pw.Container(
|
||||||
pw.Expanded(
|
width: 50,
|
||||||
child: pw.Stack(
|
height: 50,
|
||||||
alignment: pw.Alignment.topRight,
|
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: invoice.contentHash, drawText: false),
|
||||||
children: [
|
|
||||||
pw.Column(
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
|
|
||||||
if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"),
|
|
||||||
if (companyInfo.address != null) pw.Text(companyInfo.address!),
|
|
||||||
if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"),
|
|
||||||
if (companyInfo.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty)
|
|
||||||
pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (sealImage != null)
|
|
||||||
pw.Positioned(
|
|
||||||
right: 10,
|
|
||||||
top: 0,
|
|
||||||
child: pw.Opacity(
|
|
||||||
opacity: 0.8,
|
|
||||||
child: pw.Image(sealImage, width: 40, height: 40),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
pw.SizedBox(height: 30),
|
|
||||||
|
|
||||||
// 合計金額表示
|
|
||||||
pw.Container(
|
|
||||||
padding: const pw.EdgeInsets.all(8),
|
|
||||||
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
|
|
||||||
child: pw.Row(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
pw.Text(
|
|
||||||
invoice.documentType == DocumentType.receipt
|
|
||||||
? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)")
|
|
||||||
: (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"),
|
|
||||||
style: const pw.TextStyle(fontSize: 16)),
|
|
||||||
pw.Text("¥${amountFormatter.format(invoice.totalAmount)} -",
|
|
||||||
style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
];
|
||||||
pw.SizedBox(height: 20),
|
|
||||||
|
|
||||||
// 明細テーブル
|
return [pw.Column(children: content)];
|
||||||
// 明細テーブル
|
},
|
||||||
pw.Table(
|
|
||||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
|
||||||
columnWidths: {
|
|
||||||
0: const pw.FlexColumnWidth(4),
|
|
||||||
1: const pw.FixedColumnWidth(50),
|
|
||||||
2: const pw.FixedColumnWidth(80),
|
|
||||||
3: const pw.FixedColumnWidth(80),
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
// ヘッダー
|
|
||||||
pw.TableRow(
|
|
||||||
decoration: const pw.BoxDecoration(color: PdfColors.grey300),
|
|
||||||
children: [
|
|
||||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("品名 / 項目", style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
|
|
||||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("数量", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
|
|
||||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("単価", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
|
|
||||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("金額", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// データ行
|
|
||||||
...invoice.items.map((item) {
|
|
||||||
return pw.TableRow(
|
|
||||||
children: [
|
|
||||||
pw.Padding(
|
|
||||||
padding: const pw.EdgeInsets.all(4),
|
|
||||||
child: _parseMarkdown(item.description),
|
|
||||||
),
|
|
||||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.right)),
|
|
||||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.unitPrice), textAlign: pw.TextAlign.right)),
|
|
||||||
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.subtotal), textAlign: pw.TextAlign.right)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// 計算内訳
|
|
||||||
pw.Row(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
pw.Container(
|
|
||||||
width: 200,
|
|
||||||
child: pw.Column(
|
|
||||||
children: [
|
|
||||||
pw.SizedBox(height: 10),
|
|
||||||
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
|
|
||||||
if (companyInfo.taxDisplayMode == 'normal')
|
|
||||||
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
|
|
||||||
if (companyInfo.taxDisplayMode == 'text_only')
|
|
||||||
_buildSummaryRow("消費税", "(税別)"),
|
|
||||||
pw.Divider(),
|
|
||||||
_buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// 備考
|
|
||||||
// 備考
|
|
||||||
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
|
|
||||||
pw.SizedBox(height: 10),
|
|
||||||
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
|
||||||
pw.Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const pw.EdgeInsets.all(8),
|
|
||||||
decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)),
|
|
||||||
child: pw.Text(invoice.notes!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
pw.SizedBox(height: 20),
|
|
||||||
// 監査用ハッシュとQRコード
|
|
||||||
pw.Row(
|
|
||||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
pw.Column(
|
|
||||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
|
|
||||||
pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
pw.Container(
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
child: pw.BarcodeWidget(
|
|
||||||
barcode: pw.Barcode.qrCode(),
|
|
||||||
data: invoice.contentHash,
|
|
||||||
drawText: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
footer: (context) => pw.Container(
|
footer: (context) => pw.Container(
|
||||||
alignment: pw.Alignment.centerRight,
|
alignment: pw.Alignment.centerRight,
|
||||||
margin: const pw.EdgeInsets.only(top: 16),
|
margin: const pw.EdgeInsets.only(top: 16),
|
||||||
child: pw.Text(
|
child: pw.Text("Page ${context.pageNumber} / ${context.pagesCount}", style: const pw.TextStyle(color: PdfColors.grey)),
|
||||||
"Page ${context.pageNumber} / ${context.pagesCount}",
|
|
||||||
style: const pw.TextStyle(color: PdfColors.grey),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
117
lib/widgets/invoice_pdf_preview_page.dart
Normal file
117
lib/widgets/invoice_pdf_preview_page.dart
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import '../models/invoice_models.dart';
|
||||||
|
import '../services/pdf_generator.dart';
|
||||||
|
|
||||||
|
class InvoicePdfPreviewPage extends StatelessWidget {
|
||||||
|
final Invoice invoice;
|
||||||
|
final bool allowFormalIssue;
|
||||||
|
final bool isUnlocked;
|
||||||
|
final bool isLocked;
|
||||||
|
final Future<bool> Function()? onFormalIssue;
|
||||||
|
final bool showShare;
|
||||||
|
final bool showEmail;
|
||||||
|
final bool showPrint;
|
||||||
|
|
||||||
|
const InvoicePdfPreviewPage({
|
||||||
|
Key? key,
|
||||||
|
required this.invoice,
|
||||||
|
this.allowFormalIssue = true,
|
||||||
|
this.isUnlocked = false,
|
||||||
|
this.isLocked = false,
|
||||||
|
this.onFormalIssue,
|
||||||
|
this.showShare = true,
|
||||||
|
this.showEmail = true,
|
||||||
|
this.showPrint = true,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
Future<Uint8List> _buildPdfBytes() async {
|
||||||
|
final doc = await buildInvoiceDocument(invoice);
|
||||||
|
return Uint8List.fromList(await doc.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDraft = invoice.isDraft;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text("PDFプレビュー")),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PdfPreview(
|
||||||
|
build: (format) async => await _buildPdfBytes(),
|
||||||
|
allowPrinting: false,
|
||||||
|
allowSharing: false,
|
||||||
|
canChangePageFormat: false,
|
||||||
|
canChangeOrientation: false,
|
||||||
|
canDebug: false,
|
||||||
|
actions: const [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: (allowFormalIssue && isDraft && isUnlocked && !isLocked && onFormalIssue != null)
|
||||||
|
? () async {
|
||||||
|
final ok = await onFormalIssue!();
|
||||||
|
if (ok && context.mounted) Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.check_circle_outline),
|
||||||
|
label: const Text("正式発行"),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: showShare
|
||||||
|
? () async {
|
||||||
|
final bytes = await _buildPdfBytes();
|
||||||
|
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf');
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
label: const Text("共有"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: showEmail
|
||||||
|
? () async {
|
||||||
|
final bytes = await _buildPdfBytes();
|
||||||
|
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf', subject: '請求書送付');
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.mail_outline),
|
||||||
|
label: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: showPrint
|
||||||
|
? () async {
|
||||||
|
await Printing.layoutPdf(onLayout: (format) async => await _buildPdfBytes());
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.print),
|
||||||
|
label: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,8 @@ FLUTTER_ROOT=/home/user/development/flutter
|
||||||
FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0
|
FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0
|
||||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||||
FLUTTER_BUILD_DIR=build
|
FLUTTER_BUILD_DIR=build
|
||||||
FLUTTER_BUILD_NAME=1.5.0
|
FLUTTER_BUILD_NAME=1.5.06
|
||||||
FLUTTER_BUILD_NUMBER=150
|
FLUTTER_BUILD_NUMBER=151
|
||||||
DART_OBFUSCATION=false
|
DART_OBFUSCATION=false
|
||||||
TRACK_WIDGET_CREATION=true
|
TRACK_WIDGET_CREATION=true
|
||||||
TREE_SHAKE_ICONS=false
|
TREE_SHAKE_ICONS=false
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ export "FLUTTER_ROOT=/home/user/development/flutter"
|
||||||
export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0"
|
export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0"
|
||||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||||
export "FLUTTER_BUILD_DIR=build"
|
export "FLUTTER_BUILD_DIR=build"
|
||||||
export "FLUTTER_BUILD_NAME=1.5.0"
|
export "FLUTTER_BUILD_NAME=1.5.06"
|
||||||
export "FLUTTER_BUILD_NUMBER=150"
|
export "FLUTTER_BUILD_NUMBER=151"
|
||||||
export "DART_OBFUSCATION=false"
|
export "DART_OBFUSCATION=false"
|
||||||
export "TRACK_WIDGET_CREATION=true"
|
export "TRACK_WIDGET_CREATION=true"
|
||||||
export "TREE_SHAKE_ICONS=false"
|
export "TREE_SHAKE_ICONS=false"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue