""" 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, InvoiceItem 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.editing_invoice = None # 編集中の伝票 self.is_edit_mode = False # 編集モードフラグ self.is_customer_picker_open = False self.customer_search_query = "" self.show_offsets = False self.chain_verify_result = None self.is_new_customer_form_open = 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_new_customer_form_open: self.main_content.controls.append(self.create_new_customer_screen()) elif self.is_customer_picker_open: self.main_content.controls.append(self.create_customer_picker_screen()) else: # 伝票一覧画面 self.main_content.controls.append(self.create_slip_list_screen()) else: # 詳細編集画面 self.main_content.controls.append(self.create_invoice_edit_screen()) self.page.update() def create_slip_list_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() def on_verify_chain(e=None): res = self.app_service.invoice.invoice_repo.verify_chain() self.chain_verify_result = res self.update_main_content() def create_new_slip(_): """新規伝票作成""" self.editing_invoice = None self.current_tab = 1 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.IconButton( ft.Icons.VERIFIED, tooltip="チェーン検証", icon_color=ft.Colors.BLUE_300, on_click=on_verify_chain, ), 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.Container( content=self._build_chain_verify_result(), margin=ft.Margin.only(bottom=10), ) if self.chain_verify_result else ft.Container(height=0), # 伝票リスト ft.Column( controls=slip_cards, spacing=10, scroll=ft.ScrollMode.AUTO, expand=True, ), # 新規作成ボタン ft.Container( content=ft.Button( content=ft.Row([ ft.Icon(ft.Icons.ADD, 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=create_new_slip, ), padding=ft.padding.all(20), ), ]), expand=True, ) 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), ft.Container(expand=True), ft.IconButton( ft.Icons.PERSON_ADD, tooltip="新規顧客追加", icon_color=ft.Colors.WHITE, on_click=lambda _: self.open_new_customer_form(), ), ] ), 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 doc_type == self.selected_document_type else ft.Colors.GREY_300, color=ft.Colors.WHITE if doc_type == self.selected_document_type 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() def on_verify_chain(e=None): res = self.app_service.invoice.invoice_repo.verify_chain() self.chain_verify_result = res 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.IconButton( ft.Icons.VERIFIED, tooltip="チェーン検証", icon_color=ft.Colors.BLUE_300, on_click=on_verify_chain, ), 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.Container( content=self._build_chain_verify_result(), margin=ft.Margin.only(bottom=10), ) if self.chain_verify_result else ft.Container(height=0), # 履歴リスト 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() def regenerate_and_share(_=None): if not isinstance(slip, Invoice): return pdf_path = self.app_service.invoice.regenerate_pdf(slip.uuid) if pdf_path: # TODO: OSの共有ダイアログを呼ぶ(プラットフォーム依存) logging.info(f"PDF再生成: {pdf_path}(共有機能は未実装)") # 共有後に削除(今は即削除) self.app_service.invoice.delete_pdf_file(pdf_path) logging.info(f"PDF削除完了: {pdf_path}") else: logging.error("PDF再生成失敗") def edit_invoice(_=None): if not isinstance(slip, Invoice): return self.open_invoice_edit(slip) actions_row = None if isinstance(slip, Invoice): buttons = [] if not getattr(slip, "submitted_to_tax_authority", False): buttons.append( ft.ElevatedButton( content=ft.Text("編集", color=ft.Colors.WHITE), style=ft.ButtonStyle( bgcolor=ft.Colors.GREEN_600, padding=ft.padding.all(10), ), on_click=edit_invoice, width=80, height=40, ) ) buttons.append( ft.ElevatedButton( content=ft.Text("赤伝", color=ft.Colors.WHITE), style=ft.ButtonStyle( bgcolor=ft.Colors.RED_600, padding=ft.padding.all(10), ), on_click=issue_offset, width=80, height=40, ) ) buttons.append( ft.ElevatedButton( content=ft.Text("提出", color=ft.Colors.WHITE), style=ft.ButtonStyle( bgcolor=ft.Colors.ORANGE_600, padding=ft.padding.all(10), ), on_click=lambda _: self.submit_invoice_for_tax(slip.uuid), width=80, height=40, ) ) buttons.append( ft.IconButton( ft.Icons.DOWNLOAD, tooltip="PDF再生成→共有", icon_color=ft.Colors.BLUE_400, on_click=regenerate_and_share, ) ) actions_row = ft.Row(buttons, alignment=ft.MainAxisAlignment.CENTER, spacing=10) 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 _build_chain_verify_result(self) -> ft.Control: if not self.chain_verify_result: return ft.Container(height=0) r = self.chain_verify_result ok = r.get("ok", False) checked = r.get("checked", 0) errors = r.get("errors", []) if ok: return ft.Container( content=ft.Row([ ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN, size=20), ft.Text(f"チェーン検証 OK ({checked}件)", size=14, color=ft.Colors.GREEN), ]), bgcolor=ft.Colors.GREEN_50, padding=ft.Padding.all(10), border_radius=8, ) else: return ft.Container( content=ft.Column([ ft.Row([ ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED, size=20), ft.Text(f"チェーン検証 NG (checked={checked})", size=14, color=ft.Colors.RED), ]), ft.Text(f"エラー: {errors}", size=12, color=ft.Colors.RED_700), ]), bgcolor=ft.Colors.RED_50, padding=ft.Padding.all(10), border_radius=8, ) def open_invoice_edit(self, invoice: Invoice): """伝票編集画面を開く""" self.editing_invoice = invoice self.is_edit_mode = True self.selected_customer = invoice.customer self.selected_document_type = invoice.document_type self.amount_value = str(invoice.items[0].unit_price if invoice.items else "0") self.is_detail_edit_mode = False # 初期はビューモード self.current_tab = 1 # 詳細編集タブに切り替え self.update_main_content() def open_new_customer_form(self): """新規顧客フォームを開く(画面内遷移)""" self.is_new_customer_form_open = True self.update_main_content() def create_invoice_edit_screen(self) -> ft.Container: """伝票編集画面""" if self.editing_invoice: # 既存伝票の編集 return self._create_edit_existing_screen() else: # 新規伝票作成 return self.create_slip_input_screen() def _create_edit_existing_screen(self) -> ft.Container: """既存伝票の編集画面""" # 編集不可チェック is_locked = getattr(self.editing_invoice, 'submitted_to_tax_authority', False) is_view_mode = not getattr(self, 'is_detail_edit_mode', False) # 伝票種類選択(ヘッダーに移動) document_types = list(DocumentType) # 明細テーブル items_table = self._create_items_table(self.editing_invoice.items, is_locked or is_view_mode) # 顧客表示 customer_display = ft.Container( content=ft.Row([ ft.Text("顧客: ", weight=ft.FontWeight.BOLD), ft.Text(self.editing_invoice.customer.name), # 顧客名を表示 ]), padding=ft.padding.symmetric(horizontal=10, vertical=5), bgcolor=ft.Colors.GREY_100, border_radius=5, ) # 備考フィールド notes_field = ft.TextField( label="備考", value=getattr(self.editing_invoice, 'notes', ''), disabled=is_locked or is_view_mode, multiline=True, min_lines=2, max_lines=3, ) def toggle_edit_mode(_): """編集モード切替""" old_mode = getattr(self, 'is_detail_edit_mode', False) self.is_detail_edit_mode = not old_mode logging.debug(f"Toggle edit mode: {old_mode} -> {self.is_detail_edit_mode}") self.update_main_content() def save_changes(_): if is_locked: return # 伝票を更新(現在の明細を保持) # TODO: テーブルから実際の値を取得して更新 # 現在は既存の明細を維持し、備考のみ更新 self.editing_invoice.notes = notes_field.value self.editing_invoice.document_type = self.selected_document_type # TODO: ハッシュ再計算と保存 logging.info(f"伝票更新: {self.editing_invoice.invoice_number}") # 編集モード終了(ビューモードに戻る) self.is_detail_edit_mode = False # ビューモードに戻る self.update_main_content() def cancel_edit(_): self.is_detail_edit_mode = False self.is_edit_mode = False self.editing_invoice = None self.current_tab = 0 # 一覧タブに戻る self.update_main_content() return ft.Container( content=ft.Column([ # ヘッダー ft.Container( content=ft.Row([ ft.Container(expand=True), # コンパクトな伝票種類選択(セグメント化) ft.Container( content=ft.Row([ ft.GestureDetector( content=ft.Container( content=ft.Text( doc_type.value, size=10, color=ft.Colors.WHITE if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_600, weight=ft.FontWeight.BOLD if doc_type == self.editing_invoice.document_type else ft.FontWeight.NORMAL, ), padding=ft.padding.symmetric(horizontal=8, vertical=4), bgcolor=ft.Colors.BLUE_600 if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_300, border_radius=ft.border_radius.all(4), margin=ft.margin.only(right=1), ), on_tap=lambda _, dt=doc_type: self.select_document_type(dt.value) if not is_locked and not is_view_mode else None, ) for doc_type in document_types ]), padding=ft.padding.all(2), bgcolor=ft.Colors.GREY_200, border_radius=ft.border_radius.all(6), margin=ft.margin.only(right=10), ), ft.ElevatedButton( content=ft.Text("編集" if is_view_mode else "保存"), style=ft.ButtonStyle( bgcolor=ft.Colors.BLUE_600 if is_view_mode else ft.Colors.GREEN_600, ), on_click=toggle_edit_mode if is_view_mode else save_changes, disabled=is_locked, width=70, height=30, ) if not is_locked else ft.Container(), ft.Container(width=5), ft.IconButton(ft.Icons.CLOSE, on_click=cancel_edit), ]), padding=ft.padding.symmetric(horizontal=15, vertical=8), bgcolor=ft.Colors.BLUE_GREY, ), # 基本情報行(コンパクトに) ft.Container( content=ft.Row([ ft.Text(f"{self.editing_invoice.invoice_number} | {self.editing_invoice.date.strftime('%Y/%m/%d')} | {self.editing_invoice.customer.name}", size=12, weight=ft.FontWeight.BOLD), ft.Container(expand=True), ]), padding=ft.padding.symmetric(horizontal=15, vertical=3), bgcolor=ft.Colors.GREY_50, ), # 明細テーブル(フレキシブルに) ft.Container( content=ft.Column([ ft.Row([ ft.Container(expand=True), ft.IconButton( ft.Icons.ADD_CIRCLE_OUTLINE, tooltip="行を追加", icon_color=ft.Colors.GREEN_600, disabled=is_locked or is_view_mode, on_click=lambda _: self._add_item_row(), ) if not is_locked and not is_view_mode else ft.Container(), ]), ft.Container(height=3), ft.Container( content=items_table, height=300, # 高さを縮小 border=ft.border.all(1, ft.Colors.GREY_300), border_radius=5, padding=ft.padding.all(1), # パディングを最小化 width=None, # 幅を可変に expand=True, # 利用可能な幅を全て使用 ), ft.Container(height=10), # 合計金額表示 ft.Container( content=ft.Row([ ft.Container(expand=True), ft.Text("合計: ", size=14, weight=ft.FontWeight.BOLD), ft.Text( f"¥{sum(item.subtotal for item in self.editing_invoice.items):,}", size=16, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_600 ), ]), padding=ft.padding.symmetric(horizontal=10, vertical=8), bgcolor=ft.Colors.GREY_100, border_radius=5, ), ]), padding=ft.padding.all(15), expand=True, # 明細部分が最大限のスペースを占有 ), # 備考(コンパクト) ft.Container( content=ft.Column([ ft.Text("備考", size=12, weight=ft.FontWeight.BOLD), ft.Container(height=3), notes_field, ft.Container(height=5), ft.Text("🔒 税務署提出済みは編集できません" if is_locked else "✅ " + ("編集モード" if not is_view_mode else "ビューモード"), size=11, color=ft.Colors.RED_600 if is_locked else (ft.Colors.GREEN_600 if not is_view_mode else ft.Colors.BLUE_600)), ft.Container(height=10), # PDF生成ボタンを追加 ft.ElevatedButton( content=ft.Row([ ft.Icon(ft.Icons.DOWNLOAD, size=16), ft.Container(width=5), ft.Text("PDF生成", size=12, color=ft.Colors.WHITE), ]), style=ft.ButtonStyle( bgcolor=ft.Colors.BLUE_600, padding=ft.padding.symmetric(horizontal=15, vertical=10), ), on_click=lambda _: self.generate_pdf_from_edit(), width=120, height=40, ) if not is_locked else ft.Container(), ]), padding=ft.padding.symmetric(horizontal=15, vertical=10), bgcolor=ft.Colors.GREY_50, ), ]), expand=True, ) def generate_pdf_from_edit(self): """編集画面からPDFを生成""" if not self.editing_invoice: return try: pdf_path = self.app_service.invoice.regenerate_pdf(self.editing_invoice.uuid) if pdf_path: self.editing_invoice.file_path = pdf_path self.editing_invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat() logging.info(f"PDF生成完了: {pdf_path}") # TODO: 成功メッセージ表示 else: logging.error("PDF生成失敗") # TODO: エラーメッセージ表示 except Exception as e: logging.error(f"PDF生成エラー: {e}") # TODO: エラーメッセージ表示 def _add_item_row(self): """明細行を追加""" if not self.editing_invoice: return # 新しい明細行を追加 new_item = InvoiceItem( description="新しい商品", quantity=1, unit_price=0, is_discount=False ) self.editing_invoice.items.append(new_item) self.update_main_content() def _delete_item_row(self, index: int): """明細行を削除""" if not self.editing_invoice or index >= len(self.editing_invoice.items): return # 行を削除(最低1行は残す) if len(self.editing_invoice.items) > 1: del self.editing_invoice.items[index] self.update_main_content() def _update_item_field(self, item_index: int, field_name: str, value: str): """明細フィールドを更新""" if not self.editing_invoice or item_index >= len(self.editing_invoice.items): return item = self.editing_invoice.items[item_index] # デバッグ用:更新前の値をログ出力 old_value = getattr(item, field_name) logging.debug(f"Updating item {item_index} {field_name}: '{old_value}' -> '{value}'") if field_name == 'description': item.description = value elif field_name == 'quantity': try: # 空文字の場合は1を設定 if not value or value.strip() == '': item.quantity = 1 else: item.quantity = int(value) logging.debug(f"Quantity updated to: {item.quantity}") except ValueError as e: item.quantity = 1 logging.error(f"Quantity update error: {e}") elif field_name == 'unit_price': try: # 空文字の場合は0を設定 if not value or value.strip() == '': item.unit_price = 0 else: item.unit_price = int(value) logging.debug(f"Unit price updated to: {item.unit_price}") except ValueError as e: item.unit_price = 0 logging.error(f"Unit price update error: {e}") # 合計金額を更新するために画面を更新 self.update_main_content() def _create_items_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column: """明細テーブルを作成(編集モードと表示モードで別の見せ方)""" # ビューモード状態を取得 is_view_mode = not getattr(self, 'is_detail_edit_mode', False) # デバッグ用:メソッド開始時の状態をログ出力 logging.debug(f"Creating items table: locked={is_locked}, view_mode={is_view_mode}, edit_mode={getattr(self, 'is_detail_edit_mode', False)}") logging.debug(f"Items count: {len(items)}") if is_view_mode or is_locked: # 表示モード:表形式で整然と表示 return self._create_view_mode_table(items) else: # 編集モード:入力フォーム風に表示 return self._create_edit_mode_table(items, is_locked) def _create_view_mode_table(self, items: List[InvoiceItem]) -> ft.Column: """表示モード:フレキシブルな表形式で整然と表示""" # ヘッダー行 header_row = ft.Row([ ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), # 可変幅 ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35), # 固定幅 ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 ft.Container(width=35), # 削除ボタン用スペースを確保 ]) # データ行 data_rows = [] for i, item in enumerate(items): row = ft.Row([ ft.Text(item.description, size=12, expand=True), # 可変幅 ft.Text(str(item.quantity), size=12, width=35, text_align=ft.TextAlign.RIGHT), # 固定幅 ft.Text(f"¥{item.unit_price:,}", size=12, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅 ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅 ft.Container(width=35), # 削除ボタン用スペース ]) data_rows.append(row) return ft.Column([ header_row, ft.Divider(height=1, color=ft.Colors.GREY_400), ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限 ]) def _create_edit_mode_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column: """編集モード:フレキシブルな表形式""" # 編集モードに入ったら自動で空行を追加 if not any(item.description == "" and item.quantity == 0 and item.unit_price == 0 for item in items): items.append(InvoiceItem(description="", quantity=0, unit_price=0)) # ヘッダー行 header_row = ft.Row([ ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), # 可変幅 ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35), # 固定幅 ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅 ft.Container(width=35), # 削除ボタン用スペースを確保 ]) # データ行 data_rows = [] for i, item in enumerate(items): # 商品名フィールド product_field = ft.TextField( value=item.description, text_size=12, height=28, width=None, # 幅を可変に expand=True, # 可変幅 border=ft.border.all(1, ft.Colors.BLUE_200), bgcolor=ft.Colors.WHITE, content_padding=ft.padding.all(5), # 内部余白を最小化 on_change=lambda e: self._update_item_field(i, 'description', e.control.value), ) # 数量フィールド quantity_field = ft.TextField( value=str(item.quantity), text_size=12, height=28, width=35, # 固定幅 text_align=ft.TextAlign.RIGHT, border=ft.border.all(1, ft.Colors.BLUE_200), bgcolor=ft.Colors.WHITE, content_padding=ft.padding.all(5), # 内部余白を最小化 on_change=lambda e: self._update_item_field(i, 'quantity', e.control.value), keyboard_type=ft.KeyboardType.NUMBER, ) # 単価フィールド unit_price_field = ft.TextField( value=f"{item.unit_price:,}", text_size=12, height=28, width=70, # 固定幅 text_align=ft.TextAlign.RIGHT, border=ft.border.all(1, ft.Colors.BLUE_200), bgcolor=ft.Colors.WHITE, content_padding=ft.padding.all(5), # 内部余白を最小化 on_change=lambda e: self._update_item_field(i, 'unit_price', e.control.value.replace(',', '')), keyboard_type=ft.KeyboardType.NUMBER, ) # 削除ボタン delete_button = ft.IconButton( ft.Icons.DELETE_OUTLINE, tooltip="行を削除", icon_color=ft.Colors.RED_600, disabled=is_locked, icon_size=16, on_click=lambda _, idx=i: self._delete_item_row(idx), ) # データ行 row = ft.Row([ product_field, quantity_field, unit_price_field, ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅 delete_button, ]) data_rows.append(row) return ft.Column([ header_row, ft.Divider(height=1, color=ft.Colors.GREY_400), ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限 ]) def create_new_customer_screen(self) -> ft.Container: """新規顧客登録画面""" name_field = ft.TextField(label="顧客名(略称)") formal_name_field = ft.TextField(label="正式名称") address_field = ft.TextField(label="住所") phone_field = ft.TextField(label="電話番号") def save_customer(_): name = (name_field.value or "").strip() formal_name = (formal_name_field.value or "").strip() address = (address_field.value or "").strip() phone = (phone_field.value or "").strip() if not name or not formal_name: # TODO: エラー表示 return new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone) if new_customer: self.customers = self.app_service.customer.get_all_customers() self.selected_customer = new_customer logging.info(f"新規顧客登録: {new_customer.formal_name}") self.is_customer_picker_open = False self.is_new_customer_form_open = False self.update_main_content() else: logging.error("新規顧客登録失敗") def cancel(_): self.is_new_customer_form_open = False self.update_main_content() return ft.Container( content=ft.Column([ ft.Container( content=ft.Row([ ft.IconButton(ft.Icons.ARROW_BACK, on_click=cancel), ft.Text("新規顧客登録", size=18, weight=ft.FontWeight.BOLD), ]), 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), name_field, ft.Container(height=10), formal_name_field, ft.Container(height=10), address_field, ft.Container(height=10), phone_field, ft.Container(height=20), ft.Row([ ft.Button("保存", on_click=save_customer, bgcolor=ft.Colors.BLUE_GREY_800, color=ft.Colors.WHITE), ft.Button("キャンセル", on_click=cancel), ], spacing=10), ]), padding=ft.padding.all(20), expand=True, ), ]), expand=True, ) 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 submit_invoice_for_tax(self, invoice_uuid: str) -> bool: """税務署提出済みフラグを設定""" success = self.app_service.invoice.submit_to_tax_authority(invoice_uuid) if success: self.invoices = self.app_service.invoice.get_recent_invoices(20) self.update_main_content() logging.info(f"税務署提出済み: {invoice_uuid}") else: logging.error(f"税務署提出失敗: {invoice_uuid}") return success 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}") self.update_main_content() 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)