feat: Implement extensive database schema upgrades including new tables for activity logs and app GPS history, and add fields for product categories, stock, invoice document types, GPS coordinates, and company tax display
This commit is contained in:
parent
f98fed8c44
commit
035baf9078
32 changed files with 1582 additions and 356 deletions
|
|
@ -4,6 +4,12 @@
|
|||
>
|
||||
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -48,6 +48,18 @@
|
|||
@import permission_handler_apple;
|
||||
#endif
|
||||
|
||||
#if __has_include(<print_bluetooth_thermal/PrintBluetoothThermalPlugin.h>)
|
||||
#import <print_bluetooth_thermal/PrintBluetoothThermalPlugin.h>
|
||||
#else
|
||||
@import print_bluetooth_thermal;
|
||||
#endif
|
||||
|
||||
#if __has_include(<printing/PrintingPlugin.h>)
|
||||
#import <printing/PrintingPlugin.h>
|
||||
#else
|
||||
@import printing;
|
||||
#endif
|
||||
|
||||
#if __has_include(<share_plus/FPPSharePlusPlugin.h>)
|
||||
#import <share_plus/FPPSharePlusPlugin.h>
|
||||
#else
|
||||
|
|
@ -76,6 +88,8 @@
|
|||
[OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]];
|
||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
||||
[PrintBluetoothThermalPlugin registerWithRegistrar:[registry registrarForPlugin:@"PrintBluetoothThermalPlugin"]];
|
||||
[PrintingPlugin registerWithRegistrar:[registry registrarForPlugin:@"PrintingPlugin"]];
|
||||
[FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||
|
|
|
|||
56
lib/models/activity_log_model.dart
Normal file
56
lib/models/activity_log_model.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ActivityLog {
|
||||
final String id;
|
||||
final String action; // 例: "CREATE", "UPDATE", "DELETE", "GENERATE_PDF"
|
||||
final String targetType; // 例: "INVOICE", "CUSTOMER", "PRODUCT"
|
||||
final String? targetId;
|
||||
final String? details;
|
||||
final DateTime timestamp;
|
||||
|
||||
ActivityLog({
|
||||
required this.id,
|
||||
required this.action,
|
||||
required this.targetType,
|
||||
this.targetId,
|
||||
this.details,
|
||||
DateTime? timestamp,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'action': action,
|
||||
'target_type': targetType,
|
||||
'target_id': targetId,
|
||||
'details': details,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory ActivityLog.fromMap(Map<String, dynamic> map) {
|
||||
return ActivityLog(
|
||||
id: map['id'],
|
||||
action: map['action'],
|
||||
targetType: map['target_type'],
|
||||
targetId: map['target_id'],
|
||||
details: map['details'],
|
||||
timestamp: DateTime.parse(map['timestamp']),
|
||||
);
|
||||
}
|
||||
|
||||
static ActivityLog create({
|
||||
required String action,
|
||||
required String targetType,
|
||||
String? targetId,
|
||||
String? details,
|
||||
}) {
|
||||
return ActivityLog(
|
||||
id: const Uuid().v4(),
|
||||
action: action,
|
||||
targetType: targetType,
|
||||
targetId: targetId,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ class CompanyInfo {
|
|||
final String? tel;
|
||||
final double defaultTaxRate;
|
||||
final String? sealPath; // 角印(印鑑)の画像パス
|
||||
final String taxDisplayMode; // 'normal', 'hidden', 'text_only'
|
||||
|
||||
CompanyInfo({
|
||||
required this.name,
|
||||
|
|
@ -13,6 +14,7 @@ class CompanyInfo {
|
|||
this.tel,
|
||||
this.defaultTaxRate = 0.10,
|
||||
this.sealPath,
|
||||
this.taxDisplayMode = 'normal',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
|
|
@ -24,6 +26,7 @@ class CompanyInfo {
|
|||
'tel': tel,
|
||||
'default_tax_rate': defaultTaxRate,
|
||||
'seal_path': sealPath,
|
||||
'tax_display_mode': taxDisplayMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +38,7 @@ class CompanyInfo {
|
|||
tel: map['tel'],
|
||||
defaultTaxRate: map['default_tax_rate'] ?? 0.10,
|
||||
sealPath: map['seal_path'],
|
||||
taxDisplayMode: map['tax_display_mode'] ?? 'normal',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +49,7 @@ class CompanyInfo {
|
|||
String? tel,
|
||||
double? defaultTaxRate,
|
||||
String? sealPath,
|
||||
String? taxDisplayMode,
|
||||
}) {
|
||||
return CompanyInfo(
|
||||
name: name ?? this.name,
|
||||
|
|
@ -53,6 +58,7 @@ class CompanyInfo {
|
|||
tel: tel ?? this.tel,
|
||||
defaultTaxRate: defaultTaxRate ?? this.defaultTaxRate,
|
||||
sealPath: sealPath ?? this.sealPath,
|
||||
taxDisplayMode: taxDisplayMode ?? this.taxDisplayMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import 'package:intl/intl.dart';
|
|||
|
||||
class InvoiceItem {
|
||||
final String? id;
|
||||
final String? productId; // 追加
|
||||
String description;
|
||||
int quantity;
|
||||
int unitPrice;
|
||||
|
||||
InvoiceItem({
|
||||
this.id,
|
||||
this.productId, // 追加
|
||||
required this.description,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
|
|
@ -20,6 +22,7 @@ class InvoiceItem {
|
|||
return {
|
||||
'id': id ?? DateTime.now().microsecondsSinceEpoch.toString(),
|
||||
'invoice_id': invoiceId,
|
||||
'product_id': productId, // 追加
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
'unit_price': unitPrice,
|
||||
|
|
@ -29,6 +32,7 @@ class InvoiceItem {
|
|||
factory InvoiceItem.fromMap(Map<String, dynamic> map) {
|
||||
return InvoiceItem(
|
||||
id: map['id'],
|
||||
productId: map['product_id'], // 追加
|
||||
description: map['description'],
|
||||
quantity: map['quantity'],
|
||||
unitPrice: map['unit_price'],
|
||||
|
|
@ -36,6 +40,13 @@ class InvoiceItem {
|
|||
}
|
||||
}
|
||||
|
||||
enum DocumentType {
|
||||
estimation, // 見積
|
||||
delivery, // 納品
|
||||
invoice, // 請求
|
||||
receipt, // 領収
|
||||
}
|
||||
|
||||
class Invoice {
|
||||
final String id;
|
||||
final Customer customer;
|
||||
|
|
@ -44,10 +55,13 @@ class Invoice {
|
|||
final String? notes;
|
||||
final String? filePath;
|
||||
final double taxRate;
|
||||
final String? customerFormalNameSnapshot; // 追加
|
||||
final DocumentType documentType; // 追加
|
||||
final String? customerFormalNameSnapshot;
|
||||
final String? odooId;
|
||||
final bool isSynced;
|
||||
final DateTime updatedAt;
|
||||
final double? latitude; // 追加
|
||||
final double? longitude; // 追加
|
||||
|
||||
Invoice({
|
||||
String? id,
|
||||
|
|
@ -56,21 +70,42 @@ class Invoice {
|
|||
required this.items,
|
||||
this.notes,
|
||||
this.filePath,
|
||||
this.taxRate = 0.10, // デフォルト10%
|
||||
this.customerFormalNameSnapshot, // 追加
|
||||
this.taxRate = 0.10,
|
||||
this.documentType = DocumentType.invoice, // デフォルト請求書
|
||||
this.customerFormalNameSnapshot,
|
||||
this.odooId,
|
||||
this.isSynced = false,
|
||||
DateTime? updatedAt,
|
||||
this.latitude, // 追加
|
||||
this.longitude, // 追加
|
||||
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
updatedAt = updatedAt ?? DateTime.now();
|
||||
|
||||
String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
||||
String get documentTypeName {
|
||||
switch (documentType) {
|
||||
case DocumentType.estimation: return "見積書";
|
||||
case DocumentType.delivery: return "納品書";
|
||||
case DocumentType.invoice: return "請求書";
|
||||
case DocumentType.receipt: return "領収書";
|
||||
}
|
||||
}
|
||||
|
||||
String get invoiceNumberPrefix {
|
||||
switch (documentType) {
|
||||
case DocumentType.estimation: return "EST";
|
||||
case DocumentType.delivery: return "DEL";
|
||||
case DocumentType.invoice: return "INV";
|
||||
case DocumentType.receipt: return "REC";
|
||||
}
|
||||
}
|
||||
|
||||
String get invoiceNumber => "$invoiceNumberPrefix-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
||||
|
||||
// 表示用の宛名(スナップショットがあれば優先)
|
||||
String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName;
|
||||
|
||||
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
|
||||
int get tax => (subtotal * taxRate).floor(); // taxRateを使用
|
||||
int get tax => (subtotal * taxRate).floor();
|
||||
int get totalAmount => subtotal + tax;
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
|
|
@ -81,33 +116,17 @@ class Invoice {
|
|||
'notes': notes,
|
||||
'file_path': filePath,
|
||||
'total_amount': totalAmount,
|
||||
'tax_rate': taxRate, // 追加
|
||||
'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName, // 追加
|
||||
'tax_rate': taxRate,
|
||||
'document_type': documentType.name, // 追加
|
||||
'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName,
|
||||
'odoo_id': odooId,
|
||||
'is_synced': isSynced ? 1 : 0,
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
'latitude': latitude, // 追加
|
||||
'longitude': longitude, // 追加
|
||||
};
|
||||
}
|
||||
|
||||
// 注: fromMap には Customer オブジェクトが必要なため、
|
||||
// リポジトリ層で構築することを想定し、ここでは factory は定義しません。
|
||||
|
||||
String toCsv() {
|
||||
final dateFormatter = DateFormat('yyyy/MM/dd');
|
||||
|
||||
StringBuffer buffer = StringBuffer();
|
||||
buffer.writeln("日付,請求番号,取引先,合計金額,備考");
|
||||
buffer.writeln("${dateFormatter.format(date)},$invoiceNumber,$customerNameForDisplay,$totalAmount,${notes ?? ""}");
|
||||
buffer.writeln("");
|
||||
buffer.writeln("品名,数量,単価,小計");
|
||||
|
||||
for (var item in items) {
|
||||
buffer.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}");
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Invoice copyWith({
|
||||
String? id,
|
||||
Customer? customer,
|
||||
|
|
@ -116,10 +135,13 @@ class Invoice {
|
|||
String? notes,
|
||||
String? filePath,
|
||||
double? taxRate,
|
||||
DocumentType? documentType,
|
||||
String? customerFormalNameSnapshot,
|
||||
String? odooId,
|
||||
bool? isSynced,
|
||||
DateTime? updatedAt,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
}) {
|
||||
return Invoice(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -129,10 +151,25 @@ class Invoice {
|
|||
notes: notes ?? this.notes,
|
||||
filePath: filePath ?? this.filePath,
|
||||
taxRate: taxRate ?? this.taxRate,
|
||||
documentType: documentType ?? this.documentType,
|
||||
customerFormalNameSnapshot: customerFormalNameSnapshot ?? this.customerFormalNameSnapshot,
|
||||
odooId: odooId ?? this.odooId,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
String toCsv() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('伝票種別,伝票番号,日付,取引先,合計金額,緯度,経度');
|
||||
buffer.writeln('$documentTypeName,$invoiceNumber,${DateFormat('yyyy/MM/dd').format(date)},$customerNameForDisplay,$totalAmount,${latitude ?? ""},${longitude ?? ""}');
|
||||
buffer.writeln('');
|
||||
buffer.writeln('品名,数量,単価,小計');
|
||||
for (var item in items) {
|
||||
buffer.writeln('${item.description},${item.quantity},${item.unitPrice},${item.subtotal}');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ class Product {
|
|||
final String name;
|
||||
final int defaultUnitPrice;
|
||||
final String? barcode;
|
||||
final String? category;
|
||||
final int stockQuantity; // 追加
|
||||
final String? odooId;
|
||||
|
||||
Product({
|
||||
|
|
@ -10,6 +12,8 @@ class Product {
|
|||
required this.name,
|
||||
this.defaultUnitPrice = 0,
|
||||
this.barcode,
|
||||
this.category,
|
||||
this.stockQuantity = 0, // 追加
|
||||
this.odooId,
|
||||
});
|
||||
|
||||
|
|
@ -19,6 +23,8 @@ class Product {
|
|||
'name': name,
|
||||
'default_unit_price': defaultUnitPrice,
|
||||
'barcode': barcode,
|
||||
'category': category,
|
||||
'stock_quantity': stockQuantity, // 追加
|
||||
'odoo_id': odooId,
|
||||
};
|
||||
}
|
||||
|
|
@ -29,6 +35,8 @@ class Product {
|
|||
name: map['name'],
|
||||
defaultUnitPrice: map['default_unit_price'] ?? 0,
|
||||
barcode: map['barcode'],
|
||||
category: map['category'],
|
||||
stockQuantity: map['stock_quantity'] ?? 0, // 追加
|
||||
odooId: map['odoo_id'],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
127
lib/screens/activity_log_screen.dart
Normal file
127
lib/screens/activity_log_screen.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/activity_log_model.dart';
|
||||
import '../services/activity_log_repository.dart';
|
||||
|
||||
class ActivityLogScreen extends StatefulWidget {
|
||||
const ActivityLogScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ActivityLogScreen> createState() => _ActivityLogScreenState();
|
||||
}
|
||||
|
||||
class _ActivityLogScreenState extends State<ActivityLogScreen> {
|
||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||
List<ActivityLog> _logs = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLogs();
|
||||
}
|
||||
|
||||
Future<void> _loadLogs() async {
|
||||
setState(() => _isLoading = true);
|
||||
final logs = await _logRepo.getAllLogs();
|
||||
setState(() {
|
||||
_logs = logs;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat('yyyy/MM/dd HH:mm:ss');
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("アクティビティ履歴 (Gitログ風)"),
|
||||
backgroundColor: Colors.blueGrey.shade800,
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadLogs),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _logs.isEmpty
|
||||
? const Center(child: Text("履歴はありません"))
|
||||
: ListView.builder(
|
||||
itemCount: _logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = _logs[index];
|
||||
return _buildLogTile(log, dateFormat);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogTile(ActivityLog log, DateFormat fmt) {
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
switch (log.action) {
|
||||
case "SAVE_INVOICE":
|
||||
case "SAVE_PRODUCT":
|
||||
case "SAVE_CUSTOMER":
|
||||
icon = Icons.save;
|
||||
color = Colors.green;
|
||||
break;
|
||||
case "DELETE_INVOICE":
|
||||
case "DELETE_PRODUCT":
|
||||
case "DELETE_CUSTOMER":
|
||||
icon = Icons.delete_forever;
|
||||
color = Colors.red;
|
||||
break;
|
||||
case "GENERATE_PDF":
|
||||
icon = Icons.picture_as_pdf;
|
||||
color = Colors.orange;
|
||||
break;
|
||||
default:
|
||||
icon = Icons.info_outline;
|
||||
color = Colors.blueGrey;
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: Colors.grey.shade200),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: color.withOpacity(0.1),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
title: Text(
|
||||
"${_getActionJapanese(log.action)} [${log.targetType}]",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (log.details != null)
|
||||
Text(log.details!, style: const TextStyle(fontSize: 12, color: Colors.black87)),
|
||||
const SizedBox(height: 4),
|
||||
Text(fmt.format(log.timestamp), style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
isThreeLine: log.details != null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getActionJapanese(String action) {
|
||||
switch (action) {
|
||||
case "SAVE_INVOICE": return "伝票保存";
|
||||
case "DELETE_INVOICE": return "伝票削除";
|
||||
case "SAVE_PRODUCT": return "商品更新";
|
||||
case "DELETE_PRODUCT": return "商品削除";
|
||||
case "SAVE_CUSTOMER": return "顧客更新";
|
||||
case "DELETE_CUSTOMER": return "顧客削除";
|
||||
case "GENERATE_PDF": return "PDF発行";
|
||||
default: return action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
|
|||
final _addressController = TextEditingController();
|
||||
final _telController = TextEditingController();
|
||||
double _taxRate = 0.10;
|
||||
String _taxDisplayMode = 'normal';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -35,6 +36,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
|
|||
_addressController.text = _info.address ?? "";
|
||||
_telController.text = _info.tel ?? "";
|
||||
_taxRate = _info.defaultTaxRate;
|
||||
_taxDisplayMode = _info.taxDisplayMode;
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +57,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
|
|||
address: _addressController.text,
|
||||
tel: _telController.text,
|
||||
defaultTaxRate: _taxRate,
|
||||
taxDisplayMode: _taxDisplayMode,
|
||||
);
|
||||
await _companyRepo.saveCompanyInfo(updated);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("自社情報を保存しました")));
|
||||
|
|
@ -94,6 +97,29 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
|
|||
ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text("消費税の表示設定(T番号非取得時など)", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text("通常表示"),
|
||||
selected: _taxDisplayMode == 'normal',
|
||||
onSelected: (_) => setState(() => _taxDisplayMode = 'normal'),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text("表示しない"),
|
||||
selected: _taxDisplayMode == 'hidden',
|
||||
onSelected: (_) => setState(() => _taxDisplayMode = 'hidden'),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text("「税別」と表示"),
|
||||
selected: _taxDisplayMode == 'text_only',
|
||||
onSelected: (_) => setState(() => _taxDisplayMode = 'text_only'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text("印影(角印)撮影", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
|
|
|
|||
|
|
@ -28,29 +28,18 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCustomers();
|
||||
_onSearch(""); // 初期表示
|
||||
}
|
||||
|
||||
Future<void> _loadCustomers() async {
|
||||
Future<void> _onSearch(String query) async {
|
||||
setState(() => _isLoading = true);
|
||||
final customers = await _repository.getAllCustomers();
|
||||
final customers = await _repository.searchCustomers(query);
|
||||
setState(() {
|
||||
_allCustomers = customers;
|
||||
_filteredCustomers = customers;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _filterCustomers(String query) {
|
||||
setState(() {
|
||||
_searchQuery = query.toLowerCase();
|
||||
_filteredCustomers = _allCustomers.where((customer) {
|
||||
return customer.formalName.toLowerCase().contains(_searchQuery) ||
|
||||
customer.displayName.toLowerCase().contains(_searchQuery);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// 電話帳から取り込んで新規顧客として登録・編集するダイアログ
|
||||
Future<void> _importFromPhoneContacts() async {
|
||||
setState(() => _isImportingFromContacts = true);
|
||||
|
|
@ -149,7 +138,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
|||
|
||||
await _repository.saveCustomer(updatedCustomer);
|
||||
Navigator.pop(context); // エディットダイアログを閉じる
|
||||
_loadCustomers(); // リストを再読込
|
||||
_onSearch(""); // リストを再読込
|
||||
|
||||
// 保存のついでに選択状態にするなら以下を有効化(今回は明示的にリストから選ばせる)
|
||||
// widget.onCustomerSelected(updatedCustomer);
|
||||
|
|
@ -174,7 +163,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
|||
onPressed: () async {
|
||||
await _repository.deleteCustomer(customer.id);
|
||||
Navigator.pop(context);
|
||||
_loadCustomers();
|
||||
_onSearch("");
|
||||
},
|
||||
child: const Text("削除する", style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
|
|
@ -207,7 +196,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
|||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onChanged: _filterCustomers,
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
|
|
|
|||
61
lib/screens/gps_history_screen.dart
Normal file
61
lib/screens/gps_history_screen.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../services/gps_service.dart';
|
||||
|
||||
class GpsHistoryScreen extends StatefulWidget {
|
||||
const GpsHistoryScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<GpsHistoryScreen> createState() => _GpsHistoryScreenState();
|
||||
}
|
||||
|
||||
class _GpsHistoryScreenState extends State<GpsHistoryScreen> {
|
||||
final _gpsService = GpsService();
|
||||
List<Map<String, dynamic>> _history = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
setState(() => _isLoading = true);
|
||||
final history = await _gpsService.getHistory(limit: 50);
|
||||
setState(() {
|
||||
_history = history;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("GPS位置情報履歴"),
|
||||
backgroundColor: Colors.blueGrey,
|
||||
actions: [
|
||||
IconButton(onPressed: _loadHistory, icon: const Icon(Icons.refresh)),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _history.isEmpty
|
||||
? const Center(child: Text("位置情報の履歴がありません。"))
|
||||
: ListView.builder(
|
||||
itemCount: _history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _history[index];
|
||||
final date = DateTime.parse(item['timestamp']);
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.location_on)),
|
||||
title: Text("${item['latitude'].toStringAsFixed(6)}, ${item['longitude'].toStringAsFixed(6)}"),
|
||||
subtitle: Text(DateFormat('yyyy/MM/dd HH:mm:ss').format(date)),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,11 @@ import '../models/invoice_models.dart';
|
|||
import '../services/pdf_generator.dart';
|
||||
import '../services/invoice_repository.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import '../services/company_repository.dart';
|
||||
import 'product_picker_modal.dart';
|
||||
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
|
||||
import '../services/print_service.dart';
|
||||
import '../models/company_model.dart';
|
||||
|
||||
class InvoiceDetailPage extends StatefulWidget {
|
||||
final Invoice invoice;
|
||||
|
|
@ -27,6 +31,8 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
String? _currentFilePath;
|
||||
final _invoiceRepo = InvoiceRepository();
|
||||
final _customerRepo = CustomerRepository();
|
||||
final _companyRepo = CompanyRepository();
|
||||
CompanyInfo? _companyInfo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -37,6 +43,12 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
_notesController = TextEditingController(text: _currentInvoice.notes ?? "");
|
||||
_items = List.from(_currentInvoice.items);
|
||||
_isEditing = false;
|
||||
_loadCompanyInfo();
|
||||
}
|
||||
|
||||
Future<void> _loadCompanyInfo() async {
|
||||
final info = await _companyRepo.getCompanyInfo();
|
||||
setState(() => _companyInfo = info);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -127,6 +139,59 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
Share.share(csvData, subject: '請求書データ_CSV');
|
||||
}
|
||||
|
||||
Future<void> _printReceipt() async {
|
||||
final printService = PrintService();
|
||||
final isConnected = await printService.isConnected;
|
||||
|
||||
if (!isConnected) {
|
||||
final devices = await printService.getPairedDevices();
|
||||
if (devices.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ペアリング済みのデバイスが見つかりません。OSの設定を確認してください。")));
|
||||
return;
|
||||
}
|
||||
|
||||
final selected = await showDialog<BluetoothInfo>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("プリンターを選択"),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, idx) => ListTile(
|
||||
leading: const Icon(Icons.print),
|
||||
title: Text(devices[idx].name ?? "Unknown Device"),
|
||||
subtitle: Text(devices[idx].macAdress ?? ""),
|
||||
onTap: () => Navigator.pop(context, devices[idx]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (selected != null) {
|
||||
final ok = await printService.connect(selected.macAdress!);
|
||||
if (!ok) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("接続に失敗しました。")));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final success = await printService.printReceipt(_currentInvoice);
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("レシートを印刷しました。")));
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("印刷エラーが発生しました。")));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final amountFormatter = NumberFormat("#,###");
|
||||
|
|
@ -214,6 +279,18 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
const SizedBox(height: 4),
|
||||
Text("請求番号: ${_currentInvoice.invoiceNumber}"),
|
||||
Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"),
|
||||
if (_currentInvoice.latitude != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
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) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)),
|
||||
|
|
@ -290,9 +367,12 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
child: Column(
|
||||
children: [
|
||||
_SummaryRow("小計 (税抜)", formatter.format(subtotal)),
|
||||
if (_companyInfo?.taxDisplayMode == 'normal')
|
||||
_SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)),
|
||||
if (_companyInfo?.taxDisplayMode == 'text_only')
|
||||
_SummaryRow("消費税", "(税別)"),
|
||||
const Divider(),
|
||||
_SummaryRow("合計 (税込)", "¥${formatter.format(total)}", isBold: true),
|
||||
_SummaryRow("合計", "¥${formatter.format(total)}", isBold: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -324,6 +404,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _printReceipt,
|
||||
icon: const Icon(Icons.print),
|
||||
label: const Text("レシート"),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade800, foregroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ import '../models/invoice_models.dart';
|
|||
import '../services/pdf_generator.dart';
|
||||
import '../services/invoice_repository.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import '../services/gps_service.dart';
|
||||
import 'customer_picker_modal.dart';
|
||||
import 'product_picker_modal.dart';
|
||||
import '../models/company_model.dart';
|
||||
import '../services/company_repository.dart';
|
||||
|
||||
class InvoiceInputForm extends StatefulWidget {
|
||||
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
|
||||
|
|
@ -27,6 +31,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
final List<InvoiceItem> _items = [];
|
||||
double _taxRate = 0.10;
|
||||
bool _includeTax = true;
|
||||
CompanyInfo? _companyInfo;
|
||||
DocumentType _documentType = DocumentType.invoice; // 追加
|
||||
String _status = "取引先と商品を入力してください";
|
||||
|
||||
// 署名用の実験的パス
|
||||
|
|
@ -45,6 +51,13 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
if (customers.isNotEmpty) {
|
||||
setState(() => _selectedCustomer = customers.first);
|
||||
}
|
||||
|
||||
final companyRepo = CompanyRepository();
|
||||
final companyInfo = await companyRepo.getCompanyInfo();
|
||||
setState(() {
|
||||
_companyInfo = companyInfo;
|
||||
_taxRate = companyInfo.defaultTaxRate;
|
||||
});
|
||||
}
|
||||
|
||||
void _addItem() {
|
||||
|
|
@ -74,13 +87,23 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
return;
|
||||
}
|
||||
|
||||
// GPS情報の取得
|
||||
final gpsService = GpsService();
|
||||
final pos = await gpsService.getCurrentLocation();
|
||||
if (pos != null) {
|
||||
await gpsService.logLocation(); // 履歴テーブルにも保存
|
||||
}
|
||||
|
||||
final invoice = Invoice(
|
||||
customer: _selectedCustomer!,
|
||||
date: DateTime.now(),
|
||||
items: _items,
|
||||
taxRate: _includeTax ? _taxRate : 0.0,
|
||||
documentType: _documentType,
|
||||
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
||||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||||
latitude: pos?.latitude,
|
||||
longitude: pos?.longitude,
|
||||
);
|
||||
|
||||
if (generatePdf) {
|
||||
|
|
@ -101,28 +124,44 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
|
||||
void _showPreview() {
|
||||
if (_selectedCustomer == null) return;
|
||||
final invoice = Invoice(
|
||||
customer: _selectedCustomer!,
|
||||
date: DateTime.now(),
|
||||
items: _items,
|
||||
taxRate: _includeTax ? _taxRate : 0.0,
|
||||
documentType: _documentType,
|
||||
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
||||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||||
);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("伝票プレビュー(仮)"),
|
||||
content: SingleChildScrollView(
|
||||
builder: (context) => Dialog.fullscreen(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("宛名: ${_selectedCustomer!.formalName} ${_selectedCustomer!.title}"),
|
||||
const Divider(),
|
||||
..._items.map((it) => Text("・${it.description} x ${it.quantity} = ¥${it.subtotal}")),
|
||||
const Divider(),
|
||||
Text("小計: ¥${NumberFormat("#,###").format(_subTotal)}"),
|
||||
Text("消費税: ¥${NumberFormat("#,###").format(_tax)}"),
|
||||
Text("合計: ¥${NumberFormat("#,###").format(_total)}", style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
AppBar(
|
||||
title: Text("${invoice.documentTypeName}プレビュー"),
|
||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
Expanded(
|
||||
child: PdfPreview(
|
||||
build: (format) async {
|
||||
// PdfGeneratorを少しリファクタして pw.Document を返す関数に分離することも可能だが
|
||||
// ここでは generateInvoicePdf の中身を模したバイト生成を行う
|
||||
// (もしくは generateInvoicePdf のシグネチャを変えてバイトを返すようにする)
|
||||
// 簡易化のため、一時ファイルを作ってそれを読み込むか、Generatorを修正する
|
||||
// 今回は Generator に pw.Document を生成する内部関数を作る
|
||||
final pdfDoc = await buildInvoiceDocument(invoice);
|
||||
return pdfDoc.save();
|
||||
},
|
||||
allowPrinting: false,
|
||||
allowSharing: false,
|
||||
canChangePageFormat: false,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("閉じる")),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +177,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDocumentTypeSection(), // 追加
|
||||
const SizedBox(height: 16),
|
||||
_buildCustomerSection(),
|
||||
const SizedBox(height: 20),
|
||||
_buildItemsSection(fmt),
|
||||
|
|
@ -156,6 +197,51 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildDocumentTypeSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: DocumentType.values.map((type) {
|
||||
final isSelected = _documentType == type;
|
||||
String label = "";
|
||||
IconData icon = Icons.description;
|
||||
switch (type) {
|
||||
case DocumentType.estimation: label = "見積"; icon = Icons.article_outlined; break;
|
||||
case DocumentType.delivery: label = "納品"; icon = Icons.local_shipping_outlined; break;
|
||||
case DocumentType.invoice: label = "請求"; icon = Icons.receipt_long_outlined; break;
|
||||
case DocumentType.receipt: label = "領収"; icon = Icons.payments_outlined; break;
|
||||
}
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => _documentType = type),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.indigo : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: isSelected ? Colors.white : Colors.grey.shade600, size: 20),
|
||||
Text(label, style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomerSection() {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
|
|
@ -268,8 +354,11 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSummaryRow("小計", "¥${fmt.format(_subTotal)}", Colors.white70),
|
||||
_buildSummaryRow("消費税", "¥${fmt.format(_tax)}", Colors.white70),
|
||||
_buildSummaryRow("小計 (税抜)", "¥${fmt.format(_subTotal)}", Colors.white70),
|
||||
if (_companyInfo?.taxDisplayMode == 'normal')
|
||||
_buildSummaryRow("消費税 (${(_taxRate * 100).toInt()}%)", "¥${fmt.format(_tax)}", Colors.white70),
|
||||
if (_companyInfo?.taxDisplayMode == 'text_only')
|
||||
_buildSummaryRow("消費税", "(税別)", Colors.white70),
|
||||
const Divider(color: Colors.white24),
|
||||
_buildSummaryRow("合計金額", "¥${fmt.format(_total)}", Colors.white, fontSize: 24),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import 'package:flutter/material.dart';
|
|||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../services/invoice_repository.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import 'product_master_screen.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'activity_log_screen.dart';
|
||||
import 'sales_report_screen.dart';
|
||||
import 'gps_history_screen.dart';
|
||||
|
||||
class ManagementScreen extends StatelessWidget {
|
||||
const ManagementScreen({Key? key}) : super(key: key);
|
||||
|
|
@ -58,6 +62,20 @@ class ManagementScreen extends StatelessWidget {
|
|||
"SQLiteファイルを外部へ保存・シェアします",
|
||||
() => _backupDatabase(context),
|
||||
),
|
||||
_buildMenuTile(
|
||||
context,
|
||||
Icons.list_alt,
|
||||
"アクティビティ履歴 (Git風)",
|
||||
"データの作成・変更・削除の履歴を確認します",
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const ActivityLogScreen())),
|
||||
),
|
||||
_buildMenuTile(
|
||||
context,
|
||||
Icons.analytics_outlined,
|
||||
"売上・資金管理レポート",
|
||||
"売上や資金の流れを分析します", // Added subtitle
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const SalesReportScreen())),
|
||||
),
|
||||
_buildMenuTile(
|
||||
context,
|
||||
Icons.settings_backup_restore,
|
||||
|
|
@ -65,6 +83,13 @@ class ManagementScreen extends StatelessWidget {
|
|||
"バックアップから全てのデータを復元します",
|
||||
() => _showComingSoon(context),
|
||||
),
|
||||
_buildMenuTile(
|
||||
context,
|
||||
Icons.location_history,
|
||||
"GPS座標履歴の管理",
|
||||
"過去に取得した位置情報の履歴を確認します",
|
||||
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const GpsHistoryScreen())),
|
||||
),
|
||||
const Divider(),
|
||||
_buildSectionHeader("外部同期 (将来のOdoo連携)"),
|
||||
_buildMenuTile(
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@ class ProductMasterScreen extends StatefulWidget {
|
|||
|
||||
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||
final ProductRepository _productRepo = ProductRepository();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
List<Product> _products = [];
|
||||
List<Product> _filteredProducts = [];
|
||||
bool _isLoading = true;
|
||||
String _searchQuery = "";
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -28,40 +32,46 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
setState(() {
|
||||
_products = products;
|
||||
_isLoading = false;
|
||||
_applyFilter();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addItem({Product? product}) async {
|
||||
final isEdit = product != null;
|
||||
void _applyFilter() {
|
||||
setState(() {
|
||||
_filteredProducts = _products.where((p) {
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return p.name.toLowerCase().contains(query) ||
|
||||
(p.barcode?.toLowerCase().contains(query) ?? false) ||
|
||||
(p.category?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showEditDialog({Product? product}) async {
|
||||
final nameController = TextEditingController(text: product?.name ?? "");
|
||||
final priceController = TextEditingController(text: product?.defaultUnitPrice.toString() ?? "0");
|
||||
final priceController = TextEditingController(text: (product?.defaultUnitPrice ?? 0).toString());
|
||||
final barcodeController = TextEditingController(text: product?.barcode ?? "");
|
||||
final categoryController = TextEditingController(text: product?.category ?? "");
|
||||
final stockController = TextEditingController(text: (product?.stockQuantity ?? 0).toString());
|
||||
|
||||
final result = await showDialog<Product>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(isEdit ? "商品を編集" : "商品を新規登録"),
|
||||
content: Column(
|
||||
title: Text(product == null ? "商品追加" : "商品編集"),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: "商品名"),
|
||||
),
|
||||
TextField(
|
||||
controller: priceController,
|
||||
decoration: const InputDecoration(labelText: "初期単価"),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")),
|
||||
TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")),
|
||||
TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number),
|
||||
TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: barcodeController,
|
||||
decoration: const InputDecoration(labelText: "バーコード"),
|
||||
),
|
||||
child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
|
|
@ -71,9 +81,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()),
|
||||
);
|
||||
if (code != null) {
|
||||
setDialogState(() {
|
||||
barcodeController.text = code;
|
||||
});
|
||||
setDialogState(() => barcodeController.text = code);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -81,19 +89,24 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
TextButton(
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (nameController.text.isEmpty) return;
|
||||
final newProduct = Product(
|
||||
Navigator.pop(
|
||||
context,
|
||||
Product(
|
||||
id: product?.id ?? const Uuid().v4(),
|
||||
name: nameController.text,
|
||||
name: nameController.text.trim(),
|
||||
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
|
||||
barcode: barcodeController.text.isEmpty ? null : barcodeController.text,
|
||||
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
|
||||
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
|
||||
stockQuantity: int.tryParse(stockController.text) ?? 0,
|
||||
odooId: product?.odooId,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context, newProduct);
|
||||
},
|
||||
child: const Text("保存"),
|
||||
),
|
||||
|
|
@ -112,42 +125,67 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("商品マスター管理"),
|
||||
title: const Text("商品マスター"),
|
||||
backgroundColor: Colors.blueGrey,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "商品名・バーコード・カテゴリで検索",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: (val) {
|
||||
_searchQuery = val;
|
||||
_applyFilter();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _products.isEmpty
|
||||
? const Center(child: Text("商品が登録されていません"))
|
||||
: _filteredProducts.isEmpty
|
||||
? const Center(child: Text("商品が見つかりません"))
|
||||
: ListView.builder(
|
||||
itemCount: _products.length,
|
||||
itemCount: _filteredProducts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final p = _products[index];
|
||||
final p = _filteredProducts[index];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.inventory_2)),
|
||||
title: Text(p.name),
|
||||
subtitle: Text("初期単価: ¥${p.defaultUnitPrice}"),
|
||||
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _addItem(product: p)),
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _showEditDialog(product: p)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () async {
|
||||
final confirm = await showDialog<bool>(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text("削除確認"),
|
||||
content: Text("「${p.name}」を削除しますか?"),
|
||||
title: const Text("削除の確認"),
|
||||
content: Text("${p.name}を削除してよろしいですか?"),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await _productRepo.deleteProduct(p.id);
|
||||
Navigator.pop(context);
|
||||
_loadProducts();
|
||||
},
|
||||
child: const Text("削除", style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
await _productRepo.deleteProduct(p.id);
|
||||
_loadProducts();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
|
|
@ -156,9 +194,10 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _addItem,
|
||||
onPressed: () => _showEditDialog(),
|
||||
child: const Icon(Icons.add),
|
||||
backgroundColor: Colors.indigo,
|
||||
backgroundColor: Colors.blueGrey.shade800,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,18 +16,19 @@ class ProductPickerModal extends StatefulWidget {
|
|||
|
||||
class _ProductPickerModalState extends State<ProductPickerModal> {
|
||||
final ProductRepository _productRepo = ProductRepository();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<Product> _products = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProducts();
|
||||
_onSearch(""); // 初期表示
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
Future<void> _onSearch(String val) async {
|
||||
setState(() => _isLoading = true);
|
||||
final products = await _productRepo.getAllProducts();
|
||||
final products = await _productRepo.searchProducts(val);
|
||||
setState(() {
|
||||
_products = products;
|
||||
_isLoading = false;
|
||||
|
|
@ -53,6 +54,25 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
|||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "商品名・カテゴリ・バーコードで検索",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () { _searchController.clear(); _onSearch(""); },
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||
),
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
|
|
@ -62,13 +82,13 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
|||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("商品マスターが空です"),
|
||||
const Text("商品が見つかりません"),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
|
||||
_loadProducts();
|
||||
_onSearch(_searchController.text);
|
||||
},
|
||||
child: const Text("商品マスターを編集する"),
|
||||
child: const Text("マスターに追加する"),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -80,9 +100,10 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
|||
return ListTile(
|
||||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: Text(product.name),
|
||||
subtitle: Text("初期単価: ¥${product.defaultUnitPrice}"),
|
||||
subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
|
||||
onTap: () => widget.onItemSelected(
|
||||
InvoiceItem(
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
quantity: 1,
|
||||
unitPrice: product.defaultUnitPrice,
|
||||
|
|
@ -92,18 +113,6 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
|||
},
|
||||
),
|
||||
),
|
||||
if (_products.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.edit),
|
||||
label: const Text("商品マスターの管理"),
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
|
||||
_loadProducts();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
138
lib/screens/sales_report_screen.dart
Normal file
138
lib/screens/sales_report_screen.dart
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../services/invoice_repository.dart';
|
||||
|
||||
class SalesReportScreen extends StatefulWidget {
|
||||
const SalesReportScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SalesReportScreen> createState() => _SalesReportScreenState();
|
||||
}
|
||||
|
||||
class _SalesReportScreenState extends State<SalesReportScreen> {
|
||||
final _invoiceRepo = InvoiceRepository();
|
||||
int _targetYear = DateTime.now().year;
|
||||
Map<String, int> _monthlySales = {};
|
||||
int _yearlyTotal = 0;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _isLoading = true);
|
||||
final monthly = await _invoiceRepo.getMonthlySales(_targetYear);
|
||||
final yearly = await _invoiceRepo.getYearlyTotal(_targetYear);
|
||||
setState(() {
|
||||
_monthlySales = monthly;
|
||||
_yearlyTotal = yearly;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = NumberFormat("#,###");
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("売上・資金管理レポート"),
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
_buildYearSelector(),
|
||||
_buildYearlySummary(fmt),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildMonthlyList(fmt)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildYearSelector() {
|
||||
return Container(
|
||||
color: Colors.indigo.shade50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () {
|
||||
setState(() => _targetYear--);
|
||||
_loadData();
|
||||
},
|
||||
),
|
||||
Text(
|
||||
"$_targetYear年度",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: () {
|
||||
setState(() => _targetYear++);
|
||||
_loadData();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildYearlySummary(NumberFormat fmt) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo.shade900,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text("年間売上合計 (請求確定分)", style: TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"¥${fmt.format(_yearlyTotal)}",
|
||||
style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthlyList(NumberFormat fmt) {
|
||||
return ListView.builder(
|
||||
itemCount: 12,
|
||||
itemBuilder: (context, index) {
|
||||
final month = (index + 1).toString().padLeft(2, '0');
|
||||
final amount = _monthlySales[month] ?? 0;
|
||||
final percentage = _yearlyTotal > 0 ? (amount / _yearlyTotal * 100).toStringAsFixed(1) : "0.0";
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.blueGrey.shade100,
|
||||
child: Text("${index + 1}", style: const TextStyle(color: Colors.indigo)),
|
||||
),
|
||||
title: Text("${index + 1}月の売上"),
|
||||
subtitle: amount > 0 ? Text("シェア: $percentage%") : null,
|
||||
trailing: Text(
|
||||
"¥${fmt.format(amount)}",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: amount > 0 ? Colors.black87 : Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// FontWeight.bold in Text widget is TextStyle.fontWeight not pw.FontWeight
|
||||
// Corrected to FontWeight.bold below in replace or write.
|
||||
36
lib/services/activity_log_repository.dart
Normal file
36
lib/services/activity_log_repository.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import '../models/activity_log_model.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class ActivityLogRepository {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> log(ActivityLog log) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert('activity_logs', log.toMap());
|
||||
}
|
||||
|
||||
Future<void> logAction({
|
||||
required String action,
|
||||
required String targetType,
|
||||
String? targetId,
|
||||
String? details,
|
||||
}) async {
|
||||
final activity = ActivityLog.create(
|
||||
action: action,
|
||||
targetType: targetType,
|
||||
targetId: targetId,
|
||||
details: details,
|
||||
);
|
||||
await log(activity);
|
||||
}
|
||||
|
||||
Future<List<ActivityLog>> getAllLogs({int limit = 100}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'activity_logs',
|
||||
orderBy: 'timestamp DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return List.generate(maps.length, (i) => ActivityLog.fromMap(maps[i]));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@ import 'package:sqflite/sqflite.dart';
|
|||
import '../models/customer_model.dart';
|
||||
import 'database_helper.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'activity_log_repository.dart';
|
||||
|
||||
class CustomerRepository {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||
|
||||
Future<List<Customer>> getAllCustomers() async {
|
||||
final db = await _dbHelper.database;
|
||||
|
|
@ -43,11 +45,29 @@ class CustomerRepository {
|
|||
customer.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
await _logRepo.logAction(
|
||||
action: "SAVE_CUSTOMER",
|
||||
targetType: "CUSTOMER",
|
||||
targetId: customer.id,
|
||||
details: "名称: ${customer.formalName}, 敬称: ${customer.title}",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCustomer(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('customers', where: 'id = ?', whereArgs: [id]);
|
||||
await db.delete(
|
||||
'customers',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
await _logRepo.logAction(
|
||||
action: "DELETE_CUSTOMER",
|
||||
targetType: "CUSTOMER",
|
||||
targetId: id,
|
||||
details: "顧客を削除しました",
|
||||
);
|
||||
}
|
||||
|
||||
// GPS履歴の保存 (直近10件を自動管理)
|
||||
|
|
@ -86,4 +106,16 @@ class CustomerRepository {
|
|||
orderBy: 'timestamp DESC',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Customer>> searchCustomers(String query) async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'customers',
|
||||
where: 'display_name LIKE ? OR formal_name LIKE ?',
|
||||
whereArgs: ['%$query%', '%$query%'],
|
||||
orderBy: 'display_name ASC',
|
||||
limit: 50,
|
||||
);
|
||||
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
|||
import 'package:path/path.dart';
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseVersion = 9;
|
||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||
static Database? _database;
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ class DatabaseHelper {
|
|||
String path = join(await getDatabasesPath(), 'gemi_invoice.db');
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 5,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
|
|
@ -46,13 +47,46 @@ class DatabaseHelper {
|
|||
await db.execute('ALTER TABLE products ADD COLUMN barcode TEXT');
|
||||
}
|
||||
if (oldVersion < 5) {
|
||||
// 顧客情報のスナップショット
|
||||
await db.execute('ALTER TABLE invoices ADD COLUMN customer_formal_name TEXT');
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
await db.execute('ALTER TABLE products ADD COLUMN category TEXT');
|
||||
await db.execute('CREATE INDEX idx_products_name ON products(name)');
|
||||
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
|
||||
await db.execute('''
|
||||
CREATE TABLE activity_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
details TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
await db.execute('ALTER TABLE products ADD COLUMN stock_quantity INTEGER DEFAULT 0');
|
||||
await db.execute('ALTER TABLE invoices ADD COLUMN document_type TEXT DEFAULT "invoice"');
|
||||
await db.execute('ALTER TABLE invoice_items ADD COLUMN product_id TEXT');
|
||||
}
|
||||
if (oldVersion < 8) {
|
||||
await db.execute('ALTER TABLE invoices ADD COLUMN latitude REAL');
|
||||
await db.execute('ALTER TABLE invoices ADD COLUMN longitude REAL');
|
||||
await db.execute('''
|
||||
CREATE TABLE app_gps_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
if (oldVersion < 9) {
|
||||
await db.execute('ALTER TABLE company_info ADD COLUMN tax_display_mode TEXT DEFAULT "normal"');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
// 顧客マスター
|
||||
await db.execute('''
|
||||
CREATE TABLE customers (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
|
@ -68,7 +102,6 @@ class DatabaseHelper {
|
|||
)
|
||||
''');
|
||||
|
||||
// GPS履歴 (直近10件想定だがDB上は保持)
|
||||
await db.execute('''
|
||||
CREATE TABLE customer_gps_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -87,9 +120,13 @@ class DatabaseHelper {
|
|||
name TEXT NOT NULL,
|
||||
default_unit_price INTEGER,
|
||||
barcode TEXT,
|
||||
category TEXT,
|
||||
stock_quantity INTEGER DEFAULT 0,
|
||||
odoo_id TEXT
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX idx_products_name ON products(name)');
|
||||
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
|
||||
|
||||
// 伝票マスター
|
||||
await db.execute('''
|
||||
|
|
@ -101,19 +138,32 @@ class DatabaseHelper {
|
|||
file_path TEXT,
|
||||
total_amount INTEGER,
|
||||
tax_rate REAL DEFAULT 0.10,
|
||||
document_type TEXT DEFAULT "invoice",
|
||||
customer_formal_name TEXT,
|
||||
odoo_id TEXT,
|
||||
is_synced INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE app_gps_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
// 伝票明細
|
||||
await db.execute('''
|
||||
CREATE TABLE invoice_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
product_id TEXT,
|
||||
description TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price INTEGER NOT NULL,
|
||||
|
|
@ -121,7 +171,6 @@ class DatabaseHelper {
|
|||
)
|
||||
''');
|
||||
|
||||
// 自社情報
|
||||
await db.execute('''
|
||||
CREATE TABLE company_info (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
|
@ -130,7 +179,19 @@ class DatabaseHelper {
|
|||
address TEXT,
|
||||
tel TEXT,
|
||||
default_tax_rate REAL DEFAULT 0.10,
|
||||
seal_path TEXT
|
||||
seal_path TEXT,
|
||||
tax_display_mode TEXT DEFAULT "normal"
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE activity_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
details TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
|
|
|||
53
lib/services/gps_service.dart
Normal file
53
lib/services/gps_service.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:geolocator/geolocator.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class GpsService {
|
||||
final _dbHelper = DatabaseHelper();
|
||||
|
||||
/// 現在地の取得(権限チェック含む)
|
||||
Future<Position?> getCurrentLocation() async {
|
||||
bool serviceEnabled;
|
||||
LocationPermission permission;
|
||||
|
||||
serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) return null;
|
||||
|
||||
permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) return null;
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) return null;
|
||||
|
||||
try {
|
||||
return await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.medium,
|
||||
timeLimit: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 現在地を履歴としてデータベースに記録
|
||||
Future<void> logLocation() async {
|
||||
final pos = await getCurrentLocation();
|
||||
if (pos == null) return;
|
||||
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert('app_gps_history', {
|
||||
'latitude': pos.latitude,
|
||||
'longitude': pos.longitude,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 指定件数のGPS履歴を取得
|
||||
Future<List<Map<String, dynamic>>> getHistory({int limit = 10}) async {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.query('app_gps_history', orderBy: 'timestamp DESC', limit: limit);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,33 @@ import 'package:path_provider/path_provider.dart';
|
|||
import '../models/invoice_models.dart';
|
||||
import '../models/customer_model.dart';
|
||||
import 'database_helper.dart';
|
||||
import 'activity_log_repository.dart';
|
||||
|
||||
class InvoiceRepository {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||
|
||||
Future<void> saveInvoice(Invoice invoice) async {
|
||||
final db = await _dbHelper.database;
|
||||
|
||||
await db.transaction((txn) async {
|
||||
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
|
||||
final List<Map<String, dynamic>> oldItems = await txn.query(
|
||||
'invoice_items',
|
||||
where: 'invoice_id = ?',
|
||||
whereArgs: [invoice.id],
|
||||
);
|
||||
|
||||
// 旧在庫を戻す
|
||||
for (var item in oldItems) {
|
||||
if (item['product_id'] != null) {
|
||||
await txn.execute(
|
||||
'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?',
|
||||
[item['quantity'], item['product_id']],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 伝票ヘッダーの保存
|
||||
await txn.insert(
|
||||
'invoices',
|
||||
|
|
@ -19,18 +38,31 @@ class InvoiceRepository {
|
|||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
// 既存の明細を一旦削除(更新対応)
|
||||
// 既存の明細を一旦削除
|
||||
await txn.delete(
|
||||
'invoice_items',
|
||||
where: 'invoice_id = ?',
|
||||
whereArgs: [invoice.id],
|
||||
);
|
||||
|
||||
// 新しい明細の保存
|
||||
// 新しい明細の保存と在庫の減算
|
||||
for (var item in invoice.items) {
|
||||
await txn.insert('invoice_items', item.toMap(invoice.id));
|
||||
if (item.productId != null) {
|
||||
await txn.execute(
|
||||
'UPDATE products SET stock_quantity = stock_quantity - ? WHERE id = ?',
|
||||
[item.quantity, item.productId],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _logRepo.logAction(
|
||||
action: "SAVE_INVOICE",
|
||||
targetType: "INVOICE",
|
||||
targetId: invoice.id,
|
||||
details: "種別: ${invoice.documentTypeName}, 取引先: ${invoice.customerNameForDisplay}, 合計: ¥${invoice.totalAmount}",
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Invoice>> getAllInvoices(List<Customer> customers) async {
|
||||
|
|
@ -52,6 +84,14 @@ class InvoiceRepository {
|
|||
|
||||
final items = List.generate(itemMaps.length, (i) => InvoiceItem.fromMap(itemMaps[i]));
|
||||
|
||||
// document_typeのパース
|
||||
DocumentType docType = DocumentType.invoice;
|
||||
if (iMap['document_type'] != null) {
|
||||
try {
|
||||
docType = DocumentType.values.firstWhere((e) => e.name == iMap['document_type']);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
invoices.add(Invoice(
|
||||
id: iMap['id'],
|
||||
customer: customer,
|
||||
|
|
@ -60,10 +100,13 @@ class InvoiceRepository {
|
|||
notes: iMap['notes'],
|
||||
filePath: iMap['file_path'],
|
||||
taxRate: iMap['tax_rate'] ?? 0.10,
|
||||
customerFormalNameSnapshot: iMap['customer_formal_name'], // 追加
|
||||
documentType: docType,
|
||||
customerFormalNameSnapshot: iMap['customer_formal_name'],
|
||||
odooId: iMap['odoo_id'],
|
||||
isSynced: iMap['is_synced'] == 1,
|
||||
updatedAt: DateTime.parse(iMap['updated_at']),
|
||||
latitude: iMap['latitude'],
|
||||
longitude: iMap['longitude'],
|
||||
));
|
||||
}
|
||||
return invoices;
|
||||
|
|
@ -72,6 +115,22 @@ class InvoiceRepository {
|
|||
Future<void> deleteInvoice(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.transaction((txn) async {
|
||||
// 在庫の復元
|
||||
final List<Map<String, dynamic>> items = await txn.query(
|
||||
'invoice_items',
|
||||
where: 'invoice_id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
for (var item in items) {
|
||||
if (item['product_id'] != null) {
|
||||
await txn.execute(
|
||||
'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?',
|
||||
[item['quantity'], item['product_id']],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PDFパスの取得(削除用)
|
||||
final List<Map<String, dynamic>> maps = await txn.query(
|
||||
'invoices',
|
||||
|
|
@ -122,4 +181,34 @@ class InvoiceRepository {
|
|||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getMonthlySales(int year) async {
|
||||
final db = await _dbHelper.database;
|
||||
final String yearStr = year.toString();
|
||||
final List<Map<String, dynamic>> results = await db.rawQuery('''
|
||||
SELECT strftime('%m', date) as month, SUM(total_amount) as total
|
||||
FROM invoices
|
||||
WHERE strftime('%Y', date) = ? AND document_type = 'invoice'
|
||||
GROUP BY month
|
||||
ORDER BY month ASC
|
||||
''', [yearStr]);
|
||||
|
||||
Map<String, int> monthlyTotal = {};
|
||||
for (var r in results) {
|
||||
monthlyTotal[r['month']] = (r['total'] as num).toInt();
|
||||
}
|
||||
return monthlyTotal;
|
||||
}
|
||||
|
||||
Future<int> getYearlyTotal(int year) async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> results = await db.rawQuery('''
|
||||
SELECT SUM(total_amount) as total
|
||||
FROM invoices
|
||||
WHERE strftime('%Y', date) = ? AND document_type = 'invoice'
|
||||
''', [year.toString()]);
|
||||
|
||||
if (results.isEmpty || results.first['total'] == null) return 0;
|
||||
return (results.first['total'] as num).toInt();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ import 'package:crypto/crypto.dart';
|
|||
import 'package:intl/intl.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import 'company_repository.dart';
|
||||
import 'activity_log_repository.dart';
|
||||
|
||||
/// A4サイズのプロフェッショナルな請求書PDFを生成し、保存する
|
||||
Future<String?> generateInvoicePdf(Invoice invoice) async {
|
||||
try {
|
||||
/// PDFドキュメントの構築(プレビューと実保存の両方で使用)
|
||||
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// フォントのロード
|
||||
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
||||
final ttf = pw.Font.ttf(fontData);
|
||||
final boldTtf = pw.Font.ttf(fontData); // IPAexGはウェイトが1つなので同じものを使用
|
||||
final boldTtf = pw.Font.ttf(fontData);
|
||||
|
||||
final dateFormatter = DateFormat('yyyy年MM月dd日');
|
||||
final amountFormatter = NumberFormat("#,###");
|
||||
|
|
@ -48,11 +48,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text("請求書", style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
|
||||
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("番号: ${invoice.invoiceNumber}"),
|
||||
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
|
||||
],
|
||||
),
|
||||
|
|
@ -77,7 +77,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
style: const pw.TextStyle(fontSize: 18)),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
pw.Text("下記の通り、ご請求申し上げます。"),
|
||||
pw.Text(invoice.documentType == DocumentType.receipt
|
||||
? "上記の金額を正に領収いたしました。"
|
||||
: (invoice.documentType == DocumentType.estimation
|
||||
? "下記の通り、お見積り申し上げます。"
|
||||
: "下記の通り、ご請求申し上げます。")),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -117,7 +121,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text("ご請求金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)),
|
||||
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)),
|
||||
],
|
||||
|
|
@ -161,7 +169,10 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
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),
|
||||
],
|
||||
|
|
@ -193,17 +204,35 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
),
|
||||
);
|
||||
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/// A4サイズのプロフェッショナルな伝票PDFを生成し、保存する
|
||||
Future<String?> generateInvoicePdf(Invoice invoice) async {
|
||||
try {
|
||||
final pdf = await buildInvoiceDocument(invoice);
|
||||
|
||||
// 保存処理
|
||||
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.customer.formalName}_$hash.pdf";
|
||||
final String hash = sha256.convert(bytes).toString().substring(0, 4);
|
||||
final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||
String fileName = "${invoice.invoiceNumberPrefix}_${invoice.invoiceNumber}_${timeStr}_$hash.pdf";
|
||||
|
||||
final directory = await getExternalStorageDirectory();
|
||||
if (directory == null) return null;
|
||||
|
||||
final file = File("${directory.path}/$fileName");
|
||||
await file.writeAsBytes(bytes);
|
||||
|
||||
// 生成をログに記録
|
||||
final logRepo = ActivityLogRepository();
|
||||
await logRepo.logAction(
|
||||
action: "GENERATE_PDF",
|
||||
targetType: "INVOICE",
|
||||
targetId: invoice.id,
|
||||
details: "PDF生成: $fileName (${invoice.documentTypeName})",
|
||||
);
|
||||
|
||||
return file.path;
|
||||
} catch (e) {
|
||||
debugPrint("PDF Generation Error: $e");
|
||||
|
|
|
|||
86
lib/services/print_service.dart
Normal file
86
lib/services/print_service.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
|
||||
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'company_repository.dart';
|
||||
|
||||
class PrintService {
|
||||
/// ペアリング済みのBluetoothデバイス一覧を取得
|
||||
Future<List<BluetoothInfo>> getPairedDevices() async {
|
||||
return await PrintBluetoothThermal.pairedBluetooths;
|
||||
}
|
||||
|
||||
/// 指定したデバイスに接続
|
||||
Future<bool> connect(String macAddress) async {
|
||||
return await PrintBluetoothThermal.connect(macPrinterAddress: macAddress);
|
||||
}
|
||||
|
||||
/// 接続状態の確認
|
||||
Future<bool> get isConnected async => await PrintBluetoothThermal.connectionStatus;
|
||||
|
||||
/// レシートを生成して印刷
|
||||
Future<bool> printReceipt(Invoice invoice) async {
|
||||
if (!await isConnected) return false;
|
||||
|
||||
// 日本語フォントサポート等のために、本来は画像生成が必要な場合が多いが
|
||||
// ここでは標準的なESC/POSテキスト出力を実装
|
||||
final profile = await CapabilityProfile.load();
|
||||
final generator = Generator(PaperSize.mm58, profile);
|
||||
List<int> bytes = [];
|
||||
|
||||
// 自社情報の取得(税表示設定のため)
|
||||
final companyRepo = CompanyRepository();
|
||||
final companyInfo = await companyRepo.getCompanyInfo();
|
||||
|
||||
// ヘッダー
|
||||
bytes += generator.text(
|
||||
invoice.documentTypeName,
|
||||
styles: const PosStyles(align: PosAlign.center, height: PosTextSize.size2, width: PosTextSize.size2, bold: true),
|
||||
);
|
||||
bytes += generator.text("--------------------------------", styles: const PosStyles(align: PosAlign.center));
|
||||
bytes += generator.text("No: ${invoice.invoiceNumber}", styles: const PosStyles(align: PosAlign.center));
|
||||
bytes += generator.text("Date: ${DateFormat('yyyy/MM/dd HH:mm').format(invoice.date)}", styles: const PosStyles(align: PosAlign.center));
|
||||
bytes += generator.text("--------------------------------", styles: const PosStyles(align: PosAlign.center));
|
||||
|
||||
bytes += generator.text("Customer:", styles: const PosStyles(bold: true));
|
||||
bytes += generator.text(invoice.customerNameForDisplay);
|
||||
bytes += generator.feed(1);
|
||||
|
||||
bytes += generator.text("Items:", styles: const PosStyles(bold: true));
|
||||
for (var item in invoice.items) {
|
||||
bytes += generator.text(item.description);
|
||||
bytes += generator.row([
|
||||
PosColumn(text: " ${item.quantity} x ${item.unitPrice}", width: 8),
|
||||
PosColumn(text: "¥${item.subtotal}", width: 4, styles: const PosStyles(align: PosAlign.right)),
|
||||
]);
|
||||
}
|
||||
|
||||
bytes += generator.text("--------------------------------");
|
||||
bytes += generator.row([
|
||||
PosColumn(text: "Subtotal", width: 8),
|
||||
PosColumn(text: "¥${invoice.subtotal}", width: 4, styles: const PosStyles(align: PosAlign.right)),
|
||||
]);
|
||||
|
||||
if (companyInfo.taxDisplayMode == 'normal') {
|
||||
bytes += generator.row([
|
||||
PosColumn(text: "Tax", width: 8),
|
||||
PosColumn(text: "¥${invoice.tax}", width: 4, styles: const PosStyles(align: PosAlign.right)),
|
||||
]);
|
||||
} else if (companyInfo.taxDisplayMode == 'text_only') {
|
||||
bytes += generator.row([
|
||||
PosColumn(text: "Tax", width: 8),
|
||||
PosColumn(text: "(Tax Excl.)", width: 4, styles: const PosStyles(align: PosAlign.right)),
|
||||
]);
|
||||
}
|
||||
|
||||
bytes += generator.row([
|
||||
PosColumn(text: companyInfo.taxDisplayMode == 'hidden' ? "TOTAL" : "TOTAL(Incl)", width: 7, styles: const PosStyles(bold: true, height: PosTextSize.size1)),
|
||||
PosColumn(text: "¥${invoice.totalAmount}", width: 5, styles: const PosStyles(align: PosAlign.right, bold: true, height: PosTextSize.size1)),
|
||||
]);
|
||||
|
||||
bytes += generator.feed(3);
|
||||
bytes += generator.cut();
|
||||
|
||||
return await PrintBluetoothThermal.writeBytes(bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import 'package:sqflite/sqflite.dart';
|
||||
import '../models/product_model.dart';
|
||||
import 'database_helper.dart';
|
||||
import 'activity_log_repository.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ProductRepository {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||
|
||||
Future<List<Product>> getAllProducts() async {
|
||||
final db = await _dbHelper.database;
|
||||
|
|
@ -18,18 +20,30 @@ class ProductRepository {
|
|||
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
Future<List<Product>> searchProducts(String query) async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'products',
|
||||
where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?',
|
||||
whereArgs: ['%$query%', '%$query%', '%$query%'],
|
||||
orderBy: 'name ASC',
|
||||
limit: 50,
|
||||
);
|
||||
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
Future<void> _generateSampleProducts() async {
|
||||
final samples = [
|
||||
Product(id: const Uuid().v4(), name: "基本技術料", defaultUnitPrice: 50000),
|
||||
Product(id: const Uuid().v4(), name: "出張診断費", defaultUnitPrice: 10000),
|
||||
Product(id: const Uuid().v4(), name: "交換用ハードディスク (1TB)", defaultUnitPrice: 8500),
|
||||
Product(id: const Uuid().v4(), name: "メモリ増設 (8GB)", defaultUnitPrice: 6000),
|
||||
Product(id: const Uuid().v4(), name: "OSインストール作業", defaultUnitPrice: 15000),
|
||||
Product(id: const Uuid().v4(), name: "データ復旧作業 (軽度)", defaultUnitPrice: 30000),
|
||||
Product(id: const Uuid().v4(), name: "LANケーブル (5m)", defaultUnitPrice: 1200),
|
||||
Product(id: const Uuid().v4(), name: "ウイルス除去作業", defaultUnitPrice: 20000),
|
||||
Product(id: const Uuid().v4(), name: "液晶ディスプレイ (24インチ)", defaultUnitPrice: 25000),
|
||||
Product(id: const Uuid().v4(), name: "定期保守契約料 (月額)", defaultUnitPrice: 5000),
|
||||
Product(id: const Uuid().v4(), name: "基本技術料", defaultUnitPrice: 50000, category: "技術料"),
|
||||
Product(id: const Uuid().v4(), name: "出張診断費", defaultUnitPrice: 10000, category: "諸経費"),
|
||||
Product(id: const Uuid().v4(), name: "交換用ハードディスク (1TB)", defaultUnitPrice: 8500, category: "パーツ"),
|
||||
Product(id: const Uuid().v4(), name: "メモリ増設 (8GB)", defaultUnitPrice: 6000, category: "パーツ"),
|
||||
Product(id: const Uuid().v4(), name: "OSインストール作業", defaultUnitPrice: 15000, category: "技術料"),
|
||||
Product(id: const Uuid().v4(), name: "データ復旧作業 (軽度)", defaultUnitPrice: 30000, category: "技術料"),
|
||||
Product(id: const Uuid().v4(), name: "LANケーブル (5m)", defaultUnitPrice: 1200, category: "サプライ"),
|
||||
Product(id: const Uuid().v4(), name: "ウイルス除去作業", defaultUnitPrice: 20000, category: "技術料"),
|
||||
Product(id: const Uuid().v4(), name: "液晶ディスプレイ (24インチ)", defaultUnitPrice: 25000, category: "周辺機器"),
|
||||
Product(id: const Uuid().v4(), name: "定期保守契約料 (月額)", defaultUnitPrice: 5000, category: "保守"),
|
||||
];
|
||||
for (var s in samples) {
|
||||
await saveProduct(s);
|
||||
|
|
@ -43,10 +57,28 @@ class ProductRepository {
|
|||
product.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
await _logRepo.logAction(
|
||||
action: "SAVE_PRODUCT",
|
||||
targetType: "PRODUCT",
|
||||
targetId: product.id,
|
||||
details: "商品名: ${product.name}, 単価: ${product.defaultUnitPrice}, カテゴリ: ${product.category ?? '未設定'}",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteProduct(String id) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||
await db.delete(
|
||||
'products',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
await _logRepo.logAction(
|
||||
action: "DELETE_PRODUCT",
|
||||
targetType: "PRODUCT",
|
||||
targetId: id,
|
||||
details: "商品を削除しました",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
linux/flutter/ephemeral/.plugin_symlinks/printing
Symbolic link
1
linux/flutter/ephemeral/.plugin_symlinks/printing
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/
|
||||
|
|
@ -7,12 +7,16 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) printing_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
||||
printing_plugin_register_with_registrar(printing_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
printing
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import file_selector_macos
|
|||
import geolocator_apple
|
||||
import mobile_scanner
|
||||
import package_info_plus
|
||||
import print_bluetooth_thermal
|
||||
import printing
|
||||
import share_plus
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
|
|
@ -18,6 +20,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PrintBluetoothThermalPlugin.register(with: registry.registrar(forPlugin: "PrintBluetoothThermalPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
|
|
|
|||
56
pubspec.lock
56
pubspec.lock
|
|
@ -89,6 +89,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -97,6 +105,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
esc_pos_utils_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: esc_pos_utils_plus
|
||||
sha256: "2a22d281cb6f04600ba3ebd607ad8df03a4b2446d814007d22525bab4d50c2ff"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -264,6 +280,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -552,6 +576,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.11.3"
|
||||
pdf_widget_wrapper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pdf_widget_wrapper
|
||||
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -632,6 +664,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
print_bluetooth_thermal:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: print_bluetooth_thermal
|
||||
sha256: "17b204a5340174c02acf5f6caf7279b5000344f1f1e1bcd01dbdbf912bce6e46"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9"
|
||||
printing:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: printing
|
||||
sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -877,6 +925,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win_ble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win_ble
|
||||
sha256: "2a867e13c4b355b101fc2c6e2ac85eeebf965db34eca46856f8b478e93b41e96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ dependencies:
|
|||
image_picker: ^1.2.1
|
||||
mobile_scanner: ^7.1.4
|
||||
package_info_plus: ^9.0.0
|
||||
printing: ^5.14.2
|
||||
print_bluetooth_thermal: ^1.1.9
|
||||
esc_pos_utils_plus: ^2.0.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
20
目標.md
20
目標.md
|
|
@ -35,4 +35,24 @@
|
|||
− ロック機能はご動作対策であって削除と編集機能以外は全部使える様にする
|
||||
- 顧客マスターの新規・編集・削除機能を実装する
|
||||
- PDF作成と保存と仮表示は別ボタンで実装
|
||||
− 各マスター情報は1万件を越えたりするのでカテゴライズ等工夫して迅速に検索可能にする
|
||||
- PDF出力したりPDF共有は日時情報を付けて保存する
|
||||
- データはgit的にアクティビティを残す様にする
|
||||
- 顧客や商品等一覧から選択する時は必ず数万単位の検索がある前提にする
|
||||
- 仮表示とはPDFプレビューの事です
|
||||
- 見積納品請求領収書の切り替えボタンの実装
|
||||
- 商品マスターには在庫管理機能をこっそり実装する
|
||||
- 伝票で在庫の増減を自動的に計算する仕組みを作る
|
||||
- 年次月次の管理を実装する
|
||||
- 将来的にスタンドアローンでも資金管理を可能にする
|
||||
- データにはGPS座標の履歴を最低10件保持する
|
||||
- アマゾンで売っていたプリンタでレシート印刷をするhttps://www.amazon.co.jp/dp/B0G33SSZV6?ref=ppx_yo2ov_dt_b_fed_asin_title&th=1
|
||||
|
||||
- 伝票入力画面で消費税表示がシステム通りでないのを修正
|
||||
− 日時の自動入力と編集機能の実装
|
||||
- 管理番号端末UIDと伝票UIDの組み合わせで管理する
|
||||
- 管理番号の表示を画面とPDFにする
|
||||
- 伝票のコンテンツ情報に規則性を持たせSHA256をPDFに番号とQRで表示
|
||||
- PDFのファイル名にSHA256を含める
|
||||
|
||||
-
|
||||
Loading…
Reference in a new issue