547 lines
21 KiB
Python
547 lines
21 KiB
Python
"""
|
||
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
|
||
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.is_customer_picker_open = False
|
||
self.customer_search_query = ""
|
||
self.show_offsets = 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_customer_picker_open:
|
||
self.main_content.controls.append(self.create_customer_picker_screen())
|
||
else:
|
||
# 新規作成画面
|
||
self.main_content.controls.append(self.create_slip_input_screen())
|
||
else:
|
||
# 発行履歴画面
|
||
self.main_content.controls.append(self.create_slip_history_screen())
|
||
|
||
self.page.update()
|
||
|
||
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),
|
||
]
|
||
),
|
||
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 i == 0 else ft.Colors.GREY_300,
|
||
color=ft.Colors.WHITE if i == 0 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()
|
||
|
||
# 履歴カードリスト
|
||
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.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.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()
|
||
|
||
actions_row = None
|
||
if isinstance(slip, Invoice) and not getattr(slip, "is_offset", False):
|
||
actions_row = ft.Row(
|
||
[
|
||
ft.IconButton(
|
||
ft.Icons.REPLAY_CIRCLE_FILLED,
|
||
tooltip="赤伝(相殺)を発行",
|
||
icon_color=ft.Colors.RED_400,
|
||
on_click=issue_offset,
|
||
)
|
||
],
|
||
alignment=ft.MainAxisAlignment.END,
|
||
)
|
||
|
||
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 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 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}")
|
||
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)
|