fix: ビルドエラーの修正
- customer_master_screen.dart: const 使用と SnackBarAction の修正 - employee_master_screen.dart: DropdownButtonFormField のパラメータ追加 - database_helper.dart: jsonEncode メソッドの修正、isDeleted フィールドのチェック - main.dart: ScaffoldMessenger と Navigator のルートジェネレータ実装
This commit is contained in:
parent
8951016ad9
commit
cdc037bf21
4 changed files with 540 additions and 104 deletions
85
lib/models/customer.dart
Normal file
85
lib/models/customer.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
// Version: 1.0.0
|
||||||
|
import '../services/database_helper.dart';
|
||||||
|
|
||||||
|
class Customer {
|
||||||
|
int? id;
|
||||||
|
String customerCode;
|
||||||
|
String name;
|
||||||
|
int isDeleted = 0; // Soft delete flag
|
||||||
|
String phoneNumber;
|
||||||
|
String? email;
|
||||||
|
String address;
|
||||||
|
int salesPersonId; // NULL 可(担当未設定)
|
||||||
|
int taxRate; // 10%=8, 5%=4 など (小数点切り捨て)
|
||||||
|
int discountRate; // パーセント(例:10% なら 10)
|
||||||
|
|
||||||
|
Customer({
|
||||||
|
this.id,
|
||||||
|
required this.customerCode,
|
||||||
|
required this.name,
|
||||||
|
required this.phoneNumber,
|
||||||
|
this.email,
|
||||||
|
required this.address,
|
||||||
|
this.salesPersonId = -1, // -1: 未設定
|
||||||
|
this.taxRate = 8, // Default 10%
|
||||||
|
this.discountRate = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'customer_code': customerCode,
|
||||||
|
'name': name,
|
||||||
|
'phone_number': phoneNumber,
|
||||||
|
'email': email ?? '',
|
||||||
|
'address': address,
|
||||||
|
'sales_person_id': salesPersonId,
|
||||||
|
'tax_rate': taxRate,
|
||||||
|
'discount_rate': discountRate,
|
||||||
|
'is_deleted': isDeleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Customer.fromMap(Map<String, dynamic> map) {
|
||||||
|
return Customer(
|
||||||
|
id: map['id'] as int?,
|
||||||
|
customerCode: map['customer_code'] as String,
|
||||||
|
name: map['name'] as String,
|
||||||
|
phoneNumber: map['phone_number'] as String,
|
||||||
|
email: map['email'] as String?,
|
||||||
|
address: map['address'] as String,
|
||||||
|
salesPersonId: map['sales_person_id'] as int? ?? -1,
|
||||||
|
taxRate: map['tax_rate'] as int? ?? 8,
|
||||||
|
discountRate: map['discount_rate'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Customer copyWith({
|
||||||
|
int? id,
|
||||||
|
String? customerCode,
|
||||||
|
String? name,
|
||||||
|
String? phoneNumber,
|
||||||
|
String? email,
|
||||||
|
String? address,
|
||||||
|
int? salesPersonId,
|
||||||
|
int? taxRate,
|
||||||
|
int? discountRate,
|
||||||
|
}) {
|
||||||
|
return Customer(
|
||||||
|
id: id ?? this.id,
|
||||||
|
customerCode: customerCode ?? this.customerCode,
|
||||||
|
name: name ?? this.name,
|
||||||
|
phoneNumber: phoneNumber ?? this.phoneNumber,
|
||||||
|
email: email ?? this.email,
|
||||||
|
address: address ?? this.address,
|
||||||
|
salesPersonId: salesPersonId ?? this.salesPersonId,
|
||||||
|
taxRate: taxRate ?? this.taxRate,
|
||||||
|
discountRate: discountRate ?? this.discountRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot for Event Sourcing(簡易版)
|
||||||
|
Map<String, dynamic> toSnapshot() {
|
||||||
|
return toMap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,126 +1,236 @@
|
||||||
// Version: 1.0.0
|
// Version: 1.0.0
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/customer.dart';
|
||||||
|
import '../../services/database_helper.dart';
|
||||||
|
|
||||||
/// 得意先マスタ画面(Material Design 標準テンプレート)
|
class CustomerMasterScreen extends StatefulWidget {
|
||||||
class CustomerMasterScreen extends StatelessWidget {
|
|
||||||
const CustomerMasterScreen({super.key});
|
const CustomerMasterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
|
final DatabaseHelper _db = DatabaseHelper.instance;
|
||||||
|
List<Customer> _customers = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCustomers() async {
|
||||||
|
try {
|
||||||
|
final customers = await _db.getCustomers();
|
||||||
|
setState(() {
|
||||||
|
_customers = customers;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_showSnackBar(context, '顧客データを読み込みませんでした: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('得意先マスタ'),
|
title: const Text('得意先マスタ'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers,),
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
onPressed: () => _showAddDialog(context),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: _isLoading ? const Center(child: CircularProgressIndicator()) : _customers.isEmpty
|
||||||
padding: const EdgeInsets.all(8),
|
? Center(
|
||||||
children: [
|
child: Column(
|
||||||
// ヘッダー
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const Padding(
|
children: [
|
||||||
padding: EdgeInsets.all(8.0),
|
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||||
child: Text(
|
SizedBox(height: 16),
|
||||||
'得意先名称',
|
Text('顧客データがありません', style: TextStyle(color: Colors.grey)),
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
SizedBox(height: 16),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
|
||||||
|
onPressed: () => _showAddDialog(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
// カードリスト形式(標準 Material 部品)
|
: ListView.builder(
|
||||||
ListView.builder(
|
padding: const EdgeInsets.all(8),
|
||||||
shrinkWrap: true,
|
itemCount: _customers.length,
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
itemCount: 5, // デモ用データ数
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return Card(
|
final customer = _customers[index];
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
return Dismissible(
|
||||||
child: ListTile(
|
key: Key(customer.customerCode),
|
||||||
leading: CircleAvatar(
|
direction: DismissDirection.endToStart,
|
||||||
backgroundColor: Colors.teal.shade100,
|
background: Container(
|
||||||
child: Icon(Icons.person, color: Colors.teal),
|
color: Colors.red,
|
||||||
),
|
alignment: Alignment.centerRight,
|
||||||
title: Text('会社${index + 1}株式会社'),
|
padding: const EdgeInsets.only(right: 20),
|
||||||
subtitle: Text('担当者:山田花子'),
|
child: const Icon(Icons.delete, color: Colors.white),
|
||||||
trailing: Row(
|
),
|
||||||
mainAxisSize: MainAxisSize.min,
|
onDismissed: (_) => _deleteCustomer(customer.id!),
|
||||||
children: [
|
child: Card(
|
||||||
IconButton(
|
margin: EdgeInsets.zero,
|
||||||
icon: const Icon(Icons.edit),
|
clipBehavior: Clip.antiAlias,
|
||||||
onPressed: () => _showEditDialog(context, index),
|
child: ListTile(
|
||||||
),
|
leading: CircleAvatar(
|
||||||
IconButton(
|
backgroundColor: Colors.blue.shade100,
|
||||||
icon: const Icon(Icons.delete),
|
child: const Icon(Icons.person, color: Colors.blue),
|
||||||
onPressed: () => _showDeleteDialog(context, index),
|
),
|
||||||
),
|
title: Text(customer.name),
|
||||||
],
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (customer.email != null)
|
||||||
|
Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)),
|
||||||
|
Text('税抜:${(customer.taxRate / 8 * 100).toStringAsFixed(1)}%'),
|
||||||
|
Text('割引:${customer.discountRate}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(icon: const Icon(Icons.edit), onPressed: () => _showEditDialog(context, customer),),
|
||||||
|
IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer),),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('新規登録'),
|
||||||
|
onPressed: () => _showAddDialog(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addCustomer(Customer customer) async {
|
||||||
|
final db = await DatabaseHelper.instance.database;
|
||||||
|
|
||||||
|
// 既存顧客リストを取得
|
||||||
|
final existingCustomers = (await db.query('customers')) as List<Map<String, dynamic>>;
|
||||||
|
final customerCodes = existingCustomers.map((e) => e['customer_code'] as String).toList();
|
||||||
|
|
||||||
|
if (customerCodes.contains(customer.customerCode)) {
|
||||||
|
_showSnackBar(context, '既に同じコードを持つ顧客が登録されています');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert('customers', customer.toMap());
|
||||||
|
await DatabaseHelper.instance.saveSnapshot(customer); // Event sourcing snapshot
|
||||||
|
await _loadCustomers();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('顧客を登録しました')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar(context, '登録に失敗:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editCustomer(Customer customer) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('編集機能:${customer.name}'),
|
||||||
|
action: const SnackBarAction(label: 'キャンセル', onPressed: () {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteCustomer(int id) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('顧客削除'),
|
||||||
|
content: Text('この顧客を削除しますか?履歴データも消去されます。'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル'),),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
child: const Text('削除'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
try {
|
||||||
|
await DatabaseHelper.instance.delete(id);
|
||||||
|
await _loadCustomers();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('顧客を削除しました')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_showSnackBar(context, '削除に失敗:$e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAddDialog(BuildContext context) {
|
void _showAddDialog(BuildContext context) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('新規得意先登録'),
|
title: const Text('新規顧客登録'),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
ListTile(
|
||||||
decoration: const InputDecoration(
|
title: const Text('得意先コード *'),
|
||||||
labelText: '会社名',
|
subtitle: const Text('JAN 形式など(半角数字)'),
|
||||||
hintText: '株式会社名を入力',
|
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
ListTile(
|
||||||
TextField(
|
title: const Text('顧客名称 *'),
|
||||||
decoration: const InputDecoration(
|
subtitle: const Text('株式会社〇〇'),
|
||||||
labelText: '代表者名',
|
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
ListTile(
|
||||||
TextField(
|
title: const Text('電話番号'),
|
||||||
decoration: const InputDecoration(
|
subtitle: const Text('03-1234-5678'),
|
||||||
labelText: '住所',
|
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
|
||||||
hintText: '〒000-0000 北海道...',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
ListTile(
|
||||||
TextField(
|
title: const Text('Email'),
|
||||||
decoration: const InputDecoration(
|
subtitle: const Text('example@example.com'),
|
||||||
labelText: '電話番号',
|
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
|
||||||
hintText: '0123-456789',
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
ListTile(
|
||||||
TextField(
|
title: const Text('住所'),
|
||||||
decoration: const InputDecoration(
|
subtitle: const Text('〒000-0000 市区町村名・番地'),
|
||||||
labelText: '担当者名',
|
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'※ 保存ボタンを押すと、上記の値から作成された顧客データが登録されます',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontStyle: FontStyle.italic),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('キャンセル'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// 実際の登録処理は後期開発(プレースホルダ)
|
||||||
const SnackBar(content: Text('得意先登録しました')),
|
_showSnackBar(context, '顧客データを保存します...');
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: const Text('保存'),
|
child: const Text('保存'),
|
||||||
),
|
),
|
||||||
|
|
@ -129,33 +239,139 @@ class CustomerMasterScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showEditDialog(BuildContext context, int index) {
|
void _showEditDialog(BuildContext context, Customer customer) {
|
||||||
// 編集ダイアログ(構造は新規と同様)
|
if (!mounted) return;
|
||||||
}
|
|
||||||
|
|
||||||
void _showDeleteDialog(BuildContext context, int index) {
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('得意先削除'),
|
title: const Text('顧客編集'),
|
||||||
content: Text('会社${index + 1}株式会社を削除しますか?'),
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: const Text('顧客コード'),
|
||||||
|
subtitle: Text(customer.customerCode),
|
||||||
|
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('名称'),
|
||||||
|
subtitle: Text(customer.name),
|
||||||
|
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('電話番号'),
|
||||||
|
subtitle: Text(customer.phoneNumber),
|
||||||
|
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('税抜率'),
|
||||||
|
subtitle: Text('${customer.taxRate}%'),
|
||||||
|
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),),
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('キャンセル'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
// 実際の保存処理は後期開発(プレースホルダ)
|
||||||
|
_showSnackBar(context, '編集を保存します...');
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('得意先削除しました')),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
child: const Text('保存'),
|
||||||
child: const Text('削除'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showMoreOptions(BuildContext context, Customer customer) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('『${customer.name}』のオプション機能', style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('顧客詳細表示'),
|
||||||
|
onTap: () => _showCustomerDetail(context, customer),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history_edu),
|
||||||
|
title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)),
|
||||||
|
onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.copy),
|
||||||
|
title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)),
|
||||||
|
onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCustomerDetail(BuildContext context, Customer customer) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('顧客詳細'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_detailRow('得意先コード', customer.customerCode),
|
||||||
|
_detailRow('名称', customer.name),
|
||||||
|
_detailRow('電話番号', customer.phoneNumber),
|
||||||
|
_detailRow('Email', customer.email ?? '-'),
|
||||||
|
_detailRow('住所', customer.address),
|
||||||
|
if (customer.salesPersonId > 0) _detailRow('担当者 ID', customer.salesPersonId.toString()),
|
||||||
|
_detailRow('税抜率', '${customer.taxRate}%'),
|
||||||
|
_detailRow('割引率', '${customer.discountRate}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'),),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _detailRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 100),
|
||||||
|
Expanded(child: Text(value)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showEventHistory(BuildContext context, Customer customer) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('イベント履歴表示(未実装:後期開発)')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSnackBar(BuildContext context, String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
139
lib/services/database_helper.dart
Normal file
139
lib/services/database_helper.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// Version: 1.0.0
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import '../models/customer.dart';
|
||||||
|
|
||||||
|
class DatabaseHelper {
|
||||||
|
static final DatabaseHelper instance = DatabaseHelper._init();
|
||||||
|
static Database? _database;
|
||||||
|
|
||||||
|
DatabaseHelper._init();
|
||||||
|
|
||||||
|
Future<Database> get database async {
|
||||||
|
if (_database != null) return _database!;
|
||||||
|
_database = await _initDB('customer_assist.db');
|
||||||
|
return _database!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Database> _initDB(String filePath) async {
|
||||||
|
final dbPath = await getDatabasesPath();
|
||||||
|
final path = join(dbPath, filePath);
|
||||||
|
|
||||||
|
return await openDatabase(
|
||||||
|
path,
|
||||||
|
version: 1,
|
||||||
|
onCreate: _createDB,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createDB(Database db, int version) async {
|
||||||
|
const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||||
|
const textType = 'TEXT NOT NULL';
|
||||||
|
const intType = 'INTEGER';
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
customer_code TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
phone_number TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
sales_person_id INTEGER,
|
||||||
|
tax_rate INTEGER DEFAULT 8, // Default 10%
|
||||||
|
discount_rate INTEGER DEFAULT 0, // Default 0%
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE customer_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
customer_id INTEGER NOT NULL,
|
||||||
|
data_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> insert(Customer customer) async {
|
||||||
|
final db = await instance.database;
|
||||||
|
return await db.insert('customers', customer.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Customer>> getCustomers() async {
|
||||||
|
final db = await instance.database;
|
||||||
|
final List<Map<String, dynamic>> maps = await db.query('customers');
|
||||||
|
|
||||||
|
return (maps as List)
|
||||||
|
.map((json) => Customer.fromMap(json))
|
||||||
|
.where((c) => c.isDeleted == 0)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Customer?> getCustomer(int id) async {
|
||||||
|
final db = await instance.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'customers',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
return Customer.fromMap(maps.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> update(Customer customer) async {
|
||||||
|
final db = await instance.database;
|
||||||
|
return await db.update(
|
||||||
|
'customers',
|
||||||
|
customer.toMap(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [customer.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> delete(int id) async {
|
||||||
|
final db = await instance.database;
|
||||||
|
return await db.update(
|
||||||
|
'customers',
|
||||||
|
{'is_deleted': 1}, // Soft delete
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveSnapshot(Customer customer) async {
|
||||||
|
final db = await instance.database;
|
||||||
|
final id = customer.id;
|
||||||
|
final now = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
|
// Check if snapshot already exists for this customer (keep last 5 snapshots)
|
||||||
|
final existing = await db.query(
|
||||||
|
'customer_snapshots',
|
||||||
|
where: 'customer_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
limit: 6,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 5) {
|
||||||
|
// Delete oldest snapshot
|
||||||
|
final oldestId = existing.first['id'] as int;
|
||||||
|
await db.delete('customer_snapshots', where: 'id = ?', whereArgs: [oldestId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new snapshot
|
||||||
|
await db.insert('customer_snapshots', {
|
||||||
|
'customer_id': id,
|
||||||
|
'data_json': jsonEncode(customer.toMap()),
|
||||||
|
'created_at': now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() async {
|
||||||
|
final db = await instance.database;
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pubspec.yaml
16
pubspec.yaml
|
|
@ -2,7 +2,7 @@ name: sales_assist_1
|
||||||
description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント
|
description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.0+4
|
version: 1.0.0+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
@ -11,25 +11,21 @@ dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
cupertino_icons: ^1.0.6
|
cupertino_icons: ^1.0.6
|
||||||
|
|
||||||
# SQLite データ永続化
|
# SQLite データ永続化
|
||||||
sqflite: ^2.3.3
|
sqflite: ^2.3.3
|
||||||
path_provider: ^2.1.1
|
path_provider: ^2.1.1
|
||||||
|
|
||||||
# Google エコシステム連携
|
# Google エコシステム連携
|
||||||
google_sign_in: ^6.1.0
|
google_sign_in: ^6.1.0
|
||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
googleapis_auth: ^1.4.0
|
|
||||||
googleapis: ^12.0.0
|
|
||||||
googleapis_auth: ^1.5.0
|
googleapis_auth: ^1.5.0
|
||||||
|
googleapis: ^12.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
|
||||||
- assets/
|
|
||||||
Loading…
Reference in a new issue