521 lines
20 KiB
Python
521 lines
20 KiB
Python
"""
|
|
テーマ対応マスタ管理アプリケーション
|
|
UI統一 + SQLiteテーマ管理
|
|
"""
|
|
|
|
import flet as ft
|
|
import sqlite3
|
|
import signal
|
|
import sys
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import List, Dict, Optional
|
|
|
|
class ThemeManager:
|
|
"""テーマ管理クラス"""
|
|
|
|
def __init__(self):
|
|
self.themes = {}
|
|
self.current_theme = None
|
|
self._load_themes()
|
|
|
|
def _load_themes(self):
|
|
"""テーマ情報をSQLiteから読み込む"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
# テーマテーブル作成
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS themes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
title TEXT NOT NULL,
|
|
table_name TEXT NOT NULL,
|
|
fields TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
# デフォルトテーマを挿入
|
|
default_themes = [
|
|
{
|
|
'name': '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}]'
|
|
},
|
|
{
|
|
'name': 'products',
|
|
'title': '商品マスタ',
|
|
'table_name': 'products',
|
|
'fields': '[{"name": "name", "label": "商品名", "width": 200, "required": true}, {"name": "category", "label": "カテゴリ", "width": 150}, {"name": "price", "label": "価格", "width": 100, "keyboard_type": "number", "required": true}, {"name": "stock", "label": "在庫数", "width": 100, "keyboard_type": "number"}]'
|
|
},
|
|
{
|
|
'name': '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": "multiline", "required": true}, {"name": "total_amount", "label": "合計金額", "width": 150, "keyboard_type": "number", "required": true}]'
|
|
}
|
|
]
|
|
|
|
# デフォルトテーマがなければ挿入
|
|
cursor.execute("SELECT COUNT(*) FROM themes")
|
|
if cursor.fetchone()[0] == 0:
|
|
for theme in default_themes:
|
|
cursor.execute('''
|
|
INSERT INTO themes (name, title, table_name, fields)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (theme['name'], theme['title'], theme['table_name'], theme['fields']))
|
|
conn.commit()
|
|
|
|
# テーマを読み込み
|
|
cursor.execute("SELECT * FROM themes ORDER BY id")
|
|
themes_data = cursor.fetchall()
|
|
|
|
for theme_data in themes_data:
|
|
theme_id, name, title, table_name, fields_json, created_at = theme_data
|
|
self.themes[name] = {
|
|
'id': theme_id,
|
|
'title': title,
|
|
'table_name': table_name,
|
|
'fields': eval(fields_json) # JSONをPythonオブジェクトに変換
|
|
}
|
|
|
|
conn.close()
|
|
logging.info(f"テーマ読込完了: {len(self.themes)}個")
|
|
|
|
except Exception as e:
|
|
logging.error(f"テーマ読込エラー: {e}")
|
|
|
|
def get_theme_names(self) -> List[str]:
|
|
"""テーマ名リストを取得"""
|
|
return list(self.themes.keys())
|
|
|
|
def get_theme(self, name: str) -> Dict:
|
|
"""指定されたテーマを取得"""
|
|
return self.themes.get(name, {})
|
|
|
|
class UniversalMasterEditor:
|
|
"""汎用マスタ編集コンポーネント"""
|
|
|
|
def __init__(self, page: ft.Page):
|
|
self.page = page
|
|
self.theme_manager = ThemeManager()
|
|
|
|
# 現在のテーマ
|
|
self.current_theme_name = 'customers'
|
|
self.current_theme = self.theme_manager.get_theme(self.current_theme_name)
|
|
self.current_data = []
|
|
self.editing_id = None
|
|
|
|
# UI部品
|
|
self.title = ft.Text("", size=24, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_900)
|
|
self.theme_dropdown = ft.Dropdown(
|
|
label="マスタ選択",
|
|
options=[],
|
|
value="customers",
|
|
on_change=self.change_theme
|
|
)
|
|
|
|
# フォーム
|
|
self.form_fields = ft.Column([], spacing=10)
|
|
|
|
# ボタン群
|
|
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._update_theme_dropdown()
|
|
self._build_form()
|
|
self._load_data()
|
|
|
|
def _update_theme_dropdown(self):
|
|
"""テーマドロップダウンを更新"""
|
|
theme_names = self.theme_manager.get_theme_names()
|
|
self.theme_dropdown.options = [
|
|
ft.dropdown.Option(theme['title'], name)
|
|
for name, theme in self.theme_manager.themes.items()
|
|
]
|
|
self.page.update()
|
|
|
|
def change_theme(self, e):
|
|
"""テーマを切り替え"""
|
|
self.current_theme_name = e.control.value
|
|
self.current_theme = self.theme_manager.get_theme(self.current_theme_name)
|
|
self.editing_id = None
|
|
self.title.value = self.current_theme['title']
|
|
self._build_form()
|
|
self._load_data()
|
|
self.page.update()
|
|
|
|
def _build_form(self):
|
|
"""入力フォームを構築"""
|
|
self.form_fields.controls.clear()
|
|
|
|
for field in self.current_theme['fields']:
|
|
keyboard_type = ft.KeyboardType.TEXT
|
|
if field.get('keyboard_type') == 'number':
|
|
keyboard_type = ft.KeyboardType.NUMBER
|
|
elif field.get('keyboard_type') == 'multiline':
|
|
keyboard_type = ft.KeyboardType.MULTILINE
|
|
|
|
field_control = ft.TextField(
|
|
label=field['label'],
|
|
value='',
|
|
width=field['width'],
|
|
keyboard_type=keyboard_type
|
|
)
|
|
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.current_theme['table_name']
|
|
cursor.execute(f'''
|
|
SELECT * FROM {table_name}
|
|
ORDER BY id
|
|
''')
|
|
|
|
self.current_data = cursor.fetchall()
|
|
conn.close()
|
|
|
|
# データリストを更新
|
|
self.data_list.controls.clear()
|
|
for item in self.current_data:
|
|
item_id = item[0]
|
|
item_data = dict(zip([col[0] for col in cursor.description], item))
|
|
|
|
# データ行
|
|
row_controls = []
|
|
for field in self.current_theme['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.update()
|
|
|
|
except Exception as e:
|
|
logging.error(f"{self.current_theme_name}データ読込エラー: {e}")
|
|
|
|
def save_data(self, e):
|
|
"""データを保存"""
|
|
try:
|
|
# フォームデータを収集
|
|
form_data = {}
|
|
for i, field in enumerate(self.current_theme['fields']):
|
|
field_control = self.form_fields.controls[i]
|
|
form_data[field['name']] = field_control.value
|
|
|
|
# 必須項目チェック
|
|
if field.get('required', False) and not field_control.value.strip():
|
|
self._show_snackbar(f"{field['label']}は必須項目です", ft.Colors.RED)
|
|
return
|
|
|
|
# データベースに保存
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
table_name = self.current_theme['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.current_theme['fields']])
|
|
placeholders = ', '.join(['?' for _ in self.current_theme['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_theme_name}データ保存エラー: {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_theme_name}新規追加モード")
|
|
|
|
def edit_item(self, item_id):
|
|
"""データを編集"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
table_name = self.current_theme['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.current_theme['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_theme_name}編集エラー: {e}")
|
|
|
|
def delete_item(self, item_id):
|
|
"""データを削除"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
table_name = self.current_theme['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_theme_name}削除エラー: {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.theme_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.current_theme['title']}一覧", size=18, weight=ft.FontWeight.BOLD),
|
|
self.instructions,
|
|
self.data_list
|
|
], expand=True, spacing=15)
|
|
|
|
class ThemeMasterApp:
|
|
"""テーマ対応マスタ管理アプリケーション"""
|
|
|
|
def __init__(self, page: ft.Page):
|
|
self.page = page
|
|
|
|
# ログ設定
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.FileHandler('app.log'),
|
|
logging.StreamHandler()
|
|
]
|
|
)
|
|
|
|
# シグナルハンドラ設定
|
|
def signal_handler(signum, frame):
|
|
print(f"\nシグナル {signum} を受信しました")
|
|
print("✅ 正常終了処理完了")
|
|
logging.info("アプリケーション正常終了")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# データベース初期化
|
|
self._init_database()
|
|
|
|
# ウィンドウ設定
|
|
page.title = "テーマ対応マスタ管理システム"
|
|
page.window_width = 1000
|
|
page.window_height = 700
|
|
page.theme_mode = ft.ThemeMode.LIGHT
|
|
|
|
# ウィンドウクローズイベント
|
|
page.on_window_close = lambda _: signal_handler(0, None)
|
|
|
|
# テーマ対応マスタエディタ作成
|
|
self.theme_editor = UniversalMasterEditor(page)
|
|
|
|
# ページ構築
|
|
page.add(
|
|
self.theme_editor.build()
|
|
)
|
|
|
|
logging.info("テーマ対応マスタ管理システム起動完了")
|
|
print("🚀 テーマ対応マスタ管理システム起動完了")
|
|
|
|
def _init_database(self):
|
|
"""データベース初期化"""
|
|
try:
|
|
conn = sqlite3.connect('sales.db')
|
|
cursor = conn.cursor()
|
|
|
|
# 各マスタテーブル作成
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS customers (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
phone TEXT,
|
|
email TEXT,
|
|
address TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
category TEXT,
|
|
price REAL NOT NULL,
|
|
stock INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
cursor.execute('''
|
|
CREATE TABLE IF NOT EXISTS sales_slips (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
customer_name TEXT NOT NULL,
|
|
items TEXT NOT NULL,
|
|
total_amount REAL NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
logging.info("マスタデータベース初期化完了")
|
|
|
|
except Exception as e:
|
|
logging.error(f"データベース初期化エラー: {e}")
|
|
|
|
def main(page: ft.Page):
|
|
"""メイン関数"""
|
|
try:
|
|
app = ThemeMasterApp(page)
|
|
|
|
except Exception as e:
|
|
logging.error(f"アプリケーション起動エラー: {e}")
|
|
|
|
if __name__ == "__main__":
|
|
ft.run(main)
|