""" Flutter風ダッシュボード 下部ナビゲーションと洗練されたUIコンポーネントを実装 """ import flet as ft import signal import sys import logging import asyncio import threading import sqlite3 from datetime import datetime, date, time from typing import List, Dict, Optional, Any from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem, Product 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, trailing_controls: Optional[list] = None, title_control: Optional[ft.Control] = None, leading_control: 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.trailing_controls = trailing_controls or [] self.title_control = title_control self.leading_control = leading_control 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 ) ) elif self.leading_control is not None: controls.append(self.leading_control) else: controls.append(ft.Container(width=48)) # スペーーサー # 中央:タイトル title_ctrl = self.title_control if self.title_control else ft.Text( self.title, size=18, weight=ft.FontWeight.W_500, color=ft.Colors.BLUE_GREY_800 ) controls.append( ft.Container( content=title_ctrl, 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 ) ) elif self.trailing_controls: controls.append( ft.Row(self.trailing_controls, spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER) ) 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.is_product_picker_open = False self.customer_search_query = "" self.show_offsets = False self._suppress_tap_after_long_press = False self._toast_text = ft.Text("", color=ft.Colors.WHITE, size=12) self._toast = ft.Container( opacity=0, visible=False, animate_opacity=300, content=ft.Container( content=ft.Row([ ft.Icon(ft.Icons.INFO, size=16, color=ft.Colors.WHITE70), self._toast_text, ], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER), bgcolor=ft.Colors.BLUE_GREY_800, padding=ft.Padding.symmetric(horizontal=12, vertical=8), border_radius=8, ), ) self.chain_verify_result = None self._invoice_snapshot = None # 編集開始時のスナップショット self.is_new_customer_form_open = False self.is_new_product_form_open = False self.editing_product_for_form: Optional[Product] = None self._pending_product_row: Optional[int] = None 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.current_theme = "light" self.theme_presets = self._build_theme_presets() self.apply_theme(self.current_theme) # ビジネスロジックサービス self.app_service = AppService() self.invoices = [] self.customers = [] self.settings_state = { "theme": self.current_theme, "stay_on_detail_after_save": self.stay_on_detail_after_save, "company_name": "", "company_kana": "", "company_address": "", "company_phone": "", "company_representative": "", "company_email": "", "smtp_host": "", "smtp_port": "", "smtp_username": "", "smtp_password": "", "backup_path": "", "corner_stamp_path": "", "rep_stamp_path": "", "bank_accounts": [{"active": False} for _ in range(4)], } self._item_row_refs: Dict[int, Dict[str, ft.Control]] = {} self._total_amount_text: Optional[ft.Text] = None self._tax_amount_text: Optional[ft.Text] = None self._subtotal_text: Optional[ft.Text] = None self.is_reorder_mode = False self.is_company_settings_open = False self._pending_stamp_target: Optional[str] = None self._stamp_picker: Optional[ft.FilePicker] = None self.max_active_bank_accounts = 2 self.is_explorer_controls_visible = False self.is_search_overlay_visible = False self._search_field: Optional[ft.TextField] = None self.edit_button_style = ft.ButtonStyle( bgcolor=ft.Colors.WHITE, color=ft.Colors.BLUE_GREY_800, padding=ft.Padding.symmetric(horizontal=12, vertical=8), shape=ft.RoundedRectangleBorder(radius=6), side=ft.BorderSide(1, ft.Colors.BLUE_200), overlay_color=ft.Colors.BLUE_50, ) 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, is_draft=invoice.is_draft, ) 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._settings_hide_task = None self.settings_panel_wrapper = ft.Container( width=640, bgcolor=ft.Colors.WHITE, padding=ft.Padding.symmetric(horizontal=16, vertical=24), shadow=[ft.BoxShadow(blur_radius=24, color=ft.Colors.BLACK12, offset=ft.Offset(6, 0))], ) self.settings_drawer_overlay = ft.Stack( controls=[ ft.Container( expand=True, bgcolor=ft.Colors.BLACK54, on_click=self.close_settings_drawer, ), ft.Container( expand=True, alignment=ft.Alignment(-1, 0), content=ft.Container( content=self.settings_panel_wrapper, width=640, bgcolor=ft.Colors.WHITE, ), ), ], expand=True, visible=False, ) self.page.overlay.append(self.settings_drawer_overlay) self._search_field = ft.TextField( value="", hint_text="顧客名・伝票番号・備考を検索", border=ft.InputBorder.NONE, text_style=ft.TextStyle(size=16, color=ft.Colors.BLUE_GREY_900), cursor_color=ft.Colors.BLUE_GREY_700, on_submit=lambda e: self._apply_search_query(e.control.value), ) self.search_overlay = self._build_search_overlay() self.page.overlay.append(self.search_overlay) # 初期表示 self.update_main_content() def dispose(self, _=None): logging.info("FlutterStyleDashboard.dispose") try: if self.page: self.page.window.close() except Exception as e: logging.warning(f"dispose error: {e}") def open_settings_drawer(self, _=None): if not hasattr(self, "settings_drawer_overlay"): return if self._settings_hide_task: self._settings_hide_task.cancel() self._settings_hide_task = None self.settings_panel_wrapper.content = self._build_settings_panel() self.settings_drawer_overlay.visible = True self.page.update() def close_settings_drawer(self, _=None): if not hasattr(self, "settings_drawer_overlay"): return self.page.update() loop = asyncio.get_event_loop() if self._settings_hide_task: self._settings_hide_task.cancel() self._settings_hide_task = loop.create_task(self._hide_settings_drawer_async()) async def _hide_settings_drawer_async(self): await asyncio.sleep(0.3) if hasattr(self, "settings_drawer_overlay"): self.settings_drawer_overlay.visible = False self.page.update() self._settings_hide_task = None def _build_settings_panel(self) -> ft.Row: sidebar_width = int(self.settings_panel_wrapper.width * 0.25) sidebar = ft.Container( width=sidebar_width, content=ft.Column( [ ft.Text("メニュー", weight=ft.FontWeight.BOLD), ft.Divider(), ft.ListTile( leading=ft.Icon(ft.Icons.PALETTE), title=ft.Text("全体設定"), selected=not self.is_company_settings_open, on_click=lambda _: self._open_settings_page(False), ), ft.ListTile( leading=ft.Icon(ft.Icons.BUSINESS), title=ft.Text("自社情報"), selected=self.is_company_settings_open, on_click=lambda _: self._open_settings_page(True), ), ], spacing=8, expand=True, ), ) content = self._build_company_settings_content() if self.is_company_settings_open else self._build_settings_content() body = ft.Row( [ sidebar, ft.VerticalDivider(width=1), ft.Container(content=content, expand=True), ], expand=True, ) header = ft.Row( [ ft.IconButton(ft.Icons.ARROW_BACK, tooltip="閉じる", on_click=self.close_settings_drawer), ft.Text("設定", size=18, weight=ft.FontWeight.BOLD), ], spacing=8, alignment=ft.MainAxisAlignment.START, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) return ft.Column( [ header, ft.Divider(), body, ], spacing=8, expand=True, ) def _open_settings_page(self, company: bool): self.is_company_settings_open = company self.settings_panel_wrapper.content = self._build_settings_panel() self.page.update() def _build_settings_content(self) -> ft.Column: theme_radio = ft.RadioGroup( value=self.settings_state.get("theme", self.current_theme), on_change=self._on_theme_change, content=ft.Column( [ ft.Radio(value=name, label=name.title()) for name in self.theme_presets.keys() ], spacing=4, ), ) stay_switch = ft.Switch( label="保存後も編集画面に留まる", value=self.settings_state.get("stay_on_detail_after_save", self.stay_on_detail_after_save), on_change=self._on_stay_setting_change, ) def text_field(key: str, label: str, password: bool = False) -> ft.TextField: return ft.TextField( label=label, value=self.settings_state.get(key, ""), password=password, can_reveal_password=password, on_change=lambda e, k=key: self._on_settings_text_change(k, e.control.value), ) smtp_section = ft.Column( [ ft.Text("SMTPサーバ設定", weight=ft.FontWeight.BOLD), text_field("smtp_host", "ホスト"), text_field("smtp_port", "ポート"), text_field("smtp_username", "ユーザー名"), text_field("smtp_password", "パスワード", password=True), ], spacing=8, ) backup_section = ft.Column( [ ft.Text("バックアップ先", weight=ft.FontWeight.BOLD), text_field("backup_path", "保存フォルダ"), ], spacing=8, ) action_buttons = ft.Row( [ ft.ElevatedButton("保存", icon=ft.Icons.SAVE, on_click=self._save_settings_and_close), ft.TextButton("閉じる", on_click=self.close_settings_drawer), ], spacing=12, alignment=ft.MainAxisAlignment.END, ) return ft.Column( [ ft.Text("設定", size=20, weight=ft.FontWeight.BOLD), ft.Divider(), ft.Text("テーマ", weight=ft.FontWeight.BOLD), theme_radio, stay_switch, ft.Divider(), smtp_section, ft.Divider(), backup_section, ft.Divider(), action_buttons, ], spacing=16, scroll=ft.ScrollMode.AUTO, ) def _build_company_settings_content(self) -> ft.Column: def text_field(key: str, label: str, multiline: bool = False) -> ft.TextField: return ft.TextField( label=label, value=self.settings_state.get(key, ""), multiline=multiline, min_lines=1 if not multiline else 2, max_lines=4, on_change=lambda e, k=key: self._on_settings_text_change(k, e.control.value), ) def bank_entry(index: int, data: Dict[str, Any]) -> ft.Container: prefix = f"bank_{index}" def on_field_change(e, field): account = self._ensure_bank_account(index) account[field] = e.control.value self._save_bank_accounts() def on_active_change(e): account = self._ensure_bank_account(index) is_enabling = bool(e.control.value) if is_enabling and self._active_bank_count() >= self.max_active_bank_accounts: self._show_snack(f"請求書掲載は最大{self.max_active_bank_accounts}口座です", ft.Colors.RED_200) e.control.value = False self.page.update() return account["active"] = is_enabling self._save_bank_accounts() is_active = data.get("active", False) return ft.Container( content=ft.Column( [ ft.Row( [ ft.Text(f"口座 {index + 1}", weight=ft.FontWeight.BOLD), ft.Switch(label="請求書に掲載", value=is_active, on_change=on_active_change), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, ), ft.TextField(label="銀行名", value=data.get("bank_name", ""), on_change=lambda e: on_field_change(e, "bank_name")), ft.TextField(label="支店名", value=data.get("branch_name", ""), on_change=lambda e: on_field_change(e, "branch_name")), ft.TextField(label="区分", value=data.get("account_type", "普通"), on_change=lambda e: on_field_change(e, "account_type")), ft.TextField(label="口座番号", value=data.get("account_number", ""), on_change=lambda e: on_field_change(e, "account_number")), ft.TextField(label="名義", value=data.get("holder", ""), on_change=lambda e: on_field_change(e, "holder")), ], spacing=8, ), border=ft.Border.all(1, ft.Colors.GREY_300), border_radius=8, padding=ft.Padding.all(12), ) stamp_controls = ft.Column( [ ft.Text("印鑑アップロード", weight=ft.FontWeight.BOLD), ft.Row([ ft.Column([ ft.Text("角印"), ft.Row([ ft.ElevatedButton("アップロード", on_click=lambda _: self._pick_stamp("corner_stamp_path")), ft.Text(self._file_name(self.settings_state.get("corner_stamp_path"))), ]), ]), ft.Column([ ft.Text("担当者印"), ft.Row([ ft.ElevatedButton("アップロード", on_click=lambda _: self._pick_stamp("rep_stamp_path")), ft.Text(self._file_name(self.settings_state.get("rep_stamp_path"))), ]), ]), ], spacing=16), ], spacing=8, ) accounts = self.settings_state.get("bank_accounts", []) while len(accounts) < 4: accounts.append({"active": False}) account_cards = [bank_entry(i, accounts[i]) for i in range(4)] company_fields = ft.Column( [ text_field("company_name", "会社名"), text_field("company_kana", "会社名 (カナ)"), text_field("company_address", "住所", multiline=True), text_field("company_phone", "電話番号"), ft.TextField( label="代表者名", value=self.settings_state.get("company_representative", ""), on_change=lambda e: self._on_settings_text_change("company_representative", e.control.value), ), ft.TextField( label="連絡先メール", value=self.settings_state.get("company_email", ""), on_change=lambda e: self._on_settings_text_change("company_email", e.control.value), ), ], spacing=10, ) return ft.Column( [ ft.Text("自社情報設定", size=18, weight=ft.FontWeight.BOLD), ft.Divider(), company_fields, ft.Divider(), ft.Text("銀行口座", weight=ft.FontWeight.BOLD), ft.Text("最大4口座登録可能・2口座まで請求書に掲載できます"), ft.Column(account_cards, spacing=12), ft.Divider(), stamp_controls, ft.Divider(), ft.Row([ ft.ElevatedButton("保存", icon=ft.Icons.SAVE, on_click=self._save_settings_and_close), ft.TextButton("閉じる", on_click=self.close_settings_drawer), ], alignment=ft.MainAxisAlignment.END, spacing=12), ], spacing=16, expand=True, scroll=ft.ScrollMode.AUTO, ) def _ensure_bank_account(self, index: int) -> Dict[str, Any]: accounts = self.settings_state.setdefault("bank_accounts", []) while len(accounts) <= index: accounts.append({"active": False}) return accounts[index] def _save_bank_accounts(self): self.page.update() def _active_bank_count(self) -> int: accounts = self.settings_state.get("bank_accounts", []) return sum(1 for acc in accounts if acc.get("active")) def _file_name(self, path: str) -> str: if not path: return "未設定" return path.split("/")[-1] def _pick_stamp(self, target_key: str): if self._stamp_picker is None: self._show_snack("この環境では印鑑アップロードに対応していません", ft.Colors.RED_200) return self._pending_stamp_target = target_key self._stamp_picker.pick_files(allow_multiple=False) def _on_stamp_file_picked(self, e): if not e.files or not self._pending_stamp_target: return file = e.files[0] temp_path = file.path or file.name self.settings_state[self._pending_stamp_target] = temp_path self._pending_stamp_target = None self.settings_panel_wrapper.content = self._build_settings_panel() self.page.update() def _on_theme_change(self, e: ft.ControlEvent): new_theme = e.control.value or "light" if new_theme == self.current_theme: return self.settings_state["theme"] = new_theme self.apply_theme(new_theme) self.update_main_content() def _on_stay_setting_change(self, e: ft.ControlEvent): value = bool(e.control.value) self.settings_state["stay_on_detail_after_save"] = value self.stay_on_detail_after_save = value def _on_settings_text_change(self, key: str, value: str): self.settings_state[key] = value def _save_settings_and_close(self, _=None): self._show_snack("設定を保存しました", ft.Colors.BLUE_GREY_600) self.close_settings_drawer() def toggle_reorder_mode(self, _=None): self.is_reorder_mode = not self.is_reorder_mode self.update_main_content() 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_new_product_form_open: logging.info("新規商品登録画面を表示") self.main_content.controls.append(self.create_new_product_screen()) elif self.is_customer_picker_open: # 顧客選択画面 logging.info("顧客選択画面を表示") self.main_content.controls.append(self.create_customer_picker_screen()) elif getattr(self, "is_product_picker_open", False): logging.info("商品選択画面を表示") self.main_content.controls.append(self.create_product_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(戻るボタンなし、編集ボタンなし) settings_button = ft.IconButton( icon=ft.Icons.MENU, tooltip="設定", on_click=self.open_settings_drawer, ) app_bar = AppBar( title="伝票一覧", show_back=False, show_edit=False, trailing_controls=[ ft.IconButton(ft.Icons.TUNE, tooltip="詳細フィルタ", on_click=self.toggle_explorer_controls), ft.IconButton(ft.Icons.SEARCH, tooltip="検索", on_click=self.open_search_overlay), ft.IconButton(ft.Icons.REFRESH, tooltip="再読込", on_click=lambda _: self.refresh_invoices()), ], leading_control=settings_button, ) 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=self._toast, padding=ft.Padding.symmetric(horizontal=16, vertical=4)), ft.Container( content=invoice_list, expand=True, padding=ft.Padding.all(16) ), ], expand=True) logging.info("_build_invoice_list_screen: Column作成完了") return result def refresh_invoices(self): logging.info("refresh_invoices") try: self.setup_database() self.explorer_state.offset = 0 self.update_main_content() self._show_snack("一覧を更新しました", ft.Colors.BLUE_GREY_600) except Exception as e: logging.error(f"refresh_invoices error: {e}") self._show_snack("再読込に失敗しました", ft.Colors.RED_200) def toggle_explorer_controls(self, _=None): self.is_explorer_controls_visible = not self.is_explorer_controls_visible self.update_main_content() def open_search_overlay(self, _=None): if not hasattr(self, "search_overlay"): return self.is_search_overlay_visible = True self._search_field.value = self.explorer_state.query self.search_overlay.visible = True self.page.update() def close_search_overlay(self, _=None): if not hasattr(self, "search_overlay"): return self.is_search_overlay_visible = False self.search_overlay.visible = False self.page.update() def _apply_search_query(self, value: Optional[str] = None): query = (value if value is not None else self._search_field.value or "").strip() self.explorer_state.query = query self.explorer_state.offset = 0 self.close_search_overlay() self.update_main_content() def _prefill_search_keyword(self, keyword: str): if not self._search_field: return self._search_field.value = keyword self.page.update() def _clear_search_query(self, _=None): self._search_field.value = "" self.explorer_state.query = "" self.explorer_state.offset = 0 self.close_search_overlay() self.update_main_content() def _build_search_overlay(self) -> ft.Container: command_palette = ft.Container( width=420, padding=ft.Padding.symmetric(horizontal=12, vertical=8), bgcolor=ft.Colors.WHITE, border_radius=12, shadow=[ft.BoxShadow(blur_radius=20, color=ft.Colors.BLACK26)], content=ft.Column( [ ft.Row( [ ft.Icon(ft.Icons.SEARCH, size=22, color=ft.Colors.BLUE_GREY_500), ft.Container(expand=True, content=self._search_field), ft.IconButton(ft.Icons.CLOSE, tooltip="閉じる", on_click=self.close_search_overlay), ], vertical_alignment=ft.CrossAxisAlignment.CENTER, ), ft.Text( "Enterで検索、ESCで閉じる", size=11, color=ft.Colors.BLUE_GREY_400, ), ft.Row( [ ft.TextButton("すべて表示に戻す", on_click=self._clear_search_query), ft.TextButton("閉じる", on_click=self.close_search_overlay), ], alignment=ft.MainAxisAlignment.END, ), ], spacing=6, ), ) return ft.Container( visible=False, expand=True, bgcolor=ft.Colors.BLACK54, alignment=ft.Alignment(0, 0), content=command_palette, on_click=lambda _: self.close_search_overlay(), ) @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_edit_mode が立っている場合は強制的に編集モードにする if getattr(self, 'is_edit_mode', False): self.is_detail_edit_mode = True is_locked = getattr(self.editing_invoice, 'final_locked', False) is_view_mode = not getattr(self, 'is_detail_edit_mode', False) is_draft = bool(getattr(self.editing_invoice, 'is_draft', False)) def set_doc_type(dt: DocumentType): self.select_document_type(dt) self.update_main_content() doc_type_items = [ft.PopupMenuItem(content=ft.Text(dt.value), on_click=lambda _, d=dt: set_doc_type(d)) for dt in DocumentType if dt != DocumentType.DRAFT] # ドラフト切替 def toggle_draft(e): self.editing_invoice.is_draft = bool(e.control.value) self.update_main_content() draft_toggle = ft.PopupMenuItem( content=ft.Row([ ft.Text("下書き", size=12), ft.Checkbox(value=is_draft, on_change=toggle_draft), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER), ) doc_type_items.insert(0, draft_toggle) draft_suffix = " (下書き)" if is_draft else "" if not getattr(self, 'is_detail_edit_mode', False): title_ctrl = ft.Text( f"{self.selected_document_type.value}{draft_suffix}", size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_800, ) doc_type_menu = None else: doc_type_menu = ft.PopupMenuButton( content=ft.Text( f"{self.selected_document_type.value}{draft_suffix}", size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_800, ), items=doc_type_items, tooltip="帳票タイプ変更", ) title_ctrl = doc_type_menu draft_badge = ft.Container( content=ft.Text("下書き", size=11, weight=ft.FontWeight.BOLD, color=ft.Colors.BROWN_800), padding=ft.Padding.symmetric(horizontal=8, vertical=4), bgcolor=ft.Colors.BROWN_100, border_radius=12, visible=is_draft, ) delete_button = self._build_delete_draft_button(is_view_mode) trailing_controls: List[ft.Control] = [] if delete_button: trailing_controls.append(delete_button) if is_draft: trailing_controls.append(draft_badge) 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, trailing_controls=trailing_controls, title_control=title_ctrl, ) body = self._create_edit_existing_screen() body = ft.Container( content=body, bgcolor=ft.Colors.BROWN_50 if is_draft else None, ) return ft.Column([ app_bar, body, ], expand=True) def _open_popup_menu(self, menu: ft.PopupMenuButton): try: menu.open = True self.page.update() except Exception: pass 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.Row( [ ft.TextField( value=self.explorer_state.query, hint_text="", prefix_icon=ft.Icons.SEARCH, on_change=on_query_change, expand=True, dense=True, height=38, border=ft.InputBorder.OUTLINE, text_style=ft.TextStyle(size=12), content_padding=ft.Padding.symmetric(horizontal=8, vertical=0), ), ft.Dropdown( 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=120, dense=True, border=ft.InputBorder.OUTLINE, ), ft.Dropdown( 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=130, dense=True, border=ft.InputBorder.OUTLINE, ), 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, ), ft.Text( f"{len(slips)}件", size=12, color=ft.Colors.BLUE_GREY_600, ), ft.TextButton("◀", on_click=on_prev_page, tooltip="前へ"), ft.TextButton("▶", on_click=on_next_page, tooltip="次へ"), ft.IconButton(ft.Icons.VERIFIED, tooltip="チェーン検証", on_click=on_verify_chain), ], spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), padding=ft.Padding.symmetric(horizontal=10, vertical=8), bgcolor=ft.Colors.BLUE_GREY_50, border_radius=8, visible=self.is_explorer_controls_visible, ) 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 _capture_invoice_snapshot(self, invoice: Invoice) -> dict: """伝票状態のスナップショットを取得(差分検知用)""" try: return { "document_type": invoice.document_type.value if getattr(invoice, "document_type", None) else None, "date": invoice.date.isoformat() if getattr(invoice, "date", None) else None, "notes": getattr(invoice, "notes", ""), "is_draft": bool(getattr(invoice, "is_draft", False)), "customer": { "id": getattr(getattr(invoice, "customer", None), "id", None), "formal_name": getattr(getattr(invoice, "customer", None), "formal_name", None), "address": getattr(getattr(invoice, "customer", None), "address", None), "phone": getattr(getattr(invoice, "customer", None), "phone", None), }, "items": [ { "description": it.description, "quantity": it.quantity, "unit_price": it.unit_price, "is_discount": it.is_discount, "product_id": it.product_id, } for it in (getattr(invoice, "items", None) or []) ], } except Exception as e: logging.warning(f"スナップショット取得失敗: {e}") return {} def _is_invoice_changed(self, invoice: Invoice, snapshot: dict) -> bool: """スナップショットと現在の伝票状態を比較""" if not snapshot: return True try: if snapshot.get("document_type") != (invoice.document_type.value if getattr(invoice, "document_type", None) else None): return True if snapshot.get("date") != (invoice.date.isoformat() if getattr(invoice, "date", None) else None): return True if snapshot.get("notes", "") != getattr(invoice, "notes", ""): return True if snapshot.get("is_draft", False) != bool(getattr(invoice, "is_draft", False)): return True snap_cust = snapshot.get("customer", {}) or {} cust = getattr(invoice, "customer", None) if snap_cust.get("id") != getattr(cust, "id", None): return True if snap_cust.get("formal_name") != getattr(cust, "formal_name", None): return True if snap_cust.get("address") != getattr(cust, "address", None): return True if snap_cust.get("phone") != getattr(cust, "phone", None): return True snap_items = snapshot.get("items", []) or [] cur_items = getattr(invoice, "items", None) or [] if len(snap_items) != len(cur_items): return True for snap_it, cur_it in zip(snap_items, cur_items): if snap_it.get("description") != cur_it.description: return True if snap_it.get("quantity") != cur_it.quantity: return True if snap_it.get("unit_price") != cur_it.unit_price: return True if snap_it.get("is_discount") != cur_it.is_discount: return True if snap_it.get("product_id") != cur_it.product_id: return True return False except Exception as e: logging.warning(f"差分判定失敗: {e}") return True def create_slip_card(self, slip, interactive: bool = True) -> ft.Control: """伝票カード作成(コンパクト表示)""" 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 is_draft_card = isinstance(slip, Invoice) and bool(getattr(slip, "is_draft", False)) 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 self._suppress_tap_after_long_press: self._suppress_tap_after_long_press = False return 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._suppress_tap_after_long_press = True 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.Row( [ ft.Text(f"No: {invoice_number}", size=10, color=theme["subtitle_color"]), ft.Container( content=ft.Text( "下書き", 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, visible=is_draft_card, ), ], spacing=6, ), ], 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_bg = theme["draft_card_bg"] if is_draft_card else theme["card_bg"] card_border = ft.Border.all(1, theme["draft_border"]) if is_draft_card else None card_shadow = [ theme["draft_shadow_highlight"], theme["draft_shadow_depth"] ] if is_draft_card else [theme["shadow"]] card_body = ft.Container( content=ft.Column( [left_column, status_chip], spacing=4, expand=True, ), padding=ft.Padding.symmetric(horizontal=12, vertical=8), bgcolor=card_bg, border_radius=theme["card_radius"], border=card_border, shadow=card_shadow, ) if not interactive: return ft.Container(content=card_body) 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: List[ft.PopupMenuItem] = [] # 編集メニュー if not getattr(slip, 'final_locked', False): menu_items.append( ft.PopupMenuItem( content=ft.Row([ ft.Icon(ft.Icons.EDIT, size=16), ft.Text("編集", size=14), ], spacing=6), on_click=lambda _, s=slip: self.open_invoice_edit(s) ) ) # 赤伝発行メニュー if self.can_create_offset_invoice(slip): menu_items.append( ft.PopupMenuItem( content=ft.Row([ ft.Icon(ft.Icons.REMOVE_CIRCLE, size=16), ft.Text("赤伝発行", size=14), ], spacing=6), on_click=lambda _, s=slip: self.open_offset_dialog(s) ) ) # 削除メニュー menu_items.append( ft.PopupMenuItem( content=ft.Row([ ft.Icon(ft.Icons.DELETE, size=16), ft.Text("削除", size=14), ], spacing=6), on_click=lambda _, s=slip: self.open_delete_dialog(s) ) ) # コンテキストメニューダイアログ 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.is_edit_mode = False # 一覧タップでは編集モードを解除 self.selected_document_type = invoice.document_type # 閲覧モードではスナップショットをクリア self._invoice_snapshot = None 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._invoice_snapshot = self._capture_invoice_snapshot(invoice) 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_new_customer_form(self): """新規顧客フォームを開く(画面内遷移)""" self.is_new_customer_form_open = True self.update_main_content() def start_new_invoice(self, _=None): """新規伝票作成ボタンから呼ばれる入口""" self.editing_invoice = None self._invoice_snapshot = None self._init_new_invoice() def create_invoice_edit_screen(self) -> ft.Container: """伝票編集画面(新規・編集統合)""" # 常に詳細編集画面を使用 if not self.editing_invoice: self._init_new_invoice() # 既存・新規共通で詳細編集画面を返す return self._create_edit_existing_screen() def _init_new_invoice(self): """新規伝票オブジェクトを準備""" default_customer = Customer( id=0, name="選択してください", formal_name="選択してください", address="", phone="" ) self.editing_invoice = Invoice( customer=default_customer, date=datetime.now(), items=[], document_type=DocumentType.INVOICE, invoice_number="NEW-" + str(int(datetime.now().timestamp())) ) self.editing_invoice.is_draft = True self.selected_customer = default_customer self.selected_document_type = DocumentType.INVOICE self.is_detail_edit_mode = True self.is_edit_mode = True self.is_customer_picker_open = False self.is_product_picker_open = False self.is_new_customer_form_open = False self.is_new_product_form_open = False self.current_tab = 1 self._invoice_snapshot = None self.update_main_content() def back_to_list(self): """一覧画面に戻る""" self.is_edit_mode = False self.is_detail_edit_mode = False self.current_tab = 0 self.is_customer_picker_open = False self.is_product_picker_open = False self.is_new_customer_form_open = False self.is_new_product_form_open = False self.editing_customer_for_form = None self.editing_product_for_form = None self.update_main_content() def _create_edit_existing_screen(self) -> ft.Container: """既存伝票の編集画面(新規・編集共通)""" # 編集不可チェック(新規作成時はFalse) is_new_invoice = self.editing_invoice.invoice_number.startswith("NEW-") edit_bg = ft.Colors.BROWN_50 is_draft = bool(getattr(self.editing_invoice, "is_draft", False)) # 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() def set_doc_type(dt: DocumentType): if self.is_detail_edit_mode and not is_locked: self.select_document_type(dt) self.update_main_content() doc_type_items = [ ft.PopupMenuItem( content=ft.Text(dt.value), on_click=lambda _, d=dt: set_doc_type(d) ) for dt in DocumentType if dt != DocumentType.DRAFT ] def toggle_draft(e): self.editing_invoice.is_draft = bool(e.control.value) self.update_main_content() doc_type_label = ft.Row( [ ft.Icon(ft.Icons.DESCRIPTION, size=14, color=ft.Colors.BLUE_GREY_700), ft.Text( self.selected_document_type.value, size=12, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_800, ), ], spacing=4, vertical_alignment=ft.CrossAxisAlignment.CENTER, ) doc_type_chip = ft.Container( content=doc_type_label, padding=ft.Padding.symmetric(horizontal=12, vertical=6), bgcolor=ft.Colors.WHITE, border=ft.border.all(1, ft.Colors.BLUE_200), border_radius=8, ) if self.is_detail_edit_mode and not is_locked: doc_type_control = ft.PopupMenuButton( content=doc_type_chip, items=doc_type_items, tooltip="帳票タイプ変更", ) else: doc_type_control = doc_type_chip if self.is_detail_edit_mode and not is_locked: draft_control = ft.Switch( label="下書き", value=is_draft, on_change=toggle_draft, ) elif is_draft: draft_control = ft.Container( content=ft.Text("下書き", size=11, color=ft.Colors.BROWN_800), padding=ft.Padding.symmetric(horizontal=8, vertical=4), bgcolor=ft.Colors.BROWN_100, border_radius=12, ) else: draft_control = ft.Container() # 日付・時間ピッカー(テキスト入力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 _parse_picker_date(value) -> date: if isinstance(value, datetime): return value.date() if hasattr(value, "year") and hasattr(value, "month") and hasattr(value, "day"): return date(value.year, value.month, value.day) raw = str(value) if "T" in raw: raw = raw.split("T")[0] return date.fromisoformat(raw) def on_date_change(e): if not e.data: return try: picked_date = _parse_picker_date(e.data) 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(), style=self.edit_button_style, ) 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(), style=self.edit_button_style, ) # 備考フィールド 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, bgcolor=edit_bg if not is_view_mode and not is_locked else None, ) def toggle_edit_mode(_): """編集モード切替""" old_mode = getattr(self, 'is_detail_edit_mode', False) self.is_detail_edit_mode = not old_mode if not old_mode and not is_new_invoice and self.editing_invoice: self._invoice_snapshot = self._capture_invoice_snapshot(self.editing_invoice) 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 # 差分なし判定(既存伝票のみ) if not is_new_invoice and self._invoice_snapshot: if not self._is_invoice_changed(self.editing_invoice, self._invoice_snapshot): self._show_snack("変更はありませんでした", ft.Colors.BLUE_GREY_600) self.is_detail_edit_mode = False self.update_main_content() return # 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, is_draft=bool(getattr(self.editing_invoice, 'is_draft', False)), ) 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._invoice_snapshot = self._capture_invoice_snapshot(self.editing_invoice) # 一覧を更新して新規作成画面を閉じる 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) # 保存後の顧客選択状態を保持 self.selected_customer = getattr(self.editing_invoice, "customer", None) self._invoice_snapshot = self._capture_invoice_snapshot(self.editing_invoice) # 設定により遷移先を変更 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) customer_label = self.editing_invoice.customer.formal_name if not customer_label or customer_label == "選択してください": customer_label = "顧客を選択" if (not is_view_mode and not is_locked) or is_new_invoice: customer_control = ft.Button( content=ft.Text(customer_label, no_wrap=True, color=ft.Colors.BLUE_GREY_800), width=220, height=36, on_click=lambda _: select_customer(), style=self.edit_button_style, ) else: customer_control = ft.Text( customer_label, size=13, weight=ft.FontWeight.BOLD, ) date_time_row = 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, ), time_button if time_button else ft.Text( self.editing_invoice.date.strftime("%H:%M"), size=12, color=ft.Colors.BLUE_GREY_600, ), ], spacing=12, ) customer_block = ft.Column( [ customer_control, date_time_row, ], spacing=4, alignment=ft.MainAxisAlignment.START, ) summary_card = ft.Container( content=ft.Column( [ ft.Row( [ ft.Row( [ doc_type_control, ft.Text( f"No: {self.editing_invoice.invoice_number}", size=10, color=self.invoice_card_theme["subtitle_color"], ), ], spacing=6, ), draft_control, ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), ft.Row( [ customer_block, self._build_totals_row(), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), summary_badges, ], spacing=8, ), padding=ft.Padding.symmetric(horizontal=12, vertical=10), bgcolor=ft.Colors.BROWN_50 if is_draft else self.invoice_card_theme["card_bg"], border_radius=self.invoice_card_theme["card_radius"], shadow=[self.invoice_card_theme["shadow"]], ) items_section = ft.Container( content=ft.Column( [ ft.Row( [ ft.Text("明細", size=13, weight=ft.FontWeight.BOLD), ft.Container(expand=True), ft.Button( content=ft.Row([ ft.Icon(ft.Icons.SHUFFLE, size=16, color=ft.Colors.BLUE_GREY_700), ft.Text("並べ替え" + ("ON" if self.is_reorder_mode else ""), size=12), ], spacing=6), style=self.edit_button_style, on_click=self.toggle_reorder_mode, disabled=is_locked or is_view_mode, ) if not is_locked and not is_view_mode else ft.Container(), ft.Button( content=ft.Row([ ft.Icon(ft.Icons.ADD_CIRCLE_OUTLINE, size=16, color=ft.Colors.BLUE_GREY_700), ft.Text("行追加", size=12), ], spacing=6), style=self.edit_button_style, on_click=lambda _: self._add_item_row(), disabled=is_locked or is_view_mode, ) if not is_locked and not is_view_mode else ft.Container(), ], spacing=8, 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.BROWN_50 if is_draft else 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, color=ft.Colors.BLUE_GREY_700), ft.Text("PDF生成", size=12), ], spacing=6), style=self.edit_button_style, 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( [ notes_section, ], spacing=12, scroll=ft.ScrollMode.AUTO, expand=True, ), expand=True, ) top_stack = ft.Column( [ summary_card, 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, index: int, field_name: str, value: str): """明細フィールドを更新""" if not self.editing_invoice or index >= len(self.editing_invoice.items): return item = self.editing_invoice.items[index] # デバッグ用:更新前の値をログ出力 old_value = getattr(item, field_name) logging.debug(f"Updating item {index} {field_name}: '{old_value}' -> '{value}'") if field_name == 'description': item.description = value elif field_name == 'quantity': try: # 空文字の場合は1を設定 if not value or value.strip() == '': item.quantity = 1 else: item.quantity = int(value) logging.debug(f"Quantity updated to: {item.quantity}") except ValueError as e: item.quantity = 1 logging.error(f"Quantity update error: {e}") elif field_name == 'unit_price': try: # 空文字の場合は0を設定 if not value or value.strip() == '': item.unit_price = 0 else: item.unit_price = int(value) logging.debug(f"Unit price updated to: {item.unit_price}") except ValueError as e: item.unit_price = 0 logging.error(f"Unit price update error: {e}") self._refresh_item_row(index) self._refresh_total_amount_display() self.page.update() def _select_product_for_row(self, item_index: int, product_id: Optional[int]): """商品選択ドロップダウンから呼ばれ、商品情報を行に反映""" if not self.editing_invoice or item_index >= len(self.editing_invoice.items): return if not product_id: return try: products = self.app_service.product.get_all_products() product = next((p for p in products if p.id == product_id), None) if not product: return item = self.editing_invoice.items[item_index] item.product_id = product.id item.description = product.name item.unit_price = product.unit_price item.quantity = 1 self._refresh_item_row(item_index, full_refresh=True) self._refresh_total_amount_display() # 行だけ更新し、再描画は即時に行う self.page.update() except Exception as e: logging.warning(f"商品選択反映失敗: {e}") def _reorder_item_row(self, old_index: int, new_index: int): """明細行のドラッグ&ドロップ並び替え""" if not self.editing_invoice: return items = self.editing_invoice.items if old_index < 0 or old_index >= len(items) or new_index < 0 or new_index >= len(items): return item = items.pop(old_index) items.insert(new_index, item) 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: """表示モード:フレキシブルな表形式で整然と表示""" 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, products=self.app_service.product.get_all_products(), on_product_select=self._open_product_picker_for_row, row_refs=self._ensure_item_row_refs(), enable_reorder=not is_locked and self.is_reorder_mode, on_reorder=lambda old, new: self._reorder_item_row(old, new), ) def _open_product_picker_for_row(self, item_index: int): """商品選択ボタンから商品マスタ選択画面を開き、選択結果を行に反映""" self._pending_product_row = item_index # 商品マスタが無ければ新規登録画面へ try: products = self.app_service.product.get_all_products() except Exception: products = [] if not products: self.is_new_product_form_open = True self.is_product_picker_open = False self.update_main_content() return self.is_product_picker_open = True self.update_main_content() def _assign_product_to_pending_row(self, product_id: int): row = getattr(self, "_pending_product_row", None) if row is None or not self.editing_invoice or row >= len(self.editing_invoice.items): return try: products = self.app_service.product.get_all_products() product = next((p for p in products if p.id == product_id), None) if not product: return item = self.editing_invoice.items[row] item.product_id = product.id item.description = product.name item.unit_price = product.unit_price item.quantity = 1 # 先にフラグを戻してから画面更新(詳細に即戻る) self.is_product_picker_open = False self.is_new_product_form_open = False self._refresh_item_row(row, full_refresh=True) self._refresh_total_amount_display() self.update_main_content() except Exception as e: logging.warning(f"商品選択適用失敗: {e}") finally: self._pending_product_row = None 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 _build_delete_draft_button(self, is_view_mode: bool) -> Optional[ft.Control]: if not is_view_mode: return None invoice = getattr(self, "editing_invoice", None) if not invoice or not getattr(invoice, "is_draft", False): return None return ft.IconButton( icon=ft.Icons.DELETE_FOREVER, icon_color=ft.Colors.RED_400, tooltip="下書きを削除", on_click=self._confirm_delete_current_invoice, ) def _confirm_delete_current_invoice(self, _=None): invoice = getattr(self, "editing_invoice", None) if not invoice or not getattr(invoice, "is_draft", False): return dialog = ft.AlertDialog( modal=True, title=ft.Text("下書きを削除"), content=ft.Text("この下書きを削除しますか? この操作は元に戻せません。"), actions=[ ft.TextButton("キャンセル", on_click=self._close_dialog), ft.TextButton("削除", style=ft.ButtonStyle(color=ft.Colors.RED_600), on_click=lambda _: self._delete_current_draft()), ], actions_alignment=ft.MainAxisAlignment.END, ) self.page.dialog = dialog dialog.open = True self.page.update() def _close_dialog(self, _=None): dialog = getattr(self.page, "dialog", None) if dialog: dialog.open = False self.page.update() def _delete_current_draft(self): invoice = getattr(self, "editing_invoice", None) if not invoice or not getattr(invoice, "is_draft", False): self._close_dialog() return try: success = self.app_service.invoice.delete_invoice_by_uuid(invoice.uuid) if success: self._show_snack("下書きを削除しました", ft.Colors.GREEN_600) self.editing_invoice = None self.invoices = self.app_service.invoice.get_recent_invoices(20) self.current_tab = 0 else: self._show_snack("削除に失敗しました", ft.Colors.RED_600) except Exception as e: logging.error(f"ドラフト削除エラー: {e}") self._show_snack("削除中にエラーが発生しました", ft.Colors.RED_600) finally: self._close_dialog() self.update_main_content() def _build_totals_row(self) -> ft.Column: subtotal = self.editing_invoice.subtotal if self.editing_invoice else 0 tax = self.editing_invoice.tax if self.editing_invoice else 0 total = subtotal + tax self._tax_amount_text = ft.Text(f"消費税 ¥{tax:,}", size=12, color=ft.Colors.BLUE_GREY_700) self._total_amount_text = ft.Text( f"合計 ¥{total:,}", size=15, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_700 ) return ft.Column( [ self._tax_amount_text, self._total_amount_text, ], spacing=2, alignment=ft.MainAxisAlignment.END, ) def _refresh_total_amount_display(self): if not self.editing_invoice: return if self._tax_amount_text: self._tax_amount_text.value = f"消費税 ¥{self.editing_invoice.tax:,}" if self._total_amount_text: total = self.editing_invoice.total_amount self._total_amount_text.value = f"合計 ¥{total:,}" def _ensure_item_row_refs(self) -> Dict[int, Dict[str, ft.Control]]: self._item_row_refs = {} return self._item_row_refs def _refresh_item_row(self, index: int, full_refresh: bool = False): if not self.editing_invoice: return row_refs = getattr(self, "_item_row_refs", {}) row_controls = row_refs.get(index) if not row_controls or index >= len(self.editing_invoice.items): return item = self.editing_invoice.items[index] if full_refresh: product_button = row_controls.get("product") if product_button and isinstance(product_button.content, ft.Text): product_button.content.value = item.description or "商品選択" quantity_field = row_controls.get("quantity") if quantity_field: quantity_field.value = str(item.quantity) unit_price_field = row_controls.get("unit_price") if unit_price_field: unit_price_field.value = f"{item.unit_price:,}" subtotal_text = row_controls.get("subtotal") if subtotal_text: subtotal_text.value = f"¥{item.subtotal:,}" def _show_snack(self, message: str, color=ft.Colors.BLUE_GREY_800): try: logging.info(f"show_snack: {message}") snack = ft.SnackBar(content=ft.Text(message), bgcolor=color) # attach to page to ensure it's rendered even if show_snack_bar is ignored self.page.snack_bar = snack # 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.open = True # ensure open flag is set for both paths if hasattr(self.page, "snack_bar"): self.page.snack_bar.open = True # in-app toast fallback (必ず画面に表示される) if self._toast is not None: self._toast_text.value = message self._toast.content.bgcolor = color if color else ft.Colors.BLUE_GREY_800 self._toast.visible = True self._toast.opacity = 1 try: loop = asyncio.get_event_loop() loop.call_later(3, self._hide_toast) except RuntimeError: threading.Timer(3, self._hide_toast).start() self.page.update() except Exception as e: logging.warning(f"snack_bar表示失敗: {e}") def _hide_toast(self): try: if self._toast is not None: self._toast.opacity = 0 self._toast.visible = False self.page.update() except Exception as e: logging.warning(f"toast hide failed: {e}") def _build_theme_presets(self) -> Dict[str, Dict[str, Any]]: common_radius = 18 light_palette = { "page_bg": "#F3F2FB", "card_bg": ft.Colors.WHITE, "card_radius": common_radius, "shadow": ft.BoxShadow( blur_radius=16, spread_radius=0, color="#D5D8F0", offset=ft.Offset(0, 6), ), "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", "draft_card_bg": "#F5EEE4", "draft_border": "#D7C4AF", "draft_shadow_highlight": ft.BoxShadow( blur_radius=8, spread_radius=0, color="#FFFFFF", offset=ft.Offset(-2, -2), ), "draft_shadow_depth": ft.BoxShadow( blur_radius=14, spread_radius=2, color="#C3A88C", offset=ft.Offset(4, 6), ), "badge_bg": "#35C46B", "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", }, } monokai_palette = { "page_bg": "#272822", "card_bg": "#3E3D32", "card_radius": common_radius, "shadow": ft.BoxShadow( blur_radius=12, spread_radius=0, color="#00000055", offset=ft.Offset(0, 4), ), "icon_default_bg": "#F92672", "title_color": "#F8F8F2", "subtitle_color": "#A6E22E", "amount_color": "#66D9EF", "tag_text_color": "#F8F8F2", "tag_bg": "#75715E", "draft_card_bg": "#4F3F2F", "draft_border": "#CDAA7D", "draft_shadow_highlight": ft.BoxShadow( blur_radius=6, spread_radius=0, color="#ffffff22", offset=ft.Offset(-1, -1), ), "draft_shadow_depth": ft.BoxShadow( blur_radius=10, spread_radius=0, color="#00000055", offset=ft.Offset(2, 3), ), "badge_bg": "#AE81FF", "doc_type_palette": { DocumentType.INVOICE.value: "#F92672", DocumentType.ESTIMATE.value: "#AE81FF", DocumentType.DELIVERY.value: "#A6E22E", DocumentType.RECEIPT.value: "#FD971F", DocumentType.SALES.value: "#66D9EF", DocumentType.DRAFT.value: "#75715E", }, } return { "light": light_palette, "monokai": monokai_palette, } def apply_theme(self, name: str): preset = self.theme_presets.get(name) or self.theme_presets.get("light") self.current_theme = name if name in self.theme_presets else "light" self.invoice_card_theme = {k: v for k, v in preset.items() if k != "doc_type_palette"} self.doc_type_palette = preset["doc_type_palette"].copy() 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 # 既存伝票一覧も更新しておく(顧客名がすぐ反映されるように) try: self.invoices = self.app_service.invoice.get_recent_invoices(20) except Exception: pass 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_product_picker_screen(self) -> ft.Container: """商品選択画面(明細のボタンから遷移)""" try: products = self.app_service.product.get_all_products() except Exception: products = [] def close_picker(_=None): self.is_product_picker_open = False self.update_main_content() def open_new_product(_=None): self.is_new_product_form_open = True self.is_product_picker_open = False self.update_main_content() if not products: return ft.Container( content=ft.Column([ ft.Row([ ft.IconButton(ft.Icons.ARROW_BACK, on_click=close_picker), ft.Text("商品マスタがありません。新規登録してください。", weight=ft.FontWeight.BOLD), ]), ft.Container(height=12), ft.Button("新規商品を登録", on_click=open_new_product), ], spacing=10), padding=ft.Padding.all(16), expand=True, ) def build_card(p: Product, idx: int): return ft.GestureDetector( on_tap=lambda _=None, pid=p.id: self._assign_product_to_pending_row(pid), on_long_press=lambda _=None, prod=p: self._open_product_edit(prod), content=ft.Container( content=ft.Row([ ft.Column([ ft.Text(p.name, weight=ft.FontWeight.BOLD), ft.Text(f"単価: ¥{p.unit_price:,}", size=12, color=ft.Colors.BLUE_GREY_600), ft.Text(p.description or "", size=12, color=ft.Colors.BLUE_GREY_400), ], spacing=2, expand=True), ft.Icon(ft.Icons.CHEVRON_RIGHT), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN), padding=ft.Padding.all(12), border=ft.Border.all(1, ft.Colors.BLUE_GREY_100), border_radius=8, bgcolor=ft.Colors.WHITE, ) ) product_cards = [build_card(p, i) for i, p in enumerate(products)] return ft.Container( content=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.Button("新規商品", on_click=open_new_product), ], vertical_alignment=ft.CrossAxisAlignment.CENTER), ft.Divider(), ft.Column(product_cards, spacing=6, scroll=ft.ScrollMode.AUTO, expand=True), ], expand=True), padding=ft.Padding.all(16), expand=True, ) def create_new_product_screen(self) -> ft.Container: """新規商品登録画面""" editing_product = getattr(self, "editing_product_for_form", None) name_field = ft.TextField(label="商品名", value=getattr(editing_product, "name", ""), expand=True) price_field = ft.TextField(label="単価", value=str(getattr(editing_product, "unit_price", "")), keyboard_type=ft.KeyboardType.NUMBER) desc_field = ft.TextField(label="説明", value=getattr(editing_product, "description", ""), multiline=True, min_lines=2, max_lines=3) def save_product(_): name = (name_field.value or "").strip() price_raw = (price_field.value or "0").replace(",", "").strip() desc = (desc_field.value or "").strip() if not name: self._show_snack("商品名は必須です", ft.Colors.RED_600) return try: price = int(price_raw) except ValueError: self._show_snack("単価は数値で入力してください", ft.Colors.RED_600) return prod = Product(id=getattr(editing_product, "id", None), name=name, unit_price=price, description=desc) ok = self.app_service.product.save_product(prod) if ok: self._show_snack("商品を保存しました", ft.Colors.GREEN_600) self.editing_product_for_form = None self.is_new_product_form_open = False self.is_product_picker_open = True self.update_main_content() else: self._show_snack("商品保存に失敗しました", ft.Colors.RED_600) def cancel(_): self.editing_product_for_form = None self.is_new_product_form_open = False # 直前が商品ピッカーなら戻す self.is_product_picker_open = True self.update_main_content() return ft.Container( content=ft.Column([ ft.Row([ ft.IconButton(ft.Icons.ARROW_BACK, on_click=cancel), ft.Text("商品登録", size=18, weight=ft.FontWeight.BOLD), ]), ft.Divider(), ft.Column([ name_field, price_field, desc_field, ft.Row([ ft.Button("保存", on_click=save_product, bgcolor=ft.Colors.BLUE_GREY_800, color=ft.Colors.WHITE), ft.Button("キャンセル", on_click=cancel), ], spacing=8), ], spacing=10), ], spacing=12), padding=ft.Padding.all(16), expand=True, ) def _open_product_edit(self, product: Product): """商品マスタカード長押しで編集フォームへ""" self.editing_product_for_form = product self.is_product_picker_open = False self.is_new_product_form_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}") 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="", is_draft=bool(getattr(self, "editing_invoice", None) and getattr(self.editing_invoice, "is_draft", False)), ) 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)