中級者向け No.44

予算ダッシュボード

収支・カテゴリ別・月別グラフを表示する予算管理ダッシュボード。matplotlibの埋め込みと複数グラフ表示を学びます。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), matplotlib ⏱️ 制作時間: 30〜90分

1. アプリ概要

収支・カテゴリ別・月別グラフを表示する予算管理ダッシュボード。matplotlibの埋め込みと複数グラフ表示を学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・matplotlib で、難易度は ★★★ です。

Pythonでは tkinter を使うことで、クロスプラットフォームなGUIアプリを簡単に作成できます。このアプリを通じて、ウィジェットの配置・イベント処理・データ管理など、GUI開発の実践的なスキルを習得できます。

ソースコードは完全な動作状態で提供しており、コピーしてそのまま実行できます。まずは実行して動作を確認し、その後コードを読んで仕組みを理解していきましょう。カスタマイズセクションでは機能拡張のアイデアも紹介しています。

GUIアプリ開発は、プログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。アプリを作ることで、変数・関数・クラス・イベント処理など、プログラミングの重要な概念が自然と身についていきます。このアプリをきっかけに、オリジナルアプリの開発にも挑戦してみてください。

2. 機能一覧

  • 予算ダッシュボードのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • キーボードショートカット対応

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows・Mac・Linux すべて対応

以下の環境で動作確認しています。

  • Python 3.10 以上
  • OS: Windows 10/11・macOS 12+・Ubuntu 20.04+

インストールが必要なライブラリ

pip install matplotlib

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

app44.py
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
from datetime import datetime, date, timedelta

try:
    import matplotlib
    matplotlib.use("TkAgg")
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import matplotlib.pyplot as plt
    MPL_AVAILABLE = True
except ImportError:
    MPL_AVAILABLE = False


