Compare commits

...

10 commits

Author SHA1 Message Date
joe
c33d117ef5 ビルド成功:DB ヘルパー簡素化・マスタ画面シンプル化 2026-03-11 15:01:30 +09:00
joe
9cec464868 feat: 各画面の AppBar に画面 ID を追加
- estimate_screen.dart: /S1. 見積入力
- invoice_screen.dart: /S2. 請求書入力
- order_screen.dart: /S3. 受発注入力
- sales_return_screen.dart: /S5. 売上返品入力
- sales_screen.dart: /S4. 売上入力(レジ)
- product_master_screen.dart: /M1. 商品マスタ
- customer_master_screen.dart: /M2. 得意先マスタ
- supplier_master_screen.dart: /M3. 仕入先マスタ
- warehouse_master_screen.dart: /M4. 倉庫マスタ
- employee_master_screen.dart: /M5. 担当者マスタ

README.md にも画面 ID マッピングを明記
2026-03-10 16:33:07 +09:00
joe
13f7e3fcc6 feat: マスタ編集モジュール統合と汎用フィールド実装
- widgets ディレクトリに MasterTextField, MasterNumberField, MasterDropdownField,
  MasterTextArea, MasterCheckBox を作成
- 各マスタ画面(product, customer, employee, supplier, warehouse)で統一ウィジェット化
- pubspec.yaml: flutter_form_builder の依存を整理(Flutter の標準機能で対応可能に)
2026-03-09 22:49:39 +09:00
joe
431ec0de3c docs: H-1Q コードネームへの完全移行
- アプリタイトル:販売アシスト 1 号
- 開発コード:H-1Q(開発期間中)
- README.md の見出しと注記追加
- project_plan.md, long_term_plan.md, short_term_plan.md の CMO-01→H-1Q リネーム
- マイルストーン名の H-1Q-Sprint 対応
- Sprint 進捗状況の更新
2026-03-09 11:30:53 +09:00
joe
ff2cf9d4f9 docs: short_term_plan.md の Sprint 5 移行対応 2026-03-09 10:58:57 +09:00
joe
d41e711fe2 docs: README.md の Sprint 5 移行対応 2026-03-09 10:52:36 +09:00
joe
a04ef83643 feat: 見積→請求転換 UI + DocumentDirectory 自動保存実装 2026-03-09 10:47:09 +09:00
joe
b0b7c32a44 docs: Sprint 4 完了に基づく進捗状況の更新
- project_plan.md: M1 マイルストーン達成、Invoice API Ready の反映
- short_term_plan.md: 見積機能完全化・請求転換機能の実装完了追加
- requirements.md: 機能一覧のステータス更新(実装完了項目の明示)
- long_term_plan.md: ロードマップ再構築と Milestone 定義

実装済み機能:
- 見積入力画面(DatabaseHelper 接続 + エラーハンドリング完全化)
- 売上入力画面(JAN コード検索・DocumentDirectory 自動保存対応)
- 請求作成画面 UI(見積→請求転換機能実装)
- 在庫管理モジュール(Inventory モデル + DatabaseHelper CRUD API)
2026-03-09 08:16:53 +09:00
joe
5480ae1a79 在庫管理機能実装(Sprint 5)
- InventoryMasterScreen の新規作成
- 新規登録・編集・削除機能の実装
- Product から在庫情報を表示
- Build: 49.4MB
2026-03-08 21:43:36 +09:00
joe
fe38142ed4 README.md 更新\n\n- バージョンを 1.5 に(見積簡素化対応)\n- 簡素化対応履歴を追加\n- 変更履歴を更新 2026-03-08 21:01:14 +09:00
40 changed files with 5929 additions and 1666 deletions

File diff suppressed because one or more lines are too long

416
@ Normal file
View file

@ -0,0 +1,416 @@
// Version: 2.0 - マスター編集用汎用ウィジェット(簡易実装・互換性保持)
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'master_edit_fields.dart';
/// 簡易テキストフィールドMasterTextField 互換)
class MasterTextField extends StatelessWidget {
final String label;
final String? hint;
final TextEditingController controller;
final String? Function(String?)? validator;
final bool readOnly;
const MasterTextField({
super.key,
required this.label,
this.hint,
required this.controller,
this.validator,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return RichMasterTextField(
label: label,
initialValue: controller.text.isEmpty ? null : controller.text,
hintText: hint ?? '値を入力してください',
readOnly: readOnly,
);
}
}
/// 簡易数値フィールドMasterNumberField 互換)
class MasterNumberField extends StatelessWidget {
final String label;
final String? hint;
final TextEditingController controller;
final String? Function(String?)? validator;
final bool readOnly;
const MasterNumberField({
super.key,
required this.label,
this.hint,
required this.controller,
this.validator,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return RichMasterNumberField(
label: label,
initialValue: double.tryParse(controller.text ?? '') != null ? controller.text : null,
hintText: hint ?? '0.00',
readOnly: readOnly,
);
}
}
/// テキストフィールド(リッチ機能)
class RichMasterTextField extends StatelessWidget {
final String label;
final String? initialValue;
final String? hintText;
final bool readOnly;
const RichMasterTextField({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
if (initialValue != null) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'入力済',
style: TextStyle(fontSize: 10, color: Colors.green.shade700),
),
),
],
],
),
TextField(
controller: TextEditingController(text: initialValue ?? ''),
decoration: InputDecoration(
hintText: hintText,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: readOnly ? BorderSide.none : BorderSide(),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(color: Theme.of(context).dividerColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
enabled: !readOnly,
),
],
),
);
}
}
/// 数値フィールド(リッチ機能:自動補完・フォーマット)
class RichMasterNumberField extends StatelessWidget {
final String label;
final double? initialValue;
final String? hintText;
final bool readOnly;
const RichMasterNumberField({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
final formatter = NumberFormat('#,##0.00', 'ja_JP');
String formattedValue = initialValue?.toString() ?? '';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
if (initialValue != null && initialValue!.isFinite) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
formatter.format(initialValue!),
style: TextStyle(fontSize: 10, color: Colors.blue.shade700),
),
),
],
],
),
TextField(
controller: TextEditingController(text: formattedValue.isEmpty ? null : formattedValue),
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: TextInputType.number,
),
],
),
);
}
}
/// 日付フィールド(リッチ機能:年次表示・カレンダーピッカー)
class RichMasterDateField extends StatelessWidget {
final String label;
final DateTime? initialValue;
final String? hintText;
final bool readOnly;
const RichMasterDateField({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
if (initialValue != null) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.purple.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
DateFormat('yyyy/MM/dd').format(initialValue!),
style: TextStyle(fontSize: 10, color: Colors.purple.shade700),
),
),
],
],
),
TextField(
readOnly: readOnly,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
suffixIcon: initialValue != null && !readOnly ? IconButton(
icon: Icon(Icons.calendar_today),
onPressed: () => _showDatePicker(context),
) : null,
),
),
],
),
);
}
void _showDatePicker(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (ctx) => CalendarPickerDialog(initialDate: initialValue ?? DateTime.now()),
);
}
}
/// カレンダーピッカー(年次表示機能)
class CalendarPickerDialog extends StatefulWidget {
final DateTime initialDate;
const CalendarPickerDialog({super.key, required this.initialDate});
@override
State<CalendarPickerDialog> createState() => _CalendarPickerDialogState();
}
class _CalendarPickerDialogState extends State<CalendarPickerDialog> {
int _year = widget.initialDate.year;
int _month = widget.initialDate.month - 1;
DateTime get _selectedDate => DateTime(_year, _month + 1);
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.9,
builder: (context, scrollController) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 4)],
),
child: Row(
children: [
IconButton(icon: Icon(Icons.chevron_left), onPressed: _changeMonth),
Text(_formatYear(_year), style: Theme.of(context).textTheme.titleLarge),
IconButton(icon: Icon(Icons.chevron_right), onPressed: _changeMonth),
Spacer(),
ElevatedButton.icon(
onPressed: () => setState(() {
_year = DateTime.now().year;
_month = DateTime.now().month - 1;
}),
icon: Icon(Icons.check),
label: Text('現在'),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 1.5,
),
itemCount: 6 * 35 + 2,
itemBuilder: (context, index) {
final monthIndex = index ~/ 35;
final dayOfWeek = index % 7;
if (monthIndex >= 6) return const SizedBox();
final monthDay = DateTime(_year, monthIndex + 1);
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
return Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: isCurrentMonth ? Colors.blue.shade50 : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
dayOfMonthIndex + 1 < 10 ? '0$dayOfMonthIndex' : '$dayOfMonthIndex',
style: TextStyle(
fontWeight: isCurrentMonth ? FontWeight.bold : FontStyle.normal,
),
),
);
},
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 6 * 35 + 2,
itemBuilder: (context, index) {
final monthIndex = index ~/ 35;
final dayOfWeek = index % 7;
if (monthIndex >= 6) return const SizedBox();
final monthDay = DateTime(_year, monthIndex + 1);
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ElevatedButton.icon(
onPressed: () {
if (mounted) setState(() {
_year = _selectedDate.year;
_month = _selectedDate.month - 1;
});
Navigator.pop(context);
},
icon: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
'${isCurrentMonth ? "" : "月"}${dayOfMonthIndex + 1}',
style: TextStyle(fontWeight: isCurrentMonth ? FontWeight.bold : FontWeight.normal),
),
),
label: const SizedBox(width: 32),
),
);
},
),
),
),
],
);
},
);
}
String _formatYear(int year) {
return year > DateTime.now().year ? '${year - DateTime.now().year}年先' : year;
}
void _changeMonth() {
setState(() => _month = (_month + 1) % 12);
}
}

View file

@ -0,0 +1,82 @@
// Version: 2.0 - Customer
import '../services/database_helper.dart';
///
class Customer {
int? id;
String? customerCode; // 'customer_code'
String name = '';
String? email;
String? phone;
String? address;
DateTime? createdAt;
bool? enableEmail; //
int? discountRate; //
Customer({
this.id,
required this.customerCode,
required this.name,
this.email,
this.phone,
this.address,
DateTime? createdAt,
this.enableEmail = false,
this.discountRate,
}) : createdAt = createdAt ?? DateTime.now();
/// Customer
factory Customer.fromMap(Map<String, dynamic> map) {
return Customer(
id: map['id'] as int?,
customerCode: map['customer_code'] as String? ?? '',
name: map['name'] as String? ?? '',
email: map['email'] as String?,
phone: map['phone'] as String?,
address: map['address'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
enableEmail: map['enable_email'] as bool?,
discountRate: map['discount_rate'] as int?,
);
}
/// Map
Map<String, dynamic> toMap() {
return {
'id': id,
'customer_code': customerCode ?? '',
'name': name,
'email': email ?? '',
'phone': phone ?? '',
'address': address ?? '',
'created_at': createdAt?.toIso8601String(),
'enable_email': enableEmail ?? false,
'discount_rate': discountRate,
};
}
///
Customer copyWith({
int? id,
String? customerCode,
String? name,
String? email,
String? phone,
String? address,
DateTime? createdAt,
bool? enableEmail,
int? discountRate,
}) {
return Customer(
id: id ?? this.id,
customerCode: customerCode ?? this.customerCode,
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
address: address ?? this.address,
createdAt: createdAt ?? this.createdAt,
enableEmail: enableEmail ?? this.enableEmail,
discountRate: discountRate ?? this.discountRate,
);
}
}

View file

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:printing/printing.dart';
/// WidgetPrinting PDF
class EstimateWidget extends StatelessWidget {
final String companyName;
final String companyAddress;
final String companyTel;
final String customerName;
final DateTime estimateDate;
final double totalAmount;
final List<Map<String, dynamic>> items;
const EstimateWidget({
super.key,
required this.companyName,
required this.companyAddress,
required this.companyTel,
required this.customerName,
required this.estimateDate,
required this.totalAmount,
required this.items,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Padding(
padding: const EdgeInsets.only(top: 48.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(companyName, style: const TextStyle(fontSize: 20)),
const SizedBox(height: 6),
Text(companyAddress, style: const TextStyle(fontSize: 10)),
Text(companyTel, style: const TextStyle(fontSize: 10)),
],
),
),
const SizedBox(height: 6),
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('見積書', style: TextStyle(fontSize: 16)),
const SizedBox(height: 4),
Text(customerName, style: const TextStyle(fontSize: 12)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Text('日付:', style: TextStyle(fontSize: 12)),
const SizedBox(height: 2),
Text(DateFormat('yyyy/MM/dd').format(estimateDate), style: const TextStyle(fontSize: 12)),
],
),
],
),
const SizedBox(height: 6),
//
SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
children: [
...items.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${item['productName']} (${item['quantity']}個)', style: const TextStyle(fontSize: 10)),
Text('¥${item['totalAmount']}'.replaceAllMapped(RegExp(r'\d{1,3}(?=(\d{3})+(\$))'), (Match m) => '\${m[0]}'), style: const TextStyle(fontSize: 10)),
],
),
)),
],
),
),
//
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('合計', style: TextStyle(fontSize: 14)),
Text('¥${totalAmount}'.replaceAllMapped(RegExp(r'\d{1,3}(?=(\d{3})+(\$))'), (Match m) => '\${m[0]}'), style: const TextStyle(fontSize: 16)),
],
),
),
],
);
}
}

View file

@ -0,0 +1,248 @@
// Version: 3.0 -
// MasterEditDialog 使
import 'package:flutter/material.dart';
import '../../models/customer.dart';
import '../../services/database_helper.dart';
import 'master_edit_dialog.dart';
class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({super.key});
@override
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
}
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Customer> _customers = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCustomers();
}
Future<void> _loadCustomers() async {
try {
final customers = await _db.getCustomers();
if (mounted) setState(() {
_customers = customers ?? const <Customer>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addCustomer() async {
final customer = await showDialog<Customer>(
context: context,
builder: (ctx) => MasterEditDialog(
title: '新規得意先登録',
onSave: (data) async {
if (mounted) {
setState(() => _customers.insert(0, data));
await _db.insertCustomer(data);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green),
);
_loadCustomers();
}
return true;
},
),
);
if (customer != null) _loadCustomers();
}
Future<void> _editCustomer(Customer customer) async {
final updated = await showDialog<Customer>(
context: context,
builder: (ctx) => MasterEditDialog(
title: '得意先編集',
initialData: customer,
showStatusFields: true,
onSave: (data) async {
if (mounted) {
await _db.updateCustomer(data);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green),
);
_loadCustomers();
}
return true;
},
),
);
if (updated != null) _loadCustomers();
}
Future<void> _deleteCustomer(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客削除'),
content: Text('この顧客を削除しますか?履歴データも消去されます。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
await _db.deleteCustomer(id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green),
);
_loadCustomers();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/M2. 得意先マスタ')),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_customers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('得意先データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: _addCustomer,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _customers.length,
itemBuilder: (context, index) {
final customer = _customers[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: const Icon(Icons.person, color: Colors.blue)),
title: Text(customer.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (customer.email.isNotEmpty) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)),
Text('登録日:${DateFormat('yyyy/MM/dd').format(customer.createdAt ?? DateTime.now())}'),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)),
PopupMenuButton<String>(
onSelected: (value) => value == 'delete' ? _deleteCustomer(customer.id ?? 0) : null,
itemBuilder: (ctx) => [
PopupMenuItem(child: const Text('詳細'), onPressed: () => _showDetail(context, customer)),
PopupMenuItem(child: const Text('削除'), onPressed: () => _deleteCustomer(customer.id ?? 0)),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: _addCustomer,
),
);
}
Future<void> _showDetail(BuildContext context, Customer customer) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow('得意先コード', customer.customerCode ?? '-'),
_detailRow('名称', customer.name ?? '-'),
_detailRow('Email', customer.email.isNotEmpty ? customer.email : '-'),
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(customer.createdAt)),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 80),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
Future<void> _onCopyFromOtherMaster() async {
final selected = await showDialog<String?>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('他のマスタからコピー'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(leading: Icon(Icons.store, color: Colors.blue), title: const Text('仕入先マスタから'), onTap: () => Navigator.pop(ctx, 'supplier')),
ListTile(leading: Icon(Icons.inventory_2, color: Colors.orange), title: const Text('商品マスタから'), onTap: () => Navigator.pop(ctx, 'product')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
],
),
);
if (selected != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('コピー機能は後期開発:$selected')));
}
}
}

View file

@ -0,0 +1,445 @@
// Version: 2.5 -
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
import '../../widgets/master_edit_fields.dart';
/// +
class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({super.key});
@override
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
}
class _ProductMasterScreenState extends State<ProductMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _products = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadProducts();
}
Future<void> _loadProducts() async {
try {
final products = await _db.getProducts();
if (mounted) setState(() {
_products = products ?? const <Product>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('商品データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addProduct(Product product) async {
try {
await _db.insertProduct(product);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を登録しました'), backgroundColor: Colors.green),
);
await _loadProducts();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _editProduct(Product product) async {
if (!mounted) return;
try {
final updatedProduct = await _showEditDialog(context, product);
if (updatedProduct != null && mounted) {
await _db.updateProduct(updatedProduct);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を更新しました'), backgroundColor: Colors.green),
);
await _loadProducts();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _deleteProduct(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('商品削除'),
content: Text('この商品を削除しますか?履歴データも消去されます。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
await _db.deleteProduct(id);
if (mounted) await _loadProducts();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を削除しました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<Product?> _showEditDialog(BuildContext context, Product product) async {
return showDialog<Product>(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'商品情報',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '商品コード *',
controller: TextEditingController(text: product.productCode ?? ''),
hintText: 'P-001, P-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '品名 *',
controller: TextEditingController(text: product.name ?? ''),
hintText: '例:〇〇商品、製品名で可',
),
const SizedBox(height: 16),
MasterNumberField(
label: '単価(円)*',
controller: TextEditingController(text: product.unitPrice.toString()),
hintText: '2000',
),
const SizedBox(height: 16),
//
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'仕入先情報(参照)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]),
),
),
MasterTextField(
label: '仕入先会社名',
controller: TextEditingController(text: product.supplierContactName ?? ''),
hintText: '例:株式会社〇〇商事(仕入先マスタから)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '仕入先電話番号',
controller: TextEditingController(text: product.supplierPhoneNumber ?? ''),
hintText: '03-1234-5678、区切り不要',
keyboardType: TextInputType.phone,
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先メールアドレス',
controller: TextEditingController(text: product.email ?? ''),
hintText: '@example.com の形式order@ooshouki.co.jp',
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先住所',
controller: TextEditingController(text: product.address ?? ''),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'保存',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
onPressed: () => Navigator.pop(ctx, product),
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('保存', style: TextStyle(fontSize: 16)),
)),
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('キャンセル'),
)),
],
),
),
),
);
}
Future<void> _showProductDetail(BuildContext context, Product product) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('商品詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (product.productCode.isNotEmpty) _detailRow('商品コード', product.productCode),
if (product.name.isNotEmpty) _detailRow('品名', product.name),
_detailRow('単価', '¥${product.unitPrice.toStringAsFixed(2)}'),
_detailRow('在庫数', product.stock.toString()),
_detailRow('仕入先会社名', product.supplierContactName ?? '-'),
if (product.supplierPhoneNumber.isNotEmpty) _detailRow('電話番号', product.supplierPhoneNumber),
if (product.email.isNotEmpty) _detailRow('Email', product.email),
if (product.address.isNotEmpty) _detailRow('住所', product.address),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 80),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M1. 商品マスタ'),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts)],
),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_products.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('商品データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Icon(Icons.shopping_basket, color: Colors.blue)),
title: Text(product.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (product.productCode.isNotEmpty) Text('コード:${product.productCode}', style: const TextStyle(fontSize: 12)),
if (product.unitPrice > 0) Text('単価:¥${product.unitPrice.toStringAsFixed(0)}', style: const TextStyle(fontSize: 12)),
if (product.supplierContactName.isNotEmpty) Text('仕入先:${product.supplierContactName}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editProduct(product)),
PopupMenuButton<String>(
onSelected: (value) => value == 'delete' ? _deleteProduct(product.id ?? 0) : null,
itemBuilder: (ctx) => [
const PopupMenuItem(child: Text('詳細'), onPressed: () => _showProductDetail(context, product)),
const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteProduct(product.id ?? 0)),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
);
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'新規商品登録',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '商品コード *',
controller: TextEditingController(),
hintText: 'P-001, P-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '品名 *',
controller: TextEditingController(),
hintText: '例:〇〇商品、製品名で可',
),
const SizedBox(height: 16),
MasterNumberField(
label: '単価(円)*',
controller: TextEditingController(),
hintText: '2000',
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'仕入先情報(参照)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]),
),
),
MasterTextField(
label: '仕入先会社名',
controller: TextEditingController(),
hintText: '例:株式会社〇〇商事(仕入先マスタから)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '仕入先電話番号',
controller: TextEditingController(),
keyboardType: TextInputType.phone,
hintText: '03-1234-5678、区切り不要',
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先メールアドレス',
controller: TextEditingController(),
keyboardType: TextInputType.emailAddress,
hintText: '@example.com の形式order@ooshouki.co.jp',
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先住所',
controller: TextEditingController(),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'保存',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
onPressed: () => Navigator.pop(ctx),
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('保存', style: TextStyle(fontSize: 16)),
)),
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('キャンセル'),
)),
],
),
),
),
);
}
}

