501 lines
19 KiB
Python
501 lines
19 KiB
Python
"""
|
|
汎用マスタ編集コンポーネント
|
|
1つのコンポーネントで複数のマスタを管理
|
|
"""
|
|
|
|
import flet as ft
|
|
import sqlite3
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import List, Dict, Optional, Callable
|
|
|
|
from components.explorer_framework import ExplorerQueryState
|
|
|
|
class UniversalMasterEditor:
|
|
"""汎用マスタ編集コンポーネント"""
|
|
|
|
def __init__(self, page: ft.Page):
|
|
self.page = page
|
|
|
|
# マスタ定義
|
|
self.masters = {
|
|
'customers': {
|
|
'title': '顧客マスタ',
|
|
'table_name': 'customers',
|
|
'fields': [
|
|
{'name': 'name', 'label': '顧客名', 'width': 200, 'required': True},
|
|
{'name': 'phone', 'label': '電話番号', 'width': 200},
|
|
{'name': 'email', 'label': 'メールアドレス', 'width': 250},
|
|
{'name': 'address', 'label': '住所', 'width': 300}
|
|
]
|
|
},
|
|
'products': {
|
|
'title': '商品マスタ',
|
|
'table_name': 'products',
|
|
'fields': [
|
|
{'name': 'name', 'label': '商品名', 'width': 200, 'required': True},
|
|
{'name': 'unit_price', 'label': '単価', 'width': 120, 'keyboard_type': ft.KeyboardType.NUMBER, 'required': True},
|
|
{'name': 'description', 'label': '説明', 'width': 320}
|
|
]
|
|
},
|
|
'sales_slips': {
|
|
'title': '伝票マスタ',
|
|
'table_name': 'sales_slips',
|
|
'fields': [
|
|
{'name': 'title', 'label': '伝票タイトル', 'width': 300, 'required': True},
|
|
{'name': 'customer_name', 'label': '顧客名', 'width': 200, 'required': True},
|
|
{'name': 'items', 'label': '明細', 'width': 400, 'keyboard_type': ft.KeyboardType.MULTILINE, 'required': True},
|
|
{'name': 'total_amount', 'label': '合計金額', 'width': 150, 'keyboard_type': ft.KeyboardType.NUMBER, 'required': True}
|
|
]
|
|
}
|
|
}
|
|
|
|
# 現在のマスタ
|
|
self.current_master = 'customers'
|
|
self.current_data = []
|
|
self.editing_id = None
|
|
self.explorer_state = ExplorerQueryState(period_key="all", sort_key="id", limit=20)
|
|
|
|
# 並び順(マスタ共通)
|
|
self.sort_candidates = {
|
|
"id": "ID",
|
|
"created_at": "作成日時",
|
|
"updated_at": "更新日時",
|
|
}
|
|
|
|
# UI部品
|
|
self.title = ft.Text("", size=24, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_900)
|
|
self.master_dropdown = ft.Dropdown(
|
|
label="マスタ選択",
|
|
options=[
|
|
ft.dropdown.Option("顧客マスタ", "customers"),
|
|
ft.dropdown.Option("商品マスタ", "products"),
|
|
ft.dropdown.Option("伝票マスタ", "sales_slips")
|
|
],
|
|
value="customers",
|
|
on_select=self.change_master
|
|
)
|
|
|
|
# フォーム
|
|
self.form_fields = ft.Column([], spacing=10)
|
|
|
|
# Explorer操作
|
|
self.search_field = ft.TextField(
|
|
label="検索",
|
|
hint_text="名称・説明などで検索",
|
|
prefix_icon=ft.Icons.SEARCH,
|
|
on_change=self.on_search_change,
|
|
dense=True,
|
|
expand=True,
|
|
)
|
|
self.sort_dropdown = ft.Dropdown(
|
|
label="ソート",
|
|
options=[],
|
|
value="id",
|
|
on_select=self.on_sort_change,
|
|
width=170,
|
|
dense=True,
|
|
)
|
|
self.sort_dir_btn = ft.IconButton(
|
|
icon=ft.Icons.ARROW_DOWNWARD,
|
|
tooltip="並び順切替",
|
|
on_click=self.toggle_sort_direction,
|
|
)
|
|
self.prev_page_btn = ft.TextButton("◀ 前", on_click=self.prev_page)
|
|
self.next_page_btn = ft.TextButton("次 ▶", on_click=self.next_page)
|
|
self.page_status = ft.Text("", size=12, color=ft.Colors.BLUE_GREY_600)
|
|
|
|
# ボタン群
|
|
self.add_btn = ft.Button("追加", on_click=self.add_new, bgcolor=ft.Colors.BLUE, color=ft.Colors.WHITE)
|
|
self.save_btn = ft.Button("保存", on_click=self.save_data, bgcolor=ft.Colors.GREEN, color=ft.Colors.WHITE)
|
|
self.delete_btn = ft.Button("削除", on_click=self.delete_selected, bgcolor=ft.Colors.RED, color=ft.Colors.WHITE)
|
|
self.clear_btn = ft.Button("クリア", on_click=self.clear_form, bgcolor=ft.Colors.ORANGE, color=ft.Colors.WHITE)
|
|
|
|
# データリスト
|
|
self.data_list = ft.Column([], scroll=ft.ScrollMode.AUTO, height=300)
|
|
|
|
# 操作説明
|
|
self.instructions = ft.Text(
|
|
"操作方法: マスタを選択してデータを編集・追加・削除",
|
|
size=12,
|
|
color=ft.Colors.GREY_600
|
|
)
|
|
|
|
# 初期化
|
|
self._build_form()
|
|
self._refresh_sort_options()
|
|
self._load_data()
|
|
|
|
def change_master(self, e):
|
|
"""マスタを切り替え"""
|
|
self.current_master = e.control.value
|
|
self.editing_id = None
|
|
self.explorer_state.query = ""
|
|
self.explorer_state.offset = 0
|
|
self.explorer_state.sort_key = "id"
|
|
self.explorer_state.sort_desc = True
|
|
self.title.value = self.masters[self.current_master]['title']
|
|
self.search_field.value = ""
|
|
self._build_form()
|
|
self._refresh_sort_options()
|
|
self._load_data()
|
|
self.page.update()
|
|
|
|
def _refresh_sort_options(self):
|
|
"""現在マスタに合わせてソート候補を更新。"""
|
|
candidates = dict(self.sort_candidates)
|
|
for field in self.masters[self.current_master]["fields"]:
|
|
candidates.setdefault(field["name"], field["label"])
|
|
|
|
self.sort_dropdown.options = [
|
|
ft.dropdown.Option(key, label) for key, label in candidates.items()
|
|
]
|
|
|
|
if self.explorer_state.sort_key not in candidates:
|
|
self.explorer_state.sort_key = "id"
|
|
|
|
self.sort_dropdown.value = self.explorer_state.sort_key
|
|
self.sort_dir_btn.icon = (
|
|
ft.Icons.ARROW_DOWNWARD if self.explorer_state.sort_desc else ft.Icons.ARROW_UPWARD
|
|
)
|
|
|
|
def on_search_change(self, e):
|
|
self.explorer_state.query = (e.control.value or "").strip()
|
|
self.explorer_state.offset = 0
|
|
self._load_data()
|
|
self.page.update()
|
|
|
|
def on_sort_change(self, e):
|
|
self.explorer_state.sort_key = e.control.value or "id"
|
|
self.explorer_state.offset = 0
|
|
self._load_data()
|
|
self.page.update()
|
|
|
|
def toggle_sort_direction(self, _):
|
|
self.explorer_state.sort_desc = not self.explorer_state.sort_desc
|
|
self.sort_dir_btn.icon = (
|
|
ft.Icons.ARROW_DOWNWARD if self.explorer_state.sort_desc else ft.Icons.ARROW_UPWARD
|
|
)
|
|
self.explorer_state.offset = 0
|
|
self._load_data()
|
|
self.page.update()
|
|
|
|
def prev_page(self, _):
|
|
if self.explorer_state.offset <= 0:
|
|
return
|
|
self.explorer_state.offset = max(0, self.explorer_state.offset - self.explorer_state.limit)
|
|
self._load_data()
|
|
self.page.update()
|
|
|
|
def next_page(self, _):
|
|
if len(self.current_data) < self.explorer_state.limit:
|
|
return
|
|
self.explorer_state.offset += self.explorer_state.limit
|
|
self._load_data()
|
|
self.page.update()
|
|
|
|
def _build_form(self):
|
|
"""入力フォームを構築"""
|
|
self.form_fields.controls.clear()
|
|
|
|
for field in self.masters[self.current_master]['fields']:
|
|
field_control = ft.TextField(
|
|
label=field['label'],
|
|
value='',
|
|
width=field['width'],
|
|
keyboard_type=field.get('keyboard_type', ft.KeyboardType.TEXT)
|
|
)
|
|
self.form_fields.controls.append(field_control)
|
|
|
|
self.page.update()
|
|
|
|
def _load_data(self):
|
|
"""データを読み込む"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
table_name = self.masters[self.current_master]['table_name']
|
|
sort_key = self.explorer_state.sort_key or "id"
|
|
sort_direction = "DESC" if self.explorer_state.sort_desc else "ASC"
|
|
|
|
allowed_sorts = {"id", "created_at", "updated_at"}
|
|
allowed_sorts.update(field["name"] for field in self.masters[self.current_master]["fields"])
|
|
if sort_key not in allowed_sorts:
|
|
sort_key = "id"
|
|
|
|
search_columns = [field["name"] for field in self.masters[self.current_master]["fields"]]
|
|
query = (self.explorer_state.query or "").strip()
|
|
|
|
params = []
|
|
where = "1=1"
|
|
if query:
|
|
like = f"%{query}%"
|
|
where = " OR ".join([f"{col} LIKE ?" for col in search_columns])
|
|
where = f"({where})"
|
|
params.extend([like for _ in search_columns])
|
|
|
|
sql = f'''
|
|
SELECT * FROM {table_name}
|
|
WHERE {where}
|
|
ORDER BY {sort_key} {sort_direction}, id DESC
|
|
LIMIT ? OFFSET ?
|
|
'''
|
|
params.extend([int(self.explorer_state.limit), int(self.explorer_state.offset)])
|
|
|
|
cursor.execute(sql, tuple(params))
|
|
self.current_data = cursor.fetchall()
|
|
col_names = [col[0] for col in cursor.description]
|
|
conn.close()
|
|
|
|
# データリストを更新
|
|
self.data_list.controls.clear()
|
|
for item in self.current_data:
|
|
item_id = item[0]
|
|
item_data = dict(zip(col_names, item))
|
|
|
|
# データ行
|
|
row_controls = []
|
|
for field in self.masters[self.current_master]['fields']:
|
|
field_name = field['name']
|
|
value = item_data.get(field_name, '')
|
|
|
|
row_controls.append(
|
|
ft.Text(f"{field['label']}: {value}", size=12)
|
|
)
|
|
|
|
# 操作ボタン
|
|
row_controls.extend([
|
|
ft.Button(
|
|
"編集",
|
|
on_click=lambda _, did=item_id: self.edit_item(item_id),
|
|
bgcolor=ft.Colors.ORANGE,
|
|
color=ft.Colors.WHITE,
|
|
width=60
|
|
),
|
|
ft.Button(
|
|
"削除",
|
|
on_click=lambda _, did=item_id: self.delete_item(item_id),
|
|
bgcolor=ft.Colors.RED,
|
|
color=ft.Colors.WHITE,
|
|
width=60
|
|
)
|
|
])
|
|
|
|
# データカード
|
|
data_card = ft.Card(
|
|
content=ft.Container(
|
|
content=ft.Column(row_controls),
|
|
padding=10
|
|
),
|
|
margin=ft.margin.only(bottom=5)
|
|
)
|
|
|
|
self.data_list.controls.append(data_card)
|
|
|
|
self.page_status.value = (
|
|
f"表示件数: {len(self.current_data)} / offset={self.explorer_state.offset} / limit={self.explorer_state.limit}"
|
|
)
|
|
|
|
self.page.update()
|
|
|
|
except Exception as e:
|
|
logging.error(f"{self.current_master}データ読込エラー: {e}")
|
|
|
|
def save_data(self, e):
|
|
"""データを保存"""
|
|
try:
|
|
# フォームデータを収集
|
|
form_data = {}
|
|
for i, field in enumerate(self.masters[self.current_master]['fields']):
|
|
field_control = self.form_fields.controls[i]
|
|
value = (field_control.value or "").strip()
|
|
|
|
if field.get("keyboard_type") == ft.KeyboardType.NUMBER:
|
|
normalized = value.replace(",", "")
|
|
if normalized == "":
|
|
normalized = "0"
|
|
try:
|
|
value = str(int(float(normalized)))
|
|
except ValueError:
|
|
self._show_snackbar(f"{field['label']}は数値で入力してください", ft.Colors.RED)
|
|
return
|
|
|
|
form_data[field['name']] = value
|
|
|
|
# 必須項目チェック
|
|
if field.get('required', False) and not value:
|
|
self._show_snackbar(f"{field['label']}は必須項目です", ft.Colors.RED)
|
|
return
|
|
|
|
# データベースに保存
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
table_name = self.masters[self.current_master]['table_name']
|
|
|
|
if self.editing_id:
|
|
# 更新
|
|
columns = ', '.join([f"{key} = ?" for key in form_data.keys()])
|
|
cursor.execute(f'''
|
|
UPDATE {table_name}
|
|
SET {columns}
|
|
WHERE id = ?
|
|
''', tuple(form_data.values()) + (self.editing_id,))
|
|
|
|
self._show_snackbar("データを更新しました", ft.Colors.GREEN)
|
|
logging.info(f"{table_name}データ更新完了: ID={self.editing_id}")
|
|
else:
|
|
# 新規追加
|
|
columns = ', '.join([field['name'] for field in self.masters[self.current_master]['fields']])
|
|
placeholders = ', '.join(['?' for _ in self.masters[self.current_master]['fields']])
|
|
|
|
cursor.execute(f'''
|
|
INSERT INTO {table_name} ({columns})
|
|
VALUES ({placeholders})
|
|
''', tuple(form_data.values()))
|
|
|
|
self._show_snackbar("データを追加しました", ft.Colors.GREEN)
|
|
logging.info(f"{table_name}データ追加完了")
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# フォームをクリア
|
|
self.clear_form(None)
|
|
|
|
# データ再読み込み
|
|
self._load_data()
|
|
|
|
except Exception as e:
|
|
logging.error(f"{self.current_master}データ保存エラー: {e}")
|
|
self._show_snackbar("保存エラー", ft.Colors.RED)
|
|
|
|
def add_new(self, e):
|
|
"""新規データを追加"""
|
|
self.editing_id = None
|
|
self.clear_form(None)
|
|
self.form_fields.controls[0].focus()
|
|
logging.info(f"{self.current_master}新規追加モード")
|
|
|
|
def edit_item(self, item_id):
|
|
"""データを編集"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
table_name = self.masters[self.current_master]['table_name']
|
|
cursor.execute(f'''
|
|
SELECT * FROM {table_name}
|
|
WHERE id = ?
|
|
''', (item_id,))
|
|
|
|
result = cursor.fetchone()
|
|
conn.close()
|
|
|
|
if result:
|
|
self.editing_id = item_id
|
|
|
|
# フォームにデータを設定
|
|
for i, field in enumerate(self.masters[self.current_master]['fields']):
|
|
field_control = self.form_fields.controls[i]
|
|
field_control.value = result[i+1] if i+1 < len(result) else ''
|
|
|
|
self.page.update()
|
|
logging.info(f"{table_name}編集モード: ID={item_id}")
|
|
|
|
except Exception as e:
|
|
logging.error(f"{self.current_master}編集エラー: {e}")
|
|
|
|
def delete_item(self, item_id):
|
|
"""データを削除"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
table_name = self.masters[self.current_master]['table_name']
|
|
cursor.execute(f'''
|
|
DELETE FROM {table_name}
|
|
WHERE id = ?
|
|
''', (item_id,))
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# データ再読み込み
|
|
self._load_data()
|
|
|
|
self._show_snackbar("データを削除しました", ft.Colors.GREEN)
|
|
logging.info(f"{table_name}削除完了: ID={item_id}")
|
|
|
|
except Exception as e:
|
|
logging.error(f"{self.current_master}削除エラー: {e}")
|
|
self._show_snackbar("削除エラー", ft.Colors.RED)
|
|
|
|
def delete_selected(self, e):
|
|
"""選択中のデータを削除"""
|
|
if self.editing_id:
|
|
self.delete_item(self.editing_id)
|
|
else:
|
|
self._show_snackbar("削除対象が選択されていません", ft.Colors.ORANGE)
|
|
|
|
def clear_form(self, e):
|
|
"""フォームをクリア"""
|
|
for field_control in self.form_fields.controls:
|
|
field_control.value = ''
|
|
|
|
self.editing_id = None
|
|
self.page.update()
|
|
logging.info("フォームをクリア")
|
|
|
|
def _show_snackbar(self, message: str, color: ft.Colors):
|
|
"""SnackBarを表示"""
|
|
try:
|
|
self.page.snack_bar = ft.SnackBar(
|
|
content=ft.Text(message),
|
|
bgcolor=color
|
|
)
|
|
self.page.snack_bar.open = True
|
|
self.page.update()
|
|
except:
|
|
pass
|
|
|
|
def build(self):
|
|
"""UIを構築して返す"""
|
|
return ft.Column([
|
|
ft.Row([
|
|
self.title,
|
|
self.master_dropdown
|
|
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
|
ft.Divider(),
|
|
ft.Text("入力フォーム", size=18, weight=ft.FontWeight.BOLD),
|
|
self.form_fields,
|
|
ft.Row([self.add_btn, self.save_btn, self.delete_btn, self.clear_btn], spacing=10),
|
|
ft.Divider(),
|
|
ft.Text(f"{self.masters[self.current_master]['title']}一覧", size=18, weight=ft.FontWeight.BOLD),
|
|
self.instructions,
|
|
ft.Container(
|
|
content=ft.Column([
|
|
ft.Row([
|
|
self.search_field,
|
|
self.sort_dropdown,
|
|
self.sort_dir_btn,
|
|
], spacing=8),
|
|
ft.Row([
|
|
self.page_status,
|
|
ft.Container(expand=True),
|
|
self.prev_page_btn,
|
|
self.next_page_btn,
|
|
]),
|
|
]),
|
|
padding=ft.padding.all(8),
|
|
bgcolor=ft.Colors.BLUE_GREY_50,
|
|
border_radius=8,
|
|
),
|
|
self.data_list
|
|
], expand=True, spacing=15)
|
|
|
|
# 使用例
|
|
def create_universal_master_editor(page: ft.Page) -> UniversalMasterEditor:
|
|
"""汎用マスタ編集画面を作成"""
|
|
return UniversalMasterEditor(page)
|