diff --git a/main.py b/main.py index ed6d4d4..c3dc3a0 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,27 @@ logging.basicConfig( 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): """ピンチイン/アウト対応コンテナ""" @@ -50,7 +71,8 @@ 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 = "編集"): + 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 @@ -60,6 +82,7 @@ class AppBar(ft.Container): 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) @@ -110,12 +133,20 @@ class AppBar(ft.Container): else: controls.append(ft.Container(width=48)) # スペーサー - return ft.Row( + 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風の統合ダッシュボード""" @@ -276,6 +307,7 @@ class FlutterStyleDashboard: self.update_main_content() self.page.update() + @log_wrap("update_main_content") def update_main_content(self): """メインコンテンツ更新""" self.main_content.controls.clear() @@ -310,6 +342,7 @@ class FlutterStyleDashboard: 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: 開始") @@ -322,63 +355,90 @@ class FlutterStyleDashboard: ) logging.info("_build_invoice_list_screen: AppBar作成完了") - # 伝票リスト(ズーム対応) + # 伝票リスト invoice_list = self._build_invoice_list() logging.info(f"_build_invoice_list_screen: 伝票リスト作成完了") - - zoomable_list = ZoomableContainer( - content=invoice_list, - min_scale=0.8, - max_scale=2.5 - ) - logging.info("_build_invoice_list_screen: ZoomableContainer作成完了") - + result = ft.Column([ app_bar, ft.Container( - content=zoomable_list, + 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("伝票が選択されていません")]) - - # AppBar(戻るボタンあり、編集ボタン条件付き) + is_locked = getattr(self.editing_invoice, 'final_locked', False) + is_view_mode = not getattr(self, 'is_detail_edit_mode', False) app_bar = AppBar( - title=f"伝票詳細: {self.editing_invoice.invoice_number}", + 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, ) - - # 伝票詳細コンテンツ(ズーム対応) - detail_content = self._create_edit_existing_screen() - zoomable_content = ZoomableContainer( - content=detail_content, - min_scale=0.8, - max_scale=2.5 - ) - + # 編集/閲覧モード共通の画面(元の編集用ビルダーを利用) + body = self._create_edit_existing_screen() + return ft.Column([ app_bar, - ft.Container( - content=zoomable_content, - expand=True, - padding=ft.Padding.all(16) - ), + 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): @@ -562,7 +622,7 @@ class FlutterStyleDashboard: ], spacing=6, ), - padding=ft.padding.all(10), + padding=ft.Padding.all(10), bgcolor=ft.Colors.BLUE_GREY_50, border_radius=8, ) @@ -607,7 +667,7 @@ class FlutterStyleDashboard: return ft.Container( bgcolor=self.invoice_card_theme.get("page_bg"), - padding=ft.padding.symmetric(horizontal=12, vertical=10), + padding=ft.Padding.symmetric(horizontal=12, vertical=10), content=content_column, expand=True, ) @@ -671,45 +731,63 @@ class FlutterStyleDashboard: if extra_count > 0: first_item_label = f"{first_item_label} 他{extra_count}" - left_column = ft.Column( + header_row = ft.Row( [ ft.Row( [ ft.Container( content=ft.Text( slip_type, - size=10, + size=9, weight=ft.FontWeight.BOLD, color=theme["tag_text_color"], ), - padding=ft.padding.symmetric(horizontal=8, vertical=2), + padding=ft.Padding.symmetric(horizontal=8, vertical=2), bgcolor=theme["tag_bg"], border_radius=10, ), - ft.Text(f"No: {invoice_number}", size=11, color=theme["subtitle_color"]), - ft.Text(dt.strftime("%y/%m/%d %H:%M"), size=10, color=theme["subtitle_color"], expand=True, text_align=ft.TextAlign.END), + ft.Text(f"No: {invoice_number}", size=10, color=theme["subtitle_color"]), ], - spacing=8, - vertical_alignment=ft.CrossAxisAlignment.CENTER, + spacing=6, + ), + ft.Text( + dt.strftime("%y/%m/%d %H:%M"), + size=9, + color=theme["subtitle_color"], + text_align=ft.TextAlign.END, ), - ft.Text(f"{customer_name}", size=12, weight=ft.FontWeight.BOLD, color=theme["title_color"]), - ft.Text(first_item_label, size=10, color=theme["subtitle_color"]), ], - spacing=2, - expand=True, + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, ) - right_controls = [ - ft.Text( - f"¥{display_amount:,.0f}", - size=14, - weight=ft.FontWeight.BOLD, - color=theme["amount_color"], - text_align=ft.TextAlign.END, - ), - ] - if show_offset_button: - right_controls.append( + 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, @@ -717,27 +795,30 @@ class FlutterStyleDashboard: 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, + ) - right_column = ft.Column( - right_controls, - spacing=2, - horizontal_alignment=ft.CrossAxisAlignment.END, + 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.Row( - [ - ft.Column([left_column, status_chip], spacing=4, expand=True), - right_column, - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, + content=ft.Column( + [left_column, status_chip], + spacing=4, + expand=True, ), - padding=ft.padding.symmetric(horizontal=12, vertical=8), + padding=ft.Padding.symmetric(horizontal=12, vertical=8), bgcolor=theme["card_bg"], border_radius=theme["card_radius"], shadow=[theme["shadow"]], @@ -828,6 +909,7 @@ class FlutterStyleDashboard: 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): @@ -835,6 +917,7 @@ class FlutterStyleDashboard: 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): @@ -947,9 +1030,6 @@ class FlutterStyleDashboard: logging.info(f" 編集モード: {getattr(self, 'is_detail_edit_mode', False)}") logging.info(f" 表示モード: {is_view_mode}") - # 伝票種類選択(ヘッダーに移動) - document_types = list(DocumentType) - # 明細テーブル if is_view_mode: items_table = self._create_view_mode_table(self.editing_invoice.items) @@ -960,123 +1040,126 @@ class FlutterStyleDashboard: 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: - # 編集モード:顧客名入力フィールド - customer_field = ft.TextField( - label="顧客名", - value=self.editing_invoice.customer.name if self.editing_invoice.customer.name != "選択してください" else "", - disabled=is_locked, - width=300, - ) - - def update_customer_name(e): - """顧客名を更新""" - if self.editing_invoice: - # 既存顧客を検索、なければ新規作成 - customer_name = e.control.value or "" - found_customer = None - for customer in self.app_service.customer.get_all_customers(): - if customer.name == customer_name or customer.formal_name == customer_name: - found_customer = customer - break - - if found_customer: - self.editing_invoice.customer = found_customer + # 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: - # 新規顧客を作成 - from models.invoice_models import Customer - self.editing_invoice.customer = Customer( - id=0, - name=customer_name, - formal_name=customer_name, - address="", - phone="" - ) - - customer_field.on_change = update_customer_name - - customer_display = ft.Container( - content=ft.Row([ - customer_field, # 顧客ラベルを削除 - ft.ElevatedButton( - "選択", - icon=ft.Icons.PERSON_SEARCH, - on_click=lambda _: select_customer(), - style=ft.ButtonStyle( - bgcolor=ft.Colors.BLUE_600, - color=ft.Colors.WHITE - ) - ), - ]), - padding=ft.padding.symmetric(horizontal=10, vertical=5), - bgcolor=ft.Colors.BLUE_50, - border_radius=5, - ) - elif is_new_invoice: - # 新規作成時も同じ表示 - customer_field = ft.TextField( - label="顧客名", - value=self.editing_invoice.customer.name if self.editing_invoice.customer.name != "選択してください" else "", - disabled=is_locked, - width=300, - ) - - def update_customer_name(e): - """顧客名を更新""" - if self.editing_invoice: - # 既存顧客を検索、なければ新規作成 - customer_name = e.control.value or "" - found_customer = None - for customer in self.app_service.customer.get_all_customers(): - if customer.name == customer_name or customer.formal_name == customer_name: - found_customer = customer - break - - if found_customer: - self.editing_invoice.customer = found_customer + 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: - # 新規顧客を作成 - from models.invoice_models import Customer - self.editing_invoice.customer = Customer( - id=0, - name=customer_name, - formal_name=customer_name, - address="", - phone="" - ) - - customer_field.on_change = update_customer_name - - customer_display = ft.Container( + 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([ - customer_field, # 顧客ラベルを削除 - ft.ElevatedButton( - "選択", - icon=ft.Icons.PERSON_SEARCH, - on_click=lambda _: select_customer(), - style=ft.ButtonStyle( - bgcolor=ft.Colors.BLUE_600, - color=ft.Colors.WHITE - ) - ), - ]), - padding=ft.padding.symmetric(horizontal=10, vertical=5), - bgcolor=ft.Colors.BLUE_50, - border_radius=5, + 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(), ) - else: - # 既存伝票は表示のみ - customer_display = ft.Container( + time_button = ft.Button( content=ft.Row([ - ft.Text(self.editing_invoice.customer.name), # 顧客ラベルを削除 - ]), - padding=ft.padding.symmetric(horizontal=10, vertical=5), - bgcolor=ft.Colors.GREY_100, - border_radius=5, + 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="備考", @@ -1188,6 +1271,20 @@ class FlutterStyleDashboard: 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}") + success = self.app_service.invoice.update_invoice(self.editing_invoice) if success: save_succeeded = True @@ -1238,138 +1335,177 @@ class FlutterStyleDashboard: self.current_tab = 0 # 一覧タブに戻る self.update_main_content() - return ft.Container( - content=ft.Column([ - # ヘッダー + 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.Row([ - ft.Container(expand=True), - # コンパクトな伝票種類選択(セグメント化) - ft.Container( - content=ft.Row([ - ft.GestureDetector( - content=ft.Container( - content=ft.Text( - doc_type.value, - size=10, - color=ft.Colors.WHITE if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_600, - weight=ft.FontWeight.BOLD if doc_type == self.editing_invoice.document_type else ft.FontWeight.NORMAL, - ), - padding=ft.padding.symmetric(horizontal=8, vertical=4), - bgcolor=ft.Colors.BLUE_600 if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_300, - border_radius=ft.border_radius.all(4), - margin=ft.margin.only(right=1), - ), - on_tap=lambda _, dt=doc_type: self.select_document_type(dt.value) if not is_locked and not is_view_mode else None, - ) for doc_type in document_types - ]), - padding=ft.padding.all(2), - bgcolor=ft.Colors.GREY_200, - border_radius=ft.border_radius.all(6), - margin=ft.margin.only(right=10), - ), - ft.ElevatedButton( - content=ft.Text("編集" if is_view_mode else "保存"), - style=ft.ButtonStyle( - bgcolor=ft.Colors.BLUE_600 if is_view_mode else ft.Colors.GREEN_600, + 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, ), - on_click=toggle_edit_mode if is_view_mode else save_changes, - disabled=is_locked, - width=70, - height=30, - ) if not is_locked else ft.Container(), - ft.Container(width=5), - ft.IconButton(ft.Icons.CLOSE, on_click=cancel_edit), - ]), - padding=ft.padding.symmetric(horizontal=15, vertical=8), - bgcolor=ft.Colors.BLUE_GREY, - ), - - # 基本情報行(コンパクトに) - ft.Container( - content=ft.Row([ - ft.Text(f"{self.editing_invoice.invoice_number} | {self.editing_invoice.date.strftime('%Y/%m/%d %H:%M')} | {self.editing_invoice.customer.name}", size=12, weight=ft.FontWeight.BOLD), - ft.Container(expand=True), - ]), - padding=ft.padding.symmetric(horizontal=15, vertical=3), - bgcolor=ft.Colors.GREY_50, - ), - - # 顧客名入力(編集モードまたは新規作成時) - customer_display if (not is_view_mode and not is_locked) or is_new_invoice else ft.Container(height=0), - - # 明細テーブル(フレキシブルに) - ft.Container( - content=ft.Column([ - ft.Container( - content=items_table, - height=400, # 高さを拡大して見やすく - border=ft.border.all(1, ft.Colors.GREY_300), - border_radius=5, - padding=ft.padding.all(1), # パディングを最小化 - width=None, # 幅を可変に - expand=True, # 利用可能な幅を全て使用 - ), - ft.Container(height=10), - # 合計金額表示 - ft.Container( - content=ft.Row([ - ft.Text("合計(税込): ", size=14, weight=ft.FontWeight.BOLD), # 左に詰める - ft.Text( - f"¥{self.editing_invoice.total_amount:,}", - size=16, - weight=ft.FontWeight.BOLD, - color=ft.Colors.BLUE_600 - ), - ft.Container(expand=True), # スペースを取る - # +ボタンを右端に配置 - ft.IconButton( - ft.Icons.ADD_CIRCLE_OUTLINE, - tooltip="行を追加", - icon_color=ft.Colors.GREEN_600, + ft.Row([ + ft.Button( + content=ft.Text("顧客選択", size=12), + on_click=lambda _: select_customer(), disabled=is_locked or is_view_mode, - on_click=lambda _: self._add_item_row(), - ) if not is_locked and not is_view_mode else ft.Container(), - ]), - padding=ft.padding.symmetric(horizontal=5, vertical=8), # 左右のパディングを減らす - bgcolor=ft.Colors.GREY_100, - border_radius=5, - ), - ]), - padding=ft.padding.all(15), - expand=True, # 明細部分が最大限のスペースを占有 - ), - - # 備考(コンパクト) - ft.Container( - content=ft.Column([ - ft.Text("備考", size=12, weight=ft.FontWeight.BOLD), - ft.Container(height=3), - notes_field, - ft.Container(height=5), - ft.Text("🔒 税務署提出済みは編集できません" if is_locked else "✅ " + ("編集モード" if not is_view_mode else "ビューモード"), - size=11, color=ft.Colors.RED_600 if is_locked else (ft.Colors.GREEN_600 if not is_view_mode else ft.Colors.BLUE_600)), - ft.Container(height=10), - # PDF生成ボタンを追加 - ft.ElevatedButton( - content=ft.Row([ - ft.Icon(ft.Icons.DOWNLOAD, size=16), - ft.Container(width=5), - ft.Text("PDF生成", size=12, color=ft.Colors.WHITE), - ]), - style=ft.ButtonStyle( - bgcolor=ft.Colors.BLUE_600, - padding=ft.padding.symmetric(horizontal=15, vertical=10), + ) 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, ), - on_click=lambda _: self.generate_pdf_from_edit(), - width=120, - height=40, - ) if not is_locked else ft.Container(), - ]), - padding=ft.padding.symmetric(horizontal=15, vertical=10), - bgcolor=ft.Colors.GREY_50, - ), - ]), + 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): @@ -1491,6 +1627,22 @@ class FlutterStyleDashboard: 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 create_new_customer_screen(self) -> ft.Container: """新規顧客登録画面""" name_field = ft.TextField(label="顧客名(略称)") @@ -1504,18 +1656,60 @@ class FlutterStyleDashboard: address = (address_field.value or "").strip() phone = (phone_field.value or "").strip() if not name or not formal_name: - # TODO: エラー表示 + try: + self.page.snack_bar = ft.SnackBar(content=ft.Text("顧客名と正式名称は必須です"), bgcolor=ft.Colors.RED_600) + self.page.snack_bar.open = True + self.page.update() + except Exception: + pass return - new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone) - if new_customer: + try: + new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone) + except Exception as e: + logging.error(f"顧客登録エラー: {e}") + try: + self.page.snack_bar = ft.SnackBar(content=ft.Text("保存に失敗しました"), bgcolor=ft.Colors.RED_600) + self.page.snack_bar.open = True + self.page.update() + except Exception: + pass + 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 = new_customer - logging.info(f"新規顧客登録: {new_customer.formal_name}") + 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.update_main_content() else: logging.error("新規顧客登録失敗") + try: + self.page.snack_bar = ft.SnackBar(content=ft.Text("保存に失敗しました"), bgcolor=ft.Colors.RED_600) + self.page.snack_bar.open = True + self.page.update() + except Exception: + pass def cancel(_): self.is_new_customer_form_open = False @@ -1528,8 +1722,9 @@ class FlutterStyleDashboard: ft.IconButton(ft.Icons.ARROW_BACK, on_click=cancel), ft.Text("新規顧客登録", size=18, weight=ft.FontWeight.BOLD), ]), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), bgcolor=ft.Colors.BLUE_GREY, + border_radius=10, ), ft.Container( content=ft.Column([ @@ -1544,11 +1739,18 @@ class FlutterStyleDashboard: phone_field, ft.Container(height=20), ft.Row([ - ft.Button("保存", on_click=save_customer, bgcolor=ft.Colors.BLUE_GREY_800, color=ft.Colors.WHITE), - ft.Button("キャンセル", on_click=cancel), + 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), + padding=ft.Padding.all(20), expand=True, ), ]), @@ -1561,6 +1763,60 @@ class FlutterStyleDashboard: 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() + + 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), + ) + ) + + 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 @@ -1614,15 +1870,27 @@ class FlutterStyleDashboard: logging.info(f"帳票種類を変更: {selected_type.value}") # TODO: 選択された種類を保存 - def select_document_type(self, doc_type: str): + def select_document_type(self, doc_type): """帳票種類選択""" - # DocumentTypeから対応するenumを見つける - for dt in DocumentType: - if dt.value == doc_type: - self.selected_document_type = dt - logging.info(f"帳票種類を選択: {doc_type}") - self.update_main_content() - break + 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): """伝票作成 - サービス層を使用""" diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..8f4efc7 --- /dev/null +++ b/todo.md @@ -0,0 +1,9 @@ +# TODO + +## Open items +- Implement _build_doc_type_bar to render document type chips in AppBar (pending) +- Fix detail screen doc type selector state updates (pending) +- Replace deprecated ft.padding.symmetric usages (pending) + +## Done +-