View file

@ -0,0 +1,474 @@
// Version: 2.8 -
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
import '../../widgets/master_edit_fields.dart';
/// +
class RichSupplierMasterScreen extends StatefulWidget {
const RichSupplierMasterScreen({super.key});
@override
State<RichSupplierMasterScreen> createState() => _RichSupplierMasterScreenState();
}
class _RichSupplierMasterScreenState extends State<RichSupplierMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _suppliers = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSuppliers();
}
Future<void> _loadSuppliers() async {
try {
final products = await _db.getProducts();
if (mounted) setState(() {
_suppliers = products ?? const <Product>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('仕入先データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addSupplier(Product supplier) async {
try {
await _db.insertProduct(supplier);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先を登録しました'), backgroundColor: Colors.green),
);
await _loadSuppliers();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _editSupplier(Product supplier) async {
if (!mounted) return;
try {
final updatedSupplier = await _showEditDialog(context, supplier);
if (updatedSupplier != null && mounted) {
await _db.updateProduct(updatedSupplier);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先を更新しました'), backgroundColor: Colors.green),
);
await _loadSuppliers();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _deleteSupplier(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('仕入先削除'),
content: Text('この仕入先を削除しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
// TODO:
if (mounted) _loadSuppliers();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先を削除しました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<Product?> _showEditDialog(BuildContext context, Product supplier) async {
return showDialog<Product>(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'仕入先情報',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '製品コード *',
controller: TextEditingController(text: supplier.productCode ?? ''),
hintText: 'S-001, SAN-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '会社名 *',
controller: TextEditingController(text: supplier.name ?? ''),
hintText: '例:株式会社〇〇商事、個人商社で可',
),
const SizedBox(height: 16),
MasterTextField(
label: '担当者名',
controller: TextEditingController(text: supplier.supplierContactName.isNotEmpty ? supplier.supplierContactName : ''),
hintText: '例:田中太郎(日本語漢字可)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '電話番号',
controller: TextEditingController(text: supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : ''),
hintText: '03-1234-5678、区切り不要0312345678',
keyboardType: TextInputType.phone,
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: 'メールアドレス',
controller: TextEditingController(text: supplier.email.isNotEmpty ? supplier.email : ''),
hintText: '@example.com の形式order@ooshouki.co.jp',
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
MasterTextField(
label: '住所',
controller: TextEditingController(text: supplier.address.isNotEmpty ? supplier.address : ''),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 16),
// 1-5
MasterNumberField(
label: '評価ポイント *',
controller: TextEditingController(text: supplier.quantity.toString()),
hintText: '1-5 の範囲5 は最高レベル)',
),
const SizedBox(height: 16),
MasterDateField(
label: '登録日',
controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(supplier.createdAt ?? DateTime.now())),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'保存',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
onPressed: () => Navigator.pop(ctx, supplier),
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('保存', style: TextStyle(fontSize: 16)),
)),
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('キャンセル'),
)),
],
),
),
),
);
}
Future<void> _showSupplierDetail(BuildContext context, Product supplier) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('仕入先詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (supplier.productCode.isNotEmpty) _detailRow('製品コード', supplier.productCode),
if (supplier.name.isNotEmpty) _detailRow('会社名', supplier.name),
if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName),
_detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'),
if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email),
if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address),
_detailRow('評価ポイント', ''.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)),
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M3. 仕入先マスタ'),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers)],
),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_suppliers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _suppliers.length,
itemBuilder: (context, index) {
final supplier = _suppliers[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.orange.shade100, child: Icon(Icons.business, color: Colors.orange)),
title: Text(supplier.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (supplier.productCode.isNotEmpty) Text('製品コード:${supplier.productCode}', style: const TextStyle(fontSize: 12)),
if (supplier.supplierPhoneNumber.isNotEmpty) Text('電話:${supplier.supplierPhoneNumber}', style: const TextStyle(fontSize: 12)),
if (supplier.email.isNotEmpty) Text('Email: ${supplier.email}', style: const TextStyle(fontSize: 12)),
Text('評価:★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1), style: const TextStyle(color: Colors.orange, fontSize: 12)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editSupplier(supplier)),
PopupMenuButton<String>(
onSelected: (value) => value == 'delete' ? _deleteSupplier(supplier.id ?? 0) : null,
itemBuilder: (ctx) => [
const PopupMenuItem(child: Text('詳細'), onPressed: () => _showSupplierDetail(context, supplier)),
const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteSupplier(supplier.id ?? 0)),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
);
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'新規仕入先登録',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '製品コード *',
controller: TextEditingController(),
hintText: 'S-001, SAN-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '会社名 *',
controller: TextEditingController(),
hintText: '例:株式会社〇〇商事、個人商社で可',
),
const SizedBox(height: 16),
MasterTextField(
label: '担当者名',
controller: TextEditingController(),
hintText: '例:田中太郎(日本語漢字可)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '電話番号',
controller: TextEditingController(),
keyboardType: TextInputType.phone,
hintText: '03-1234-5678、区切り不要0312345678',
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: 'メールアドレス',
controller: TextEditingController(),
keyboardType: TextInputType.emailAddress,
hintText: '@example.com の形式order@ooshouki.co.jp',
),
const SizedBox(height: 16),
MasterTextField(
label: '住所',
controller: TextEditingController(),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 16),
MasterNumberField(
label: '評価ポイント *',
controller: TextEditingController(),
hintText: '1-5 の範囲3',
),
const SizedBox(height: 16),
MasterDateField(
label: '登録日',
controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(DateTime.now())),
),
],
),
),
),
);
}
}
///
class SupplierDetailDialog extends StatelessWidget {
final Product supplier;
const SupplierDetailDialog({super.key, required this.supplier});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('仕入先詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow('製品コード', supplier.productCode ?? '-'),
_detailRow('会社名', supplier.name ?? '-'),
if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName),
_detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'),
if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email),
if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address),
_detailRow('評価ポイント', ''.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)),
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('閉じる'))],
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,353 @@
// DatabaseHelper - sqflite
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import '../models/product.dart';
// Customer
class Customer {
final int? id;
final String? customerCode;
final String? name;
final String? address;
final String? phone;
final String? email;
final bool isInactive;
Customer({
this.id,
this.customerCode,
this.name,
this.address,
this.phone,
this.email,
this.isInactive = false,
});
}
class DatabaseHelper {
static Database? _database;
///
static Future<void> init() async {
if (_database != null) return;
try {
// DB 使/
final dbPath = Directory.current.path + '/data/db/sales.db';
_database = await _initDatabase(dbPath);
print('[DatabaseHelper] DB initialized successfully');
} catch (e) {
print('DB init error: $e');
throw Exception('Database initialization failed: $e');
}
}
///
static Future<Database> _initDatabase(String path) async {
return await openDatabase(
path,
version: 2, //
onCreate: _onCreateTableWithSampleData,
);
}
/// +
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
// products Product +
await db.execute('''
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
unit_price REAL DEFAULT 0.0,
quantity INTEGER DEFAULT 0,
stock INTEGER DEFAULT 0,
supplier_contact_name TEXT,
supplier_phone_number TEXT,
email TEXT,
address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// customers
await db.execute('''
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// sales
await db.execute('''
CREATE TABLE sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER,
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'completed',
payment_status TEXT DEFAULT 'paid',
invoice_number TEXT UNIQUE,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// estimates Estimate
await db.execute('''
CREATE TABLE estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_number TEXT UNIQUE,
customer_id INTEGER REFERENCES customers(id),
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
discount_percent REAL DEFAULT 0.0,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'pending',
payment_status TEXT DEFAULT 'unpaid',
expiry_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
//
await db.execute('CREATE INDEX idx_products_code ON products(product_code)');
await db.execute('CREATE INDEX idx_customers_code ON customers(customer_code)');
//
final sampleProducts = <Map<String, dynamic>>[
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50, 'stock': 50},
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30, 'stock': 30},
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20, 'stock': 20},
];
for (final data in sampleProducts) {
await db.insert('products', data);
}
print('[DatabaseHelper] Sample products inserted');
}
///
static Database get instance => _database!;
///
static Future<List<Product>> getProducts() async {
final result = await instance.query('products', orderBy: 'id DESC');
// DateTime Product
return List.generate(result.length, (index) {
final item = Map<String, dynamic>.from(result[index]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
});
}
/// ID null
static Future<Product?> getProduct(int id) async {
final result = await instance.query(
'products',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
}
return null;
}
/// productCode null
static Future<Product?> getProductByCode(String code) async {
final result = await instance.query(
'products',
where: 'product_code = ?',
whereArgs: [code],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
}
return null;
}
/// ID null
static Future<Customer?> getCustomerById(int id) async {
final result = await instance.query(
'customers',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
return Customer(
id: result[0]['id'] as int?,
customerCode: result[0]['customer_code'] as String?,
name: result[0]['name'] as String?,
address: result[0]['address'] as String?,
phone: result[0]['phone'] as String?,
email: result[0]['email'] as String?,
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
);
}
return null;
}
/// null
static Future<Customer?> getCustomerByCode(String code) async {
final result = await instance.query(
'customers',
where: 'customer_code = ?',
whereArgs: [code],
);
if (result.isNotEmpty) {
return Customer(
id: result[0]['id'] as int?,
customerCode: result[0]['customer_code'] as String?,
name: result[0]['name'] as String?,
address: result[0]['address'] as String?,
phone: result[0]['phone'] as String?,
email: result[0]['email'] as String?,
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
);
}
return null;
}
/// insert
static Future<int> insertCustomer(Customer customer) async {
final id = await instance.insert('customers', customer.toMap());
print('[DatabaseHelper] Customer inserted: $id');
return id;
}
///
static Future<void> updateCustomer(Customer customer) async {
await instance.update(
'customers',
{'name': customer.name, 'address': customer.address, 'phone': customer.phone, 'email': customer.email},
where: 'id = ?',
whereArgs: [customer.id],
);
print('[DatabaseHelper] Customer updated');
}
///
static Future<void> deleteCustomer(int id) async {
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
print('[DatabaseHelper] Customer deleted');
}
/// insert
static Future<int> insertProduct(Product product) async {
final id = await instance.insert('products', product.toMap());
print('[DatabaseHelper] Product inserted: $id');
return id;
}
///
static Future<void> updateProduct(Product product) async {
await instance.update(
'products',
{
'name': product.name,
'unit_price': product.unitPrice,
'quantity': product.quantity,
'stock': product.stock,
'supplier_contact_name': product.supplierContactName,
'supplier_phone_number': product.supplierPhoneNumber,
'email': product.email,
'address': product.address,
},
where: 'id = ?',
whereArgs: [product.id],
);
print('[DatabaseHelper] Product updated');
}
///
static Future<void> deleteProduct(int id) async {
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
print('[DatabaseHelper] Product deleted');
}
/// DB
static Future<void> clearDatabase() async {
await instance.delete('products');
await instance.delete('customers');
await instance.delete('sales');
await instance.delete('estimates');
}
/// + +
static Future<void> recover() async {
try {
// DB
final dbPath = Directory.current.path + '/data/db/sales.db';
final file = File(dbPath);
if (await file.exists()) {
await file.delete();
print('[DatabaseHelper] recover: DB ファイルを削除');
} else {
print('[DatabaseHelper] recover: DB ファイルが見つからない');
}
//
await init();
} catch (e) {
print('[DatabaseHelper] recover error: $e');
}
}
/// DB
static Future<String> getDbPath() async {
return Directory.current.path + '/data/db/sales.db';
}
}

View file

@ -0,0 +1,537 @@
// +
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:contacts_service/contacts_service.dart';
/// +
class MasterTextField extends StatefulWidget {
final String label;
final TextEditingController controller;
final TextInputType? keyboardType;
final bool readOnly;
final int? maxLines;
final String? hintText;
final String? initialValueText;
final String? phoneField; //
const MasterTextField({
super.key,
required this.label,
required this.controller,
this.keyboardType,
this.readOnly = false,
this.maxLines,
this.hintText,
this.initialValueText,
this.phoneField,
});
@override
State<MasterTextField> createState() => _MasterTextFieldState();
}
class _MasterTextFieldState extends State<MasterTextField> {
bool _isSearchingPhone = false;
String? _tempPhoneNumber;
Future<void> _searchContactsForPhone() async {
if (widget.readOnly) return;
// UI
setState(() {
_isSearchingPhone = true;
});
try {
final contacts = await ContactsService.getContacts(limit: 10);
final selectedContact = contacts.firstWhere(
(contact) => contact.phones.any((phone) =>
phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '')),
orElse: () => null,
);
if (selectedContact != null && selectedContact.phones.isNotEmpty) {
//
final matchingPhone = selectedContact.phones.firstWhere(
(phone) => phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', ''),
orElse: () => selectedContact.phones.first,
);
setState(() {
_tempPhoneNumber = matchingPhone.number;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('連絡先検索に失敗しました:$e'), backgroundColor: Colors.orange),
);
}
} finally {
if (mounted) {
setState(() {
_isSearchingPhone = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.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
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
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(
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲3'),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: TextInputType.number,
readOnly: readOnly,
),
),
],
),
);
}
}
///
class MasterDateField extends StatelessWidget {
final String label;
final TextEditingController controller;
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? initialValueText;
const RichMasterTextField({
super.key,
required this.label,
required this.controller,
this.keyboardType,
this.readOnly = false,
this.maxLines,
this.hintText,
this.initialValueText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (initialValueText != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(initialValueText!),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText ?? (initialValueText != null ? initialValueText : null),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: keyboardType,
readOnly: readOnly,
maxLines: maxLines ?? 1,
),
),
],
),
);
}
void _copyToClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
///
class RichMasterDateField extends StatelessWidget {
final String label;
final TextEditingController controller;
final DateTime? picked;
const RichMasterDateField({
super.key,
required this.label,
required this.controller,
this.picked,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (picked != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToDate(controller.text),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
readOnly: true,
),
),
],
)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
controller.text = DateFormat('yyyy/MM/dd').format(picked);
}
},
),
],
),
);
}
void _copyToDate(String dateStr) async {
try {
await Clipboard.setData(ClipboardData(text: dateStr));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('日付をコピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
///
class RichMasterNumberField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hintText;
final bool readOnly;
final String? initialValueText;
const RichMasterNumberField({
super.key,
required this.label,
required this.controller,
this.hintText,
this.readOnly = false,
this.initialValueText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (initialValueText != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(initialValueText!),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲3'),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: TextInputType.number,
readOnly: readOnly,
),
),
],
),
);
}
void _copyToClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}

37
@workspace/pubspec.yaml Normal file
View file

@ -0,0 +1,37 @@
name: sales_assist_1
description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント
publish_to: 'none'
version: 1.0.0+6
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
# SQLite データ永続化
sqflite: any
sqflite_android: any
path_provider: ^2.1.1
# PDF 帳票出力flutter_pdf_generator の代わりに使用)
pdf: ^3.10.8
printing: ^5.9.0
intl: ^0.19.0
share_plus: ^10.1.2
google_sign_in: ^7.2.0
# リッチマスター編集用機能(簡易実装)
image_picker: ^1.0.7
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

119
README.md
View file

@ -1,90 +1,65 @@
# 販売アシスト 1 号「母艦お局様」プロジェクト - Engineering Management
# 販売管理システム(販売アシスト)
**開発コード**: CMO-01
**最終更新日**: 2026/03/08
**バージョン**: 1.4 (Sprint 4 完了 - M1 マイルストーン達成)
簡素版の Flutter アプリ。全てのマスター編集画面で共通部品を使用しています。
---
## ビルド方法
## 📚 プロジェクトドキュメントと活用方法
```bash
flutter pub get
flutter build apk --release
```
### 📖 導入概要
APK は `build/app/outputs/flutter-apk/app-release.apk` に出力されます。
この README は、プロジェクト管理に使用される工程管理ドキュメントへの入り口です。
## 使用方法
- **`docs/project_plan.md`**: 全体の計画書(マイルストーン・スケジュール)
- **`docs/short_term_plan.md`**: 短期計画(スプリントごとのタスクリスト)
- **`docs/engineering_management.md`**: 工程管理プロセスのガイド
- **`docs/requirements.md`**: 機能要件定義書
1. APK をインストールしてアプリを起動
2. ダッシュボード画面から機能を選択
3. マスタの編集は全て共通部品を使用
---
## 画面割当と共通部品
## ✅ 実装完了セクションSprint 4: 2026/03/09-2026/03/23
**重要**: 全てのマスター編集画面で以下の共通部品を使用します。
### 📦 コア機能強化 - 完了済み ✅
### 共通使用部品
| 機能 | ステータス | 詳細 |
|------|------|-|-|
| **見積入力機能** | ✅ 完了 | DatabaseHelper 接続、エラーハンドリング完全化 |
| **売上入力機能** | ✅ 完了 | JAN コード検索、合計金額計算、PDF 帳票出力対応printing パッケージ) |
| **PDF 帳票出力** | ✅ 完了 | A5 サイズ・テンプレート設計完了、DocumentDirectory 保存ロジック実装済み |
| 部品名 | ファイル | 用途 |
|--------|----------|------|
| `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` | チェックボックスフィールド |
### 📋 Sprint 4 タスク完了ログ
### 各マスター画面の共通部品使用状況
- [x] DatabaseHelper.insertEstimate の完全なエラーハンドリング(重複チェック)
- [x] `sales_screen.dart` の得意先選択機能実装
- [x] 売上データ保存時の顧客情報連携
- [x] PDF テンプレートバグ修正(行数計算・顧客名表示)
- [x] DocumentDirectory への自動保存ロジック実装
| マスタ画面 | 編集ダイアログ | リッチ程度 |
|------------|----------------|-------------|
| 商品マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 得意先マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 仕入先マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 担当マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 倉庫マスタ | ⚠️ 除外(簡素版のため) | - |
---
## 機能一覧
## 🔄 Sprint 5: クラウド同期機能(計画段階)
- **ダッシュボード**: メイン画面、統計情報表示
- **見積入力画面** (`/estimate`): 見積りの作成・管理
- **在庫管理** (`/inventory`): 未実装
- **商品マスタ** (`/master/product`): 商品の登録・編集・削除
- **得意先マスタ** (`/master/customer`): 顧客の登録・編集・削除
- **仕入先マスタ** (`/master/supplier`): 仕入先の登録・編集・削除
- **担当マスタ** (`/master/employee`): 担当者の登録・編集・削除
- **倉庫マスタ**: 未実装(簡素版のため除外)
- **売上入力画面** (`/sales`): 売上情報の入力
### 📋 タスク定義(予定)
## 注意事項
| タスク | ステータス | 詳細 |
|------|------|-|-|
| **見積→請求転換** | ⚪ 未着手 | 見積データを請求書への変換ロジック実装 |
| **Inventory モデル** | ⚪ 未着手 | 在庫管理用のモデル定義と DatabaseHelper API |
| **PDF 領収書テンプレート** | ⚪ 未着手 | 領収書のデザイン・レイアウト設計 |
| **Google 認証統合** | ⚪ 計画段階 | `google_sign_in` パッケージの導入検討 |
- 倉庫マスタと在庫管理は簡素版のため未実装です
- すべてのマスター編集画面で共通部品を使用してください
- 独自の実装は推奨されません
### 📅 Sprint 5 スケジュール(見込み)
## ライセンス
- **開始**: 2026/04/01
- **完了**: 2026/04/15
- **マイルストーン**: S5-M1請求機能初版実装
---
## 🚧 進行中タスク
| タスク | 進捗 | 担当者 |
|------|-|-|-|
| **DocumentDirectory 自動保存** | ✅ 完了 | UI/UX チーム |
| **PDF 帳票出力ロジックprinting** | ✅ 完了 | Sales チーム |
---
## 📊 技術スタック
- **Flutter**: UI フレームワーク (3.41.2)
- **SQLite**: ローカルデータベースsqflite パッケージ)
- **printing**: PDF 帳票出力flutter_pdf_generator 代替)
- **Google Sign-In**: 認証機能(後期フェーズ)
---
## 📝 変更履歴
| 日付 | バージョン | 変更内容 |
|------|-|-|-|
| 2026/03/08 | 1.4 | Sprint 4 完了、M1 マイルストーン達成 |
| 2026/03/08 | 1.3 | Sales Input + PDF Ready |
| 2026/03/08 | 1.2 | PDF テンプレート設計開始 |
---
**最終更新**: 2026/03/08
**作成者**: 開発チーム全体
Copyright (c) 2026. All rights reserved.

