""" Flutter風ダッシュボード 下部ナビゲーションと洗練されたUIコンポーネントを実装 """ import flet as ft import signal import sys import logging from datetime import datetime from typing import List, Dict, Optional from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer from components.customer_picker import CustomerPickerModal from services.app_service import AppService # ロギング設定 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) class FlutterStyleDashboard: """Flutter風の統合ダッシュボード""" def __init__(self, page: ft.Page): self.page = page self.current_tab = 0 # 0: 新規作成, 1: 発行履歴 self.selected_customer = None self.selected_document_type = DocumentType.INVOICE self.amount_value = "250000" self.customer_picker = None self.is_customer_picker_open = False self.customer_search_query = "" self.show_offsets = False # ビジネスロジックサービス self.app_service = AppService() self.invoices = [] self.customers = [] self.setup_page() self.setup_database() self.setup_ui() def setup_page(self): """ページ設定""" self.page.title = "販売アシスト1号" self.page.window.width = 420 self.page.window.height = 900 self.page.theme_mode = ft.ThemeMode.LIGHT # Fletのライフサイクルに任せる(SystemExitがasyncioに伝播して警告になりやすい) def setup_database(self): """データ初期化(サービス層経由)""" try: # 顧客データ読み込み self.customers = self.app_service.customer.get_all_customers() # 伝票データ読み込み self.invoices = self.app_service.invoice.get_recent_invoices(20) logging.info(f"データ初期化: 顧客{len(self.customers)}件, 伝票{len(self.invoices)}件") except Exception as e: logging.error(f"データ初期化エラー: {e}") def create_sample_data(self): """サンプル伝票データ作成""" try: # サンプルデータ sample_invoices = create_sample_invoices() for invoice in sample_invoices: self.cursor.execute(''' INSERT OR REPLACE INTO slips (document_type, customer_name, amount, date, status, description) VALUES (?, ?, ?, ?, ?, ?) ''', ( invoice.document_type.value, invoice.customer.formal_name, invoice.total_amount, invoice.date.strftime('%Y-%m-%d'), '完了', invoice.notes )) self.conn.commit() except Exception as e: logging.error(f"サンプルデータ作成エラー: {e}") def setup_ui(self): """UIセットアップ""" # 下部ナビゲーション(NavigationRail風) bottom_nav = ft.Container( content=ft.Row([ ft.Container( content=ft.IconButton( ft.Icons.ADD_BOX, selected=self.current_tab == 0, on_click=lambda _: self.on_tab_change(0), ), ), ft.Text("新規作成"), ft.Container( content=ft.IconButton( ft.Icons.HISTORY, selected=self.current_tab == 1, on_click=lambda _: self.on_tab_change(1), ), ), ft.Text("発行履歴"), ], alignment=ft.MainAxisAlignment.CENTER), padding=ft.Padding.all(10), bgcolor=ft.Colors.BLUE_GREY_50, ) # メインコンテンツ self.main_content = ft.Column([], expand=True) # ページ構成 self.page.add( ft.Column([ self.main_content, bottom_nav, ], expand=True) ) # 初期表示 self.update_main_content() def on_tab_change(self, index): """タブ切り替え""" self.current_tab = index self.update_main_content() self.page.update() def update_main_content(self): """メインコンテンツ更新""" self.main_content.controls.clear() if self.current_tab == 0: if self.is_customer_picker_open: self.main_content.controls.append(self.create_customer_picker_screen()) else: # 新規作成画面 self.main_content.controls.append(self.create_slip_input_screen()) else: # 発行履歴画面 self.main_content.controls.append(self.create_slip_history_screen()) self.page.update() def create_customer_picker_screen(self) -> ft.Container: """顧客選択画面(画面内遷移・ダイアログ不使用)""" self.customers = self.app_service.customer.get_all_customers() def back(_=None): self.is_customer_picker_open = False self.customer_search_query = "" self.update_main_content() list_container = ft.Column([], spacing=0, scroll=ft.ScrollMode.AUTO, expand=True) def render_list(customers: List[Customer]): list_container.controls.clear() for customer in customers: list_container.controls.append( ft.ListTile( title=ft.Text(customer.formal_name, weight=ft.FontWeight.BOLD), subtitle=ft.Text(f"{customer.address}\n{customer.phone}"), on_click=lambda _, c=customer: select_customer(c), ) ) self.page.update() def select_customer(customer: Customer): self.selected_customer = customer logging.info(f"顧客を選択: {customer.formal_name}") back() def on_search_change(e): q = (e.control.value or "").strip().lower() self.customer_search_query = q if not q: render_list(self.customers) return filtered = [ c for c in self.customers if q in (c.name or "").lower() or q in (c.formal_name or "").lower() or q in (c.address or "").lower() or q in (c.phone or "").lower() ] render_list(filtered) search_field = ft.TextField( label="顧客検索", prefix_icon=ft.Icons.SEARCH, value=self.customer_search_query, on_change=on_search_change, autofocus=True, ) header = ft.Container( content=ft.Row( [ ft.IconButton(ft.Icons.ARROW_BACK, on_click=back), ft.Text("顧客を選択", size=18, weight=ft.FontWeight.BOLD), ] ), padding=ft.padding.all(15), bgcolor=ft.Colors.BLUE_GREY, ) content = ft.Column( [ header, ft.Container(content=search_field, padding=ft.padding.all(15)), ft.Container(content=list_container, padding=ft.padding.symmetric(horizontal=15), expand=True), ], expand=True, ) # 初期表示 render_list(self.customers) return ft.Container(content=content, expand=True) def create_slip_input_screen(self) -> ft.Container: """伝票入力画面""" # 帳票種類選択(タブ風ボタン) document_types = list(DocumentType) type_buttons = ft.Row([ ft.Container( content=ft.Button( content=ft.Text(doc_type.value, size=12), bgcolor=ft.Colors.BLUE_GREY_800 if i == 0 else ft.Colors.GREY_300, color=ft.Colors.WHITE if i == 0 else ft.Colors.BLACK, on_click=lambda _, idx=i, dt=doc_type: self.select_document_type(dt.value), width=100, height=45, ), margin=ft.margin.only(right=5), ) for i, doc_type in enumerate(document_types) ], wrap=True) return ft.Container( content=ft.Column([ # ヘッダー ft.Container( content=ft.Row([ ft.Text("📋 販売アシスト1号", size=20, weight=ft.FontWeight.BOLD), ft.Container(expand=True), ft.IconButton(ft.Icons.SETTINGS, icon_size=20), ]), padding=ft.padding.all(15), bgcolor=ft.Colors.BLUE_GREY, ), # 帳票種類選択 ft.Container( content=ft.Column([ ft.Text("帳票の種類を選択", size=16, weight=ft.FontWeight.BOLD), ft.Container(height=10), type_buttons, ]), padding=ft.padding.all(20), ), # 顧客選択 ft.Container( content=ft.Column([ ft.Text("宛先と基本金額の設定", size=16, weight=ft.FontWeight.BOLD), ft.Container(height=10), ft.Row( [ ft.TextField( label="取引先名", value=self.selected_customer.formal_name if self.selected_customer else "未選択", read_only=True, border_color=ft.Colors.BLUE_GREY, expand=True, ), ft.IconButton( ft.Icons.PERSON_SEARCH, tooltip="顧客を選択", on_click=self.open_customer_picker, ), ], spacing=8, ), ft.Container(height=10), ft.TextField( label="基本金額 (税抜)", value=self.amount_value, keyboard_type=ft.KeyboardType.NUMBER, on_change=self.on_amount_change, border_color=ft.Colors.BLUE_GREY, ), ]), padding=ft.padding.all(20), ), # 作成ボタン ft.Container( content=ft.Button( content=ft.Row([ ft.Icon(ft.Icons.DESCRIPTION, color=ft.Colors.WHITE), ft.Text("伝票を作成して詳細編集へ", color=ft.Colors.WHITE), ], alignment=ft.MainAxisAlignment.CENTER), style=ft.ButtonStyle( bgcolor=ft.Colors.BLUE_GREY_800, padding=ft.padding.all(20), shape=ft.RoundedRectangleBorder(radius=15), ), width=400, height=60, on_click=self.create_slip, ), padding=ft.padding.all(20), ), ]), expand=True, ) def create_slip_history_screen(self) -> ft.Container: """伝票履歴画面""" # 履歴データ読み込み slips = self.load_slips() if not self.show_offsets: slips = [s for s in slips if not (isinstance(s, Invoice) and getattr(s, "is_offset", False))] def on_toggle_offsets(e): self.show_offsets = bool(e.control.value) self.update_main_content() # 履歴カードリスト slip_cards = [] for slip in slips: card = self.create_slip_card(slip) slip_cards.append(card) return ft.Container( content=ft.Column([ # ヘッダー ft.Container( content=ft.Row([ ft.Text("📄 発行履歴管理", size=20, weight=ft.FontWeight.BOLD), ft.Container(expand=True), ft.Row( [ ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE), ft.Switch(value=self.show_offsets, on_change=on_toggle_offsets), ], spacing=5, ), ft.IconButton(ft.Icons.CLEAR_ALL, icon_size=20), ]), padding=ft.padding.all(15), bgcolor=ft.Colors.BLUE_GREY, ), # 履歴リスト ft.Column( controls=slip_cards, spacing=10, scroll=ft.ScrollMode.AUTO, expand=True, ), ]), expand=True, ) def create_slip_card(self, slip) -> ft.Card: """伝票カード作成""" # サービス層からは Invoice オブジェクトが返る if isinstance(slip, Invoice): slip_type = slip.document_type.value customer_name = slip.customer.formal_name amount = slip.total_amount date = slip.date.strftime("%Y-%m-%d") status = "赤伝" if getattr(slip, "is_offset", False) else "完了" else: slip_id, slip_type, customer_name, amount, date, status, description, created_at = slip # タイプに応じたアイコンと色 type_config = { "売上伝票": {"icon": "💰", "color": ft.Colors.GREEN}, "見積書": {"icon": "📄", "color": ft.Colors.BLUE}, "納品書": {"icon": "📦", "color": ft.Colors.PURPLE}, "請求書": {"icon": "📋", "color": ft.Colors.ORANGE}, "領収書": {"icon": "🧾", "color": ft.Colors.RED} } config = type_config.get(slip_type, {"icon": "📝", "color": ft.Colors.GREY}) def issue_offset(_=None): if not isinstance(slip, Invoice): return if getattr(slip, "is_offset", False): return offset_invoice = self.app_service.invoice.create_offset_invoice(slip.uuid) if offset_invoice and offset_invoice.file_path: self.app_service.invoice.delete_pdf_file(offset_invoice.file_path) # リストを更新 self.invoices = self.app_service.invoice.get_recent_invoices(20) self.update_main_content() actions_row = None if isinstance(slip, Invoice) and not getattr(slip, "is_offset", False): actions_row = ft.Row( [ ft.IconButton( ft.Icons.REPLAY_CIRCLE_FILLED, tooltip="赤伝(相殺)を発行", icon_color=ft.Colors.RED_400, on_click=issue_offset, ) ], alignment=ft.MainAxisAlignment.END, ) display_amount = amount if isinstance(slip, Invoice) and getattr(slip, "is_offset", False): display_amount = -abs(amount) return ft.Card( content=ft.Container( content=ft.Column([ ft.Row([ ft.Container( content=ft.Text(config["icon"], size=24), width=40, height=40, bgcolor=config["color"], border_radius=20, alignment=ft.alignment.Alignment(0, 0), ), ft.Container( content=ft.Column([ ft.Text(slip_type, size=12, weight=ft.FontWeight.BOLD), ft.Text(f"{customer_name} ¥{display_amount:,.0f}", size=10), ]), expand=True, ), ]), ft.Container(height=5), ft.Text(f"日付: {date}", size=10, color=ft.Colors.GREY_600), ft.Text(f"状態: {status}", size=10, color=ft.Colors.GREY_600), actions_row if actions_row else ft.Container(height=0), ]), padding=ft.padding.all(15), ), elevation=3, ) def open_customer_picker(self, e=None): """顧客選択を開く(画面内遷移)""" logging.info("顧客選択画面へ遷移") self.is_customer_picker_open = True self.update_main_content() def on_customer_selected(self, customer: Customer): """顧客選択時の処理""" self.selected_customer = customer logging.info(f"顧客を選択: {customer.formal_name}") # UIを更新して選択された顧客を表示 self.update_main_content() def on_customer_deleted(self, customer: Customer): """顧客削除時の処理""" self.app_service.customer.delete_customer(customer.id) self.customers = self.app_service.customer.get_all_customers() logging.info(f"顧客を削除: {customer.formal_name}") # モーダルを再表示してリストを更新 if self.customer_picker and self.customer_picker.is_open: self.customer_picker.update_customer_list(self.customers) def on_amount_change(self, e): """金額変更時の処理""" self.amount_value = e.control.value logging.info(f"金額を変更: {self.amount_value}") def on_document_type_change(self, index): """帳票種類変更""" document_types = list(DocumentType) selected_type = document_types[index] logging.info(f"帳票種類を変更: {selected_type.value}") # TODO: 選択された種類を保存 def select_document_type(self, doc_type: str): """帳票種類選択""" # DocumentTypeから対応するenumを見つける for dt in DocumentType: if dt.value == doc_type: self.selected_document_type = dt logging.info(f"帳票種類を選択: {doc_type}") break def create_slip(self, e=None): """伝票作成 - サービス層を使用""" if not self.selected_customer: logging.warning("顧客が選択されていません") return try: amount = int(self.amount_value) if self.amount_value else 250000 except ValueError: amount = 250000 logging.info(f"伝票を作成: {self.selected_document_type.value}, {self.selected_customer.formal_name}, ¥{amount:,}") # サービス層経由で伝票作成 invoice = self.app_service.invoice.create_invoice( customer=self.selected_customer, document_type=self.selected_document_type, amount=amount, notes="" ) if invoice: if invoice.file_path: self.app_service.invoice.delete_pdf_file(invoice.file_path) invoice.file_path = None logging.info(f"伝票作成成功: {invoice.invoice_number}") # リストを更新 self.invoices = self.app_service.invoice.get_recent_invoices(20) # 発行履歴タブに切り替え self.on_tab_change(1) else: logging.error("伝票作成失敗") def load_slips(self) -> List[Invoice]: """伝票データ読み込み - サービス層経由""" return self.app_service.invoice.get_recent_invoices(20) def main(page: ft.Page): """メイン関数""" app = FlutterStyleDashboard(page) page.update() if __name__ == "__main__": ft.app(target=main)