h-1.flet.3/app_slip_explorer.py
2026-02-20 23:24:01 +09:00

416 lines
15 KiB
Python

"""
伝票エクスプローラー
伝票の検索・閲覧・管理を直感的に行う
土地勘を持たせるための視覚的ナビゲーション
"""
import flet as ft
import sqlite3
import signal
import sys
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Optional
# ロギング設定
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
class SlipExplorer:
"""伝票エクスプローラー"""
def __init__(self, page: ft.Page):
self.page = page
self.setup_page()
self.setup_database()
self.setup_ui()
def setup_page(self):
"""ページ設定"""
self.page.title = "伝票エクスプローラー"
self.page.window.width = 420
self.page.window.height = 900
self.page.window.resizable = False
self.page.window_center = True
self.page.theme_mode = ft.ThemeMode.LIGHT
# シグナルハンドラ
def signal_handler(signum, frame):
logging.info("伝票エクスプローラー正常終了")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
def setup_database(self):
"""データベース初期化"""
try:
self.conn = sqlite3.connect('sales_assist.db')
self.cursor = self.conn.cursor()
# 伝票テーブル作成
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS slips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slip_type TEXT, -- 売上伝票、見積書、納品書、請求書、領収書
customer_name TEXT,
amount REAL,
date TEXT,
status TEXT, -- 下書き、発行済、入金済、キャンセル
description TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''')
# サンプルデータ作成
self.create_sample_data()
logging.info("伝票データベース接続完了")
except Exception as e:
logging.error(f"データベースエラー: {e}")
self.conn = None
def create_sample_data(self):
"""サンプルデータ作成"""
try:
# サンプル伝票データ
sample_slips = [
("売上伝票", "田中商事", 50000, "2026-02-19", "発行済", "A商品100個"),
("見積書", "鈴木商店", 75000, "2026-02-18", "下書き", "B商品50個"),
("納品書", "伊藤工業", 120000, "2026-02-17", "発行済", "C製品20台"),
("請求書", "高橋建設", 200000, "2026-02-16", "入金済", "工事代金"),
("領収書", "渡辺商事", 80000, "2026-02-15", "発行済", "D商品40個"),
]
for slip in sample_slips:
self.cursor.execute('''
INSERT OR IGNORE INTO slips
(slip_type, customer_name, amount, date, status, description)
VALUES (?, ?, ?, ?, ?, ?)
''', slip)
self.conn.commit()
except Exception as e:
logging.error(f"サンプルデータ作成エラー: {e}")
def setup_ui(self):
"""UI構築"""
# ヘッダー
header = ft.Container(
content=ft.Column([
ft.Text(
"📋 伝票エクスプローラー",
size=20,
weight=ft.FontWeight.BOLD,
color=ft.Colors.WHITE,
text_align=ft.TextAlign.CENTER
),
ft.Text(
"伝票の検索・閲覧・管理",
size=14,
color=ft.Colors.WHITE,
text_align=ft.TextAlign.CENTER
)
], spacing=5),
padding=15,
bgcolor=ft.Colors.BLUE_600,
border_radius=15,
margin=ft.Margin.only(bottom=15)
)
# 検索バー
search_bar = ft.Container(
content=ft.Row([
ft.TextField(
hint_text="伝票を検索...",
prefix_icon=ft.Icons.SEARCH,
filled=True,
dense=True,
expand=True,
on_change=self.on_search_change
),
ft.IconButton(ft.Icons.FILTER_LIST, tooltip="フィルター", icon_size=20),
], spacing=5),
padding=ft.Padding.symmetric(horizontal=15, vertical=5),
margin=ft.Margin.only(bottom=15)
)
# フィルターパネル
filter_panel = ft.Container(
content=ft.Column([
ft.Text("🔍 フィルター", size=14, weight=ft.FontWeight.BOLD),
ft.Divider(height=1),
# 顧客フィルター(チェックボックス)
ft.Text("顧客", size=12, weight=ft.FontWeight.BOLD),
ft.Column([
self.create_checkbox_filter("田中商事", "customer"),
self.create_checkbox_filter("鈴木商店", "customer"),
self.create_checkbox_filter("伊藤工業", "customer"),
self.create_checkbox_filter("高橋建設", "customer"),
], spacing=5),
# 期間フィルター(ラジオボタン)
ft.Text("期間", size=12, weight=ft.FontWeight.BOLD),
ft.Column([
self.create_radio_filter("今日", "period", True),
self.create_radio_filter("今週", "period"),
self.create_radio_filter("今月", "period"),
self.create_radio_filter("全期間", "period"),
], spacing=5),
# 金額帯フィルター(チェックボックス)
ft.Text("金額帯", size=12, weight=ft.FontWeight.BOLD),
ft.Column([
self.create_checkbox_filter("0-1万円", "amount_0_1"),
self.create_checkbox_filter("1-5万円", "amount_1_5"),
self.create_checkbox_filter("5万円以上", "amount_5_plus"),
], spacing=5),
], spacing=10),
padding=15,
bgcolor=ft.Colors.WHITE,
border_radius=10,
margin=ft.Margin.only(bottom=15)
)
# 伝票一覧
self.slip_list = ft.Column([], spacing=8, scroll=ft.ScrollMode.AUTO)
# 統計情報
stats_container = ft.Container(
content=self.get_stats_info(),
padding=15,
bgcolor=ft.Colors.BLUE_50,
border_radius=10,
margin=ft.Margin.only(bottom=15)
)
# メインコンテナ
self.main_container = ft.Column([
header,
search_bar,
filter_panel,
stats_container,
ft.Container(
content=self.slip_list,
expand=True,
padding=ft.Padding.symmetric(horizontal=15)
)
], spacing=5)
# ページに追加
self.page.add(
ft.Container(
content=self.main_container,
padding=10,
bgcolor=ft.Colors.GREY_50,
expand=True
)
)
# 初期データ読み込み
self.load_slips()
def create_checkbox_filter(self, label: str, filter_type: str) -> ft.Row:
"""チェックボックスフィルター作成"""
checkbox = ft.Checkbox(label=label, value=False, on_change=lambda e: self.apply_filters())
return ft.Row([checkbox], spacing=0)
def create_radio_filter(self, label: str, filter_type: str, is_default: bool = False) -> ft.Row:
"""ラジオボタンフィルター作成"""
radio = ft.Radio(
value=label,
label=label,
on_change=lambda e: self.apply_filters()
)
return ft.Row([radio], spacing=0)
def apply_filters(self):
"""フィルター適用"""
# TODO: フィルターロジック実装
self.load_slips()
def get_stats_info(self) -> ft.Column:
"""統計情報取得"""
if not self.conn:
return ft.Column([
ft.Text("データベース未接続", size=12, color=ft.Colors.RED)
])
try:
# 各種統計
self.cursor.execute("SELECT COUNT(*) FROM slips")
total_slips = self.cursor.fetchone()[0]
self.cursor.execute("SELECT COUNT(*) FROM slips WHERE status = '下書き'")
draft_slips = self.cursor.fetchone()[0]
self.cursor.execute("SELECT COUNT(*) FROM slips WHERE status = '入金済'")
paid_slips = self.cursor.fetchone()[0]
self.cursor.execute("SELECT SUM(amount) FROM slips WHERE status = '入金済'")
total_amount = self.cursor.fetchone()[0] or 0
return ft.Column([
ft.Text("📊 伝票統計", size=14, weight=ft.FontWeight.BOLD),
ft.Row([
ft.Text(f"総数: {total_slips}", size=12, expand=True),
ft.Text(f"下書き: {draft_slips}", size=12, expand=True),
]),
ft.Row([
ft.Text(f"入金済: {paid_slips}", size=12, expand=True),
ft.Text(f"合計: ¥{total_amount:,.0f}", size=12, expand=True),
])
], spacing=5)
except Exception as e:
return ft.Column([
ft.Text("統計取得エラー", size=12, color=ft.Colors.RED)
])
def load_slips(self, filter_type: str = "all"):
"""伝票一覧読み込み"""
if not self.conn:
return
try:
query = "SELECT * FROM slips"
params = []
if filter_type != "all":
query += " WHERE slip_type = ?"
params = [filter_type]
query += " ORDER BY date DESC, created_at DESC"
self.cursor.execute(query, params)
slips = self.cursor.fetchall()
# 一覧をクリアして再構築
self.slip_list.controls.clear()
for slip in slips:
slip_item = self.create_slip_item(slip)
self.slip_list.controls.append(slip_item)
self.page.update()
except Exception as e:
logging.error(f"伝票読み込みエラー: {e}")
def create_slip_item(self, slip: tuple, is_highlighted: bool = False) -> ft.Container:
"""伝票アイテム作成(ハイライト対応)"""
slip_id, slip_type, customer_name, amount, date, status, description, created_at = slip
# ステータスに応じた色
status_colors = {
"下書き": ft.Colors.ORANGE,
"発行済": ft.Colors.BLUE,
"入金済": ft.Colors.GREEN,
"キャンセル": ft.Colors.RED
}
# タイプに応じたアイコン
type_icons = {
"売上伝票": "💰",
"見積書": "📄",
"納品書": "📦",
"請求書": "📋",
"領収書": "🧾"
}
# ハイライト効果
if is_highlighted:
bgcolor = ft.Colors.BLUE_50
border_color = ft.Colors.BLUE_600
opacity = 1.0
shadow = ft.BoxShadow(
spread_radius=2,
blur_radius=8,
color=ft.Colors.with_opacity(0.3, ft.Colors.BLUE),
offset=ft.Offset(0, 4)
)
else:
bgcolor = ft.Colors.WHITE
border_color = ft.Colors.GREY_200
opacity = 0.3 # グレーアウト
shadow = None
return ft.Container(
content=ft.Row([
# アイコン
ft.Container(
content=ft.Text(type_icons.get(slip_type, "📝"), size=24),
width=50,
height=50,
bgcolor=ft.Colors.BLUE_50 if is_highlighted else ft.Colors.GREY_100,
alignment=ft.alignment.Alignment(0, 0),
border_radius=10
),
# メイン情報
ft.Column([
ft.Text(f"{slip_type} - {customer_name}", size=14, weight=ft.FontWeight.BOLD),
ft.Text(f"¥{amount:,.0f} - {date}", size=12, color=ft.Colors.GREY_600),
ft.Text(description or "", size=10, color=ft.Colors.GREY_500)
], expand=True),
# ステータス
ft.Container(
content=ft.Text(status, size=10, color=ft.Colors.WHITE),
padding=ft.Padding.symmetric(horizontal=8, vertical=4),
bgcolor=status_colors.get(status, ft.Colors.GREY),
border_radius=10
)
], spacing=10),
padding=12,
bgcolor=bgcolor,
border_radius=10,
border=ft.Border.all(2, border_color),
opacity=opacity,
shadow=shadow,
on_click=lambda _: self.open_slip(slip_id)
)
def on_search_change(self, e):
"""検索変更時"""
search_text = e.control.value.lower()
# TODO: 検索機能実装
pass
def filter_by_type(self, type_value: str):
"""タイプでフィルター"""
self.load_slips(type_value)
def open_slip(self, slip_id: int):
"""伝票を開く"""
self.page.snack_bar = ft.SnackBar(
content=ft.Text(f"伝票#{slip_id}を開きます"),
bgcolor=ft.Colors.BLUE
)
self.page.snack_bar.open = True
self.page.update()
# TODO: 伝票詳細画面へ遷移
pass
def main(page: ft.Page):
"""メイン関数"""
try:
explorer = SlipExplorer(page)
logging.info("伝票エクスプローラー起動完了")
except Exception as e:
logging.error(f"伝票エクスプローラー起動エラー: {e}")
page.snack_bar = ft.SnackBar(
content=ft.Text(f"起動エラー: {e}"),
bgcolor=ft.Colors.RED
)
page.snack_bar.open = True
page.update()
if __name__ == "__main__":
ft.run(main)