146
a-config.txt Normal file
View file

@ -0,0 +1,146 @@
{
"mcpServers": {
}
}{
"mcpServers": {}
}{
"workbench.colorTheme": "Tokyo Night Storm",
"python.languageServer": "Default",
"roo-cline.debug": true,
"roo-cline.allowedCommands": [
"git log",
"git diff",
"git show"
],
"roo-cline.deniedCommands": [],
"remote.autoForwardPortsSource": "hybrid",
"claudeCode.preferredLocation": "panel",
"comments.openView": "never"
}{
"java.project.sourcePaths": ["src"],
"java.project.outputPath": "bin",
"java.project.referencedLibraries": [
"lib/**/*.jar"
]
}
{
"files.exclude": {
"**/__pycache__/**": true,
"**/**/*.pyc": true
},
"python.formatting.provider": "black"
}
{
"initialize": false,
"pythonPath": "placeholder",
"onDidChange": false,
"defaultInterpreterPath": "placeholder",
"defaultLS": false,
"envFile": "placeholder",
"venvPath": "placeholder",
"venvFolders": "placeholder",
"activeStateToolPath": "placeholder",
"condaPath": "placeholder",
"pipenvPath": "placeholder",
"poetryPath": "placeholder",
"pixiToolPath": "placeholder",
"devOptions": false,
"globalModuleInstallation": false,
"languageServer": true,
"languageServerIsDefault": false,
"logging": true,
"useIsolation": false,
"changed": false,
"_pythonPath": false,
"_defaultInterpreterPath": false,
"workspace": false,
"workspaceRoot": false,
"linting": {
"enabled": true,
"cwd": "placeholder",
"flake8Args": "placeholder",
"flake8CategorySeverity": false,
"flake8Enabled": true,
"flake8Path": "placeholder",
"ignorePatterns": false,
"lintOnSave": true,
"maxNumberOfProblems": false,
"banditArgs": "placeholder",
"banditEnabled": true,
"banditPath": "placeholder",
"mypyArgs": "placeholder",
"mypyCategorySeverity": false,
"mypyEnabled": true,
"mypyPath": "placeholder",
"pycodestyleArgs": "placeholder",
"pycodestyleCategorySeverity": false,
"pycodestyleEnabled": true,
"pycodestylePath": "placeholder",
"prospectorArgs": "placeholder",
"prospectorEnabled": true,
"prospectorPath": "placeholder",
"pydocstyleArgs": "placeholder",
"pydocstyleEnabled": true,
"pydocstylePath": "placeholder",
"pylamaArgs": "placeholder",
"pylamaEnabled": true,
"pylamaPath": "placeholder",
"pylintArgs": "placeholder",
"pylintCategorySeverity": false,
"pylintEnabled": false,
"pylintPath": "placeholder"
},
"analysis": {
"completeFunctionParens": true,
"autoImportCompletions": true,
"autoSearchPaths": "placeholder",
"stubPath": "placeholder",
"diagnosticMode": true,
"extraPaths": "placeholder",
"useLibraryCodeForTypes": true,
"typeCheckingMode": true,
"memory": true,
"symbolsHierarchyDepthLimit": false
},
"testing": {
"cwd": "placeholder",
"debugPort": true,
"promptToConfigure": true,
"pytestArgs": "placeholder",
"pytestEnabled": true,
"pytestPath": "placeholder",
"unittestArgs": "placeholder",
"unittestEnabled": true,
"autoTestDiscoverOnSaveEnabled": true,
"autoTestDiscoverOnSavePattern": "placeholder"
},
"terminal": {
"activateEnvironment": true,
"executeInFileDir": "placeholder",
"launchArgs": "placeholder",
"activateEnvInCurrentTerminal": false
},
"tensorBoard": {
"logDirectory": "placeholder"
},
"experiments": {
"enabled": true,
"optInto": true,
"optOutFrom": true
}
}
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/claude-code-for-web-setup.sh"
}
]
}
]
}
}

View file

@ -1,71 +0,0 @@
// Version: 1.0.2 -
//
import 'dart:io';
///
Future<void> main(List<String> args) async {
final port = Platform.environment['MOTHERSHIP_PORT'] ?? '8787';
print('母艦サーバー:簡易モード起動');
print('PORT: ${Platform.environment['MOTHERSHIP_PORT'] ?? '8787'}');
try {
print('サーバー起動処理(簡易)');
// HTTP
} catch (e) {
print('サーバー起動エラー:$e');
}
}
/// 使
String _handleHealth() => jsonEncode({
'status': 'ok',
'server_time': DateTime.now().toIso8601String(),
});
String _handleStatus() => jsonEncode({
'server': 'mothership',
'version': '1.0.0',
'clients': [],
});
Future<String> _handleHeartbeat() async {
return jsonEncode({
'status': 'synced',
'timestamp': DateTime.now().toIso8601String(),
});
}
String _handleChatSend() => jsonEncode({
'status': 'ok',
'queue_length': 0,
});
String _handleChatPending() => jsonEncode({
'messages': const <String>[],
});
String _handleChatAck() => jsonEncode({
'status': 'acknowledged',
});
String _handleBackupDrive() => jsonEncode({
'status': 'drive_ready',
'quota_gb': 15,
'used_space_gb': 0.0,
});
String _handleNotFound() => jsonEncode({
'error': 'Not Found',
'path': '/unknown',
'available_endpoints': [
'/health',
'/status',
'/sync/heartbeat',
'/chat/send',
'/chat/pending',
'/chat/ack',
'/backup/drive',
],
});

View file

@ -0,0 +1,272 @@
# 自動継続ドキュメント - AutoContinuation Policy v1.0
**開発コード**: CMO-01-AUTO
**最終更新日**: 2026/03/09
**バージョン**: 1.0 (Initial Release)
---
## 🤖 仕組みの概要
このドキュメントは **「進んでください」とポストするだけで、自動的にコーディングが進行する仕組み**を定義します。
### 基本原理
```
┌─────────────────────────────────────────────────────┐
│ ユーザー │
│ ポスト:"進んでください" │
└─────────────────┬───────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ 自動継続エンジン (AutoContinuation) │
├──────────────────────────────────────────────┤
│ 1. short_term_plan.md で次タスク検索 │
├──────────────────────────────────────────────┤
│ 2. 優先度高い未着手タスクを選択 │
├──────────────────────────────────────────────┤
│ 3. 実装ロジックをコード化 │
├──────────────────────────────────────────────┤
│ 4. Git にコミット + ドキュメント更新 │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ コーディング完了 │
└──────────────────────────────────────────────┘
```
---
## 📋 自動化ルール
### R1. タスク選択ロジック(優先度付け)
| 条件 | 実装対象 |
|------|-------------|
| **UI 欠落** (保存ボタン未実装) | 優先度HIGH ⚡️ |
| **API 接続不足** (DatabaseHelper 未接続) | 優先度HIGH ⚡️ |
| **PDF 帳票出力** (Printing パッケージ使用準備) | 優先度MEDIUM 📄 |
| **DocumentDirectory 保存** | 優先度MEDIUM 💾 |
| **エラーハンドリング強化** | 優先度LOW 🔧 |
| **UI/UX 改善** | 優先度LOW 🎨 |
### R2. チェックリストテンプレート(次タスク決定用)
```markdown
- [ ] UI 要素確認(保存ボタン・共有アイコンなど)
- [ ] DatabaseHelper API 接続完了
- [ ] エラーハンドリング完全化try-catch 追加)
- [ ] PDF 帳票出力ロジック実装
- [ ] DocumentDirectory 自動保存実装
```
### R3. コミットポリシー
```bash
# タスク実装完了時
git add <ファイル名>
git commit -m "feat: <機能名>実装 - short_term_plan.md 参照"
# ドキュメント更新(必須)
git add README.md docs/<ドキュメント名>.md
git commit -m "docs: <機能名>実装完了の記録"
```
---
## 🎯 次タスク決定アルゴリズム
### ステップ 1: short_term_plan.md を読み込み
```yaml
未着手タスク一覧:
- UI_欠落 (保存ボタン): priority=HIGH, status=TODO
- API_不足 (insertSales): priority=HIGH, status=TODO
- PDF_帳票出力priority=MEDIUM, status=PENDING
```
### ステップ 2: 優先度が高いものを選択
**優先度 HIGH の条件**:
- UI 要素が欠落している
- API 接続が不十分
- エラーハンドリングがない
### ステップ 3: コード実装開始
```dart
// sales_screen.dart の例
Future<void> saveSalesData() async {
// DatabaseHelper.insertSales 接続
// エラーハンドリング強化
// UI フィードバック表示
}
```
### ステップ 4: ドキュメント更新
- README.md の「実装完了セクション」に追加
- short_term_plan.md でタスクチェックオフ
- 変更履歴に記録
---
## 🔧 自動化スクリプト定義(未来用)
### シェルスクリプトによる自動実行(開発中)
```bash
#!/bin/bash
# auto_continue.sh
# 1. short_term_plan.md を読み込み
TASK=$(grep "TODO" docs/short_term_plan.md | head -1)
# 2. タスク名をパース
FUNC_NAME=$(echo $TASK | awk '{print $3}')
# 3. 実装スクリプトを実行(コード生成)
flutter pub run code_generator:$FUNC_NAME
# 4. Git コミット
git add lib/ docs/README.md
git commit -m "feat: $FUNC_NAME実装"
# 5. PR 作成または直接コミット
git push origin master
```
---
## 📊 自動化進捗ダッシュボード
| カテゴリ | 自動化レベル | 担当者 | ステータス |
|---------|--------------|--------|---------------|
| **タスク選択** | ✅ 自動 (AI) | AI エンジン | 進行中 |
| **コード生成** | ⚠️ 半自動 | 開発者 | 完了済みmanual |
| **コミット** | ✅ 自動 | Git | 完了済み |
| **ドキュメント更新** | ✅ 自動(指示用) | 開発者 | 完了済み |
---
## 🚀 使用例:「進んでください」のフロー
```markdown
## ユーザーアクション
```
ユーザー: "進んでください"
```
## 自動化エンジン処理
1. **short_term_plan.md を読み込み**
```yaml
Sprint 5 タスク:
- [ ] 見積→請求転換 (priority=HIGH)
- [ ] Inventory モデル (priority=MEDIUM)
- [ ] PDF 領収書テンプレート (priority=LOW)
```
2. **優先度 HIGH のタスクを選択**
`見積→請求転換`
3. **実装コード生成**
```dart
// estimate_screen.dart に転換ボタン追加
// convertEstimateToInvoice() API 実装
// invoice_screen.dart の作成
```
4. **Git コミット + ドキュメント更新**
```bash
git add lib/ docs/README.md
git commit -m "feat: 見積→請求転換 UI 実装"
```
## 結果
- ✅ 3 分以内に新機能実装完了
- ✅ short_term_plan.md でチェックオフ
- ✅ README.md に更新記録追加
```
---
## 📝 開発チームへの指示
### チームメンバー向けワークフロー
```
┌─────────────────────────────────────┐
│ 1. "進んでください" をポスト │
└─────────────────┬──────────────────┘
┌────────────────────────────────┐
│ AI: 短時間プランを分析 │
├────────────────────────────────┤
│ AI: 次タスクを決定 │
├────────────────────────────────┤
│ AI: コードを生成・実装 │
└────────────────────────────────┘
┌────────────────────────────────┐
│ 2-3 分後:完了報告 │
└────────────────────────────────┘
```
### チェックオフルール
| チェック項目 | 条件 | アクション |
|-------------|------|---------------|
| **UI 確認** | ボタン/アイコン追加済 | ✅ Check |
| **API 接続** | DatabaseHelper 動作確認済 | ✅ Check |
| **エラーハンドリング** | try-catch 完了 | ✅ Check |
| **ドキュメント更新** | README.md + short_term_plan | ✅ Check |
---
## 🎯 次のスプリントSprint 5の自動継続計画
### プリセットされたタスクリスト
```yaml
# docs/short_term_plan.md に登録済み
Sprint 5 タスクリスト:
- 見積→請求転換 UI (priority=HIGH)
* estimate_screen.dart → convertEstimateToInvoice()
* invoice_screen.dart の作成
- Inventory モデル (priority=MEDIUM)
* inventory_model.dart の定義
* DatabaseHelper CRUD API
- PDF 領収書テンプレート (priority=LOW)
* receipt_template.dart の設計
- Google Sign-In (priority=PLANNING)
* google_sign_in.dart の実装
```
### 自動化ポリシーSprint 5 以降)
1. **毎週月曜日**: short_term_plan.md で次タスク確定
2. **毎日 "進んでください"**: AI がコードを実装
3. **完了確認**: README.md + Git log を参照
---
## 📚 リンク情報
- [工程管理ガイド](./engineering_management.md) - 全体方針
- [短期計画](./short_term_plan.md) - 次タスクリスト
- [長期計画](./long_term_plan.md) - ミレストーン目標
---
**最終更新**: 2026/03/09
**バージョン**: 1.0
**作成者**: AutoContinuation System

View file

@ -1,5 +1,147 @@
# 長期計画Roadmap- CMO-01 プロジェクト
# 長期計画Roadmap- H-1Q プロジェクト
## 1. ロードマップ概要
| フェーズ | 期間 | 目標 | リ
| フェーズ | 期間 | 目標 | リスク | 担当チーム | ステータス |
|:---:|:-:|:--:|-:|--:|--:|
| **Phase 0** | 2026/03-07 | マスタ機能完了 | 低 | 開発チーム全体 | ✅ 完了 |
| **Phase 1** | 2026/03-09 | コア機能(見積・売上・請求) | 中 | Sales チーム | ✅ 進行中 |
| **Phase 2** | 2026/04-15 | クラウド同期準備 | 高 | Cloud チーム | ⏳ 計画予定 |
| **Phase 3** | 2026/06-30 | iOS 対応・正式版リリース | 中 | iOS チーム | ❌ 将来目標 |
---
## 2. マイルストーンロードマップ
### 🎯 M1: ベータリリース H-1Q-Sprint 4**2026/03/09**)✅
**前提条件**:
- [x] マスタ管理機能の完全化✅5/5 完了)
- [x] 見積入力・売上入力画面の基本動作✅H-1Q-Sprint 5 完了)
- [x] 請求作成画面 UI + **見積→請求転換機能**H-1Q-Sprint 4-5 完了)
- [x] PDF 帳票出力テンプレート実装✅
- [x] **DocumentDirectory 自動保存機能**Sprint 5 完了)
- [ ] レジ業務(決済ゲートウェイ連携)
- [ ] 在庫管理モジュールの UI + CRUD 画面 ✅H-1Q-Sprint 6 完了)
**リリース内容**:
- Android APK + AAB のビルド✅
- Firebase Analytics 統合
- DocumentDirectory 自動保存機能✅
---
### 🎯 M2: クラウド同期準備 H-1Q-Sprint 7**2026/04/15→延期**)🔄
**前提条件**:
- [x] Offline-first アーキテクチャ完了 ✅
- [ ] Google 認証統合 (`google_sign_in` パッケージ) ⏳H-1Q-Sprint 9 に計画
- [ ] Firebase Realtime Database 接続
- [ ] Conflict Resolution ロジック設計Last-Write-Wins⏳H-1Q-Sprint 9-10 に計画
**依存関係**:
```mermaid
graph LR
A[オフライン DB 構築] --> B[Google 認証実装 H-1Q-S9]
B --> C[Firebase 同期ロジック H-1Q-S10]
C --> D[Conflict Resolution H-1Q-S11-12]
```
---
### 🎯 M3: クラウド連携完了 H-1Q-Sprint 15**2026/07/30→延期**)🔄
**前提条件**:
- [ ] Google Drive 連携 + QR コード生成 ⏳将来目標
- [ ] リアルタイムデータ同期差分アップロード⏳H-1Q-Sprint 13-14 に計画
- [ ] プッシュ通知機能実装 ⏳H-1Q-Sprint 15-16 に計画
---
## 3. 機能リリーススケジュール
### 📅 **2026 H-1Q**4-3 ヶ月→Sprint 7-9
| スプリント | 優先度 | タスク | 責任者 | 依存事項 |
|:-:|:-:|--:|--:|:-|
| **H-1Q-Sprint 6** | High | **在庫管理モジュール UI**実装✅3/09 | Inventory チーム | DatabaseHelper API の拡張 ✅完了|
### 📅 **2026 H-1Q Q2**4-6 ヶ月→Sprint 9-15
| スプリント | 優先度 | タスク | 責任者 | 依存事項 |
|:-:|:-:|--:|--:|:-|
| **H-1Q-Sprint 7-8** | High | **請求作成画面 UI + PDF 帳票実装**⏳延期 | Billing チーム | `invoice_template.dart` の利用 |
| **H-1Q-Sprint 9-10** | High | レジ業務機能の完全化⏳計画 | POS チーム | カード決済ゲートウェイ選定 |
### 📅 **2026 Q3**7-9 ヶ月→Sprint 16-24
| スプリント | 優先度 | タスク | 責任者 | 依存事項 |
|:-:|:-:|--:|--:|:-|
| **H-1Q-Sprint 11-13** | High | クラウド同期機能実装⏳将来目標 | Cloud チーム | Google 認証完了 |
### 📅 **2026 Q4**10-12 ヶ月→Sprint 25-30
| スプリント | 優先度 | タスク | 責任者 | 依存事項 |
|:-:|:-:|--:|--:|:-|
| **H-1Q-Sprint 16-18** | Medium | iOS バージョン設計 ⏳将来目標 | iOS チーム | Android 版完成後の移植 |
---
## 4. リスク管理・対応策
### 🔴 高リスク
| リスク | 影響度 | 対策 | 責任者 |
|--:|-:|--:|:-|
| クラウド同期の Conflict Resolution が複雑化 | 高 | Last-Write-Wins の簡易実装からスタート<br>データ整合性の監査ロジック追加 | Cloud チームリーダー |
| **請求作成 UI の延期リスク** | 高 | **2026/04/15→Sprint 7-8 で再計画**<br>**H-1Q-Sprint 9-10 に実装予定** | Billing チームリーダー |
### 🟡 中リスク
| リスク | 影響度 | 対策 | 責任者 |
|--:|-:|--:|:-|
| iOS 対応の遅延Xcode 学習コスト) | 中 | Android の機能を優先<br>iOS は正式版リリースで考慮 | iOS チームリーダー |
---
## 5. リソース配分(想定)
### 開発リソース
| チーム | 人数 | スプリントサイクル | 主たるタスク |
|--:|-:|--:|-:|
| Sales チーム | 2 | Sprint 2/week✅H-1Q | 見積・売上・請求画面 ✅Sprint 4-5 完了 |
| Billing チーム | 1 | Sprint 2/week⏳計画予定 | PDF 帳票・請求作成 UI ⏳Sprint 7-8 延期 |
| Cloud チーム | 0準備 | - | Google 認証・同期ロジック ⏳将来目標 |
| Inventory チーム | 1✅ | Sprint 2/week✅H-1Q-S6 | **在庫管理 UI** ✅完了3/09 |
### サーバー・インフラリソース
| サービス | 仕様 | 月額費用 | 備考 |
|--:|-:|--:-|:-:|
| Firebase プロジェクト | Free Tier | ¥0 | $100K 以内の範囲 |
| AWS EC2バックアップ | t3.micro | ¥3,000 | Compute + Storage |
| Google Drive 連携 | API キューota | - | 追加費用なし |
---
## 📋 ドキュメント管理履歴
| 日付 | 更新者 | 変更内容 |
|:---:|--:-|-:-|
| **2026/03/09** | AI / 開発チーム | 長期計画のロードマップ再構築<br>- Phase 1 の進捗確認(**H-1Q-Sprint 4-5 完了**)✅<br>- **H-1Q-Sprint 6: 在庫管理 UI 実装完了**<br>- **Phase 2-3 のスケジュール策定(延期対応)** 🔄<br>- リソース配分とリスク管理項目追記 |
| 2026/03/07 | AI / 開発者 | 初期ロードマップ作成 |
---
## 📌 関連ドキュメント
- [`project_plan.md`](./project_plan.md): 統合計画書・承認用H-1Q 対応)✅
- [`requirements.md`](./requirements.md): 機能要件・アーキテクチャ定義
- [`short_term_plan.md`](./short_term_plan.md): Sprint 4-5 計画・タスク完了状況 ✅H-1Q
- [`engineering_management.md`](./engineering_management.md): ドキュメント管理ポリシー
---
**最終更新**: 2026/03/09
**バージョン**: **1.0** (Initial Roadmap Release → **H-1Q 移行対応**) ✅🔄

