diff --git a/components/editor_framework.py b/components/editor_framework.py index e2afd04..c215c59 100644 --- a/components/editor_framework.py +++ b/components/editor_framework.py @@ -110,6 +110,7 @@ def build_invoice_items_edit_table( on_delete_row: Callable[[int], None], products: List[Product], on_product_select: Callable[[int], None] | None = None, + row_refs: Optional[dict] = None, ) -> ft.Column: """編集モードの明細テーブル。""" header_row = ft.Row( @@ -173,19 +174,29 @@ def build_invoice_items_edit_table( on_click=lambda _, idx=i: on_delete_row(idx), ) + subtotal_text = ft.Text( + f"¥{item.subtotal:,}", + size=12, + weight=ft.FontWeight.BOLD, + width=70, + text_align=ft.TextAlign.RIGHT, + ) + + if row_refs is not None: + row_refs[i] = { + "product": product_field, + "quantity": quantity_field, + "unit_price": unit_price_field, + "subtotal": subtotal_text, + } + data_rows.append( ft.Row( [ product_field, quantity_field, unit_price_field, - ft.Text( - f"¥{item.subtotal:,}", - size=12, - weight=ft.FontWeight.BOLD, - width=70, - text_align=ft.TextAlign.RIGHT, - ), + subtotal_text, delete_button, ], key=f"row-{i}-{item.description}", diff --git a/main.py b/main.py index c2fb57c..c575d58 100644 --- a/main.py +++ b/main.py @@ -247,11 +247,13 @@ class FlutterStyleDashboard: DocumentType.SALES.value: "#42A5F5", DocumentType.DRAFT.value: "#90A4AE", } - + # ビジネスロジックサービス self.app_service = AppService() self.invoices = [] self.customers = [] + self._item_row_refs: Dict[int, Dict[str, ft.Control]] = {} + self._total_amount_text: Optional[ft.Text] = None self.setup_page() self.setup_database() @@ -313,7 +315,8 @@ class FlutterStyleDashboard: document_type=invoice.document_type, amount=invoice.total_amount, notes=invoice.notes, - items=invoice.items + items=invoice.items, + is_draft=invoice.is_draft, ) logging.info(f"サンプルデータ作成完了: {len(sample_invoices)}件") @@ -509,6 +512,14 @@ class FlutterStyleDashboard: 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, @@ -518,7 +529,7 @@ class FlutterStyleDashboard: 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=([draft_badge] + ([doc_type_menu] if doc_type_menu else [])) if is_draft else ([doc_type_menu] if doc_type_menu else []), + trailing_controls=trailing_controls, title_control=title_ctrl, ) @@ -864,7 +875,7 @@ class FlutterStyleDashboard: logging.warning(f"差分判定失敗: {e}") return True - def create_slip_card(self, slip) -> ft.Container: + def create_slip_card(self, slip, interactive: bool = True) -> ft.Control: """伝票カード作成(コンパクト表示)""" theme = self.invoice_card_theme palette = self.doc_type_palette @@ -928,7 +939,24 @@ class FlutterStyleDashboard: bgcolor=theme["tag_bg"], border_radius=10, ), - ft.Text(f"No: {invoice_number}", size=10, color=theme["subtitle_color"]), + 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, ), @@ -994,21 +1022,6 @@ class FlutterStyleDashboard: status_chip = ft.Text("✓ LOCK", size=9, color=theme["tag_text_color"]) if final_locked else ft.Container() - if is_draft_card: - status_chip = ft.Row( - [ - ft.Container( - content=ft.Text("DRAFT", size=9, weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE), - padding=ft.Padding.symmetric(horizontal=6, vertical=2), - bgcolor=theme["draft_badge_bg"], - border_radius=999, - ), - status_chip or ft.Container(), - ], - spacing=4, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - 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 = [ @@ -1029,6 +1042,9 @@ class FlutterStyleDashboard: shadow=card_shadow, ) + if not interactive: + return ft.Container(content=card_body) + return ft.GestureDetector( content=card_body, on_tap=on_single_tap, @@ -1242,6 +1258,7 @@ class FlutterStyleDashboard: # 編集不可チェック(新規作成時は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を許可) @@ -1273,39 +1290,36 @@ class FlutterStyleDashboard: """顧客選択画面を開く""" 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, - bgcolor=edit_bg, + def set_doc_type(dt: DocumentType): + if self.is_detail_edit_mode and not is_locked: + self.select_document_type(dt) + + 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 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 + def toggle_draft(e): + self.editing_invoice.is_draft = bool(e.control.value) + self.update_main_content() - 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 + if self.is_detail_edit_mode and not is_locked: + doc_type_menu = ft.PopupMenuButton( + content=ft.Text( + self.selected_document_type.value, + size=12, + weight=ft.FontWeight.BOLD, + color=ft.Colors.WHITE, + ), + items=doc_type_items, + tooltip="帳票タイプ変更", + ) + else: + doc_type_menu = None # 日付・時間ピッカー(テキスト入力NGなのでボタン+ポップアップ) date_button = None @@ -1425,6 +1439,8 @@ class FlutterStyleDashboard: 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で更新された明細を保存前に正規化して確定 @@ -1477,7 +1493,8 @@ class FlutterStyleDashboard: document_type=self.editing_invoice.document_type, amount=amount, notes=getattr(self.editing_invoice, 'notes', ''), - items=self.editing_invoice.items # UIの明細を渡す + items=self.editing_invoice.items, + is_draft=bool(getattr(self.editing_invoice, 'is_draft', False)), ) logging.info(f"create_invoice戻り値: {success}") if success: @@ -1581,62 +1598,126 @@ class FlutterStyleDashboard: 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), + width=220, + height=36, + on_click=lambda _: select_customer(), + style=ft.ButtonStyle( + padding=ft.Padding.symmetric(horizontal=10, vertical=6), + bgcolor=ft.Colors.BLUE_GREY_100, + shape=ft.RoundedRectangleBorder(radius=6), + ), + ) + else: + customer_control = ft.Text( + customer_label, + size=13, + weight=ft.FontWeight.BOLD, + ) + + if self.is_detail_edit_mode and not is_locked: + doc_type_control = ft.PopupMenuButton( + content=ft.Text( + self.selected_document_type.value, + size=12, + weight=ft.FontWeight.BOLD, + color=ft.Colors.WHITE, + ), + items=doc_type_items, + tooltip="帳票タイプ変更", + ) + else: + doc_type_control = ft.Text( + self.selected_document_type.value, + size=12, + weight=ft.FontWeight.BOLD, + color=ft.Colors.WHITE, + ) + + draft_control: ft.Control + 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() + summary_card = ft.Container( content=ft.Column( [ ft.Row( [ - customer_field if customer_field else ft.Text( - self.editing_invoice.customer.formal_name, - size=13, - weight=ft.FontWeight.BOLD, + ft.Row( + [ + ft.Container( + content=doc_type_control if self.is_detail_edit_mode and not is_locked else ft.Text( + self.selected_document_type.value, + size=9, + weight=ft.FontWeight.BOLD, + color=self.invoice_card_theme["tag_text_color"], + ), + padding=ft.Padding.symmetric(horizontal=8, vertical=2), + bgcolor=self.invoice_card_theme["tag_bg"], + border_radius=10, + ), + ft.Text( + f"No: {self.editing_invoice.invoice_number}", + size=10, + color=self.invoice_card_theme["subtitle_color"], + ), + ], + spacing=6, ), - ft.Row([ - ft.Button( - content=ft.Text("顧客選択", size=12), - on_click=lambda _: select_customer(), - disabled=is_locked or is_view_mode, - ) if not is_view_mode and not is_locked else ft.Container(), - ft.Text( - f"¥{self.editing_invoice.total_amount:,} (税込)", - size=15, - weight=ft.FontWeight.BOLD, - color=ft.Colors.BLUE_700, - ), - ], spacing=8, alignment=ft.MainAxisAlignment.END), + draft_control, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + ft.Row( + [ + customer_control, + self._build_total_amount_text(), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, ), ft.Row( [ - ft.Text( - self.editing_invoice.invoice_number, + date_button if date_button else ft.Text( + self.editing_invoice.date.strftime("%Y/%m/%d"), size=12, - color=ft.Colors.BLUE_GREY_500, + 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, ), - 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, + spacing=12, ), summary_badges, ], - spacing=6, + spacing=8, ), - padding=ft.Padding.all(14), - bgcolor=ft.Colors.BLUE_GREY_50, - border_radius=10, + 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( @@ -1666,7 +1747,7 @@ class FlutterStyleDashboard: spacing=6, ), padding=ft.Padding.all(12), - bgcolor=ft.Colors.WHITE, + bgcolor=ft.Colors.BROWN_50 if is_draft else ft.Colors.WHITE, border_radius=10, ) @@ -1703,7 +1784,6 @@ class FlutterStyleDashboard: lower_scroll = ft.Container( content=ft.Column( [ - summary_card, notes_section, ], spacing=12, @@ -1715,6 +1795,7 @@ class FlutterStyleDashboard: top_stack = ft.Column( [ + summary_card, items_section, ], spacing=12, @@ -1778,16 +1859,16 @@ class FlutterStyleDashboard: del self.editing_invoice.items[index] self.update_main_content() - def _update_item_field(self, item_index: int, field_name: str, value: str): + def _update_item_field(self, index: int, field_name: str, value: str): """明細フィールドを更新""" - if not self.editing_invoice or item_index >= len(self.editing_invoice.items): + if not self.editing_invoice or index >= len(self.editing_invoice.items): return - item = self.editing_invoice.items[item_index] + item = self.editing_invoice.items[index] # デバッグ用:更新前の値をログ出力 old_value = getattr(item, field_name) - logging.debug(f"Updating item {item_index} {field_name}: '{old_value}' -> '{value}'") + logging.debug(f"Updating item {index} {field_name}: '{old_value}' -> '{value}'") if field_name == 'description': item.description = value @@ -1814,8 +1895,9 @@ class FlutterStyleDashboard: 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]): """商品選択ドロップダウンから呼ばれ、商品情報を行に反映""" @@ -1832,8 +1914,11 @@ class FlutterStyleDashboard: 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.update_main_content() + self.page.update() except Exception as e: logging.warning(f"商品選択反映失敗: {e}") @@ -1882,6 +1967,7 @@ class FlutterStyleDashboard: 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(), ) def _open_product_picker_for_row(self, item_index: int): @@ -1913,9 +1999,12 @@ class FlutterStyleDashboard: 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}") @@ -1938,6 +2027,108 @@ class FlutterStyleDashboard: 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_total_amount_text(self) -> ft.Text: + total = self.editing_invoice.total_amount if self.editing_invoice else 0 + self._total_amount_text = ft.Text( + f"¥{total:,} (税込)", + size=15, + weight=ft.FontWeight.BOLD, + color=ft.Colors.BLUE_700, + ) + return self._total_amount_text + + def _refresh_total_amount_display(self): + if not self._total_amount_text: + return + total = self.editing_invoice.total_amount if self.editing_invoice else 0 + 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}") @@ -2399,7 +2590,8 @@ class FlutterStyleDashboard: customer=self.selected_customer, document_type=self.selected_document_type, amount=amount, - notes="" + notes="", + is_draft=bool(getattr(self, "editing_invoice", None) and getattr(self.editing_invoice, "is_draft", False)), ) if invoice: diff --git a/services/app_service.py b/services/app_service.py index fa66764..c9b08f1 100644 --- a/services/app_service.py +++ b/services/app_service.py @@ -134,7 +134,8 @@ class InvoiceService: document_type: DocumentType, amount: int, notes: str = "", - items: List[InvoiceItem] = None) -> Optional[Invoice]: + items: List[InvoiceItem] = None, + is_draft: bool = False) -> Optional[Invoice]: """新規伝票作成 Args: @@ -171,7 +172,8 @@ class InvoiceService: date=datetime.now(), items=items, document_type=document_type, - notes=notes + notes=notes, + is_draft=is_draft ) # --- 長期保管向け: canonical payload + hash chain ---