""" 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 ZoomableContainer(ft.Container): """ピンチイン/アウト対応コンテナ""" def __init__(self, content=None, min_scale=0.5, max_scale=3.0, **kwargs): super().__init__(**kwargs) self.content = content self.min_scale = min_scale self.max_scale = max_scale self.scale = 1.0 self.initial_distance = 0 # ズーム機能を無効化してシンプルなコンテナとして使用 # 将来的な実装のためにクラスは残す class AppBar(ft.Container): """標準化されたアプリケーションヘッダー""" def __init__(self, title: str, show_back: bool = False, show_edit: bool = False, on_back=None, on_edit=None, page=None): super().__init__() self.title = title self.show_back = show_back self.show_edit = show_edit self.on_back = on_back self.on_edit = on_edit self.page_ref = page # page_refとして保存 self.bgcolor = ft.Colors.BLUE_GREY_50 self.padding = ft.Padding.symmetric(horizontal=16, vertical=8) self.content = self._build_content() def _build_content(self) -> ft.Row: """AppBarのコンテンツを構築""" controls = [] # 左側:戻るボタン if self.show_back: controls.append( ft.IconButton( icon=ft.Icons.ARROW_BACK, icon_color=ft.Colors.BLUE_GREY_700, tooltip="戻る", on_click=self.on_back if self.on_back else None ) ) else: controls.append(ft.Container(width=48)) # スペーーサー # 中央:タイトル controls.append( ft.Container( content=ft.Text( self.title, size=18, weight=ft.FontWeight.W_500, color=ft.Colors.BLUE_GREY_800 ), expand=True, alignment=ft.alignment.Alignment(0, 0) # 中央揃え ) ) # 右側:編集ボタン if self.show_edit: controls.append( ft.IconButton( icon=ft.Icons.EDIT, icon_color=ft.Colors.BLUE_GREY_700, tooltip="編集", on_click=self.on_edit if self.on_edit else None ) ) else: controls.append(ft.Container(width=48)) # スペーサー return ft.Row( controls, alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER ) 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 %H:%M'), '完了', invoice.notes )) self.conn.commit() except Exception as e: logging.error(f"サンプルデータ作成エラー: {e}") def setup_ui(self): """UIセットアップ""" # メインコンテンツ self.main_content = ft.Column([], expand=True) # ページ構成 self.page.add( ft.Column([ self.main_content, ], 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.is_customer_picker_open: # 顧客選択画面 self.main_content.controls.append(self.create_customer_picker_screen()) elif self.current_tab == 0: # 伝票一覧画面 self.main_content.controls.append(self._build_invoice_list_screen()) elif self.current_tab == 1: # 伝票詳細/編集画面 self.main_content.controls.append(self._build_invoice_detail_screen()) elif self.current_tab == 2: # 新規作成画面 self.main_content.controls.append(self._build_new_invoice_screen()) self.page.update() def _build_invoice_list_screen(self) -> ft.Column: """伝票一覧画面を構築""" # AppBar(戻るボタンなし、編集ボタンなし) app_bar = AppBar( title="伝票一覧", show_back=False, show_edit=False ) # 伝票リスト(ズーム対応) invoice_list = self._build_invoice_list() zoomable_list = ZoomableContainer( content=invoice_list, min_scale=0.8, max_scale=2.5 ) # 浮動アクションボタン fab = ft.FloatingActionButton( icon=ft.Icons.ADD, on_click=lambda _: self.open_new_invoice(), tooltip="新規伝票作成" ) return ft.Column([ app_bar, ft.Container( content=zoomable_list, expand=True, padding=ft.Padding.all(16) ), ], expand=True) def _build_invoice_detail_screen(self) -> ft.Column: """伝票詳細画面を構築""" if not self.editing_invoice: return ft.Column([ft.Text("伝票が選択されていません")]) # AppBar(戻るボタンあり、編集ボタン条件付き) is_locked = getattr(self.editing_invoice, 'final_locked', False) app_bar = AppBar( title=f"伝票詳細: {self.editing_invoice.invoice_number}", show_back=True, show_edit=not is_locked, on_back=lambda _: self.back_to_list(), on_edit=lambda _: self.toggle_edit_mode() ) # 伝票詳細コンテンツ(ズーム対応) detail_content = self._create_edit_existing_screen() zoomable_content = ZoomableContainer( content=detail_content, min_scale=0.8, max_scale=2.5 ) return ft.Column([ app_bar, ft.Container( content=zoomable_content, expand=True, padding=ft.Padding.all(16) ), ], expand=True) def _build_new_invoice_screen(self) -> ft.Column: """新規作成画面を構築""" # AppBar(戻るボタンあり、編集ボタンなし) app_bar = AppBar( title="新規伝票作成", show_back=True, show_edit=False, on_back=lambda _: self.back_to_list() ) # 新規作成コンテンツ(ズーム対応) new_content = self.create_slip_input_screen() zoomable_content = ZoomableContainer( content=new_content, min_scale=0.8, max_scale=2.5 ) return ft.Column([ app_bar, ft.Container( content=zoomable_content, expand=True, padding=ft.Padding.all(16) ), ], expand=True) def back_to_list(self): """一覧画面に戻る""" self.current_tab = 0 self.editing_invoice = None self.update_main_content() def toggle_edit_mode(self): """編集モードを切り替え""" if hasattr(self, 'is_detail_edit_mode'): self.is_detail_edit_mode = not self.is_detail_edit_mode else: self.is_detail_edit_mode = True self.update_main_content() def _build_invoice_list(self) -> ft.Column: """伝票リストを構築""" # 履歴データ読み込み slips = self.load_slips() logging.info(f"伝票データ取得: {len(slips)}件") if not self.show_offsets: slips = [s for s in slips if not (isinstance(s, Invoice) and getattr(s, "is_offset", False))] logging.info(f"赤伝除外後: {len(slips)}件") def on_toggle_offsets(e): self.show_offsets = bool(e.control.value) self.update_main_content() def on_verify_chain(_): """チェーン検証""" try: result = self.app_service.invoice.verify_chain() self.chain_verify_result = result self.update_main_content() except Exception as e: logging.error(f"チェーン検証エラー: {e}") # 履歴カードリスト slip_cards = [] for i, slip in enumerate(slips): logging.info(f"伝票{i}: {type(slip)}") card = self.create_slip_card(slip) slip_cards.append(card) logging.info(f"カード作成数: {len(slip_cards)}") return ft.Column([ # 検証結果表示(あれば) 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, ), ]) 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() # AppBar(戻るボタンあり) app_bar = AppBar( title="顧客選択", show_back=True, show_edit=False, on_back=back ) 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 # 新規伝票作成中の場合は顧客を設定 if hasattr(self, 'editing_invoice') and self.editing_invoice: self.editing_invoice.customer = customer logging.info(f"伝票に顧客を設定: {customer.formal_name}") else: 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( [ app_bar, # AppBarを追加 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 save_as_draft(self, _): """下書きとして保存""" try: # 下書き伝票を作成 draft_invoice = self.app_service.invoice.create_draft_invoice( customer=self.selected_customer, document_type=DocumentType.DRAFT, amount=int(self.amount_value or "0"), notes="下書き" ) if draft_invoice: logging.info(f"下書き保存成功: {draft_invoice.invoice_number}") # 一覧を更新 self.invoices = self.app_service.invoice.get_recent_invoices(20) self.back_to_list() else: logging.error("下書き保存失敗") except Exception as e: logging.error(f"下書き保存エラー: {e}") 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) # 下書きボタン(新規作成時のみ表示) draft_button = ft.Container( content=ft.ElevatedButton( content=ft.Row([ ft.Icon(ft.Icons.DRAFTS, size=16), ft.Text("下書きとして保存", size=12), ], spacing=5), style=ft.ButtonStyle( bgcolor=ft.Colors.ORANGE_500, color=ft.Colors.WHITE, ), on_click=self.save_as_draft, ), margin=ft.margin.only(top=10), ) 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, draft_button, # 下書きボタンを追加 ]), 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.Container(width=8), # スペースを追加 ft.IconButton( ft.Icons.PERSON_SEARCH, tooltip="顧客を選択", on_click=self.open_customer_picker, ), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, # 検索ボタンを右端に配置 ), 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=16, weight=ft.FontWeight.BOLD), # 文字を小さく ft.Container(expand=True), ft.Row([ ft.IconButton( ft.Icons.VERIFIED, tooltip="チェーン検証", icon_color=ft.Colors.BLUE_300, icon_size=16, # アイコンを小さく on_click=on_verify_chain, ), ft.Row( [ ft.Text("赤伝", size=10, color=ft.Colors.WHITE), # 文字を小さく ft.Switch(value=self.show_offsets, on_change=on_toggle_offsets), ], spacing=3, # 間隔を狭める ), ft.IconButton( ft.Icons.CLEAR_ALL, icon_size=16, # アイコンを小さく ), ], spacing=3), # 間隔を狭める ]), padding=ft.padding.all(8), # パディングを狭める bgcolor=ft.Colors.BLUE_GREY, ), # 検証結果表示(あれば) ft.Container( content=self._build_chain_verify_result(), margin=ft.Margin.only(bottom=5), # 間隔を狭める ) if self.chain_verify_result else ft.Container(height=0), # 履歴リスト(極限密度表示) ft.Column( controls=slip_cards, spacing=0, # カード間隔を0pxに scroll=ft.ScrollMode.AUTO, expand=True, ), ]), expand=True, ) def can_create_offset_invoice(self, invoice: Invoice) -> bool: """赤伝発行可能かチェック""" # LOCK済み伝票であること if not getattr(invoice, 'final_locked', False): return False # すでに赤伝が存在しないこと try: import sqlite3 with sqlite3.connect('sales.db') as conn: cursor = conn.cursor() cursor.execute( 'SELECT COUNT(*) FROM invoices WHERE offset_target_uuid = ?', (invoice.uuid,) ) offset_count = cursor.fetchone()[0] return offset_count == 0 except Exception as e: logging.error(f"赤伝存在チェックエラー: {e}") return False 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 %H:%M") status = "赤伝" if getattr(slip, "is_offset", False) else "完了" # 最初の商品名を取得(複数ある場合は「他」を付与) if slip.items and len(slip.items) > 0: first_item_name = slip.items[0].description if len(slip.items) > 1: first_item_name += "(他" + str(len(slip.items) - 1) + ")" else: first_item_name = "" else: slip_id, slip_type, customer_name, amount, date, status, description, created_at = slip date = date.strftime("%Y-%m-%d %H:%M") first_item_name = description or "" # タイプに応じたアイコンと色 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 on_single_tap(_): """シングルタップ:詳細表示""" if isinstance(slip, Invoice): self.open_invoice_detail(slip) def on_double_tap(_): """ダブルタップ:編集モード切替""" if isinstance(slip, Invoice): self.open_invoice_edit(slip) def on_long_press(_): """長押し:コンテキストメニュー""" self.show_context_menu(slip) # 赤伝ボタンの表示条件チェック show_offset_button = False if isinstance(slip, Invoice): show_offset_button = self.can_create_offset_invoice(slip) # 長押しメニューで操作するため、ボタンは不要 display_amount = amount if isinstance(slip, Invoice) and getattr(slip, "is_offset", False): display_amount = -abs(amount) return ft.GestureDetector( content=ft.Card( content=ft.Container( content=ft.Column([ ft.Row([ ft.Container( content=ft.Text(config["icon"], size=16), # アイコンを少し大きく width=28, height=28, bgcolor=config["color"], border_radius=14, alignment=ft.alignment.Alignment(0, 0), ), ft.Container( content=ft.Column([ ft.Text(slip_type, size=7, weight=ft.FontWeight.BOLD), # タイプ文字をさらに小さく ft.Text(customer_name, size=15, weight=ft.FontWeight.W_500), # 顧客名を1.5倍に ft.Row([ ft.Text(first_item_name, size=12, color=ft.Colors.GREY_600), # 商品名をさらに大きく ft.Container(expand=True), # スペースを取る ft.Text(f"¥{display_amount:,.0f}", size=11, weight=ft.FontWeight.BOLD), # 金額を右寄せ ]), ], spacing=0, # 行間を最小化 tight=True, # 余白を最小化 ), expand=True, ), ]), ft.Container(height=0), # 間隔を完全に削除 ft.Row([ ft.Text(f"{date} | {status}", size=9, color=ft.Colors.GREY_600), # 日付を大きく ft.Container(expand=True), # スペースを取る # 赤伝ボタン(条件付きで表示) ft.Container( content=ft.IconButton( icon=ft.icons.REMOVE_CIRCLE_OUTLINE, icon_color=ft.Colors.RED_500, icon_size=16, tooltip="赤伝発行", on_click=lambda _: self.create_offset_invoice_dialog(slip), disabled=not show_offset_button ) if show_offset_button else ft.Container(width=20), ), ]), ], spacing=0, # カラムの行間を最小化 tight=True, # 余白を最小化 ), padding=ft.padding.all(2), # パディングを最小化 ), elevation=0, ), on_tap=on_single_tap, on_double_tap=on_double_tap, on_long_press=on_long_press, ) def create_offset_invoice_dialog(self, invoice: Invoice): """赤伝発行確認ダイアログ""" def close_dialog(_): self.dialog.open = False self.update_main_content() def confirm_create_offset(_): # 赤伝を発行 offset_invoice = self.app_service.invoice.create_offset_invoice( invoice.uuid, f"相殺伝票: {invoice.invoice_number}" ) if offset_invoice: logging.info(f"赤伝発行成功: {offset_invoice.invoice_number}") # 一覧を更新 self.invoices = self.app_service.invoice.get_recent_invoices(20) self.update_main_content() else: logging.error(f"赤伝発行失敗: {invoice.invoice_number}") close_dialog(_) # 確認ダイアログ self.dialog = ft.AlertDialog( modal=True, title=ft.Text("赤伝発行確認"), content=ft.Column([ ft.Text(f"以下の伝票の赤伝を発行します。"), ft.Container(height=10), ft.Text(f"伝票番号: {invoice.invoice_number}"), ft.Text(f"顧客: {invoice.customer.formal_name}"), ft.Text(f"金額: ¥{invoice.total_amount:,.0f}"), ft.Container(height=10), ft.Text("赤伝発行後は取り消せません。よろしいですか?", color=ft.Colors.RED, weight=ft.FontWeight.BOLD), ], tight=True), actions=[ ft.TextButton("キャンセル", on_click=close_dialog), ft.ElevatedButton( "赤伝発行", on_click=confirm_create_offset, style=ft.ButtonStyle( color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_500 ) ), ], actions_alignment=ft.MainAxisAlignment.END, ) self.dialog.open = True self.update_main_content() def show_context_menu(self, slip): """コンテキストメニューを表示""" if not isinstance(slip, Invoice): return def close_dialog(_): self.dialog.open = False self.update_main_content() def edit_invoice(_): self.open_invoice_edit(slip) close_dialog(_) def delete_invoice(_): self.delete_invoice(slip.uuid) close_dialog(_) def create_offset(_): self.create_offset_invoice_dialog(slip) close_dialog(_) # メニューアイテムの構築 menu_items = [] # 編集メニュー if not getattr(slip, 'final_locked', False): menu_items.append( ft.PopupMenuItem( text=ft.Row([ ft.Icon(ft.Icons.EDIT, size=16), ft.Text("編集", size=14), ], spacing=8), on_click=edit_invoice ) ) # 赤伝発行メニュー if self.can_create_offset_invoice(slip): menu_items.append( ft.PopupMenuItem( text=ft.Row([ ft.Icon(ft.Icons.REMOVE_CIRCLE, size=16), ft.Text("赤伝発行", size=14), ], spacing=8), on_click=create_offset ) ) # 削除メニュー menu_items.append( ft.PopupMenuItem( text=ft.Row([ ft.Icon(ft.Icons.DELETE, size=16), ft.Text("削除", size=14), ], spacing=8), on_click=delete_invoice ) ) # コンテキストメニューダイアログ self.dialog = ft.AlertDialog( modal=True, title=ft.Text(f"操作選択: {slip.invoice_number}"), content=ft.Column(menu_items, tight=True, spacing=2), actions=[ ft.TextButton("キャンセル", on_click=close_dialog), ], actions_alignment=ft.MainAxisAlignment.END, ) self.dialog.open = True self.update_main_content() def open_invoice_detail(self, invoice: Invoice): """伝票詳細を開く""" self.editing_invoice = invoice self.current_tab = 1 self.is_detail_edit_mode = False # 表示モードで開く self.update_main_content() def open_invoice_edit(self, invoice: Invoice): """伝票編集を開く""" self.editing_invoice = invoice self.current_tab = 1 self.is_detail_edit_mode = True # 編集モードで開く self.update_main_content() def delete_invoice(self, invoice_uuid: str): """伝票を削除""" try: success = self.app_service.invoice.delete_invoice_by_uuid(invoice_uuid) if success: logging.info(f"伝票削除成功: {invoice_uuid}") # リストを更新 self.invoices = self.app_service.invoice.get_recent_invoices(20) self.update_main_content() else: logging.error(f"伝票削除失敗: {invoice_uuid}") except Exception as e: logging.error(f"伝票削除エラー: {e}") 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 not self.editing_invoice: # 新規伝票の場合は空のInvoiceを作成 from models.invoice_models import Invoice, Customer, DocumentType default_customer = Customer( id=0, name="選択してください", formal_name="選択してください", address="", phone="" ) self.editing_invoice = Invoice( customer=default_customer, date=datetime.now(), items=[], document_type=DocumentType.SALES, invoice_number="NEW-" + str(int(datetime.now().timestamp())) # 新規伝票番号 ) self.is_detail_edit_mode = True # 新規作成モード # 既存・新規共通で詳細編集画面を返す return self._create_edit_existing_screen() def _create_edit_existing_screen(self) -> ft.Container: """既存伝票の編集画面(新規・編集共通)""" # 編集不可チェック(新規作成時はFalse) is_new_invoice = self.editing_invoice.invoice_number.startswith("NEW-") # LOCK条件:明示的に確定された場合のみLOCK # PDF生成だけではLOCKしない(お試しPDFを許可) pdf_generated = getattr(self.editing_invoice, 'pdf_generated_at', None) is not None chain_hash = getattr(self.editing_invoice, 'chain_hash', None) is not None final_locked = getattr(self.editing_invoice, 'final_locked', False) # 明示的確定フラグ is_locked = final_locked if not is_new_invoice else False is_view_mode = not getattr(self, 'is_detail_edit_mode', False) # デバッグ用にLOCK状態をログ出力 logging.info(f"伝票LOCK状態: {self.editing_invoice.invoice_number}") logging.info(f" PDF生成: {pdf_generated}") logging.info(f" チェーン収容: {chain_hash}") logging.info(f" 明示的確定: {final_locked}") logging.info(f" LOCK状態: {is_locked}") logging.info(f" 新規伝票: {is_new_invoice}") logging.info(f" 編集モード: {getattr(self, 'is_detail_edit_mode', False)}") logging.info(f" 表示モード: {is_view_mode}") # 伝票種類選択(ヘッダーに移動) document_types = list(DocumentType) # 明細テーブル if is_view_mode: items_table = self._create_view_mode_table(self.editing_invoice.items) else: items_table = self._create_edit_mode_table(self.editing_invoice.items, is_locked) # 顧客表示・選択 def select_customer(): """顧客選択画面を開く""" self.current_tab = 2 # 顧客選択タブ self.update_main_content() # 編集モード時は顧客名入力フィールドを表示 if not is_view_mode and not is_locked: # 編集モード:顧客名入力フィールド customer_field = ft.TextField( label="顧客名", value=self.editing_invoice.customer.name if self.editing_invoice.customer.name != "選択してください" else "", disabled=is_locked, width=300, ) def update_customer_name(e): """顧客名を更新""" if self.editing_invoice: # 既存顧客を検索、なければ新規作成 customer_name = e.control.value or "" found_customer = None for customer in self.app_service.customer.get_all_customers(): if customer.name == customer_name or customer.formal_name == customer_name: found_customer = customer break if found_customer: self.editing_invoice.customer = found_customer else: # 新規顧客を作成 from models.invoice_models import Customer self.editing_invoice.customer = Customer( id=0, name=customer_name, formal_name=customer_name, address="", phone="" ) customer_field.on_change = update_customer_name customer_display = ft.Container( content=ft.Row([ customer_field, # 顧客ラベルを削除 ft.ElevatedButton( "選択", icon=ft.Icons.PERSON_SEARCH, on_click=lambda _: select_customer(), style=ft.ButtonStyle( bgcolor=ft.Colors.BLUE_600, color=ft.Colors.WHITE ) ), ]), padding=ft.padding.symmetric(horizontal=10, vertical=5), bgcolor=ft.Colors.BLUE_50, border_radius=5, ) elif is_new_invoice: # 新規作成時も同じ表示 customer_field = ft.TextField( label="顧客名", value=self.editing_invoice.customer.name if self.editing_invoice.customer.name != "選択してください" else "", disabled=is_locked, width=300, ) def update_customer_name(e): """顧客名を更新""" if self.editing_invoice: # 既存顧客を検索、なければ新規作成 customer_name = e.control.value or "" found_customer = None for customer in self.app_service.customer.get_all_customers(): if customer.name == customer_name or customer.formal_name == customer_name: found_customer = customer break if found_customer: self.editing_invoice.customer = found_customer else: # 新規顧客を作成 from models.invoice_models import Customer self.editing_invoice.customer = Customer( id=0, name=customer_name, formal_name=customer_name, address="", phone="" ) customer_field.on_change = update_customer_name customer_display = ft.Container( content=ft.Row([ customer_field, # 顧客ラベルを削除 ft.ElevatedButton( "選択", icon=ft.Icons.PERSON_SEARCH, on_click=lambda _: select_customer(), style=ft.ButtonStyle( bgcolor=ft.Colors.BLUE_600, color=ft.Colors.WHITE ) ), ]), padding=ft.padding.symmetric(horizontal=10, vertical=5), bgcolor=ft.Colors.BLUE_50, border_radius=5, ) else: # 既存伝票は表示のみ customer_display = ft.Container( content=ft.Row([ 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 # 伝票を更新(現在の明細を保持) # テーブルから実際の値を取得して更新 self.editing_invoice.notes = notes_field.value self.editing_invoice.document_type = self.selected_document_type # TODO: テーブルの明細データを取得して更新 # 現在は編集された明細データが反映されていない logging.info(f"更新前明細件数: {len(self.editing_invoice.items)}") for i, item in enumerate(self.editing_invoice.items): logging.info(f" 明細{i+1}: {item.description} x{item.quantity} @¥{item.unit_price}") # DBに保存(新規・更新共通) try: if is_new_invoice: # 新規作成 logging.info(f"=== 新規伝票作成開 ===") logging.info(f"顧客情報: {self.editing_invoice.customer.name} (ID: {self.editing_invoice.customer.id})") logging.info(f"伝票種類: {self.editing_invoice.document_type.value}") logging.info(f"明細件数: {len(self.editing_invoice.items)}") # 顧客を先にDBに保存(新規顧客の場合) if self.editing_invoice.customer.id == 0: logging.info(f"新規顧客をDBに保存します: {self.editing_invoice.customer.name}") # 新規顧客をDBに保存 customer_id = self.app_service.customer.create_customer( name=self.editing_invoice.customer.name, formal_name=self.editing_invoice.customer.formal_name, address=self.editing_invoice.customer.address, phone=self.editing_invoice.customer.phone ) logging.info(f"create_customer戻り値: {customer_id}") if customer_id > 0: # IDが正しく取得できたかチェック self.editing_invoice.customer.id = customer_id logging.info(f"新規顧客をDBに保存: {self.editing_invoice.customer.name} (ID: {customer_id})") else: logging.error(f"顧客保存失敗: {self.editing_invoice.customer.name}") return # 合計金額は表示時に計算するため、DBには保存しない amount = 0 # ダミー値(実際は表示時に計算) logging.info(f"伝票作成パラメータ: customer.id={self.editing_invoice.customer.id}, document_type={self.editing_invoice.document_type}, amount={amount}") success = self.app_service.invoice.create_invoice( customer=self.editing_invoice.customer, document_type=self.editing_invoice.document_type, amount=amount, notes=getattr(self.editing_invoice, 'notes', ''), items=self.editing_invoice.items # UIの明細を渡す ) logging.info(f"create_invoice戻り値: {success}") if success: logging.info(f"伝票作成成功: {self.editing_invoice.invoice_number}") # 一覧を更新して新規作成画面を閉じる self.invoices = self.app_service.invoice.get_recent_invoices(20) logging.info(f"更新後伝票件数: {len(self.invoices)}") self.editing_invoice = None self.current_tab = 0 # 一覧タブに戻る self.update_main_content() else: logging.error(f"伝票作成失敗: {self.editing_invoice.invoice_number}") else: # 更新 logging.info(f"=== 伝票更新開 ===") success = self.app_service.invoice.update_invoice(self.editing_invoice) if success: logging.info(f"伝票更新成功: {self.editing_invoice.invoice_number}") # 一覧を更新して編集画面を閉じる self.invoices = self.app_service.invoice.get_recent_invoices(20) self.editing_invoice = None self.current_tab = 0 # 一覧タブに戻る self.update_main_content() else: logging.error(f"伝票更新失敗: {self.editing_invoice.invoice_number}") except Exception as e: logging.error(f"伝票保存エラー: {e}") import traceback logging.error(f"詳細エラー: {traceback.format_exc()}") # 編集モード終了(ビューモードに戻る) 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 %H:%M')} | {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, ), # 顧客名入力(編集モードまたは新規作成時) customer_display if (not is_view_mode and not is_locked) or is_new_invoice else ft.Container(height=0), # 明細テーブル(フレキシブルに) ft.Container( content=ft.Column([ ft.Container( content=items_table, height=400, # 高さを拡大して見やすく 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.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 ), 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(), ]), padding=ft.padding.symmetric(horizontal=5, 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=0, unit_price=0, ) # 元のinvoice.itemsに直接追加 self.editing_invoice.items.append(new_item) # UIを更新 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}") # 合計金額を更新するために画面を更新 # 空行削除ロジック:商品名が無く数量も単価も0なら削除 self._remove_empty_items() self.update_main_content() def _remove_empty_items(self): """商品名が無く数量も単価も0の明細を削除""" if not self.editing_invoice: return # 空行を特定(ただし最低1行は残す) non_empty_items = [] empty_count = 0 for item in self.editing_invoice.items: if (not item.description or item.description.strip() == "") and \ item.quantity == 0 and \ item.unit_price == 0: empty_count += 1 # 最低1行は残すため、空行が複数ある場合のみ削除 if empty_count > 1: continue # 削除 non_empty_items.append(item) self.editing_invoice.items = non_empty_items 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: """編集モード:フレキシブルな表形式""" # 自動空行追加を無効化(ユーザーが明示的に追加する場合のみ) # TODO: 必要に応じて空行追加ボタンを提供 # 空行の自動追加を無効化 pass # ヘッダー行 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, idx=i: self._update_item_field(idx, '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, idx=i: self._update_item_field(idx, '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, idx=i: self._update_item_field(idx, '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}") # 編集中の伝票があれば顧客を設定 if self.editing_invoice: self.editing_invoice.customer = customer logging.info(f"編集中伝票に顧客を設定: {customer.formal_name}") # 顧客選択画面を閉じて元の画面に戻る self.is_customer_picker_open = False self.customer_search_query = "" 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)