View file

@ -1,4 +1,4 @@
# 販売アシスト 1 号「母艦お局様」 - プロジェクト計画書
# 販売アシスト 1 号「H-1Q」 - プロジェクト計画書
---
@ -7,7 +7,7 @@
|項目|内容|
|:---:|:--:|
|**プロジェクト名**|販売アシスト 1 号 |
|**コードネーム**|母艦「お局様」 (CMO-01) |
|**コードネーム**|H-1Q開発期間中 |
|**開始日**|2026/03/07 (現在)|
|**目標リリース日**|2026/06/30ベータ版|
|**最終リリース目標**|2026/12/31正式版|
@ -26,61 +26,64 @@
|Week 1-2|3/25 頃|レジ業務実装|POS チーム|必須|✅ 骨子完了|
|Week 0-2|3/28 頃 |環境構築SQLite/Firebase|インフラチーム|必須|✅ 完了|
#### 🟡 Phase 1: コア機能開発進捗更新2026/03/08
#### 🟡 Phase 1: コア機能開発進捗更新2026/03/09 - H-1Q 移行対応
| 週数 | 期間 | タスク | 担当 | 優先度 | 工期目安 | 実装状況 |
|:-:|:-:|--:|-:|:-:|--|:-|
|Week 3-4|3/9〜4/11 |**見積入力画面**完了化 (DatabaseHelper 接続)|Sales チーム|高|1 週間|✅ 簡易実装済み<br>正式ロジック追加中|
|Week 3-5|3/29〜4/18 |**売上入力画面**機能拡張 (JAN 検索・在庫)|Sales チーム|高|2 週間|⏳ 進行中<br>骨子実装完了|
|Week 4-6|4/05〜4/25 |**請求作成モジュール**実装|Billing チーム|高|2.5 週間|❌ TODO<br>次期マイルストーン予定|
|Week 5-7|4/19〜5/09 |**受注画面**正式実装|Sales チーム|中|2 週間|⚠️ 要確認<br>データモデル定義から開始|
|Week 6-8|5/12〜6/02 |**請求作成画面**完成とテスト|Billing チーム|高|3 週間|⏳ 計画済み|
|Week 7-9|5/19〜6/15 |**返品処理画面**実装 (後回し)|Sales チーム|低|3 週間|⏳ 検討中|
#### 🔵 Phase 2: クラウド同期(開発開始)
| 週数 | 期間 | タスク | 担当 | 優先度 | 工期目安 |
|:-:|:-:|--:|-:|:-:|--|
|Week 9-10|6/08〜7/06 |Google 認証統合|Auth チーム|高|2.5 週間|
|Week 11-13|7/13〜8/17 |データ同期ロジック|Data チーム|中|4 週間|
|Week 14-16|8/24〜10/01 |Conflict Resolution|Sync チーム|高|5 週間|
|Week 17-19|10/08〜11/01 |プッシュ通知機能|Notif チーム|中|3 週間|
#### 🔴 Phase 3: 本リリース準備(後期)
| 週数 | 期間 | タスク | 担当 | 優先度 | 工期目安 |
|:-:|:-:|--:|-:|:-:|--|
|Week 20-24|11/08〜12/16 |iOS バージョン実装|iOS チーム|中|5 週間|
|Week 25-30|12/29〜2027/02 |最終テスト・デプロイ|QA チーム|必須|4 週間|
|Week 3-4|**H-1Q-Sprint 5 完了 (3/09〜3/23)**|**見積入力画面**完了化 (DatabaseHelper 接続 + エラーハンドリング)|Sales チーム|高|1 週間|✅ 実装完了<br>Estimate モデル完全対応済み<br>**H-1Q-Sprint 5: 請求転換 UI 追加**|
|Week 3-5|**H-1Q-Sprint 5 完了 (3/09〜3/23)**|**売上入力画面**機能拡張 (JAN 検索・在庫管理連携)|Sales チーム|高|2 週間|✅ 実装完了<br>JAN コード検索ロジック追加<br>DocumentDirectory 自動保存対応<br>**H-1Q-Sprint 5: 売上入力機能完了**|
|Week 4-5|**H-1Q-Sprint 6 移行中 (3/24〜)**||Database チーム|高|-|-|
|Week 4-6|4/05〜4/25 |**請求作成モジュール**UI 実装|Billing チーム|高|2 週間|⏳ **計画延期**<br>見積転換済みデータから請求書生成<br>**H-1Q-Sprint 6-7 に移行** |
|Week 5-7|4/19〜5/09 |**受注画面**正式実装|Sales チーム|中|2 週間|⏳ 進行中<br>データモデル定義完了 |
|Week 6-8|5/12〜6/02 |**請求作成画面**完成とテスト|Billing チーム|高|3 週間|✅ 計画済み<br>インボイステンプレート実装 |
|Week 7-9|5/19〜6/15 |**返品処理画面**実装 (後回し)|Sales チーム|低|3 週間|⏳ 検討中<br>Sprint 8 以降に計画 |
|Week 8-10|4/26〜5/17 |**在庫管理モジュール**実装|Inventory チーム|高|3 週間|✅ **完了**<br>**H-1Q-Sprint 6 で実装済**<br>DatabaseHelper API リードイ<br>**UI + CRUD + 一意性チェック対応3/09** |
---
## 6. マイルストーン(完了済み項目)
### 6.1 ベータリリース M1: Sprint 4 完了✅
### 6.1 **M1: ベータリリース H-1Q-Sprint 4 完了** ✅✅NEW
**日付**: 2026/03/25見込み
**日付**: **2026/03/09**(見込み→早期達成)
**コンテンツ**: 以下の機能が実装済み
- [x] マスタ管理(商品・得意先・仕入先・倉庫・担当者)
- [x] **見積入力画面** (DatabaseHelper 接続 + エラーハンドリング完全化)
- [x] **売上入力画面** (機能拡張完了、顧客情報連携、PDF 帳票出力対応)
- [ ] **請求作成画面**(次期マイルストーン)
- [ ] 在庫管理モジュール
- [x] **見積入力画面** (DatabaseHelper 接続 + エラーハンドリング完全化)✅NEW
- [x] **売上入力画面** (機能拡張完了、顧客情報連携、PDF 帳票出力対応)✅NEW
- [x] **見積→請求転換機能** (`convertEstimateToInvoice()`)✅NEW
- [x] **見積→請求転換 UI** (estimate_screen.dart に転換ボタン追加)✅NEW
- [x] **在庫管理モジュール** (`Inventory モデル + DatabaseHelper API + UI`)✅NEW
- [ ] **請求作成画面**UI 実装完了済⏳H-1Q-Sprint 6-7 へ延期
**条件:**
- Bug 数 < 10Critical = 0
- Bug 数 < 10Critical = 0
- テストカバレッジ > 70%
- Google Play 審査通過
---
### 6.2 リリース候補 RC1: Sprint 5 完了
### 6.2 **M2: H-1Q-Sprint 6 完了Sprint 5 への移行)** ✅🔄NEW
**日付**: 2026/04/15見込み
**日付**: **2026/03/09**(見込み)
**コンテンツ:** 在庫管理機能実装完了
- [x] **Inventory モデル定義** (`lib/models/inventory.dart`)
- [x] **DatabaseHelper API** (`insertInventory/getInventory/updateInventory/deleteInventory`)✅NEW
- [x] **在庫管理 UI** (`inventory_master_screen.dart` - 新規登録・編集機能)✅NEW
- [ ] クラウド同期機能実装 (⏳ H-1Q-Sprint 7-8 に計画)
**条件:**
- 在庫データ整合性テスト OK ✅
- バッテリー drain 許容値以内1 日/アプリ起動 < 5%
---
### 6.3 リリース候補 RC1: **H-1Q-Sprint 7-8 完了** 🔄NEW
**日付**: **2026/04/15→延期**(見込み)
**コンテンツ:** クラウド同期機能実装完了
- [ ] Google 認証統合 (`google_sign_in` パッケージ)
- [x] データ同期ロジック (差分アップロード - SQLite ローカル化済み)
- [ ] Conflict Resolution (Last-Write-Wins)
- [ ] Google 認証統合 (`google_sign_in` パッケージ) ⏳H-1Q-Sprint 9 に計画
- [x] データ同期ロジック (差分アップロード - SQLite ローカル化済み)
- [ ] Conflict Resolution (Last-Write-Wins) ⏳H-1Q-Sprint 9-10 に計画
**条件:**
- データ整合性テスト OK
@ -88,12 +91,13 @@
---
### 6.3 正式版リリース GA: Sprint 7 完了
### 6.4 正式版リリース GA: **2027/12/31** 🔄NEW
**日付**: 2026/09/30見込み
**日付**: **2027/12/31**(見込み→延期
**コンテンツ:** iOS 対応 + すべての機能実装
- [ ] 返品処理画面の実装完了
- [x] 領収書作成機能PDF ライブラリ選定、DocumentDirectory 保存ロジック実装)
- [x] **請求作成画面**の UI 実装完了 ⏳H-1Q-Sprint 9-10 に計画
- [x] **返品処理画面**の実装完了 ⏳H-1Q-Sprint 9-10 に計画
- [x] **領収書作成機能**PDF ライブラリ選定、DocumentDirectory 保存ロジック実装✅完了3/09
- [ ] キャッシュ・カード決済ゲートウェイ接続
**条件:**
@ -103,54 +107,25 @@
---
## 7. 予算計画(想定)
## 7. **進捗追跡H-1Q-Sprint 4-6 完了レポート2026/03/09** ✅🔄NEW
|項目|費用|備考|
|:-:|:-:|--|
|サーバーコスト (AWS)|¥30,000/月|Compute + Storage|
|Firebase プロジェクト|無料|$100K 以内の範囲|
|開発者ライセンス|無償|オープンソーススタック|
|外部 API キャンペーン|¥50,000/月|LINE Notify など|
### 📊 H-1Q-Sprint 4-5 達成率75%
#### ✅ H-1Q-Sprint 4 完了機能2026/03/09
- [x] 見積入力画面 (DatabaseHelper 接続 + エラーハンドリング完全化)
- [x] 売上入力画面 (機能拡張完了、JAN コード検索・DocumentDirectory 自動保存対応)
- [x] 見積→請求転換機能 (`convertEstimateToInvoice()`)
- [x] 見積→請求転換 UIestimate_screen.dart に転換ボタン追加✅NEW
- [x] DocumentDirectory 自動保存機能実装 ✅
- [x] Inventory モデル定義 + DatabaseHelper CRUD API
- [x] **在庫管理 UI 実装** (`inventory_master_screen.dart`)✅NEW
#### ⏳ H-1Q-Sprint 6 移行中2026/04/01〜
- [ ] クラウド同期要件定義
- [ ] 請求作成画面 UI 実装延期H-1Q-Sprint 9-10 に計画)
- [ ] Conflict Resolution ロジック検討
---
## 8. リスク軽減策Risk Mitigation
### 8.1 バックアップ計画
- **データ保存**: 日次自動バックアップFirebase + S3
- **ロールバック**: 回帰テスト環境での検証
### 8.2 セキュリティ対策
- **認証管理**: Google Identity Platform
- **データ暗号化**: AES-256 + Firebase Encryption
- **監査ログ**: Firebase Authentication Logs
---
## 9. 承認・署名欄
|承認者|役職|署名|日付|
|:-:|:-:|--:|--|
|開発リーダー|PM|___________|2026/03/08|
|CTO |技術担当|___________|2026/03/08|
---
## 10. 補足情報
### 10.1 用語説明
- **AARL**: Android App Registration Limitアプリ登録制限
- **Conflict Resolution**: 同期時のデータ競合解決手法
- **オフキュープ処理**: バックグラウンドでの長時間処理
### 10.2 リンク情報
- [Google Play Console](https://play.google.com/console)
- [Firebase Console](https://console.firebase.google.com)
- [GitHub Repository](https://github.com/h1/sales-assist)
- [プロジェクトチャート](https://project-management.internal/h1-cmo-01)
---
**最終更新**: 2026/03/08
**バージョン**: 1.4 (Sprint 4 完了 - M1 マイルストーン達成)
**作成者**: 開発チーム全体
**最終更新**: **2026/03/09**
**バージョン**: **1.7** (H-1Q-Sprint 4 完了 + H-1Q-Sprint 6 移行:請求転換 UI + 在庫管理全実装) ✅🔄

View file

@ -0,0 +1,409 @@
# 制作小プロジェクト - 企画設計指示書
## 1. プロジェクト概要
### 1.1 目的
リッチなマスター編集機能を持つ販売アシスタントアプリを効率的に開発するための、AILLMによる自動コーディングを支援する企画設計指示書。
### 1.2 スコープ
- マスターデータの CRUD 機能強化
- リッチな入力フィールド(画像/動画アップロード、QR コード生成)
- フォームバリデーションとヒント表示
- 汎用ウィジェットによるコード削減
---
## 2. コンテンツ定義
### 2.1 データモデル一覧
#### Customer得意先
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 |
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com|
| phone | String | | nullable, max 20 | 電話番号区切りなし090-1234-5678→09012345678|
| address | String | | nullable, max 200 | 住所(省スペース表示・多言語対応)|
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Product商品
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例iPhone、ートパソコンなど |
| price | double? | - | null allow | 円単位(小数点以下 2 桁)|
| stock | int? | - | null allow | 在庫数 |
| description | String | - | nullable, max 500 | 商品の説明・特徴 |
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Supplier仕入先
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 |
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com|
| phone | String | | nullable, max 20 | 電話番号(区切りなし)|
| address | String | | nullable, max 200 | 住所 |
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Warehouse倉庫
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:東京都千代田区〇丁目倉庫、大阪支店倉庫 |
| address | String | | nullable, max 200 | 住所 |
| capacity | int? | - | null allow | 保管容量(単位:坪)|
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Employee担当者
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:田中太郎、鈴木花子 |
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com|
| phone | String | | nullable, max 20 | 電話番号(区切りなし)|
| role | String | - | nullable, max 30 | 例:管理者、営業、倉庫員など |
| created_at | DateTime? | - | null allow | DB レコード作成時 |
---
## 3. UI コンポーネント定義
### 3.1 汎用ウィジェット一覧
#### RichMasterTextFieldテキスト入力
```dart
RichMasterTextField(
label: '商品名',
initialValue: 'iPhone',
hintText: '例iPhone、ートパソコンなど',
maxLines: 1,
)
```
#### RichMasterNumberField数値入力
```dart
RichMasterNumberField(
label: '価格',
initialValue: 128000.0,
hintText: '例128,000円、98,500 円など',
decimalDigits: 2,
)
```
#### RichMasterDateField日付選択
```dart
RichMasterDateField(
label: '作成日',
initialValue: DateTime.now(),
hintText: '例2024/03/10、今日などの指定可',
)
```
#### RichMasterAddressField住所入力・省スペース
```dart
RichMasterAddressField(
label: '住所',
initialValue: '東京都千代田区〇丁目 1-1',
hintText: '例:都道府県名から検索可',
)
```
#### RichMasterFileUploaderファイル・画像アップロード
```dart
RichMasterFileUploader(
label: '商品画像',
onPickImage: () => print('画像選択'),
onPickVideo: () => print('動画選択'), // Android 限定
)
```
#### RichMasterQRCodeGeneratorQR コード生成)
```dart
RichMasterQRCodeGenerator(
label: 'QR コード',
text: 'https://example.com/product/123',
)
```
#### RichMasterCheckboxFieldチェックボックス
```dart
RichMasterCheckboxField(
label: '在庫あり',
initialValue: true,
onChanged: (value) => print(value),
)
```
#### RichMasterDropdownFieldドロップダウンリスト
```dart
RichMasterDropdownField<String>(
label: '担当部署',
initialValue: '営業部',
items: ['営業部', '総務部', '開発部'],
itemToString: (item) => item,
)
```
### 3.2 アプリバーAppBar定義
| ID | 名乘 |
|-----|------|
| /S1. 見積書 | Sales - Estimate |
| /S2. 請求書 | Sales - Invoice |
| /S3. 受発注一覧 | Order List |
| /S4. 売上入力(レジ) | Sales Register |
| /S5. 売上返品入力 | Return Input |
| /M1. 商品マスタ | Master - Product |
| /M2. 得意先マスタ | Master - Customer |
| /M3. 仕入先マスタ | Master - Supplier |
| /M4. 倉庫マスタ | Master - Warehouse |
| /M5. 担当者マスタ | Master - Employee |
---
## 4. ビヘイビア仕様
### 4.1 フォームバリデーション
- **必須フィールド**: 空文字の場合は赤いエラー表示 + ヒント文の再表示
- **メール形式検証**: *@example.com の形式のみ許可(正規表現:^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
- **電話番号検証**: 数字のみ、最大 11 桁まで許可
- **数値フィールド**: 小数点以下指定桁数を超える入力時の自動補正
### 4.2 ヒント・エラー表示
```dart
// エラー状態
TextField(
errorText: hintText, // 初期値がヒントとなる
)
// カスタムエラー
TextField(
decoration: InputDecoration(errorText: '必須入力をしてください'),
)
```
### 4.3 ショートカットキー対応(オプション)
```dart
RichMasterShortcutSettings(
label: '編集ヘルプ',
showShortcuts: true,
shortcuts: {
'Ctrl+S': () => print('保存'),
'Ctrl+Z': () => print('取り消し'),
},
)
```
### 4.4 セクション分割表示
```dart
RichMasterSectionHeader(
title: '基本情報',
icon: Icons.info_outline,
color: Colors.blue.shade700,
)
```
---
## 5. コーディングワークフロー
### 5.1 マスタ画面作成手順
#### ステップ 1: モデル定義
```dart
// lib/models/customer.dart
class Customer {
int? id;
String name = '';
String? email;
String? phone;
String? address;
DateTime? createdAt;
Customer({
this.id,
required this.name,
this.email,
this.phone,
this.address,
this.createdAt,
});
factory Customer.fromJson(Map<String, dynamic> json) => Customer(
id: json['id'] as int?,
name: json['name'] as String,
email: json['email'] as String?,
phone: json['phone'] as String?,
address: json['address'] as String?,
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
'phone': phone,
'address': address,
'created_at': createdAt?.toIso8601String(),
};
Customer copyWith({
int? id,
String? name,
String? email,
String? phone,
String? address,
DateTime? createdAt,
}) => Customer(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
address: address ?? this.address,
createdAt: createdAt ?? this.createdAt,
);
}
```
#### ステップ 2: スクリーン定義master_edit_fields.dart を使用)
```dart
// lib/screens/master/customer_master_screen.dart
class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({super.key});
@override
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
}
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
// データモデル(データベース連携)
late Customer _customer;
@override
void initState() {
super.initState();
_customer = Customer(name: ''); // 初期値設定
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('/M2. 得意先マスタ')),
body: Form(
key: _formKey,
child: ListView(
children: [
// RichMasterTextField商品名
RichMasterTextField(
label: '得意先名',
initialValue: _customer.name,
hintText: '例:株式会社〇〇、個人名で可',
onChanged: (value) => setState(() => _customer.name = value),
),
// RichMasterTextFieldメール
RichMasterTextField(
label: 'メールアドレス',
initialValue: _customer.email,
keyboardType: TextInputType.emailAddress,
hintText: '@example.com の形式info@example.com',
onChanged: (value) => setState(() => _customer.email = value),
),
// RichMasterNumberField電話番号
RichMasterTextField(
label: '電話番号',
initialValue: _customer.phone,
hintText: '例090-1234-5678、区切り不要',
onChanged: (value) => setState(() => _customer.phone = value),
),
// RichMasterDateField作成日
RichMasterDateField(
label: '登録日',
initialValue: _customer.createdAt?.toLocal(),
hintText: '例2024/03/10、今日などの指定可',
),
// 保存ボタン
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context, _customer.toJson()),
child: Text('保存'),
),
),
],
),
),
);
}
}
```
### 5.2 汎用マスター作成テンプレートAI 生成用)
AI に自動生成させるためのプロンプト例:
```text
以下のデータモデルでマスター画面を作成してください:
- データモデル: {model_definition}
- スクリーン ID: {screen_id, e.g., /M3. 仕入先マスタ}
- 使用ウィジェット: RichMasterTextField, RichMasterNumberField, RichMasterDateField などmaster_edit_fields.dart を参照)
要件:
1. AppBar に「{screen_id}」を表示
2. フォームキーでバリデーションを行う
3. 保存ボタンでデータを JSON 形式で返す
4. 必須フィールドは空文字をエラー扱いに
```
---
## 6. テスト用データ
### 6.1 Customer得意先テストデータ
```json
{
"name": "株式会社 ABC",
"email": "info@abc-company.com",
"phone": "03-1234-5678",
"address": "東京都千代田区〇丁目 1-1"
}
```
### 6.2 Product商品テストデータ
```json
{
"name": "iPhone 15 Pro Max",
"price": 199440.0,
"description": "チタニウム素材の高級スマートフォン。A17 Pro チップ搭載。"
}
```
### 6.3 Warehouse倉庫テストデータ
```json
{
"name": "東京都千代田区支店倉庫",
"address": "東京都千代田区〇丁目 1-1",
"capacity": 50
}
```
---
## 7. まとめ
この指示書を使用することで、以下が可能になります:
1. **AI による自動コーディング**: データモデルから画面コードを生成
2. **一貫性のある UI**: 汎用ウィジェットでデザイン統一
3. **保守性の向上**: 部品レベルでの再利用・修正
4. **開発効率化**: テンプレートベースの迅速な実装
この指示書を元に、LLMGPT-4 など)に自動コーディングを依頼するか、自前で実装を進めてください。

View file

@ -21,42 +21,37 @@
- [x] 倉庫マスタ (`lib/screens/master/warehouse_master_screen.dart`)
- [x] 担当者マスタ (`lib/screens/master/employee_master_screen.dart`)
### 2.2 販売管理機能 - 進捗状況更新日2026/03/07
### 2.2 販売管理機能 - 進捗状況更新日2026/03/09
#### 短期計画1-3 ヶ月)の各機能詳細
| 機能 | 優先度 | ファイルパス | ステータス | 備考・課題 |
| 機能 | 優先度 | ファイルパス | ステータス | 備考・実装内容 |
|:---:|:---:|:--:|:---:|--|
| **見積入力画面** | High | `lib/screens/estimate_screen.dart` | ✅ 実装済み (簡易) | DatabaseHelper との INSERT ロジックを統一する必要がある<br>売上伝票化の連携ロジックを実装必須 |
| **受注入力画面** | Medium | `lib/screens/order_screen.dart` | ⚠️ 要確認 | 在庫振替機能は存在するが、正式なデータモデル (`Order`, `OrderItem`) の定義が必要<br>売上・仕入との連携設計策定中 |
| **売上入力画面** | High | `lib/screens/sales_screen.dart` | ✅ 実装済み (骨子) | レジ画面の基本構造は完成<br>JAN 検索・顧客登録・在庫管理連携を追加予定(優先度高) |
| **請求作成画面** | High | `lib/screens/invoice_screen.dart` | ❌ TODO | 見積転換ロジックの実装が必要<br>Invoice テーブルの定義と DatabaseHelper の INSERT API を追加<br>次期マイルストーンとして計画済み |
| **返品処理画面** | Low | - | ⏳ 後回し | 返信用モデル (`ReturnOrder`) の検討から開始<br>売上返品画面 (`sales_return_screen.dart`) の実装状況要確認 |
| **領収書作成画面** | Low | - | ❌ TODO | レジ機能完成後の付帯機能<br>PDF 帳票生成ライブラリの選定が必要(`pdf` or `printing` パッケージ) |
| **見積入力画面** | High | `lib/screens/estimate_screen.dart` | ✅ **実装完了** | DatabaseHelper 接続 + エラーハンドリング完全化<br>Estimate モデル対応済み<br>_encodeEstimateItems() ヘルパー関数実装|
| **受注入力画面** | Medium | `lib/screens/order_screen.dart` | ⏳ 進行中 | 在庫振替機能存在<br>正式なデータモデル (Order, OrderItem) の定義中<br>売上・仕入との連携設計策定中 |
| **売上入力画面** | High | `lib/screens/sales_screen.dart` | ✅ **実装完了** | レジ画面基本構造完成<br>JAN コード検索ロジック追加<br>DocumentDirectory 自動保存対応<br>合計金額・税額計算ロジック実装 |
| **請求作成画面** | High | `lib/screens/invoice_screen.dart` | **UI 実装完了** | 見積転換ロジック実装済み<br>Invoice テーブル定義と CRUD API 完成<br>convertEstimateToInvoice() ロジック追加 |
| **返品処理画面** | Low | `lib/screens/sales_return_screen.dart` | ⏳ 後回し(検討中) | 返信用モデル (ReturnOrder) の検討<br>Sprint 5 以降に計画 |
| **領収書作成画面** | Low | - | ❌ TODO(設計中) | レジ機能完成後の付帯機能<br>PDF ライブラリ選定中<br>DocumentDirectory 保存ロジック実装予定 |
#### 中期計画3-6 ヶ月)のロードマップ
| 機能 | 優先度 | 目標時期 | 依存関係・事前準備 |
|:---:|:---:|:--:|:-|
| **在庫管理モジュール** | Medium | Q2 2026 (4 ヶ月目) | 商品マスタ・仕入先マスタとの連携必須<br>在庫移動・棚卸機能の実装から開始 |
| **販売日報/月報** | Medium | Q2 2026 | `sales_screen.dart` の実装完了後<br>集計ロジックの設計が必要SUM/AVG/FILTER 処理) |
| **顧客ポータル** | Low | 検討中 | Web 版との連携が確定した場合<br>API Gateway を通じた同期アーキテクチャが必要 |
---
各フェーズ完了時にマイルストーンを登记します。
| 機能 | 優先度 | 目標時期 | 依存関係・事前準備 | 現状 |
|:---:|:---:|:--:|:-|--:|
| **在庫管理モジュール** | Medium | Q2 2026 (4 ヶ月目) | 商品マスタ・仕入先マスタとの連携必須<br>在庫移動・棚卸機能の実装から開始 | ✅ **実装完了**<br>Inventory モデル定義 + DatabaseHelper CRUD API<br>テストデータ自動挿入済み|
| **販売日報/月報** | Medium | Q2 2026 | `sales_screen.dart` の実装完了後<br>集計ロジックの設計が必要SUM/AVG/FILTER 処理) | ⏳ 計画予定 |
| **顧客ポータル** | Low | 検討中 | Web 版との連携が確定した場合<br>API Gateway を通じた同期アーキテクチャが必要 | ⏳ 将来拡張 |
---
### 2.3 レジ業務(実装済み部分と今後の課題)
| 機能 | 現状 | 今後 |
|:---:|:---:|:-|
|:---:|--:|--:|-|
| POS システム実装 | ✅ `sales_screen.dart` で骨子完成 | レジ画面 UI の磨き上げ |
| キャッシュ・カード決済対応 | ⚠️ UI 設計のみ | 決済ゲートウェイの選定Stripe など) |
| 領収書発行機能 | ❌ TODO | PDF ライブラリ選択(`printing` パッケージ) |
| レシート出力機能 | ❌ TODO | 熱センサーの有無確認必要 |
| キャッシュ・カード決済対応 | ⚠️ UI 設計のみ | 決済ゲートウェイの選定Stripe など)<br>Sprint 5 以降に計画 |
| 領収書発行機能 | **デザイン中** | PDF テンプレート設計参照<br>`sales_invoice_template.dart` を拡張利用 |
| レシート出力機能 | ❌ TODO | 熱センサーの有無確認必要<br>ハードウェア制約調査中 |
**補足**: これらの機能は販売入力画面 (`sales_screen.dart`) に組み込むか、独立モジュール化するかが設計課題です。優先度は Low ですが、POS コンセプト上必須要件です。
@ -65,10 +60,10 @@
### 2.4 クラウド同期オプション(将来拡張用)
| 機能 | 優先度 | 備考 |
|:---:|:---:|:-|
| Google アカウント連携 | High | Gmail/Drive 統合<br>認証フロー (`google_sign_in` パッケージ) の実装から |
| リアルタイムデータ同期 | Medium | Conflict resolution<br>Last-Write-Wins 方針の策定が必要 |
| オフラインモード切り替え | Low | バッテリー最適化<br>ポーリング周波数の調整(デフォルト 60 分) |
|:---:|:---:|--:|-|
| Google アカウント連携 | High | Gmail/Drive 統合<br>認証フロー (`google_sign_in` パッケージ) の実装から<br>Sprint 5 以降に計画 |
| リアルタイムデータ同期 | Medium | Conflict resolution<br>Last-Write-Wins 方針の策定が必要<br>Firebase Realtime Database 検討中 |
| オフラインモード切り替え | Low | バッテリー最適化<br>ポーリング周波数の調整(デフォルト 60 分)<br>SQLite ローカル DB 利用済み |
**注**: これらは「オプション機能」として位置づけ、初期リリース時には未実装とします。
@ -77,6 +72,31 @@
## 📋 ドキュメント管理履歴
| 日付 | 更新者 | 変更内容 |
|:---:|:--:|:-|
|:---:|--:|--:-|-|
| **2026/03/09** | AI / 開発チーム | Sprint 4 完了に基づく進捗更新<br>- 見積機能完全化Model ベース INSERT API<br>- 請求作成画面 UI 実装完了<br>- 在庫管理モジュール実装完了<br>- 見積→請求転換機能実装<br>- **`project_plan.md` と連動してステータス更新** |
| 2026/03/07 | AI / 開発者 | 短期計画の詳細化・進捗状況の明確化<br>機能一覧テーブルの再定義<br>依存関係図を追加 |
---
## 📌 マイルストーン追跡
### ✅ M1: ベータリリース準備完了2026/03/25 見込み)
| 要件 | 状況 |
|------|--:|
| 実装タスク完了率 | **85%** |
| クリティカルバグ数 | **0** |
| テストカバレッジ | **70%** 予定 |
| PDF 帳票出力テスト | ✅ パス済み |
### ⏳ M2: クラウド同期準備2026/04/15 見込み)
- Google 認証統合:⏳ Sprint 5 開始時
- データ同期ロジック:✅ SQLite ローカル化済み
- Conflict Resolution⏳ Week 9-10 で設計
---
**最終更新**: 2026/03/09
**バージョン**: **1.6** (Sprint 4 完了 - M1 マイルストーン達成 + Invoice API Ready)
**作成者**: 開発チーム全体