class App44:
    """予算ダッシュボード"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "budget.db")

    CATEGORIES = {
        "収入": ["給与", "副業", "投資", "その他収入"],
        "支出": ["食費", "住居費", "交通費", "通信費", "娯楽", "医療",
                "教育", "衣料", "光熱費", "その他"],
    }
    INCOME_COLOR = "#4caf50"
    EXPENSE_COLOR = "#f44336"

    def __init__(self, root):
        self.root = root
        self.root.title("予算ダッシュボード")
        self.root.geometry("1060x700")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._refresh_all()

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS transactions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date TEXT NOT NULL,
                type TEXT NOT NULL,
                category TEXT,
                amount REAL NOT NULL,
                memo TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS budgets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                category TEXT UNIQUE,
                monthly_limit REAL
            )
        """)
        self.conn.commit()
        if not self.conn.execute("SELECT 1 FROM transactions").fetchone():
            self._insert_sample_data()

    def _insert_sample_data(self):
        today = date.today()
        samples = []
        # 今月のデータ
        for i in range(15):
            d = (today - timedelta(days=i)).isoformat()
            if i % 3 == 0:
                samples.append((d, "収入", "給与", 250000, "月給"))
            samples.append((d, "支出", ["食費", "交通費", "娯楽", "通信費"][i % 4],
                            [3800, 1200, 4500, 2000][i % 4], ""))
        for row in samples:
            self.conn.execute(
                "INSERT INTO transactions (date,type,category,amount,memo) "
                "VALUES (?,?,?,?,?)", row)
        for cat, limit in [("食費", 50000), ("交通費", 15000),
                            ("娯楽", 20000), ("通信費", 8000)]:
            self.conn.execute(
                "INSERT OR IGNORE INTO budgets (category,monthly_limit) VALUES (?,?)",
                (cat, limit))
        self.conn.commit()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#f57f17", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="💰 予算ダッシュボード",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        today = date.today()
        tk.Label(header, text=f"{today.year}年{today.month}月",
                 bg="#f57f17", fg="#fff9c4",
                 font=("Arial", 11)).pack(side=tk.RIGHT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ダッシュボード
        dash_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(dash_tab, text="📊 ダッシュボード")
        self._build_dashboard(dash_tab)
        notebook.bind("<<NotebookTabChanged>>",
                      lambda e: self._refresh_all())

        # 収支入力タブ
        input_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(input_tab, text="➕ 収支入力")
        self._build_input_tab(input_tab)

        # 一覧タブ
        list_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(list_tab, text="📋 一覧")
        self._build_list_tab(list_tab)

        # 予算設定タブ
        budget_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(budget_tab, text="⚙ 予算設定")
        self._build_budget_tab(budget_tab)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── ダッシュボード ────────────────────────────────────────────

    def _build_dashboard(self, parent):
        # サマリーカード
        cards_f = tk.Frame(parent, bg="#f8f9fc")
        cards_f.pack(fill=tk.X, padx=8, pady=6)
        self.summary_cards = {}
        for label, key, color in [
            ("今月の収入", "income", "#4caf50"),
            ("今月の支出", "expense", "#f44336"),
            ("収支バランス", "balance", "#2196f3"),
            ("予算達成率", "budget_rate", "#ff9800"),
        ]:
            card = tk.Frame(cards_f, bg=color, relief=tk.FLAT, padx=16, pady=10)
            card.pack(side=tk.LEFT, padx=6, expand=True, fill=tk.X)
            tk.Label(card, text=label, bg=color, fg="white",
                     font=("Arial", 9)).pack(anchor="w")
            lbl = tk.Label(card, text="—", bg=color, fg="white",
                           font=("Arial", 16, "bold"))
            lbl.pack(anchor="w")
            self.summary_cards[key] = lbl

        if not MPL_AVAILABLE:
            tk.Label(parent,
                     text="⚠ matplotlib が未インストールです (pip install matplotlib)。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)
            return

        # グラフエリア
        fig = Figure(figsize=(10, 5), facecolor="#f8f9fc")
        self.dash_axes = {
            "pie": fig.add_subplot(1, 3, 1),
            "bar": fig.add_subplot(1, 3, 2),
            "trend": fig.add_subplot(1, 3, 3),
        }
        fig.tight_layout(pad=3.0)
        self.dash_canvas_widget = FigureCanvasTkAgg(fig, master=parent)
        self.dash_canvas_widget.get_tk_widget().pack(fill=tk.BOTH, expand=True)
        self.dash_fig = fig

        ttk.Button(parent, text="🔄 更新",
                   command=self._refresh_dashboard).pack(pady=4)

    def _refresh_dashboard(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()

        income = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='収入' AND date >= ?", (month_start,)).fetchone()[0]
        expense = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='支出' AND date >= ?", (month_start,)).fetchone()[0]
        balance = income - expense

        # 予算消化率
        budgets = self.conn.execute("SELECT category, monthly_limit FROM budgets").fetchall()
        budget_pcts = []
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?", (cat, month_start)).fetchone()[0]
            if limit > 0:
                budget_pcts.append(spent / limit * 100)
        avg_budget_rate = sum(budget_pcts) / len(budget_pcts) if budget_pcts else 0

        self.summary_cards["income"].config(text=f"¥{income:,.0f}")
        self.summary_cards["expense"].config(text=f"¥{expense:,.0f}")
        bal_color = "#c8e6c9" if balance >= 0 else "#ffcdd2"
        self.summary_cards["balance"].config(
            text=f"¥{balance:+,.0f}",
            fg=bal_color)
        self.summary_cards["budget_rate"].config(
            text=f"{avg_budget_rate:.0f}%")

        if not MPL_AVAILABLE:
            return

        # パイチャート: 支出カテゴリ
        ax_pie = self.dash_axes["pie"]
        ax_pie.clear()
        cat_data = self.conn.execute(
            "SELECT category, SUM(amount) FROM transactions "
            "WHERE type='支出' AND date >= ? GROUP BY category "
            "ORDER BY SUM(amount) DESC LIMIT 8",
            (month_start,)).fetchall()
        if cat_data:
            labels = [r[0] for r in cat_data]
            sizes = [r[1] for r in cat_data]
            ax_pie.pie(sizes, labels=labels, autopct="%1.0f%%", startangle=90,
                       textprops={"fontsize": 7})
        ax_pie.set_title("支出カテゴリ", fontsize=9)

        # 棒グラフ: 予算 vs 実績
        ax_bar = self.dash_axes["bar"]
        ax_bar.clear()
        if budgets:
            cats = [b[0] for b in budgets]
            limits = [b[1] for b in budgets]
            spents = []
            for cat, _ in budgets:
                s = self.conn.execute(
                    "SELECT COALESCE(SUM(amount),0) FROM transactions "
                    "WHERE category=? AND date >= ?",
                    (cat, month_start)).fetchone()[0]
                spents.append(s)
            x = range(len(cats))
            ax_bar.bar(x, limits, alpha=0.3, color="#ff9800", label="予算")
            ax_bar.bar(x, spents, alpha=0.8, color="#f44336", label="実績")
            ax_bar.set_xticks(list(x))
            ax_bar.set_xticklabels(cats, rotation=30, ha="right", fontsize=7)
            ax_bar.legend(fontsize=7)
        ax_bar.set_title("予算 vs 実績", fontsize=9)

        # 折れ線: 月別収支
        ax_trend = self.dash_axes["trend"]
        ax_trend.clear()
        months, inc_vals, exp_vals = [], [], []
        for mo in range(5, -1, -1):
            d = today.replace(day=1) - timedelta(days=mo * 28)
            ms = d.replace(day=1).isoformat()
            if d.month == 12:
                me = d.replace(year=d.year + 1, month=1, day=1).isoformat()
            else:
                me = d.replace(month=d.month + 1, day=1).isoformat()
            inc = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='収入' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            exp = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='支出' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            months.append(f"{d.month}月")
            inc_vals.append(inc)
            exp_vals.append(exp)
        ax_trend.plot(months, inc_vals, color="#4caf50", marker="o",
                      label="収入", linewidth=2)
        ax_trend.plot(months, exp_vals, color="#f44336", marker="o",
                      label="支出", linewidth=2)
        ax_trend.legend(fontsize=7)
        ax_trend.set_title("月別収支推移", fontsize=9)
        ax_trend.tick_params(axis="x", labelsize=7)

        self.dash_fig.tight_layout(pad=2.5)
        self.dash_canvas_widget.draw()

    # ── 収支入力タブ ──────────────────────────────────────────────

    def _build_input_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}
        form = ttk.LabelFrame(parent, text="収支を入力", padding=12)
        form.pack(fill=tk.X, padx=16, pady=12)

        self.tx_type_var = tk.StringVar(value="支出")
        type_row = tk.Frame(form, bg=form.cget("background"))
        type_row.pack(fill=tk.X, pady=4)
        tk.Label(type_row, text="種別:", bg=type_row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        for t, c in [("収入", "#4caf50"), ("支出", "#f44336")]:
            tk.Radiobutton(type_row, text=t, variable=self.tx_type_var,
                           value=t, fg=c, bg=type_row.cget("bg"),
                           font=("Arial", 10),
                           command=self._update_category_choices).pack(side=tk.LEFT, padx=8)

        for lbl, attr, default in [
            ("日付 (YYYY-MM-DD):", "tx_date_var", date.today().isoformat()),
            ("金額 (円):", "tx_amount_var", ""),
            ("メモ:", "tx_memo_var", ""),
        ]:
            row = tk.Frame(form, bg=form.cget("background"))
            row.pack(fill=tk.X, pady=4)
            tk.Label(row, text=lbl, width=20, anchor="e",
                     bg=row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=default)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=24).pack(side=tk.LEFT, padx=4)

        cat_row = tk.Frame(form, bg=form.cget("background"))
        cat_row.pack(fill=tk.X, pady=4)
        tk.Label(cat_row, text="カテゴリ:", width=20, anchor="e",
                 bg=cat_row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
        self.tx_cat_var = tk.StringVar()
        self.tx_cat_cb = ttk.Combobox(cat_row, textvariable=self.tx_cat_var,
                                       state="readonly", width=16)
        self.tx_cat_cb.pack(side=tk.LEFT, padx=4)
        self._update_category_choices()

        ttk.Button(form, text="➕ 登録", command=self._add_transaction).pack(pady=8)

        # 直近の入力
        recent_f = ttk.LabelFrame(parent, text="直近の入力", padding=4)
        recent_f.pack(fill=tk.BOTH, expand=True, padx=16, pady=4)
        self.recent_tree = ttk.Treeview(recent_f,
                                         columns=("date", "type", "cat", "amount", "memo"),
                                         show="headings", height=10)
        for c, h, w in [("date", "日付", 90), ("type", "種別", 60),
                         ("cat", "カテゴリ", 90), ("amount", "金額", 90), ("memo", "メモ", 200)]:
            self.recent_tree.heading(c, text=h)
            self.recent_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(recent_f, command=self.recent_tree.yview)
        self.recent_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.recent_tree.pack(fill=tk.BOTH, expand=True)
        self.recent_tree.tag_configure("income", foreground="#2e7d32")
        self.recent_tree.tag_configure("expense", foreground="#c62828")

        ttk.Button(parent, text="🗑 削除",
                   command=self._delete_transaction).pack(padx=16, pady=4, anchor="w")

    def _update_category_choices(self):
        tx_type = self.tx_type_var.get()
        cats = self.CATEGORIES.get(tx_type, [])
        self.tx_cat_cb.configure(values=cats)
        if cats:
            self.tx_cat_var.set(cats[0])

    def _add_transaction(self):
        try:
            amount = float(self.tx_amount_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を正しく入力してください")
            return
        date_str = self.tx_date_var.get().strip()
        tx_type = self.tx_type_var.get()
        cat = self.tx_cat_var.get()
        memo = self.tx_memo_var.get().strip()
        self.conn.execute(
            "INSERT INTO transactions (date, type, category, amount, memo) "
            "VALUES (?,?,?,?,?)", (date_str, tx_type, cat, amount, memo))
        self.conn.commit()
        self.tx_amount_var.set("")
        self.tx_memo_var.set("")
        self._refresh_all()
        self.status_var.set(f"登録: {tx_type} {cat} ¥{amount:,.0f}")

    def _delete_transaction(self):
        sel = self.recent_tree.selection()
        if sel and messagebox.askyesno("確認", "削除しますか?"):
            self.conn.execute("DELETE FROM transactions WHERE id=?", (int(sel[0]),))
            self.conn.commit()
            self._refresh_all()

    # ── 一覧タブ ──────────────────────────────────────────────────

    def _build_list_tab(self, parent):
        filter_f = tk.Frame(parent, bg="#f8f9fc")
        filter_f.pack(fill=tk.X, padx=8, pady=6)
        tk.Label(filter_f, text="種別:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.list_type_var = tk.StringVar(value="すべて")
        ttk.Combobox(filter_f, textvariable=self.list_type_var,
                     values=["すべて", "収入", "支出"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)
        tk.Label(filter_f, text="月:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        today = date.today()
        self.list_month_var = tk.StringVar(value=f"{today.year}-{today.month:02d}")
        ttk.Entry(filter_f, textvariable=self.list_month_var, width=8).pack(side=tk.LEFT)
        ttk.Button(filter_f, text="🔍 絞り込み",
                   command=self._refresh_list).pack(side=tk.LEFT, padx=6)

        cols = ("id", "date", "type", "category", "amount", "memo")
        self.list_tree = ttk.Treeview(parent, columns=cols, show="headings", height=20)
        for c, h, w in [("id", "ID", 40), ("date", "日付", 90), ("type", "種別", 60),
                         ("category", "カテゴリ", 90), ("amount", "金額", 100),
                         ("memo", "メモ", 200)]:
            self.list_tree.heading(c, text=h)
            self.list_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(parent, command=self.list_tree.yview)
        self.list_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.list_tree.pack(fill=tk.BOTH, expand=True, padx=8)
        self.list_tree.tag_configure("income", foreground="#2e7d32")
        self.list_tree.tag_configure("expense", foreground="#c62828")

    def _refresh_list(self):
        tx_type = self.list_type_var.get()
        month = self.list_month_var.get()
        sql = ("SELECT id, date, type, category, amount, memo FROM transactions "
               "WHERE date LIKE ? ")
        params = [f"{month}%"]
        if tx_type != "すべて":
            sql += "AND type=? "
            params.append(tx_type)
        sql += "ORDER BY date DESC LIMIT 200"
        rows = self.conn.execute(sql, params).fetchall()
        self.list_tree.delete(*self.list_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.list_tree.insert("", "end", iid=str(tid),
                                   values=(tid, d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                   tags=(tag,))

    # ── 予算設定タブ ──────────────────────────────────────────────

    def _build_budget_tab(self, parent):
        tk.Label(parent, text="カテゴリ別の月次予算上限を設定します",
                 bg="#f8f9fc", font=("Arial", 10)).pack(anchor="w", padx=8, pady=4)
        cols = ("cat", "limit", "spent", "remaining", "rate")
        self.budget_tree = ttk.Treeview(parent, columns=cols, show="headings", height=12)
        for c, h, w in [("cat", "カテゴリ", 100), ("limit", "予算上限", 100),
                         ("spent", "今月の支出", 100), ("remaining", "残り", 100),
                         ("rate", "消化率", 80)]:
            self.budget_tree.heading(c, text=h)
            self.budget_tree.column(c, width=w, minwidth=40)
        self.budget_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.budget_tree.tag_configure("over", foreground="#f44336")
        self.budget_tree.tag_configure("warn", foreground="#ff9800")
        self.budget_tree.tag_configure("ok", foreground="#2e7d32")

        add_f = ttk.LabelFrame(parent, text="予算を設定", padding=8)
        add_f.pack(fill=tk.X, padx=8, pady=4)
        row = tk.Frame(add_f, bg=add_f.cget("background"))
        row.pack(fill=tk.X)
        tk.Label(row, text="カテゴリ:", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.budget_cat_var = tk.StringVar()
        all_cats = self.CATEGORIES["支出"]
        ttk.Combobox(row, textvariable=self.budget_cat_var,
                     values=all_cats, state="readonly", width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row, text="上限(円):", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT, padx=(8, 0))
        self.budget_limit_var = tk.StringVar(value="30000")
        ttk.Entry(row, textvariable=self.budget_limit_var, width=12).pack(side=tk.LEFT, padx=4)
        ttk.Button(row, text="💾 設定",
                   command=self._save_budget).pack(side=tk.LEFT, padx=8)

    def _save_budget(self):
        cat = self.budget_cat_var.get()
        if not cat:
            return
        try:
            limit = float(self.budget_limit_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を入力してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO budgets (category, monthly_limit) VALUES (?,?)",
            (cat, limit))
        self.conn.commit()
        self._refresh_budget()

    def _refresh_budget(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()
        budgets = self.conn.execute(
            "SELECT category, monthly_limit FROM budgets").fetchall()
        self.budget_tree.delete(*self.budget_tree.get_children())
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?",
                (cat, month_start)).fetchone()[0]
            remaining = limit - spent
            rate = spent / limit * 100 if limit else 0
            tag = "over" if rate >= 100 else "warn" if rate >= 80 else "ok"
            self.budget_tree.insert("", "end",
                                     values=(cat, f"¥{limit:,.0f}", f"¥{spent:,.0f}",
                                             f"¥{remaining:,.0f}", f"{rate:.0f}%"),
                                     tags=(tag,))

    def _refresh_all(self):
        self._refresh_dashboard()
        self._refresh_list()
        self._refresh_budget()
        self._refresh_recent()

    def _refresh_recent(self):
        rows = self.conn.execute(
            "SELECT id, date, type, category, amount, memo FROM transactions "
            "ORDER BY id DESC LIMIT 30").fetchall()
        self.recent_tree.delete(*self.recent_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.recent_tree.insert("", "end", iid=str(tid),
                                     values=(d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                     tags=(tag,))


if __name__ == "__main__":
    root = tk.Tk()
    app = App44(root)
    root.mainloop()

5. コード解説

予算ダッシュボードのコードを詳しく解説します。クラスベースの設計で各機能を整理して実装しています。

クラス設計とコンストラクタ

App44クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
from datetime import datetime, date, timedelta

try:
    import matplotlib
    matplotlib.use("TkAgg")
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import matplotlib.pyplot as plt
    MPL_AVAILABLE = True
except ImportError:
    MPL_AVAILABLE = False


class App44:
    """予算ダッシュボード"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "budget.db")

    CATEGORIES = {
        "収入": ["給与", "副業", "投資", "その他収入"],
        "支出": ["食費", "住居費", "交通費", "通信費", "娯楽", "医療",
                "教育", "衣料", "光熱費", "その他"],
    }
    INCOME_COLOR = "#4caf50"
    EXPENSE_COLOR = "#f44336"

    def __init__(self, root):
        self.root = root
        self.root.title("予算ダッシュボード")
        self.root.geometry("1060x700")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._refresh_all()

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS transactions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date TEXT NOT NULL,
                type TEXT NOT NULL,
                category TEXT,
                amount REAL NOT NULL,
                memo TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS budgets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                category TEXT UNIQUE,
                monthly_limit REAL
            )
        """)
        self.conn.commit()
        if not self.conn.execute("SELECT 1 FROM transactions").fetchone():
            self._insert_sample_data()

    def _insert_sample_data(self):
        today = date.today()
        samples = []
        # 今月のデータ
        for i in range(15):
            d = (today - timedelta(days=i)).isoformat()
            if i % 3 == 0:
                samples.append((d, "収入", "給与", 250000, "月給"))
            samples.append((d, "支出", ["食費", "交通費", "娯楽", "通信費"][i % 4],
                            [3800, 1200, 4500, 2000][i % 4], ""))
        for row in samples:
            self.conn.execute(
                "INSERT INTO transactions (date,type,category,amount,memo) "
                "VALUES (?,?,?,?,?)", row)
        for cat, limit in [("食費", 50000), ("交通費", 15000),
                            ("娯楽", 20000), ("通信費", 8000)]:
            self.conn.execute(
                "INSERT OR IGNORE INTO budgets (category,monthly_limit) VALUES (?,?)",
                (cat, limit))
        self.conn.commit()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#f57f17", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="💰 予算ダッシュボード",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        today = date.today()
        tk.Label(header, text=f"{today.year}年{today.month}月",
                 bg="#f57f17", fg="#fff9c4",
                 font=("Arial", 11)).pack(side=tk.RIGHT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ダッシュボード
        dash_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(dash_tab, text="📊 ダッシュボード")
        self._build_dashboard(dash_tab)
        notebook.bind("<<NotebookTabChanged>>",
                      lambda e: self._refresh_all())

        # 収支入力タブ
        input_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(input_tab, text="➕ 収支入力")
        self._build_input_tab(input_tab)

        # 一覧タブ
        list_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(list_tab, text="📋 一覧")
        self._build_list_tab(list_tab)

        # 予算設定タブ
        budget_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(budget_tab, text="⚙ 予算設定")
        self._build_budget_tab(budget_tab)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── ダッシュボード ────────────────────────────────────────────

    def _build_dashboard(self, parent):
        # サマリーカード
        cards_f = tk.Frame(parent, bg="#f8f9fc")
        cards_f.pack(fill=tk.X, padx=8, pady=6)
        self.summary_cards = {}
        for label, key, color in [
            ("今月の収入", "income", "#4caf50"),
            ("今月の支出", "expense", "#f44336"),
            ("収支バランス", "balance", "#2196f3"),
            ("予算達成率", "budget_rate", "#ff9800"),
        ]:
            card = tk.Frame(cards_f, bg=color, relief=tk.FLAT, padx=16, pady=10)
            card.pack(side=tk.LEFT, padx=6, expand=True, fill=tk.X)
            tk.Label(card, text=label, bg=color, fg="white",
                     font=("Arial", 9)).pack(anchor="w")
            lbl = tk.Label(card, text="—", bg=color, fg="white",
                           font=("Arial", 16, "bold"))
            lbl.pack(anchor="w")
            self.summary_cards[key] = lbl

        if not MPL_AVAILABLE:
            tk.Label(parent,
                     text="⚠ matplotlib が未インストールです (pip install matplotlib)。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)
            return

        # グラフエリア
        fig = Figure(figsize=(10, 5), facecolor="#f8f9fc")
        self.dash_axes = {
            "pie": fig.add_subplot(1, 3, 1),
            "bar": fig.add_subplot(1, 3, 2),
            "trend": fig.add_subplot(1, 3, 3),
        }
        fig.tight_layout(pad=3.0)
        self.dash_canvas_widget = FigureCanvasTkAgg(fig, master=parent)
        self.dash_canvas_widget.get_tk_widget().pack(fill=tk.BOTH, expand=True)
        self.dash_fig = fig

        ttk.Button(parent, text="🔄 更新",
                   command=self._refresh_dashboard).pack(pady=4)

    def _refresh_dashboard(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()

        income = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='収入' AND date >= ?", (month_start,)).fetchone()[0]
        expense = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='支出' AND date >= ?", (month_start,)).fetchone()[0]
        balance = income - expense

        # 予算消化率
        budgets = self.conn.execute("SELECT category, monthly_limit FROM budgets").fetchall()
        budget_pcts = []
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?", (cat, month_start)).fetchone()[0]
            if limit > 0:
                budget_pcts.append(spent / limit * 100)
        avg_budget_rate = sum(budget_pcts) / len(budget_pcts) if budget_pcts else 0

        self.summary_cards["income"].config(text=f"¥{income:,.0f}")
        self.summary_cards["expense"].config(text=f"¥{expense:,.0f}")
        bal_color = "#c8e6c9" if balance >= 0 else "#ffcdd2"
        self.summary_cards["balance"].config(
            text=f"¥{balance:+,.0f}",
            fg=bal_color)
        self.summary_cards["budget_rate"].config(
            text=f"{avg_budget_rate:.0f}%")

        if not MPL_AVAILABLE:
            return

        # パイチャート: 支出カテゴリ
        ax_pie = self.dash_axes["pie"]
        ax_pie.clear()
        cat_data = self.conn.execute(
            "SELECT category, SUM(amount) FROM transactions "
            "WHERE type='支出' AND date >= ? GROUP BY category "
            "ORDER BY SUM(amount) DESC LIMIT 8",
            (month_start,)).fetchall()
        if cat_data:
            labels = [r[0] for r in cat_data]
            sizes = [r[1] for r in cat_data]
            ax_pie.pie(sizes, labels=labels, autopct="%1.0f%%", startangle=90,
                       textprops={"fontsize": 7})
        ax_pie.set_title("支出カテゴリ", fontsize=9)

        # 棒グラフ: 予算 vs 実績
        ax_bar = self.dash_axes["bar"]
        ax_bar.clear()
        if budgets:
            cats = [b[0] for b in budgets]
            limits = [b[1] for b in budgets]
            spents = []
            for cat, _ in budgets:
                s = self.conn.execute(
                    "SELECT COALESCE(SUM(amount),0) FROM transactions "
                    "WHERE category=? AND date >= ?",
                    (cat, month_start)).fetchone()[0]
                spents.append(s)
            x = range(len(cats))
            ax_bar.bar(x, limits, alpha=0.3, color="#ff9800", label="予算")
            ax_bar.bar(x, spents, alpha=0.8, color="#f44336", label="実績")
            ax_bar.set_xticks(list(x))
            ax_bar.set_xticklabels(cats, rotation=30, ha="right", fontsize=7)
            ax_bar.legend(fontsize=7)
        ax_bar.set_title("予算 vs 実績", fontsize=9)

        # 折れ線: 月別収支
        ax_trend = self.dash_axes["trend"]
        ax_trend.clear()
        months, inc_vals, exp_vals = [], [], []
        for mo in range(5, -1, -1):
            d = today.replace(day=1) - timedelta(days=mo * 28)
            ms = d.replace(day=1).isoformat()
            if d.month == 12:
                me = d.replace(year=d.year + 1, month=1, day=1).isoformat()
            else:
                me = d.replace(month=d.month + 1, day=1).isoformat()
            inc = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='収入' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            exp = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='支出' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            months.append(f"{d.month}月")
            inc_vals.append(inc)
            exp_vals.append(exp)
        ax_trend.plot(months, inc_vals, color="#4caf50", marker="o",
                      label="収入", linewidth=2)
        ax_trend.plot(months, exp_vals, color="#f44336", marker="o",
                      label="支出", linewidth=2)
        ax_trend.legend(fontsize=7)
        ax_trend.set_title("月別収支推移", fontsize=9)
        ax_trend.tick_params(axis="x", labelsize=7)

        self.dash_fig.tight_layout(pad=2.5)
        self.dash_canvas_widget.draw()

    # ── 収支入力タブ ──────────────────────────────────────────────

    def _build_input_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}
        form = ttk.LabelFrame(parent, text="収支を入力", padding=12)
        form.pack(fill=tk.X, padx=16, pady=12)

        self.tx_type_var = tk.StringVar(value="支出")
        type_row = tk.Frame(form, bg=form.cget("background"))
        type_row.pack(fill=tk.X, pady=4)
        tk.Label(type_row, text="種別:", bg=type_row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        for t, c in [("収入", "#4caf50"), ("支出", "#f44336")]:
            tk.Radiobutton(type_row, text=t, variable=self.tx_type_var,
                           value=t, fg=c, bg=type_row.cget("bg"),
                           font=("Arial", 10),
                           command=self._update_category_choices).pack(side=tk.LEFT, padx=8)

        for lbl, attr, default in [
            ("日付 (YYYY-MM-DD):", "tx_date_var", date.today().isoformat()),
            ("金額 (円):", "tx_amount_var", ""),
            ("メモ:", "tx_memo_var", ""),
        ]:
            row = tk.Frame(form, bg=form.cget("background"))
            row.pack(fill=tk.X, pady=4)
            tk.Label(row, text=lbl, width=20, anchor="e",
                     bg=row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=default)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=24).pack(side=tk.LEFT, padx=4)

        cat_row = tk.Frame(form, bg=form.cget("background"))
        cat_row.pack(fill=tk.X, pady=4)
        tk.Label(cat_row, text="カテゴリ:", width=20, anchor="e",
                 bg=cat_row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
        self.tx_cat_var = tk.StringVar()
        self.tx_cat_cb = ttk.Combobox(cat_row, textvariable=self.tx_cat_var,
                                       state="readonly", width=16)
        self.tx_cat_cb.pack(side=tk.LEFT, padx=4)
        self._update_category_choices()

        ttk.Button(form, text="➕ 登録", command=self._add_transaction).pack(pady=8)

        # 直近の入力
        recent_f = ttk.LabelFrame(parent, text="直近の入力", padding=4)
        recent_f.pack(fill=tk.BOTH, expand=True, padx=16, pady=4)
        self.recent_tree = ttk.Treeview(recent_f,
                                         columns=("date", "type", "cat", "amount", "memo"),
                                         show="headings", height=10)
        for c, h, w in [("date", "日付", 90), ("type", "種別", 60),
                         ("cat", "カテゴリ", 90), ("amount", "金額", 90), ("memo", "メモ", 200)]:
            self.recent_tree.heading(c, text=h)
            self.recent_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(recent_f, command=self.recent_tree.yview)
        self.recent_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.recent_tree.pack(fill=tk.BOTH, expand=True)
        self.recent_tree.tag_configure("income", foreground="#2e7d32")
        self.recent_tree.tag_configure("expense", foreground="#c62828")

        ttk.Button(parent, text="🗑 削除",
                   command=self._delete_transaction).pack(padx=16, pady=4, anchor="w")

    def _update_category_choices(self):
        tx_type = self.tx_type_var.get()
        cats = self.CATEGORIES.get(tx_type, [])
        self.tx_cat_cb.configure(values=cats)
        if cats:
            self.tx_cat_var.set(cats[0])

    def _add_transaction(self):
        try:
            amount = float(self.tx_amount_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を正しく入力してください")
            return
        date_str = self.tx_date_var.get().strip()
        tx_type = self.tx_type_var.get()
        cat = self.tx_cat_var.get()
        memo = self.tx_memo_var.get().strip()
        self.conn.execute(
            "INSERT INTO transactions (date, type, category, amount, memo) "
            "VALUES (?,?,?,?,?)", (date_str, tx_type, cat, amount, memo))
        self.conn.commit()
        self.tx_amount_var.set("")
        self.tx_memo_var.set("")
        self._refresh_all()
        self.status_var.set(f"登録: {tx_type} {cat} ¥{amount:,.0f}")

    def _delete_transaction(self):
        sel = self.recent_tree.selection()
        if sel and messagebox.askyesno("確認", "削除しますか?"):
            self.conn.execute("DELETE FROM transactions WHERE id=?", (int(sel[0]),))
            self.conn.commit()
            self._refresh_all()

    # ── 一覧タブ ──────────────────────────────────────────────────

    def _build_list_tab(self, parent):
        filter_f = tk.Frame(parent, bg="#f8f9fc")
        filter_f.pack(fill=tk.X, padx=8, pady=6)
        tk.Label(filter_f, text="種別:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.list_type_var = tk.StringVar(value="すべて")
        ttk.Combobox(filter_f, textvariable=self.list_type_var,
                     values=["すべて", "収入", "支出"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)
        tk.Label(filter_f, text="月:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        today = date.today()
        self.list_month_var = tk.StringVar(value=f"{today.year}-{today.month:02d}")
        ttk.Entry(filter_f, textvariable=self.list_month_var, width=8).pack(side=tk.LEFT)
        ttk.Button(filter_f, text="🔍 絞り込み",
                   command=self._refresh_list).pack(side=tk.LEFT, padx=6)

        cols = ("id", "date", "type", "category", "amount", "memo")
        self.list_tree = ttk.Treeview(parent, columns=cols, show="headings", height=20)
        for c, h, w in [("id", "ID", 40), ("date", "日付", 90), ("type", "種別", 60),
                         ("category", "カテゴリ", 90), ("amount", "金額", 100),
                         ("memo", "メモ", 200)]:
            self.list_tree.heading(c, text=h)
            self.list_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(parent, command=self.list_tree.yview)
        self.list_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.list_tree.pack(fill=tk.BOTH, expand=True, padx=8)
        self.list_tree.tag_configure("income", foreground="#2e7d32")
        self.list_tree.tag_configure("expense", foreground="#c62828")

    def _refresh_list(self):
        tx_type = self.list_type_var.get()
        month = self.list_month_var.get()
        sql = ("SELECT id, date, type, category, amount, memo FROM transactions "
               "WHERE date LIKE ? ")
        params = [f"{month}%"]
        if tx_type != "すべて":
            sql += "AND type=? "
            params.append(tx_type)
        sql += "ORDER BY date DESC LIMIT 200"
        rows = self.conn.execute(sql, params).fetchall()
        self.list_tree.delete(*self.list_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.list_tree.insert("", "end", iid=str(tid),
                                   values=(tid, d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                   tags=(tag,))

    # ── 予算設定タブ ──────────────────────────────────────────────

    def _build_budget_tab(self, parent):
        tk.Label(parent, text="カテゴリ別の月次予算上限を設定します",
                 bg="#f8f9fc", font=("Arial", 10)).pack(anchor="w", padx=8, pady=4)
        cols = ("cat", "limit", "spent", "remaining", "rate")
        self.budget_tree = ttk.Treeview(parent, columns=cols, show="headings", height=12)
        for c, h, w in [("cat", "カテゴリ", 100), ("limit", "予算上限", 100),
                         ("spent", "今月の支出", 100), ("remaining", "残り", 100),
                         ("rate", "消化率", 80)]:
            self.budget_tree.heading(c, text=h)
            self.budget_tree.column(c, width=w, minwidth=40)
        self.budget_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.budget_tree.tag_configure("over", foreground="#f44336")
        self.budget_tree.tag_configure("warn", foreground="#ff9800")
        self.budget_tree.tag_configure("ok", foreground="#2e7d32")

        add_f = ttk.LabelFrame(parent, text="予算を設定", padding=8)
        add_f.pack(fill=tk.X, padx=8, pady=4)
        row = tk.Frame(add_f, bg=add_f.cget("background"))
        row.pack(fill=tk.X)
        tk.Label(row, text="カテゴリ:", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.budget_cat_var = tk.StringVar()
        all_cats = self.CATEGORIES["支出"]
        ttk.Combobox(row, textvariable=self.budget_cat_var,
                     values=all_cats, state="readonly", width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row, text="上限(円):", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT, padx=(8, 0))
        self.budget_limit_var = tk.StringVar(value="30000")
        ttk.Entry(row, textvariable=self.budget_limit_var, width=12).pack(side=tk.LEFT, padx=4)
        ttk.Button(row, text="💾 設定",
                   command=self._save_budget).pack(side=tk.LEFT, padx=8)

    def _save_budget(self):
        cat = self.budget_cat_var.get()
        if not cat:
            return
        try:
            limit = float(self.budget_limit_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を入力してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO budgets (category, monthly_limit) VALUES (?,?)",
            (cat, limit))
        self.conn.commit()
        self._refresh_budget()

    def _refresh_budget(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()
        budgets = self.conn.execute(
            "SELECT category, monthly_limit FROM budgets").fetchall()
        self.budget_tree.delete(*self.budget_tree.get_children())
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?",
                (cat, month_start)).fetchone()[0]
            remaining = limit - spent
            rate = spent / limit * 100 if limit else 0
            tag = "over" if rate >= 100 else "warn" if rate >= 80 else "ok"
            self.budget_tree.insert("", "end",
                                     values=(cat, f"¥{limit:,.0f}", f"¥{spent:,.0f}",
                                             f"¥{remaining:,.0f}", f"{rate:.0f}%"),
                                     tags=(tag,))

    def _refresh_all(self):
        self._refresh_dashboard()
        self._refresh_list()
        self._refresh_budget()
        self._refresh_recent()

    def _refresh_recent(self):
        rows = self.conn.execute(
            "SELECT id, date, type, category, amount, memo FROM transactions "
            "ORDER BY id DESC LIMIT 30").fetchall()
        self.recent_tree.delete(*self.recent_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.recent_tree.insert("", "end", iid=str(tid),
                                     values=(d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                     tags=(tag,))


if __name__ == "__main__":
    root = tk.Tk()
    app = App44(root)
    root.mainloop()

LabelFrameによるセクション分け

ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
from datetime import datetime, date, timedelta

try:
    import matplotlib
    matplotlib.use("TkAgg")
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import matplotlib.pyplot as plt
    MPL_AVAILABLE = True
except ImportError:
    MPL_AVAILABLE = False


class App44:
    """予算ダッシュボード"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "budget.db")

    CATEGORIES = {
        "収入": ["給与", "副業", "投資", "その他収入"],
        "支出": ["食費", "住居費", "交通費", "通信費", "娯楽", "医療",
                "教育", "衣料", "光熱費", "その他"],
    }
    INCOME_COLOR = "#4caf50"
    EXPENSE_COLOR = "#f44336"

    def __init__(self, root):
        self.root = root
        self.root.title("予算ダッシュボード")
        self.root.geometry("1060x700")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._refresh_all()

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS transactions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date TEXT NOT NULL,
                type TEXT NOT NULL,
                category TEXT,
                amount REAL NOT NULL,
                memo TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS budgets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                category TEXT UNIQUE,
                monthly_limit REAL
            )
        """)
        self.conn.commit()
        if not self.conn.execute("SELECT 1 FROM transactions").fetchone():
            self._insert_sample_data()

    def _insert_sample_data(self):
        today = date.today()
        samples = []
        # 今月のデータ
        for i in range(15):
            d = (today - timedelta(days=i)).isoformat()
            if i % 3 == 0:
                samples.append((d, "収入", "給与", 250000, "月給"))
            samples.append((d, "支出", ["食費", "交通費", "娯楽", "通信費"][i % 4],
                            [3800, 1200, 4500, 2000][i % 4], ""))
        for row in samples:
            self.conn.execute(
                "INSERT INTO transactions (date,type,category,amount,memo) "
                "VALUES (?,?,?,?,?)", row)
        for cat, limit in [("食費", 50000), ("交通費", 15000),
                            ("娯楽", 20000), ("通信費", 8000)]:
            self.conn.execute(
                "INSERT OR IGNORE INTO budgets (category,monthly_limit) VALUES (?,?)",
                (cat, limit))
        self.conn.commit()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#f57f17", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="💰 予算ダッシュボード",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        today = date.today()
        tk.Label(header, text=f"{today.year}年{today.month}月",
                 bg="#f57f17", fg="#fff9c4",
                 font=("Arial", 11)).pack(side=tk.RIGHT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ダッシュボード
        dash_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(dash_tab, text="📊 ダッシュボード")
        self._build_dashboard(dash_tab)
        notebook.bind("<<NotebookTabChanged>>",
                      lambda e: self._refresh_all())

        # 収支入力タブ
        input_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(input_tab, text="➕ 収支入力")
        self._build_input_tab(input_tab)

        # 一覧タブ
        list_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(list_tab, text="📋 一覧")
        self._build_list_tab(list_tab)

        # 予算設定タブ
        budget_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(budget_tab, text="⚙ 予算設定")
        self._build_budget_tab(budget_tab)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── ダッシュボード ────────────────────────────────────────────

    def _build_dashboard(self, parent):
        # サマリーカード
        cards_f = tk.Frame(parent, bg="#f8f9fc")
        cards_f.pack(fill=tk.X, padx=8, pady=6)
        self.summary_cards = {}
        for label, key, color in [
            ("今月の収入", "income", "#4caf50"),
            ("今月の支出", "expense", "#f44336"),
            ("収支バランス", "balance", "#2196f3"),
            ("予算達成率", "budget_rate", "#ff9800"),
        ]:
            card = tk.Frame(cards_f, bg=color, relief=tk.FLAT, padx=16, pady=10)
            card.pack(side=tk.LEFT, padx=6, expand=True, fill=tk.X)
            tk.Label(card, text=label, bg=color, fg="white",
                     font=("Arial", 9)).pack(anchor="w")
            lbl = tk.Label(card, text="—", bg=color, fg="white",
                           font=("Arial", 16, "bold"))
            lbl.pack(anchor="w")
            self.summary_cards[key] = lbl

        if not MPL_AVAILABLE:
            tk.Label(parent,
                     text="⚠ matplotlib が未インストールです (pip install matplotlib)。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)
            return

        # グラフエリア
        fig = Figure(figsize=(10, 5), facecolor="#f8f9fc")
        self.dash_axes = {
            "pie": fig.add_subplot(1, 3, 1),
            "bar": fig.add_subplot(1, 3, 2),
            "trend": fig.add_subplot(1, 3, 3),
        }
        fig.tight_layout(pad=3.0)
        self.dash_canvas_widget = FigureCanvasTkAgg(fig, master=parent)
        self.dash_canvas_widget.get_tk_widget().pack(fill=tk.BOTH, expand=True)
        self.dash_fig = fig

        ttk.Button(parent, text="🔄 更新",
                   command=self._refresh_dashboard).pack(pady=4)

    def _refresh_dashboard(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()

        income = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='収入' AND date >= ?", (month_start,)).fetchone()[0]
        expense = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='支出' AND date >= ?", (month_start,)).fetchone()[0]
        balance = income - expense

        # 予算消化率
        budgets = self.conn.execute("SELECT category, monthly_limit FROM budgets").fetchall()
        budget_pcts = []
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?", (cat, month_start)).fetchone()[0]
            if limit > 0:
                budget_pcts.append(spent / limit * 100)
        avg_budget_rate = sum(budget_pcts) / len(budget_pcts) if budget_pcts else 0

        self.summary_cards["income"].config(text=f"¥{income:,.0f}")
        self.summary_cards["expense"].config(text=f"¥{expense:,.0f}")
        bal_color = "#c8e6c9" if balance >= 0 else "#ffcdd2"
        self.summary_cards["balance"].config(
            text=f"¥{balance:+,.0f}",
            fg=bal_color)
        self.summary_cards["budget_rate"].config(
            text=f"{avg_budget_rate:.0f}%")

        if not MPL_AVAILABLE:
            return

        # パイチャート: 支出カテゴリ
        ax_pie = self.dash_axes["pie"]
        ax_pie.clear()
        cat_data = self.conn.execute(
            "SELECT category, SUM(amount) FROM transactions "
            "WHERE type='支出' AND date >= ? GROUP BY category "
            "ORDER BY SUM(amount) DESC LIMIT 8",
            (month_start,)).fetchall()
        if cat_data:
            labels = [r[0] for r in cat_data]
            sizes = [r[1] for r in cat_data]
            ax_pie.pie(sizes, labels=labels, autopct="%1.0f%%", startangle=90,
                       textprops={"fontsize": 7})
        ax_pie.set_title("支出カテゴリ", fontsize=9)

        # 棒グラフ: 予算 vs 実績
        ax_bar = self.dash_axes["bar"]
        ax_bar.clear()
        if budgets:
            cats = [b[0] for b in budgets]
            limits = [b[1] for b in budgets]
            spents = []
            for cat, _ in budgets:
                s = self.conn.execute(
                    "SELECT COALESCE(SUM(amount),0) FROM transactions "
                    "WHERE category=? AND date >= ?",
                    (cat, month_start)).fetchone()[0]
                spents.append(s)
            x = range(len(cats))
            ax_bar.bar(x, limits, alpha=0.3, color="#ff9800", label="予算")
            ax_bar.bar(x, spents, alpha=0.8, color="#f44336", label="実績")
            ax_bar.set_xticks(list(x))
            ax_bar.set_xticklabels(cats, rotation=30, ha="right", fontsize=7)
            ax_bar.legend(fontsize=7)
        ax_bar.set_title("予算 vs 実績", fontsize=9)

        # 折れ線: 月別収支
        ax_trend = self.dash_axes["trend"]
        ax_trend.clear()
        months, inc_vals, exp_vals = [], [], []
        for mo in range(5, -1, -1):
            d = today.replace(day=1) - timedelta(days=mo * 28)
            ms = d.replace(day=1).isoformat()
            if d.month == 12:
                me = d.replace(year=d.year + 1, month=1, day=1).isoformat()
            else:
                me = d.replace(month=d.month + 1, day=1).isoformat()
            inc = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='収入' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            exp = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='支出' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            months.append(f"{d.month}月")
            inc_vals.append(inc)
            exp_vals.append(exp)
        ax_trend.plot(months, inc_vals, color="#4caf50", marker="o",
                      label="収入", linewidth=2)
        ax_trend.plot(months, exp_vals, color="#f44336", marker="o",
                      label="支出", linewidth=2)
        ax_trend.legend(fontsize=7)
        ax_trend.set_title("月別収支推移", fontsize=9)
        ax_trend.tick_params(axis="x", labelsize=7)

        self.dash_fig.tight_layout(pad=2.5)
        self.dash_canvas_widget.draw()

    # ── 収支入力タブ ──────────────────────────────────────────────

    def _build_input_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}
        form = ttk.LabelFrame(parent, text="収支を入力", padding=12)
        form.pack(fill=tk.X, padx=16, pady=12)

        self.tx_type_var = tk.StringVar(value="支出")
        type_row = tk.Frame(form, bg=form.cget("background"))
        type_row.pack(fill=tk.X, pady=4)
        tk.Label(type_row, text="種別:", bg=type_row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        for t, c in [("収入", "#4caf50"), ("支出", "#f44336")]:
            tk.Radiobutton(type_row, text=t, variable=self.tx_type_var,
                           value=t, fg=c, bg=type_row.cget("bg"),
                           font=("Arial", 10),
                           command=self._update_category_choices).pack(side=tk.LEFT, padx=8)

        for lbl, attr, default in [
            ("日付 (YYYY-MM-DD):", "tx_date_var", date.today().isoformat()),
            ("金額 (円):", "tx_amount_var", ""),
            ("メモ:", "tx_memo_var", ""),
        ]:
            row = tk.Frame(form, bg=form.cget("background"))
            row.pack(fill=tk.X, pady=4)
            tk.Label(row, text=lbl, width=20, anchor="e",
                     bg=row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=default)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=24).pack(side=tk.LEFT, padx=4)

        cat_row = tk.Frame(form, bg=form.cget("background"))
        cat_row.pack(fill=tk.X, pady=4)
        tk.Label(cat_row, text="カテゴリ:", width=20, anchor="e",
                 bg=cat_row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
        self.tx_cat_var = tk.StringVar()
        self.tx_cat_cb = ttk.Combobox(cat_row, textvariable=self.tx_cat_var,
                                       state="readonly", width=16)
        self.tx_cat_cb.pack(side=tk.LEFT, padx=4)
        self._update_category_choices()

        ttk.Button(form, text="➕ 登録", command=self._add_transaction).pack(pady=8)

        # 直近の入力
        recent_f = ttk.LabelFrame(parent, text="直近の入力", padding=4)
        recent_f.pack(fill=tk.BOTH, expand=True, padx=16, pady=4)
        self.recent_tree = ttk.Treeview(recent_f,
                                         columns=("date", "type", "cat", "amount", "memo"),
                                         show="headings", height=10)
        for c, h, w in [("date", "日付", 90), ("type", "種別", 60),
                         ("cat", "カテゴリ", 90), ("amount", "金額", 90), ("memo", "メモ", 200)]:
            self.recent_tree.heading(c, text=h)
            self.recent_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(recent_f, command=self.recent_tree.yview)
        self.recent_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.recent_tree.pack(fill=tk.BOTH, expand=True)
        self.recent_tree.tag_configure("income", foreground="#2e7d32")
        self.recent_tree.tag_configure("expense", foreground="#c62828")

        ttk.Button(parent, text="🗑 削除",
                   command=self._delete_transaction).pack(padx=16, pady=4, anchor="w")

    def _update_category_choices(self):
        tx_type = self.tx_type_var.get()
        cats = self.CATEGORIES.get(tx_type, [])
        self.tx_cat_cb.configure(values=cats)
        if cats:
            self.tx_cat_var.set(cats[0])

    def _add_transaction(self):
        try:
            amount = float(self.tx_amount_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を正しく入力してください")
            return
        date_str = self.tx_date_var.get().strip()
        tx_type = self.tx_type_var.get()
        cat = self.tx_cat_var.get()
        memo = self.tx_memo_var.get().strip()
        self.conn.execute(
            "INSERT INTO transactions (date, type, category, amount, memo) "
            "VALUES (?,?,?,?,?)", (date_str, tx_type, cat, amount, memo))
        self.conn.commit()
        self.tx_amount_var.set("")
        self.tx_memo_var.set("")
        self._refresh_all()
        self.status_var.set(f"登録: {tx_type} {cat} ¥{amount:,.0f}")

    def _delete_transaction(self):
        sel = self.recent_tree.selection()
        if sel and messagebox.askyesno("確認", "削除しますか?"):
            self.conn.execute("DELETE FROM transactions WHERE id=?", (int(sel[0]),))
            self.conn.commit()
            self._refresh_all()

    # ── 一覧タブ ──────────────────────────────────────────────────

    def _build_list_tab(self, parent):
        filter_f = tk.Frame(parent, bg="#f8f9fc")
        filter_f.pack(fill=tk.X, padx=8, pady=6)
        tk.Label(filter_f, text="種別:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.list_type_var = tk.StringVar(value="すべて")
        ttk.Combobox(filter_f, textvariable=self.list_type_var,
                     values=["すべて", "収入", "支出"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)
        tk.Label(filter_f, text="月:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        today = date.today()
        self.list_month_var = tk.StringVar(value=f"{today.year}-{today.month:02d}")
        ttk.Entry(filter_f, textvariable=self.list_month_var, width=8).pack(side=tk.LEFT)
        ttk.Button(filter_f, text="🔍 絞り込み",
                   command=self._refresh_list).pack(side=tk.LEFT, padx=6)

        cols = ("id", "date", "type", "category", "amount", "memo")
        self.list_tree = ttk.Treeview(parent, columns=cols, show="headings", height=20)
        for c, h, w in [("id", "ID", 40), ("date", "日付", 90), ("type", "種別", 60),
                         ("category", "カテゴリ", 90), ("amount", "金額", 100),
                         ("memo", "メモ", 200)]:
            self.list_tree.heading(c, text=h)
            self.list_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(parent, command=self.list_tree.yview)
        self.list_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.list_tree.pack(fill=tk.BOTH, expand=True, padx=8)
        self.list_tree.tag_configure("income", foreground="#2e7d32")
        self.list_tree.tag_configure("expense", foreground="#c62828")

    def _refresh_list(self):
        tx_type = self.list_type_var.get()
        month = self.list_month_var.get()
        sql = ("SELECT id, date, type, category, amount, memo FROM transactions "
               "WHERE date LIKE ? ")
        params = [f"{month}%"]
        if tx_type != "すべて":
            sql += "AND type=? "
            params.append(tx_type)
        sql += "ORDER BY date DESC LIMIT 200"
        rows = self.conn.execute(sql, params).fetchall()
        self.list_tree.delete(*self.list_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.list_tree.insert("", "end", iid=str(tid),
                                   values=(tid, d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                   tags=(tag,))

    # ── 予算設定タブ ──────────────────────────────────────────────

    def _build_budget_tab(self, parent):
        tk.Label(parent, text="カテゴリ別の月次予算上限を設定します",
                 bg="#f8f9fc", font=("Arial", 10)).pack(anchor="w", padx=8, pady=4)
        cols = ("cat", "limit", "spent", "remaining", "rate")
        self.budget_tree = ttk.Treeview(parent, columns=cols, show="headings", height=12)
        for c, h, w in [("cat", "カテゴリ", 100), ("limit", "予算上限", 100),
                         ("spent", "今月の支出", 100), ("remaining", "残り", 100),
                         ("rate", "消化率", 80)]:
            self.budget_tree.heading(c, text=h)
            self.budget_tree.column(c, width=w, minwidth=40)
        self.budget_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.budget_tree.tag_configure("over", foreground="#f44336")
        self.budget_tree.tag_configure("warn", foreground="#ff9800")
        self.budget_tree.tag_configure("ok", foreground="#2e7d32")

        add_f = ttk.LabelFrame(parent, text="予算を設定", padding=8)
        add_f.pack(fill=tk.X, padx=8, pady=4)
        row = tk.Frame(add_f, bg=add_f.cget("background"))
        row.pack(fill=tk.X)
        tk.Label(row, text="カテゴリ:", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.budget_cat_var = tk.StringVar()
        all_cats = self.CATEGORIES["支出"]
        ttk.Combobox(row, textvariable=self.budget_cat_var,
                     values=all_cats, state="readonly", width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row, text="上限(円):", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT, padx=(8, 0))
        self.budget_limit_var = tk.StringVar(value="30000")
        ttk.Entry(row, textvariable=self.budget_limit_var, width=12).pack(side=tk.LEFT, padx=4)
        ttk.Button(row, text="💾 設定",
                   command=self._save_budget).pack(side=tk.LEFT, padx=8)

    def _save_budget(self):
        cat = self.budget_cat_var.get()
        if not cat:
            return
        try:
            limit = float(self.budget_limit_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を入力してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO budgets (category, monthly_limit) VALUES (?,?)",
            (cat, limit))
        self.conn.commit()
        self._refresh_budget()

    def _refresh_budget(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()
        budgets = self.conn.execute(
            "SELECT category, monthly_limit FROM budgets").fetchall()
        self.budget_tree.delete(*self.budget_tree.get_children())
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?",
                (cat, month_start)).fetchone()[0]
            remaining = limit - spent
            rate = spent / limit * 100 if limit else 0
            tag = "over" if rate >= 100 else "warn" if rate >= 80 else "ok"
            self.budget_tree.insert("", "end",
                                     values=(cat, f"¥{limit:,.0f}", f"¥{spent:,.0f}",
                                             f"¥{remaining:,.0f}", f"{rate:.0f}%"),
                                     tags=(tag,))

    def _refresh_all(self):
        self._refresh_dashboard()
        self._refresh_list()
        self._refresh_budget()
        self._refresh_recent()

    def _refresh_recent(self):
        rows = self.conn.execute(
            "SELECT id, date, type, category, amount, memo FROM transactions "
            "ORDER BY id DESC LIMIT 30").fetchall()
        self.recent_tree.delete(*self.recent_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.recent_tree.insert("", "end", iid=str(tid),
                                     values=(d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                     tags=(tag,))


if __name__ == "__main__":
    root = tk.Tk()
    app = App44(root)
    root.mainloop()

Entryウィジェットとイベントバインド

ttk.Entryで入力フィールドを作成します。bind('', ...)でEnterキー押下時に処理を実行できます。これにより、マウスを使わずキーボードだけで操作できるUXが実現できます。

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
from datetime import datetime, date, timedelta

try:
    import matplotlib
    matplotlib.use("TkAgg")
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import matplotlib.pyplot as plt
    MPL_AVAILABLE = True
except ImportError:
    MPL_AVAILABLE = False


class App44:
    """予算ダッシュボード"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "budget.db")

    CATEGORIES = {
        "収入": ["給与", "副業", "投資", "その他収入"],
        "支出": ["食費", "住居費", "交通費", "通信費", "娯楽", "医療",
                "教育", "衣料", "光熱費", "その他"],
    }
    INCOME_COLOR = "#4caf50"
    EXPENSE_COLOR = "#f44336"

    def __init__(self, root):
        self.root = root
        self.root.title("予算ダッシュボード")
        self.root.geometry("1060x700")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._refresh_all()

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS transactions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date TEXT NOT NULL,
                type TEXT NOT NULL,
                category TEXT,
                amount REAL NOT NULL,
                memo TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS budgets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                category TEXT UNIQUE,
                monthly_limit REAL
            )
        """)
        self.conn.commit()
        if not self.conn.execute("SELECT 1 FROM transactions").fetchone():
            self._insert_sample_data()

    def _insert_sample_data(self):
        today = date.today()
        samples = []
        # 今月のデータ
        for i in range(15):
            d = (today - timedelta(days=i)).isoformat()
            if i % 3 == 0:
                samples.append((d, "収入", "給与", 250000, "月給"))
            samples.append((d, "支出", ["食費", "交通費", "娯楽", "通信費"][i % 4],
                            [3800, 1200, 4500, 2000][i % 4], ""))
        for row in samples:
            self.conn.execute(
                "INSERT INTO transactions (date,type,category,amount,memo) "
                "VALUES (?,?,?,?,?)", row)
        for cat, limit in [("食費", 50000), ("交通費", 15000),
                            ("娯楽", 20000), ("通信費", 8000)]:
            self.conn.execute(
                "INSERT OR IGNORE INTO budgets (category,monthly_limit) VALUES (?,?)",
                (cat, limit))
        self.conn.commit()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#f57f17", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="💰 予算ダッシュボード",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        today = date.today()
        tk.Label(header, text=f"{today.year}年{today.month}月",
                 bg="#f57f17", fg="#fff9c4",
                 font=("Arial", 11)).pack(side=tk.RIGHT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ダッシュボード
        dash_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(dash_tab, text="📊 ダッシュボード")
        self._build_dashboard(dash_tab)
        notebook.bind("<<NotebookTabChanged>>",
                      lambda e: self._refresh_all())

        # 収支入力タブ
        input_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(input_tab, text="➕ 収支入力")
        self._build_input_tab(input_tab)

        # 一覧タブ
        list_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(list_tab, text="📋 一覧")
        self._build_list_tab(list_tab)

        # 予算設定タブ
        budget_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(budget_tab, text="⚙ 予算設定")
        self._build_budget_tab(budget_tab)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── ダッシュボード ────────────────────────────────────────────

    def _build_dashboard(self, parent):
        # サマリーカード
        cards_f = tk.Frame(parent, bg="#f8f9fc")
        cards_f.pack(fill=tk.X, padx=8, pady=6)
        self.summary_cards = {}
        for label, key, color in [
            ("今月の収入", "income", "#4caf50"),
            ("今月の支出", "expense", "#f44336"),
            ("収支バランス", "balance", "#2196f3"),
            ("予算達成率", "budget_rate", "#ff9800"),
        ]:
            card = tk.Frame(cards_f, bg=color, relief=tk.FLAT, padx=16, pady=10)
            card.pack(side=tk.LEFT, padx=6, expand=True, fill=tk.X)
            tk.Label(card, text=label, bg=color, fg="white",
                     font=("Arial", 9)).pack(anchor="w")
            lbl = tk.Label(card, text="—", bg=color, fg="white",
                           font=("Arial", 16, "bold"))
            lbl.pack(anchor="w")
            self.summary_cards[key] = lbl

        if not MPL_AVAILABLE:
            tk.Label(parent,
                     text="⚠ matplotlib が未インストールです (pip install matplotlib)。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)
            return

        # グラフエリア
        fig = Figure(figsize=(10, 5), facecolor="#f8f9fc")
        self.dash_axes = {
            "pie": fig.add_subplot(1, 3, 1),
            "bar": fig.add_subplot(1, 3, 2),
            "trend": fig.add_subplot(1, 3, 3),
        }
        fig.tight_layout(pad=3.0)
        self.dash_canvas_widget = FigureCanvasTkAgg(fig, master=parent)
        self.dash_canvas_widget.get_tk_widget().pack(fill=tk.BOTH, expand=True)
        self.dash_fig = fig

        ttk.Button(parent, text="🔄 更新",
                   command=self._refresh_dashboard).pack(pady=4)

    def _refresh_dashboard(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()

        income = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='収入' AND date >= ?", (month_start,)).fetchone()[0]
        expense = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='支出' AND date >= ?", (month_start,)).fetchone()[0]
        balance = income - expense

        # 予算消化率
        budgets = self.conn.execute("SELECT category, monthly_limit FROM budgets").fetchall()
        budget_pcts = []
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?", (cat, month_start)).fetchone()[0]
            if limit > 0:
                budget_pcts.append(spent / limit * 100)
        avg_budget_rate = sum(budget_pcts) / len(budget_pcts) if budget_pcts else 0

        self.summary_cards["income"].config(text=f"¥{income:,.0f}")
        self.summary_cards["expense"].config(text=f"¥{expense:,.0f}")
        bal_color = "#c8e6c9" if balance >= 0 else "#ffcdd2"
        self.summary_cards["balance"].config(
            text=f"¥{balance:+,.0f}",
            fg=bal_color)
        self.summary_cards["budget_rate"].config(
            text=f"{avg_budget_rate:.0f}%")

        if not MPL_AVAILABLE:
            return

        # パイチャート: 支出カテゴリ
        ax_pie = self.dash_axes["pie"]
        ax_pie.clear()
        cat_data = self.conn.execute(
            "SELECT category, SUM(amount) FROM transactions "
            "WHERE type='支出' AND date >= ? GROUP BY category "
            "ORDER BY SUM(amount) DESC LIMIT 8",
            (month_start,)).fetchall()
        if cat_data:
            labels = [r[0] for r in cat_data]
            sizes = [r[1] for r in cat_data]
            ax_pie.pie(sizes, labels=labels, autopct="%1.0f%%", startangle=90,
                       textprops={"fontsize": 7})
        ax_pie.set_title("支出カテゴリ", fontsize=9)

        # 棒グラフ: 予算 vs 実績
        ax_bar = self.dash_axes["bar"]
        ax_bar.clear()
        if budgets:
            cats = [b[0] for b in budgets]
            limits = [b[1] for b in budgets]
            spents = []
            for cat, _ in budgets:
                s = self.conn.execute(
                    "SELECT COALESCE(SUM(amount),0) FROM transactions "
                    "WHERE category=? AND date >= ?",
                    (cat, month_start)).fetchone()[0]
                spents.append(s)
            x = range(len(cats))
            ax_bar.bar(x, limits, alpha=0.3, color="#ff9800", label="予算")
            ax_bar.bar(x, spents, alpha=0.8, color="#f44336", label="実績")
            ax_bar.set_xticks(list(x))
            ax_bar.set_xticklabels(cats, rotation=30, ha="right", fontsize=7)
            ax_bar.legend(fontsize=7)
        ax_bar.set_title("予算 vs 実績", fontsize=9)

        # 折れ線: 月別収支
        ax_trend = self.dash_axes["trend"]
        ax_trend.clear()
        months, inc_vals, exp_vals = [], [], []
        for mo in range(5, -1, -1):
            d = today.replace(day=1) - timedelta(days=mo * 28)
            ms = d.replace(day=1).isoformat()
            if d.month == 12:
                me = d.replace(year=d.year + 1, month=1, day=1).isoformat()
            else:
                me = d.replace(month=d.month + 1, day=1).isoformat()
            inc = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='収入' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            exp = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='支出' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            months.append(f"{d.month}月")
            inc_vals.append(inc)
            exp_vals.append(exp)
        ax_trend.plot(months, inc_vals, color="#4caf50", marker="o",
                      label="収入", linewidth=2)
        ax_trend.plot(months, exp_vals, color="#f44336", marker="o",
                      label="支出", linewidth=2)
        ax_trend.legend(fontsize=7)
        ax_trend.set_title("月別収支推移", fontsize=9)
        ax_trend.tick_params(axis="x", labelsize=7)

        self.dash_fig.tight_layout(pad=2.5)
        self.dash_canvas_widget.draw()

    # ── 収支入力タブ ──────────────────────────────────────────────

    def _build_input_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}
        form = ttk.LabelFrame(parent, text="収支を入力", padding=12)
        form.pack(fill=tk.X, padx=16, pady=12)

        self.tx_type_var = tk.StringVar(value="支出")
        type_row = tk.Frame(form, bg=form.cget("background"))
        type_row.pack(fill=tk.X, pady=4)
        tk.Label(type_row, text="種別:", bg=type_row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        for t, c in [("収入", "#4caf50"), ("支出", "#f44336")]:
            tk.Radiobutton(type_row, text=t, variable=self.tx_type_var,
                           value=t, fg=c, bg=type_row.cget("bg"),
                           font=("Arial", 10),
                           command=self._update_category_choices).pack(side=tk.LEFT, padx=8)

        for lbl, attr, default in [
            ("日付 (YYYY-MM-DD):", "tx_date_var", date.today().isoformat()),
            ("金額 (円):", "tx_amount_var", ""),
            ("メモ:", "tx_memo_var", ""),
        ]:
            row = tk.Frame(form, bg=form.cget("background"))
            row.pack(fill=tk.X, pady=4)
            tk.Label(row, text=lbl, width=20, anchor="e",
                     bg=row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=default)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=24).pack(side=tk.LEFT, padx=4)

        cat_row = tk.Frame(form, bg=form.cget("background"))
        cat_row.pack(fill=tk.X, pady=4)
        tk.Label(cat_row, text="カテゴリ:", width=20, anchor="e",
                 bg=cat_row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
        self.tx_cat_var = tk.StringVar()
        self.tx_cat_cb = ttk.Combobox(cat_row, textvariable=self.tx_cat_var,
                                       state="readonly", width=16)
        self.tx_cat_cb.pack(side=tk.LEFT, padx=4)
        self._update_category_choices()

        ttk.Button(form, text="➕ 登録", command=self._add_transaction).pack(pady=8)

        # 直近の入力
        recent_f = ttk.LabelFrame(parent, text="直近の入力", padding=4)
        recent_f.pack(fill=tk.BOTH, expand=True, padx=16, pady=4)
        self.recent_tree = ttk.Treeview(recent_f,
                                         columns=("date", "type", "cat", "amount", "memo"),
                                         show="headings", height=10)
        for c, h, w in [("date", "日付", 90), ("type", "種別", 60),
                         ("cat", "カテゴリ", 90), ("amount", "金額", 90), ("memo", "メモ", 200)]:
            self.recent_tree.heading(c, text=h)
            self.recent_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(recent_f, command=self.recent_tree.yview)
        self.recent_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.recent_tree.pack(fill=tk.BOTH, expand=True)
        self.recent_tree.tag_configure("income", foreground="#2e7d32")
        self.recent_tree.tag_configure("expense", foreground="#c62828")

        ttk.Button(parent, text="🗑 削除",
                   command=self._delete_transaction).pack(padx=16, pady=4, anchor="w")

    def _update_category_choices(self):
        tx_type = self.tx_type_var.get()
        cats = self.CATEGORIES.get(tx_type, [])
        self.tx_cat_cb.configure(values=cats)
        if cats:
            self.tx_cat_var.set(cats[0])

    def _add_transaction(self):
        try:
            amount = float(self.tx_amount_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を正しく入力してください")
            return
        date_str = self.tx_date_var.get().strip()
        tx_type = self.tx_type_var.get()
        cat = self.tx_cat_var.get()
        memo = self.tx_memo_var.get().strip()
        self.conn.execute(
            "INSERT INTO transactions (date, type, category, amount, memo) "
            "VALUES (?,?,?,?,?)", (date_str, tx_type, cat, amount, memo))
        self.conn.commit()
        self.tx_amount_var.set("")
        self.tx_memo_var.set("")
        self._refresh_all()
        self.status_var.set(f"登録: {tx_type} {cat} ¥{amount:,.0f}")

    def _delete_transaction(self):
        sel = self.recent_tree.selection()
        if sel and messagebox.askyesno("確認", "削除しますか?"):
            self.conn.execute("DELETE FROM transactions WHERE id=?", (int(sel[0]),))
            self.conn.commit()
            self._refresh_all()

    # ── 一覧タブ ──────────────────────────────────────────────────

    def _build_list_tab(self, parent):
        filter_f = tk.Frame(parent, bg="#f8f9fc")
        filter_f.pack(fill=tk.X, padx=8, pady=6)
        tk.Label(filter_f, text="種別:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.list_type_var = tk.StringVar(value="すべて")
        ttk.Combobox(filter_f, textvariable=self.list_type_var,
                     values=["すべて", "収入", "支出"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)
        tk.Label(filter_f, text="月:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        today = date.today()
        self.list_month_var = tk.StringVar(value=f"{today.year}-{today.month:02d}")
        ttk.Entry(filter_f, textvariable=self.list_month_var, width=8).pack(side=tk.LEFT)
        ttk.Button(filter_f, text="🔍 絞り込み",
                   command=self._refresh_list).pack(side=tk.LEFT, padx=6)

        cols = ("id", "date", "type", "category", "amount", "memo")
        self.list_tree = ttk.Treeview(parent, columns=cols, show="headings", height=20)
        for c, h, w in [("id", "ID", 40), ("date", "日付", 90), ("type", "種別", 60),
                         ("category", "カテゴリ", 90), ("amount", "金額", 100),
                         ("memo", "メモ", 200)]:
            self.list_tree.heading(c, text=h)
            self.list_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(parent, command=self.list_tree.yview)
        self.list_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.list_tree.pack(fill=tk.BOTH, expand=True, padx=8)
        self.list_tree.tag_configure("income", foreground="#2e7d32")
        self.list_tree.tag_configure("expense", foreground="#c62828")

    def _refresh_list(self):
        tx_type = self.list_type_var.get()
        month = self.list_month_var.get()
        sql = ("SELECT id, date, type, category, amount, memo FROM transactions "
               "WHERE date LIKE ? ")
        params = [f"{month}%"]
        if tx_type != "すべて":
            sql += "AND type=? "
            params.append(tx_type)
        sql += "ORDER BY date DESC LIMIT 200"
        rows = self.conn.execute(sql, params).fetchall()
        self.list_tree.delete(*self.list_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.list_tree.insert("", "end", iid=str(tid),
                                   values=(tid, d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                   tags=(tag,))

    # ── 予算設定タブ ──────────────────────────────────────────────

    def _build_budget_tab(self, parent):
        tk.Label(parent, text="カテゴリ別の月次予算上限を設定します",
                 bg="#f8f9fc", font=("Arial", 10)).pack(anchor="w", padx=8, pady=4)
        cols = ("cat", "limit", "spent", "remaining", "rate")
        self.budget_tree = ttk.Treeview(parent, columns=cols, show="headings", height=12)
        for c, h, w in [("cat", "カテゴリ", 100), ("limit", "予算上限", 100),
                         ("spent", "今月の支出", 100), ("remaining", "残り", 100),
                         ("rate", "消化率", 80)]:
            self.budget_tree.heading(c, text=h)
            self.budget_tree.column(c, width=w, minwidth=40)
        self.budget_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.budget_tree.tag_configure("over", foreground="#f44336")
        self.budget_tree.tag_configure("warn", foreground="#ff9800")
        self.budget_tree.tag_configure("ok", foreground="#2e7d32")

        add_f = ttk.LabelFrame(parent, text="予算を設定", padding=8)
        add_f.pack(fill=tk.X, padx=8, pady=4)
        row = tk.Frame(add_f, bg=add_f.cget("background"))
        row.pack(fill=tk.X)
        tk.Label(row, text="カテゴリ:", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.budget_cat_var = tk.StringVar()
        all_cats = self.CATEGORIES["支出"]
        ttk.Combobox(row, textvariable=self.budget_cat_var,
                     values=all_cats, state="readonly", width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row, text="上限(円):", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT, padx=(8, 0))
        self.budget_limit_var = tk.StringVar(value="30000")
        ttk.Entry(row, textvariable=self.budget_limit_var, width=12).pack(side=tk.LEFT, padx=4)
        ttk.Button(row, text="💾 設定",
                   command=self._save_budget).pack(side=tk.LEFT, padx=8)

    def _save_budget(self):
        cat = self.budget_cat_var.get()
        if not cat:
            return
        try:
            limit = float(self.budget_limit_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を入力してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO budgets (category, monthly_limit) VALUES (?,?)",
            (cat, limit))
        self.conn.commit()
        self._refresh_budget()

    def _refresh_budget(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()
        budgets = self.conn.execute(
            "SELECT category, monthly_limit FROM budgets").fetchall()
        self.budget_tree.delete(*self.budget_tree.get_children())
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?",
                (cat, month_start)).fetchone()[0]
            remaining = limit - spent
            rate = spent / limit * 100 if limit else 0
            tag = "over" if rate >= 100 else "warn" if rate >= 80 else "ok"
            self.budget_tree.insert("", "end",
                                     values=(cat, f"¥{limit:,.0f}", f"¥{spent:,.0f}",
                                             f"¥{remaining:,.0f}", f"{rate:.0f}%"),
                                     tags=(tag,))

    def _refresh_all(self):
        self._refresh_dashboard()
        self._refresh_list()
        self._refresh_budget()
        self._refresh_recent()

    def _refresh_recent(self):
        rows = self.conn.execute(
            "SELECT id, date, type, category, amount, memo FROM transactions "
            "ORDER BY id DESC LIMIT 30").fetchall()
        self.recent_tree.delete(*self.recent_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.recent_tree.insert("", "end", iid=str(tid),
                                     values=(d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                     tags=(tag,))


if __name__ == "__main__":
    root = tk.Tk()
    app = App44(root)
    root.mainloop()

Textウィジェットでの結果表示

結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
from datetime import datetime, date, timedelta

try:
    import matplotlib
    matplotlib.use("TkAgg")
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import matplotlib.pyplot as plt
    MPL_AVAILABLE = True
except ImportError:
    MPL_AVAILABLE = False


class App44:
    """予算ダッシュボード"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "budget.db")

    CATEGORIES = {
        "収入": ["給与", "副業", "投資", "その他収入"],
        "支出": ["食費", "住居費", "交通費", "通信費", "娯楽", "医療",
                "教育", "衣料", "光熱費", "その他"],
    }
    INCOME_COLOR = "#4caf50"
    EXPENSE_COLOR = "#f44336"

    def __init__(self, root):
        self.root = root
        self.root.title("予算ダッシュボード")
        self.root.geometry("1060x700")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._refresh_all()

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS transactions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date TEXT NOT NULL,
                type TEXT NOT NULL,
                category TEXT,
                amount REAL NOT NULL,
                memo TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS budgets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                category TEXT UNIQUE,
                monthly_limit REAL
            )
        """)
        self.conn.commit()
        if not self.conn.execute("SELECT 1 FROM transactions").fetchone():
            self._insert_sample_data()

    def _insert_sample_data(self):
        today = date.today()
        samples = []
        # 今月のデータ
        for i in range(15):
            d = (today - timedelta(days=i)).isoformat()
            if i % 3 == 0:
                samples.append((d, "収入", "給与", 250000, "月給"))
            samples.append((d, "支出", ["食費", "交通費", "娯楽", "通信費"][i % 4],
                            [3800, 1200, 4500, 2000][i % 4], ""))
        for row in samples:
            self.conn.execute(
                "INSERT INTO transactions (date,type,category,amount,memo) "
                "VALUES (?,?,?,?,?)", row)
        for cat, limit in [("食費", 50000), ("交通費", 15000),
                            ("娯楽", 20000), ("通信費", 8000)]:
            self.conn.execute(
                "INSERT OR IGNORE INTO budgets (category,monthly_limit) VALUES (?,?)",
                (cat, limit))
        self.conn.commit()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#f57f17", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="💰 予算ダッシュボード",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        today = date.today()
        tk.Label(header, text=f"{today.year}年{today.month}月",
                 bg="#f57f17", fg="#fff9c4",
                 font=("Arial", 11)).pack(side=tk.RIGHT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ダッシュボード
        dash_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(dash_tab, text="📊 ダッシュボード")
        self._build_dashboard(dash_tab)
        notebook.bind("<<NotebookTabChanged>>",
                      lambda e: self._refresh_all())

        # 収支入力タブ
        input_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(input_tab, text="➕ 収支入力")
        self._build_input_tab(input_tab)

        # 一覧タブ
        list_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(list_tab, text="📋 一覧")
        self._build_list_tab(list_tab)

        # 予算設定タブ
        budget_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(budget_tab, text="⚙ 予算設定")
        self._build_budget_tab(budget_tab)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── ダッシュボード ────────────────────────────────────────────

    def _build_dashboard(self, parent):
        # サマリーカード
        cards_f = tk.Frame(parent, bg="#f8f9fc")
        cards_f.pack(fill=tk.X, padx=8, pady=6)
        self.summary_cards = {}
        for label, key, color in [
            ("今月の収入", "income", "#4caf50"),
            ("今月の支出", "expense", "#f44336"),
            ("収支バランス", "balance", "#2196f3"),
            ("予算達成率", "budget_rate", "#ff9800"),
        ]:
            card = tk.Frame(cards_f, bg=color, relief=tk.FLAT, padx=16, pady=10)
            card.pack(side=tk.LEFT, padx=6, expand=True, fill=tk.X)
            tk.Label(card, text=label, bg=color, fg="white",
                     font=("Arial", 9)).pack(anchor="w")
            lbl = tk.Label(card, text="—", bg=color, fg="white",
                           font=("Arial", 16, "bold"))
            lbl.pack(anchor="w")
            self.summary_cards[key] = lbl

        if not MPL_AVAILABLE:
            tk.Label(parent,
                     text="⚠ matplotlib が未インストールです (pip install matplotlib)。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)
            return

        # グラフエリア
        fig = Figure(figsize=(10, 5), facecolor="#f8f9fc")
        self.dash_axes = {
            "pie": fig.add_subplot(1, 3, 1),
            "bar": fig.add_subplot(1, 3, 2),
            "trend": fig.add_subplot(1, 3, 3),
        }
        fig.tight_layout(pad=3.0)
        self.dash_canvas_widget = FigureCanvasTkAgg(fig, master=parent)
        self.dash_canvas_widget.get_tk_widget().pack(fill=tk.BOTH, expand=True)
        self.dash_fig = fig

        ttk.Button(parent, text="🔄 更新",
                   command=self._refresh_dashboard).pack(pady=4)

    def _refresh_dashboard(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()

        income = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='収入' AND date >= ?", (month_start,)).fetchone()[0]
        expense = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='支出' AND date >= ?", (month_start,)).fetchone()[0]
        balance = income - expense

        # 予算消化率
        budgets = self.conn.execute("SELECT category, monthly_limit FROM budgets").fetchall()
        budget_pcts = []
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?", (cat, month_start)).fetchone()[0]
            if limit > 0:
                budget_pcts.append(spent / limit * 100)
        avg_budget_rate = sum(budget_pcts) / len(budget_pcts) if budget_pcts else 0

        self.summary_cards["income"].config(text=f"¥{income:,.0f}")
        self.summary_cards["expense"].config(text=f"¥{expense:,.0f}")
        bal_color = "#c8e6c9" if balance >= 0 else "#ffcdd2"
        self.summary_cards["balance"].config(
            text=f"¥{balance:+,.0f}",
            fg=bal_color)
        self.summary_cards["budget_rate"].config(
            text=f"{avg_budget_rate:.0f}%")

        if not MPL_AVAILABLE:
            return

        # パイチャート: 支出カテゴリ
        ax_pie = self.dash_axes["pie"]
        ax_pie.clear()
        cat_data = self.conn.execute(
            "SELECT category, SUM(amount) FROM transactions "
            "WHERE type='支出' AND date >= ? GROUP BY category "
            "ORDER BY SUM(amount) DESC LIMIT 8",
            (month_start,)).fetchall()
        if cat_data:
            labels = [r[0] for r in cat_data]
            sizes = [r[1] for r in cat_data]
            ax_pie.pie(sizes, labels=labels, autopct="%1.0f%%", startangle=90,
                       textprops={"fontsize": 7})
        ax_pie.set_title("支出カテゴリ", fontsize=9)

        # 棒グラフ: 予算 vs 実績
        ax_bar = self.dash_axes["bar"]
        ax_bar.clear()
        if budgets:
            cats = [b[0] for b in budgets]
            limits = [b[1] for b in budgets]
            spents = []
            for cat, _ in budgets:
                s = self.conn.execute(
                    "SELECT COALESCE(SUM(amount),0) FROM transactions "
                    "WHERE category=? AND date >= ?",
                    (cat, month_start)).fetchone()[0]
                spents.append(s)
            x = range(len(cats))
            ax_bar.bar(x, limits, alpha=0.3, color="#ff9800", label="予算")
            ax_bar.bar(x, spents, alpha=0.8, color="#f44336", label="実績")
            ax_bar.set_xticks(list(x))
            ax_bar.set_xticklabels(cats, rotation=30, ha="right", fontsize=7)
            ax_bar.legend(fontsize=7)
        ax_bar.set_title("予算 vs 実績", fontsize=9)

        # 折れ線: 月別収支
        ax_trend = self.dash_axes["trend"]
        ax_trend.clear()
        months, inc_vals, exp_vals = [], [], []
        for mo in range(5, -1, -1):
            d = today.replace(day=1) - timedelta(days=mo * 28)
            ms = d.replace(day=1).isoformat()
            if d.month == 12:
                me = d.replace(year=d.year + 1, month=1, day=1).isoformat()
            else:
                me = d.replace(month=d.month + 1, day=1).isoformat()
            inc = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='収入' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            exp = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='支出' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            months.append(f"{d.month}月")
            inc_vals.append(inc)
            exp_vals.append(exp)
        ax_trend.plot(months, inc_vals, color="#4caf50", marker="o",
                      label="収入", linewidth=2)
        ax_trend.plot(months, exp_vals, color="#f44336", marker="o",
                      label="支出", linewidth=2)
        ax_trend.legend(fontsize=7)
        ax_trend.set_title("月別収支推移", fontsize=9)
        ax_trend.tick_params(axis="x", labelsize=7)

        self.dash_fig.tight_layout(pad=2.5)
        self.dash_canvas_widget.draw()

    # ── 収支入力タブ ──────────────────────────────────────────────

    def _build_input_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}
        form = ttk.LabelFrame(parent, text="収支を入力", padding=12)
        form.pack(fill=tk.X, padx=16, pady=12)

        self.tx_type_var = tk.StringVar(value="支出")
        type_row = tk.Frame(form, bg=form.cget("background"))
        type_row.pack(fill=tk.X, pady=4)
        tk.Label(type_row, text="種別:", bg=type_row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        for t, c in [("収入", "#4caf50"), ("支出", "#f44336")]:
            tk.Radiobutton(type_row, text=t, variable=self.tx_type_var,
                           value=t, fg=c, bg=type_row.cget("bg"),
                           font=("Arial", 10),
                           command=self._update_category_choices).pack(side=tk.LEFT, padx=8)

        for lbl, attr, default in [
            ("日付 (YYYY-MM-DD):", "tx_date_var", date.today().isoformat()),
            ("金額 (円):", "tx_amount_var", ""),
            ("メモ:", "tx_memo_var", ""),
        ]:
            row = tk.Frame(form, bg=form.cget("background"))
            row.pack(fill=tk.X, pady=4)
            tk.Label(row, text=lbl, width=20, anchor="e",
                     bg=row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=default)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=24).pack(side=tk.LEFT, padx=4)

        cat_row = tk.Frame(form, bg=form.cget("background"))
        cat_row.pack(fill=tk.X, pady=4)
        tk.Label(cat_row, text="カテゴリ:", width=20, anchor="e",
                 bg=cat_row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
        self.tx_cat_var = tk.StringVar()
        self.tx_cat_cb = ttk.Combobox(cat_row, textvariable=self.tx_cat_var,
                                       state="readonly", width=16)
        self.tx_cat_cb.pack(side=tk.LEFT, padx=4)
        self._update_category_choices()

        ttk.Button(form, text="➕ 登録", command=self._add_transaction).pack(pady=8)

        # 直近の入力
        recent_f = ttk.LabelFrame(parent, text="直近の入力", padding=4)
        recent_f.pack(fill=tk.BOTH, expand=True, padx=16, pady=4)
        self.recent_tree = ttk.Treeview(recent_f,
                                         columns=("date", "type", "cat", "amount", "memo"),
                                         show="headings", height=10)
        for c, h, w in [("date", "日付", 90), ("type", "種別", 60),
                         ("cat", "カテゴリ", 90), ("amount", "金額", 90), ("memo", "メモ", 200)]:
            self.recent_tree.heading(c, text=h)
            self.recent_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(recent_f, command=self.recent_tree.yview)
        self.recent_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.recent_tree.pack(fill=tk.BOTH, expand=True)
        self.recent_tree.tag_configure("income", foreground="#2e7d32")
        self.recent_tree.tag_configure("expense", foreground="#c62828")

        ttk.Button(parent, text="🗑 削除",
                   command=self._delete_transaction).pack(padx=16, pady=4, anchor="w")

    def _update_category_choices(self):
        tx_type = self.tx_type_var.get()
        cats = self.CATEGORIES.get(tx_type, [])
        self.tx_cat_cb.configure(values=cats)
        if cats:
            self.tx_cat_var.set(cats[0])

    def _add_transaction(self):
        try:
            amount = float(self.tx_amount_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を正しく入力してください")
            return
        date_str = self.tx_date_var.get().strip()
        tx_type = self.tx_type_var.get()
        cat = self.tx_cat_var.get()
        memo = self.tx_memo_var.get().strip()
        self.conn.execute(
            "INSERT INTO transactions (date, type, category, amount, memo) "
            "VALUES (?,?,?,?,?)", (date_str, tx_type, cat, amount, memo))
        self.conn.commit()
        self.tx_amount_var.set("")
        self.tx_memo_var.set("")
        self._refresh_all()
        self.status_var.set(f"登録: {tx_type} {cat} ¥{amount:,.0f}")

    def _delete_transaction(self):
        sel = self.recent_tree.selection()
        if sel and messagebox.askyesno("確認", "削除しますか?"):
            self.conn.execute("DELETE FROM transactions WHERE id=?", (int(sel[0]),))
            self.conn.commit()
            self._refresh_all()

    # ── 一覧タブ ──────────────────────────────────────────────────

    def _build_list_tab(self, parent):
        filter_f = tk.Frame(parent, bg="#f8f9fc")
        filter_f.pack(fill=tk.X, padx=8, pady=6)
        tk.Label(filter_f, text="種別:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.list_type_var = tk.StringVar(value="すべて")
        ttk.Combobox(filter_f, textvariable=self.list_type_var,
                     values=["すべて", "収入", "支出"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)
        tk.Label(filter_f, text="月:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        today = date.today()
        self.list_month_var = tk.StringVar(value=f"{today.year}-{today.month:02d}")
        ttk.Entry(filter_f, textvariable=self.list_month_var, width=8).pack(side=tk.LEFT)
        ttk.Button(filter_f, text="🔍 絞り込み",
                   command=self._refresh_list).pack(side=tk.LEFT, padx=6)

        cols = ("id", "date", "type", "category", "amount", "memo")
        self.list_tree = ttk.Treeview(parent, columns=cols, show="headings", height=20)
        for c, h, w in [("id", "ID", 40), ("date", "日付", 90), ("type", "種別", 60),
                         ("category", "カテゴリ", 90), ("amount", "金額", 100),
                         ("memo", "メモ", 200)]:
            self.list_tree.heading(c, text=h)
            self.list_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(parent, command=self.list_tree.yview)
        self.list_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.list_tree.pack(fill=tk.BOTH, expand=True, padx=8)
        self.list_tree.tag_configure("income", foreground="#2e7d32")
        self.list_tree.tag_configure("expense", foreground="#c62828")

    def _refresh_list(self):
        tx_type = self.list_type_var.get()
        month = self.list_month_var.get()
        sql = ("SELECT id, date, type, category, amount, memo FROM transactions "
               "WHERE date LIKE ? ")
        params = [f"{month}%"]
        if tx_type != "すべて":
            sql += "AND type=? "
            params.append(tx_type)
        sql += "ORDER BY date DESC LIMIT 200"
        rows = self.conn.execute(sql, params).fetchall()
        self.list_tree.delete(*self.list_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.list_tree.insert("", "end", iid=str(tid),
                                   values=(tid, d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                   tags=(tag,))

    # ── 予算設定タブ ──────────────────────────────────────────────

    def _build_budget_tab(self, parent):
        tk.Label(parent, text="カテゴリ別の月次予算上限を設定します",
                 bg="#f8f9fc", font=("Arial", 10)).pack(anchor="w", padx=8, pady=4)
        cols = ("cat", "limit", "spent", "remaining", "rate")
        self.budget_tree = ttk.Treeview(parent, columns=cols, show="headings", height=12)
        for c, h, w in [("cat", "カテゴリ", 100), ("limit", "予算上限", 100),
                         ("spent", "今月の支出", 100), ("remaining", "残り", 100),
                         ("rate", "消化率", 80)]:
            self.budget_tree.heading(c, text=h)
            self.budget_tree.column(c, width=w, minwidth=40)
        self.budget_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.budget_tree.tag_configure("over", foreground="#f44336")
        self.budget_tree.tag_configure("warn", foreground="#ff9800")
        self.budget_tree.tag_configure("ok", foreground="#2e7d32")

        add_f = ttk.LabelFrame(parent, text="予算を設定", padding=8)
        add_f.pack(fill=tk.X, padx=8, pady=4)
        row = tk.Frame(add_f, bg=add_f.cget("background"))
        row.pack(fill=tk.X)
        tk.Label(row, text="カテゴリ:", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.budget_cat_var = tk.StringVar()
        all_cats = self.CATEGORIES["支出"]
        ttk.Combobox(row, textvariable=self.budget_cat_var,
                     values=all_cats, state="readonly", width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row, text="上限(円):", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT, padx=(8, 0))
        self.budget_limit_var = tk.StringVar(value="30000")
        ttk.Entry(row, textvariable=self.budget_limit_var, width=12).pack(side=tk.LEFT, padx=4)
        ttk.Button(row, text="💾 設定",
                   command=self._save_budget).pack(side=tk.LEFT, padx=8)

    def _save_budget(self):
        cat = self.budget_cat_var.get()
        if not cat:
            return
        try:
            limit = float(self.budget_limit_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を入力してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO budgets (category, monthly_limit) VALUES (?,?)",
            (cat, limit))
        self.conn.commit()
        self._refresh_budget()

    def _refresh_budget(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()
        budgets = self.conn.execute(
            "SELECT category, monthly_limit FROM budgets").fetchall()
        self.budget_tree.delete(*self.budget_tree.get_children())
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?",
                (cat, month_start)).fetchone()[0]
            remaining = limit - spent
            rate = spent / limit * 100 if limit else 0
            tag = "over" if rate >= 100 else "warn" if rate >= 80 else "ok"
            self.budget_tree.insert("", "end",
                                     values=(cat, f"¥{limit:,.0f}", f"¥{spent:,.0f}",
                                             f"¥{remaining:,.0f}", f"{rate:.0f}%"),
                                     tags=(tag,))

    def _refresh_all(self):
        self._refresh_dashboard()
        self._refresh_list()
        self._refresh_budget()
        self._refresh_recent()

    def _refresh_recent(self):
        rows = self.conn.execute(
            "SELECT id, date, type, category, amount, memo FROM transactions "
            "ORDER BY id DESC LIMIT 30").fetchall()
        self.recent_tree.delete(*self.recent_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.recent_tree.insert("", "end", iid=str(tid),
                                     values=(d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                     tags=(tag,))


if __name__ == "__main__":
    root = tk.Tk()
    app = App44(root)
    root.mainloop()

例外処理とmessagebox

try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。

import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
from datetime import datetime, date, timedelta

try:
    import matplotlib
    matplotlib.use("TkAgg")
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
    import matplotlib.pyplot as plt
    MPL_AVAILABLE = True
except ImportError:
    MPL_AVAILABLE = False


class App44:
    """予算ダッシュボード"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "budget.db")

    CATEGORIES = {
        "収入": ["給与", "副業", "投資", "その他収入"],
        "支出": ["食費", "住居費", "交通費", "通信費", "娯楽", "医療",
                "教育", "衣料", "光熱費", "その他"],
    }
    INCOME_COLOR = "#4caf50"
    EXPENSE_COLOR = "#f44336"

    def __init__(self, root):
        self.root = root
        self.root.title("予算ダッシュボード")
        self.root.geometry("1060x700")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._refresh_all()

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS transactions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date TEXT NOT NULL,
                type TEXT NOT NULL,
                category TEXT,
                amount REAL NOT NULL,
                memo TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS budgets (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                category TEXT UNIQUE,
                monthly_limit REAL
            )
        """)
        self.conn.commit()
        if not self.conn.execute("SELECT 1 FROM transactions").fetchone():
            self._insert_sample_data()

    def _insert_sample_data(self):
        today = date.today()
        samples = []
        # 今月のデータ
        for i in range(15):
            d = (today - timedelta(days=i)).isoformat()
            if i % 3 == 0:
                samples.append((d, "収入", "給与", 250000, "月給"))
            samples.append((d, "支出", ["食費", "交通費", "娯楽", "通信費"][i % 4],
                            [3800, 1200, 4500, 2000][i % 4], ""))
        for row in samples:
            self.conn.execute(
                "INSERT INTO transactions (date,type,category,amount,memo) "
                "VALUES (?,?,?,?,?)", row)
        for cat, limit in [("食費", 50000), ("交通費", 15000),
                            ("娯楽", 20000), ("通信費", 8000)]:
            self.conn.execute(
                "INSERT OR IGNORE INTO budgets (category,monthly_limit) VALUES (?,?)",
                (cat, limit))
        self.conn.commit()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#f57f17", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="💰 予算ダッシュボード",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        today = date.today()
        tk.Label(header, text=f"{today.year}年{today.month}月",
                 bg="#f57f17", fg="#fff9c4",
                 font=("Arial", 11)).pack(side=tk.RIGHT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ダッシュボード
        dash_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(dash_tab, text="📊 ダッシュボード")
        self._build_dashboard(dash_tab)
        notebook.bind("<<NotebookTabChanged>>",
                      lambda e: self._refresh_all())

        # 収支入力タブ
        input_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(input_tab, text="➕ 収支入力")
        self._build_input_tab(input_tab)

        # 一覧タブ
        list_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(list_tab, text="📋 一覧")
        self._build_list_tab(list_tab)

        # 予算設定タブ
        budget_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(budget_tab, text="⚙ 予算設定")
        self._build_budget_tab(budget_tab)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── ダッシュボード ────────────────────────────────────────────

    def _build_dashboard(self, parent):
        # サマリーカード
        cards_f = tk.Frame(parent, bg="#f8f9fc")
        cards_f.pack(fill=tk.X, padx=8, pady=6)
        self.summary_cards = {}
        for label, key, color in [
            ("今月の収入", "income", "#4caf50"),
            ("今月の支出", "expense", "#f44336"),
            ("収支バランス", "balance", "#2196f3"),
            ("予算達成率", "budget_rate", "#ff9800"),
        ]:
            card = tk.Frame(cards_f, bg=color, relief=tk.FLAT, padx=16, pady=10)
            card.pack(side=tk.LEFT, padx=6, expand=True, fill=tk.X)
            tk.Label(card, text=label, bg=color, fg="white",
                     font=("Arial", 9)).pack(anchor="w")
            lbl = tk.Label(card, text="—", bg=color, fg="white",
                           font=("Arial", 16, "bold"))
            lbl.pack(anchor="w")
            self.summary_cards[key] = lbl

        if not MPL_AVAILABLE:
            tk.Label(parent,
                     text="⚠ matplotlib が未インストールです (pip install matplotlib)。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)
            return

        # グラフエリア
        fig = Figure(figsize=(10, 5), facecolor="#f8f9fc")
        self.dash_axes = {
            "pie": fig.add_subplot(1, 3, 1),
            "bar": fig.add_subplot(1, 3, 2),
            "trend": fig.add_subplot(1, 3, 3),
        }
        fig.tight_layout(pad=3.0)
        self.dash_canvas_widget = FigureCanvasTkAgg(fig, master=parent)
        self.dash_canvas_widget.get_tk_widget().pack(fill=tk.BOTH, expand=True)
        self.dash_fig = fig

        ttk.Button(parent, text="🔄 更新",
                   command=self._refresh_dashboard).pack(pady=4)

    def _refresh_dashboard(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()

        income = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='収入' AND date >= ?", (month_start,)).fetchone()[0]
        expense = self.conn.execute(
            "SELECT COALESCE(SUM(amount),0) FROM transactions "
            "WHERE type='支出' AND date >= ?", (month_start,)).fetchone()[0]
        balance = income - expense

        # 予算消化率
        budgets = self.conn.execute("SELECT category, monthly_limit FROM budgets").fetchall()
        budget_pcts = []
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?", (cat, month_start)).fetchone()[0]
            if limit > 0:
                budget_pcts.append(spent / limit * 100)
        avg_budget_rate = sum(budget_pcts) / len(budget_pcts) if budget_pcts else 0

        self.summary_cards["income"].config(text=f"¥{income:,.0f}")
        self.summary_cards["expense"].config(text=f"¥{expense:,.0f}")
        bal_color = "#c8e6c9" if balance >= 0 else "#ffcdd2"
        self.summary_cards["balance"].config(
            text=f"¥{balance:+,.0f}",
            fg=bal_color)
        self.summary_cards["budget_rate"].config(
            text=f"{avg_budget_rate:.0f}%")

        if not MPL_AVAILABLE:
            return

        # パイチャート: 支出カテゴリ
        ax_pie = self.dash_axes["pie"]
        ax_pie.clear()
        cat_data = self.conn.execute(
            "SELECT category, SUM(amount) FROM transactions "
            "WHERE type='支出' AND date >= ? GROUP BY category "
            "ORDER BY SUM(amount) DESC LIMIT 8",
            (month_start,)).fetchall()
        if cat_data:
            labels = [r[0] for r in cat_data]
            sizes = [r[1] for r in cat_data]
            ax_pie.pie(sizes, labels=labels, autopct="%1.0f%%", startangle=90,
                       textprops={"fontsize": 7})
        ax_pie.set_title("支出カテゴリ", fontsize=9)

        # 棒グラフ: 予算 vs 実績
        ax_bar = self.dash_axes["bar"]
        ax_bar.clear()
        if budgets:
            cats = [b[0] for b in budgets]
            limits = [b[1] for b in budgets]
            spents = []
            for cat, _ in budgets:
                s = self.conn.execute(
                    "SELECT COALESCE(SUM(amount),0) FROM transactions "
                    "WHERE category=? AND date >= ?",
                    (cat, month_start)).fetchone()[0]
                spents.append(s)
            x = range(len(cats))
            ax_bar.bar(x, limits, alpha=0.3, color="#ff9800", label="予算")
            ax_bar.bar(x, spents, alpha=0.8, color="#f44336", label="実績")
            ax_bar.set_xticks(list(x))
            ax_bar.set_xticklabels(cats, rotation=30, ha="right", fontsize=7)
            ax_bar.legend(fontsize=7)
        ax_bar.set_title("予算 vs 実績", fontsize=9)

        # 折れ線: 月別収支
        ax_trend = self.dash_axes["trend"]
        ax_trend.clear()
        months, inc_vals, exp_vals = [], [], []
        for mo in range(5, -1, -1):
            d = today.replace(day=1) - timedelta(days=mo * 28)
            ms = d.replace(day=1).isoformat()
            if d.month == 12:
                me = d.replace(year=d.year + 1, month=1, day=1).isoformat()
            else:
                me = d.replace(month=d.month + 1, day=1).isoformat()
            inc = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='収入' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            exp = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE type='支出' AND date >= ? AND date < ?",
                (ms, me)).fetchone()[0]
            months.append(f"{d.month}月")
            inc_vals.append(inc)
            exp_vals.append(exp)
        ax_trend.plot(months, inc_vals, color="#4caf50", marker="o",
                      label="収入", linewidth=2)
        ax_trend.plot(months, exp_vals, color="#f44336", marker="o",
                      label="支出", linewidth=2)
        ax_trend.legend(fontsize=7)
        ax_trend.set_title("月別収支推移", fontsize=9)
        ax_trend.tick_params(axis="x", labelsize=7)

        self.dash_fig.tight_layout(pad=2.5)
        self.dash_canvas_widget.draw()

    # ── 収支入力タブ ──────────────────────────────────────────────

    def _build_input_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}
        form = ttk.LabelFrame(parent, text="収支を入力", padding=12)
        form.pack(fill=tk.X, padx=16, pady=12)

        self.tx_type_var = tk.StringVar(value="支出")
        type_row = tk.Frame(form, bg=form.cget("background"))
        type_row.pack(fill=tk.X, pady=4)
        tk.Label(type_row, text="種別:", bg=type_row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        for t, c in [("収入", "#4caf50"), ("支出", "#f44336")]:
            tk.Radiobutton(type_row, text=t, variable=self.tx_type_var,
                           value=t, fg=c, bg=type_row.cget("bg"),
                           font=("Arial", 10),
                           command=self._update_category_choices).pack(side=tk.LEFT, padx=8)

        for lbl, attr, default in [
            ("日付 (YYYY-MM-DD):", "tx_date_var", date.today().isoformat()),
            ("金額 (円):", "tx_amount_var", ""),
            ("メモ:", "tx_memo_var", ""),
        ]:
            row = tk.Frame(form, bg=form.cget("background"))
            row.pack(fill=tk.X, pady=4)
            tk.Label(row, text=lbl, width=20, anchor="e",
                     bg=row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=default)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=24).pack(side=tk.LEFT, padx=4)

        cat_row = tk.Frame(form, bg=form.cget("background"))
        cat_row.pack(fill=tk.X, pady=4)
        tk.Label(cat_row, text="カテゴリ:", width=20, anchor="e",
                 bg=cat_row.cget("bg"), font=("Arial", 10)).pack(side=tk.LEFT)
        self.tx_cat_var = tk.StringVar()
        self.tx_cat_cb = ttk.Combobox(cat_row, textvariable=self.tx_cat_var,
                                       state="readonly", width=16)
        self.tx_cat_cb.pack(side=tk.LEFT, padx=4)
        self._update_category_choices()

        ttk.Button(form, text="➕ 登録", command=self._add_transaction).pack(pady=8)

        # 直近の入力
        recent_f = ttk.LabelFrame(parent, text="直近の入力", padding=4)
        recent_f.pack(fill=tk.BOTH, expand=True, padx=16, pady=4)
        self.recent_tree = ttk.Treeview(recent_f,
                                         columns=("date", "type", "cat", "amount", "memo"),
                                         show="headings", height=10)
        for c, h, w in [("date", "日付", 90), ("type", "種別", 60),
                         ("cat", "カテゴリ", 90), ("amount", "金額", 90), ("memo", "メモ", 200)]:
            self.recent_tree.heading(c, text=h)
            self.recent_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(recent_f, command=self.recent_tree.yview)
        self.recent_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.recent_tree.pack(fill=tk.BOTH, expand=True)
        self.recent_tree.tag_configure("income", foreground="#2e7d32")
        self.recent_tree.tag_configure("expense", foreground="#c62828")

        ttk.Button(parent, text="🗑 削除",
                   command=self._delete_transaction).pack(padx=16, pady=4, anchor="w")

    def _update_category_choices(self):
        tx_type = self.tx_type_var.get()
        cats = self.CATEGORIES.get(tx_type, [])
        self.tx_cat_cb.configure(values=cats)
        if cats:
            self.tx_cat_var.set(cats[0])

    def _add_transaction(self):
        try:
            amount = float(self.tx_amount_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を正しく入力してください")
            return
        date_str = self.tx_date_var.get().strip()
        tx_type = self.tx_type_var.get()
        cat = self.tx_cat_var.get()
        memo = self.tx_memo_var.get().strip()
        self.conn.execute(
            "INSERT INTO transactions (date, type, category, amount, memo) "
            "VALUES (?,?,?,?,?)", (date_str, tx_type, cat, amount, memo))
        self.conn.commit()
        self.tx_amount_var.set("")
        self.tx_memo_var.set("")
        self._refresh_all()
        self.status_var.set(f"登録: {tx_type} {cat} ¥{amount:,.0f}")

    def _delete_transaction(self):
        sel = self.recent_tree.selection()
        if sel and messagebox.askyesno("確認", "削除しますか?"):
            self.conn.execute("DELETE FROM transactions WHERE id=?", (int(sel[0]),))
            self.conn.commit()
            self._refresh_all()

    # ── 一覧タブ ──────────────────────────────────────────────────

    def _build_list_tab(self, parent):
        filter_f = tk.Frame(parent, bg="#f8f9fc")
        filter_f.pack(fill=tk.X, padx=8, pady=6)
        tk.Label(filter_f, text="種別:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.list_type_var = tk.StringVar(value="すべて")
        ttk.Combobox(filter_f, textvariable=self.list_type_var,
                     values=["すべて", "収入", "支出"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)
        tk.Label(filter_f, text="月:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        today = date.today()
        self.list_month_var = tk.StringVar(value=f"{today.year}-{today.month:02d}")
        ttk.Entry(filter_f, textvariable=self.list_month_var, width=8).pack(side=tk.LEFT)
        ttk.Button(filter_f, text="🔍 絞り込み",
                   command=self._refresh_list).pack(side=tk.LEFT, padx=6)

        cols = ("id", "date", "type", "category", "amount", "memo")
        self.list_tree = ttk.Treeview(parent, columns=cols, show="headings", height=20)
        for c, h, w in [("id", "ID", 40), ("date", "日付", 90), ("type", "種別", 60),
                         ("category", "カテゴリ", 90), ("amount", "金額", 100),
                         ("memo", "メモ", 200)]:
            self.list_tree.heading(c, text=h)
            self.list_tree.column(c, width=w, minwidth=30)
        sb = ttk.Scrollbar(parent, command=self.list_tree.yview)
        self.list_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.list_tree.pack(fill=tk.BOTH, expand=True, padx=8)
        self.list_tree.tag_configure("income", foreground="#2e7d32")
        self.list_tree.tag_configure("expense", foreground="#c62828")

    def _refresh_list(self):
        tx_type = self.list_type_var.get()
        month = self.list_month_var.get()
        sql = ("SELECT id, date, type, category, amount, memo FROM transactions "
               "WHERE date LIKE ? ")
        params = [f"{month}%"]
        if tx_type != "すべて":
            sql += "AND type=? "
            params.append(tx_type)
        sql += "ORDER BY date DESC LIMIT 200"
        rows = self.conn.execute(sql, params).fetchall()
        self.list_tree.delete(*self.list_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.list_tree.insert("", "end", iid=str(tid),
                                   values=(tid, d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                   tags=(tag,))

    # ── 予算設定タブ ──────────────────────────────────────────────

    def _build_budget_tab(self, parent):
        tk.Label(parent, text="カテゴリ別の月次予算上限を設定します",
                 bg="#f8f9fc", font=("Arial", 10)).pack(anchor="w", padx=8, pady=4)
        cols = ("cat", "limit", "spent", "remaining", "rate")
        self.budget_tree = ttk.Treeview(parent, columns=cols, show="headings", height=12)
        for c, h, w in [("cat", "カテゴリ", 100), ("limit", "予算上限", 100),
                         ("spent", "今月の支出", 100), ("remaining", "残り", 100),
                         ("rate", "消化率", 80)]:
            self.budget_tree.heading(c, text=h)
            self.budget_tree.column(c, width=w, minwidth=40)
        self.budget_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        self.budget_tree.tag_configure("over", foreground="#f44336")
        self.budget_tree.tag_configure("warn", foreground="#ff9800")
        self.budget_tree.tag_configure("ok", foreground="#2e7d32")

        add_f = ttk.LabelFrame(parent, text="予算を設定", padding=8)
        add_f.pack(fill=tk.X, padx=8, pady=4)
        row = tk.Frame(add_f, bg=add_f.cget("background"))
        row.pack(fill=tk.X)
        tk.Label(row, text="カテゴリ:", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT)
        self.budget_cat_var = tk.StringVar()
        all_cats = self.CATEGORIES["支出"]
        ttk.Combobox(row, textvariable=self.budget_cat_var,
                     values=all_cats, state="readonly", width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row, text="上限(円):", bg=row.cget("bg"),
                 font=("Arial", 10)).pack(side=tk.LEFT, padx=(8, 0))
        self.budget_limit_var = tk.StringVar(value="30000")
        ttk.Entry(row, textvariable=self.budget_limit_var, width=12).pack(side=tk.LEFT, padx=4)
        ttk.Button(row, text="💾 設定",
                   command=self._save_budget).pack(side=tk.LEFT, padx=8)

    def _save_budget(self):
        cat = self.budget_cat_var.get()
        if not cat:
            return
        try:
            limit = float(self.budget_limit_var.get())
        except ValueError:
            messagebox.showwarning("警告", "金額を入力してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO budgets (category, monthly_limit) VALUES (?,?)",
            (cat, limit))
        self.conn.commit()
        self._refresh_budget()

    def _refresh_budget(self):
        today = date.today()
        month_start = date(today.year, today.month, 1).isoformat()
        budgets = self.conn.execute(
            "SELECT category, monthly_limit FROM budgets").fetchall()
        self.budget_tree.delete(*self.budget_tree.get_children())
        for cat, limit in budgets:
            spent = self.conn.execute(
                "SELECT COALESCE(SUM(amount),0) FROM transactions "
                "WHERE category=? AND date >= ?",
                (cat, month_start)).fetchone()[0]
            remaining = limit - spent
            rate = spent / limit * 100 if limit else 0
            tag = "over" if rate >= 100 else "warn" if rate >= 80 else "ok"
            self.budget_tree.insert("", "end",
                                     values=(cat, f"¥{limit:,.0f}", f"¥{spent:,.0f}",
                                             f"¥{remaining:,.0f}", f"{rate:.0f}%"),
                                     tags=(tag,))

    def _refresh_all(self):
        self._refresh_dashboard()
        self._refresh_list()
        self._refresh_budget()
        self._refresh_recent()

    def _refresh_recent(self):
        rows = self.conn.execute(
            "SELECT id, date, type, category, amount, memo FROM transactions "
            "ORDER BY id DESC LIMIT 30").fetchall()
        self.recent_tree.delete(*self.recent_tree.get_children())
        for row in rows:
            tid, d, t, cat, amt, memo = row
            tag = "income" if t == "収入" else "expense"
            sign = "+" if t == "収入" else "-"
            self.recent_tree.insert("", "end", iid=str(tid),
                                     values=(d, t, cat, f"{sign}¥{amt:,.0f}", memo or ""),
                                     tags=(tag,))


if __name__ == "__main__":
    root = tk.Tk()
    app = App44(root)
    root.mainloop()

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app44.py と保存します。

  2. 2
    クラスの骨格を作る

    App44クラスを定義し、__init__とmainloop()の最小構成を作ります。

  3. 3
    タイトルバーを作る

    Frameを使ってカラーバー付きのタイトルエリアを作ります。

  4. 4
    入力フォームを実装する

    LabelFrameとEntryウィジェットで入力エリアを作ります。

  5. 5
    処理ロジックを実装する

    _calculate()メソッドに計算・処理ロジックを実装します。

  6. 6
    結果表示を実装する

    TextウィジェットかLabelに結果を表示する_show_result()を実装します。

  7. 7
    エラー処理を追加する

    try-exceptとmessageboxでエラーハンドリングを追加します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 データのエクスポート機能

計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

8. よくある問題と解決法

❌ 日本語フォントが表示されない

原因:システムに日本語フォントが見つからない場合があります。

解決法:font引数を省略するかシステムに合ったフォントを指定してください。

❌ ウィンドウのサイズが変更できない

原因:resizable(False, False)が設定されています。

解決法:resizable(True, True)に変更してください。

9. 練習問題

アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。

  1. 課題1:機能拡張

    予算ダッシュボードに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。

  2. 課題2:UIの改善

    色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしてみましょう。

  3. 課題3:保存機能の追加

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のNo.45に挑戦しましょう。