""" 伝票エクスプローラー 伝票の検索・閲覧・管理を直感的に行う 土地勘を持たせるための視覚的ナビゲーション """ 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)