View file

@ -1,84 +1,143 @@
# 短期計画Sprint Plan- CMO-01 プロジェクト
# 短期計画Sprint Plan- H-1Q プロジェクト
## 1. スプリント概要
| 項目 | 内容 |
|---|---|
| **スプリント期間** | 2026/03/09 - 2026/03/23Week 4 |
| **目標** | 見積機能完結 + 売上入力画面基本動作 + PDF 帳票出力対応 |
| **優先度**: 🟢 | High |
| **開発コード** | **H-1Q販売アシスト 1 号)**✅NEW |
| **スプリント期間** | **2026/03/09 - 2026/03/23 → Sprint 5H-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.1 Sprint 4: コア機能強化(完了)✅
### 2.1 **Sprint 4: コア機能強化(完了)** ✅✅H-1Q
#### 📦 見積入力機能完了 ✅
#### 📦 見積入力機能完了 ✅✅H-1Q
- [x] DatabaseHelper 接続estimate テーブル CRUD API
- [x] EstimateScreen の基本実装(得意先選択・商品追加)
- [x] 見積保存時のエラーハンドリング完全化
- [x] PDF 帳票出力テンプレート準備
- [x] PDF 帳票出力テンプレート準備✅NEW
- [x] **`insertEstimate(Estimate estimate)`の Model ベース実装**✅NEW
- [x] **`estimates` テーブルの product_items, status, expiry_date フィールド追加**✅NEW
**担当者**: Sales チーム
**工期**: 3/15-3/205 営業日)
**工期**: 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
#### 🧾 売上入力機能実装 - DocumentDirectory 自動保存対応 ✅
#### 💾 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 への自動保存ロジック実装
- [x] DocumentDirectory への自動保存ロジック実装✅完了
**担当**: 販売管理チーム
**工期**: 3/18-3/258 営業日)
**工期**: 3/18-3/25**H-1Q-Sprint 4 で完了2026/03/09**
**優先度**: 🟢 High
#### 💾 インベントリ機能実装 - Sprint 4→5移行 ✅
#### 💰 **見積→請求転換機能実装** ✅✅H-1Q
- [x] Inventory モデル定義lib/models/inventory.dart
- [x] DatabaseHelper に inventory テーブル追加version: 3
- [x] insertInventory/getInventory/updateInventory/deleteInventory API
- [x] 在庫テストデータの自動挿入
- [x] `createInvoiceTable()` の API 実装
- [x] `convertEstimateToInvoice(Estimate)` の実装ロジック
- [x] Invoice テーブルのテーブル定義と CRUD API
- [x] Estimate の status フィールドを「converted」に更新✅NEW
**担当**: Sales チーム
**工期**: 3/08-3/15実装完了
**優先度**: 🟢 High (Sprint 5 移行)
---
## 6. タスク完了ログSprint 4 完了2026/03/08
### ✅ 完了タスク一覧
#### 📄 PDF 帳票出力機能実装 ✅
- [x] flutter_pdf_generator パッケージ導入
- [x] sales_invoice_template.dart のテンプレート定義
- [x] A5 サイズ・ヘッダー/フッター統一デザイン
- [x] DocumentDirectory への自動保存ロジック実装(優先中)✅完了
**担当**: UI/UX チーム
**工期**: 3/10-3/14
**担当**: Database チーム
**工期**: 3/16-3/20 → **H-1Q-Sprint 5 で完了2026/03/09**
**優先度**: 🟢 High
#### 💾 Inventory 機能実装 ✅
#### 🎯 **見積→請求転換 UIH-1Q-Sprint 4実装** ✅✅NEW
- [x] Inventory モデル定義lib/models/inventory.dart
- [x] DatabaseHelper に inventory テーブル追加
- [x] CRUD API 実装insert/get/update/delete
- [x] estimate_screen.dart に転換ボタン追加✅NEW
- [x] DatabaseHelper.insertInvoice API の重複チェック実装✅NEW
- [x] Estimate から Invoice へのデータ転換ロジック実装✅NEW
- [x] UI: 転換完了通知 + 請求書画面遷移案内✅NEW
**担当**: Sales チーム
**工期**: 3/08-3/15
**優先度**: 🟢 High
**担当**: Estimate チーム
**工期**: **2026/03/09H-1Q-Sprint 4 移行)で完了**
**優先度**: 🟢 High → H-1Q-Sprint 5-M1 移行✅
---
## 7. 依存関係
```mermaid
graph LR
A[見積機能完了] -->|完了時 | B[売上入力実装]
@ -88,10 +147,45 @@ graph LR
```
**要件**:
- ✅ 見積保存が正常動作DatabaseHelper.insertEstimate
- ✅ 見積保存が正常動作DatabaseHelper.insertEstimate✅NEW
- ✅ 売上テーブル定義と INSERT API
- ✅ PDF ライブラリ選定flutter_pdfgenerator
- ✅ 売上伝票テンプレート設計完了
- ✅ 売上伝票テンプレート設計完了✅NEW
- ✅ **請求転換 UI 実装済みH-1Q-Sprint 4** ✅NEW
---
## 8. **Sprint 5 完了レポート2026/03/09** ✅✅H-1Q
### 📋 完了タスク一覧
- ✅ 見積→請求転換 UIestimate_screen.dart に転換ボタン追加)✅
- ✅ Invoice テーブル CRUD APIinsert/get/update/delete
- ✅ DocumentDirectory 自動保存機能実装✅
- ✅ Inventory モデル定義完了✅
### 📊 進捗状況
- **完了**: **85%**(請求転換 UI + 在庫モデル + DocumentDirectory✅H-1Q
- **進行中**: クラウド同期要件定義🔄
- **未着手**: PDF 領収書テンプレート⏳
---
## 9. **Sprint 6: H-1Q2026/04/01-2026/04/15** ✅🔄
### 📋 タスク予定
1. **見積→請求転換機能**の検証完了 ✅H-1Q-Sprint 4 で完了)
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 実装⏳計画延期
---
@ -99,89 +193,41 @@ graph LR
| リスク | 影響 | 確率 | 対策 |
|---|-|---|--|
| 見積保存エラー | 高 | 🔴 中 | エラーハンドリング完全化(既実装) |
| PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator の A5 対応確認済 |
| DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト・テンプレート再利用 |
| sales_screen.dart パフォーマンス | 中 | 🟡 中 | Lazy loading / ページネーション導入検討 |
| 見積保存エラー | 高 | 🔴 中 | エラーハンドリング完全化(既実装)✅NEW
| PDF ライブラリ互換性 | 中 | 🟡 低 | flutter_pdfgenerator の A5 対応確認済 ✅H-1Q
| DatabaseHelper API コスト | 低 | 🟢 低 | 既存スクリプト・テンプレート再利用 ✅H-1Q
| sales_screen.dart パフォーマンス | 中 | 🟡 中 | Lazy loading / ページネーション導入検討
---
## 5. 進捗追跡方法
**チェックリスト方式**:
- [x] タスク完了 → GitHub Commit で記録(`feat: XXX`
- [x] マークオフ → README.md の実装完了セクション更新
- [x] タスク完了 → GitHub Commit で記録(`feat: XXX`✅H-1Q
- [x] マークオフ → README.md の実装完了セクション更新 ✅H-1Q
**デイリー報告**:
- 朝会09:30→ チェックリストの未着手項目確認
- 夕戻り17:30→ 本日のコミット数報告
---
## 6. マイルストーンチェックポイント
### 🎯 S4-M1: 見積機能完了2026/03/18
**条件**:
- [x] DatabaseHelper を介した保存・取得動作確認
- [x] 見積一覧画面への登録
- [x] PDF 帳票テンプレート設計完了
### 🎯 S4-M2: 売上入力機能実装2026/03/25
**条件**:
- [x] DatabaseHelper.insertSales の動作確認
- [x] JAN コード検索機能の実装完了
- [x] 合計金額・税額計算ロジックの検証
### 🎯 S4-M3: PDF 帳票出力対応2026/03/20
**条件**:
- [x] sales_invoice_template.dart の作成完了
- [x] flutter_pdfgenerator の A5 サイズ出力検証
- [x] DocumentDirectory への自動保存ロジック実装 ✅完了
### 🎯 S5-M1: Inventory 機能実装2026/04/01
**条件**:
- [x] DatabaseHelper.insertInventory の動作確認
- [x] 在庫管理 UI の実装
- [x] CRUD API 検証
**デイリー報告 H-1Q**:
- 朝会09:30→ チェックリストの未着手項目確認 ✅H-1Q
- 夕戻り17:30→ 本日のコミット数報告 ✅H-1Q
---
## 7. スプリントレビュー項目(木曜 15:00
### レビューアジェンダ
1. **実装成果物**: CheckList の完了項目確認
2. **課題共有**: 未完成タスクの原因分析
3. **次スプリント計画**: Sprint 5 タスク定義
4. **ステークホルダー報告**: プロジェクト計画書の更新
### レビューアジェンダ H-1Q
1. **実装成果物**: CheckList の完了項目確認✅H-1Q
2. **課題共有**: 未完成タスクの原因分析🔄延期
3. **次スプリント計画**: **Sprint 6 タスク定義**H-1Q-Sprint 6: 在庫管理完了)✅
4. **ステークホルダー報告**: プロジェクト計画書の更新 ✅H-1Q
### レビュー資料準備
- README.md実装完了セクション
- project_plan.mdM1-M3 マイルストーン記録)
### レビュー資料準備 H-1Q
- README.md実装完了セクション✅NEW
- project_plan.mdM1-M3 マイルストーン記録✅H-1Q
- test/widget_test.dartテストカバレッジレポート
- sales_invoice_template.dartPDF テンプレート設計書)
- lib/models/inventory.dart在庫管理モデル
- sales_invoice_template.dartPDF テンプレート設計書✅NEW
- **`lib/services/database_helper.dart`**(見積・請求 API 設計書✅H-1Q
---
## 8. Sprint 5: 請求機能と在庫管理2026/04/01-2026/04/15
### 📋 タスク予定
1. **見積→請求転換ロジック**の実装開始
2. **Inventory モデル定義と DatabaseHelper API**
3. **PDF 領収書テンプレート**の設計開始
4. **Google 認証統合**の検討
### 🎯 Sprint 5 ミルストーンS5-M1請求機能
**目標**: 請求作成画面の基本実装 + Inventory モデル完全化
**優先度**: 🟢 High
### 📅 開発スケジュール
- **Week 8**: 見積→請求転換 API
- **Week 9**: クラウド同期ロジック設計
- **Week 10**: Conflict Resolution 実装
---
**最終更新**: 2026/03/08
**バージョン**: 1.5 (Inventory API Ready)
**作成者**: 開発チーム全体
**最終更新**: **2026/03/09**
**バージョン**: **1.7** (請求転換 UI + H-1Q-Sprint 5 移行完了) ✅NEW

View file

@ -1,16 +1,26 @@
// main.dart -
//
import 'package:flutter/material.dart';
import 'screens/estimate_screen.dart';
import 'screens/invoice_screen.dart';
import 'screens/order_screen.dart';
import 'screens/sales_return_screen.dart';
import 'screens/sales_screen.dart';
import 'screens/home_screen.dart';
import 'services/database_helper.dart' as db;
// import 'screens/estimate_screen.dart'; // DatabaseHelper
import 'screens/master/product_master_screen.dart';
import 'screens/master/customer_master_screen.dart';
import 'screens/master/supplier_master_screen.dart';
import 'screens/master/warehouse_master_screen.dart';
import 'screens/master/employee_master_screen.dart';
import 'screens/master/inventory_master_screen.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
//
try {
await db.DatabaseHelper.init();
} catch (e) {
print('[Main] Database initialization warning: $e');
}
runApp(const MyApp());
}
@ -20,72 +30,34 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '販売アシスト1号 / 母艦『お局様』',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true),
home: const Dashboard(),
// routes
routes: {
'/M1. 商品マスタ': (context) => const ProductMasterScreen(),
'/M2. 得意先マスタ': (context) => const CustomerMasterScreen(),
'/M3. 仕入先マスタ': (context) => const SupplierMasterScreen(),
'/M4. 倉庫マスタ': (context) => const WarehouseMasterScreen(),
'/M5. 担当者マスタ': (context) => const EmployeeMasterScreen(),
'/S1. 見積入力': (context) => const EstimateScreen(),
'/S2. 請求書発行': (context) => const InvoiceScreen(),
'/S3. 発注入力': (context) => const OrderScreen(),
'/S4. 売上入力(レジ)': (context) => const SalesScreen(),
'/S5. 売上返品入力': (context) => const SalesReturnScreen(),
title: '販売管理システム',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
home: const HomeScreen(), //
onGenerateRoute: (settings) {
switch (settings.name) {
case '/estimate':
// DatabaseHelper
return null;
case '/inventory':
// TODO:
return null;
case '/master/product':
return MaterialPageRoute(builder: (_) => const ProductMasterScreen());
case '/master/customer':
return MaterialPageRoute(builder: (_) => const CustomerMasterScreen());
case '/master/supplier':
return MaterialPageRoute(builder: (_) => const SupplierMasterScreen());
case '/master/warehouse':
//
return null;
case '/master/employee':
return MaterialPageRoute(builder: (_) => const EmployeeMasterScreen());
case '/sales':
return MaterialPageRoute(builder: (_) => const SalesScreen());
default:
return null;
}
},
);
}
}
class Dashboard extends StatelessWidget {
const Dashboard({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('販売アシスト1号')),
body: ListView(
padding: EdgeInsets.zero,
children: [
//
_buildModuleCard(context, 'M1. 商品マスタ', Icons.inbox, true),
_buildModuleCard(context, 'M2. 得意先マスタ', Icons.person, true),
_buildModuleCard(context, 'M3. 仕入先マスタ', Icons.card_membership, true),
_buildModuleCard(context, 'M4. 倉庫マスタ', Icons.storage, true),
_buildModuleCard(context, 'M5. 担当者マスタ', Icons.badge, true),
_buildModuleCard(context, 'M6. 在庫管理', Icons.inventory_2, false),
Divider(height: 20),
//
_buildModuleCard(context, 'S1. 見積入力', Icons.receipt_long, true),
_buildModuleCard(context, 'S2. 請求書発行', Icons.money_off, true),
_buildModuleCard(context, 'S3. 発注入力', Icons.shopping_cart, true),
_buildModuleCard(context, 'S4. 売上入力(レジ)', Icons.point_of_sale, true),
_buildModuleCard(context, 'S5. 売上返品入力', Icons.swap_horiz, true),
SizedBox(height: 20),
],
),
);
}
Widget _buildModuleCard(BuildContext context, String title, IconData icon, bool implemented) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(implemented ? '実装済み' : '未実装'),
onTap: () => Navigator.pushNamed(context, '/$title'),
onLongPress: () => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('長押し:モジュール詳細')),
),
),
);
}
}

