h-1.flet.3/app_flutter_style_dashboard.py
2026-02-21 23:49:15 +09:00

1261 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Flutter風ダッシュボード
下部ナビゲーションと洗練されたUIコンポーネントを実装
"""
import flet as ft
import signal
import sys
import logging
from datetime import datetime
from typing import List, Dict, Optional
from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem
from components.customer_picker import CustomerPickerModal
from services.app_service import AppService
# ロギング設定
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
class FlutterStyleDashboard:
"""Flutter風の統合ダッシュボード"""
def __init__(self, page: ft.Page):
self.page = page
self.current_tab = 0 # 0: 伝票一覧, 1: 詳細編集
self.selected_customer = None
self.selected_document_type = DocumentType.INVOICE
self.amount_value = "250000"
self.customer_picker = None
self.editing_invoice = None # 編集中の伝票
self.is_edit_mode = False # 編集モードフラグ
self.is_customer_picker_open = False
self.customer_search_query = ""
self.show_offsets = False
self.chain_verify_result = None
self.is_new_customer_form_open = False
# ビジネスロジックサービス
self.app_service = AppService()
self.invoices = []
self.customers = []
self.setup_page()
self.setup_database()
self.setup_ui()
def setup_page(self):
"""ページ設定"""
self.page.title = "販売アシスト1号"
self.page.window.width = 420
self.page.window.height = 900
self.page.theme_mode = ft.ThemeMode.LIGHT
# Fletのライフサイクルに任せるSystemExitがasyncioに伝播して警告になりやすい
def setup_database(self):
"""データ初期化(サービス層経由)"""
try:
# 顧客データ読み込み
self.customers = self.app_service.customer.get_all_customers()
# 伝票データ読み込み
self.invoices = self.app_service.invoice.get_recent_invoices(20)
logging.info(f"データ初期化: 顧客{len(self.customers)}件, 伝票{len(self.invoices)}")
except Exception as e:
logging.error(f"データ初期化エラー: {e}")
def create_sample_data(self):
"""サンプル伝票データ作成"""
try:
# サンプルデータ
sample_invoices = create_sample_invoices()
for invoice in sample_invoices:
self.cursor.execute('''
INSERT OR REPLACE INTO slips
(document_type, customer_name, amount, date, status, description)
VALUES (?, ?, ?, ?, ?, ?)
''', (
invoice.document_type.value,
invoice.customer.formal_name,
invoice.total_amount,
invoice.date.strftime('%Y-%m-%d'),
'完了',
invoice.notes
))
self.conn.commit()
except Exception as e:
logging.error(f"サンプルデータ作成エラー: {e}")
def setup_ui(self):
"""UIセットアップ"""
# 下部ナビゲーションNavigationRail風
bottom_nav = ft.Container(
content=ft.Row([
ft.Container(
content=ft.IconButton(
ft.Icons.ADD_BOX,
selected=self.current_tab == 0,
on_click=lambda _: self.on_tab_change(0),
),
),
ft.Text("新規作成"),
ft.Container(
content=ft.IconButton(
ft.Icons.HISTORY,
selected=self.current_tab == 1,
on_click=lambda _: self.on_tab_change(1),
),
),
ft.Text("発行履歴"),
], alignment=ft.MainAxisAlignment.CENTER),
padding=ft.Padding.all(10),
bgcolor=ft.Colors.BLUE_GREY_50,
)
# メインコンテンツ
self.main_content = ft.Column([], expand=True)
# ページ構成
self.page.add(
ft.Column([
self.main_content,
bottom_nav,
], expand=True)
)
# 初期表示
self.update_main_content()
def on_tab_change(self, index):
"""タブ切り替え"""
self.current_tab = index
self.update_main_content()
self.page.update()
def update_main_content(self):
"""メインコンテンツ更新"""
self.main_content.controls.clear()
if self.current_tab == 0:
if self.is_new_customer_form_open:
self.main_content.controls.append(self.create_new_customer_screen())
elif self.is_customer_picker_open:
self.main_content.controls.append(self.create_customer_picker_screen())
else:
# 伝票一覧画面
self.main_content.controls.append(self.create_slip_list_screen())
else:
# 詳細編集画面
self.main_content.controls.append(self.create_invoice_edit_screen())
self.page.update()
def create_slip_list_screen(self) -> ft.Container:
"""伝票一覧画面"""
# 履歴データ読み込み
slips = self.load_slips()
if not self.show_offsets:
slips = [s for s in slips if not (isinstance(s, Invoice) and getattr(s, "is_offset", False))]
def on_toggle_offsets(e):
self.show_offsets = bool(e.control.value)
self.update_main_content()
def on_verify_chain(e=None):
res = self.app_service.invoice.invoice_repo.verify_chain()
self.chain_verify_result = res
self.update_main_content()
def create_new_slip(_):
"""新規伝票作成"""
self.editing_invoice = None
self.current_tab = 1
self.update_main_content()
# 履歴カードリスト
slip_cards = []
for slip in slips:
card = self.create_slip_card(slip)
slip_cards.append(card)
return ft.Container(
content=ft.Column([
# ヘッダー
ft.Container(
content=ft.Row([
ft.Text("📄 伝票一覧", size=20, weight=ft.FontWeight.BOLD),
ft.Container(expand=True),
ft.IconButton(
ft.Icons.VERIFIED,
tooltip="チェーン検証",
icon_color=ft.Colors.BLUE_300,
on_click=on_verify_chain,
),
ft.Row(
[
ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE),
ft.Switch(value=self.show_offsets, on_change=on_toggle_offsets),
],
spacing=5,
),
ft.IconButton(ft.Icons.CLEAR_ALL, icon_size=20),
]),
padding=ft.padding.all(15),
bgcolor=ft.Colors.BLUE_GREY,
),
# 検証結果表示(あれば)
ft.Container(
content=self._build_chain_verify_result(),
margin=ft.Margin.only(bottom=10),
) if self.chain_verify_result else ft.Container(height=0),
# 伝票リスト
ft.Column(
controls=slip_cards,
spacing=10,
scroll=ft.ScrollMode.AUTO,
expand=True,
),
# 新規作成ボタン
ft.Container(
content=ft.Button(
content=ft.Row([
ft.Icon(ft.Icons.ADD, color=ft.Colors.WHITE),
ft.Text("新規伝票を作成", color=ft.Colors.WHITE),
], alignment=ft.MainAxisAlignment.CENTER),
style=ft.ButtonStyle(
bgcolor=ft.Colors.BLUE_GREY_800,
padding=ft.padding.all(20),
shape=ft.RoundedRectangleBorder(radius=15),
),
width=400,
height=60,
on_click=create_new_slip,
),
padding=ft.padding.all(20),
),
]),
expand=True,
)
def create_customer_picker_screen(self) -> ft.Container:
"""顧客選択画面(画面内遷移・ダイアログ不使用)"""
self.customers = self.app_service.customer.get_all_customers()
def back(_=None):
self.is_customer_picker_open = False
self.customer_search_query = ""
self.update_main_content()
list_container = ft.Column([], spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
def render_list(customers: List[Customer]):
list_container.controls.clear()
for customer in customers:
list_container.controls.append(
ft.ListTile(
title=ft.Text(customer.formal_name, weight=ft.FontWeight.BOLD),
subtitle=ft.Text(f"{customer.address}\n{customer.phone}"),
on_click=lambda _, c=customer: select_customer(c),
)
)
self.page.update()
def select_customer(customer: Customer):
self.selected_customer = customer
logging.info(f"顧客を選択: {customer.formal_name}")
back()
def on_search_change(e):
q = (e.control.value or "").strip().lower()
self.customer_search_query = q
if not q:
render_list(self.customers)
return
filtered = [
c
for c in self.customers
if q in (c.name or "").lower()
or q in (c.formal_name or "").lower()
or q in (c.address or "").lower()
or q in (c.phone or "").lower()
]
render_list(filtered)
search_field = ft.TextField(
label="顧客検索",
prefix_icon=ft.Icons.SEARCH,
value=self.customer_search_query,
on_change=on_search_change,
autofocus=True,
)
header = ft.Container(
content=ft.Row(
[
ft.IconButton(ft.Icons.ARROW_BACK, on_click=back),
ft.Text("顧客を選択", size=18, weight=ft.FontWeight.BOLD),
ft.Container(expand=True),
ft.IconButton(
ft.Icons.PERSON_ADD,
tooltip="新規顧客追加",
icon_color=ft.Colors.WHITE,
on_click=lambda _: self.open_new_customer_form(),
),
]
),
padding=ft.padding.all(15),
bgcolor=ft.Colors.BLUE_GREY,
)
content = ft.Column(
[
header,
ft.Container(content=search_field, padding=ft.padding.all(15)),
ft.Container(content=list_container, padding=ft.padding.symmetric(horizontal=15), expand=True),
],
expand=True,
)
# 初期表示
render_list(self.customers)
return ft.Container(content=content, expand=True)
def create_slip_input_screen(self) -> ft.Container:
"""伝票入力画面"""
# 帳票種類選択(タブ風ボタン)
document_types = list(DocumentType)
type_buttons = ft.Row([
ft.Container(
content=ft.Button(
content=ft.Text(doc_type.value, size=12),
bgcolor=ft.Colors.BLUE_GREY_800 if doc_type == self.selected_document_type else ft.Colors.GREY_300,
color=ft.Colors.WHITE if doc_type == self.selected_document_type else ft.Colors.BLACK,
on_click=lambda _, idx=i, dt=doc_type: self.select_document_type(dt.value),
width=100,
height=45,
),
margin=ft.margin.only(right=5),
) for i, doc_type in enumerate(document_types)
], wrap=True)
return ft.Container(
content=ft.Column([
# ヘッダー
ft.Container(
content=ft.Row([
ft.Text("📋 販売アシスト1号", size=20, weight=ft.FontWeight.BOLD),
ft.Container(expand=True),
ft.IconButton(ft.Icons.SETTINGS, icon_size=20),
]),
padding=ft.padding.all(15),
bgcolor=ft.Colors.BLUE_GREY,
),
# 帳票種類選択
ft.Container(
content=ft.Column([
ft.Text("帳票の種類を選択", size=16, weight=ft.FontWeight.BOLD),
ft.Container(height=10),
type_buttons,
]),
padding=ft.padding.all(20),
),
# 顧客選択
ft.Container(
content=ft.Column([
ft.Text("宛先と基本金額の設定", size=16, weight=ft.FontWeight.BOLD),
ft.Container(height=10),
ft.Row(
[
ft.TextField(
label="取引先名",
value=self.selected_customer.formal_name if self.selected_customer else "未選択",
read_only=True,
border_color=ft.Colors.BLUE_GREY,
expand=True,
),
ft.IconButton(
ft.Icons.PERSON_SEARCH,
tooltip="顧客を選択",
on_click=self.open_customer_picker,
),
],
spacing=8,
),
ft.Container(height=10),
ft.TextField(
label="基本金額 (税抜)",
value=self.amount_value,
keyboard_type=ft.KeyboardType.NUMBER,
on_change=self.on_amount_change,
border_color=ft.Colors.BLUE_GREY,
),
]),
padding=ft.padding.all(20),
),
# 作成ボタン
ft.Container(
content=ft.Button(
content=ft.Row([
ft.Icon(ft.Icons.DESCRIPTION, color=ft.Colors.WHITE),
ft.Text("伝票を作成して詳細編集へ", color=ft.Colors.WHITE),
], alignment=ft.MainAxisAlignment.CENTER),
style=ft.ButtonStyle(
bgcolor=ft.Colors.BLUE_GREY_800,
padding=ft.padding.all(20),
shape=ft.RoundedRectangleBorder(radius=15),
),
width=400,
height=60,
on_click=self.create_slip,
),
padding=ft.padding.all(20),
),
]),
expand=True,
)
def create_slip_history_screen(self) -> ft.Container:
"""伝票履歴画面"""
# 履歴データ読み込み
slips = self.load_slips()
if not self.show_offsets:
slips = [s for s in slips if not (isinstance(s, Invoice) and getattr(s, "is_offset", False))]
def on_toggle_offsets(e):
self.show_offsets = bool(e.control.value)
self.update_main_content()
def on_verify_chain(e=None):
res = self.app_service.invoice.invoice_repo.verify_chain()
self.chain_verify_result = res
self.update_main_content()
# 履歴カードリスト
slip_cards = []
for slip in slips:
card = self.create_slip_card(slip)
slip_cards.append(card)
return ft.Container(
content=ft.Column([
# ヘッダー
ft.Container(
content=ft.Row([
ft.Text("📄 発行履歴管理", size=20, weight=ft.FontWeight.BOLD),
ft.Container(expand=True),
ft.IconButton(
ft.Icons.VERIFIED,
tooltip="チェーン検証",
icon_color=ft.Colors.BLUE_300,
on_click=on_verify_chain,
),
ft.Row(
[
ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE),
ft.Switch(value=self.show_offsets, on_change=on_toggle_offsets),
],
spacing=5,
),
ft.IconButton(ft.Icons.CLEAR_ALL, icon_size=20),
]),
padding=ft.padding.all(15),
bgcolor=ft.Colors.BLUE_GREY,
),
# 検証結果表示(あれば)
ft.Container(
content=self._build_chain_verify_result(),
margin=ft.Margin.only(bottom=10),
) if self.chain_verify_result else ft.Container(height=0),
# 履歴リスト
ft.Column(
controls=slip_cards,
spacing=10,
scroll=ft.ScrollMode.AUTO,
expand=True,
),
]),
expand=True,
)
def create_slip_card(self, slip) -> ft.Card:
"""伝票カード作成"""
# サービス層からは Invoice オブジェクトが返る
if isinstance(slip, Invoice):
slip_type = slip.document_type.value
customer_name = slip.customer.formal_name
amount = slip.total_amount
date = slip.date.strftime("%Y-%m-%d")
status = "赤伝" if getattr(slip, "is_offset", False) else "完了"
else:
slip_id, slip_type, customer_name, amount, date, status, description, created_at = slip
# タイプに応じたアイコンと色
type_config = {
"売上伝票": {"icon": "💰", "color": ft.Colors.GREEN},
"見積書": {"icon": "📄", "color": ft.Colors.BLUE},
"納品書": {"icon": "📦", "color": ft.Colors.PURPLE},
"請求書": {"icon": "📋", "color": ft.Colors.ORANGE},
"領収書": {"icon": "🧾", "color": ft.Colors.RED}
}
config = type_config.get(slip_type, {"icon": "📝", "color": ft.Colors.GREY})
def issue_offset(_=None):
if not isinstance(slip, Invoice):
return
if getattr(slip, "is_offset", False):
return
offset_invoice = self.app_service.invoice.create_offset_invoice(slip.uuid)
if offset_invoice and offset_invoice.file_path:
self.app_service.invoice.delete_pdf_file(offset_invoice.file_path)
# リストを更新
self.invoices = self.app_service.invoice.get_recent_invoices(20)
self.update_main_content()
def regenerate_and_share(_=None):
if not isinstance(slip, Invoice):
return
pdf_path = self.app_service.invoice.regenerate_pdf(slip.uuid)
if pdf_path:
# TODO: OSの共有ダイアログを呼ぶプラットフォーム依存
logging.info(f"PDF再生成: {pdf_path}(共有機能は未実装)")
# 共有後に削除(今は即削除)
self.app_service.invoice.delete_pdf_file(pdf_path)
logging.info(f"PDF削除完了: {pdf_path}")
else:
logging.error("PDF再生成失敗")
def edit_invoice(_=None):
if not isinstance(slip, Invoice):
return
self.open_invoice_edit(slip)
actions_row = None
if isinstance(slip, Invoice):
buttons = []
if not getattr(slip, "submitted_to_tax_authority", False):
buttons.append(
ft.ElevatedButton(
content=ft.Text("編集", color=ft.Colors.WHITE),
style=ft.ButtonStyle(
bgcolor=ft.Colors.GREEN_600,
padding=ft.padding.all(10),
),
on_click=edit_invoice,
width=80,
height=40,
)
)
buttons.append(
ft.ElevatedButton(
content=ft.Text("赤伝", color=ft.Colors.WHITE),
style=ft.ButtonStyle(
bgcolor=ft.Colors.RED_600,
padding=ft.padding.all(10),
),
on_click=issue_offset,
width=80,
height=40,
)
)
buttons.append(
ft.ElevatedButton(
content=ft.Text("提出", color=ft.Colors.WHITE),
style=ft.ButtonStyle(
bgcolor=ft.Colors.ORANGE_600,
padding=ft.padding.all(10),
),
on_click=lambda _: self.submit_invoice_for_tax(slip.uuid),
width=80,
height=40,
)
)
buttons.append(
ft.IconButton(
ft.Icons.DOWNLOAD,
tooltip="PDF再生成→共有",
icon_color=ft.Colors.BLUE_400,
on_click=regenerate_and_share,
)
)
actions_row = ft.Row(buttons, alignment=ft.MainAxisAlignment.CENTER, spacing=10)
display_amount = amount
if isinstance(slip, Invoice) and getattr(slip, "is_offset", False):
display_amount = -abs(amount)
return ft.Card(
content=ft.Container(
content=ft.Column([
ft.Row([
ft.Container(
content=ft.Text(config["icon"], size=24),
width=40,
height=40,
bgcolor=config["color"],
border_radius=20,
alignment=ft.alignment.Alignment(0, 0),
),
ft.Container(
content=ft.Column([
ft.Text(slip_type, size=12, weight=ft.FontWeight.BOLD),
ft.Text(f"{customer_name} ¥{display_amount:,.0f}", size=10),
]),
expand=True,
),
]),
ft.Container(height=5),
ft.Text(f"日付: {date}", size=10, color=ft.Colors.GREY_600),
ft.Text(f"状態: {status}", size=10, color=ft.Colors.GREY_600),
actions_row if actions_row else ft.Container(height=0),
]),
padding=ft.padding.all(15),
),
elevation=3,
)
def _build_chain_verify_result(self) -> ft.Control:
if not self.chain_verify_result:
return ft.Container(height=0)
r = self.chain_verify_result
ok = r.get("ok", False)
checked = r.get("checked", 0)
errors = r.get("errors", [])
if ok:
return ft.Container(
content=ft.Row([
ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN, size=20),
ft.Text(f"チェーン検証 OK ({checked}件)", size=14, color=ft.Colors.GREEN),
]),
bgcolor=ft.Colors.GREEN_50,
padding=ft.Padding.all(10),
border_radius=8,
)
else:
return ft.Container(
content=ft.Column([
ft.Row([
ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED, size=20),
ft.Text(f"チェーン検証 NG (checked={checked})", size=14, color=ft.Colors.RED),
]),
ft.Text(f"エラー: {errors}", size=12, color=ft.Colors.RED_700),
]),
bgcolor=ft.Colors.RED_50,
padding=ft.Padding.all(10),
border_radius=8,
)
def open_invoice_edit(self, invoice: Invoice):
"""伝票編集画面を開く"""
self.editing_invoice = invoice
self.is_edit_mode = True
self.selected_customer = invoice.customer
self.selected_document_type = invoice.document_type
self.amount_value = str(invoice.items[0].unit_price if invoice.items else "0")
self.is_detail_edit_mode = False # 初期はビューモード
self.current_tab = 1 # 詳細編集タブに切り替え
self.update_main_content()
def open_new_customer_form(self):
"""新規顧客フォームを開く(画面内遷移)"""
self.is_new_customer_form_open = True
self.update_main_content()
def create_invoice_edit_screen(self) -> ft.Container:
"""伝票編集画面"""
if self.editing_invoice:
# 既存伝票の編集
return self._create_edit_existing_screen()
else:
# 新規伝票作成
return self.create_slip_input_screen()
def _create_edit_existing_screen(self) -> ft.Container:
"""既存伝票の編集画面"""
# 編集不可チェック
is_locked = getattr(self.editing_invoice, 'submitted_to_tax_authority', False)
is_view_mode = not getattr(self, 'is_detail_edit_mode', False)
# 伝票種類選択(ヘッダーに移動)
document_types = list(DocumentType)
# 明細テーブル
items_table = self._create_items_table(self.editing_invoice.items, is_locked or is_view_mode)
# 顧客表示
customer_display = ft.Container(
content=ft.Row([
ft.Text("顧客: ", weight=ft.FontWeight.BOLD),
ft.Text(self.editing_invoice.customer.name), # 顧客名を表示
]),
padding=ft.padding.symmetric(horizontal=10, vertical=5),
bgcolor=ft.Colors.GREY_100,
border_radius=5,
)
# 備考フィールド
notes_field = ft.TextField(
label="備考",
value=getattr(self.editing_invoice, 'notes', ''),
disabled=is_locked or is_view_mode,
multiline=True,
min_lines=2,
max_lines=3,
)
def toggle_edit_mode(_):
"""編集モード切替"""
old_mode = getattr(self, 'is_detail_edit_mode', False)
self.is_detail_edit_mode = not old_mode
logging.debug(f"Toggle edit mode: {old_mode} -> {self.is_detail_edit_mode}")
self.update_main_content()
def save_changes(_):
if is_locked:
return
# 伝票を更新(現在の明細を保持)
# TODO: テーブルから実際の値を取得して更新
# 現在は既存の明細を維持し、備考のみ更新
self.editing_invoice.notes = notes_field.value
self.editing_invoice.document_type = self.selected_document_type
# TODO: ハッシュ再計算と保存
logging.info(f"伝票更新: {self.editing_invoice.invoice_number}")
# 編集モード終了(ビューモードに戻る)
self.is_detail_edit_mode = False # ビューモードに戻る
self.update_main_content()
def cancel_edit(_):
self.is_detail_edit_mode = False
self.is_edit_mode = False
self.editing_invoice = None
self.current_tab = 0 # 一覧タブに戻る
self.update_main_content()
return ft.Container(
content=ft.Column([
# ヘッダー
ft.Container(
content=ft.Row([
ft.Container(expand=True),
# コンパクトな伝票種類選択(セグメント化)
ft.Container(
content=ft.Row([
ft.GestureDetector(
content=ft.Container(
content=ft.Text(
doc_type.value,
size=10,
color=ft.Colors.WHITE if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_600,
weight=ft.FontWeight.BOLD if doc_type == self.editing_invoice.document_type else ft.FontWeight.NORMAL,
),
padding=ft.padding.symmetric(horizontal=8, vertical=4),
bgcolor=ft.Colors.BLUE_600 if doc_type == self.editing_invoice.document_type else ft.Colors.GREY_300,
border_radius=ft.border_radius.all(4),
margin=ft.margin.only(right=1),
),
on_tap=lambda _, dt=doc_type: self.select_document_type(dt.value) if not is_locked and not is_view_mode else None,
) for doc_type in document_types
]),
padding=ft.padding.all(2),
bgcolor=ft.Colors.GREY_200,
border_radius=ft.border_radius.all(6),
margin=ft.margin.only(right=10),
),
ft.ElevatedButton(
content=ft.Text("編集" if is_view_mode else "保存"),
style=ft.ButtonStyle(
bgcolor=ft.Colors.BLUE_600 if is_view_mode else ft.Colors.GREEN_600,
),
on_click=toggle_edit_mode if is_view_mode else save_changes,
disabled=is_locked,
width=70,
height=30,
) if not is_locked else ft.Container(),
ft.Container(width=5),
ft.IconButton(ft.Icons.CLOSE, on_click=cancel_edit),
]),
padding=ft.padding.symmetric(horizontal=15, vertical=8),
bgcolor=ft.Colors.BLUE_GREY,
),
# 基本情報行(コンパクトに)
ft.Container(
content=ft.Row([
ft.Text(f"{self.editing_invoice.invoice_number} | {self.editing_invoice.date.strftime('%Y/%m/%d')} | {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,
),
# 明細テーブル(フレキシブルに)
ft.Container(
content=ft.Column([
ft.Row([
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(),
]),
ft.Container(height=3),
ft.Container(
content=items_table,
height=300, # 高さを縮小
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.Container(expand=True),
ft.Text("合計: ", size=14, weight=ft.FontWeight.BOLD),
ft.Text(
f"¥{sum(item.subtotal for item in self.editing_invoice.items):,}",
size=16,
weight=ft.FontWeight.BOLD,
color=ft.Colors.BLUE_600
),
]),
padding=ft.padding.symmetric(horizontal=10, vertical=8),
bgcolor=ft.Colors.GREY_100,
border_radius=5,
),
]),
padding=ft.padding.all(15),
expand=True, # 明細部分が最大限のスペースを占有
),
# 備考(コンパクト)
ft.Container(
content=ft.Column([
ft.Text("備考", size=12, weight=ft.FontWeight.BOLD),
ft.Container(height=3),
notes_field,
ft.Container(height=5),
ft.Text("🔒 税務署提出済みは編集できません" if is_locked else "" + ("編集モード" if not is_view_mode else "ビューモード"),
size=11, color=ft.Colors.RED_600 if is_locked else (ft.Colors.GREEN_600 if not is_view_mode else ft.Colors.BLUE_600)),
ft.Container(height=10),
# PDF生成ボタンを追加
ft.ElevatedButton(
content=ft.Row([
ft.Icon(ft.Icons.DOWNLOAD, size=16),
ft.Container(width=5),
ft.Text("PDF生成", size=12, color=ft.Colors.WHITE),
]),
style=ft.ButtonStyle(
bgcolor=ft.Colors.BLUE_600,
padding=ft.padding.symmetric(horizontal=15, vertical=10),
),
on_click=lambda _: self.generate_pdf_from_edit(),
width=120,
height=40,
) if not is_locked else ft.Container(),
]),
padding=ft.padding.symmetric(horizontal=15, vertical=10),
bgcolor=ft.Colors.GREY_50,
),
]),
expand=True,
)
def generate_pdf_from_edit(self):
"""編集画面からPDFを生成"""
if not self.editing_invoice:
return
try:
pdf_path = self.app_service.invoice.regenerate_pdf(self.editing_invoice.uuid)
if pdf_path:
self.editing_invoice.file_path = pdf_path
self.editing_invoice.pdf_generated_at = datetime.now().replace(microsecond=0).isoformat()
logging.info(f"PDF生成完了: {pdf_path}")
# TODO: 成功メッセージ表示
else:
logging.error("PDF生成失敗")
# TODO: エラーメッセージ表示
except Exception as e:
logging.error(f"PDF生成エラー: {e}")
# TODO: エラーメッセージ表示
def _add_item_row(self):
"""明細行を追加"""
if not self.editing_invoice:
return
# 新しい明細行を追加
new_item = InvoiceItem(
description="新しい商品",
quantity=1,
unit_price=0,
is_discount=False
)
self.editing_invoice.items.append(new_item)
self.update_main_content()
def _delete_item_row(self, index: int):
"""明細行を削除"""
if not self.editing_invoice or index >= len(self.editing_invoice.items):
return
# 行を削除最低1行は残す
if len(self.editing_invoice.items) > 1:
del self.editing_invoice.items[index]
self.update_main_content()
def _update_item_field(self, item_index: int, field_name: str, value: str):
"""明細フィールドを更新"""
if not self.editing_invoice or item_index >= len(self.editing_invoice.items):
return
item = self.editing_invoice.items[item_index]
# デバッグ用:更新前の値をログ出力
old_value = getattr(item, field_name)
logging.debug(f"Updating item {item_index} {field_name}: '{old_value}' -> '{value}'")
if field_name == 'description':
item.description = value
elif field_name == 'quantity':
try:
# 空文字の場合は1を設定
if not value or value.strip() == '':
item.quantity = 1
else:
item.quantity = int(value)
logging.debug(f"Quantity updated to: {item.quantity}")
except ValueError as e:
item.quantity = 1
logging.error(f"Quantity update error: {e}")
elif field_name == 'unit_price':
try:
# 空文字の場合は0を設定
if not value or value.strip() == '':
item.unit_price = 0
else:
item.unit_price = int(value)
logging.debug(f"Unit price updated to: {item.unit_price}")
except ValueError as e:
item.unit_price = 0
logging.error(f"Unit price update error: {e}")
# 合計金額を更新するために画面を更新
self.update_main_content()
def _create_items_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column:
"""明細テーブルを作成(編集モードと表示モードで別の見せ方)"""
# ビューモード状態を取得
is_view_mode = not getattr(self, 'is_detail_edit_mode', False)
# デバッグ用:メソッド開始時の状態をログ出力
logging.debug(f"Creating items table: locked={is_locked}, view_mode={is_view_mode}, edit_mode={getattr(self, 'is_detail_edit_mode', False)}")
logging.debug(f"Items count: {len(items)}")
if is_view_mode or is_locked:
# 表示モード:表形式で整然と表示
return self._create_view_mode_table(items)
else:
# 編集モード:入力フォーム風に表示
return self._create_edit_mode_table(items, is_locked)
def _create_view_mode_table(self, items: List[InvoiceItem]) -> ft.Column:
"""表示モード:フレキシブルな表形式で整然と表示"""
# ヘッダー行
header_row = ft.Row([
ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), # 可変幅
ft.Text("", size=12, weight=ft.FontWeight.BOLD, width=35), # 固定幅
ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅
ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅
ft.Container(width=35), # 削除ボタン用スペースを確保
])
# データ行
data_rows = []
for i, item in enumerate(items):
row = ft.Row([
ft.Text(item.description, size=12, expand=True), # 可変幅
ft.Text(str(item.quantity), size=12, width=35, text_align=ft.TextAlign.RIGHT), # 固定幅
ft.Text(f"¥{item.unit_price:,}", size=12, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅
ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅
ft.Container(width=35), # 削除ボタン用スペース
])
data_rows.append(row)
return ft.Column([
header_row,
ft.Divider(height=1, color=ft.Colors.GREY_400),
ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限
])
def _create_edit_mode_table(self, items: List[InvoiceItem], is_locked: bool) -> ft.Column:
"""編集モード:フレキシブルな表形式"""
# 編集モードに入ったら自動で空行を追加
if not any(item.description == "" and item.quantity == 0 and item.unit_price == 0 for item in items):
items.append(InvoiceItem(description="", quantity=0, unit_price=0))
# ヘッダー行
header_row = ft.Row([
ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True), # 可変幅
ft.Text("", size=12, weight=ft.FontWeight.BOLD, width=35), # 固定幅
ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅
ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70), # 固定幅
ft.Container(width=35), # 削除ボタン用スペースを確保
])
# データ行
data_rows = []
for i, item in enumerate(items):
# 商品名フィールド
product_field = ft.TextField(
value=item.description,
text_size=12,
height=28,
width=None, # 幅を可変に
expand=True, # 可変幅
border=ft.border.all(1, ft.Colors.BLUE_200),
bgcolor=ft.Colors.WHITE,
content_padding=ft.padding.all(5), # 内部余白を最小化
on_change=lambda e: self._update_item_field(i, 'description', e.control.value),
)
# 数量フィールド
quantity_field = ft.TextField(
value=str(item.quantity),
text_size=12,
height=28,
width=35, # 固定幅
text_align=ft.TextAlign.RIGHT,
border=ft.border.all(1, ft.Colors.BLUE_200),
bgcolor=ft.Colors.WHITE,
content_padding=ft.padding.all(5), # 内部余白を最小化
on_change=lambda e: self._update_item_field(i, 'quantity', e.control.value),
keyboard_type=ft.KeyboardType.NUMBER,
)
# 単価フィールド
unit_price_field = ft.TextField(
value=f"{item.unit_price:,}",
text_size=12,
height=28,
width=70, # 固定幅
text_align=ft.TextAlign.RIGHT,
border=ft.border.all(1, ft.Colors.BLUE_200),
bgcolor=ft.Colors.WHITE,
content_padding=ft.padding.all(5), # 内部余白を最小化
on_change=lambda e: self._update_item_field(i, 'unit_price', e.control.value.replace(',', '')),
keyboard_type=ft.KeyboardType.NUMBER,
)
# 削除ボタン
delete_button = ft.IconButton(
ft.Icons.DELETE_OUTLINE,
tooltip="行を削除",
icon_color=ft.Colors.RED_600,
disabled=is_locked,
icon_size=16,
on_click=lambda _, idx=i: self._delete_item_row(idx),
)
# データ行
row = ft.Row([
product_field,
quantity_field,
unit_price_field,
ft.Text(f"¥{item.subtotal:,}", size=12, weight=ft.FontWeight.BOLD, width=70, text_align=ft.TextAlign.RIGHT), # 固定幅
delete_button,
])
data_rows.append(row)
return ft.Column([
header_row,
ft.Divider(height=1, color=ft.Colors.GREY_400),
ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250), # 高さを制限
])
def create_new_customer_screen(self) -> ft.Container:
"""新規顧客登録画面"""
name_field = ft.TextField(label="顧客名(略称)")
formal_name_field = ft.TextField(label="正式名称")
address_field = ft.TextField(label="住所")
phone_field = ft.TextField(label="電話番号")
def save_customer(_):
name = (name_field.value or "").strip()
formal_name = (formal_name_field.value or "").strip()
address = (address_field.value or "").strip()
phone = (phone_field.value or "").strip()
if not name or not formal_name:
# TODO: エラー表示
return
new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone)
if new_customer:
self.customers = self.app_service.customer.get_all_customers()
self.selected_customer = new_customer
logging.info(f"新規顧客登録: {new_customer.formal_name}")
self.is_customer_picker_open = False
self.is_new_customer_form_open = False
self.update_main_content()
else:
logging.error("新規顧客登録失敗")
def cancel(_):
self.is_new_customer_form_open = False
self.update_main_content()
return ft.Container(
content=ft.Column([
ft.Container(
content=ft.Row([
ft.IconButton(ft.Icons.ARROW_BACK, on_click=cancel),
ft.Text("新規顧客登録", size=18, weight=ft.FontWeight.BOLD),
]),
padding=ft.padding.all(15),
bgcolor=ft.Colors.BLUE_GREY,
),
ft.Container(
content=ft.Column([
ft.Text("顧客情報を入力", size=16, weight=ft.FontWeight.BOLD),
ft.Container(height=10),
name_field,
ft.Container(height=10),
formal_name_field,
ft.Container(height=10),
address_field,
ft.Container(height=10),
phone_field,
ft.Container(height=20),
ft.Row([
ft.Button("保存", on_click=save_customer, bgcolor=ft.Colors.BLUE_GREY_800, color=ft.Colors.WHITE),
ft.Button("キャンセル", on_click=cancel),
], spacing=10),
]),
padding=ft.padding.all(20),
expand=True,
),
]),
expand=True,
)
def open_customer_picker(self, e=None):
"""顧客選択を開く(画面内遷移)"""
logging.info("顧客選択画面へ遷移")
self.is_customer_picker_open = True
self.update_main_content()
def on_customer_selected(self, customer: Customer):
"""顧客選択時の処理"""
self.selected_customer = customer
logging.info(f"顧客を選択: {customer.formal_name}")
# UIを更新して選択された顧客を表示
self.update_main_content()
def submit_invoice_for_tax(self, invoice_uuid: str) -> bool:
"""税務署提出済みフラグを設定"""
success = self.app_service.invoice.submit_to_tax_authority(invoice_uuid)
if success:
self.invoices = self.app_service.invoice.get_recent_invoices(20)
self.update_main_content()
logging.info(f"税務署提出済み: {invoice_uuid}")
else:
logging.error(f"税務署提出失敗: {invoice_uuid}")
return success
def on_customer_deleted(self, customer: Customer):
"""顧客削除時の処理"""
self.app_service.customer.delete_customer(customer.id)
self.customers = self.app_service.customer.get_all_customers()
logging.info(f"顧客を削除: {customer.formal_name}")
# モーダルを再表示してリストを更新
if self.customer_picker and self.customer_picker.is_open:
self.customer_picker.update_customer_list(self.customers)
def on_amount_change(self, e):
"""金額変更時の処理"""
self.amount_value = e.control.value
logging.info(f"金額を変更: {self.amount_value}")
def on_document_type_change(self, index):
"""帳票種類変更"""
document_types = list(DocumentType)
selected_type = document_types[index]
logging.info(f"帳票種類を変更: {selected_type.value}")
# TODO: 選択された種類を保存
def select_document_type(self, doc_type: str):
"""帳票種類選択"""
# DocumentTypeから対応するenumを見つける
for dt in DocumentType:
if dt.value == doc_type:
self.selected_document_type = dt
logging.info(f"帳票種類を選択: {doc_type}")
self.update_main_content()
break
def create_slip(self, e=None):
"""伝票作成 - サービス層を使用"""
if not self.selected_customer:
logging.warning("顧客が選択されていません")
return
try:
amount = int(self.amount_value) if self.amount_value else 250000
except ValueError:
amount = 250000
logging.info(f"伝票を作成: {self.selected_document_type.value}, {self.selected_customer.formal_name}, ¥{amount:,}")
# サービス層経由で伝票作成
invoice = self.app_service.invoice.create_invoice(
customer=self.selected_customer,
document_type=self.selected_document_type,
amount=amount,
notes=""
)
if invoice:
if invoice.file_path:
self.app_service.invoice.delete_pdf_file(invoice.file_path)
invoice.file_path = None
logging.info(f"伝票作成成功: {invoice.invoice_number}")
# リストを更新
self.invoices = self.app_service.invoice.get_recent_invoices(20)
# 発行履歴タブに切り替え
self.on_tab_change(1)
else:
logging.error("伝票作成失敗")
def load_slips(self) -> List[Invoice]:
"""伝票データ読み込み - サービス層経由"""
return self.app_service.invoice.get_recent_invoices(20)
def main(page: ft.Page):
"""メイン関数"""
app = FlutterStyleDashboard(page)
page.update()
if __name__ == "__main__":
ft.app(target=main)