""" Flutter風ダッシュボード 下部ナビゲーションと洗練されたUIコンポーネントを実装 """ import flet as ft import signal import sys import logging import sqlite3 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 components.explorer_framework import ( ExplorerQueryState, EXPLORER_PERIODS, EXPLORER_SORTS, to_date_range, ) from components.editor_framework import normalize_invoice_items, validate_invoice_items from components.editor_framework import ( build_invoice_items_view_table, build_invoice_items_edit_table, ) from components.universal_master_editor import UniversalMasterEditor from services.app_service import AppService # ロギング設定 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) def log_wrap(name: str): """関数の開始/終了/例外を簡易ログするデコレータ""" def deco(fn): def _wrapped(*args, **kwargs): import time import traceback arg_types = [type(a).__name__ for a in args] t0 = time.time() try: logging.info(f"{name} start args={arg_types} kwargs_keys={list(kwargs.keys())}") res = fn(*args, **kwargs) dt = time.time() - t0 logging.info(f"{name} ok dt={dt:.3f}s res_type={type(res).__name__}") return res except Exception: logging.error(f"{name} error:\n{traceback.format_exc()}") raise return _wrapped return deco 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, action_icon=None, action_tooltip: str = "編集", bottom: Optional[ft.Control] = 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.action_icon = action_icon or ft.Icons.EDIT self.action_tooltip = action_tooltip self.bottom = bottom 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=self.action_icon, icon_color=ft.Colors.BLUE_GREY_700, tooltip=self.action_tooltip, on_click=self.on_edit if self.on_edit else None ) ) else: controls.append(ft.Container(width=48)) # スペーサー content_row = ft.Row( controls, alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER ) if self.bottom: return ft.Column( [content_row, self.bottom], spacing=6, alignment=ft.MainAxisAlignment.START, ) return content_row 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.master_editor: Optional[UniversalMasterEditor] = None self.explorer_state = ExplorerQueryState( period_key="3m", include_offsets=self.show_offsets, limit=50, offset=0, ) # 保存後遷移設定(True: 詳細に留まる, False: 一覧へ戻る) self.stay_on_detail_after_save = True self.invoice_card_theme = { "page_bg": "#F3F2FB", "card_bg": ft.Colors.WHITE, "card_radius": 18, "shadow": ft.BoxShadow( blur_radius=16, spread_radius=0, color="#D5D8F0", offset=ft.Offset(0, 6), ), "icon_fg": ft.Colors.WHITE, "icon_default_bg": "#5C6BC0", "title_color": ft.Colors.BLUE_GREY_900, "subtitle_color": ft.Colors.BLUE_GREY_500, "amount_color": "#2F3C7E", "tag_text_color": "#4B4F67", "tag_bg": "#E7E9FB", "badge_bg": "#35C46B", } self.doc_type_palette = { DocumentType.INVOICE.value: "#5C6BC0", DocumentType.ESTIMATE.value: "#7E57C2", DocumentType.DELIVERY.value: "#26A69A", DocumentType.RECEIPT.value: "#FF7043", DocumentType.SALES.value: "#42A5F5", DocumentType.DRAFT.value: "#90A4AE", } # ビジネスロジックサービス 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 self.page.on_disconnect = self.dispose self.page.on_close = self.dispose # 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)}件") # 伝票データがない場合はサンプルデータを作成 if len(self.invoices) == 0: logging.info("サンプルデータを作成します") self.create_sample_data_via_service() # 再度データ読み込み self.invoices = self.app_service.invoice.get_recent_invoices(20) logging.info(f"サンプルデータ作成後: 伝票{len(self.invoices)}件") except Exception as e: logging.error(f"データ初期化エラー: {e}") def create_sample_data_via_service(self): """サービス層経由でサンプルデータ作成""" try: sample_invoices = create_sample_invoices() for invoice in sample_invoices: # 顧客を先に作成(存在しない場合) customer = invoice.customer if customer.id == 0 or not any(c.id == customer.id for c in self.customers): customer_id = self.app_service.customer.create_customer( name=customer.name, formal_name=customer.formal_name, address=customer.address, phone=customer.phone ) customer.id = customer_id # 伝票を作成 self.app_service.invoice.create_invoice( customer=customer, document_type=invoice.document_type, amount=invoice.total_amount, notes=invoice.notes, items=invoice.items ) logging.info(f"サンプルデータ作成完了: {len(sample_invoices)}件") except Exception as e: logging.error(f"サンプルデータ作成エラー: {e}") def create_sample_data(self): """サンプル伝票データ作成""" logging.warning("create_sample_dataは使用されていません") 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 dispose(self, e=None): """リソース解放""" try: self.app_service.close() except Exception as err: logging.warning(f"クリーンアップ失敗: {err}") def on_tab_change(self, index): """タブ切り替え""" self.current_tab = index self.update_main_content() self.page.update() @log_wrap("update_main_content") def update_main_content(self): """メインコンテンツ更新""" self.main_content.controls.clear() logging.info(f"update_main_content: current_tab={self.current_tab}, is_customer_picker_open={self.is_customer_picker_open}") if self.is_new_customer_form_open: logging.info("新規顧客登録画面を表示") self.main_content.controls.append(self.create_new_customer_screen()) elif self.is_customer_picker_open: # 顧客選択画面 logging.info("顧客選択画面を表示") self.main_content.controls.append(self.create_customer_picker_screen()) elif self.current_tab == 0: # 伝票一覧画面 logging.info("伝票一覧画面を表示") self.main_content.controls.append(self._build_invoice_list_screen()) elif self.current_tab == 1: # 伝票詳細/編集画面 logging.info("伝票詳細/編集画面を表示") self.main_content.controls.append(self._build_invoice_detail_screen()) elif self.current_tab == 2: # マスタ編集画面 logging.info("マスタ編集画面を表示") self.main_content.controls.append(self._build_master_editor_screen()) else: # 不明なタブは一覧に戻す self.current_tab = 0 logging.info("不明なタブ、一覧画面を表示") self.main_content.controls.append(self._build_invoice_list_screen()) logging.info(f"コントロール数: {len(self.main_content.controls)}") self.page.update() @log_wrap("_build_invoice_list_screen") def _build_invoice_list_screen(self) -> ft.Column: """伝票一覧画面を構築""" logging.info("_build_invoice_list_screen: 開始") # AppBar(戻るボタンなし、編集ボタンなし) app_bar = AppBar( title="伝票一覧", show_back=False, show_edit=False ) logging.info("_build_invoice_list_screen: AppBar作成完了") # 伝票リスト invoice_list = self._build_invoice_list() logging.info(f"_build_invoice_list_screen: 伝票リスト作成完了") result = ft.Column([ app_bar, ft.Container( content=invoice_list, expand=True, padding=ft.Padding.all(16) ), ], expand=True) logging.info("_build_invoice_list_screen: Column作成完了") return result @log_wrap("_build_invoice_detail_screen") def _build_invoice_detail_screen(self) -> ft.Column: """伝票詳細画面を構築""" if not self.editing_invoice: return ft.Column([ft.Text("伝票が選択されていません")]) is_locked = getattr(self.editing_invoice, 'final_locked', False) is_view_mode = not getattr(self, 'is_detail_edit_mode', False) app_bar = AppBar( title="伝票詳細", show_back=True, show_edit=not is_locked, on_back=lambda _: self.back_to_list(), on_edit=lambda _: self.on_detail_appbar_action(), action_icon=ft.Icons.SAVE if getattr(self, 'is_detail_edit_mode', False) else ft.Icons.EDIT, action_tooltip="保存" if getattr(self, 'is_detail_edit_mode', False) else "編集", bottom=None, ) # 編集/閲覧モード共通の画面(元の編集用ビルダーを利用) body = self._create_edit_existing_screen() return ft.Column([ app_bar, body, ], expand=True) def _build_doc_type_bar(self, is_locked: bool, is_view_mode: bool) -> ft.Control: """AppBar下部の帳票種別チップ""" document_types = list(DocumentType) can_select = not (is_locked or is_view_mode) active_type = getattr(self, "selected_document_type", DocumentType.INVOICE) chips = [ ft.GestureDetector( content=ft.Container( content=ft.Text( dt.value, size=11, weight=ft.FontWeight.BOLD if dt == active_type else ft.FontWeight.NORMAL, color=ft.Colors.WHITE if dt == active_type else ft.Colors.BLUE_GREY_600, text_align=ft.TextAlign.CENTER, ), padding=ft.Padding.symmetric(horizontal=10, vertical=6), bgcolor=ft.Colors.BLUE_600 if dt == active_type else ft.Colors.BLUE_GREY_100, border_radius=18, ), on_tap=(lambda e, x=dt: self.select_document_type(x)) if can_select else None, ) for dt in document_types ] return ft.Container( content=ft.Row( controls=[ ft.Row([ ft.Icon(ft.Icons.EDIT if not is_locked else ft.Icons.LOCK, size=16, color=ft.Colors.BLUE_GREY_500), ft.Text("帳票タイプ", size=11, color=ft.Colors.BLUE_GREY_600), ], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER), ft.Row(controls=chips, spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), padding=ft.Padding.symmetric(horizontal=12, vertical=6), bgcolor=ft.Colors.BLUE_GREY_50, border_radius=12, ) def on_detail_appbar_action(self): """詳細画面の右上アクション(編集/保存)""" if getattr(self, 'is_detail_edit_mode', False): save_handler = getattr(self, "_detail_save_handler", None) if save_handler: save_handler(None) return self.toggle_edit_mode() def _build_master_editor_screen(self) -> ft.Column: """マスタ編集画面を構築""" if self.master_editor is None: self.master_editor = UniversalMasterEditor(self.page) app_bar = AppBar( title="マスタ編集", show_back=True, show_edit=False, on_back=lambda _: self.back_to_list(), ) return ft.Column([ app_bar, ft.Container( content=self.master_editor.build(), 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: """伝票リストを構築""" logging.info("_build_invoice_list: 開始") date_from, date_to = to_date_range(self.explorer_state.period_key) slips = self.load_slips( query=self.explorer_state.query, date_from=date_from, date_to=date_to, sort_by=self.explorer_state.sort_key, sort_desc=self.explorer_state.sort_desc, limit=self.explorer_state.limit, offset=self.explorer_state.offset, include_offsets=self.explorer_state.include_offsets, ) self.show_offsets = self.explorer_state.include_offsets logging.info(f"伝票データ取得: {len(slips)}件") def on_query_change(e): self.explorer_state.query = (e.control.value or "").strip() self.explorer_state.offset = 0 self.update_main_content() def on_period_change(e): self.explorer_state.period_key = e.control.value or "3m" self.explorer_state.offset = 0 self.update_main_content() def on_sort_change(e): self.explorer_state.sort_key = e.control.value or "date" self.explorer_state.offset = 0 self.update_main_content() def on_sort_direction_toggle(_): self.explorer_state.sort_desc = not self.explorer_state.sort_desc self.explorer_state.offset = 0 self.update_main_content() def on_toggle_offsets(e): self.explorer_state.include_offsets = bool(e.control.value) self.explorer_state.offset = 0 self.show_offsets = self.explorer_state.include_offsets self.update_main_content() def on_prev_page(_): if self.explorer_state.offset <= 0: return self.explorer_state.offset = max(0, self.explorer_state.offset - self.explorer_state.limit) self.update_main_content() def on_next_page(_): if len(slips) < self.explorer_state.limit: return self.explorer_state.offset += self.explorer_state.limit 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}") explorer_controls = ft.Container( content=ft.Column( [ ft.Row( [ ft.TextField( label="検索", hint_text="伝票番号 / 顧客名 / 種別 / 備考", value=self.explorer_state.query, prefix_icon=ft.Icons.SEARCH, on_change=on_query_change, expand=True, dense=True, ), ft.Dropdown( label="期間", value=self.explorer_state.period_key, options=[ ft.dropdown.Option(k, v) for k, v in EXPLORER_PERIODS.items() ], on_select=on_period_change, width=140, dense=True, ), ft.Dropdown( label="ソート", value=self.explorer_state.sort_key, options=[ ft.dropdown.Option(k, v) for k, v in EXPLORER_SORTS.items() ], on_select=on_sort_change, width=150, dense=True, ), ft.IconButton( icon=ft.Icons.ARROW_DOWNWARD if self.explorer_state.sort_desc else ft.Icons.ARROW_UPWARD, tooltip="並び順切替", on_click=on_sort_direction_toggle, ), ], spacing=8, ), ft.Row( [ ft.Text("赤伝", size=10, color=ft.Colors.WHITE), ft.Switch( value=self.explorer_state.include_offsets, on_change=on_toggle_offsets, ), ft.Container(width=10), ft.Text("保存後詳細に留まる", size=10, color=ft.Colors.WHITE), ft.Switch( value=self.stay_on_detail_after_save, on_change=lambda e: setattr(self, 'stay_on_detail_after_save', bool(e.control.value)), ), ft.Text( f"表示中: {len(slips)}件 / offset={self.explorer_state.offset}", size=12, color=ft.Colors.BLUE_GREY_700, ), ft.Container(expand=True), ft.TextButton("◀ 前", on_click=on_prev_page), ft.TextButton("次 ▶", on_click=on_next_page), ft.OutlinedButton("マスタ編集", on_click=self.open_master_editor), ft.OutlinedButton("チェーン検証", on_click=on_verify_chain), ], alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), ], spacing=6, ), padding=ft.Padding.all(10), bgcolor=ft.Colors.BLUE_GREY_50, border_radius=8, ) if not slips: logging.info("_build_invoice_list: 伝票データなし、空のリストを返す") return ft.Column( [ explorer_controls, ft.Container(height=20), ft.Text("伝票データがありません", size=16, color=ft.Colors.GREY_600), ft.Text("検索条件を緩めるか、データを登録してください", size=14, color=ft.Colors.GREY_500), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, expand=True, ) slip_cards = [] for i, slip in enumerate(slips): logging.info(f"伝票{i}: {type(slip)}") slip_cards.append(self.create_slip_card(slip)) logging.info(f"カード作成数: {len(slip_cards)}") content_column = ft.Column( [ explorer_controls, ft.Container(height=8), 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=8, scroll=ft.ScrollMode.AUTO, expand=True, ), ], expand=True, ) return ft.Container( bgcolor=self.invoice_card_theme.get("page_bg"), padding=ft.Padding.symmetric(horizontal=12, vertical=10), content=content_column, expand=True, ) def can_create_offset_invoice(self, invoice: Invoice) -> bool: """赤伝発行可能かチェック""" if not getattr(invoice, "final_locked", False): return False try: with sqlite3.connect("sales.db") as conn: cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM invoices WHERE offset_target_uuid = ?", (invoice.uuid,), ) return cursor.fetchone()[0] == 0 except Exception as e: logging.error(f"赤伝存在チェックエラー: {e}") return False def create_slip_card(self, slip) -> ft.Container: """伝票カード作成(コンパクト表示)""" theme = self.invoice_card_theme palette = self.doc_type_palette if isinstance(slip, Invoice): slip_type = slip.document_type.value customer_name = slip.customer.formal_name amount = slip.total_amount invoice_number = slip.invoice_number dt = slip.date final_locked = getattr(slip, "final_locked", False) items = slip.items or [] first_item = items[0].description if items else "(明細なし)" extra_count = max(len(items) - 1, 0) else: slip_id, slip_type, customer_name, amount, dt, status, description, created_at = slip invoice_number = getattr(slip, "invoice_number", f"ID:{slip_id}") final_locked = status == "完了" first_item = description or "(明細なし)" extra_count = 0 icon_bg = palette.get(slip_type, theme["icon_default_bg"]) display_amount = -abs(amount) if isinstance(slip, Invoice) and getattr(slip, "is_offset", False) else amount 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(_): # 長押しは直接編集画面へ(ビューワではなく編集) logging.info(f"long_press -> open_invoice_edit: {getattr(slip, 'invoice_number', 'unknown')}") self.open_invoice_edit(slip) show_offset_button = isinstance(slip, Invoice) and self.can_create_offset_invoice(slip) first_item_label = first_item[:30] + ("…" if len(first_item) > 30 else "") if extra_count > 0: first_item_label = f"{first_item_label} 他{extra_count}" header_row = ft.Row( [ ft.Row( [ ft.Container( content=ft.Text( slip_type, size=9, weight=ft.FontWeight.BOLD, color=theme["tag_text_color"], ), padding=ft.Padding.symmetric(horizontal=8, vertical=2), bgcolor=theme["tag_bg"], border_radius=10, ), ft.Text(f"No: {invoice_number}", size=10, color=theme["subtitle_color"]), ], spacing=6, ), ft.Text( dt.strftime("%y/%m/%d %H:%M"), size=9, color=theme["subtitle_color"], text_align=ft.TextAlign.END, ), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) customer_row = ft.Row( [ ft.Text( customer_name, size=12, weight=ft.FontWeight.BOLD, color=theme["title_color"], expand=True, ), ft.Text( f"¥{display_amount:,.0f}", size=14, weight=ft.FontWeight.BOLD, color=theme["amount_color"], ), ], vertical_alignment=ft.CrossAxisAlignment.CENTER, ) item_row = ft.Row( [ ft.Text( first_item_label, size=10, color=theme["subtitle_color"], expand=True, ), ft.IconButton( icon=ft.icons.REMOVE_CIRCLE_OUTLINE, icon_color=ft.Colors.RED_400, icon_size=16, tooltip="赤伝発行", on_click=lambda _: self.create_offset_invoice_dialog(slip), style=ft.ButtonStyle(padding=0, bgcolor={"": ft.Colors.TRANSPARENT}), ) if show_offset_button else ft.Container(width=0), ], vertical_alignment=ft.CrossAxisAlignment.CENTER, ) left_column = ft.Column( [ header_row, customer_row, item_row, ], spacing=3, expand=True, ) status_chip = ft.Text("✓ LOCK", size=9, color=theme["tag_text_color"]) if final_locked else ft.Container() card_body = ft.Container( content=ft.Column( [left_column, status_chip], spacing=4, expand=True, ), padding=ft.Padding.symmetric(horizontal=12, vertical=8), bgcolor=theme["card_bg"], border_radius=theme["card_radius"], shadow=[theme["shadow"]], ) return ft.GestureDetector( content=card_body, on_tap=on_single_tap, on_double_tap=on_double_tap, on_long_press=on_long_press, ) 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.selected_document_type = invoice.document_type 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.selected_document_type = invoice.document_type 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 = True # 編集モードで開く self.is_customer_picker_open = False self.is_new_customer_form_open = 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}") # 明細テーブル 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.open_customer_picker() customer_field = None if (not is_view_mode and not is_locked) or 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=260, ) 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 # 日付・時間ピッカー(テキスト入力NGなのでボタン+ポップアップ) date_button = None time_button = None if not is_view_mode and not is_locked: # DatePickerをページに登録(重複追加を防止) if not hasattr(self, "_date_picker"): self._date_picker = ft.DatePicker( first_date=datetime(2000, 1, 1), last_date=datetime(2100, 12, 31), ) self.page.overlay.append(self._date_picker) if not hasattr(self, "_time_picker"): self._time_picker = ft.TimePicker() self.page.overlay.append(self._time_picker) def on_date_change(e): if not e.data: return try: if isinstance(e.data, datetime): picked_date = e.data elif hasattr(e.data, "year") and hasattr(e.data, "month") and hasattr(e.data, "day"): picked_date = datetime(e.data.year, e.data.month, e.data.day) else: raw = str(e.data) picked_date = datetime.fromisoformat(raw) current = self.editing_invoice.date self.editing_invoice.date = datetime( picked_date.year, picked_date.month, picked_date.day, current.hour, current.minute ) # ボタン表示を更新 try: if date_button is not None: date_button.content.controls[1].value = picked_date.strftime("%Y/%m/%d") if time_button is not None: time_button.content.controls[1].value = self.editing_invoice.date.strftime("%H:%M") self.page.update() except Exception: pass except Exception as exc: logging.warning(f"日付パース失敗: {e.data} ({exc})") def on_time_change(e): if not e.data: return try: if hasattr(e.data, "hour") and hasattr(e.data, "minute"): h, m = e.data.hour, e.data.minute else: parts = str(e.data).split(":") h, m = int(parts[0]), int(parts[1]) current = self.editing_invoice.date self.editing_invoice.date = datetime( current.year, current.month, current.day, h, m ) # ボタン表示を更新 try: if time_button is not None: time_button.content.controls[1].value = f"{h:02d}:{m:02d}" self.page.update() except Exception: pass except Exception as exc: logging.warning(f"時間パース失敗: {e.data} ({exc})") self._date_picker.on_change = on_date_change self._time_picker.on_change = on_time_change date_button = ft.Button( content=ft.Row([ ft.Icon(ft.Icons.EVENT, size=16), ft.Text(self.editing_invoice.date.strftime("%Y/%m/%d"), size=12), ], spacing=6), on_click=lambda _: self._open_date_picker(), ) time_button = ft.Button( content=ft.Row([ ft.Icon(ft.Icons.ACCESS_TIME, size=16), ft.Text(self.editing_invoice.date.strftime("%H:%M"), size=12), ], spacing=6), on_click=lambda _: self._open_time_picker(), ) # 備考フィールド 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 save_succeeded = False self.editing_invoice.notes = notes_field.value self.editing_invoice.document_type = self.selected_document_type # UIで更新された明細を保存前に正規化して確定 normalized_items = normalize_invoice_items(self.editing_invoice.items) validation = validate_invoice_items(normalized_items) if not validation.ok: logging.warning(f"伝票保存バリデーションエラー: {validation.errors}") self._show_snack(validation.errors[0], ft.Colors.RED_600) return self.editing_invoice.items = normalized_items 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: save_succeeded = True logging.info(f"伝票作成成功: {self.editing_invoice.invoice_number}") self._show_snack("伝票を保存しました", ft.Colors.GREEN_600) # 一覧を更新して新規作成画面を閉じる 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}") self._show_snack("保存に失敗しました。編集内容を確認してください。", ft.Colors.RED_600) else: # 更新 logging.info(f"=== 伝票更新開 ===") # 顧客ID未発行なら先に作成 if getattr(self.editing_invoice.customer, "id", 0) == 0: try: new_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, ) if isinstance(new_id, int) and new_id > 0: self.editing_invoice.customer.id = new_id except Exception as e: logging.error(f"顧客作成失敗(update側): {e}") # 既存顧客ならマスタも更新 if getattr(self.editing_invoice.customer, "id", 0) > 0: try: self.app_service.customer.update_customer(self.editing_invoice.customer) except Exception as e: logging.warning(f"顧客更新失敗(続行): {e}") success = self.app_service.invoice.update_invoice(self.editing_invoice) if success: save_succeeded = True logging.info(f"伝票更新成功: {self.editing_invoice.invoice_number}") self._show_snack("伝票を更新しました", ft.Colors.GREEN_600) # 一覧データは更新 self.invoices = self.app_service.invoice.get_recent_invoices(20) # 設定により遷移先を変更 if not self.stay_on_detail_after_save: self.editing_invoice = None self.current_tab = 0 # 一覧タブに戻る self.update_main_content() else: logging.error(f"伝票更新失敗: {self.editing_invoice.invoice_number}") self._show_snack("更新に失敗しました。編集内容を確認してください。", ft.Colors.RED_600) except Exception as e: logging.error(f"伝票保存エラー: {e}") import traceback logging.error(f"詳細エラー: {traceback.format_exc()}") self._show_snack("保存中にエラーが発生しました", ft.Colors.RED_600) save_succeeded = False if save_succeeded: # 編集モード終了(ビューモードに戻る) self.is_detail_edit_mode = False # ビューモードに戻る self.update_main_content() # AppBar右上の保存アイコンからも同じ保存処理を呼べるようにする self._detail_save_handler = save_changes 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() summary_tags = [] if pdf_generated: summary_tags.append("PDF生成済み") if chain_hash: summary_tags.append("監査チェーン登録済み") if final_locked: summary_tags.append("LOCK") summary_badges = ft.Row( controls=[ ft.Container( content=ft.Text(tag, size=10, color=ft.Colors.WHITE), padding=ft.Padding.symmetric(horizontal=8, vertical=4), bgcolor=ft.Colors.BLUE_GREY_400, border_radius=12, ) for tag in summary_tags ], spacing=4, wrap=True, run_spacing=4, ) if summary_tags else ft.Container(height=0) summary_card = ft.Container( content=ft.Column( [ ft.Row( [ customer_field if customer_field else ft.Text( self.editing_invoice.customer.formal_name, size=13, weight=ft.FontWeight.BOLD, ), ft.Row([ ft.Button( content=ft.Text("顧客選択", size=12), on_click=lambda _: select_customer(), disabled=is_locked or is_view_mode, ) if not is_view_mode and not is_locked else ft.Container(), ft.Text( f"¥{self.editing_invoice.total_amount:,} (税込)", size=15, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_700, ), ], spacing=8, alignment=ft.MainAxisAlignment.END), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, ), ft.Row( [ ft.Text( self.editing_invoice.invoice_number, size=12, color=ft.Colors.BLUE_GREY_500, ), ft.Row([ date_button if date_button else ft.Text( self.editing_invoice.date.strftime("%Y/%m/%d"), size=12, color=ft.Colors.BLUE_GREY_600, ), ft.Text(" "), time_button if time_button else ft.Text( self.editing_invoice.date.strftime("%H:%M"), size=12, color=ft.Colors.BLUE_GREY_600, ), ], spacing=4, alignment=ft.MainAxisAlignment.END), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, ), summary_badges, ], spacing=6, ), padding=ft.Padding.all(14), bgcolor=ft.Colors.BLUE_GREY_50, border_radius=10, ) items_section = ft.Container( content=ft.Column( [ ft.Row( [ ft.Text("明細", size=13, weight=ft.FontWeight.BOLD), 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(), ], vertical_alignment=ft.CrossAxisAlignment.CENTER, ), ft.Container( content=items_table, border=ft.Border.all(1, ft.Colors.GREY_300), border_radius=6, padding=ft.Padding.all(4), ), ], spacing=6, ), padding=ft.Padding.all(12), bgcolor=ft.Colors.WHITE, border_radius=10, ) notes_section = ft.Container( content=ft.Column( [ ft.Text("備考", size=13, weight=ft.FontWeight.BOLD), notes_field, ft.Row( [ ft.Button( content=ft.Row([ ft.Icon(ft.Icons.DOWNLOAD, size=16), ft.Text("PDF生成", size=12), ], spacing=6), style=ft.ButtonStyle( bgcolor=ft.Colors.BLUE_600, color=ft.Colors.WHITE, ), on_click=lambda _: self.generate_pdf_from_edit(), disabled=is_locked, ) if not is_locked else ft.Container(), ], alignment=ft.MainAxisAlignment.END, ), ], spacing=10, ), padding=ft.Padding.all(14), bgcolor=ft.Colors.WHITE, border_radius=10, ) lower_scroll = ft.Container( content=ft.Column( [ summary_card, notes_section, ], spacing=12, scroll=ft.ScrollMode.AUTO, expand=True, ), expand=True, ) top_stack = ft.Column( [ items_section, ], spacing=12, ) return ft.Container( content=ft.Column( [ top_stack, lower_scroll, ], spacing=12, expand=True, ), 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}") # 入力途中で画面全体を再描画すると編集値が飛びやすいため、 # ここではモデル更新のみに留める(再描画は保存/行追加/行削除時に実施)。 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: """表示モード:フレキシブルな表形式で整然と表示""" return build_invoice_items_view_table(items) def _create_edit_mode_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column: """編集モード:フレキシブルな表形式""" return build_invoice_items_edit_table( items=items, is_locked=is_locked, on_update_field=self._update_item_field, on_delete_row=self._delete_item_row, ) def _open_date_picker(self): if hasattr(self, "_date_picker"): try: self._date_picker.open = True self.page.update() except Exception as e: logging.warning(f"DatePicker open error: {e}") def _open_time_picker(self): if hasattr(self, "_time_picker"): try: self._time_picker.open = True self.page.update() except Exception as e: logging.warning(f"TimePicker open error: {e}") def _show_snack(self, message: str, color=ft.Colors.BLUE_GREY_800): try: snack = ft.SnackBar(content=ft.Text(message), bgcolor=color) # prefer show_snack_bar API if available (more reliable on web) if hasattr(self.page, "show_snack_bar"): self.page.show_snack_bar(snack) else: self.page.snack_bar = snack self.page.snack_bar.open = True self.page.update() except Exception as e: logging.warning(f"snack_bar表示失敗: {e}") def create_new_customer_screen(self) -> ft.Container: """新規/既存顧客登録・編集画面""" editing_customer = getattr(self, "editing_customer_for_form", None) name_field = ft.TextField(label="顧客名(略称)", value=getattr(editing_customer, "name", "")) formal_name_field = ft.TextField(label="正式名称", value=getattr(editing_customer, "formal_name", "")) address_field = ft.TextField(label="住所", value=getattr(editing_customer, "address", "")) phone_field = ft.TextField(label="電話番号", value=getattr(editing_customer, "phone", "")) 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: self._show_snack("顧客名と正式名称は必須です", ft.Colors.RED_600) return # 既存編集か新規かで分岐 if editing_customer and getattr(editing_customer, "id", 0) > 0: try: editing_customer.name = name editing_customer.formal_name = formal_name editing_customer.address = address editing_customer.phone = phone self.app_service.customer.update_customer(editing_customer) self.customers = self.app_service.customer.get_all_customers() self.selected_customer = editing_customer if self.editing_invoice: self.editing_invoice.customer = editing_customer self._show_snack("顧客を更新しました", ft.Colors.GREEN_600) self.editing_customer_for_form = None self.is_customer_picker_open = False self.is_new_customer_form_open = False self.update_main_content() return except Exception as e: logging.error(f"顧客更新エラー: {e}") self._show_snack("保存に失敗しました", ft.Colors.RED_600) return # 新規登録フロー try: new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone) except Exception as e: logging.error(f"顧客登録エラー: {e}") self._show_snack("保存に失敗しました", ft.Colors.RED_600) return # create_customer がID(int)を返す場合にも対応 created_customer_obj = None if isinstance(new_customer, Customer): created_customer_obj = new_customer elif isinstance(new_customer, int): # IDから顧客を再取得 try: if hasattr(self.app_service.customer, "get_customer_by_id"): created_customer_obj = self.app_service.customer.get_customer_by_id(new_customer) else: # 全件から検索 for c in self.app_service.customer.get_all_customers(): if c.id == new_customer: created_customer_obj = c break except Exception as e: logging.error(f"顧客再取得エラー: {e}") if created_customer_obj: self.customers = self.app_service.customer.get_all_customers() self.selected_customer = created_customer_obj if self.editing_invoice: self.editing_invoice.customer = created_customer_obj logging.info(f"新規顧客登録: {created_customer_obj.formal_name}") self.is_customer_picker_open = False self.is_new_customer_form_open = False self.editing_customer_for_form = None self.update_main_content() else: logging.error("新規顧客登録失敗") self._show_snack("保存に失敗しました", ft.Colors.RED_600) def cancel(_): self.editing_customer_for_form = None 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, border_radius=10, ), 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( content=ft.Text("保存", color=ft.Colors.WHITE), bgcolor=ft.Colors.BLUE_GREY_800, on_click=save_customer, ), ft.Button( content=ft.Text("キャンセル"), 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 create_customer_picker_screen(self) -> ft.Container: """簡易顧客選択画面(画面遷移用)""" customers = getattr(self, "customers", []) or [] def close_picker(_=None): self.is_customer_picker_open = False self.update_main_content() def on_pick(customer: Customer): self.selected_customer = customer if self.editing_invoice: self.editing_invoice.customer = customer close_picker() def open_new_customer(_=None): self.is_new_customer_form_open = True self.is_customer_picker_open = False self.update_main_content() def open_edit_customer(c: Customer): # 長押しで顧客を編集 self.editing_customer_for_form = c self.is_new_customer_form_open = True self.is_customer_picker_open = False self.update_main_content() customer_cards = [] for c in customers: customer_cards.append( ft.Container( content=ft.Card( content=ft.ListTile( title=ft.Text(c.formal_name, weight=ft.FontWeight.BOLD), subtitle=ft.Text(c.address or ""), trailing=ft.Text(c.phone or ""), ) ), on_click=lambda _, cu=c: on_pick(cu), on_long_press=lambda _, cu=c: open_edit_customer(cu), ) ) body = ft.Column( [ ft.Row([ ft.IconButton(ft.Icons.ARROW_BACK, on_click=close_picker), ft.Text("顧客を選択", size=18, weight=ft.FontWeight.BOLD, expand=True), ft.IconButton(ft.Icons.ADD, tooltip="新規顧客", on_click=open_new_customer), ], vertical_alignment=ft.CrossAxisAlignment.CENTER), ft.Divider(), ft.Column(customer_cards, spacing=6, scroll=ft.ScrollMode.AUTO, expand=True), ], spacing=10, expand=True, ) return ft.Container( content=body, padding=ft.Padding.all(12), expand=True, ) def open_master_editor(self, e=None): """マスタ編集画面を開く""" self.is_customer_picker_open = False self.is_new_customer_form_open = False self.current_tab = 2 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): """帳票種類選択""" resolved_type = None if isinstance(doc_type, DocumentType): resolved_type = doc_type else: for dt in DocumentType: if dt.value == doc_type: resolved_type = dt break if not resolved_type: logging.warning(f"未知の帳票種類: {doc_type}") return if resolved_type == getattr(self, "selected_document_type", None): return self.selected_document_type = resolved_type logging.info(f"帳票種類を選択: {resolved_type.value}") self.update_main_content() 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, query: str = "", date_from: Optional[str] = None, date_to: Optional[str] = None, sort_by: str = "date", sort_desc: bool = True, limit: int = 50, offset: int = 0, include_offsets: bool = False, ) -> List[Invoice]: """伝票データ読み込み - Explorer条件を適用。""" return self.app_service.invoice.search_invoices( query=query, date_from=date_from, date_to=date_to, sort_by=sort_by, sort_desc=sort_desc, limit=limit, offset=offset, include_offsets=include_offsets, ) def main(page: ft.Page): """メイン関数""" app = FlutterStyleDashboard(page) page.update() if __name__ == "__main__": import flet as ft ft.run(main, port=8550)