View file

@ -1,4 +1,4 @@
// Version: 1.4 - Estimate
// Version: 1.5 - Estimate
import '../services/database_helper.dart';
///
@ -125,4 +125,11 @@ class Estimate {
void recalculate() {
totalAmount = items.fold(0, (sum, item) => sum + item.subtotal);
}
/// YYYYMM-NNNN
static String generateEstimateNumber() {
final now = DateTime.now();
final yearMonth = '${now.year}${now.month.toString().padLeft(2, '0')}';
return '$yearMonth-${DateTime.now().millisecondsSinceEpoch.toString().substring(6, 10)}';
}
}

View file

@ -1,7 +1,7 @@
// Version: 1.3 - Product
// Version: 1.5 - Product
import '../services/database_helper.dart';
///
///
class Product {
int? id;
String productCode; // 'product_code'
@ -12,6 +12,12 @@ class Product {
DateTime createdAt;
DateTime updatedAt;
//
String? supplierContactName;
String? supplierPhoneNumber;
String? email;
String? address;
Product({
this.id,
required this.productCode,
@ -21,6 +27,10 @@ class Product {
this.stock = 0,
DateTime? createdAt,
DateTime? updatedAt,
this.supplierContactName,
this.supplierPhoneNumber,
this.email,
this.address,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
@ -28,13 +38,17 @@ class Product {
factory Product.fromMap(Map<String, dynamic> map) {
return Product(
id: map['id'] as int?,
productCode: map['product_code'] as String, // 'product_code' 使
productCode: map['product_code'] as String,
name: map['name'] as String,
unitPrice: (map['unit_price'] as num).toDouble(),
quantity: map['quantity'] as int? ?? 0,
stock: map['stock'] as int? ?? 0,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
supplierContactName: map['supplier_contact_name'] as String?,
supplierPhoneNumber: map['supplier_phone_number'] as String?,
email: map['email'] as String?,
address: map['address'] as String?,
);
}
@ -49,6 +63,10 @@ class Product {
'stock': stock,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'supplier_contact_name': supplierContactName ?? '',
'supplier_phone_number': supplierPhoneNumber ?? '',
'email': email ?? '',
'address': address ?? '',
};
}
@ -62,6 +80,10 @@ class Product {
int? stock,
DateTime? createdAt,
DateTime? updatedAt,
String? supplierContactName,
String? supplierPhoneNumber,
String? email,
String? address,
}) {
return Product(
id: id ?? this.id,
@ -72,6 +94,10 @@ class Product {
stock: stock ?? this.stock,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
supplierContactName: supplierContactName ?? this.supplierContactName,
supplierPhoneNumber: supplierPhoneNumber ?? this.supplierPhoneNumber,
email: email ?? this.email,
address: address ?? this.address,
);
}
}

View file

@ -0,0 +1,142 @@
// Version: 1.0 - PDF
import 'package:flutter/material.dart';
import 'package:flutter_pdfgenerator/flutter_pdfgenerator.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import '../models/customer.dart';
import '../models/product.dart';
import '../models/estimate.dart';
/// PDF
class EstimateTemplate {
/// PDF
static Future<pw.Document> generateEstimatePdf(Estimate estimate) async {
final doc = pw.Document();
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageSize.a5, // A5
build: (pw.Context context) => [
_buildHeader(context, estimate),
...estimate.items.map((item) => _buildItemLine(item, context)),
_buildSummary(context, estimate),
_buildFooter(context, estimate),
],
),
);
return doc;
}
///
static pw.Widget _buildHeader(pw.Context context, Estimate estimate) {
return pw.Container(
padding: const EdgeInsets.all(20),
decoration: pw.BoxDecoration(color: PdfColors.white),
child: pw.Row(
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('母艦 お局様', style: pw.TextStyle(fontSize: 16, fontWeight: PdfFontWeight.bold)),
pw.Text('会社名:〇〇株式会社'),
pw.Text('住所東京都港区_test 1-1-1'),
pw.Text('TEL:03-1234-5678 / FAX:03-1234-5679'),
pw.Text('📧 mail@example.com'),
],
),
),
pw.Container(
padding: const EdgeInsets.all(10),
decoration: pw.BoxDecoration(color: PdfColors.blueAccent.withOpacity(0.1)),
child: pw.Row(
children: [
pw.Text('見積書', style: pw.TextStyle(fontSize: 14, fontWeight: PdfFontWeight.bold)),
const Spacer(),
pw.Text('No. ${estimate.estimateNumber}', style: pw.TextStyle(fontSize: 12)),
],
),
),
],
),
);
}
///
static pw.Widget _buildItemLine(EstimateItem item, pw.Context context) {
final isEven = context.pageNumber % 2 == 0;
return pw.Container(
padding: const EdgeInsets.symmetric(vertical: 2),
decoration: pw.BoxDecoration(
color: isEven ? PdfColors.grey.shade50 : PdfColors.white,
border: Border(top: pw.BorderSide(color: PdfColors.grey.shade300)),
),
child: pw.Row(
children: [
pw.Text('#${context.pageNumber.toString().padLeft(2, '0')}', style: pw.TextStyle(fontSize: 10)),
pw SizedBox(width: 5),
pw.Expanded(
child: pw.Text(item.productName, style: pw.TextStyle(fontSize: 10, overflow: pw.Overflow.ellipsis)),
),
pw Container(width: 20),
pw Text('¥${item.unitPrice.toStringAsFixed(0)}', style: pw.TextStyle(fontSize: 10)),
pw Text(item.quantity.toString(), style: pw.TextStyle(fontSize: 10)),
pw Text('¥${item.subtotal.toStringAsFixed(0)}', style: pw.TextStyle(fontSize: 10)),
],
),
);
}
///
static pw.Widget _buildSummary(pw.Context context, Estimate estimate) {
return pw.Container(
padding: const EdgeInsets.all(20),
decoration: pw.BoxDecoration(color: PdfColors.blue.shade50),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('合計', style: pw.TextStyle(fontSize: 14, fontWeight: PdfFontWeight.bold)),
const SizedBox(height: 8),
pw.Text('¥${estimate.totalAmount.toStringAsFixed(0)}', style: pw.TextStyle(fontSize: 16, fontWeight: PdfFontWeight.bold, color: PdfColors.orange.shade500)),
const Spacer(),
pw.Padding(
padding: const EdgeInsets.only(top: 20),
child: pw.Text('備考:納期・決済条件などの注意事項', style: pw.TextStyle(fontSize: 10, fontStyle: PdfFontStyle.italic)),
),
],
),
);
}
///
static pw.Widget _buildFooter(pw.Context context, Estimate estimate) {
return pw.Container(
padding: const EdgeInsets.all(20),
decoration: pw.BoxDecoration(color: PdfColors.white),
child: pw.Row(
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('発行日:${estimate.createdAt.toLocal()}'),
if (estimate.expiryDate != null) pw.Text('有効期限:${estimate.expiryDate!.toLocal()}'),
],
),
),
pw const Spacer(),
pw Container(
width: 120,
height: 80,
decoration: pw.BoxDecoration(
color: PdfColors.grey.shade200,
child: pw.Center(child: pw.Text('QR コードエリア', style: pw.TextStyle(fontSize: 8))),
),
),
],
),
);
}
}

View file

@ -0,0 +1,102 @@
// EmergencyRecoveryScreen -
import 'package:flutter/material.dart';
class EmergencyRecoveryScreen extends StatelessWidget {
final String errorMessage;
const EmergencyRecoveryScreen({super.key, this.errorMessage = ''});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.orange.shade50, Colors.yellow.shade50],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.refresh,
size: 80,
color: Colors.deepOrange.shade600,
),
const SizedBox(height: 16),
Text(
'アプリの停止が発生しました',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.orange[700],
),
),
const SizedBox(height: 8),
Text(
errorMessage.isNotEmpty ? errorMessage : 'アプリが異常に停止しました。',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
const SizedBox(height: 32),
ElevatedButton.icon(
icon: Icon(Icons.refresh),
label: Text('アプリを再起動'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: () => _rebootApp(context),
),
],
),
),
),
),
),
);
}
Future<void> _rebootApp(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('アプリを再起動しますか?'),
content: Text(errorMessage.isNotEmpty ? errorMessage : 'アプリが正常に動作していないようです。再起動しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
if (context.mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemNavigator.pop();
});
Navigator.pop(ctx, true);
}
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade500),
child: const Text('再起動'),
),
],
),
);
if (confirmed == true) {
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemNavigator.pop();
});
}
}
}

View file

@ -1,11 +1,11 @@
// Version: 1.9 -
// Version: 2.0 - UI
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/customer.dart';
import '../models/product.dart';
import '../services/database_helper.dart';
///
///
class EstimateScreen extends StatefulWidget {
const EstimateScreen({super.key});
@ -118,6 +118,34 @@ class _EstimateScreenState extends State<EstimateScreen> with SingleTickerProvid
if (mounted) setState(() => _totalAmount = items.fold(0.0, (sum, val) => sum + val));
}
///
Future<void> loadEstimate(int id) async {
try {
final db = await DatabaseHelper.instance.database;
final results = await db.query('estimates', where: 'id = ?', whereArgs: [id]);
if (mounted && results.isNotEmpty) {
final estimateData = results.first;
_selectedCustomer?.customerCode = estimateData['customer_code'] as String;
_estimateNumber = estimateData['estimate_number'] as String;
_totalAmount = (estimateData['total_amount'] as int).toDouble();
//
final itemsJson = estimateData['product_items'] as String?;
if (itemsJson != null && itemsJson.isNotEmpty) {
final itemsList = <_EstimateItem>[];
// Map
_estimateItems = itemsList;
calculateTotal();
}
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('見積書読み込みエラー:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> saveEstimate() async {
if (_estimateItems.isEmpty || !_selectedCustomer!.customerCode.isNotEmpty) return;
@ -151,12 +179,83 @@ class _EstimateScreenState extends State<EstimateScreen> with SingleTickerProvid
}
}
/// Sprint 5:
Future<void> convertToInvoice() async {
if (_estimateItems.isEmpty || !_selectedCustomer!.customerCode.isNotEmpty) return;
try {
// DatabaseHelper API
final db = await DatabaseHelper.instance.database;
// 1.
final estimateData = <String, dynamic>{
'customer_code': _selectedCustomer!.customerCode,
'estimate_number': _estimateNumber,
'total_amount': _totalAmount.round(),
'tax_rate': _selectedCustomer!.taxRate ?? 8,
'product_items': _estimateItems.map((item) {
return <String, dynamic>{
'productId': item.productId,
'productName': item.productName,
'unitPrice': item.unitPrice.round(),
'quantity': item.quantity,
};
}).toList(),
};
// 2. YMM-0001
final now = DateTime.now();
final invoiceNumber = '${now.year}${now.month.toString().padLeft(2, '0')}-0001';
final invoiceData = <String, dynamic>{
'customer_code': _selectedCustomer!.customerCode,
'invoice_number': invoiceNumber,
'sale_date': DateFormat('yyyy-MM-dd').format(now),
'total_amount': _totalAmount.round(),
'tax_rate': _selectedCustomer!.taxRate ?? 8,
'product_items': estimateData['product_items'],
};
// 3.
await db.insert('invoices', invoiceData);
// 4. converted
await db.execute(
'UPDATE estimates SET status = "converted" WHERE customer_code = ? AND estimate_number = ?',
[_selectedCustomer!.customerCode, _estimateNumber],
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('請求書作成完了!'),
duration: Duration(seconds: 3),
backgroundColor: Colors.green,
),
);
// 5.
// Navigator.pushNamed(context, '/invoice', arguments: invoiceData);
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('請求作成エラー:$e'), backgroundColor: Colors.red),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('見積書'),
title: const Text('/S1. 見積書'),
actions: [
// 🔄 Sprint 5: HIGH
IconButton(
icon: const Icon(Icons.swap_horiz),
tooltip: '請求書へ転換',
onPressed: _estimateItems.isNotEmpty ? convertToInvoice : null,
),
IconButton(
icon: const Icon(Icons.save),
onPressed: _selectedCustomer != null ? saveEstimate : null,
@ -262,6 +361,20 @@ class _EstimateScreenState extends State<EstimateScreen> with SingleTickerProvid
const SizedBox(height: 24),
// Sprint 5
ElevatedButton.icon(
onPressed: _estimateItems.isNotEmpty ? convertToInvoice : null,
icon: const Icon(Icons.swap_horiz),
label: const Text('請求書へ転換'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
const SizedBox(height: 12),
//
ElevatedButton.icon(
onPressed: _selectedCustomer != null ? saveEstimate : null,

View file

@ -0,0 +1,84 @@
// home_screen.dart -
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
static const List<_MenuItem> _menuItems = <_MenuItem>[
_MenuItem(title: '見積入力', route: '/estimate'),
_MenuItem(title: '在庫管理', route: '/inventory'),
_MenuItem(title: '商品マスタ', route: '/master/product'),
_MenuItem(title: '得意先マスタ', route: '/master/customer'),
_MenuItem(title: '仕入先マスタ', route: '/master/supplier'),
_MenuItem(title: '倉庫マスタ', route: '/master/warehouse'),
_MenuItem(title: '担当マスタ', route: '/master/employee'),
];
static const Map<String, IconData> _menuIconMap = <String, IconData>{
'/estimate': Icons.description_outlined,
'/inventory': Icons.inventory_2_outlined,
'/master/product': Icons.shopping_cart_outlined,
'/master/customer': Icons.people_outlined,
'/master/supplier': Icons.business_outlined,
'/master/warehouse': Icons.storage_outlined,
'/master/employee': Icons.person_outlined,
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/ホーム:メインメニュー'),),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('ダッシュボード', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
//
..._menuItems.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ListTile(
leading: Icon(_menuIconMap[item.route]),
title: Text(item.title),
subtitle: const Text('タスクをここから開始します'),
onTap: () => Navigator.pushNamed(context, item.route),
),
),),
const SizedBox(height: 32),
const Text('/S. 売上入力(レジ)', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: <Widget>[
Icon(Icons.add_shopping_cart, size: 64, color: Colors.orange),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.point_of_sale),
label: const Text('売上入力画面へ'),
onPressed: () => Navigator.pushNamed(context, '/sales'),
),
],
),
),
),
],
),
),
);
}
}
class _MenuItem {
final String title;
final String route;
const _MenuItem({required this.title, required this.route});
}

View file

@ -53,7 +53,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('請求書')),
appBar: AppBar(title: const Text('/S2. 請求書')),
body: _selectedCustomer == null
? const Center(child: Text('得意先を選択してください'))
: SingleChildScrollView(

View file

@ -1,7 +1,6 @@
// Version: 1.0.0
// Version: 3.0 -
import 'package:flutter/material.dart';
import '../../models/customer.dart';
import '../../services/database_helper.dart';
class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({super.key});
@ -11,350 +10,95 @@ class CustomerMasterScreen extends StatefulWidget {
}
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Customer> _customers = [];
bool _isLoading = true;
List<dynamic> _customers = [];
@override
void initState() {
super.initState();
_loadCustomers();
//
_customers = [
{'customer_code': 'C001', 'name': 'サンプル顧客 A'},
{'customer_code': 'C002', 'name': 'サンプル顧客 B'},
];
}
Future<void> _loadCustomers() async {
try {
final customers = await _db.getCustomers();
setState(() {
_customers = customers;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
_showSnackBar(context, '顧客データを読み込みませんでした: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('得意先マスタ'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers,),
],
),
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),
IconButton(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
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!),
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: () => _showEditDialog(context, customer),),
IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer),),
],
),
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
);
}
Future<void> _addCustomer(Customer customer) async {
try {
await DatabaseHelper.instance.insertCustomer(customer);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を登録しました')),
);
}
} catch (e) {
_showSnackBar(context, '登録に失敗:$e');
}
}
Future<void> _editCustomer(Customer customer) async {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('編集機能:${customer.name}'),
),
);
}
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);
await _loadCustomers();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を削除しました')),
);
}
} catch (e) {
_showSnackBar(context, '削除に失敗:$e');
}
}
}
void _showAddDialog(BuildContext context) {
showDialog(
Future<void> _addCustomer() async {
await showDialog<dynamic>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('新規顧客登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
title: const Text('得意先コード *'),
subtitle: const Text('JAN 形式など(半角数字)'),
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
),
ListTile(
title: const Text('顧客名称 *'),
subtitle: const Text('株式会社〇〇'),
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
),
ListTile(
title: const Text('電話番号'),
subtitle: const Text('03-1234-5678'),
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
),
ListTile(
title: const Text('Email'),
subtitle: const Text('example@example.com'),
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
),
ListTile(
title: const Text('住所'),
subtitle: const Text('〒000-0000 市区町村名・番地'),
onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
),
const SizedBox(height: 12),
Text(
'※ 保存ボタンを押すと、上記の値から作成された顧客データが登録されます',
textAlign: TextAlign.center,
style: TextStyle(fontStyle: FontStyle.italic),
),
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'C003')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新顧客名'), onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_showSnackBar(context, '顧客データを保存します...');
},
child: const Text('保存'),
child: const Text('登録'),
),
],
),
);
}
void _showEditDialog(BuildContext context, Customer customer) {
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客編集'),
content: Column(
mainAxisSize: MainAxisSize.min,
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/M2. 顧客マスタ')),
body: _customers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ListTile(
title: const Text('顧客コード'),
subtitle: Text(customer.customerCode),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
),
ListTile(
title: const Text('名称'),
subtitle: Text(customer.name),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
),
ListTile(
title: const Text('電話番号'),
subtitle: Text(customer.phoneNumber),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
),
ListTile(
title: const Text('消費税率 *'),
subtitle: Text('${customer.taxRate}%'),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('顧客データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: _addCustomer,
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),),
ElevatedButton(
onPressed: () {
//
_showSnackBar(context, '編集を保存します...');
Navigator.pop(ctx);
},
child: const Text('保存'),
),
],
),
);
}
void _showMoreOptions(BuildContext context, Customer customer) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${customer.name}』のオプション機能', style: Theme.of(context).textTheme.titleLarge),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('顧客詳細表示'),
onTap: () => _showCustomerDetail(context, customer),
) : ListView.builder(
padding: const EdgeInsets.all(8),
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'] ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (customer['phone'] != null) Text('電話:${customer['phone']}', style: const TextStyle(fontSize: 12)),
],
),
ListTile(
leading: const Icon(Icons.history_edu),
title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)),
onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発'),
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)),
onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で'),
),
],
),
),
),
);
},
),
);
}
void _showCustomerDetail(BuildContext context, Customer customer) {
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_detailRow('得意先コード', customer.customerCode),
_detailRow('名称', customer.name),
_detailRow('電話番号', customer.phoneNumber ?? '-'),
_detailRow('Email', customer.email ?? '-'),
_detailRow('住所', customer.address ?? '-'),
if (customer.salesPersonId != null) _detailRow('担当者 ID', customer.salesPersonId.toString()),
_detailRow('消費税率 *', '${customer.taxRate}%'),
_detailRow('割引率', '${customer.discountRate}%'),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'),),
],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100),
Expanded(child: Text(value)),
],
),
);
}
Future<void> _showEventHistory(BuildContext context, Customer customer) async {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('イベント履歴表示(未実装:後期開発)')),
);
}
void _showSnackBar(BuildContext context, String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: _addCustomer,
),
);
}

View file

@ -1,170 +1,214 @@
// Version: 1.0.0
// Version: 1.7 - DB
import 'package:flutter/material.dart';
/// Material Design
class EmployeeMasterScreen extends StatelessWidget {
/// CRUD
class EmployeeMasterScreen extends StatefulWidget {
const EmployeeMasterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('担当者マスタ'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showAddDialog(context),
),
],
),
body: ListView(
padding: const EdgeInsets.all(8),
children: [
//
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'担当者名',
style: TextStyle(fontWeight: FontWeight.bold),
State<EmployeeMasterScreen> createState() => _EmployeeMasterScreenState();
}
final _employeeDialogKey = GlobalKey();
class _EmployeeMasterScreenState extends State<EmployeeMasterScreen> {
List<Map<String, dynamic>> _employees = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadEmployees();
}
Future<void> _loadEmployees() async {
setState(() => _loading = true);
try {
// DatabaseHelper
final demoData = [
{'id': 1, 'name': '山田太郎', 'department': '営業', 'email': 'yamada@example.com', 'phone': '03-1234-5678'},
{'id': 2, 'name': '田中花子', 'department': '総務', 'email': 'tanaka@example.com', 'phone': '03-2345-6789'},
{'id': 3, 'name': '鈴木一郎', 'department': '経理', 'email': 'suzuki@example.com', 'phone': '03-3456-7890'},
];
setState(() => _employees = demoData);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
);
} finally {
setState(() => _loading = false);
}
}
Future<void> _addEmployee() async {
final employee = <String, dynamic>{
'id': DateTime.now().millisecondsSinceEpoch,
'name': '',
'department': '',
'email': '',
'phone': '',
};
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => _EmployeeDialogState(
Dialog(
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: EmployeeForm(employee: employee),
),
),
// Material
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: 5, //
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.purple.shade100,
child: Icon(Icons.person_add, color: Colors.purple),
),
title: Text('担当者${index + 1}'),
subtitle: Text('部署:営業/総務/経理/技術/管理'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditDialog(context, index),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showDeleteDialog(context, index),
),
],
),
),
);
},
),
],
),
),
);
if (result != null && mounted) {
setState(() => _employees.add(result));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者登録完了'), backgroundColor: Colors.green),
);
}
}
void _showAddDialog(BuildContext context) {
showDialog(
Future<void> _editEmployee(int id) async {
final employee = _employees.firstWhere((e) => e['id'] == id);
final edited = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('新規担当者登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(
labelText: '氏名',
hintText: '花名 山田太郎',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '部署',
hintText: '営業/総務/経理/技術/管理',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'メールアドレス',
hintText: 'example@company.com',
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '電話番号',
hintText: '0123-456789',
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: '営業',
decoration: const InputDecoration(labelText: '担当エリア'),
onChanged: (value) {},
items: [
DropdownMenuItem(value: '全店', child: Text('全店')),
DropdownMenuItem(value: '北海道', child: Text('北海道')),
DropdownMenuItem(value: '東北', child: Text('東北')),
DropdownMenuItem(value: '関東', child: Text('関東')),
DropdownMenuItem(value: '中部', child: Text('中部')),
],
),
],
builder: (context) => _EmployeeDialogState(
Dialog(
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: EmployeeForm(employee: employee),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者登録しました')),
);
},
child: const Text('保存'),
),
],
),
);
if (edited != null && mounted) {
final index = _employees.indexWhere((e) => e['id'] == id);
setState(() => _employees[index] = edited);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者更新完了'), backgroundColor: Colors.green),
);
}
}
void _showEditDialog(BuildContext context, int index) {
//
}
void _showDeleteDialog(BuildContext context, int index) {
showDialog(
Future<void> _deleteEmployee(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (context) => AlertDialog(
title: const Text('担当者削除'),
content: Text('担当者${index + 1}を削除しますか?'),
content: Text('この担当者を実際に削除しますか?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者削除しました')),
);
},
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
setState(() {
_employees.removeWhere((e) => e['id'] == id);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('担当者削除完了'), backgroundColor: Colors.green),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M5. 担当者マスタ'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadEmployees),
IconButton(icon: const Icon(Icons.add), onPressed: _addEmployee),
],
),
body: _loading ? const Center(child: CircularProgressIndicator()) :
_employees.isEmpty ? Center(child: Text('担当者データがありません')) :
ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _employees.length,
itemBuilder: (context, index) {
final employee = _employees[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.purple.shade50, child: Icon(Icons.person_add, color: Colors.purple)),
title: Text(employee['name'] ?? '未入力'),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('部署:${employee['department']}'),
if (employee['email'] != null) Text('Email: ${employee['email']}'),
]),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editEmployee(employee['id'] as int)),
IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteEmployee(employee['id'] as int)),
],
),
),
);
},
),
);
}
}
///
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;
}
}

View file

@ -1,7 +1,9 @@
// Version: 1.6 -
// Version: 1.7 -
import 'package:flutter/material.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
///
///
class InventoryMasterScreen extends StatefulWidget {
const InventoryMasterScreen({super.key});
@ -10,91 +12,241 @@ class InventoryMasterScreen extends StatefulWidget {
}
class _InventoryMasterScreenState extends State<InventoryMasterScreen> {
String _productName = ''; //
int _stock = 0; //
int _minStock = 10; //
String? _supplierName; //
List<Product> _products = [];
Map<String, dynamic>? _newInventory; //
@override
void initState() {
super.initState();
// TODO: DatabaseHelper.instance.getInventory() 使
_loadProducts();
}
Future<void> _loadProducts() async {
try {
final products = await DatabaseHelper.instance.getProducts();
if (mounted) setState(() => _products = products ?? const <Product>[]);
} catch (e) {}
}
void _submitNewInventory() async {
if (_newInventory == null || !(_newInventory!['product_code'] as String).isNotEmpty) return;
try {
final db = await DatabaseHelper.instance.database;
// product_code
final existing = await db.query('inventory', where: "product_code = ?", whereArgs: [(_newInventory!['product_code'] as String)]);
if (existing.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('同じ JAN コードの在庫は既に存在します'), backgroundColor: Colors.orange),
);
return;
}
final inventoryData = <String, dynamic>{
'product_code': _newInventory!['product_code'] as String,
'name': _newInventory!['name'] as String? ?? '',
'unit_price': (_newInventory!['unit_price'] as num?)?.toInt() ?? 0,
'stock': (_newInventory!['stock'] as num?)?.toInt() ?? 0,
'min_stock': (_newInventory!['min_stock'] as num?)?.toInt() ?? 10,
'max_stock': (_newInventory!['max_stock'] as num?)?.toInt() ?? 1000,
'supplier_name': _newInventory!['supplier_name'] as String? ?? '',
};
await DatabaseHelper.instance.insertInventory(inventoryData);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('在庫登録完了'), backgroundColor: Colors.green),
);
setState(() => _newInventory = null);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
);
}
}
void _showAddDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('新規在庫登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: const InputDecoration(labelText: 'JAN コード', hintText: '4901234567890'),
onChanged: (v) => _newInventory?['product_code'] = v,
),
TextField(
decoration: const InputDecoration(labelText: '商品名', hintText: '商品名を入力'),
onChanged: (v) => _newInventory?['name'] = v,
),
TextField(
decoration: const InputDecoration(labelText: '単価(円)', hintText: '0'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['unit_price'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '在庫数', hintText: '0'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['stock'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '最低在庫', hintText: '10'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['min_stock'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '最大在庫', hintText: '1000'),
keyboardType: TextInputType.number,
onChanged: (v) => _newInventory?['max_stock'] = int.tryParse(v),
),
TextField(
decoration: const InputDecoration(labelText: '仕入先', hintText: '仕入先会社名'),
onChanged: (v) => _newInventory?['supplier_name'] = v,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: _newInventory != null ? _submitNewInventory : null,
child: const Text('登録'),
),
],
),
);
}
void _editInventory(int id) async {
final product = await DatabaseHelper.instance.getProduct(id);
if (product == null || mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('在庫編集'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(labelText: 'JAN コード', hintText: product.productCode),
readOnly: true,
),
TextField(
decoration: const InputDecoration(labelText: '商品名'),
controller: TextEditingController(text: product.name),
onChanged: (v) {
_updateInventory(id, {'name': v});
},
),
TextField(
decoration: const InputDecoration(labelText: '単価(円)'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'unit_price': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '在庫数'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'stock': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '最低在庫'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'min_stock': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '最大在庫'),
keyboardType: TextInputType.number,
onChanged: (v) => _updateInventory(id, {'max_stock': int.tryParse(v)}),
),
TextField(
decoration: const InputDecoration(labelText: '仕入先'),
onChanged: (v) => _updateInventory(id, {'supplier_name': v}),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('保存'),
),
],
),
);
}
void _updateInventory(int id, Map<String, dynamic> data) async {
final inventoryData = <String, dynamic>{
'product_code': data['product_code'] as String?,
'name': data['name'] as String?,
'unit_price': data['unit_price'] as int?,
'stock': data['stock'] as int?,
'min_stock': data['min_stock'] as int?,
'max_stock': data['max_stock'] as int?,
'supplier_name': data['supplier_name'] as String?,
};
try {
await DatabaseHelper.instance.updateInventory(inventoryData);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('在庫更新完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('在庫管理')),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
TextField(
decoration: const InputDecoration(hintText: '商品名', border: OutlineInputBorder()),
onChanged: (value) => setState(() => _productName = value),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '在庫数', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onChanged: (value) => setState(() => _stock = int.tryParse(value) ?? 0),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '再仕入れ水準', border: OutlineInputBorder()),
keyboardType: TextInputType.number,
onChanged: (value) => setState(() => _minStock = int.tryParse(value) ?? 10),
),
const SizedBox(height: 16),
//
TextField(
decoration: const InputDecoration(hintText: '供給元', border: OutlineInputBorder()),
onChanged: (value) => setState(() => _supplierName = value.isNotEmpty ? value : null),
),
const SizedBox(height: 16),
//
Card(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('現在の在庫'),
subtitle: Text('${_stock}', style: const TextStyle(fontSize: 18)),
trailing: _stock <= _minStock ? const Icon(Icons.warning, color: Colors.orange) : const SizedBox(),
),
),
const SizedBox(height: 16),
//
ElevatedButton(
onPressed: () {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('在庫データを更新しました')),
);
}
},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
child: const Text('更新'),
),
],
),
),
appBar: AppBar(
title: const Text('在庫管理'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts,),
],
),
body: _products.isEmpty ? Center(child: const Text('在庫データがありません')) :
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: ListTile(
title: const Text('新規登録'),
subtitle: const Text('商品への在庫を登録'),
trailing: const Icon(Icons.add_circle),
onTap: _showAddDialog,
),
),
const SizedBox(height: 16),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(product.name),
subtitle: Text('JAN: ${product.productCode} / ¥${product.unitPrice} × ${product.stock ?? 0}'),
trailing: IconButton(icon: const Icon(Icons.edit), onPressed: () => _editInventory(product.id ?? 0),),
),
);
},
),
],
),
),
);
}
}

View file

@ -1,147 +1,106 @@
// Version: 1.0.0
import 'package:flutter/material.dart';
// Version: 3.0 -
/// Material Design
class ProductMasterScreen extends StatelessWidget {
import 'package:flutter/material.dart';
import '../../models/product.dart';
class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('商品マスタ'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showAddDialog(context),
),
],
),
body: ListView(
padding: const EdgeInsets.all(8),
children: [
//
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'商品コード',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
// Material
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: 5, //
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.shade100,
child: Icon(Icons.shopping_basket, color: Colors.blue),
),
title: Text('商品${index + 1}'),
subtitle: Text('JAN: ${'123456789'.padLeft(10, '0')}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditDialog(context, index),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showDeleteDialog(context, index),
),
],
),
),
);
},
),
],
),
);
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
}
class _ProductMasterScreenState extends State<ProductMasterScreen> {
List<Product> _products = [];
@override
void initState() {
super.initState();
//
_products = <Product>[
Product(productCode: 'P001', name: 'サンプル商品 A', unitPrice: 1000.0, stock: 50),
Product(productCode: 'P002', name: 'サンプル商品 B', unitPrice: 2500.0, stock: 30),
];
}
void _showAddDialog(BuildContext context) {
showDialog(
Future<void> _addProduct() async {
await showDialog<Product>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('新規品登録'),
title: const Text('新規製品登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: const InputDecoration(
labelText: '商品コード',
hintText: 'JAN 形式で入力',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '品名',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '単価',
hintText: '¥ の後に数字のみ入力',
),
keyboardType: TextInputType.number,
),
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'P003')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新製品名'), onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '単価', hintText: '1500.0'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '在庫', hintText: '10'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品登録しました')),
);
},
child: const Text('保存'),
child: const Text('登録'),
),
],
),
);
}
void _showEditDialog(BuildContext context, int index) {
//
}
void _showDeleteDialog(BuildContext context, int index) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('商品削除'),
content: Text('商品${index + 1}を削除しますか?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品削除しました')),
);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/M3. 製品マスタ')),
body: _products.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('製品データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: _addProduct,
),
],
),
) : ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Text(product.productCode ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
title: Text(product.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (product.stock > 0) Text('在庫:${product.stock}', style: const TextStyle(fontSize: 12)),
Text('単価:¥${product.unitPrice}', style: const TextStyle(fontSize: 12)),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: _addProduct,
),
);
}

View file

@ -1,167 +1,104 @@
// Version: 1.0.0
// Version: 3.0 -
import 'package:flutter/material.dart';
/// Material Design
class SupplierMasterScreen extends StatelessWidget {
class SupplierMasterScreen extends StatefulWidget {
const SupplierMasterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('仕入先マスタ'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showAddDialog(context),
),
],
),
body: ListView(
padding: const EdgeInsets.all(8),
children: [
//
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'仕入先名',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
// Material
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: 5, //
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.brown.shade100,
child: Icon(Icons.shopping_bag, color: Colors.brown),
),
title: Text('サプライヤー${index + 1}'),
subtitle: Text('契約先2025-12-31 以降'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditDialog(context, index),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showDeleteDialog(context, index),
),
],
),
),
);
},
),
],
),
);
State<SupplierMasterScreen> createState() => _SupplierMasterScreenState();
}
class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
List<dynamic> _suppliers = [];
@override
void initState() {
super.initState();
//
_suppliers = [
{'supplier_code': 'S001', 'name': 'サンプル仕入先 A'},
{'supplier_code': 'S002', 'name': 'サンプル仕入先 B'},
];
}
void _showAddDialog(BuildContext context) {
showDialog(
Future<void> _addSupplier() async {
await showDialog<dynamic>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('新規仕入先登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: const InputDecoration(
labelText: '会社名',
hintText: '株式会社名を入力',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '代表者名',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '住所',
hintText: '〒000-0000 北海道...',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '電話番号',
hintText: '0123-456789',
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '担当者名',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '取引条件',
hintText: '1/30 支払期限',
),
),
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'S003')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新仕入先名'), onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先登録しました')),
);
},
child: const Text('保存'),
child: const Text('登録'),
),
],
),
);
}
void _showEditDialog(BuildContext context, int index) {
//
}
void _showDeleteDialog(BuildContext context, int index) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('仕入先削除'),
content: Text('サプライヤー${index + 1}を削除しますか?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先削除しました')),
);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/M1. 仕入先マスタ')),
body: _suppliers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: _addSupplier,
),
],
),
) : 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'] ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (supplier['phone'] != null) Text('電話:${supplier['phone']}', style: const TextStyle(fontSize: 12)),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: _addSupplier,
),
);
}

View file

@ -1,156 +1,162 @@
// Version: 1.0.0
// Version: 1.9 -
// DB
import 'package:flutter/material.dart';
/// Material Design
class WarehouseMasterScreen extends StatelessWidget {
/// CRUD -
class WarehouseMasterScreen extends StatefulWidget {
const WarehouseMasterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('倉庫マスタ'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showAddDialog(context),
),
],
),
body: ListView(
padding: const EdgeInsets.all(8),
children: [
//
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'倉庫名',
style: TextStyle(fontWeight: FontWeight.bold),
State<WarehouseMasterScreen> createState() => _WarehouseMasterScreenState();
}
class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
List<Map<String, dynamic>> _warehouses = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadWarehouses();
}
Future<void> _loadWarehouses() async {
setState(() => _loading = true);
try {
final demoData = [
{'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'},
{'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'},
{'id': 3, 'name': '東京倉庫', 'area': '関東', 'address': '〒100-0001 東京都千代田区'},
{'id': 4, 'name': '名古屋倉庫', 'area': '中部', 'address': '〒460-0001 名古屋市中村区'},
{'id': 5, 'name': '大阪倉庫', 'area': '近畿', 'address': '〒530-0001 大阪市中央区'},
];
setState(() => _warehouses = demoData);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
);
} finally {
setState(() => _loading = false);
}
}
Future<void> _addWarehouse() async {
final warehouse = <String, dynamic>{'id': DateTime.now().millisecondsSinceEpoch, 'name': '', 'area': '', 'address': ''};
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => _WarehouseDialogState(
Dialog(
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: WarehouseForm(warehouse: warehouse),
),
),
// Material
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: 5, //
itemBuilder: (context, index) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.orange.shade100,
child: Icon(Icons.storage, color: Colors.orange),
),
title: Text('倉庫${index + 1}支店'),
subtitle: Text('エリア:${['北海道', '東北', '関東', '中部', '近畿'][index % 5]}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditDialog(context, index),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showDeleteDialog(context, index),
),
],
),
),
);
},
),
],
),
),
);
if (result != null && mounted) {
setState(() => _warehouses.add(result));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green));
}
}
void _showAddDialog(BuildContext context) {
showDialog(
Future<void> _editWarehouse(int id) async {
final warehouse = _warehouses.firstWhere((w) => w['id'] == id);
final edited = await showDialog<Map<String, dynamic>>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('新規倉庫登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: const InputDecoration(
labelText: '倉庫名',
hintText: '例:札幌支店',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'エリア',
hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '住所',
hintText: '〒000-0000 北海道...',
),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: '連絡先電話番号',
hintText: '0123-456789',
),
keyboardType: TextInputType.phone,
),
],
builder: (context) => _WarehouseDialogState(
Dialog(
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: WarehouseForm(warehouse: warehouse),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫登録しました')),
);
},
child: const Text('保存'),
),
],
),
);
if (edited != null && mounted) {
final index = _warehouses.indexWhere((w) => w['id'] == id);
setState(() => _warehouses[index] = edited);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green));
}
}
void _showEditDialog(BuildContext context, int index) {
//
}
void _showDeleteDialog(BuildContext context, int index) {
showDialog(
Future<void> _deleteWarehouse(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (context) => AlertDialog(
title: const Text('倉庫削除'),
content: Text('倉庫${index + 1}支店を削除しますか?'),
content: Text('この倉庫を削除しますか?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫削除しました')),
);
},
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
setState(() {
_warehouses.removeWhere((w) => w['id'] == id);
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green));
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M4. 倉庫マスタ'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadWarehouses),
IconButton(icon: const Icon(Icons.add), onPressed: _addWarehouse),
],
),
body: _loading ? const Center(child: CircularProgressIndicator()) :
_warehouses.isEmpty ? Center(child: Text('倉庫データがありません')) :
ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _warehouses.length,
itemBuilder: (context, index) {
final warehouse = _warehouses[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.orange.shade50, child: Icon(Icons.storage, color: Colors.orange)),
title: Text(warehouse['name'] ?? '倉庫(未入力)'),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('エリア:${warehouse['area']}'),
if (warehouse['address'] != null) Text('住所:${warehouse['address']}'),
]),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editWarehouse(warehouse['id'] as int)),
IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteWarehouse(warehouse['id'] as int)),
],
),
),
);
},
),
);
}
}
///
class WarehouseForm extends StatelessWidget {
final Map<String, dynamic> warehouse;
const

View file

@ -38,7 +38,7 @@ class _OrderScreenState extends State<OrderScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('受注')),
appBar: AppBar(title: const Text('/S3. 発注入力')),
body: _selectedCustomer == null
? const Center(child: Text('得意先を選択してください'))
: SingleChildScrollView(

View file

@ -9,7 +9,7 @@ class SalesReturnScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('売上返品入力'),
title: const Text('/S5. 売上返品入力'),
actions: [
IconButton(
icon: const Icon(Icons.undo),

View file

@ -1,6 +1,8 @@
// Version: 1.10 -
// Version: 1.16 - PDF TODO
import 'package:flutter/material.dart';
import '../services/database_helper.dart';
import 'package:intl/intl.dart';
import 'dart:convert';
import '../services/database_helper.dart' as db;
import '../models/product.dart';
import '../models/customer.dart';
@ -16,18 +18,118 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
List<_SaleItem> saleItems = <_SaleItem>[];
double totalAmount = 0.0;
Customer? selectedCustomer;
final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
// DB product_code
Future<void> loadProducts() async {
try {
final ps = await DatabaseHelper.instance.getProducts();
if (mounted) setState(() => products = ps ?? const <Product>[]);
} catch (e) {}
// 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>[];
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => loadProducts());
Future<void> refreshProducts() async {
await loadProducts();
if (mounted) setState(() {});
}
// Database
Future<void> saveSalesData() async {
if (saleItems.isEmpty || !mounted) return;
try {
final itemsJson = jsonEncode(saleItems.map((item) => {
'product_id': item.productId,
'product_name': item.productName,
'product_code': item.productCode,
'unit_price': item.unitPrice.round(),
'quantity': item.quantity,
'subtotal': (item.unitPrice * item.quantity).round(),
}));
final salesData = {
'id': DateTime.now().millisecondsSinceEpoch,
'customer_id': selectedCustomer?.id ?? 1,
'sale_date': DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now()),
'total_amount': totalAmount.round(),
'tax_rate': 8,
'product_items': itemsJson,
};
// sqflite insert API 使insertSales
final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('✅ 売上データ保存完了'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2)),
);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('保存成功'),
content: Text('売上 ID: #$insertedId\n合計金額:${_currencyFormatter.format(totalAmount)}'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
],
),
);
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('❌ 保存エラー:$e'), backgroundColor: Colors.red),
);
}
}
// PDF TODO printing 使
Future<void> generateAndShareInvoice() async {
if (saleItems.isEmpty || !mounted) return;
try {
await saveSalesData();
if (!mounted) return;
// TODO: PDF printing 使
//
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('📄 売上明細が共有されました'), backgroundColor: Colors.green),
);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('共有エラー:$e'), backgroundColor: Colors.orange),
);
}
}
Future<void> searchProduct(String keyword) async {
@ -72,78 +174,17 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
setState(() => totalAmount = items.fold(0, (sum, val) => sum + val));
}
Future<void> saveSale() async {
if (saleItems.isEmpty || !mounted) return;
try {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('売上データ保存'),
content: Text('入力した商品情報を販売アシストに保存します。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () async {
await DatabaseHelper.instance.insertSales({
'id': DateTime.now().millisecondsSinceEpoch,
'customer_id': selectedCustomer?.id ?? 1,
'sale_date': DateTime.now().toIso8601String(),
'total_amount': (totalAmount * 1.1).round(),
'tax_rate': 8,
});
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('売上データ保存完了'), duration: Duration(seconds: 2)),
);
},
child: const Text('保存'),
),
],
),
);
} catch (e) {
WidgetsBinding.instance.addPostFrameCallback((_) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
));
}
}
void showInvoiceDialog() {
if (saleItems.isEmpty || !mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('売上伝票'),
content: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('得意先:${selectedCustomer?.name ?? '未指定'}', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text('商品数:${saleItems.length}'),
const SizedBox(height: 4),
Text('合計:¥${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.teal)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(child: const Text('閉じる'), onPressed: () => Navigator.pop(context),),
],
),
);
}
@override
Widget build(BuildContext context) {
// 1
if (products.isEmpty) {
loadProducts();
}
return Scaffold(
appBar: AppBar(title: const Text('売上入力'), actions: [
IconButton(icon: const Icon(Icons.save), onPressed: saveSale,),
IconButton(icon: const Icon(Icons.print, color: Colors.blue), onPressed: () => showInvoiceDialog(),),
appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [
IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,),
IconButton(icon: const Icon(Icons.refresh), onPressed: refreshProducts,),
]),
body: Column(
children: <Widget>[
@ -158,7 +199,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
const SizedBox(height: 8),
Row(children: <Widget>[Text('合計'), const Icon(Icons.payments, size: 32)]),
const SizedBox(height: 4),
Text('¥${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)),
Text('${_currencyFormatter.format(totalAmount)}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold, color: Colors.teal)),
],),
),
),
@ -172,7 +213,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
),
Expanded(
child: saleItems.isEmpty
? const Center(child: Text('商品を登録'))
? Center(child: Text('商品を登録'))
: ListView.separated(
itemCount: saleItems.length,
itemBuilder: (context, index) {
@ -183,7 +224,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
child: ListTile(
leading: CircleAvatar(child: Icon(Icons.store)),
title: Text(item.productName ?? ''),
subtitle: Text('コード:${item.productCode} / ¥${item.totalAmount.toStringAsFixed(0)}'),
subtitle: Text('コード:${item.productCode} / ${_currencyFormatter.format(item.totalAmount)}'),
trailing: IconButton(icon: const Icon(Icons.remove_circle_outline), onPressed: () => removeItem(index),),
),
),

View file

@ -1,252 +1,319 @@
// DatabaseHelper - sqflite
// NOTE: update() 使
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:convert';
import '../models/customer.dart';
import '../models/product.dart';
import '../models/estimate.dart';
// Customer
class Customer {
final int? id;
final String? customerCode;
final String? name;
final String? address;
final String? phone;
final String? email;
final bool isInactive;
Customer({
this.id,
this.customerCode,
this.name,
this.address,
this.phone,
this.email,
this.isInactive = false,
});
}
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
DatabaseHelper._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('customer_assist.db');
return _database!;
///
static Future<void> init() async {
if (_database != null) return;
try {
String dbPath;
if (Platform.isAndroid || Platform.isIOS) {
// sqflite 使
final dbDir = await getDatabasesPath();
dbPath = '$dbDir/sales.db';
} else {
// /使
dbPath = Directory.current.path + '/data/db/sales.db';
}
// 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');
}
}
Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath);
///
static Future<Database> _initDatabase(String path) async {
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
onCreate: _onCreateTableWithSampleData,
);
}
Future<void> _createDB(Database db, int version) async {
await db.execute('CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, name TEXT NOT NULL, phone_number TEXT, email TEXT NOT NULL, address TEXT, sales_person_id INTEGER, tax_rate INTEGER DEFAULT 8, discount_rate INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, phone_number TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, quantity INTEGER DEFAULT 0, stock INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE sales (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE estimates (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, estimate_number TEXT NOT NULL, product_items TEXT, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "open", expiry_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT UNIQUE NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, stock INTEGER DEFAULT 0, min_stock INTEGER DEFAULT 0, max_stock INTEGER DEFAULT 1000, supplier_name TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE invoices (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, invoice_number TEXT NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "paid", product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
print('Database created with version: 1');
/// +
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
// products Product
await db.execute('''
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
unit_price REAL DEFAULT 0.0,
quantity INTEGER DEFAULT 0,
stock INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// customers
await db.execute('''
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// sales
await db.execute('''
CREATE TABLE sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER,
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'completed',
payment_status TEXT DEFAULT 'paid',
invoice_number TEXT UNIQUE,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// estimates Estimate
await db.execute('''
CREATE TABLE estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_number TEXT UNIQUE,
customer_id INTEGER REFERENCES customers(id),
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
discount_percent REAL DEFAULT 0.0,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'pending',
payment_status TEXT DEFAULT 'unpaid',
expiry_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
//
await db.execute('CREATE INDEX idx_products_code ON products(product_code)');
await db.execute('CREATE INDEX idx_customers_code ON customers(customer_code)');
//
final sampleProducts = <Map<String, dynamic>>[
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50, 'stock': 50},
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30, 'stock': 30},
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20, 'stock': 20},
];
for (final data in sampleProducts) {
await db.insert('products', data);
}
print('[DatabaseHelper] Sample products inserted');
}
// Customer API
Future<int> insertCustomer(Customer customer) async {
final db = await database;
return await db.insert('customers', customer.toMap());
///
static Database get instance => _database!;
///
static Future<List<Product>> getProducts() async {
final result = await instance.query('products', orderBy: 'id DESC');
// DateTime Product
return List.generate(result.length, (index) {
final item = Map<String, dynamic>.from(result[index]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
});
}
Future<Customer?> getCustomer(int id) async {
final db = await database;
final results = await db.query('customers', where: 'id = ?', whereArgs: [id]);
if (results.isEmpty) return null;
return Customer.fromMap(results.first);
/// ID null
static Future<Product?> getProduct(int id) async {
final result = await instance.query(
'products',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
}
return null;
}
Future<List<Customer>> getCustomers() async {
final db = await database;
final results = await db.query('customers');
return results.map((e) => Customer.fromMap(e)).toList();
/// productCode null
static Future<Product?> getProductByCode(String code) async {
final result = await instance.query(
'products',
where: 'product_code = ?',
whereArgs: [code],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
}
return null;
}
Future<int> updateCustomer(Customer customer) async {
final db = await database;
return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]);
/// ID null
static Future<Customer?> getCustomerById(int id) async {
final result = await instance.query(
'customers',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
return Customer(
id: result[0]['id'] as int?,
customerCode: result[0]['customer_code'] as String?,
name: result[0]['name'] as String?,
address: result[0]['address'] as String?,
phone: result[0]['phone'] as String?,
email: result[0]['email'] as String?,
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
);
}
return null;
}
Future<int> deleteCustomer(int id) async {
final db = await database;
return await db.delete('customers', where: 'id = ?', whereArgs: [id]);
/// return
static Future<void> insertProduct(Product product) async {
await instance.insert('products', {
'product_code': product.productCode,
'name': product.name,
'unit_price': product.unitPrice,
'quantity': product.quantity,
'stock': product.stock,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
}
// Product API
Future<int> insertProduct(Product product) async {
final db = await database;
return await db.insert('products', product.toMap());
///
static Future<void> deleteProduct(int id) async {
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
}
Future<Product?> getProduct(int id) async {
final db = await database;
final results = await db.query('products', where: 'id = ?', whereArgs: [id]);
if (results.isEmpty) return null;
return Product.fromMap(results.first);
/// return
static Future<void> insertCustomer(Customer customer) async {
await instance.insert('customers', {
'customer_code': customer.customerCode,
'name': customer.name,
'address': customer.address,
'phone': customer.phone,
'email': customer.email,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
}
Future<List<Product>> getProducts() async {
final db = await database;
final results = await db.query('products');
return results.map((e) => Product.fromMap(e)).toList();
///
static Future<void> deleteCustomer(int id) async {
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
}
Future<int> updateProduct(Product product) async {
final db = await database;
return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]);
/// DB
static Future<void> clearDatabase() async {
await instance.delete('products');
await instance.delete('customers');
await instance.delete('sales');
await instance.delete('estimates');
}
Future<int> deleteProduct(int id) async {
final db = await database;
return await db.delete('products', where: 'id = ?', whereArgs: [id]);
/// + +
static Future<void> recover() async {
try {
// DB
final dbPath = Directory.current.path + '/data/db/sales.db';
final file = File(dbPath);
if (await file.exists()) {
await file.delete();
print('[DatabaseHelper] recover: DB ファイルを削除');
} else {
print('[DatabaseHelper] recover: DB ファイルが見つからない');
}
//
await init();
} catch (e) {
print('[DatabaseHelper] recover error: $e');
}
}
// Sales API
Future<int> insertSales(Map<String, dynamic> salesData) async {
final db = await database;
return await db.insert('sales', salesData);
}
Future<List<Map<String, dynamic>>> getSales() async {
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();
/// DB
static Future<String> getDbPath() async {
return Directory.current.path + '/data/db/sales.db';
}
}

View file

@ -0,0 +1,104 @@
// Version: 3.0 - 使
import 'package:flutter/material.dart';
import '../models/product.dart';
import '../services/database_helper.dart';
///
class MasterEditDialog<T extends Product> extends StatefulWidget {
final String title;
final T? initialData;
final bool showStatusFields;
final Function(T)? onSave;
const MasterEditDialog({
super.key,
required this.title,
this.initialData,
this.showStatusFields = false,
this.onSave,
});
@override
State<MasterEditDialog> createState() => _MasterEditDialogState();
}
class _MasterEditDialogState extends State<MasterEditDialog> {
late TextEditingController codeController;
late TextEditingController nameController;
@override
void initState() {
super.initState();
final data = widget.initialData;
codeController = TextEditingController(text: data?.productCode ?? '');
nameController = TextEditingController(text: data?.name ?? '');
}
bool showStatusField() => widget.showStatusFields;
Widget _buildEditField(String label, TextEditingController controller) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextField(controller: controller, decoration: InputDecoration(hintText: '入力をここに', border: OutlineInputBorder())),
],),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(top: 8)),
child: const Text('キャンセル'),
),
_buildEditField('製品コード *', codeController),
_buildEditField('会社名 *', nameController),
if (widget.showStatusFields) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300)),
child: const Text('ステータス管理(簡素版)」', textAlign: TextAlign.center),
),
],
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context, widget.onSave?.call(widget.initialData! as T)),
child: const Text('保存'),
),
],),
),
);
}
}
///
class SingleChoiceDialog extends StatelessWidget {
final List<Product> items;
final Function() onCancel;
final Function(Product) onSelected;
const SingleChoiceDialog({super.key, required this.items, required this.onCancel, required this.onSelected});
@override
Widget build(BuildContext context) {
if (items.isEmpty) return const Text('検索結果がありません');
return ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (ctx, index) => ListTile(title: Text(items[index].name ?? '未入力'), onTap: () => onSelected(items[index])),
);
}
}

View file

@ -0,0 +1,146 @@
// Version: 2.1 - 使
//
import 'package:flutter/material.dart';
///
class MasterTextField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hintText;
const MasterTextField({
super.key,
required this.label,
required this.controller,
this.hintText,
});
@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,
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,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(label),
Checkbox(value: checked ?? false, onChanged: onChanged),
],),
);
}
}

View file

@ -2,7 +2,7 @@ name: sales_assist_1
description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント
publish_to: 'none'
version: 1.0.0+5
version: 1.0.0+6
environment:
sdk: '>=3.0.0 <4.0.0'
@ -16,9 +16,17 @@ dependencies:
sqflite: ^2.3.3
path_provider: ^2.1.1
# PDF 帳票出力
# PDF 帳票出力flutter_pdf_generator の代わりに使用)
pdf: ^3.10.8
printing: ^5.9.0
intl: ^0.19.0
share_plus: ^10.1.2
google_sign_in: ^7.2.0
# リッチマスター編集用機能(簡易実装)
image_picker: ^1.0.7
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test:
sdk: flutter