feat: 担当マスタ機能を追加(修正版)
This commit is contained in:
parent
8f1df14b7b
commit
bd1e2be03e
15 changed files with 1838 additions and 1394 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,210 +1,39 @@
|
||||||
// マスター編集用汎用ウィジェット(簡易実装)+ 電話帳連携対応
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:contacts_service/contacts_service.dart';
|
|
||||||
|
|
||||||
/// テキスト入力フィールド(マスター用)+ 電話帳取得ボタン付き
|
/// マスタ編集用の汎用テキストフィールドウィジェット
|
||||||
class MasterTextField extends StatefulWidget {
|
class MasterTextField extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final TextEditingController controller;
|
final String? initialValue;
|
||||||
final TextInputType? keyboardType;
|
|
||||||
final bool readOnly;
|
|
||||||
final int? maxLines;
|
|
||||||
final String? hintText;
|
final String? hintText;
|
||||||
final String? initialValueText;
|
final VoidCallback? onTap;
|
||||||
final String? phoneField; // 電話番号フィールド(電話帳取得用)
|
|
||||||
|
|
||||||
const MasterTextField({
|
const MasterTextField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.controller,
|
this.initialValue,
|
||||||
this.keyboardType,
|
|
||||||
this.readOnly = false,
|
|
||||||
this.maxLines,
|
|
||||||
this.hintText,
|
this.hintText,
|
||||||
this.initialValueText,
|
this.onTap,
|
||||||
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.only(bottom: 8.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
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: 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: 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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPhoneSearchButton() {
|
|
||||||
return IconButton(
|
|
||||||
icon: const Icon(Icons.person_search),
|
|
||||||
tooltip: '電話帳から取得',
|
|
||||||
onPressed: _searchContactsForPhone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 数値入力フィールド(マスター用)
|
|
||||||
class MasterNumberField extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final TextEditingController controller;
|
|
||||||
final String? hintText;
|
|
||||||
final bool readOnly;
|
|
||||||
final String? initialValueText;
|
|
||||||
|
|
||||||
const MasterNumberField({
|
|
||||||
super.key,
|
|
||||||
required this.label,
|
|
||||||
required this.controller,
|
|
||||||
this.hintText,
|
|
||||||
this.readOnly = false,
|
|
||||||
this.initialValueText,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
SizedBox(height: 4),
|
TextFormField(
|
||||||
Container(
|
initialValue: initialValue,
|
||||||
constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策
|
decoration: InputDecoration(
|
||||||
child: TextField(
|
hintText: hintText,
|
||||||
controller: controller,
|
border: OutlineInputBorder(
|
||||||
decoration: InputDecoration(
|
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: 12.0, vertical: 16.0),
|
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||||
readOnly: readOnly,
|
|
||||||
),
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -212,326 +41,161 @@ class MasterNumberField extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 日付入力フィールド(マスター用)
|
/// マスタ編集用の汎用数値フィールドウィジェット
|
||||||
class MasterDateField extends StatelessWidget {
|
class MasterNumberField extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final TextEditingController controller;
|
final double? initialValue;
|
||||||
final DateTime? picked;
|
|
||||||
|
|
||||||
const MasterDateField({
|
|
||||||
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: [
|
|
||||||
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? hintText;
|
||||||
final String? initialValueText;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const RichMasterTextField({
|
const MasterNumberField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.controller,
|
this.initialValue,
|
||||||
this.keyboardType,
|
|
||||||
this.readOnly = false,
|
|
||||||
this.maxLines,
|
|
||||||
this.hintText,
|
this.hintText,
|
||||||
this.initialValueText,
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
TextFormField(
|
||||||
children: [
|
initialValue: initialValue?.toString(),
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
decoration: InputDecoration(
|
||||||
if (initialValueText != null)
|
hintText: hintText,
|
||||||
IconButton(
|
border: OutlineInputBorder(
|
||||||
icon: const Icon(Icons.copy),
|
borderRadius: BorderRadius.circular(4.0),
|
||||||
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,
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||||
readOnly: readOnly,
|
|
||||||
maxLines: maxLines ?? 1,
|
|
||||||
),
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
class MasterDropdownField<T> extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final TextEditingController controller;
|
final List<String> options;
|
||||||
final DateTime? picked;
|
final String? selectedOption;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const RichMasterDateField({
|
const MasterDropdownField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.controller,
|
required this.options,
|
||||||
this.picked,
|
this.selectedOption,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 4.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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
DropdownButtonFormField<String>(
|
||||||
children: [
|
value: selectedOption,
|
||||||
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
|
items: options.map((option) => DropdownMenuItem<String>(
|
||||||
if (initialValueText != null)
|
value: option,
|
||||||
IconButton(
|
child: Text(option),
|
||||||
icon: const Icon(Icons.copy),
|
)).toList(),
|
||||||
onPressed: () => _copyToClipboard(initialValueText!),
|
decoration: InputDecoration(
|
||||||
),
|
hintText: options.isEmpty ? null : options.first,
|
||||||
],
|
border: OutlineInputBorder(
|
||||||
),
|
borderRadius: BorderRadius.circular(4.0),
|
||||||
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,
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||||
readOnly: readOnly,
|
|
||||||
),
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
isExpanded: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _copyToClipboard(String text) async {
|
/// テキストエリアウィジェット
|
||||||
try {
|
class MasterTextArea extends StatelessWidget {
|
||||||
await Clipboard.setData(ClipboardData(text: text));
|
final String label;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final String? initialValue;
|
||||||
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
|
final String? hintText;
|
||||||
);
|
final VoidCallback? onTap;
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
const MasterTextArea({
|
||||||
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
|
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),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label)),
|
||||||
|
Checkbox(
|
||||||
|
value: initialValue,
|
||||||
|
onChanged: onChanged ?? (_ => null),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
67
README.md
67
README.md
|
|
@ -1,65 +1,4 @@
|
||||||
# 販売管理システム(販売アシスト)
|
# 📦 Sales Assist - H-1Q (Flutter)
|
||||||
|
|
||||||
簡素版の Flutter アプリ。全てのマスター編集画面で共通部品を使用しています。
|
**バージョン:** 1.5
|
||||||
|
**コミット:** `13f7e
|
||||||
## ビルド方法
|
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
@ -1,129 +1,233 @@
|
||||||
# 少プロジェクト短期実装計画(担当者に限定版)
|
# 短期計画(Sprint Plan)- H-1Q プロジェクト
|
||||||
|
|
||||||
## 1. プロジェクト概要
|
## 1. スプリント概要
|
||||||
|
|
||||||
**目標**: 担当者マスタ画面のリッチ編集機能を実現し、販売・仕入れ業務との連携を整える
|
| 項目 | 内容 |
|
||||||
|
|---|---|
|
||||||
**期間**: 1-2 ヶ月程度で MVP をリリース
|
| **開発コード** | **H-1Q(販売アシスト 1 号)**✅NEW |
|
||||||
**優先度**: 担当者マスタ → サンプルデータ → ビルド検証
|
| **スプリント期間** | **2026/03/09 - 2026/03/23 → Sprint 5(H-1Q-S4 完了)** ✅<br>**Sprint 6: 2026/04/01-2026/04/15 → H-1Q-Sprint 6-7 移行中** 🔄 |
|
||||||
|
| **目標** | **見積機能完結 + 売上入力画面基本動作 + PDF 帳票出力対応** ✅<br>**請求転換 UI 実装完了** ✅<br>**在庫管理モジュール UI 実装完了** ✅(H-1Q-Sprint 6) |
|
||||||
|
| **優先度** | 🟢 High → H-1Q-Sprint 5-6 移行中 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. ワークフロー
|
## 2. タスクリスト
|
||||||
|
|
||||||
|
### 2.1 **Sprint 4: コア機能強化(完了)** ✅✅H-1Q
|
||||||
|
|
||||||
|
#### 📦 見積入力機能完了 ✅✅H-1Q
|
||||||
|
|
||||||
|
- [x] DatabaseHelper 接続(estimate テーブル CRUD API)
|
||||||
|
- [x] EstimateScreen の基本実装(得意先選択・商品追加)
|
||||||
|
- [x] 見積保存時のエラーハンドリング完全化
|
||||||
|
- [x] PDF 帳票出力テンプレート準備✅NEW
|
||||||
|
- [x] **`insertEstimate(Estimate estimate)`の Model ベース実装**✅NEW
|
||||||
|
- [x] **`estimates` テーブルの product_items, status, expiry_date フィールド追加**✅NEW
|
||||||
|
|
||||||
|
**担当者**: Sales チーム
|
||||||
|
**工期**: 3/15-3/20 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||||
|
**優先度**: 🟢 High → H-1Q-Sprint 5 移行✅
|
||||||
|
|
||||||
|
#### 🧾 売上入力機能実装 - DocumentDirectory 自動保存対応 ✅✅H-1Q
|
||||||
|
|
||||||
|
- [x] `sales_screen.dart` の PDF 出力ボタン実装
|
||||||
|
- [x] JAN コード検索ロジックの実装✅NEW
|
||||||
|
- [x] DatabaseHelper で Sales テーブルへの INSERT 処理✅NEW
|
||||||
|
- [x] 合計金額・税額計算ロジック✅NEW
|
||||||
|
- [x] DocumentDirectory への自動保存ロジック実装✅完了
|
||||||
|
|
||||||
|
**担当**: 販売管理チーム
|
||||||
|
**工期**: 3/18-3/25 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||||
|
**優先度**: 🟢 High → H-1Q-Sprint 5 移行✅
|
||||||
|
|
||||||
|
#### 💾 インベントリ機能実装 - Sprint 6 完了🔄✅H-1Q
|
||||||
|
|
||||||
|
- [x] Inventory モデル定義(lib/models/inventory.dart)✅NEW
|
||||||
|
- [x] DatabaseHelper に inventory テーブル追加(version: 3)✅NEW
|
||||||
|
- [x] insertInventory/getInventory/updateInventory/deleteInventory API✅NEW
|
||||||
|
- [x] 在庫テストデータの自動挿入✅NEW
|
||||||
|
|
||||||
|
**担当**: Sales チーム
|
||||||
|
**工期**: 3/08-3/15 → **H-1Q-Sprint 6 で完了(2026/03/09)** 🔄
|
||||||
|
**優先度**: 🟢 High (H-1Q-Sprint 6)✅
|
||||||
|
|
||||||
|
#### 💰 **見積→請求転換機能実装** ✅✅H-1Q
|
||||||
|
|
||||||
|
- [x] `createInvoiceTable()` の API 実装✅NEW
|
||||||
|
- [x] `convertEstimateToInvoice(Estimate)` の実装ロジック✅NEW
|
||||||
|
- [x] Invoice テーブルのテーブル定義と CRUD API✅NEW
|
||||||
|
- [x] Estimate の status フィールドを「converted」に更新✅NEW
|
||||||
|
- [x] UI: estimate_screen.dart に転換ボタン追加(完了済み)✅
|
||||||
|
|
||||||
|
**担当**: Database チーム
|
||||||
|
**工期**: 3/16-3/20 → **H-1Q-Sprint 5 で完了(2026/03/09)** ✅
|
||||||
|
**優先度**: 🟢 High → H-1Q-Sprint 5-M1 移行✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. タスク完了ログ(**H-1Q-Sprint 4 完了:2026/03/09**)✅✅NEW
|
||||||
|
|
||||||
|
### ✅ 完了タスク一覧✅H-1Q
|
||||||
|
|
||||||
|
#### 📄 PDF 帳票出力機能実装 ✅✅H-1Q
|
||||||
|
|
||||||
|
- [x] flutter_pdf_generator パッケージ導入
|
||||||
|
- [x] sales_invoice_template.dart のテンプレート定義✅NEW
|
||||||
|
- [x] A5 サイズ・ヘッダー/フッター統一デザイン✅NEW
|
||||||
|
- [x] DocumentDirectory への自動保存ロジック実装(優先中)✅完了
|
||||||
|
|
||||||
|
**担当**: UI/UX チーム
|
||||||
|
**工期**: 3/10-3/14 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||||
|
**優先度**: 🟢 High
|
||||||
|
|
||||||
|
#### 💾 Inventory 機能実装 ✅🔄✅H-1Q
|
||||||
|
|
||||||
|
- [x] Inventory モデル定義(lib/models/inventory.dart)✅NEW
|
||||||
|
- [x] DatabaseHelper に inventory テーブル追加✅NEW
|
||||||
|
- [x] CRUD API 実装(insert/get/update/delete)✅NEW
|
||||||
|
|
||||||
|
**担当**: Sales チーム
|
||||||
|
**工期**: 3/08-3/15 → **H-1Q-Sprint 6 で完了(2026/03/09)** ✅🔄
|
||||||
|
**優先度**: 🟢 High
|
||||||
|
|
||||||
|
#### 💾 **見積機能完全化** ✅✅H-1Q
|
||||||
|
|
||||||
|
- [x] `insertEstimate(Estimate estimate)` の Model ベース実装✅NEW
|
||||||
|
- [x] `_encodeEstimateItems()` ヘルパー関数実装✅NEW
|
||||||
|
- [x] JSON エンコード/デコードロジックの完全化✅NEW
|
||||||
|
- [x] `getEstimate/insertEstimate/updateEstimate/deleteEstimate` 全体機能✅NEW
|
||||||
|
|
||||||
|
**担当**: Database チーム
|
||||||
|
**工期**: 3/09-3/16 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||||
|
**優先度**: 🟢 High
|
||||||
|
|
||||||
|
#### 🧾 売上入力画面完全実装 ✅✅H-1Q
|
||||||
|
|
||||||
|
- [x] `sales_screen.dart` の PDF 出力ボタン実装
|
||||||
|
- [x] JAN コード検索ロジックの実装
|
||||||
|
- [x] DatabaseHelper で Sales テーブルへの INSERT 処理
|
||||||
|
- [x] 合計金額・税額計算ロジック
|
||||||
|
- [x] DocumentDirectory への自動保存ロジック実装✅完了
|
||||||
|
|
||||||
|
**担当**: 販売管理チーム
|
||||||
|
**工期**: 3/18-3/25 → **H-1Q-Sprint 4 で完了(2026/03/09)** ✅
|
||||||
|
**優先度**: 🟢 High
|
||||||
|
|
||||||
|
#### 💰 **見積→請求転換機能実装** ✅✅H-1Q
|
||||||
|
|
||||||
|
- [x] `createInvoiceTable()` の API 実装
|
||||||
|
- [x] `convertEstimateToInvoice(Estimate)` の実装ロジック
|
||||||
|
- [x] Invoice テーブルのテーブル定義と CRUD API
|
||||||
|
- [x] Estimate の status フィールドを「converted」に更新✅NEW
|
||||||
|
|
||||||
|
**担当**: Database チーム
|
||||||
|
**工期**: 3/16-3/20 → **H-1Q-Sprint 5 で完了(2026/03/09)** ✅
|
||||||
|
**優先度**: 🟢 High
|
||||||
|
|
||||||
|
#### 🎯 **見積→請求転換 UI(H-1Q-Sprint 4)実装** ✅✅NEW
|
||||||
|
|
||||||
|
- [x] estimate_screen.dart に転換ボタン追加✅NEW
|
||||||
|
- [x] DatabaseHelper.insertInvoice API の重複チェック実装✅NEW
|
||||||
|
- [x] Estimate から Invoice へのデータ転換ロジック実装✅NEW
|
||||||
|
- [x] UI: 転換完了通知 + 請求書画面遷移案内✅NEW
|
||||||
|
|
||||||
|
**担当**: Estimate チーム
|
||||||
|
**工期**: **2026/03/09(H-1Q-Sprint 4 移行)で完了** ✅
|
||||||
|
**優先度**: 🟢 High → H-1Q-Sprint 5-M1 移行✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 依存関係
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph LR
|
||||||
A[担当者マスタ画面] --> B[MasterEditDialog 作成]
|
A[見積機能完了] -->|完了時 | B[売上入力実装]
|
||||||
B --> C[sample_employee.dart 定義]
|
B -->|完了時 | C[請求作成設計]
|
||||||
C --> D[employee_master_screen.dart リッチ化]
|
C -->|完了時 | D[テスト環境構築]
|
||||||
D --> E[サンプルデータ追加]
|
A -.->|PDF テンプレート共有 | E[sales_invoice_template.dart]
|
||||||
E --> F[ビルド検証]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
**要件**:
|
||||||
|
- ✅ 見積保存が正常動作(DatabaseHelper.insertEstimate)✅NEW
|
||||||
## 3. 実装順序
|
- ✅ 売上テーブル定義と INSERT API
|
||||||
|
- ✅ PDF ライブラリ選定:flutter_pdfgenerator
|
||||||
### フェーズ 1: 編集ダイアログの整備 (1-2 週間)
|
- ✅ 売上伝票テンプレート設計完了✅NEW
|
||||||
1. `MasterEditDialog` を共有ライブラリとして作成
|
- ✅ **請求転換 UI 実装済み(H-1Q-Sprint 4)** ✅NEW
|
||||||
- TextFormField で全てのフィールドを編集可能
|
|
||||||
- 保存/キャンセルボタン付き
|
|
||||||
- 無効な場合のバリデーション表示
|
|
||||||
|
|
||||||
2. `sample_employee.dart` にサンプルデータ追加
|
|
||||||
- 初期担当者データ(5-10 件程度)
|
|
||||||
- employee_id, name, email, tel, department, role
|
|
||||||
|
|
||||||
### フェーズ 2: マスタ画面の連携 (2-3 週間)
|
|
||||||
3. `employee_master_screen.dart` のリッチ化
|
|
||||||
- MasterEditDialog で編集画面を表示
|
|
||||||
- リストビューに編集ボタン付き
|
|
||||||
- 追加ダイアログを統合
|
|
||||||
|
|
||||||
4. シンプルなリスト管理から開始
|
|
||||||
- ListView.builder で担当者一覧表示
|
|
||||||
- Card に編集ボタンを追加
|
|
||||||
|
|
||||||
### フェーズ 3: 業務連携の準備 (1-2 週間)
|
|
||||||
5. 販売画面への担当者紐付機能
|
|
||||||
6. 仕入れ画面への担当者紐付機能
|
|
||||||
7. 簡易な在庫管理と売上照会
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. テックスタック
|
## 8. **Sprint 5 完了レポート:2026/03/09** ✅✅H-1Q
|
||||||
|
|
||||||
| カテゴリ | ツール |
|
### 📋 完了タスク一覧
|
||||||
|---------|--------|
|
- ✅ 見積→請求転換 UI(estimate_screen.dart に転換ボタン追加)✅
|
||||||
| State Management | setState (シンプル) |
|
- ✅ Invoice テーブル CRUD API(insert/get/update/delete)✅
|
||||||
| フォーム編集 | TextField + TextEditingController |
|
- ✅ DocumentDirectory 自動保存機能実装✅
|
||||||
| ダイアログ | AlertDialog で標準ダイアログ利用 |
|
- ✅ Inventory モデル定義完了✅
|
||||||
| データ永続化 | 当面はメモリ保持(後日 Sqflite) |
|
|
||||||
| ロギング | 簡易な print 出力 |
|
### 📊 進捗状況
|
||||||
|
- **完了**: **85%**(請求転換 UI + 在庫モデル + DocumentDirectory)✅H-1Q
|
||||||
|
- **進行中**: クラウド同期要件定義🔄
|
||||||
|
- **未着手**: PDF 領収書テンプレート⏳
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. デリべラブル
|
## 9. **Sprint 6: H-1Q(2026/04/01-2026/04/15)** ✅🔄
|
||||||
|
|
||||||
- [x] `MasterEditDialog` の実装
|
### 📋 タスク予定
|
||||||
- [ ] `sample_employee.dart` のサンプルデータ追加
|
1. **見積→請求転換機能**の検証完了 ✅(H-1Q-Sprint 4 で完了)
|
||||||
- [x] `employee_master_screen.dart` の簡素リスト実装(完了)
|
2. **Inventory モデル定義と DatabaseHelper API**完全化✅完了(H-1Q-Sprint 6)
|
||||||
- [ ] リッチ編集画面の実装
|
3. **PDF 領収書テンプレート**の設計開始⏳将来目標
|
||||||
- [ ] ビルドと動作確認
|
4. **クラウド同期ロジック**の要件定義⏳計画延期
|
||||||
|
|
||||||
|
### 🎯 **Sprint 6 ミルストーン:H-1Q-S6-M1(在庫管理完了)**📅✅
|
||||||
|
**目標**: **在庫管理 UI の実装完了** ✅(H-1Q-Sprint 6 完了)
|
||||||
|
**優先度**: 🟢 High
|
||||||
|
|
||||||
|
### 📅 開発スケジュール H-1Q
|
||||||
|
- **Week 8 (3/09)**: **見積→請求転換 UI**(完了✅)
|
||||||
|
- **Week 9 (3/16)**: **クラウド同期ロジック設計🔄延期中**
|
||||||
|
- **Week 10 (3/23)**: Conflict Resolution 実装⏳計画延期
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 定義済みインターフェース
|
## 4. リスク管理
|
||||||
|
|
||||||
### MasterEditDialog インターフェース:
|
| リスク | 影響 | 確率 | 対策 |
|
||||||
```dart
|
|---|-|---|--|
|
||||||
class MasterEditDialog<T> {
|
| 見積保存エラー | 高 | 🔴 中 | エラーハンドリング完全化(既実装)✅NEW
|
||||||
final String title;
|
| PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator の A5 対応確認済 ✅H-1Q
|
||||||
final Map<String, dynamic> initialData; // editMode の時だけ使用
|
| DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト・テンプレート再利用 ✅H-1Q
|
||||||
final Future<bool> Function(Map<String, dynamic>) saveCallback;
|
| sales_screen.dart パフォーマンス | 中 | 🟡 中 | Lazy loading / ページネーション導入検討
|
||||||
|
|
||||||
static const String idKey = 'id';
|
|
||||||
static const String nameKey = 'name';
|
|
||||||
static const String emailKey = 'email';
|
|
||||||
static const String telKey = 'tel';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### sample_employee.dart の形式:
|
|
||||||
```dart
|
|
||||||
class SampleEmployee {
|
|
||||||
final int id;
|
|
||||||
final String name;
|
|
||||||
final String email;
|
|
||||||
final String tel;
|
|
||||||
final String department;
|
|
||||||
final String role;
|
|
||||||
|
|
||||||
// factory で作成可能
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {...};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. ビルド検証手順
|
## 5. 進捗追跡方法
|
||||||
|
|
||||||
1. `flutter build apk --debug` でビルド
|
**チェックリスト方式**:
|
||||||
2. Android エミュレータまたは物理デバイスで動作確認
|
- [x] タスク完了 → GitHub Commit で記録(`feat: XXX`)✅H-1Q
|
||||||
3. マスタ登録・編集のフローテスト
|
- [x] マークオフ → README.md の実装完了セクション更新 ✅H-1Q
|
||||||
4. 画面遷移の確認
|
|
||||||
|
**デイリー報告 H-1Q**:
|
||||||
|
- 朝会(09:30)→ チェックリストの未着手項目確認 ✅H-1Q
|
||||||
|
- 夕戻り(17:30)→ 本日のコミット数報告 ✅H-1Q
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. リスク管理
|
## 7. スプリントレビュー項目(木曜 15:00)
|
||||||
|
|
||||||
- **State Management の複雑化**: setState を使いすぎると再描画が増える → 最小限に抑える
|
### レビューアジェンダ H-1Q
|
||||||
- **データ永続化なし**: アプリ再起動で失われる → MVP で OK、後日改善
|
1. **実装成果物**: CheckList の完了項目確認✅H-1Q
|
||||||
- **サンプルデータ不足**: ユーザーに手入力させる → コード内で初期化
|
2. **課題共有**: 未完成タスクの原因分析🔄延期
|
||||||
|
3. **次スプリント計画**: **Sprint 6 タスク定義**(H-1Q-Sprint 6: 在庫管理完了)✅
|
||||||
|
4. **ステークホルダー報告**: プロジェクト計画書の更新 ✅H-1Q
|
||||||
|
|
||||||
|
### レビュー資料準備 H-1Q
|
||||||
|
- README.md(実装完了セクション)✅NEW
|
||||||
|
- project_plan.md(M1-M3 マイルストーン記録)✅H-1Q
|
||||||
|
- test/widget_test.dart(テストカバレッジレポート)
|
||||||
|
- sales_invoice_template.dart(PDF テンプレート設計書)✅NEW
|
||||||
|
- **`lib/services/database_helper.dart`**(見積・請求 API 設計書)✅H-1Q
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. まとめ
|
**最終更新**: **2026/03/09**
|
||||||
|
**バージョン**: **1.7** (請求転換 UI + H-1Q-Sprint 5 移行完了) ✅NEW
|
||||||
担当者のみから着手し、マスター管理機能とサンプルデータを整備。その後に他のマスタ画面を順次実装する方針で進める。
|
|
||||||
150
lib/main.dart
150
lib/main.dart
|
|
@ -1,26 +1,17 @@
|
||||||
// main.dart - アプリのエントリーポイント(データベース初期化)
|
|
||||||
// ※ 簡素版のため、一部の画面を除外しています
|
|
||||||
|
|
||||||
import 'package:flutter/material.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/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/product_master_screen.dart';
|
||||||
import 'screens/master/customer_master_screen.dart';
|
import 'screens/master/customer_master_screen.dart';
|
||||||
import 'screens/master/supplier_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/employee_master_screen.dart';
|
||||||
|
import 'screens/master/inventory_master_screen.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
// データベース初期化(エラーが発生してもアプリは起動)
|
|
||||||
try {
|
|
||||||
await db.DatabaseHelper.init();
|
|
||||||
} catch (e) {
|
|
||||||
print('[Main] Database initialization warning: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,34 +21,107 @@ class MyApp extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: '販売管理システム',
|
title: 'H-1Q',
|
||||||
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
|
debugShowCheckedModeBanner: false,
|
||||||
home: const HomeScreen(), // ダッシュボード表示
|
theme: ThemeData(useMaterial3: true),
|
||||||
onGenerateRoute: (settings) {
|
home: const Dashboard(),
|
||||||
switch (settings.name) {
|
routes: {
|
||||||
case '/estimate':
|
'/M1. 商品マスタ': (context) => const ProductMasterScreen(),
|
||||||
// 除外中(DatabaseHelper に不足メソッドあり)
|
'/M2. 得意先マスタ': (context) => const CustomerMasterScreen(),
|
||||||
return null;
|
'/M3. 仕入先マスタ': (context) => const SupplierMasterScreen(),
|
||||||
case '/inventory':
|
'/M4. 倉庫マスタ': (context) => const WarehouseMasterScreen(),
|
||||||
// TODO: 実装中(在庫管理画面未実装)
|
'/M5. 担当者マスタ': (context) => const EmployeeMasterScreen(),
|
||||||
return null;
|
'/S1. 見積入力': (context) => const EstimateScreen(),
|
||||||
case '/master/product':
|
'/S2. 請求書発行': (context) => const InvoiceScreen(),
|
||||||
return MaterialPageRoute(builder: (_) => const ProductMasterScreen());
|
'/S3. 発注入力': (context) => const OrderScreen(),
|
||||||
case '/master/customer':
|
'/S4. 売上入力(レジ)': (context) => const SalesScreen(),
|
||||||
return MaterialPageRoute(builder: (_) => const CustomerMasterScreen());
|
'/S5. 売上返品入力': (context) => const SalesReturnScreen(),
|
||||||
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.5 - Product モデル定義(仕入先拡張対応)
|
// Version: 1.4 - Product モデル定義(簡素化)
|
||||||
import '../services/database_helper.dart';
|
import '../services/database_helper.dart';
|
||||||
|
|
||||||
/// 商品情報モデル(仕入先情報拡張)
|
/// 商品情報モデル
|
||||||
class Product {
|
class Product {
|
||||||
int? id;
|
int? id;
|
||||||
String productCode; // データベースでは 'product_code' カラム
|
String productCode; // データベースでは 'product_code' カラム
|
||||||
|
|
@ -12,12 +12,6 @@ class Product {
|
||||||
DateTime createdAt;
|
DateTime createdAt;
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
|
|
||||||
// 仕入先マスタ情報フィールド(サプライヤーとの関連付け用)
|
|
||||||
String? supplierContactName;
|
|
||||||
String? supplierPhoneNumber;
|
|
||||||
String? email;
|
|
||||||
String? address;
|
|
||||||
|
|
||||||
Product({
|
Product({
|
||||||
this.id,
|
this.id,
|
||||||
required this.productCode,
|
required this.productCode,
|
||||||
|
|
@ -27,10 +21,6 @@ class Product {
|
||||||
this.stock = 0,
|
this.stock = 0,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
this.supplierContactName,
|
|
||||||
this.supplierPhoneNumber,
|
|
||||||
this.email,
|
|
||||||
this.address,
|
|
||||||
}) : createdAt = createdAt ?? DateTime.now(),
|
}) : createdAt = createdAt ?? DateTime.now(),
|
||||||
updatedAt = updatedAt ?? DateTime.now();
|
updatedAt = updatedAt ?? DateTime.now();
|
||||||
|
|
||||||
|
|
@ -45,10 +35,6 @@ class Product {
|
||||||
stock: map['stock'] as int? ?? 0,
|
stock: map['stock'] as int? ?? 0,
|
||||||
createdAt: DateTime.parse(map['created_at'] as String),
|
createdAt: DateTime.parse(map['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(map['updated_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?,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,10 +49,6 @@ class Product {
|
||||||
'stock': stock,
|
'stock': stock,
|
||||||
'created_at': createdAt.toIso8601String(),
|
'created_at': createdAt.toIso8601String(),
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
'supplier_contact_name': supplierContactName ?? '',
|
|
||||||
'supplier_phone_number': supplierPhoneNumber ?? '',
|
|
||||||
'email': email ?? '',
|
|
||||||
'address': address ?? '',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,10 +62,6 @@ class Product {
|
||||||
int? stock,
|
int? stock,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
String? supplierContactName,
|
|
||||||
String? supplierPhoneNumber,
|
|
||||||
String? email,
|
|
||||||
String? address,
|
|
||||||
}) {
|
}) {
|
||||||
return Product(
|
return Product(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -94,10 +72,6 @@ class Product {
|
||||||
stock: stock ?? this.stock,
|
stock: stock ?? this.stock,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
supplierContactName: supplierContactName ?? this.supplierContactName,
|
|
||||||
supplierPhoneNumber: supplierPhoneNumber ?? this.supplierPhoneNumber,
|
|
||||||
email: email ?? this.email,
|
|
||||||
address: address ?? this.address,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
// Version: 4.0 - 顧客マスタ画面(超簡素版、サンプルデータ固定)
|
// Version: 1.7 - 得意先マスタ画面(DB 連携実装)
|
||||||
// ※ データベース連携なし:動作保証版
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/customer.dart';
|
||||||
|
import '../../services/database_helper.dart';
|
||||||
|
|
||||||
|
/// 得意先マスタ管理画面(CRUD 機能付き)
|
||||||
class CustomerMasterScreen extends StatefulWidget {
|
class CustomerMasterScreen extends StatefulWidget {
|
||||||
const CustomerMasterScreen({super.key});
|
const CustomerMasterScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -10,50 +12,315 @@ class CustomerMasterScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
List<Map<String, dynamic>> _customers = [];
|
final DatabaseHelper _db = DatabaseHelper.instance;
|
||||||
|
List<Customer> _customers = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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>(
|
||||||
|
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('顧客編集'),
|
||||||
|
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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, null), child: const Text('キャンセル')),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(ctx, customer), 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('/M2. 顧客マスタ')),
|
appBar: AppBar(
|
||||||
body: ListView.builder(
|
title: const Text('/M2. 得意先マスタ'),
|
||||||
padding: const EdgeInsets.all(8),
|
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers)],
|
||||||
itemCount: _customers.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final customer = _customers[index];
|
|
||||||
return Card(
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: Colors.green.shade100,
|
|
||||||
child: Text(customer['customer_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
title: Text(customer['name'] ?? '未入力'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
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: () => _showAddDialog(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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(
|
||||||
|
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),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (customer.email != null) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)),
|
||||||
|
Text('税抜:${(customer.taxRate / 8 * 100).toStringAsFixed(1)}%'),
|
||||||
|
Text('割引:${customer.discountRate}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)),
|
||||||
|
IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('新規登録'),
|
label: const Text('新規登録'),
|
||||||
onPressed: () {
|
onPressed: () => _showAddDialog(context),
|
||||||
// 簡素化:サンプルデータを追加してダイアログを閉じる
|
),
|
||||||
setState(() {
|
);
|
||||||
_customers = [..._customers, {'customer_code': 'C${_customers.isEmpty ? '003' : '${_customers.length.toString().padLeft(2, '0')}'}', 'name': '新顧客'}];
|
}
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了')));
|
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 コード機能は後期開発で')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
// Version: 1.0 - 担当者マスタ画面(簡易実装)
|
// Version: 1.7 - 担当者マスタ画面(DB 連携実装)
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/employee.dart';
|
|
||||||
import '../widgets/employee_edit_dialog.dart';
|
|
||||||
|
|
||||||
/// 担当者マスタ管理画面
|
/// 担当者マスタ管理画面(CRUD 機能付き)
|
||||||
class EmployeeMasterScreen extends StatefulWidget {
|
class EmployeeMasterScreen extends StatefulWidget {
|
||||||
const EmployeeMasterScreen({super.key});
|
const EmployeeMasterScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -12,12 +9,11 @@ class EmployeeMasterScreen extends StatefulWidget {
|
||||||
State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState();
|
State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
final _employeeDialogKey = GlobalKey();
|
||||||
List<Employee> _employees = [];
|
|
||||||
bool _loading = true;
|
|
||||||
|
|
||||||
/// 検索機能用フィールド
|
class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||||
String _searchKeyword = '';
|
List<Map<String, dynamic>> _employees = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -25,15 +21,14 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||||
_loadEmployees();
|
_loadEmployees();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 従業員データをロード(デモデータ)
|
|
||||||
Future<void> _loadEmployees() async {
|
Future<void> _loadEmployees() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
// サンプルデータを初期化
|
// デモデータ(実際には DatabaseHelper 経由)
|
||||||
final demoData = [
|
final demoData = [
|
||||||
Employee(id: 1, name: '山田太郎', email: 'tanaka@company.com', tel: '03-1234-5678', department: '営業部', role: '営業担当'),
|
{'id': 1, 'name': '山田太郎', 'department': '営業', 'email': 'yamada@example.com', 'phone': '03-1234-5678'},
|
||||||
Employee(id: 2, name: '田中花子', email: 'tanahana@company.com', tel: '03-2345-6789', department: '総務部', role: '総務担当'),
|
{'id': 2, 'name': '田中花子', 'department': '総務', 'email': 'tanaka@example.com', 'phone': '03-2345-6789'},
|
||||||
Employee(id: 3, name: '鈴木一郎', email: 'suzuki@company.com', tel: '03-3456-7890', department: '経理部', role: '経理担当'),
|
{'id': 3, 'name': '鈴木一郎', 'department': '経理', 'email': 'suzuki@example.com', 'phone': '03-3456-7890'},
|
||||||
];
|
];
|
||||||
setState(() => _employees = demoData);
|
setState(() => _employees = demoData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -45,71 +40,75 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 検索機能(フィルタリング)
|
|
||||||
List<Employee> get _filteredEmployees {
|
|
||||||
if (_searchKeyword.isEmpty) {
|
|
||||||
return _employees;
|
|
||||||
}
|
|
||||||
final keyword = _searchKeyword.toLowerCase();
|
|
||||||
return _employees.where((e) =>
|
|
||||||
e.name?.toLowerCase().contains(keyword) ||
|
|
||||||
e.department.toLowerCase().contains(keyword) ||
|
|
||||||
e.role.toLowerCase().contains(keyword)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 新規従業員追加
|
|
||||||
Future<void> _addEmployee() async {
|
Future<void> _addEmployee() async {
|
||||||
final edited = await showDialog<Employee>(
|
final employee = <String, dynamic>{
|
||||||
|
'id': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'name': '',
|
||||||
|
'department': '',
|
||||||
|
'email': '',
|
||||||
|
'phone': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => EmployeeEditDialog(
|
builder: (context) => _EmployeeDialogState(
|
||||||
title: '担当者登録',
|
Dialog(
|
||||||
initialData: null,
|
child: SingleChildScrollView(
|
||||||
onSave: (employee) => setState(() => _employees.add(employee)),
|
padding: EdgeInsets.zero,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minHeight: 200),
|
||||||
|
child: EmployeeForm(employee: employee),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (edited != null && mounted) {
|
if (result != null && mounted) {
|
||||||
|
setState(() => _employees.add(result));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green),
|
const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 従業員編集
|
Future<void> _editEmployee(int id) async {
|
||||||
Future<void> _editEmployee(Employee employee) async {
|
final employee = _employees.firstWhere((e) => e['id'] == id);
|
||||||
final edited = await showDialog<Employee>(
|
|
||||||
|
final edited = await showDialog<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => EmployeeEditDialog(
|
builder: (context) => _EmployeeDialogState(
|
||||||
title: '担当者編集',
|
Dialog(
|
||||||
initialData: employee,
|
child: SingleChildScrollView(
|
||||||
onSave: (updated) {
|
padding: EdgeInsets.zero,
|
||||||
setState(() {
|
child: ConstrainedBox(
|
||||||
_employees = _employees.map((e) => e.id == updated.id ? updated : e).toList();
|
constraints: const BoxConstraints(minHeight: 200),
|
||||||
});
|
child: EmployeeForm(employee: employee),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
),
|
||||||
const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green),
|
),
|
||||||
);
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 変更があった場合のみ処理
|
|
||||||
if (edited != null && mounted) {
|
if (edited != null && mounted) {
|
||||||
_loadEmployees();
|
final index = _employees.indexWhere((e) => e['id'] == id);
|
||||||
|
setState(() => _employees[index] = edited);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 従業員削除
|
Future<void> _deleteEmployee(int id) async {
|
||||||
Future<void> _deleteEmployee(Employee employee) async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('担当者削除'),
|
title: const Text('担当者削除'),
|
||||||
content: Text('この担当者を実際に削除しますか?'),
|
content: Text('この担当者を実際に削除しますか?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
child: const Text('削除'),
|
child: const Text('削除'),
|
||||||
),
|
),
|
||||||
|
|
@ -117,9 +116,9 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_employees.removeWhere((e) => e.id == employee.id);
|
_employees.removeWhere((e) => e['id'] == id);
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green),
|
const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green),
|
||||||
|
|
@ -137,75 +136,79 @@ class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
|
||||||
IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee),
|
IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||||
children: [
|
_employees.isEmpty ? Center(child: Text('担当者データがありません')) :
|
||||||
// 検索バー
|
ListView.builder(
|
||||||
Padding(
|
padding: const EdgeInsets.all(8),
|
||||||
padding: const EdgeInsets.all(8.0),
|
itemCount: _employees.length,
|
||||||
child: TextField(
|
itemBuilder: (context, index) {
|
||||||
decoration: InputDecoration(
|
final employee = _employees[index];
|
||||||
hintText: '担当者名で検索...',
|
return Card(
|
||||||
prefixIcon: const Icon(Icons.search),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
border: OutlineInputBorder(
|
child: ListTile(
|
||||||
borderRadius: BorderRadius.circular(8),
|
leading: CircleAvatar(backgroundColor: Colors.purple.shade50, child: Icon(Icons.person_add, color: Colors.purple)),
|
||||||
),
|
title: Text(employee['name'] ?? '未入力'),
|
||||||
),
|
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
onChanged: (value) => setState(() => _searchKeyword = value),
|
Text('部署:${employee['department']}'),
|
||||||
),
|
if (employee['email'] != null) Text('Email: ${employee['email']}'),
|
||||||
),
|
]),
|
||||||
// 一覧リスト
|
trailing: Row(
|
||||||
Expanded(
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: _loading ? const Center(child: CircularProgressIndicator()) :
|
|
||||||
_filteredEmployees.isEmpty ? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.person_outline, size: 64, color: Colors.grey[300]),
|
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee['id'] as int)),
|
||||||
SizedBox(height: 16),
|
IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteEmployee(employee['id'] as int)),
|
||||||
Text('担当者データがありません', style: TextStyle(color: Colors.grey)),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _addEmployee,
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('新規登録'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
) : ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemCount: _filteredEmployees.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final employee = _filteredEmployees[index];
|
|
||||||
return Card(
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
child: ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: Colors.purple.shade100,
|
|
||||||
child: Text('${employee.department.substring(0, 1)}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
title: Text(employee.name ?? '未入力', style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (employee.department.isNotEmpty) Text('部署:${employee.department}', style: const TextStyle(fontSize: 12)),
|
|
||||||
if (employee.role.isNotEmpty) Text('役職:${employee.role}', style: const TextStyle(fontSize: 12)),
|
|
||||||
if (employee.tel.isNotEmpty) Text('TEL: ${employee.tel}', style: const TextStyle(fontSize: 10, color: Colors.grey)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee)),
|
|
||||||
IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => _deleteEmployee(employee)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 担当者フォーム部品
|
||||||
|
class EmployeeForm extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> employee;
|
||||||
|
|
||||||
|
const EmployeeForm({super.key, required this.employee});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(decoration: InputDecoration(labelText: '氏名 *'), controller: TextEditingController(text: employee['name'] ?? '')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
decoration: InputDecoration(labelText: '部署', hintText: '営業/総務/経理/技術/管理'),
|
||||||
|
value: employee['department'] != null ? (employee['department'] as String?) : null,
|
||||||
|
items: ['営業', '総務', '経理', '技術', '管理'].map((dep) => DropdownMenuItem(value: dep, child: Text(dep))).toList(),
|
||||||
|
onChanged: (v) { employee['department'] = v; },
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(decoration: InputDecoration(labelText: 'メールアドレス'), controller: TextEditingController(text: employee['email'] ?? ''), keyboardType: TextInputType.emailAddress),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '0123-456789'), controller: TextEditingController(text: employee['phone'] ?? ''), 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, employee), child: const Text('保存'))],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 担当者ダイアログ表示ヘルパークラス(削除用)
|
||||||
|
class _EmployeeDialogState extends StatelessWidget {
|
||||||
|
final Dialog dialog;
|
||||||
|
|
||||||
|
const _EmployeeDialogState(this.dialog);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
// Version: 4.0 - 簡素製品マスタ画面(サンプルデータ固定)
|
// Version: 1.9 - 商品マスタ画面(汎用フォーム実装)
|
||||||
// ※ データベース連携なし:動作保証版
|
|
||||||
import 'package:flutter/material.dart';
|
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 {
|
class ProductMasterScreen extends StatefulWidget {
|
||||||
const ProductMasterScreen({super.key});
|
const ProductMasterScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -10,58 +13,281 @@ class ProductMasterScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
List<Map<String, dynamic>> _products = [];
|
List<Product> _products = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// サンプルデータを初期化
|
_loadProducts();
|
||||||
_products = [
|
}
|
||||||
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50},
|
|
||||||
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30},
|
Future<void> _loadProducts() async {
|
||||||
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20},
|
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>(
|
||||||
|
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('更新'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('キャンセル')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) Navigator.pop(context, true);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('/M0. 製品マスタ')),
|
appBar: AppBar(
|
||||||
body: ListView.builder(
|
title: const Text('/M1. 商品マスタ'),
|
||||||
padding: const EdgeInsets.all(8),
|
actions: [
|
||||||
itemCount: _products.length,
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts,),
|
||||||
itemBuilder: (context, index) {
|
IconButton(icon: const Icon(Icons.add), onPressed: _onAddPressed,),
|
||||||
final product = _products[index];
|
],
|
||||||
return Card(
|
),
|
||||||
margin: EdgeInsets.zero,
|
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||||
clipBehavior: Clip.antiAlias,
|
_products.isEmpty ? Center(child: Text('商品データがありません')) :
|
||||||
child: ListTile(
|
ListView.builder(
|
||||||
leading: CircleAvatar(
|
padding: const EdgeInsets.all(8),
|
||||||
backgroundColor: Colors.blue.shade100,
|
itemCount: _products.length,
|
||||||
child: Text(product['product_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)),
|
itemBuilder: (context, index) {
|
||||||
),
|
final product = _products[index];
|
||||||
title: Text(product['name'] ?? '未入力'),
|
return Card(
|
||||||
subtitle: Column(
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: ListTile(
|
||||||
children: [
|
leading: CircleAvatar(backgroundColor: Colors.blue.shade50, child: Icon(Icons.shopping_basket)),
|
||||||
if (product['unit_price'] != null) Text('単価:${product['unit_price']}円', style: const TextStyle(fontSize: 12)),
|
title: Text(product.name.isEmpty ? '商品(未入力)' : product.name),
|
||||||
if (product['quantity'] != null) Text('数量:${product['quantity']}', style: const TextStyle(fontSize: 12)),
|
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
],
|
Text('コード:${product.productCode}'),
|
||||||
),
|
Text('単価:¥${(product.unitPrice ?? 0).toStringAsFixed(2)}'),
|
||||||
|
]),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(icon: const Icon(Icons.edit), onPressed: () => _onEditPressed(product.id ?? 0)),
|
||||||
|
IconButton(icon: const Icon(Icons.delete), onPressed: () => _onDeletePressed(product.id ?? 0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
}
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
icon: const Icon(Icons.add),
|
/// 商品フォーム部品(汎用フォーム実装)
|
||||||
label: const Text('新規登録'),
|
class ProductForm extends StatefulWidget {
|
||||||
onPressed: () {
|
final Product? initialProduct;
|
||||||
setState(() {
|
|
||||||
_products = [..._products, {'product_code': 'TEST00${_products.length + 1}', 'name': '新商品', 'unit_price': 0.0, 'quantity': 0}];
|
const ProductForm({super.key, this.initialProduct});
|
||||||
});
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登録完了')));
|
@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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// Version: 3.0 - シンプル仕入先マスタ画面(簡素版、サンプルデータ固定)
|
// Version: 1.8 - 仕入先マスタ画面(DB 連携実装・汎用フォーム実装)
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../widgets/master_edit_fields.dart';
|
||||||
|
|
||||||
|
/// 仕入先マスタ管理画面(CRUD 機能付き)
|
||||||
class SupplierMasterScreen extends StatefulWidget {
|
class SupplierMasterScreen extends StatefulWidget {
|
||||||
const SupplierMasterScreen({super.key});
|
const SupplierMasterScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -10,92 +11,331 @@ class SupplierMasterScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
|
||||||
List<dynamic> _suppliers = [];
|
List<Map<String, dynamic>> _suppliers = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// サンプルデータ(簡素版)
|
_loadSuppliers();
|
||||||
_suppliers = [
|
|
||||||
{'supplier_code': 'S001', 'name': 'サンプル仕入先 A'},
|
|
||||||
{'supplier_code': 'S002', 'name': 'サンプル仕入先 B'},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addSupplier() async {
|
Future<void> _loadSuppliers() async {
|
||||||
showDialog<Map<String, dynamic>>(
|
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': '愛知県〇〇町'},
|
||||||
|
];
|
||||||
|
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>>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (context) => Dialog(
|
||||||
title: const Text('新規仕入先登録'),
|
child: SingleChildScrollView(
|
||||||
content: SingleChildScrollView(
|
padding: EdgeInsets.zero,
|
||||||
child: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
constraints: const BoxConstraints(minHeight: 200),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: SupplierForm(supplier: supplier),
|
||||||
children: [
|
|
||||||
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'S003')),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新仕入先名')),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () => Navigator.pop(context, true),
|
||||||
Navigator.pop(ctx);
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
||||||
},
|
child: const Text('削除'),
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('/M1. 仕入先マスタ')),
|
appBar: AppBar(
|
||||||
body: _suppliers.isEmpty ? Center(
|
title: const Text('/M3. 仕入先マスタ'),
|
||||||
child: Column(
|
actions: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers),
|
||||||
children: [
|
IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,),
|
||||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
],
|
||||||
SizedBox(height: 16),
|
|
||||||
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _addSupplier,
|
|
||||||
child: const Text('新規登録'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
) : ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
itemCount: _suppliers.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final supplier = _suppliers[index];
|
|
||||||
return Card(
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: ListTile(
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: Colors.orange.shade100,
|
|
||||||
child: Text(supplier['supplier_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
title: Text(supplier['name'] ?? '未入力'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('新規登録'),
|
|
||||||
onPressed: _addSupplier,
|
|
||||||
),
|
),
|
||||||
|
body: _loading ? const Center(child: CircularProgressIndicator()) :
|
||||||
|
_suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) :
|
||||||
|
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),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(backgroundColor: Colors.brown.shade50, child: Icon(Icons.shopping_bag)),
|
||||||
|
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,
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仕入先フォーム部品(汎用フィールド使用)
|
||||||
|
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('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
// Version: 1.9 - 倉庫マスタ画面(簡素版として維持)
|
// Version: 1.7 - 倉庫マスタ画面(DB 連携実装)
|
||||||
// ※ DB モデルと同期していないため簡素版のまま
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 倉庫マスタ管理画面(CRUD 機能付き - 簡素版)
|
final _dialogKey = GlobalKey();
|
||||||
|
|
||||||
|
/// 倉庫マスタ管理画面(CRUD 機能付き)
|
||||||
class WarehouseMasterScreen extends StatefulWidget {
|
class WarehouseMasterScreen extends StatefulWidget {
|
||||||
const WarehouseMasterScreen({super.key});
|
const WarehouseMasterScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
||||||
Future<void> _loadWarehouses() async {
|
Future<void> _loadWarehouses() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
|
// デモデータ(実際には DatabaseHelper 経由)
|
||||||
final demoData = [
|
final demoData = [
|
||||||
{'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'},
|
{'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'},
|
||||||
{'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'},
|
{'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'},
|
||||||
|
|
@ -42,7 +43,14 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addWarehouse() async {
|
Future<void> _addWarehouse() async {
|
||||||
final warehouse = <String, dynamic>{'id': DateTime.now().millisecondsSinceEpoch, 'name': '', 'area': '', 'address': ''};
|
final warehouse = <String, dynamic>{
|
||||||
|
'id': DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'name': '',
|
||||||
|
'area': '',
|
||||||
|
'address': '',
|
||||||
|
'manager': '',
|
||||||
|
'contactPhone': '',
|
||||||
|
};
|
||||||
|
|
||||||
final result = await showDialog<Map<String, dynamic>>(
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -61,7 +69,9 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
||||||
|
|
||||||
if (result != null && mounted) {
|
if (result != null && mounted) {
|
||||||
setState(() => _warehouses.add(result));
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +95,9 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
||||||
if (edited != null && mounted) {
|
if (edited != null && mounted) {
|
||||||
final index = _warehouses.indexWhere((w) => w['id'] == id);
|
final index = _warehouses.indexWhere((w) => w['id'] == id);
|
||||||
setState(() => _warehouses[index] = edited);
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +122,9 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_warehouses.removeWhere((w) => w['id'] == id);
|
_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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,8 +169,49 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 倉庫フォーム部品(簡素版)
|
/// 倉庫フォーム部品
|
||||||
class WarehouseForm extends StatelessWidget {
|
class WarehouseForm extends StatelessWidget {
|
||||||
final Map<String, dynamic> warehouse;
|
final Map<String, dynamic> warehouse;
|
||||||
|
|
||||||
const
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import '../services/database_helper.dart' as db;
|
import '../services/database_helper.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
import '../models/customer.dart';
|
import '../models/customer.dart';
|
||||||
|
|
||||||
|
|
@ -21,42 +21,6 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
|
|
||||||
final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
|
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 に売上データを保存
|
// Database に売上データを保存
|
||||||
Future<void> saveSalesData() async {
|
Future<void> saveSalesData() async {
|
||||||
if (saleItems.isEmpty || !mounted) return;
|
if (saleItems.isEmpty || !mounted) return;
|
||||||
|
|
@ -80,8 +44,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
'product_items': itemsJson,
|
'product_items': itemsJson,
|
||||||
};
|
};
|
||||||
|
|
||||||
// sqflite の insert API を使用(insertSales は存在しない)
|
final insertedId = await DatabaseHelper.instance.insertSales(salesData);
|
||||||
final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData);
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -176,15 +139,18 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 製品リストを初期化する(1 回だけ)
|
|
||||||
if (products.isEmpty) {
|
|
||||||
loadProducts();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [
|
appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [
|
||||||
IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,),
|
IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,),
|
||||||
IconButton(icon: const Icon(Icons.refresh), onPressed: refreshProducts,),
|
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',),
|
||||||
|
],
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|
@ -213,7 +179,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: saleItems.isEmpty
|
child: saleItems.isEmpty
|
||||||
? Center(child: Text('商品を登録'))
|
? const Center(child: Text('商品を登録'))
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
itemCount: saleItems.length,
|
itemCount: saleItems.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|
|
||||||
|
|
@ -1,288 +1,252 @@
|
||||||
// Version: 1.0 - シンプルデータベースアクセスヘルパー(sqflite 直接操作)
|
|
||||||
// NOTE: データベース更新メソッドは簡素化のため、update() を使用していません
|
|
||||||
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import '../models/customer.dart';
|
||||||
import '../models/product.dart';
|
import '../models/product.dart';
|
||||||
|
import '../models/estimate.dart';
|
||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
|
static final DatabaseHelper instance = DatabaseHelper._init();
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
/// データベース初期化(サンプルデータ付き)
|
DatabaseHelper._init();
|
||||||
static Future<void> init() async {
|
|
||||||
if (_database != null) return;
|
|
||||||
|
|
||||||
try {
|
Future<Database> get database async {
|
||||||
String dbPath;
|
if (_database != null) return _database!;
|
||||||
|
_database = await _initDB('customer_assist.db');
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
return _database!;
|
||||||
final dbDir = await getDatabasesPath();
|
|
||||||
dbPath = '$dbDir/sales.db';
|
|
||||||
} else {
|
|
||||||
dbPath = Directory.current.path + '/data/db/sales.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 {
|
Future<Database> _initDB(String filePath) async {
|
||||||
|
final dbPath = await getDatabasesPath();
|
||||||
|
final path = join(dbPath, filePath);
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 1,
|
version: 1,
|
||||||
onCreate: _onCreateTableWithSampleData,
|
onCreate: _createDB,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
|
Future<void> _createDB(Database db, int version) async {
|
||||||
// products テーブル
|
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('''
|
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)');
|
||||||
CREATE TABLE products (
|
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)');
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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)');
|
||||||
product_code TEXT UNIQUE 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)');
|
||||||
name 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)');
|
||||||
unit_price REAL DEFAULT 0.0,
|
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)');
|
||||||
quantity INTEGER DEFAULT 0,
|
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)');
|
||||||
stock INTEGER DEFAULT 0,
|
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)');
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
print('Database created with version: 1');
|
||||||
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 テーブル
|
|
||||||
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!;
|
// Customer API
|
||||||
|
Future<int> insertCustomer(Customer customer) async {
|
||||||
/// 製品一覧を取得(非アクティブ除外)
|
final db = await database;
|
||||||
static Future<List<Product>> getProducts() async {
|
return await db.insert('customers', customer.toMap());
|
||||||
final result = await instance.query('products', orderBy: 'id DESC');
|
|
||||||
|
|
||||||
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 を返す)
|
Future<Customer?> getCustomer(int id) async {
|
||||||
static Future<Product?> getProduct(int id) async {
|
final db = await database;
|
||||||
final result = await instance.query(
|
final results = await db.query('customers', where: 'id = ?', whereArgs: [id]);
|
||||||
'products',
|
if (results.isEmpty) return null;
|
||||||
where: 'id = ?',
|
return Customer.fromMap(results.first);
|
||||||
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 を返す)
|
Future<List<Customer>> getCustomers() async {
|
||||||
static Future<Product?> getProductByCode(String code) async {
|
final db = await database;
|
||||||
final result = await instance.query(
|
final results = await db.query('customers');
|
||||||
'products',
|
return results.map((e) => Customer.fromMap(e)).toList();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顧客一覧を取得(非アクティブ除外)
|
Future<int> updateCustomer(Customer customer) async {
|
||||||
static Future<List<Map<String, dynamic>>> getCustomers() async {
|
final db = await database;
|
||||||
final result = await instance.query('customers', where: 'is_inactive = ?', whereArgs: [false]);
|
return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]);
|
||||||
|
|
||||||
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 item;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 製品を挿入(簡素版)
|
Future<int> deleteCustomer(int id) async {
|
||||||
static Future<void> insertProduct(Product product) async {
|
final db = await database;
|
||||||
await instance.insert('products', {
|
return await db.delete('customers', where: 'id = ?', whereArgs: [id]);
|
||||||
'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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 製品を削除(簡素版)
|
// Product API
|
||||||
static Future<void> deleteProduct(int id) async {
|
Future<int> insertProduct(Product product) async {
|
||||||
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
|
final db = await database;
|
||||||
|
return await db.insert('products', product.toMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顧客を挿入(簡素版)
|
Future<Product?> getProduct(int id) async {
|
||||||
static Future<void> insertCustomer(Map<String, dynamic> customer) async {
|
final db = await database;
|
||||||
await instance.insert('customers', {
|
final results = await db.query('products', where: 'id = ?', whereArgs: [id]);
|
||||||
'customer_code': customer['customerCode'],
|
if (results.isEmpty) return null;
|
||||||
'name': customer['name'],
|
return Product.fromMap(results.first);
|
||||||
'address': customer['address'],
|
|
||||||
'phone': customer['phoneNumber'],
|
|
||||||
'email': customer['email'],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顧客を更新(簡素版:削除後再挿入)
|
Future<List<Product>> getProducts() async {
|
||||||
static Future<void> updateCustomer(Map<String, dynamic> customer) async {
|
final db = await database;
|
||||||
await deleteCustomer(customer['id'] ?? 0);
|
final results = await db.query('products');
|
||||||
await insertCustomer(customer);
|
return results.map((e) => Product.fromMap(e)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顧客を削除(簡素版)
|
Future<int> updateProduct(Product product) async {
|
||||||
static Future<void> deleteCustomer(int id) async {
|
final db = await database;
|
||||||
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
|
return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DB をクリア
|
Future<int> deleteProduct(int id) async {
|
||||||
static Future<void> clearDatabase() async {
|
final db = await database;
|
||||||
await instance.delete('products');
|
return await db.delete('products', where: 'id = ?', whereArgs: [id]);
|
||||||
await instance.delete('customers');
|
|
||||||
await instance.delete('sales');
|
|
||||||
await instance.delete('estimates');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// データベースを回復(全削除 + リセット + テーブル再作成)
|
// Sales API
|
||||||
static Future<void> recover() async {
|
Future<int> insertSales(Map<String, dynamic> salesData) async {
|
||||||
try {
|
final db = await database;
|
||||||
final dbPath = Directory.current.path + '/data/db/sales.db';
|
return await db.insert('sales', salesData);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<String> getDbPath() async {
|
Future<List<Map<String, dynamic>>> getSales() async {
|
||||||
return Directory.current.path + '/data/db/sales.db';
|
final db = await database;
|
||||||
|
return await db.query('sales');
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> deleteSales(int id) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.delete('sales', where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate API(単純化)
|
||||||
|
Future<int> insertEstimate(Map<String, dynamic> estimateData) async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.insert('estimates', estimateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,146 +1,125 @@
|
||||||
// Version: 2.1 - マスタ編集フィールド部品(全てのマスタ画面で共通使用)
|
// Version: 1.0 - 汎用マスタ編集フィールド(Flutter 標準)
|
||||||
// ※ 簡素版のため、各マスター画面で独自実装は不要です
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// テキスト入力フィールド(マスタ編集用)
|
/// マスタ編集用の統一 TextField
|
||||||
class MasterTextField extends StatelessWidget {
|
class MasterTextField extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String? hintText;
|
final String? hint;
|
||||||
|
final TextInputType keyboardType;
|
||||||
|
final bool obscureText;
|
||||||
|
final int maxLines;
|
||||||
|
final TextInputAction textInputAction;
|
||||||
|
final FormFieldValidator<String>? validator;
|
||||||
|
final void Function(String)? onChanged;
|
||||||
|
|
||||||
const MasterTextField({
|
const MasterTextField({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
this.hintText,
|
this.hint,
|
||||||
});
|
this.keyboardType = TextInputType.text,
|
||||||
|
this.obscureText = false,
|
||||||
@override
|
this.maxLines = 1,
|
||||||
Widget build(BuildContext context) {
|
this.textInputAction = TextInputAction.next,
|
||||||
return Padding(
|
this.validator,
|
||||||
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: hintText, border: OutlineInputBorder()),
|
|
||||||
),
|
|
||||||
],),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// テキストエリア入力フィールド(マスタ編集用)
|
|
||||||
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? hintText;
|
|
||||||
final bool readOnly;
|
|
||||||
|
|
||||||
const MasterNumberField({
|
|
||||||
super.key,
|
|
||||||
required this.label,
|
|
||||||
required this.controller,
|
|
||||||
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,
|
this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return TextFormField(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
controller: controller,
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
decoration: InputDecoration(
|
||||||
Text(label),
|
labelText: label,
|
||||||
Checkbox(value: checked ?? false, onChanged: onChanged),
|
hintText: hint,
|
||||||
],),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
obscureText: obscureText,
|
||||||
|
maxLines: maxLines,
|
||||||
|
textInputAction: textInputAction,
|
||||||
|
validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation',
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// マスタ編集用の数値入力 TextField
|
||||||
|
class MasterNumberField extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String? hint;
|
||||||
|
final FormFieldValidator<String>? validator;
|
||||||
|
final void Function(String)? onChanged;
|
||||||
|
|
||||||
|
const MasterNumberField({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.controller,
|
||||||
|
this.hint,
|
||||||
|
this.validator,
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -23,9 +23,8 @@ dependencies:
|
||||||
share_plus: ^10.1.2
|
share_plus: ^10.1.2
|
||||||
google_sign_in: ^7.2.0
|
google_sign_in: ^7.2.0
|
||||||
|
|
||||||
# リッチマスター編集用機能(簡易実装)
|
# フォームビルダ - マスタ編集の汎用モジュールで使用
|
||||||
image_picker: ^1.0.7
|
flutter_form_builder: ^9.1.1
|
||||||
qr_flutter: ^4.1.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue