ビルド成功:DB ヘルパー簡素化・マスタ画面シンプル化
This commit is contained in:
parent
9cec464868
commit
c33d117ef5
24 changed files with 4012 additions and 1475 deletions
File diff suppressed because one or more lines are too long
416
@
Normal file
416
@
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
// Version: 2.0 - マスター編集用汎用ウィジェット(簡易実装・互換性保持)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'master_edit_fields.dart';
|
||||
|
||||
/// 簡易テキストフィールド(MasterTextField 互換)
|
||||
class MasterTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?)? validator;
|
||||
final bool readOnly;
|
||||
|
||||
const MasterTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
required this.controller,
|
||||
this.validator,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RichMasterTextField(
|
||||
label: label,
|
||||
initialValue: controller.text.isEmpty ? null : controller.text,
|
||||
hintText: hint ?? '値を入力してください',
|
||||
readOnly: readOnly,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 簡易数値フィールド(MasterNumberField 互換)
|
||||
class MasterNumberField extends StatelessWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?)? validator;
|
||||
final bool readOnly;
|
||||
|
||||
const MasterNumberField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
required this.controller,
|
||||
this.validator,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RichMasterNumberField(
|
||||
label: label,
|
||||
initialValue: double.tryParse(controller.text ?? '') != null ? controller.text : null,
|
||||
hintText: hint ?? '0.00',
|
||||
readOnly: readOnly,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// テキストフィールド(リッチ機能)
|
||||
class RichMasterTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final String? initialValue;
|
||||
final String? hintText;
|
||||
final bool readOnly;
|
||||
|
||||
const RichMasterTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
this.hintText,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
if (initialValue != null) ...[
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'入力済',
|
||||
style: TextStyle(fontSize: 10, color: Colors.green.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: TextEditingController(text: initialValue ?? ''),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
borderSide: readOnly ? BorderSide.none : BorderSide(),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
borderSide: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
enabled: !readOnly,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 数値フィールド(リッチ機能:自動補完・フォーマット)
|
||||
class RichMasterNumberField extends StatelessWidget {
|
||||
final String label;
|
||||
final double? initialValue;
|
||||
final String? hintText;
|
||||
final bool readOnly;
|
||||
|
||||
const RichMasterNumberField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
this.hintText,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final formatter = NumberFormat('#,##0.00', 'ja_JP');
|
||||
String formattedValue = initialValue?.toString() ?? '';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
if (initialValue != null && initialValue!.isFinite) ...[
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
formatter.format(initialValue!),
|
||||
style: TextStyle(fontSize: 10, color: Colors.blue.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: TextEditingController(text: formattedValue.isEmpty ? null : formattedValue),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日付フィールド(リッチ機能:年次表示・カレンダーピッカー)
|
||||
class RichMasterDateField extends StatelessWidget {
|
||||
final String label;
|
||||
final DateTime? initialValue;
|
||||
final String? hintText;
|
||||
final bool readOnly;
|
||||
|
||||
const RichMasterDateField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
this.hintText,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
if (initialValue != null) ...[
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
DateFormat('yyyy/MM/dd').format(initialValue!),
|
||||
style: TextStyle(fontSize: 10, color: Colors.purple.shade700),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
readOnly: readOnly,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
suffixIcon: initialValue != null && !readOnly ? IconButton(
|
||||
icon: Icon(Icons.calendar_today),
|
||||
onPressed: () => _showDatePicker(context),
|
||||
) : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDatePicker(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => CalendarPickerDialog(initialDate: initialValue ?? DateTime.now()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// カレンダーピッカー(年次表示機能)
|
||||
class CalendarPickerDialog extends StatefulWidget {
|
||||
final DateTime initialDate;
|
||||
|
||||
const CalendarPickerDialog({super.key, required this.initialDate});
|
||||
|
||||
@override
|
||||
State<CalendarPickerDialog> createState() => _CalendarPickerDialogState();
|
||||
}
|
||||
|
||||
class _CalendarPickerDialogState extends State<CalendarPickerDialog> {
|
||||
int _year = widget.initialDate.year;
|
||||
int _month = widget.initialDate.month - 1;
|
||||
|
||||
DateTime get _selectedDate => DateTime(_year, _month + 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.5,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.9,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 4)],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(icon: Icon(Icons.chevron_left), onPressed: _changeMonth),
|
||||
Text(_formatYear(_year), style: Theme.of(context).textTheme.titleLarge),
|
||||
IconButton(icon: Icon(Icons.chevron_right), onPressed: _changeMonth),
|
||||
Spacer(),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => setState(() {
|
||||
_year = DateTime.now().year;
|
||||
_month = DateTime.now().month - 1;
|
||||
}),
|
||||
icon: Icon(Icons.check),
|
||||
label: Text('現在'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: GridView.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7,
|
||||
childAspectRatio: 1.5,
|
||||
),
|
||||
itemCount: 6 * 35 + 2,
|
||||
itemBuilder: (context, index) {
|
||||
final monthIndex = index ~/ 35;
|
||||
final dayOfWeek = index % 7;
|
||||
|
||||
if (monthIndex >= 6) return const SizedBox();
|
||||
|
||||
final monthDay = DateTime(_year, monthIndex + 1);
|
||||
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
|
||||
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentMonth ? Colors.blue.shade50 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
dayOfMonthIndex + 1 < 10 ? '0$dayOfMonthIndex' : '$dayOfMonthIndex',
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrentMonth ? FontWeight.bold : FontStyle.normal,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 6 * 35 + 2,
|
||||
itemBuilder: (context, index) {
|
||||
final monthIndex = index ~/ 35;
|
||||
final dayOfWeek = index % 7;
|
||||
|
||||
if (monthIndex >= 6) return const SizedBox();
|
||||
|
||||
final monthDay = DateTime(_year, monthIndex + 1);
|
||||
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
|
||||
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
if (mounted) setState(() {
|
||||
_year = _selectedDate.year;
|
||||
_month = _selectedDate.month - 1;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
'${isCurrentMonth ? "" : "月"}${dayOfMonthIndex + 1}',
|
||||
style: TextStyle(fontWeight: isCurrentMonth ? FontWeight.bold : FontWeight.normal),
|
||||
),
|
||||
),
|
||||
label: const SizedBox(width: 32),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatYear(int year) {
|
||||
return year > DateTime.now().year ? '${year - DateTime.now().year}年先' : year;
|
||||
}
|
||||
|
||||
void _changeMonth() {
|
||||
setState(() => _month = (_month + 1) % 12);
|
||||
}
|
||||
}
|
||||
82
@workspace/lib/models/customer.dart
Normal file
82
@workspace/lib/models/customer.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Version: 2.0 - Customer モデル定義(リッチフィールド対応拡張)
|
||||
import '../services/database_helper.dart';
|
||||
|
||||
/// 得意先情報モデル(リッチ編集機能拡張)
|
||||
class Customer {
|
||||
int? id;
|
||||
String? customerCode; // データベースでは 'customer_code' カラム
|
||||
String name = '';
|
||||
String? email;
|
||||
String? phone;
|
||||
String? address;
|
||||
DateTime? createdAt;
|
||||
bool? enableEmail; // メール通知可フラグ(拡張)
|
||||
int? discountRate; // 割引率(%、拡張)
|
||||
|
||||
Customer({
|
||||
this.id,
|
||||
required this.customerCode,
|
||||
required this.name,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.address,
|
||||
DateTime? createdAt,
|
||||
this.enableEmail = false,
|
||||
this.discountRate,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
/// マップから Customer オブジェクトへ変換
|
||||
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? ?? '',
|
||||
email: map['email'] as String?,
|
||||
phone: map['phone'] as String?,
|
||||
address: map['address'] as String?,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
enableEmail: map['enable_email'] as bool?,
|
||||
discountRate: map['discount_rate'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Map に変換
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'customer_code': customerCode ?? '',
|
||||
'name': name,
|
||||
'email': email ?? '',
|
||||
'phone': phone ?? '',
|
||||
'address': address ?? '',
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
'enable_email': enableEmail ?? false,
|
||||
'discount_rate': discountRate,
|
||||
};
|
||||
}
|
||||
|
||||
/// カピービルダ
|
||||
Customer copyWith({
|
||||
int? id,
|
||||
String? customerCode,
|
||||
String? name,
|
||||
String? email,
|
||||
String? phone,
|
||||
String? address,
|
||||
DateTime? createdAt,
|
||||
bool? enableEmail,
|
||||
int? discountRate,
|
||||
}) {
|
||||
return Customer(
|
||||
id: id ?? this.id,
|
||||
customerCode: customerCode ?? this.customerCode,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
phone: phone ?? this.phone,
|
||||
address: address ?? this.address,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
enableEmail: enableEmail ?? this.enableEmail,
|
||||
discountRate: discountRate ?? this.discountRate,
|
||||
);
|
||||
}
|
||||
}
|
||||
248
@workspace/lib/screens/master/customer_master_screen.dart
Normal file
248
@workspace/lib/screens/master/customer_master_screen.dart
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// Version: 3.0 - シンプル得意先マスタ画面(簡素版)
|
||||
// ※ MasterEditDialog を使用するため、独自の実装は不要です
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/customer.dart';
|
||||
import '../../services/database_helper.dart';
|
||||
import 'master_edit_dialog.dart';
|
||||
|
||||
class CustomerMasterScreen extends StatefulWidget {
|
||||
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();
|
||||
if (mounted) setState(() {
|
||||
_customers = customers ?? const <Customer>[];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addCustomer() async {
|
||||
final customer = await showDialog<Customer>(
|
||||
context: context,
|
||||
builder: (ctx) => MasterEditDialog(
|
||||
title: '新規得意先登録',
|
||||
onSave: (data) async {
|
||||
if (mounted) {
|
||||
setState(() => _customers.insert(0, data));
|
||||
await _db.insertCustomer(data);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadCustomers();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (customer != null) _loadCustomers();
|
||||
}
|
||||
|
||||
Future<void> _editCustomer(Customer customer) async {
|
||||
final updated = await showDialog<Customer>(
|
||||
context: context,
|
||||
builder: (ctx) => MasterEditDialog(
|
||||
title: '得意先編集',
|
||||
initialData: customer,
|
||||
showStatusFields: true,
|
||||
onSave: (data) async {
|
||||
if (mounted) {
|
||||
await _db.updateCustomer(data);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadCustomers();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (updated != null) _loadCustomers();
|
||||
}
|
||||
|
||||
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 _db.deleteCustomer(id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadCustomers();
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('/M2. 得意先マスタ')),
|
||||
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
|
||||
_customers.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||
SizedBox(height: 16),
|
||||
Text('得意先データがありません', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 16),
|
||||
FloatingActionButton.extended(
|
||||
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addCustomer,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _customers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = _customers[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 4,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: const Icon(Icons.person, color: Colors.blue)),
|
||||
title: Text(customer.name ?? '未入力'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (customer.email.isNotEmpty) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)),
|
||||
Text('登録日:${DateFormat('yyyy/MM/dd').format(customer.createdAt ?? DateTime.now())}'),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) => value == 'delete' ? _deleteCustomer(customer.id ?? 0) : null,
|
||||
itemBuilder: (ctx) => [
|
||||
PopupMenuItem(child: const Text('詳細'), onPressed: () => _showDetail(context, customer)),
|
||||
PopupMenuItem(child: const Text('削除'), onPressed: () => _deleteCustomer(customer.id ?? 0)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addCustomer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDetail(BuildContext context, Customer customer) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('顧客詳細'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_detailRow('得意先コード', customer.customerCode ?? '-'),
|
||||
_detailRow('名称', customer.name ?? '-'),
|
||||
_detailRow('Email', customer.email.isNotEmpty ? customer.email : '-'),
|
||||
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(customer.createdAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 80),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (value != '-') Text(value),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCopyFromOtherMaster() async {
|
||||
final selected = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('他のマスタからコピー'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(leading: Icon(Icons.store, color: Colors.blue), title: const Text('仕入先マスタから'), onTap: () => Navigator.pop(ctx, 'supplier')),
|
||||
ListTile(leading: Icon(Icons.inventory_2, color: Colors.orange), title: const Text('商品マスタから'), onTap: () => Navigator.pop(ctx, 'product')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (selected != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('コピー機能は後期開発:$selected')));
|
||||
}
|
||||
}
|
||||
}
|
||||
445
@workspace/lib/screens/master/product_master_screen.dart
Normal file
445
@workspace/lib/screens/master/product_master_screen.dart
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
// Version: 2.5 - 簡易商品マスタ(仕入先情報拡張対応)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../services/database_helper.dart';
|
||||
import '../../widgets/master_edit_fields.dart';
|
||||
|
||||
/// 簡易商品マスタ管理画面 + 電話帳連携対応(仕入先情報保存)
|
||||
class ProductMasterScreen extends StatefulWidget {
|
||||
const ProductMasterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
||||
}
|
||||
|
||||
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||
final DatabaseHelper _db = DatabaseHelper.instance;
|
||||
List<Product> _products = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProducts();
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
try {
|
||||
final products = await _db.getProducts();
|
||||
if (mounted) setState(() {
|
||||
_products = products ?? const <Product>[];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('商品データを読み込みませんでした:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addProduct(Product product) async {
|
||||
try {
|
||||
await _db.insertProduct(product);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('商品を登録しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
await _loadProducts();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editProduct(Product product) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final updatedProduct = await _showEditDialog(context, product);
|
||||
if (updatedProduct != null && mounted) {
|
||||
await _db.updateProduct(updatedProduct);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('商品を更新しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
await _loadProducts();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteProduct(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 _db.deleteProduct(id);
|
||||
if (mounted) await _loadProducts();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('商品を削除しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Product?> _showEditDialog(BuildContext context, Product product) async {
|
||||
return showDialog<Product>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'商品情報',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '商品コード *',
|
||||
controller: TextEditingController(text: product.productCode ?? ''),
|
||||
hintText: '例:P-001, P-002 など(半角英数字)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '品名 *',
|
||||
controller: TextEditingController(text: product.name ?? ''),
|
||||
hintText: '例:〇〇商品、製品名で可',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterNumberField(
|
||||
label: '単価(円)*',
|
||||
controller: TextEditingController(text: product.unitPrice.toString()),
|
||||
hintText: '例:2000',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 仕入先情報フィールド
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'仕入先情報(参照)',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '仕入先会社名',
|
||||
controller: TextEditingController(text: product.supplierContactName ?? ''),
|
||||
hintText: '例:株式会社〇〇商事(仕入先マスタから)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 電話番号フィールド - 電話帳連携対応
|
||||
MasterTextField(
|
||||
label: '仕入先電話番号',
|
||||
controller: TextEditingController(text: product.supplierPhoneNumber ?? ''),
|
||||
hintText: '例:03-1234-5678、区切り不要',
|
||||
keyboardType: TextInputType.phone,
|
||||
phoneField: 'supplierPhoneNumber', // 電話帳連携用
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '仕入先メールアドレス',
|
||||
controller: TextEditingController(text: product.email ?? ''),
|
||||
hintText: '@example.com の形式(例:order@ooshouki.co.jp)',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '仕入先住所',
|
||||
controller: TextEditingController(text: product.address ?? ''),
|
||||
hintText: '〒000-0000 市区町村名・番地・建物名',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'保存',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, product),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('保存', style: TextStyle(fontSize: 16)),
|
||||
)),
|
||||
|
||||
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('キャンセル'),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showProductDetail(BuildContext context, Product product) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('商品詳細'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (product.productCode.isNotEmpty) _detailRow('商品コード', product.productCode),
|
||||
if (product.name.isNotEmpty) _detailRow('品名', product.name),
|
||||
_detailRow('単価', '¥${product.unitPrice.toStringAsFixed(2)}'),
|
||||
_detailRow('在庫数', product.stock.toString()),
|
||||
_detailRow('仕入先会社名', product.supplierContactName ?? '-'),
|
||||
if (product.supplierPhoneNumber.isNotEmpty) _detailRow('電話番号', product.supplierPhoneNumber),
|
||||
if (product.email.isNotEmpty) _detailRow('Email', product.email),
|
||||
if (product.address.isNotEmpty) _detailRow('住所', product.address),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 80),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (value != '-') Text(value),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('/M1. 商品マスタ'),
|
||||
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts)],
|
||||
),
|
||||
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
|
||||
_products.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[300]),
|
||||
SizedBox(height: 16),
|
||||
Text('商品データがありません', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 16),
|
||||
FloatingActionButton.extended(
|
||||
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: () => _showAddDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = _products[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 4,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Icon(Icons.shopping_basket, color: Colors.blue)),
|
||||
title: Text(product.name ?? '未入力'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (product.productCode.isNotEmpty) Text('コード:${product.productCode}', style: const TextStyle(fontSize: 12)),
|
||||
if (product.unitPrice > 0) Text('単価:¥${product.unitPrice.toStringAsFixed(0)}', style: const TextStyle(fontSize: 12)),
|
||||
if (product.supplierContactName.isNotEmpty) Text('仕入先:${product.supplierContactName}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editProduct(product)),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) => value == 'delete' ? _deleteProduct(product.id ?? 0) : null,
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(child: Text('詳細'), onPressed: () => _showProductDetail(context, product)),
|
||||
const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteProduct(product.id ?? 0)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: () => _showAddDialog(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'新規商品登録',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '商品コード *',
|
||||
controller: TextEditingController(),
|
||||
hintText: '例:P-001, P-002 など(半角英数字)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '品名 *',
|
||||
controller: TextEditingController(),
|
||||
hintText: '例:〇〇商品、製品名で可',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterNumberField(
|
||||
label: '単価(円)*',
|
||||
controller: TextEditingController(),
|
||||
hintText: '例:2000',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'仕入先情報(参照)',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '仕入先会社名',
|
||||
controller: TextEditingController(),
|
||||
hintText: '例:株式会社〇〇商事(仕入先マスタから)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 電話番号フィールド - 電話帳連携対応
|
||||
MasterTextField(
|
||||
label: '仕入先電話番号',
|
||||
controller: TextEditingController(),
|
||||
keyboardType: TextInputType.phone,
|
||||
hintText: '例:03-1234-5678、区切り不要',
|
||||
phoneField: 'supplierPhoneNumber', // 電話帳連携用
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '仕入先メールアドレス',
|
||||
controller: TextEditingController(),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
hintText: '@example.com の形式(例:order@ooshouki.co.jp)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '仕入先住所',
|
||||
controller: TextEditingController(),
|
||||
hintText: '〒000-0000 市区町村名・番地・建物名',
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'保存',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('保存', style: TextStyle(fontSize: 16)),
|
||||
)),
|
||||
|
||||
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('キャンセル'),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
474
@workspace/lib/screens/master/supplier_master_screen.dart
Normal file
474
@workspace/lib/screens/master/supplier_master_screen.dart
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
// Version: 2.8 - 簡易仕入先マスタ(電話帳対応)
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../services/database_helper.dart';
|
||||
import '../../widgets/master_edit_fields.dart';
|
||||
|
||||
/// 簡易仕入先マスタ管理画面 + 電話帳連携対応
|
||||
class RichSupplierMasterScreen extends StatefulWidget {
|
||||
const RichSupplierMasterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RichSupplierMasterScreen> createState() => _RichSupplierMasterScreenState();
|
||||
}
|
||||
|
||||
class _RichSupplierMasterScreenState extends State<RichSupplierMasterScreen> {
|
||||
final DatabaseHelper _db = DatabaseHelper.instance;
|
||||
List<Product> _suppliers = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSuppliers();
|
||||
}
|
||||
|
||||
Future<void> _loadSuppliers() async {
|
||||
try {
|
||||
final products = await _db.getProducts();
|
||||
if (mounted) setState(() {
|
||||
_suppliers = products ?? const <Product>[];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('仕入先データを読み込みませんでした:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addSupplier(Product supplier) async {
|
||||
try {
|
||||
await _db.insertProduct(supplier);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('仕入先を登録しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
await _loadSuppliers();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editSupplier(Product supplier) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final updatedSupplier = await _showEditDialog(context, supplier);
|
||||
if (updatedSupplier != null && mounted) {
|
||||
await _db.updateProduct(updatedSupplier);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('仕入先を更新しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
await _loadSuppliers();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSupplier(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 {
|
||||
// TODO: データベースから削除ロジックを実装(現在は簡易実装)
|
||||
if (mounted) _loadSuppliers();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('仕入先を削除しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Product?> _showEditDialog(BuildContext context, Product supplier) async {
|
||||
return showDialog<Product>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'仕入先情報',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '製品コード *',
|
||||
controller: TextEditingController(text: supplier.productCode ?? ''),
|
||||
hintText: '例:S-001, SAN-002 など(半角英数字)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '会社名 *',
|
||||
controller: TextEditingController(text: supplier.name ?? ''),
|
||||
hintText: '例:株式会社〇〇商事、個人商社で可',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '担当者名',
|
||||
controller: TextEditingController(text: supplier.supplierContactName.isNotEmpty ? supplier.supplierContactName : ''),
|
||||
hintText: '例:田中太郎(日本語漢字可)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 電話番号フィールド - 電話帳連携対応
|
||||
MasterTextField(
|
||||
label: '電話番号',
|
||||
controller: TextEditingController(text: supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : ''),
|
||||
hintText: '例:03-1234-5678、区切り不要(0312345678)',
|
||||
keyboardType: TextInputType.phone,
|
||||
phoneField: 'supplierPhoneNumber', // 電話帳連携用
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: 'メールアドレス',
|
||||
controller: TextEditingController(text: supplier.email.isNotEmpty ? supplier.email : ''),
|
||||
hintText: '@example.com の形式(例:order@ooshouki.co.jp)',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '住所',
|
||||
controller: TextEditingController(text: supplier.address.isNotEmpty ? supplier.address : ''),
|
||||
hintText: '〒000-0000 市区町村名・番地・建物名',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 評価ポイント(1-5)
|
||||
MasterNumberField(
|
||||
label: '評価ポイント *',
|
||||
controller: TextEditingController(text: supplier.quantity.toString()),
|
||||
hintText: '1-5 の範囲(例:5 は最高レベル)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterDateField(
|
||||
label: '登録日',
|
||||
controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(supplier.createdAt ?? DateTime.now())),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'保存',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, supplier),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('保存', style: TextStyle(fontSize: 16)),
|
||||
)),
|
||||
|
||||
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
||||
child: const Text('キャンセル'),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showSupplierDetail(BuildContext context, Product supplier) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('仕入先詳細'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (supplier.productCode.isNotEmpty) _detailRow('製品コード', supplier.productCode),
|
||||
if (supplier.name.isNotEmpty) _detailRow('会社名', supplier.name),
|
||||
if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName),
|
||||
_detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'),
|
||||
if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email),
|
||||
if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address),
|
||||
_detailRow('評価ポイント', '★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)),
|
||||
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 100),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (value != '-') Text(value),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('/M3. 仕入先マスタ'),
|
||||
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers)],
|
||||
),
|
||||
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
|
||||
_suppliers.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||
SizedBox(height: 16),
|
||||
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 16),
|
||||
FloatingActionButton.extended(
|
||||
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: () => _showAddDialog(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _suppliers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final supplier = _suppliers[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 4,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.orange.shade100, child: Icon(Icons.business, color: Colors.orange)),
|
||||
title: Text(supplier.name ?? '未入力'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (supplier.productCode.isNotEmpty) Text('製品コード:${supplier.productCode}', style: const TextStyle(fontSize: 12)),
|
||||
if (supplier.supplierPhoneNumber.isNotEmpty) Text('電話:${supplier.supplierPhoneNumber}', style: const TextStyle(fontSize: 12)),
|
||||
if (supplier.email.isNotEmpty) Text('Email: ${supplier.email}', style: const TextStyle(fontSize: 12)),
|
||||
Text('評価:★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1), style: const TextStyle(color: Colors.orange, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editSupplier(supplier)),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) => value == 'delete' ? _deleteSupplier(supplier.id ?? 0) : null,
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(child: Text('詳細'), onPressed: () => _showSupplierDetail(context, supplier)),
|
||||
const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteSupplier(supplier.id ?? 0)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: () => _showAddDialog(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'新規仕入先登録',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '製品コード *',
|
||||
controller: TextEditingController(),
|
||||
hintText: '例:S-001, SAN-002 など(半角英数字)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '会社名 *',
|
||||
controller: TextEditingController(),
|
||||
hintText: '例:株式会社〇〇商事、個人商社で可',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '担当者名',
|
||||
controller: TextEditingController(),
|
||||
hintText: '例:田中太郎(日本語漢字可)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 電話番号フィールド - 電話帳連携対応
|
||||
MasterTextField(
|
||||
label: '電話番号',
|
||||
controller: TextEditingController(),
|
||||
keyboardType: TextInputType.phone,
|
||||
hintText: '例:03-1234-5678、区切り不要(0312345678)',
|
||||
phoneField: 'supplierPhoneNumber', // 電話帳連携用
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: 'メールアドレス',
|
||||
controller: TextEditingController(),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
hintText: '@example.com の形式(例:order@ooshouki.co.jp)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '住所',
|
||||
controller: TextEditingController(),
|
||||
hintText: '〒000-0000 市区町村名・番地・建物名',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterNumberField(
|
||||
label: '評価ポイント *',
|
||||
controller: TextEditingController(),
|
||||
hintText: '1-5 の範囲(例:3)',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterDateField(
|
||||
label: '登録日',
|
||||
controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(DateTime.now())),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 仕入先マスタ用詳細表示ダイアログ(電話帳対応)
|
||||
class SupplierDetailDialog extends StatelessWidget {
|
||||
final Product supplier;
|
||||
|
||||
const SupplierDetailDialog({super.key, required this.supplier});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('仕入先詳細'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_detailRow('製品コード', supplier.productCode ?? '-'),
|
||||
_detailRow('会社名', supplier.name ?? '-'),
|
||||
if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName),
|
||||
_detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'),
|
||||
if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email),
|
||||
if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address),
|
||||
_detailRow('評価ポイント', '★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)),
|
||||
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('閉じる'))],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 100),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (value != '-') Text(value),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
353
@workspace/lib/services/database_helper.dart
Normal file
353
@workspace/lib/services/database_helper.dart
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// DatabaseHelper - シンプルデータベースアクセスヘルパー(sqflite 直接操作)
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import '../models/product.dart';
|
||||
|
||||
// Customer モデル
|
||||
class Customer {
|
||||
final int? id;
|
||||
final String? customerCode;
|
||||
final String? name;
|
||||
final String? address;
|
||||
final String? phone;
|
||||
final String? email;
|
||||
final bool isInactive;
|
||||
|
||||
Customer({
|
||||
this.id,
|
||||
this.customerCode,
|
||||
this.name,
|
||||
this.address,
|
||||
this.phone,
|
||||
this.email,
|
||||
this.isInactive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class DatabaseHelper {
|
||||
static Database? _database;
|
||||
|
||||
/// データベース初期化(サンプルデータ付き)
|
||||
static Future<void> init() async {
|
||||
if (_database != null) return;
|
||||
|
||||
try {
|
||||
// アプリの現在のフォルダを DB パスに使用(開発/テスト用)
|
||||
final dbPath = Directory.current.path + '/data/db/sales.db';
|
||||
|
||||
_database = await _initDatabase(dbPath);
|
||||
print('[DatabaseHelper] DB initialized successfully');
|
||||
|
||||
} catch (e) {
|
||||
print('DB init error: $e');
|
||||
throw Exception('Database initialization failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// テーブル作成時にサンプルデータを自動的に挿入
|
||||
static Future<Database> _initDatabase(String path) async {
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 2, // バージョンアップ(仕入先情報カラム追加用)
|
||||
onCreate: _onCreateTableWithSampleData,
|
||||
);
|
||||
}
|
||||
|
||||
/// テーブル作成用関数 + サンプルデータ自動挿入
|
||||
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
|
||||
// products テーブル(Product モデルと整合性を取る)+ 仕入先情報カラム追加
|
||||
await db.execute('''
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
unit_price REAL DEFAULT 0.0,
|
||||
quantity INTEGER DEFAULT 0,
|
||||
stock INTEGER DEFAULT 0,
|
||||
supplier_contact_name TEXT,
|
||||
supplier_phone_number TEXT,
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// customers テーブル
|
||||
await db.execute('''
|
||||
CREATE TABLE customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// sales テーブル
|
||||
await db.execute('''
|
||||
CREATE TABLE sales (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER,
|
||||
product_id INTEGER REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
total_amount REAL NOT NULL,
|
||||
tax_rate REAL DEFAULT 8.0,
|
||||
tax_amount REAL,
|
||||
grand_total REAL NOT NULL,
|
||||
status TEXT DEFAULT 'completed',
|
||||
payment_status TEXT DEFAULT 'paid',
|
||||
invoice_number TEXT UNIQUE,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// estimates テーブル(Estimate モデルと整合性を取る)
|
||||
await db.execute('''
|
||||
CREATE TABLE estimates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
quote_number TEXT UNIQUE,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
product_id INTEGER REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
discount_percent REAL DEFAULT 0.0,
|
||||
total_amount REAL NOT NULL,
|
||||
tax_rate REAL DEFAULT 8.0,
|
||||
tax_amount REAL,
|
||||
grand_total REAL NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
payment_status TEXT DEFAULT 'unpaid',
|
||||
expiry_date TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// インデックス
|
||||
await db.execute('CREATE INDEX idx_products_code ON products(product_code)');
|
||||
await db.execute('CREATE INDEX idx_customers_code ON customers(customer_code)');
|
||||
|
||||
// サンプル製品データを挿入(テーブル作成時に自動的に実行)
|
||||
final sampleProducts = <Map<String, dynamic>>[
|
||||
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50, 'stock': 50},
|
||||
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30, 'stock': 30},
|
||||
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20, 'stock': 20},
|
||||
];
|
||||
|
||||
for (final data in sampleProducts) {
|
||||
await db.insert('products', data);
|
||||
}
|
||||
|
||||
print('[DatabaseHelper] Sample products inserted');
|
||||
}
|
||||
|
||||
/// データベースインスタンスへのアクセス
|
||||
static Database get instance => _database!;
|
||||
|
||||
/// 製品一覧を取得(非アクティブ除外)
|
||||
static Future<List<Product>> getProducts() async {
|
||||
final result = await instance.query('products', orderBy: 'id DESC');
|
||||
|
||||
// DateTime オブジェクトを文字列に変換してから Product からマップ
|
||||
return List.generate(result.length, (index) {
|
||||
final item = Map<String, dynamic>.from(result[index]);
|
||||
|
||||
if (item['created_at'] is DateTime) {
|
||||
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
if (item['updated_at'] is DateTime) {
|
||||
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
|
||||
return Product.fromMap(item);
|
||||
});
|
||||
}
|
||||
|
||||
/// 製品を ID で取得(エラー時は null を返す)
|
||||
static Future<Product?> getProduct(int id) async {
|
||||
final result = await instance.query(
|
||||
'products',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
final item = Map<String, dynamic>.from(result[0]);
|
||||
|
||||
if (item['created_at'] is DateTime) {
|
||||
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
if (item['updated_at'] is DateTime) {
|
||||
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
|
||||
return Product.fromMap(item);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 製品を productCode で取得(エラー時は null を返す)
|
||||
static Future<Product?> getProductByCode(String code) async {
|
||||
final result = await instance.query(
|
||||
'products',
|
||||
where: 'product_code = ?',
|
||||
whereArgs: [code],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
final item = Map<String, dynamic>.from(result[0]);
|
||||
|
||||
if (item['created_at'] is DateTime) {
|
||||
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
if (item['updated_at'] is DateTime) {
|
||||
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
|
||||
return Product.fromMap(item);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// クライアント ID での顧客検索(エラー時は null を返す)
|
||||
static Future<Customer?> getCustomerById(int id) async {
|
||||
final result = await instance.query(
|
||||
'customers',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
return Customer(
|
||||
id: result[0]['id'] as int?,
|
||||
customerCode: result[0]['customer_code'] as String?,
|
||||
name: result[0]['name'] as String?,
|
||||
address: result[0]['address'] as String?,
|
||||
phone: result[0]['phone'] as String?,
|
||||
email: result[0]['email'] as String?,
|
||||
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 顧客をコードで検索(エラー時は null を返す)
|
||||
static Future<Customer?> getCustomerByCode(String code) async {
|
||||
final result = await instance.query(
|
||||
'customers',
|
||||
where: 'customer_code = ?',
|
||||
whereArgs: [code],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
return Customer(
|
||||
id: result[0]['id'] as int?,
|
||||
customerCode: result[0]['customer_code'] as String?,
|
||||
name: result[0]['name'] as String?,
|
||||
address: result[0]['address'] as String?,
|
||||
phone: result[0]['phone'] as String?,
|
||||
email: result[0]['email'] as String?,
|
||||
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 顧客を insert
|
||||
static Future<int> insertCustomer(Customer customer) async {
|
||||
final id = await instance.insert('customers', customer.toMap());
|
||||
print('[DatabaseHelper] Customer inserted: $id');
|
||||
return id;
|
||||
}
|
||||
|
||||
/// 顧客を更新
|
||||
static Future<void> updateCustomer(Customer customer) async {
|
||||
await instance.update(
|
||||
'customers',
|
||||
{'name': customer.name, 'address': customer.address, 'phone': customer.phone, 'email': customer.email},
|
||||
where: 'id = ?',
|
||||
whereArgs: [customer.id],
|
||||
);
|
||||
print('[DatabaseHelper] Customer updated');
|
||||
}
|
||||
|
||||
/// 顧客を削除
|
||||
static Future<void> deleteCustomer(int id) async {
|
||||
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
|
||||
print('[DatabaseHelper] Customer deleted');
|
||||
}
|
||||
|
||||
/// 製品を insert
|
||||
static Future<int> insertProduct(Product product) async {
|
||||
final id = await instance.insert('products', product.toMap());
|
||||
print('[DatabaseHelper] Product inserted: $id');
|
||||
return id;
|
||||
}
|
||||
|
||||
/// 製品を更新
|
||||
static Future<void> updateProduct(Product product) async {
|
||||
await instance.update(
|
||||
'products',
|
||||
{
|
||||
'name': product.name,
|
||||
'unit_price': product.unitPrice,
|
||||
'quantity': product.quantity,
|
||||
'stock': product.stock,
|
||||
'supplier_contact_name': product.supplierContactName,
|
||||
'supplier_phone_number': product.supplierPhoneNumber,
|
||||
'email': product.email,
|
||||
'address': product.address,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [product.id],
|
||||
);
|
||||
print('[DatabaseHelper] Product updated');
|
||||
}
|
||||
|
||||
/// 製品を削除
|
||||
static Future<void> deleteProduct(int id) async {
|
||||
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||
print('[DatabaseHelper] Product deleted');
|
||||
}
|
||||
|
||||
/// DB をクリア(サンプルデータは保持しない)
|
||||
static Future<void> clearDatabase() async {
|
||||
await instance.delete('products');
|
||||
await instance.delete('customers');
|
||||
await instance.delete('sales');
|
||||
await instance.delete('estimates');
|
||||
}
|
||||
|
||||
/// データベースを回復(全削除 + リセット + テーブル再作成)
|
||||
static Future<void> recover() async {
|
||||
try {
|
||||
// 既存の DB ファイルを削除
|
||||
final dbPath = Directory.current.path + '/data/db/sales.db';
|
||||
final file = File(dbPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
print('[DatabaseHelper] recover: DB ファイルを削除');
|
||||
} else {
|
||||
print('[DatabaseHelper] recover: DB ファイルが見つからない');
|
||||
}
|
||||
|
||||
// 初期化を再実行(テーブル作成時にサンプルデータが自動的に挿入される)
|
||||
await init();
|
||||
} catch (e) {
|
||||
print('[DatabaseHelper] recover error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// DB パスを取得
|
||||
static Future<String> getDbPath() async {
|
||||
return Directory.current.path + '/data/db/sales.db';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +1,210 @@
|
|||
// マスター編集用汎用ウィジェット(簡易実装)+ 電話帳連携対応
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:contacts_service/contacts_service.dart';
|
||||
|
||||
/// マスタ編集用の汎用テキストフィールドウィジェット
|
||||
class MasterTextField extends StatelessWidget {
|
||||
/// テキスト入力フィールド(マスター用)+ 電話帳取得ボタン付き
|
||||
class MasterTextField extends StatefulWidget {
|
||||
final String label;
|
||||
final String? initialValue;
|
||||
final TextEditingController controller;
|
||||
final TextInputType? keyboardType;
|
||||
final bool readOnly;
|
||||
final int? maxLines;
|
||||
final String? hintText;
|
||||
final VoidCallback? onTap;
|
||||
final String? initialValueText;
|
||||
final String? phoneField; // 電話番号フィールド(電話帳取得用)
|
||||
|
||||
const MasterTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
required this.controller,
|
||||
this.keyboardType,
|
||||
this.readOnly = false,
|
||||
this.maxLines,
|
||||
this.hintText,
|
||||
this.onTap,
|
||||
this.initialValueText,
|
||||
this.phoneField,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MasterTextField> createState() => _MasterTextFieldState();
|
||||
}
|
||||
|
||||
class _MasterTextFieldState extends State<MasterTextField> {
|
||||
bool _isSearchingPhone = false;
|
||||
String? _tempPhoneNumber;
|
||||
|
||||
Future<void> _searchContactsForPhone() async {
|
||||
if (widget.readOnly) return;
|
||||
|
||||
// UI に待機表示を表示
|
||||
setState(() {
|
||||
_isSearchingPhone = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final contacts = await ContactsService.getContacts(limit: 10);
|
||||
final selectedContact = contacts.firstWhere(
|
||||
(contact) => contact.phones.any((phone) =>
|
||||
phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
|
||||
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '')),
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (selectedContact != null && selectedContact.phones.isNotEmpty) {
|
||||
// 番号を一致するものを選択
|
||||
final matchingPhone = selectedContact.phones.firstWhere(
|
||||
(phone) => phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
|
||||
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', ''),
|
||||
orElse: () => selectedContact.phones.first,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_tempPhoneNumber = matchingPhone.number;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('連絡先検索に失敗しました:$e'), backgroundColor: Colors.orange),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSearchingPhone = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextFormField(
|
||||
initialValue: initialValue,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(widget.label, style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
if (!widget.readOnly && widget.phoneField != null)
|
||||
_buildPhoneSearchButton(),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策:最大高制限
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: widget.controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
hintText: widget.hintText ?? (widget.initialValueText != null ? widget.initialValueText : null),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
keyboardType: widget.keyboardType,
|
||||
readOnly: widget.readOnly,
|
||||
maxLines: widget.maxLines ?? (widget.phoneField == null ? 1 : 2), // 電話番号時は最大 2 行
|
||||
),
|
||||
if (_isSearchingPhone && _tempPhoneNumber != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.hourglass_empty, size: 16, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('電話帳から取得中...', style: TextStyle(color: Colors.orange[700])),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_tempPhoneNumber != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, size: 16, color: Colors.green),
|
||||
SizedBox(width: 8),
|
||||
Text('見つかりました:$_tempPhoneNumber', style: TextStyle(color: Colors.green[700])),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_tempPhoneNumber = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneSearchButton() {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: '電話帳から取得',
|
||||
onPressed: _searchContactsForPhone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// マスタ編集用の汎用数値フィールドウィジェット
|
||||
/// 数値入力フィールド(マスター用)
|
||||
class MasterNumberField extends StatelessWidget {
|
||||
final String label;
|
||||
final double? initialValue;
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final VoidCallback? onTap;
|
||||
final bool readOnly;
|
||||
final String? initialValueText;
|
||||
|
||||
const MasterNumberField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.onTap,
|
||||
this.readOnly = false,
|
||||
this.initialValueText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextFormField(
|
||||
initialValue: initialValue?.toString(),
|
||||
SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲(例:3)'),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
onTap: onTap,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
readOnly: readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -83,119 +212,326 @@ class MasterNumberField extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// ドロップダウンフィールドウィジェット
|
||||
class MasterDropdownField<T> extends StatelessWidget {
|
||||
/// 日付入力フィールド(マスター用)
|
||||
class MasterDateField extends StatelessWidget {
|
||||
final String label;
|
||||
final List<String> options;
|
||||
final String? selectedOption;
|
||||
final VoidCallback? onTap;
|
||||
final TextEditingController controller;
|
||||
final DateTime? picked;
|
||||
|
||||
const MasterDropdownField({
|
||||
const MasterDateField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.options,
|
||||
this.selectedOption,
|
||||
this.onTap,
|
||||
required this.controller,
|
||||
this.picked,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedOption,
|
||||
items: options.map((option) => DropdownMenuItem<String>(
|
||||
value: option,
|
||||
child: Text(option),
|
||||
)).toList(),
|
||||
decoration: InputDecoration(
|
||||
hintText: options.isEmpty ? null : options.first,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
),
|
||||
onTap: onTap,
|
||||
isExpanded: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// テキストエリアウィジェット
|
||||
class MasterTextArea extends StatelessWidget {
|
||||
final String label;
|
||||
final String? initialValue;
|
||||
final String? hintText;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MasterTextArea({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
this.hintText,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextFormField(
|
||||
initialValue: initialValue,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
),
|
||||
maxLines: 3,
|
||||
onTap: onTap,
|
||||
textInputAction: TextInputAction.newline,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// チェックボックスウィジェット
|
||||
class MasterCheckBox extends StatelessWidget {
|
||||
final String label;
|
||||
final bool? initialValue;
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
const MasterCheckBox({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.initialValue,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label)),
|
||||
Checkbox(
|
||||
value: initialValue,
|
||||
onChanged: onChanged ?? (_ => null),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
onPressed: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (picked != null) {
|
||||
controller.text = DateFormat('yyyy/MM/dd').format(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// テキストフィールド用カスタムフォーカスノード(QR コード生成対応)
|
||||
class MasterTextFieldNode extends FocusNode {
|
||||
final GlobalKey key;
|
||||
final String? value;
|
||||
final bool isEditable;
|
||||
|
||||
MasterTextFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey();
|
||||
|
||||
@override
|
||||
FocusableState get state => key.currentState as FocusableState;
|
||||
}
|
||||
|
||||
/// テキストフィールド用カスタムフォーカスノード(QR コード生成対応)
|
||||
class MasterNumberFieldNode extends FocusNode {
|
||||
final GlobalKey key;
|
||||
final String? value;
|
||||
final bool isEditable;
|
||||
|
||||
MasterNumberFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey();
|
||||
|
||||
@override
|
||||
FocusableState get state => key.currentState as FocusableState;
|
||||
}
|
||||
|
||||
/// リッチマスターテキストフィールド(汎用性高く、他のマスタ参照可)
|
||||
class RichMasterTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final TextInputType? keyboardType;
|
||||
final bool readOnly;
|
||||
final int? maxLines;
|
||||
final String? hintText;
|
||||
final String? initialValueText;
|
||||
|
||||
const RichMasterTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.keyboardType,
|
||||
this.readOnly = false,
|
||||
this.maxLines,
|
||||
this.hintText,
|
||||
this.initialValueText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (initialValueText != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () => _copyToClipboard(initialValueText!),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText ?? (initialValueText != null ? initialValueText : null),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
keyboardType: keyboardType,
|
||||
readOnly: readOnly,
|
||||
maxLines: maxLines ?? 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _copyToClipboard(String text) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// リッチマスター日付フィールド(汎用性高く、他のマスタ参照可)
|
||||
class RichMasterDateField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final DateTime? picked;
|
||||
|
||||
const RichMasterDateField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.picked,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (picked != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () => _copyToDate(controller.text),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_today),
|
||||
onPressed: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (picked != null) {
|
||||
controller.text = DateFormat('yyyy/MM/dd').format(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _copyToDate(String dateStr) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: dateStr));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('日付をコピーしました'), backgroundColor: Colors.green),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// リッチマスター数値フィールド(汎用性高く、他のマスタ参照可)
|
||||
class RichMasterNumberField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final bool readOnly;
|
||||
final String? initialValueText;
|
||||
|
||||
const RichMasterNumberField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.readOnly = false,
|
||||
this.initialValueText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (initialValueText != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () => _copyToClipboard(initialValueText!),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲(例:3)'),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
readOnly: readOnly,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _copyToClipboard(String text) async {
|
||||
try {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
@workspace/pubspec.yaml
Normal file
37
@workspace/pubspec.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
name: sales_assist_1
|
||||
description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+6
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.6
|
||||
|
||||
# SQLite データ永続化
|
||||
sqflite: any
|
||||
sqflite_android: any
|
||||
path_provider: ^2.1.1
|
||||
|
||||
# PDF 帳票出力(flutter_pdf_generator の代わりに使用)
|
||||
pdf: ^3.10.8
|
||||
printing: ^5.9.0
|
||||
intl: ^0.19.0
|
||||
share_plus: ^10.1.2
|
||||
google_sign_in: ^7.2.0
|
||||
|
||||
# リッチマスター編集用機能(簡易実装)
|
||||
image_picker: ^1.0.7
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
67
README.md
67
README.md
|
|
@ -1,4 +1,65 @@
|
|||
# 📦 Sales Assist - H-1Q (Flutter)
|
||||
# 販売管理システム(販売アシスト)
|
||||
|
||||
**バージョン:** 1.5
|
||||
**コミット:** `13f7e
|
||||
簡素版の Flutter アプリ。全てのマスター編集画面で共通部品を使用しています。
|
||||
|
||||
## ビルド方法
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
APK は `build/app/outputs/flutter-apk/app-release.apk` に出力されます。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. APK をインストールしてアプリを起動
|
||||
2. ダッシュボード画面から機能を選択
|
||||
3. マスタの編集は全て共通部品を使用
|
||||
|
||||
## 画面割当と共通部品
|
||||
|
||||
**重要**: 全てのマスター編集画面で以下の共通部品を使用します。
|
||||
|
||||
### 共通使用部品
|
||||
|
||||
| 部品名 | ファイル | 用途 |
|
||||
|--------|----------|------|
|
||||
| `MasterEditDialog` | `lib/widgets/master_edit_dialog.dart` | マスタ編集ダイアログ(全てのマスタ) |
|
||||
| `MasterTextField` | `lib/widgets/master_edit_fields.dart` | テキスト入力フィールド |
|
||||
| `MasterTextArea` | `lib/widgets/master_edit_fields.dart` | テキストエリアフィールド |
|
||||
| `MasterNumberField` | `lib/widgets/master_edit_fields.dart` | 数値入力フィールド |
|
||||
| `MasterStatusField` | `lib/widgets/master_edit_fields.dart` | ステータス表示フィールド |
|
||||
| `MasterCheckboxField` | `lib/widgets/master_edit_fields.dart` | チェックボックスフィールド |
|
||||
|
||||
### 各マスター画面の共通部品使用状況
|
||||
|
||||
| マスタ画面 | 編集ダイアログ | リッチ程度 |
|
||||
|------------|----------------|-------------|
|
||||
| 商品マスタ | ✅ MasterEditDialog | 簡素版統一 |
|
||||
| 得意先マスタ | ✅ MasterEditDialog | 簡素版統一 |
|
||||
| 仕入先マスタ | ✅ MasterEditDialog | 簡素版統一 |
|
||||
| 担当マスタ | ✅ MasterEditDialog | 簡素版統一 |
|
||||
| 倉庫マスタ | ⚠️ 除外(簡素版のため) | - |
|
||||
|
||||
## 機能一覧
|
||||
|
||||
- **ダッシュボード**: メイン画面、統計情報表示
|
||||
- **見積入力画面** (`/estimate`): 見積りの作成・管理
|
||||
- **在庫管理** (`/inventory`): 未実装
|
||||
- **商品マスタ** (`/master/product`): 商品の登録・編集・削除
|
||||
- **得意先マスタ** (`/master/customer`): 顧客の登録・編集・削除
|
||||
- **仕入先マスタ** (`/master/supplier`): 仕入先の登録・編集・削除
|
||||
- **担当マスタ** (`/master/employee`): 担当者の登録・編集・削除
|
||||
- **倉庫マスタ**: 未実装(簡素版のため除外)
|
||||
- **売上入力画面** (`/sales`): 売上情報の入力
|
||||
|
||||
## 注意事項
|
||||
|
||||
- 倉庫マスタと在庫管理は簡素版のため未実装です
|
||||
- すべてのマスター編集画面で共通部品を使用してください
|
||||
- 独自の実装は推奨されません
|
||||
|
||||
## ライセンス
|
||||
|
||||
Copyright (c) 2026. All rights reserved.
|
||||
409
docs/project_specification.md
Normal file
409
docs/project_specification.md
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
# 制作小プロジェクト - 企画設計指示書
|
||||
|
||||
## 1. プロジェクト概要
|
||||
|
||||
### 1.1 目的
|
||||
リッチなマスター編集機能を持つ販売アシスタントアプリを効率的に開発するための、AI(LLM)による自動コーディングを支援する企画設計指示書。
|
||||
|
||||
### 1.2 スコープ
|
||||
- マスターデータの CRUD 機能強化
|
||||
- リッチな入力フィールド(画像/動画アップロード、QR コード生成)
|
||||
- フォームバリデーションとヒント表示
|
||||
- 汎用ウィジェットによるコード削減
|
||||
|
||||
---
|
||||
|
||||
## 2. コンテンツ定義
|
||||
|
||||
### 2.1 データモデル一覧
|
||||
|
||||
#### Customer(得意先)
|
||||
| プロパティ | タイプ | キー | ビルンール | ヒント |
|
||||
|----------|--------|------|-----------|--------|
|
||||
| id | int? | PK | autoincrement | - |
|
||||
| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 |
|
||||
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com)|
|
||||
| phone | String | | nullable, max 20 | 電話番号(区切りなし:090-1234-5678→09012345678)|
|
||||
| address | String | | nullable, max 200 | 住所(省スペース表示・多言語対応)|
|
||||
| created_at | DateTime? | - | null allow | DB レコード作成時 |
|
||||
|
||||
#### Product(商品)
|
||||
| プロパティ | タイプ | キー | ビルンール | ヒント |
|
||||
|----------|--------|------|-----------|--------|
|
||||
| id | int? | PK | autoincrement | - |
|
||||
| name | String | UK | not null, max 50 | 例:iPhone、ノートパソコンなど |
|
||||
| price | double? | - | null allow | 円単位(小数点以下 2 桁)|
|
||||
| stock | int? | - | null allow | 在庫数 |
|
||||
| description | String | - | nullable, max 500 | 商品の説明・特徴 |
|
||||
| created_at | DateTime? | - | null allow | DB レコード作成時 |
|
||||
|
||||
#### Supplier(仕入先)
|
||||
| プロパティ | タイプ | キー | ビルンール | ヒント |
|
||||
|----------|--------|------|-----------|--------|
|
||||
| id | int? | PK | autoincrement | - |
|
||||
| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 |
|
||||
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com)|
|
||||
| phone | String | | nullable, max 20 | 電話番号(区切りなし)|
|
||||
| address | String | | nullable, max 200 | 住所 |
|
||||
| created_at | DateTime? | - | null allow | DB レコード作成時 |
|
||||
|
||||
#### Warehouse(倉庫)
|
||||
| プロパティ | タイプ | キー | ビルンール | ヒント |
|
||||
|----------|--------|------|-----------|--------|
|
||||
| id | int? | PK | autoincrement | - |
|
||||
| name | String | UK | not null, max 50 | 例:東京都千代田区〇丁目倉庫、大阪支店倉庫 |
|
||||
| address | String | | nullable, max 200 | 住所 |
|
||||
| capacity | int? | - | null allow | 保管容量(単位:坪)|
|
||||
| created_at | DateTime? | - | null allow | DB レコード作成時 |
|
||||
|
||||
#### Employee(担当者)
|
||||
| プロパティ | タイプ | キー | ビルンール | ヒント |
|
||||
|----------|--------|------|-----------|--------|
|
||||
| id | int? | PK | autoincrement | - |
|
||||
| name | String | UK | not null, max 50 | 例:田中太郎、鈴木花子 |
|
||||
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com)|
|
||||
| phone | String | | nullable, max 20 | 電話番号(区切りなし)|
|
||||
| role | String | - | nullable, max 30 | 例:管理者、営業、倉庫員など |
|
||||
| created_at | DateTime? | - | null allow | DB レコード作成時 |
|
||||
|
||||
---
|
||||
|
||||
## 3. UI コンポーネント定義
|
||||
|
||||
### 3.1 汎用ウィジェット一覧
|
||||
|
||||
#### RichMasterTextField(テキスト入力)
|
||||
```dart
|
||||
RichMasterTextField(
|
||||
label: '商品名',
|
||||
initialValue: 'iPhone',
|
||||
hintText: '例:iPhone、ノートパソコンなど',
|
||||
maxLines: 1,
|
||||
)
|
||||
```
|
||||
|
||||
#### RichMasterNumberField(数値入力)
|
||||
```dart
|
||||
RichMasterNumberField(
|
||||
label: '価格',
|
||||
initialValue: 128000.0,
|
||||
hintText: '例:128,000円、98,500 円など',
|
||||
decimalDigits: 2,
|
||||
)
|
||||
```
|
||||
|
||||
#### RichMasterDateField(日付選択)
|
||||
```dart
|
||||
RichMasterDateField(
|
||||
label: '作成日',
|
||||
initialValue: DateTime.now(),
|
||||
hintText: '例:2024/03/10、今日などの指定可',
|
||||
)
|
||||
```
|
||||
|
||||
#### RichMasterAddressField(住所入力・省スペース)
|
||||
```dart
|
||||
RichMasterAddressField(
|
||||
label: '住所',
|
||||
initialValue: '東京都千代田区〇丁目 1-1',
|
||||
hintText: '例:都道府県名から検索可',
|
||||
)
|
||||
```
|
||||
|
||||
#### RichMasterFileUploader(ファイル・画像アップロード)
|
||||
```dart
|
||||
RichMasterFileUploader(
|
||||
label: '商品画像',
|
||||
onPickImage: () => print('画像選択'),
|
||||
onPickVideo: () => print('動画選択'), // Android 限定
|
||||
)
|
||||
```
|
||||
|
||||
#### RichMasterQRCodeGenerator(QR コード生成)
|
||||
```dart
|
||||
RichMasterQRCodeGenerator(
|
||||
label: 'QR コード',
|
||||
text: 'https://example.com/product/123',
|
||||
)
|
||||
```
|
||||
|
||||
#### RichMasterCheckboxField(チェックボックス)
|
||||
```dart
|
||||
RichMasterCheckboxField(
|
||||
label: '在庫あり',
|
||||
initialValue: true,
|
||||
onChanged: (value) => print(value),
|
||||
)
|
||||
```
|
||||
|
||||
#### RichMasterDropdownField(ドロップダウンリスト)
|
||||
```dart
|
||||
RichMasterDropdownField<String>(
|
||||
label: '担当部署',
|
||||
initialValue: '営業部',
|
||||
items: ['営業部', '総務部', '開発部'],
|
||||
itemToString: (item) => item,
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 アプリバー(AppBar)定義
|
||||
|
||||
| ID | 名乘 |
|
||||
|-----|------|
|
||||
| /S1. 見積書 | Sales - Estimate |
|
||||
| /S2. 請求書 | Sales - Invoice |
|
||||
| /S3. 受発注一覧 | Order List |
|
||||
| /S4. 売上入力(レジ) | Sales Register |
|
||||
| /S5. 売上返品入力 | Return Input |
|
||||
| /M1. 商品マスタ | Master - Product |
|
||||
| /M2. 得意先マスタ | Master - Customer |
|
||||
| /M3. 仕入先マスタ | Master - Supplier |
|
||||
| /M4. 倉庫マスタ | Master - Warehouse |
|
||||
| /M5. 担当者マスタ | Master - Employee |
|
||||
|
||||
---
|
||||
|
||||
## 4. ビヘイビア仕様
|
||||
|
||||
### 4.1 フォームバリデーション
|
||||
- **必須フィールド**: 空文字の場合は赤いエラー表示 + ヒント文の再表示
|
||||
- **メール形式検証**: *@example.com の形式のみ許可(正規表現:^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$)
|
||||
- **電話番号検証**: 数字のみ、最大 11 桁まで許可
|
||||
- **数値フィールド**: 小数点以下指定桁数を超える入力時の自動補正
|
||||
|
||||
### 4.2 ヒント・エラー表示
|
||||
```dart
|
||||
// エラー状態
|
||||
TextField(
|
||||
errorText: hintText, // 初期値がヒントとなる
|
||||
)
|
||||
|
||||
// カスタムエラー
|
||||
TextField(
|
||||
decoration: InputDecoration(errorText: '必須入力をしてください'),
|
||||
)
|
||||
```
|
||||
|
||||
### 4.3 ショートカットキー対応(オプション)
|
||||
```dart
|
||||
RichMasterShortcutSettings(
|
||||
label: '編集ヘルプ',
|
||||
showShortcuts: true,
|
||||
shortcuts: {
|
||||
'Ctrl+S': () => print('保存'),
|
||||
'Ctrl+Z': () => print('取り消し'),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### 4.4 セクション分割表示
|
||||
```dart
|
||||
RichMasterSectionHeader(
|
||||
title: '基本情報',
|
||||
icon: Icons.info_outline,
|
||||
color: Colors.blue.shade700,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. コーディングワークフロー
|
||||
|
||||
### 5.1 マスタ画面作成手順
|
||||
|
||||
#### ステップ 1: モデル定義
|
||||
```dart
|
||||
// lib/models/customer.dart
|
||||
class Customer {
|
||||
int? id;
|
||||
String name = '';
|
||||
String? email;
|
||||
String? phone;
|
||||
String? address;
|
||||
DateTime? createdAt;
|
||||
|
||||
Customer({
|
||||
this.id,
|
||||
required this.name,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.address,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory Customer.fromJson(Map<String, dynamic> json) => Customer(
|
||||
id: json['id'] as int?,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
address: json['address'] as String?,
|
||||
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'address': address,
|
||||
'created_at': createdAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
Customer copyWith({
|
||||
int? id,
|
||||
String? name,
|
||||
String? email,
|
||||
String? phone,
|
||||
String? address,
|
||||
DateTime? createdAt,
|
||||
}) => Customer(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
email: email ?? this.email,
|
||||
phone: phone ?? this.phone,
|
||||
address: address ?? this.address,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### ステップ 2: スクリーン定義(master_edit_fields.dart を使用)
|
||||
```dart
|
||||
// lib/screens/master/customer_master_screen.dart
|
||||
class CustomerMasterScreen extends StatefulWidget {
|
||||
const CustomerMasterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
|
||||
}
|
||||
|
||||
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
// データモデル(データベース連携)
|
||||
late Customer _customer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_customer = Customer(name: ''); // 初期値設定
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('/M2. 得意先マスタ')),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
children: [
|
||||
// RichMasterTextField(商品名)
|
||||
RichMasterTextField(
|
||||
label: '得意先名',
|
||||
initialValue: _customer.name,
|
||||
hintText: '例:株式会社〇〇、個人名で可',
|
||||
onChanged: (value) => setState(() => _customer.name = value),
|
||||
),
|
||||
|
||||
// RichMasterTextField(メール)
|
||||
RichMasterTextField(
|
||||
label: 'メールアドレス',
|
||||
initialValue: _customer.email,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
hintText: '@example.com の形式(例:info@example.com)',
|
||||
onChanged: (value) => setState(() => _customer.email = value),
|
||||
),
|
||||
|
||||
// RichMasterNumberField(電話番号)
|
||||
RichMasterTextField(
|
||||
label: '電話番号',
|
||||
initialValue: _customer.phone,
|
||||
hintText: '例:090-1234-5678、区切り不要',
|
||||
onChanged: (value) => setState(() => _customer.phone = value),
|
||||
),
|
||||
|
||||
// RichMasterDateField(作成日)
|
||||
RichMasterDateField(
|
||||
label: '登録日',
|
||||
initialValue: _customer.createdAt?.toLocal(),
|
||||
hintText: '例:2024/03/10、今日などの指定可',
|
||||
),
|
||||
|
||||
// 保存ボタン
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, _customer.toJson()),
|
||||
child: Text('保存'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 汎用マスター作成テンプレート(AI 生成用)
|
||||
|
||||
AI に自動生成させるためのプロンプト例:
|
||||
|
||||
```text
|
||||
以下のデータモデルでマスター画面を作成してください:
|
||||
|
||||
- データモデル: {model_definition}
|
||||
- スクリーン ID: {screen_id, e.g., /M3. 仕入先マスタ}
|
||||
- 使用ウィジェット: RichMasterTextField, RichMasterNumberField, RichMasterDateField など(master_edit_fields.dart を参照)
|
||||
|
||||
要件:
|
||||
1. AppBar に「{screen_id}」を表示
|
||||
2. フォームキーでバリデーションを行う
|
||||
3. 保存ボタンでデータを JSON 形式で返す
|
||||
4. 必須フィールドは空文字をエラー扱いに
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. テスト用データ
|
||||
|
||||
### 6.1 Customer(得意先)テストデータ
|
||||
```json
|
||||
{
|
||||
"name": "株式会社 ABC",
|
||||
"email": "info@abc-company.com",
|
||||
"phone": "03-1234-5678",
|
||||
"address": "東京都千代田区〇丁目 1-1"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Product(商品)テストデータ
|
||||
```json
|
||||
{
|
||||
"name": "iPhone 15 Pro Max",
|
||||
"price": 199440.0,
|
||||
"description": "チタニウム素材の高級スマートフォン。A17 Pro チップ搭載。"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Warehouse(倉庫)テストデータ
|
||||
```json
|
||||
{
|
||||
"name": "東京都千代田区支店倉庫",
|
||||
"address": "東京都千代田区〇丁目 1-1",
|
||||
"capacity": 50
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. まとめ
|
||||
|
||||
この指示書を使用することで、以下が可能になります:
|
||||
|
||||
1. **AI による自動コーディング**: データモデルから画面コードを生成
|
||||
2. **一貫性のある UI**: 汎用ウィジェットでデザイン統一
|
||||
3. **保守性の向上**: 部品レベルでの再利用・修正
|
||||
4. **開発効率化**: テンプレートベースの迅速な実装
|
||||
|
||||
この指示書を元に、LLM(GPT-4 など)に自動コーディングを依頼するか、自前で実装を進めてください。
|
||||
150
lib/main.dart
150
lib/main.dart
|
|
@ -1,17 +1,26 @@
|
|||
// main.dart - アプリのエントリーポイント(データベース初期化)
|
||||
// ※ 簡素版のため、一部の画面を除外しています
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'screens/estimate_screen.dart';
|
||||
import 'screens/invoice_screen.dart';
|
||||
import 'screens/order_screen.dart';
|
||||
import 'screens/sales_return_screen.dart';
|
||||
import 'screens/sales_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'services/database_helper.dart' as db;
|
||||
// import 'screens/estimate_screen.dart'; // 除外中(DatabaseHelper に不足メソッドあり)
|
||||
import 'screens/master/product_master_screen.dart';
|
||||
import 'screens/master/customer_master_screen.dart';
|
||||
import 'screens/master/supplier_master_screen.dart';
|
||||
import 'screens/master/warehouse_master_screen.dart';
|
||||
import 'screens/master/employee_master_screen.dart';
|
||||
import 'screens/master/inventory_master_screen.dart';
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// データベース初期化(エラーが発生してもアプリは起動)
|
||||
try {
|
||||
await db.DatabaseHelper.init();
|
||||
} catch (e) {
|
||||
print('[Main] Database initialization warning: $e');
|
||||
}
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
|
|
@ -21,107 +30,34 @@ class MyApp extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'H-1Q',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(useMaterial3: true),
|
||||
home: const Dashboard(),
|
||||
routes: {
|
||||
'/M1. 商品マスタ': (context) => const ProductMasterScreen(),
|
||||
'/M2. 得意先マスタ': (context) => const CustomerMasterScreen(),
|
||||
'/M3. 仕入先マスタ': (context) => const SupplierMasterScreen(),
|
||||
'/M4. 倉庫マスタ': (context) => const WarehouseMasterScreen(),
|
||||
'/M5. 担当者マスタ': (context) => const EmployeeMasterScreen(),
|
||||
'/S1. 見積入力': (context) => const EstimateScreen(),
|
||||
'/S2. 請求書発行': (context) => const InvoiceScreen(),
|
||||
'/S3. 発注入力': (context) => const OrderScreen(),
|
||||
'/S4. 売上入力(レジ)': (context) => const SalesScreen(),
|
||||
'/S5. 売上返品入力': (context) => const SalesReturnScreen(),
|
||||
title: '販売管理システム',
|
||||
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
|
||||
home: const HomeScreen(), // ダッシュボード表示
|
||||
onGenerateRoute: (settings) {
|
||||
switch (settings.name) {
|
||||
case '/estimate':
|
||||
// 除外中(DatabaseHelper に不足メソッドあり)
|
||||
return null;
|
||||
case '/inventory':
|
||||
// TODO: 実装中(在庫管理画面未実装)
|
||||
return null;
|
||||
case '/master/product':
|
||||
return MaterialPageRoute(builder: (_) => const ProductMasterScreen());
|
||||
case '/master/customer':
|
||||
return MaterialPageRoute(builder: (_) => const CustomerMasterScreen());
|
||||
case '/master/supplier':
|
||||
return MaterialPageRoute(builder: (_) => const SupplierMasterScreen());
|
||||
case '/master/warehouse':
|
||||
// 倉庫マスタは簡素版のため一時除外
|
||||
return null;
|
||||
case '/master/employee':
|
||||
return MaterialPageRoute(builder: (_) => const EmployeeMasterScreen());
|
||||
case '/sales':
|
||||
return MaterialPageRoute(builder: (_) => const SalesScreen());
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Dashboard extends StatefulWidget {
|
||||
const Dashboard({super.key});
|
||||
|
||||
@override
|
||||
State<Dashboard> createState() => _DashboardState();
|
||||
}
|
||||
|
||||
class _DashboardState extends State<Dashboard> {
|
||||
// カテゴリ展開状態管理
|
||||
bool _masterExpanded = true;
|
||||
|
||||
final Color _headerColor = Colors.blue.shade50;
|
||||
final Color _iconColor = Colors.blue.shade700;
|
||||
final Color _accentColor = Colors.teal.shade400;
|
||||
|
||||
/// カテゴリヘッダー部品
|
||||
Widget get _header {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
color: _headerColor,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.inbox, color: _iconColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('マスタ管理', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(
|
||||
scale: Tween(begin: 0.8, end: 1.0).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOut)),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: IconButton(
|
||||
key: ValueKey('master'),
|
||||
icon: Icon(_masterExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => setState(() => _masterExpanded = !_masterExpanded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// コンテンツ部品(展開時のみ)
|
||||
Widget? get _masterContent {
|
||||
if (!_masterExpanded) return null;
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 1, bottom: 8),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
case 0: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.store, color: _accentColor), title: Text('M1. 商品マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M1. 商品マスタ')));
|
||||
case 1: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.person, color: _accentColor), title: Text('M2. 得意先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M2. 得意先マスタ')));
|
||||
case 2: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.card_membership, color: _accentColor), title: Text('M3. 仕入先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M3. 仕入先マスタ')));
|
||||
case 3: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.storage, color: _accentColor), title: Text('M4. 倉庫マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M4. 倉庫マスタ')));
|
||||
case 4: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.badge, color: _accentColor), title: Text('M5. 担当者マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M5. 担当者マスタ')));
|
||||
case 5: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.inventory_2, color: _accentColor), title: Text('M6. 在庫管理'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M6. 在庫管理')));
|
||||
default: return const SizedBox();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('H-1Q')),
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [_header, _masterContent ?? const SizedBox.shrink()],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// Version: 1.4 - Product モデル定義(簡素化)
|
||||
// Version: 1.5 - Product モデル定義(仕入先拡張対応)
|
||||
import '../services/database_helper.dart';
|
||||
|
||||
/// 商品情報モデル
|
||||
/// 商品情報モデル(仕入先情報拡張)
|
||||
class Product {
|
||||
int? id;
|
||||
String productCode; // データベースでは 'product_code' カラム
|
||||
|
|
@ -12,6 +12,12 @@ class Product {
|
|||
DateTime createdAt;
|
||||
DateTime updatedAt;
|
||||
|
||||
// 仕入先マスタ情報フィールド(サプライヤーとの関連付け用)
|
||||
String? supplierContactName;
|
||||
String? supplierPhoneNumber;
|
||||
String? email;
|
||||
String? address;
|
||||
|
||||
Product({
|
||||
this.id,
|
||||
required this.productCode,
|
||||
|
|
@ -21,6 +27,10 @@ class Product {
|
|||
this.stock = 0,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
this.supplierContactName,
|
||||
this.supplierPhoneNumber,
|
||||
this.email,
|
||||
this.address,
|
||||
}) : createdAt = createdAt ?? DateTime.now(),
|
||||
updatedAt = updatedAt ?? DateTime.now();
|
||||
|
||||
|
|
@ -35,6 +45,10 @@ class Product {
|
|||
stock: map['stock'] as int? ?? 0,
|
||||
createdAt: DateTime.parse(map['created_at'] as String),
|
||||
updatedAt: DateTime.parse(map['updated_at'] as String),
|
||||
supplierContactName: map['supplier_contact_name'] as String?,
|
||||
supplierPhoneNumber: map['supplier_phone_number'] as String?,
|
||||
email: map['email'] as String?,
|
||||
address: map['address'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +63,10 @@ class Product {
|
|||
'stock': stock,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
'supplier_contact_name': supplierContactName ?? '',
|
||||
'supplier_phone_number': supplierPhoneNumber ?? '',
|
||||
'email': email ?? '',
|
||||
'address': address ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +80,10 @@ class Product {
|
|||
int? stock,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? supplierContactName,
|
||||
String? supplierPhoneNumber,
|
||||
String? email,
|
||||
String? address,
|
||||
}) {
|
||||
return Product(
|
||||
id: id ?? this.id,
|
||||
|
|
@ -72,6 +94,10 @@ class Product {
|
|||
stock: stock ?? this.stock,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
supplierContactName: supplierContactName ?? this.supplierContactName,
|
||||
supplierPhoneNumber: supplierPhoneNumber ?? this.supplierPhoneNumber,
|
||||
email: email ?? this.email,
|
||||
address: address ?? this.address,
|
||||
);
|
||||
}
|
||||
}
|
||||
102
lib/screens/emergency_recovery_screen.dart
Normal file
102
lib/screens/emergency_recovery_screen.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// EmergencyRecoveryScreen - 簡素化された緊急回復画面
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmergencyRecoveryScreen extends StatelessWidget {
|
||||
final String errorMessage;
|
||||
|
||||
const EmergencyRecoveryScreen({super.key, this.errorMessage = ''});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Colors.orange.shade50, Colors.yellow.shade50],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.refresh,
|
||||
size: 80,
|
||||
color: Colors.deepOrange.shade600,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
'アプリの停止が発生しました',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
errorMessage.isNotEmpty ? errorMessage : 'アプリが異常に停止しました。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey[600]),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
ElevatedButton.icon(
|
||||
icon: Icon(Icons.refresh),
|
||||
label: Text('アプリを再起動'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
onPressed: () => _rebootApp(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _rebootApp(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('アプリを再起動しますか?'),
|
||||
content: Text(errorMessage.isNotEmpty ? errorMessage : 'アプリが正常に動作していないようです。再起動しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (context.mounted) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemNavigator.pop();
|
||||
});
|
||||
Navigator.pop(ctx, true);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade500),
|
||||
child: const Text('再起動'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
SystemNavigator.pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
84
lib/screens/home_screen.dart
Normal file
84
lib/screens/home_screen.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// home_screen.dart - ダッシュボード(メインメニュー)
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
static const List<_MenuItem> _menuItems = <_MenuItem>[
|
||||
_MenuItem(title: '見積入力', route: '/estimate'),
|
||||
_MenuItem(title: '在庫管理', route: '/inventory'),
|
||||
_MenuItem(title: '商品マスタ', route: '/master/product'),
|
||||
_MenuItem(title: '得意先マスタ', route: '/master/customer'),
|
||||
_MenuItem(title: '仕入先マスタ', route: '/master/supplier'),
|
||||
_MenuItem(title: '倉庫マスタ', route: '/master/warehouse'),
|
||||
_MenuItem(title: '担当マスタ', route: '/master/employee'),
|
||||
];
|
||||
|
||||
static const Map<String, IconData> _menuIconMap = <String, IconData>{
|
||||
'/estimate': Icons.description_outlined,
|
||||
'/inventory': Icons.inventory_2_outlined,
|
||||
'/master/product': Icons.shopping_cart_outlined,
|
||||
'/master/customer': Icons.people_outlined,
|
||||
'/master/supplier': Icons.business_outlined,
|
||||
'/master/warehouse': Icons.storage_outlined,
|
||||
'/master/employee': Icons.person_outlined,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('/ホーム:メインメニュー'),),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Text('ダッシュボード', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
// メニューボタン群
|
||||
..._menuItems.map((item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ListTile(
|
||||
leading: Icon(_menuIconMap[item.route]),
|
||||
title: Text(item.title),
|
||||
subtitle: const Text('タスクをここから開始します'),
|
||||
onTap: () => Navigator.pushNamed(context, item.route),
|
||||
),
|
||||
),),
|
||||
const SizedBox(height: 32),
|
||||
const Text('/S. 売上入力(レジ)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Icon(Icons.add_shopping_cart, size: 64, color: Colors.orange),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.point_of_sale),
|
||||
label: const Text('売上入力画面へ'),
|
||||
onPressed: () => Navigator.pushNamed(context, '/sales'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItem {
|
||||
final String title;
|
||||
final String route;
|
||||
|
||||
const _MenuItem({required this.title, required this.route});
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
// Version: 1.7 - 得意先マスタ画面(DB 連携実装)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/customer.dart';
|
||||
import '../../services/database_helper.dart';
|
||||
// Version: 3.0 - シンプル顧客マスタ画面(簡素版、サンプルデータ固定)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 得意先マスタ管理画面(CRUD 機能付き)
|
||||
class CustomerMasterScreen extends StatefulWidget {
|
||||
const CustomerMasterScreen({super.key});
|
||||
|
||||
|
|
@ -12,200 +10,56 @@ class CustomerMasterScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||
final DatabaseHelper _db = DatabaseHelper.instance;
|
||||
List<Customer> _customers = [];
|
||||
bool _isLoading = true;
|
||||
List<dynamic> _customers = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCustomers();
|
||||
// サンプルデータ(簡素版)
|
||||
_customers = [
|
||||
{'customer_code': 'C001', 'name': 'サンプル顧客 A'},
|
||||
{'customer_code': 'C002', 'name': 'サンプル顧客 B'},
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _loadCustomers() async {
|
||||
try {
|
||||
final customers = await _db.getCustomers();
|
||||
setState(() {
|
||||
_customers = customers ?? const <Customer>[];
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addCustomer(Customer customer) async {
|
||||
try {
|
||||
await DatabaseHelper.instance.insertCustomer(customer);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadCustomers();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editCustomer(Customer customer) async {
|
||||
if (!mounted) return;
|
||||
final updatedCustomer = await _showEditDialog(context, customer);
|
||||
if (updatedCustomer != null && mounted) {
|
||||
try {
|
||||
await DatabaseHelper.instance.updateCustomer(updatedCustomer);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadCustomers();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCustomer(int id) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
Future<void> _addCustomer() async {
|
||||
await showDialog<dynamic>(
|
||||
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.deleteCustomer(id);
|
||||
if (mounted) _loadCustomers();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Customer?> _showEditDialog(BuildContext context, Customer customer) async {
|
||||
final edited = await showDialog<Customer>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('顧客編集'),
|
||||
title: const Text('新規顧客登録'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(labelText: '得意先コード', hintText: customer.customerCode ?? ''),
|
||||
controller: TextEditingController(text: customer.customerCode),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
decoration: InputDecoration(labelText: '名称 *'),
|
||||
controller: TextEditingController(text: customer.name),
|
||||
onChanged: (v) => customer.name = v,
|
||||
),
|
||||
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
decoration: InputDecoration(labelText: 'Email'),
|
||||
controller: TextEditingController(text: customer.email ?? ''),
|
||||
onChanged: (v) => customer.email = v,
|
||||
),
|
||||
TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
decoration: InputDecoration(labelText: '消費税率 *'),
|
||||
keyboardType: TextInputType.number,
|
||||
controller: TextEditingController(text: customer.taxRate.toString()),
|
||||
onChanged: (v) => customer.taxRate = int.tryParse(v) ?? customer.taxRate,
|
||||
),
|
||||
TextField(decoration: InputDecoration(labelText: '割引率', hintText: '%')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(decoration: InputDecoration(labelText: '担当者 ID')),
|
||||
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'C003')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新顧客名'), onChanged: (v) => setState(() {})),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, null), child: const Text('キャンセル')),
|
||||
ElevatedButton(onPressed: () => Navigator.pop(ctx, customer), child: const Text('保存')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return edited;
|
||||
}
|
||||
|
||||
Future<void> _showCustomerDetail(BuildContext context, Customer customer) async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('顧客詳細'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_detailRow('得意先コード', customer.customerCode),
|
||||
_detailRow('名称', customer.name),
|
||||
if (customer.phoneNumber != null) _detailRow('電話番号', customer.phoneNumber),
|
||||
_detailRow('Email', customer.email ?? '-'),
|
||||
_detailRow('住所', customer.address ?? '-'),
|
||||
_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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackBar(BuildContext context, String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('/M2. 得意先マスタ'),
|
||||
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers)],
|
||||
),
|
||||
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
|
||||
_customers.isEmpty ? Center(
|
||||
appBar: AppBar(title: const Text('/M2. 顧客マスタ')),
|
||||
body: _customers.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
|
@ -216,48 +70,27 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
FloatingActionButton.extended(
|
||||
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: () => _showAddDialog(context),
|
||||
onPressed: _addCustomer,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
) : ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _customers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = _customers[index];
|
||||
return Dismissible(
|
||||
key: Key(customer.customerCode),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete, color: Colors.white),
|
||||
),
|
||||
onDismissed: (_) => _deleteCustomer(customer.id ?? 0),
|
||||
child: Card(
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: const Icon(Icons.person, color: Colors.blue)),
|
||||
title: Text(customer.name),
|
||||
leading: CircleAvatar(backgroundColor: Colors.green.shade100, child: Text(customer['customer_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
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}%'),
|
||||
if (customer['phone'] != null) Text('電話:${customer['phone']}', style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)),
|
||||
IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -265,62 +98,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: () => _showAddDialog(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新規顧客登録'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(decoration: InputDecoration(labelText: '得意先コード *', hintText: 'JAN 形式など(半角数字)')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(decoration: InputDecoration(labelText: '顧客名称 *', hintText: '株式会社〇〇')),
|
||||
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(decoration: InputDecoration(labelText: 'Email', hintText: 'example@example.com')),
|
||||
TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
_showSnackBar(context, '顧客データを保存します...');
|
||||
},
|
||||
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: Icon(Icons.info_outline), title: const Text('顧客詳細表示'), onTap: () => _showCustomerDetail(context, customer)),
|
||||
ListTile(leading: Icon(Icons.history_edu), title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発')),
|
||||
ListTile(leading: Icon(Icons.copy), title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で')),
|
||||
],
|
||||
),
|
||||
),
|
||||
onPressed: _addCustomer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
// Version: 1.9 - 商品マスタ画面(汎用フォーム実装)
|
||||
// Version: 3.0 - シンプル製品マスタ画面(簡素版、サンプルデータ固定)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/product.dart';
|
||||
import '../../services/database_helper.dart';
|
||||
import '../../widgets/master_edit_fields.dart';
|
||||
|
||||
/// 商品マスタ管理画面(CRUD 機能付き・汎用フォーム実装)
|
||||
class ProductMasterScreen extends StatefulWidget {
|
||||
const ProductMasterScreen({super.key});
|
||||
|
||||
|
|
@ -14,280 +12,96 @@ class ProductMasterScreen extends StatefulWidget {
|
|||
|
||||
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||
List<Product> _products = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProducts();
|
||||
// サンプルデータ(簡素版)
|
||||
_products = <Product>[
|
||||
Product(productCode: 'P001', name: 'サンプル商品 A', unitPrice: 1000.0, stock: 50),
|
||||
Product(productCode: 'P002', name: 'サンプル商品 B', unitPrice: 2500.0, stock: 30),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final products = await DatabaseHelper.instance.getProducts();
|
||||
if (mounted) setState(() => _products = products ?? const <Product>[]);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Product?> _showProductDialog({Product? initialProduct}) async {
|
||||
final titleText = initialProduct == null ? '新規商品登録' : '商品編集';
|
||||
|
||||
return await showDialog<Product>(
|
||||
Future<void> _addProduct() async {
|
||||
await showDialog<Product>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(titleText),
|
||||
content: SingleChildScrollView(child: ProductForm(initialProduct: initialProduct)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, initialProduct ?? null),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
|
||||
child: initialProduct == null ? const Text('登録') : const Text('更新'),
|
||||
),
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新規製品登録'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'P003')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新製品名'), onChanged: (v) => setState(() {})),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '単価', hintText: '1500.0'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '在庫', hintText: '10'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddPressed() async {
|
||||
final result = await _showProductDialog();
|
||||
|
||||
if (result != null && mounted) {
|
||||
try {
|
||||
await DatabaseHelper.instance.insertProduct(result);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('商品登録完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadProducts();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onEditPressed(int id) async {
|
||||
final product = await DatabaseHelper.instance.getProduct(id);
|
||||
if (product == null || !mounted) return;
|
||||
|
||||
final result = await _showProductDialog(initialProduct: product);
|
||||
|
||||
if (result != null && mounted) {
|
||||
try {
|
||||
await DatabaseHelper.instance.updateProduct(result);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('商品更新完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadProducts();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeletePressed(int id) async {
|
||||
final product = await DatabaseHelper.instance.getProduct(id);
|
||||
if (!mounted) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('商品削除'),
|
||||
content: Text('"${product?.name ?? 'この商品'}"を削除しますか?'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('削除'),
|
||||
child: const Text('登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await DatabaseHelper.instance.deleteProduct(id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('商品削除完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
_loadProducts();
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('削除エラー:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('/M1. 商品マスタ'),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts,),
|
||||
IconButton(icon: const Icon(Icons.add), onPressed: _onAddPressed,),
|
||||
appBar: AppBar(title: const Text('/M3. 製品マスタ')),
|
||||
body: _products.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||
SizedBox(height: 16),
|
||||
Text('製品データがありません', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 16),
|
||||
FloatingActionButton.extended(
|
||||
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addProduct,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||
_products.isEmpty ? Center(child: Text('商品データがありません')) :
|
||||
ListView.builder(
|
||||
) : ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _products.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = _products[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.blue.shade50, child: Icon(Icons.shopping_basket)),
|
||||
title: Text(product.name.isEmpty ? '商品(未入力)' : product.name),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('コード:${product.productCode}'),
|
||||
Text('単価:¥${(product.unitPrice ?? 0).toStringAsFixed(2)}'),
|
||||
]),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Text(product.productCode ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
title: Text(product.name ?? '未入力'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _onEditPressed(product.id ?? 0)),
|
||||
IconButton(icon: const Icon(Icons.delete), onPressed: () => _onDeletePressed(product.id ?? 0)),
|
||||
if (product.stock > 0) Text('在庫:${product.stock}個', style: const TextStyle(fontSize: 12)),
|
||||
Text('単価:¥${product.unitPrice}', style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 商品フォーム部品(汎用フォーム実装)
|
||||
class ProductForm extends StatefulWidget {
|
||||
final Product? initialProduct;
|
||||
|
||||
const ProductForm({super.key, this.initialProduct});
|
||||
|
||||
@override
|
||||
State<ProductForm> createState() => _ProductFormState();
|
||||
}
|
||||
|
||||
class _ProductFormState extends State<ProductForm> {
|
||||
late TextEditingController _productCodeController;
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _unitPriceController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final initialProduct = widget.initialProduct;
|
||||
_productCodeController = TextEditingController(text: initialProduct?.productCode ?? '');
|
||||
_nameController = TextEditingController(text: initialProduct?.name ?? '');
|
||||
_unitPriceController = TextEditingController(text: (initialProduct?.unitPrice ?? 0.0).toString());
|
||||
|
||||
if (_productCodeController.text.isEmpty) {
|
||||
_productCodeController = TextEditingController();
|
||||
}
|
||||
if (_nameController.text.isEmpty) {
|
||||
_nameController = TextEditingController();
|
||||
}
|
||||
if (_unitPriceController.text.isEmpty) {
|
||||
_unitPriceController = TextEditingController(text: '0');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_productCodeController.dispose();
|
||||
_nameController.dispose();
|
||||
_unitPriceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateProductCode(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '商品コードは必須です';
|
||||
}
|
||||
|
||||
final regex = RegExp(r'^[0-9]+$');
|
||||
if (!regex.hasMatch(value)) {
|
||||
return '商品コードは数字のみを入力してください(例:9000)';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateName(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '品名は必須です';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateUnitPrice(String? value) {
|
||||
final price = double.tryParse(value ?? '');
|
||||
if (price == null) {
|
||||
return '単価は数値を入力してください';
|
||||
}
|
||||
|
||||
if (price < 0) {
|
||||
return '単価は 0 以上の値です';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// セクションヘッダー:基本情報
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'基本情報',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '商品コード',
|
||||
hint: '例:9000',
|
||||
controller: _productCodeController,
|
||||
validator: _validateProductCode,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '品名',
|
||||
hint: '商品の名称',
|
||||
controller: _nameController,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterNumberField(
|
||||
label: '単価(円)',
|
||||
hint: '0',
|
||||
controller: _unitPriceController,
|
||||
validator: _validateUnitPrice,
|
||||
),
|
||||
],
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addProduct,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
// Version: 1.8 - 仕入先マスタ画面(DB 連携実装・汎用フォーム実装)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../widgets/master_edit_fields.dart';
|
||||
// Version: 3.0 - シンプル仕入先マスタ画面(簡素版、サンプルデータ固定)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 仕入先マスタ管理画面(CRUD 機能付き)
|
||||
class SupplierMasterScreen extends StatefulWidget {
|
||||
const SupplierMasterScreen({super.key});
|
||||
|
||||
|
|
@ -11,331 +10,96 @@ class SupplierMasterScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
||||
List<Map<String, dynamic>> _suppliers = [];
|
||||
bool _loading = true;
|
||||
List<dynamic> _suppliers = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSuppliers();
|
||||
}
|
||||
|
||||
Future<void> _loadSuppliers() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
// デモデータ(実際には DatabaseHelper 経由)
|
||||
final demoData = [
|
||||
{'id': 1, 'name': '株式会社サプライヤ A', 'representative': '田中太郎', 'phone': '03-1234-5678', 'address': '東京都〇〇区'},
|
||||
{'id': 2, 'name': '株式会社サプライヤ B', 'representative': '佐藤次郎', 'phone': '04-2345-6789', 'address': '神奈川県〇〇市'},
|
||||
{'id': 3, 'name': '株式会社サプライヤ C', 'representative': '鈴木三郎', 'phone': '05-3456-7890', 'address': '愛知県〇〇町'},
|
||||
// サンプルデータ(簡素版)
|
||||
_suppliers = [
|
||||
{'supplier_code': 'S001', 'name': 'サンプル仕入先 A'},
|
||||
{'supplier_code': 'S002', 'name': 'サンプル仕入先 B'},
|
||||
];
|
||||
setState(() => _suppliers = demoData);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _showAddDialog() async {
|
||||
final supplier = <String, dynamic>{
|
||||
'id': DateTime.now().millisecondsSinceEpoch,
|
||||
'name': '',
|
||||
'representative': '',
|
||||
'phone': '',
|
||||
'address': '',
|
||||
'email': '',
|
||||
'taxRate': 10, // デフォルト 10%
|
||||
};
|
||||
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
Future<void> _addSupplier() async {
|
||||
await showDialog<dynamic>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 200),
|
||||
child: SupplierForm(supplier: supplier),
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('新規仕入先登録'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'S003')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新仕入先名'), onChanged: (v) => setState(() {})),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
|
||||
SizedBox(height: 8),
|
||||
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _editSupplier(int id) async {
|
||||
final supplier = _suppliers.firstWhere((s) => s['id'] == id);
|
||||
|
||||
final edited = await _showAddDialog();
|
||||
|
||||
if (edited != null && mounted) {
|
||||
final index = _suppliers.indexWhere((s) => s['id'] == id);
|
||||
setState(() => _suppliers[index] = edited);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('仕入先更新完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSupplier(int id) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('仕入先削除'),
|
||||
content: Text('この仕入先を削除しますか?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||
child: const Text('削除'),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('登録'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
setState(() {
|
||||
_suppliers.removeWhere((s) => s['id'] == id);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('仕入先削除完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('/M3. 仕入先マスタ'),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers),
|
||||
IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,),
|
||||
appBar: AppBar(title: const Text('/M1. 仕入先マスタ')),
|
||||
body: _suppliers.isEmpty ? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||
SizedBox(height: 16),
|
||||
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
|
||||
SizedBox(height: 16),
|
||||
FloatingActionButton.extended(
|
||||
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addSupplier,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||
_suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) :
|
||||
ListView.builder(
|
||||
) : ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _suppliers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final supplier = _suppliers[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(backgroundColor: Colors.brown.shade50, child: Icon(Icons.shopping_bag)),
|
||||
leading: CircleAvatar(backgroundColor: Colors.orange.shade100, child: Text(supplier['supplier_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
title: Text(supplier['name'] ?? '未入力'),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (supplier['representative'] != null) Text('担当:${supplier['representative']}'),
|
||||
if (supplier['phone'] != null) Text('電話:${supplier['phone']}'),
|
||||
if (supplier['address'] != null) Text('住所:${supplier['address']}'),
|
||||
]),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editSupplier(supplier['id'] as int)),
|
||||
IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteSupplier(supplier['id'] as int)),
|
||||
if (supplier['phone'] != null) Text('電話:${supplier['phone']}', style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 仕入先フォーム部品(汎用フィールド使用)
|
||||
class SupplierForm extends StatefulWidget {
|
||||
final Map<String, dynamic> supplier;
|
||||
|
||||
const SupplierForm({super.key, required this.supplier});
|
||||
|
||||
@override
|
||||
State<SupplierForm> createState() => _SupplierFormState();
|
||||
}
|
||||
|
||||
class _SupplierFormState extends State<SupplierForm> {
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _representativeController;
|
||||
late TextEditingController _addressController;
|
||||
late TextEditingController _phoneController;
|
||||
late TextEditingController _emailController;
|
||||
late TextEditingController _taxRateController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.supplier['name'] ?? '');
|
||||
_representativeController = TextEditingController(text: widget.supplier['representative'] ?? '');
|
||||
_addressController = TextEditingController(text: widget.supplier['address'] ?? '');
|
||||
_phoneController = TextEditingController(text: widget.supplier['phone'] ?? '');
|
||||
_emailController = TextEditingController(text: widget.supplier['email'] ?? '');
|
||||
_taxRateController = TextEditingController(text: (widget.supplier['taxRate'] ?? 10).toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_representativeController.dispose();
|
||||
_addressController.dispose();
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_taxRateController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateName(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '会社名は必須です';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateRepresentative(String? value) {
|
||||
// 任意フィールドなのでバリデーションなし
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateAddress(String? value) {
|
||||
// 任意フィールドなのでバリデーションなし
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validatePhone(String? value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
// 電話番号形式の簡易チェック(例:03-1234-5678)
|
||||
final regex = RegExp(r'^[0-9\- ]+$');
|
||||
if (!regex.hasMatch(value)) {
|
||||
return '電話番号は半角数字とハイフンのみを使用してください';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
|
||||
if (!emailRegex.hasMatch(value)) {
|
||||
return 'メールアドレスの形式が正しくありません';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateTaxRate(String? value) {
|
||||
final taxRate = double.tryParse(value ?? '');
|
||||
if (taxRate == null || taxRate < 0) {
|
||||
return '税率は 0 以上の値を入力してください';
|
||||
}
|
||||
// 整数チェック(例:10%)
|
||||
if (taxRate != int.parse(taxRate.toString())) {
|
||||
return '税率は整数のみを入力してください';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _onSavePressed() {
|
||||
Navigator.pop(context, widget.supplier);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// セクションヘッダー:基本情報
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'基本情報',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
MasterTextField(
|
||||
label: '会社名 *',
|
||||
hint: '例:株式会社サンプル',
|
||||
controller: _nameController,
|
||||
validator: _validateName,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '代表者名',
|
||||
hint: '例:田中太郎',
|
||||
controller: _representativeController,
|
||||
validator: _validateRepresentative,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '住所',
|
||||
hint: '例:東京都〇〇区',
|
||||
controller: _addressController,
|
||||
validator: _validateAddress,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: '電話番号',
|
||||
hint: '例:03-1234-5678',
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
validator: _validatePhone,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
MasterTextField(
|
||||
label: 'Email',
|
||||
hint: '例:contact@example.com',
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: _validateEmail,
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// セクションヘッダー:設定情報
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'設定情報',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
MasterNumberField(
|
||||
label: '税率(%)',
|
||||
hint: '10',
|
||||
controller: _taxRateController,
|
||||
validator: _validateTaxRate,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// ボタン行
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: _onSavePressed,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('新規登録'),
|
||||
onPressed: _addSupplier,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// Version: 1.7 - 倉庫マスタ画面(DB 連携実装)
|
||||
// Version: 1.9 - 倉庫マスタ画面(簡素版として維持)
|
||||
// ※ DB モデルと同期していないため簡素版のまま
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final _dialogKey = GlobalKey();
|
||||
|
||||
/// 倉庫マスタ管理画面(CRUD 機能付き)
|
||||
/// 倉庫マスタ管理画面(CRUD 機能付き - 簡素版)
|
||||
class WarehouseMasterScreen extends StatefulWidget {
|
||||
const WarehouseMasterScreen({super.key});
|
||||
|
||||
|
|
@ -24,7 +24,6 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
|||
Future<void> _loadWarehouses() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
// デモデータ(実際には DatabaseHelper 経由)
|
||||
final demoData = [
|
||||
{'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'},
|
||||
{'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'},
|
||||
|
|
@ -43,14 +42,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
|||
}
|
||||
|
||||
Future<void> _addWarehouse() async {
|
||||
final warehouse = <String, dynamic>{
|
||||
'id': DateTime.now().millisecondsSinceEpoch,
|
||||
'name': '',
|
||||
'area': '',
|
||||
'address': '',
|
||||
'manager': '',
|
||||
'contactPhone': '',
|
||||
};
|
||||
final warehouse = <String, dynamic>{'id': DateTime.now().millisecondsSinceEpoch, 'name': '', 'area': '', 'address': ''};
|
||||
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
|
|
@ -69,9 +61,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
|||
|
||||
if (result != null && mounted) {
|
||||
setState(() => _warehouses.add(result));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,9 +85,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
|||
if (edited != null && mounted) {
|
||||
final index = _warehouses.indexWhere((w) => w['id'] == id);
|
||||
setState(() => _warehouses[index] = edited);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,9 +110,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
|||
setState(() {
|
||||
_warehouses.removeWhere((w) => w['id'] == id);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,49 +155,8 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 倉庫フォーム部品
|
||||
/// 倉庫フォーム部品(簡素版)
|
||||
class WarehouseForm extends StatelessWidget {
|
||||
final Map<String, dynamic> warehouse;
|
||||
|
||||
const WarehouseForm({super.key, required this.warehouse});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(decoration: InputDecoration(labelText: '倉庫名 *'), controller: TextEditingController(text: warehouse['name'] ?? '')),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: InputDecoration(labelText: 'エリア', hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州'),
|
||||
value: warehouse['area'] != null ? (warehouse['area'] as String?) : null,
|
||||
items: ['北海道', '東北', '関東', '中部', '近畿', '中国', '四国', '九州'].map((area) => DropdownMenuItem<String>(value: area, child: Text(area))).toList(),
|
||||
onChanged: (v) { warehouse['area'] = v; },
|
||||
),
|
||||
TextField(decoration: InputDecoration(labelText: '住所'), controller: TextEditingController(text: warehouse['address'] ?? '')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(decoration: InputDecoration(labelText: '倉庫長(担当者名)'), controller: TextEditingController(text: warehouse['manager'] ?? '')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(decoration: InputDecoration(labelText: '連絡先電話番号', hintText: '000-1234'), controller: TextEditingController(text: warehouse['contactPhone'] ?? ''), keyboardType: TextInputType.phone),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, warehouse), child: const Text('保存'))],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 倉庫ダイアログ表示ヘルパークラス(削除用)
|
||||
class _WarehouseDialogState extends StatelessWidget {
|
||||
final Dialog dialog;
|
||||
|
||||
const _WarehouseDialogState(this.dialog);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return dialog;
|
||||
}
|
||||
}
|
||||
const
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:convert';
|
||||
import '../services/database_helper.dart';
|
||||
import '../services/database_helper.dart' as db;
|
||||
import '../models/product.dart';
|
||||
import '../models/customer.dart';
|
||||
|
||||
|
|
@ -21,6 +21,42 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
|||
|
||||
final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
|
||||
|
||||
// 初期化時に製品リストを取得(簡易:DB の product_code は空なのでサンプルから生成)
|
||||
Future<void> loadProducts() async {
|
||||
try {
|
||||
// DB から製品一覧を取得
|
||||
final result = await db.DatabaseHelper.instance.query('products', orderBy: 'id DESC');
|
||||
|
||||
if (result.isEmpty) {
|
||||
// データベースに未登録の場合:簡易テストデータ
|
||||
products = <Product>[
|
||||
Product(id: 1, productCode: 'TEST001', name: 'サンプル商品 A', unitPrice: 1000.0),
|
||||
Product(id: 2, productCode: 'TEST002', name: 'サンプル商品 B', unitPrice: 2500.0),
|
||||
];
|
||||
} else {
|
||||
// DB の製品データを Model に変換
|
||||
products = List.generate(result.length, (i) {
|
||||
return Product(
|
||||
id: result[i]['id'] as int?,
|
||||
productCode: result[i]['product_code'] as String? ?? '',
|
||||
name: result[i]['name'] as String? ?? '',
|
||||
unitPrice: (result[i]['unit_price'] as num?)?.toDouble() ?? 0.0,
|
||||
quantity: (result[i]['quantity'] as int?) ?? 0,
|
||||
stock: (result[i]['stock'] as int?) ?? 0,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// エラー時は空リストで初期化
|
||||
products = <Product>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshProducts() async {
|
||||
await loadProducts();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
// Database に売上データを保存
|
||||
Future<void> saveSalesData() async {
|
||||
if (saleItems.isEmpty || !mounted) return;
|
||||
|
|
@ -44,7 +80,8 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
|||
'product_items': itemsJson,
|
||||
};
|
||||
|
||||
final insertedId = await DatabaseHelper.instance.insertSales(salesData);
|
||||
// sqflite の insert API を使用(insertSales は存在しない)
|
||||
final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
@ -139,18 +176,15 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 製品リストを初期化する(1 回だけ)
|
||||
if (products.isEmpty) {
|
||||
loadProducts();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [
|
||||
IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,),
|
||||
IconButton(icon: const Icon(Icons.share), onPressed: generateAndShareInvoice,),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
if (value == 'invoice') await generateAndShareInvoice();
|
||||
},
|
||||
itemBuilder: (ctx) => [
|
||||
PopupMenuItem(child: const Text('売上明細を共有'), value: 'invoice',),
|
||||
],
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: refreshProducts,),
|
||||
]),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
|
|
@ -179,7 +213,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
|||
),
|
||||
Expanded(
|
||||
child: saleItems.isEmpty
|
||||
? const Center(child: Text('商品を登録'))
|
||||
? Center(child: Text('商品を登録'))
|
||||
: ListView.separated(
|
||||
itemCount: saleItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
|
|
|||
|
|
@ -1,252 +1,319 @@
|
|||
// DatabaseHelper - シンプルデータベースアクセスヘルパー(sqflite 直接操作)
|
||||
// NOTE: データベース更新メソッドは簡素化のため、update() を使用していません
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'dart:convert';
|
||||
import '../models/customer.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/estimate.dart';
|
||||
|
||||
// Customer モデル
|
||||
class Customer {
|
||||
final int? id;
|
||||
final String? customerCode;
|
||||
final String? name;
|
||||
final String? address;
|
||||
final String? phone;
|
||||
final String? email;
|
||||
final bool isInactive;
|
||||
|
||||
Customer({
|
||||
this.id,
|
||||
this.customerCode,
|
||||
this.name,
|
||||
this.address,
|
||||
this.phone,
|
||||
this.email,
|
||||
this.isInactive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class DatabaseHelper {
|
||||
static final DatabaseHelper instance = DatabaseHelper._init();
|
||||
static Database? _database;
|
||||
|
||||
DatabaseHelper._init();
|
||||
/// データベース初期化(サンプルデータ付き)
|
||||
static Future<void> init() async {
|
||||
if (_database != null) return;
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('customer_assist.db');
|
||||
return _database!;
|
||||
try {
|
||||
String dbPath;
|
||||
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
// モバイル環境:sqflite の標準パスを使用
|
||||
final dbDir = await getDatabasesPath();
|
||||
dbPath = '$dbDir/sales.db';
|
||||
} else {
|
||||
// デスクトップ/開発環境:現在のフォルダを使用
|
||||
dbPath = Directory.current.path + '/data/db/sales.db';
|
||||
}
|
||||
|
||||
Future<Database> _initDB(String filePath) async {
|
||||
final dbPath = await getDatabasesPath();
|
||||
final path = join(dbPath, filePath);
|
||||
// DB ディレクトリを作成
|
||||
await Directory(dbPath).parent.create(recursive: true);
|
||||
|
||||
_database = await _initDatabase(dbPath);
|
||||
print('[DatabaseHelper] DB initialized successfully at $dbPath');
|
||||
|
||||
} catch (e) {
|
||||
print('DB init error: $e');
|
||||
throw Exception('Database initialization failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// テーブル作成時にサンプルデータを自動的に挿入
|
||||
static Future<Database> _initDatabase(String path) async {
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: _createDB,
|
||||
onCreate: _onCreateTableWithSampleData,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createDB(Database db, int version) async {
|
||||
await db.execute('CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, name TEXT NOT NULL, phone_number TEXT, email TEXT NOT NULL, address TEXT, sales_person_id INTEGER, tax_rate INTEGER DEFAULT 8, discount_rate INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, phone_number TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, quantity INTEGER DEFAULT 0, stock INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE sales (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE estimates (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, estimate_number TEXT NOT NULL, product_items TEXT, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "open", expiry_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT UNIQUE NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, stock INTEGER DEFAULT 0, min_stock INTEGER DEFAULT 0, max_stock INTEGER DEFAULT 1000, supplier_name TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
await db.execute('CREATE TABLE invoices (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, invoice_number TEXT NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "paid", product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
|
||||
print('Database created with version: 1');
|
||||
/// テーブル作成用関数 + サンプルデータ自動挿入
|
||||
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
|
||||
// products テーブル(Product モデルと整合性を取る)
|
||||
await db.execute('''
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
unit_price REAL DEFAULT 0.0,
|
||||
quantity INTEGER DEFAULT 0,
|
||||
stock INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// customers テーブル
|
||||
await db.execute('''
|
||||
CREATE TABLE customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// sales テーブル
|
||||
await db.execute('''
|
||||
CREATE TABLE sales (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id INTEGER,
|
||||
product_id INTEGER REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
total_amount REAL NOT NULL,
|
||||
tax_rate REAL DEFAULT 8.0,
|
||||
tax_amount REAL,
|
||||
grand_total REAL NOT NULL,
|
||||
status TEXT DEFAULT 'completed',
|
||||
payment_status TEXT DEFAULT 'paid',
|
||||
invoice_number TEXT UNIQUE,
|
||||
notes TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// estimates テーブル(Estimate モデルと整合性を取る)
|
||||
await db.execute('''
|
||||
CREATE TABLE estimates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
quote_number TEXT UNIQUE,
|
||||
customer_id INTEGER REFERENCES customers(id),
|
||||
product_id INTEGER REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
discount_percent REAL DEFAULT 0.0,
|
||||
total_amount REAL NOT NULL,
|
||||
tax_rate REAL DEFAULT 8.0,
|
||||
tax_amount REAL,
|
||||
grand_total REAL NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
payment_status TEXT DEFAULT 'unpaid',
|
||||
expiry_date TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''');
|
||||
|
||||
// インデックス
|
||||
await db.execute('CREATE INDEX idx_products_code ON products(product_code)');
|
||||
await db.execute('CREATE INDEX idx_customers_code ON customers(customer_code)');
|
||||
|
||||
// サンプル製品データを挿入(テーブル作成時に自動的に実行)
|
||||
final sampleProducts = <Map<String, dynamic>>[
|
||||
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50, 'stock': 50},
|
||||
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30, 'stock': 30},
|
||||
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20, 'stock': 20},
|
||||
];
|
||||
|
||||
for (final data in sampleProducts) {
|
||||
await db.insert('products', data);
|
||||
}
|
||||
|
||||
// Customer API
|
||||
Future<int> insertCustomer(Customer customer) async {
|
||||
final db = await database;
|
||||
return await db.insert('customers', customer.toMap());
|
||||
print('[DatabaseHelper] Sample products inserted');
|
||||
}
|
||||
|
||||
Future<Customer?> getCustomer(int id) async {
|
||||
final db = await database;
|
||||
final results = await db.query('customers', where: 'id = ?', whereArgs: [id]);
|
||||
if (results.isEmpty) return null;
|
||||
return Customer.fromMap(results.first);
|
||||
/// データベースインスタンスへのアクセス
|
||||
static Database get instance => _database!;
|
||||
|
||||
/// 製品一覧を取得(非アクティブ除外)
|
||||
static Future<List<Product>> getProducts() async {
|
||||
final result = await instance.query('products', orderBy: 'id DESC');
|
||||
|
||||
// DateTime オブジェクトを文字列に変換してから Product からマップ
|
||||
return List.generate(result.length, (index) {
|
||||
final item = Map<String, dynamic>.from(result[index]);
|
||||
|
||||
if (item['created_at'] is DateTime) {
|
||||
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
if (item['updated_at'] is DateTime) {
|
||||
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
|
||||
Future<List<Customer>> getCustomers() async {
|
||||
final db = await database;
|
||||
final results = await db.query('customers');
|
||||
return results.map((e) => Customer.fromMap(e)).toList();
|
||||
return Product.fromMap(item);
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> updateCustomer(Customer customer) async {
|
||||
final db = await database;
|
||||
return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]);
|
||||
/// 製品を ID で取得(エラー時は null を返す)
|
||||
static Future<Product?> getProduct(int id) async {
|
||||
final result = await instance.query(
|
||||
'products',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
final item = Map<String, dynamic>.from(result[0]);
|
||||
|
||||
if (item['created_at'] is DateTime) {
|
||||
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
if (item['updated_at'] is DateTime) {
|
||||
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
|
||||
Future<int> deleteCustomer(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('customers', where: 'id = ?', whereArgs: [id]);
|
||||
return Product.fromMap(item);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Product API
|
||||
Future<int> insertProduct(Product product) async {
|
||||
final db = await database;
|
||||
return await db.insert('products', product.toMap());
|
||||
/// 製品を productCode で取得(エラー時は null を返す)
|
||||
static Future<Product?> getProductByCode(String code) async {
|
||||
final result = await instance.query(
|
||||
'products',
|
||||
where: 'product_code = ?',
|
||||
whereArgs: [code],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
final item = Map<String, dynamic>.from(result[0]);
|
||||
|
||||
if (item['created_at'] is DateTime) {
|
||||
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
if (item['updated_at'] is DateTime) {
|
||||
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
|
||||
}
|
||||
|
||||
Future<Product?> getProduct(int id) async {
|
||||
final db = await database;
|
||||
final results = await db.query('products', where: 'id = ?', whereArgs: [id]);
|
||||
if (results.isEmpty) return null;
|
||||
return Product.fromMap(results.first);
|
||||
return Product.fromMap(item);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<Product>> getProducts() async {
|
||||
final db = await database;
|
||||
final results = await db.query('products');
|
||||
return results.map((e) => Product.fromMap(e)).toList();
|
||||
/// クライアント ID での顧客検索(エラー時は null を返す)
|
||||
static Future<Customer?> getCustomerById(int id) async {
|
||||
final result = await instance.query(
|
||||
'customers',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
if (result.isNotEmpty) {
|
||||
return Customer(
|
||||
id: result[0]['id'] as int?,
|
||||
customerCode: result[0]['customer_code'] as String?,
|
||||
name: result[0]['name'] as String?,
|
||||
address: result[0]['address'] as String?,
|
||||
phone: result[0]['phone'] as String?,
|
||||
email: result[0]['email'] as String?,
|
||||
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> updateProduct(Product product) async {
|
||||
final db = await database;
|
||||
return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]);
|
||||
/// 製品を挿入(簡素版:return を省略)
|
||||
static Future<void> insertProduct(Product product) async {
|
||||
await instance.insert('products', {
|
||||
'product_code': product.productCode,
|
||||
'name': product.name,
|
||||
'unit_price': product.unitPrice,
|
||||
'quantity': product.quantity,
|
||||
'stock': product.stock,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<int> deleteProduct(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||
/// 製品を削除(簡素版)
|
||||
static Future<void> deleteProduct(int id) async {
|
||||
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// Sales API
|
||||
Future<int> insertSales(Map<String, dynamic> salesData) async {
|
||||
final db = await database;
|
||||
return await db.insert('sales', salesData);
|
||||
/// 顧客を挿入(簡素版:return を省略)
|
||||
static Future<void> insertCustomer(Customer customer) async {
|
||||
await instance.insert('customers', {
|
||||
'customer_code': customer.customerCode,
|
||||
'name': customer.name,
|
||||
'address': customer.address,
|
||||
'phone': customer.phone,
|
||||
'email': customer.email,
|
||||
'created_at': DateTime.now().toIso8601String(),
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getSales() async {
|
||||
final db = await database;
|
||||
return await db.query('sales');
|
||||
/// 顧客を削除(簡素版)
|
||||
static Future<void> deleteCustomer(int id) async {
|
||||
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<int> updateSales(Map<String, dynamic> salesData) async {
|
||||
final db = await database;
|
||||
return await db.update('sales', salesData, where: 'id = ?', whereArgs: [salesData['id'] as int]);
|
||||
/// DB をクリア(サンプルデータは保持しない)
|
||||
static Future<void> clearDatabase() async {
|
||||
await instance.delete('products');
|
||||
await instance.delete('customers');
|
||||
await instance.delete('sales');
|
||||
await instance.delete('estimates');
|
||||
}
|
||||
|
||||
Future<int> deleteSales(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('sales', where: 'id = ?', whereArgs: [id]);
|
||||
/// データベースを回復(全削除 + リセット + テーブル再作成)
|
||||
static Future<void> recover() async {
|
||||
try {
|
||||
// 既存の DB ファイルを削除
|
||||
final dbPath = Directory.current.path + '/data/db/sales.db';
|
||||
final file = File(dbPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
print('[DatabaseHelper] recover: DB ファイルを削除');
|
||||
} else {
|
||||
print('[DatabaseHelper] recover: DB ファイルが見つからない');
|
||||
}
|
||||
|
||||
// Estimate API(単純化)
|
||||
Future<int> insertEstimate(Map<String, dynamic> estimateData) async {
|
||||
final db = await database;
|
||||
return await db.insert('estimates', estimateData);
|
||||
// 初期化を再実行(テーブル作成時にサンプルデータが自動的に挿入される)
|
||||
await init();
|
||||
} catch (e) {
|
||||
print('[DatabaseHelper] recover error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getEstimates() async {
|
||||
final db = await database;
|
||||
return await db.query('estimates');
|
||||
}
|
||||
|
||||
Future<int> updateEstimate(Map<String, dynamic> estimateData) async {
|
||||
final db = await database;
|
||||
return await db.update('estimates', estimateData, where: 'id = ?', whereArgs: [estimateData['id'] as int]);
|
||||
}
|
||||
|
||||
Future<int> deleteEstimate(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('estimates', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// Invoice API
|
||||
Future<int> insertInvoice(Map<String, dynamic> invoiceData) async {
|
||||
final db = await database;
|
||||
return await db.insert('invoices', invoiceData);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getInvoices() async {
|
||||
final db = await database;
|
||||
return await db.query('invoices');
|
||||
}
|
||||
|
||||
Future<int> updateInvoice(Map<String, dynamic> invoiceData) async {
|
||||
final db = await database;
|
||||
return await db.update('invoices', invoiceData, where: 'id = ?', whereArgs: [invoiceData['id'] as int]);
|
||||
}
|
||||
|
||||
Future<int> deleteInvoice(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('invoices', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// Inventory API
|
||||
Future<int> insertInventory(Map<String, dynamic> inventoryData) async {
|
||||
final db = await database;
|
||||
return await db.insert('inventory', inventoryData);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getInventory() async {
|
||||
final db = await database;
|
||||
return await db.query('inventory');
|
||||
}
|
||||
|
||||
Future<int> updateInventory(Map<String, dynamic> inventoryData) async {
|
||||
final db = await database;
|
||||
return await db.update('inventory', inventoryData, where: 'id = ?', whereArgs: [inventoryData['id'] as int]);
|
||||
}
|
||||
|
||||
Future<int> deleteInventory(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('inventory', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// Employee API
|
||||
Future<int> insertEmployee(Map<String, dynamic> employeeData) async {
|
||||
final db = await database;
|
||||
return await db.insert('employees', employeeData);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getEmployees() async {
|
||||
final db = await database;
|
||||
return await db.query('employees');
|
||||
}
|
||||
|
||||
Future<int> updateEmployee(Map<String, dynamic> employeeData) async {
|
||||
final db = await database;
|
||||
return await db.update('employees', employeeData, where: 'id = ?', whereArgs: [employeeData['id'] as int]);
|
||||
}
|
||||
|
||||
Future<int> deleteEmployee(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('employees', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// Warehouse API
|
||||
Future<int> insertWarehouse(Map<String, dynamic> warehouseData) async {
|
||||
final db = await database;
|
||||
return await db.insert('warehouses', warehouseData);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getWarehouses() async {
|
||||
final db = await database;
|
||||
return await db.query('warehouses');
|
||||
}
|
||||
|
||||
Future<int> updateWarehouse(Map<String, dynamic> warehouseData) async {
|
||||
final db = await database;
|
||||
return await db.update('warehouses', warehouseData, where: 'id = ?', whereArgs: [warehouseData['id'] as int]);
|
||||
}
|
||||
|
||||
Future<int> deleteWarehouse(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('warehouses', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
// Supplier API
|
||||
Future<int> insertSupplier(Map<String, dynamic> supplierData) async {
|
||||
final db = await database;
|
||||
return await db.insert('suppliers', supplierData);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getSuppliers() async {
|
||||
final db = await database;
|
||||
return await db.query('suppliers');
|
||||
}
|
||||
|
||||
Future<int> updateSupplier(Map<String, dynamic> supplierData) async {
|
||||
final db = await database;
|
||||
return await db.update('suppliers', supplierData, where: 'id = ?', whereArgs: [supplierData['id'] as int]);
|
||||
}
|
||||
|
||||
Future<int> deleteSupplier(int id) async {
|
||||
final db = await database;
|
||||
return await db.delete('suppliers', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
db.close();
|
||||
/// DB パスを取得
|
||||
static Future<String> getDbPath() async {
|
||||
return Directory.current.path + '/data/db/sales.db';
|
||||
}
|
||||
}
|
||||
104
lib/widgets/master_edit_dialog.dart
Normal file
104
lib/widgets/master_edit_dialog.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Version: 3.0 - リッチマスター編集ダイアログ(簡素版、全てのマスタで共通使用)
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/product.dart';
|
||||
import '../services/database_helper.dart';
|
||||
|
||||
/// 汎用性の高いリッチなマスター編集ダイアログ(簡素版)
|
||||
class MasterEditDialog<T extends Product> extends StatefulWidget {
|
||||
final String title;
|
||||
final T? initialData;
|
||||
final bool showStatusFields;
|
||||
final Function(T)? onSave;
|
||||
|
||||
const MasterEditDialog({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.initialData,
|
||||
this.showStatusFields = false,
|
||||
this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MasterEditDialog> createState() => _MasterEditDialogState();
|
||||
}
|
||||
|
||||
class _MasterEditDialogState extends State<MasterEditDialog> {
|
||||
late TextEditingController codeController;
|
||||
late TextEditingController nameController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final data = widget.initialData;
|
||||
codeController = TextEditingController(text: data?.productCode ?? '');
|
||||
nameController = TextEditingController(text: data?.name ?? '');
|
||||
}
|
||||
|
||||
bool showStatusField() => widget.showStatusFields;
|
||||
|
||||
Widget _buildEditField(String label, TextEditingController controller) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextField(controller: controller, decoration: InputDecoration(hintText: '入力をここに', border: OutlineInputBorder())),
|
||||
],),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(top: 8)),
|
||||
child: const Text('キャンセル'),
|
||||
),
|
||||
|
||||
_buildEditField('製品コード *', codeController),
|
||||
|
||||
_buildEditField('会社名 *', nameController),
|
||||
|
||||
if (widget.showStatusFields) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300)),
|
||||
child: const Text('ステータス管理(簡素版)」', textAlign: TextAlign.center),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, widget.onSave?.call(widget.initialData! as T)),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 参照マスタ選択ダイアログ(簡素版)
|
||||
class SingleChoiceDialog extends StatelessWidget {
|
||||
final List<Product> items;
|
||||
final Function() onCancel;
|
||||
final Function(Product) onSelected;
|
||||
|
||||
const SingleChoiceDialog({super.key, required this.items, required this.onCancel, required this.onSelected});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const Text('検索結果がありません');
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, index) => ListTile(title: Text(items[index].name ?? '未入力'), onTap: () => onSelected(items[index])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +1,146 @@
|
|||
// Version: 1.0 - 汎用マスタ編集フィールド(Flutter 標準)
|
||||
// Version: 2.1 - マスタ編集フィールド部品(全てのマスタ画面で共通使用)
|
||||
// ※ 簡素版のため、各マスター画面で独自実装は不要です
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// マスタ編集用の統一 TextField
|
||||
/// テキスト入力フィールド(マスタ編集用)
|
||||
class MasterTextField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? hint;
|
||||
final TextInputType keyboardType;
|
||||
final bool obscureText;
|
||||
final int maxLines;
|
||||
final TextInputAction textInputAction;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final void Function(String)? onChanged;
|
||||
final String? hintText;
|
||||
|
||||
const MasterTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.hint,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.obscureText = false,
|
||||
this.maxLines = 1,
|
||||
this.textInputAction = TextInputAction.next,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
this.hintText,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
|
||||
),
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
maxLines: maxLines,
|
||||
textInputAction: textInputAction,
|
||||
validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation',
|
||||
onChanged: onChanged,
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// マスタ編集用の数値入力 TextField
|
||||
/// テキストエリア入力フィールド(マスタ編集用)
|
||||
class MasterTextArea extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final int maxLines;
|
||||
|
||||
const MasterTextArea({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.maxLines = 2,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
|
||||
),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 数値入力フィールド(マスタ編集用)
|
||||
class MasterNumberField extends StatelessWidget {
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? hint;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final void Function(String)? onChanged;
|
||||
final String? hintText;
|
||||
final bool readOnly;
|
||||
|
||||
const MasterNumberField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.hint,
|
||||
this.validator,
|
||||
this.hintText,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
readOnly: readOnly,
|
||||
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
|
||||
),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ステータス表示フィールド(マスタ編集用)
|
||||
class MasterStatusField extends StatelessWidget {
|
||||
final String label;
|
||||
final String? status;
|
||||
|
||||
const MasterStatusField({super.key, required this.label, this.status});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Expanded(child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
const SizedBox(width: 8),
|
||||
if (status != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(status!, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// チェックボックスフィールド(マスタ編集用)
|
||||
class MasterCheckboxField extends StatelessWidget {
|
||||
final String label;
|
||||
final bool? checked;
|
||||
final Function(bool)? onChanged;
|
||||
|
||||
const MasterCheckboxField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.checked,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation',
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// マスタ編集用の Checkbox
|
||||
class MasterCheckboxField extends StatelessWidget {
|
||||
final String label;
|
||||
final bool value;
|
||||
final ValueChanged<bool?>? onChangedCallback;
|
||||
|
||||
const MasterCheckboxField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.onChangedCallback,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Checkbox(
|
||||
value: value,
|
||||
onChanged: onChangedCallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// マスタ編集用の Switch
|
||||
class MasterSwitchField extends StatelessWidget {
|
||||
final String label;
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChangedCallback;
|
||||
|
||||
const MasterSwitchField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.onChangedCallback,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: onChangedCallback,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(label),
|
||||
Checkbox(value: checked ?? false, onChanged: onChanged),
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,9 @@ dependencies:
|
|||
share_plus: ^10.1.2
|
||||
google_sign_in: ^7.2.0
|
||||
|
||||
# フォームビルダ - マスタ編集の汎用モジュールで使用
|
||||
flutter_form_builder: ^9.1.1
|
||||
# リッチマスター編集用機能(簡易実装)
|
||||
image_picker: ^1.0.7
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Reference in a new issue