中級者向け No.44

予算ダッシュボード

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

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

1. アプリ概要

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

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

このアプリはこんなときに使います。家計簿の収支や売上記録のように「カテゴリ × 時系列」を持つ数字は、表のまま眺めても傾向が読み取れません。このダッシュボードは matplotlib のグラフを tkinter ウィンドウに直接埋め込み、収支サマリー・カテゴリ別の内訳・月別の推移を1画面に並べて可視化します。Excel でやっていた月次集計を自前ツール化したい、あるいはセンサー値や学習記録など他の時系列データにも同じ型を使い回したい——そんなときの土台になる構成です。

matplotlib を GUI に載せるときの定番のつまずきが3つあります。① グラフが表示されない: FigureCanvasTkAgg で Figure を Canvas 化し、canvas.draw()canvas.get_tk_widget().pack()2段階が必要です(どちらか欠けると真っ白になります)。② 日本語ラベルが豆腐(□□□)になる: plt.rcParams['font.family'] に日本語フォント(Windows なら 'Yu Gothic'、Mac なら 'Hiragino Sans'、Linux なら 'Noto Sans CJK JP')を指定します。掲載コードは起動時にインストール済みのフォントを調べ、候補リストの先頭から見つかったものを自動で選びます。③ データ更新でグラフが重なる・メモリが増え続ける: 再描画の前に ax.clear() で軸をクリアし、Figure を毎回作り直さないようにします。この3点を押さえれば、matplotlib 埋め込みの8割はトラブルなく動きます。

慣れたら、こう応用できます。掲載コードは明細を同じフォルダの budget.db(SQLite)に保存し、テーブルが空のときだけサンプル明細を投入する仕組みです。初回起動後は「収支入力」タブから自分の明細を足せば、そのまま家計簿として使えます。SQLite の書き方は SQLiteタスク管理アプリ の実装も参考になります。予算の消化率による色分けは「予算設定」タブに実装済みなので、次の一手は fig.savefig() で月次レポートを PNG/PDF に書き出す拡張がおすすめです。matplotlib そのものの基礎は Pythonライブラリ一覧 でも用途別に解説しています。

予算ダッシュボード 実行画面(Windows)
実行画面(Windows)
予算ダッシュボード 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「🔄 更新」ボタン(_refresh_dashboard())・「➕ 登録」ボタン(_add_transaction())・「🗑 削除」ボタン(_delete_transaction())・「🔍 絞り込み」ボタン(_refresh_list())・「💾 設定」ボタン(_save_budget())で操作
  • イベント <<NotebookTabChanged>> から _refresh_all() を実行
  • ttk.Treeview による表形式の一覧表示
  • sqlite3.connect() による SQLite データベース接続と SQL 実行
  • messagebox.askyesno()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「予算ダッシュボード」・サイズ 1060x721 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

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

pip install matplotlib

4. 完全なソースコード

💡
コードのコピー方法

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

📦 必要なライブラリをインストール(初回のみ)
pip install matplotlib
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
    from matplotlib import font_manager
    import matplotlib.pyplot as plt
    MPL_AVAILABLE = True
except ImportError:
    MPL_AVAILABLE = False

if MPL_AVAILABLE:
    # グラフ内の日本語が豆腐(□)にならないよう、入っている和文フォントを1つ選ぶ
    _installed = {f.name for f in font_manager.fontManager.ttflist}
    for _name in ("Meiryo", "Yu Gothic", "Noto Sans CJK JP", "Noto Sans JP",
                  "Hiragino Sans", "IPAexGothic", "MS Gothic", "DejaVu Sans"):
        if _name in _installed:
            matplotlib.rcParams["font.family"] = _name
            break
    matplotlib.rcParams["axes.unicode_minus"] = 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("1060x721")
        self.root.minsize(1060, 721)
        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=ttk.Style().lookup("TLabelframe", "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=ttk.Style().lookup("TLabelframe", "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=ttk.Style().lookup("TLabelframe", "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=ttk.Style().lookup("TLabelframe", "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 クラスにアプリの全機能をまとめています(メソッド17個・全525行)。__init__ ではタイトル「予算ダッシュボード」とウィンドウサイズ 1060x721 を設定します。そのあと _init_db()_build_ui()_refresh_all() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×10(ほかにループでも生成)・tk.Label×11(ほかにループでも生成)・ttk.Notebookttk.Button×5・ttk.LabelFrame×3・ttk.Style×3(ほかにループでも生成)・tk.Radiobutton(ループで生成)・ttk.Entry×2(ほかにループでも生成)・ttk.Combobox×3・ttk.Treeview×3・ttk.Scrollbar×2 で構成しています。見出し付きの枠(LabelFrame)は「収支を入力」「直近の入力」「予算を設定」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「🔄 更新」ボタン(_refresh_dashboard())・「➕ 登録」ボタン(_add_transaction())・「🗑 削除」ボタン(_delete_transaction())・「🔍 絞り込み」ボタン(_refresh_list())・「💾 設定」ボタン(_save_budget())を command= で接続しています。また bind("<<NotebookTabChanged>>", ...) から _refresh_all() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

SQLite データベースの接続と実行

sqlite3.connect() でデータベースファイルに接続し、SQL の実行は execute()、確定は commit() で行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:DB_PATH = os.path.join(os.path.dirname(__file__), 'budget.db')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ImportErrorValueError を捕捉しています。あわせて messagebox.askyesno()messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

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

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

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

    新しいファイルを作成して app44.py と保存します。外部ライブラリ matplotlib を使います。先に pip install matplotlib を実行してください。

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

    App44 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("予算ダッシュボード")geometry("1060x721")configure(bg="#f8f9fc")minsize(1060, 721) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×10(ほかにループでも生成)・tk.Label×11(ほかにループでも生成)・ttk.Notebookttk.Button×5・ttk.LabelFrame×3・ttk.Style×3(ほかにループでも生成)・tk.Radiobutton(ループで生成)・ttk.Entry×2(ほかにループでも生成)・ttk.Combobox×3・ttk.Treeview×3・ttk.Scrollbar×2 を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_refresh_dashboard()_add_transaction()_delete_transaction()_refresh_list()_save_budget())につなぎます。bind("<<NotebookTabChanged>>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _refresh_dashboard()(103行)・_refresh_list()(19行)・_add_transaction()(18行)など を実装します。SQL の実行結果は execute() の戻り値から取り出します。

  7. 7
    動作確認する

    python app44.py で起動し、各ボタンが反応すること・データベースファイルを開いて内容が表示されることを確認します。

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

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

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

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

💡 クラス定数を書き換えて動きを変える

データはクラス定数(DB_PATHCATEGORIES)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 配色をまとめて管理する

背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

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

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 「database is locked」というエラーが出る

原因:同じデータベースファイルを別のツール(DB Browser など)で開いたまま書き込むと、SQLite がファイルをロックしてこのエラーになります。

解決法:他のツールでの接続を閉じてから実行してください。書き込みは commit() で確定するまで反映されない点にも注意しましょう。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("1060x721") の固定サイズで起動するためです。

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(1060, 721) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは入力した数字を集計して並べるだけで、家計の良し悪しを判断したり節約を勧めたりはしません。記録はコードと同じフォルダの budget.db に入り、消した行は元に戻せません。練習用のフォルダへコードをコピーしてから試すと安全です。課題を試すには matplotlib が要ります。

  1. 課題1:日付の書き方が違う入力を弾く

    日付の欄は何を書いても登録できます。形が違うと、記録は残るのに集計から外れます。登録する前に形を確かめてください。

    期待結果:「➕ 収支入力」で日付の欄を空にし、金額に 1000、カテゴリに 食費 を選んで「➕ 登録」を押す。いまは「直近の入力」に日付が空の行が増えるのに、「📊 ダッシュボード」の「今月の支出」も「⚙ 予算設定」の「今月の支出」も 1,000 円増えない。直したあとは「日付は YYYY-MM-DD の形で入力してください」と出て、登録されない。

    合格条件

    • 2026-09-10 のような日付は、これまでどおり登録できてダッシュボードにも出る
    • 弾かれたときは「直近の入力」にも行が増えない
    • 金額が数字でないときの「金額を正しく入力してください」は、これまでどおり出る

    ヒント①(どこを触るか): 触るのは _add_transaction() の1か所です。date_str = self.tx_date_var.get().strip() の下に足します。
    ヒント②(使うもの): datetime.strptime(date_str, "%Y-%m-%d")try で囲むのがいちばん簡単です。datetime は5行目でインポート済みで、すぐ上の金額の確認が同じ形の手本になります。
    つまずきやすい点: datetime.strptime("2026-9-10", "%Y-%m-%d") はエラーになりません。1桁の月日も通るので、ゼロ埋めまで揃えたいなら変換した結果を .date().isoformat() で作り直します。日付は文字列として保存されるため、形が違っても入ってしまい、比べるときだけ食い違います。すでに入っている行は、この直しでは消えません。

  2. 課題2:削除する前に、消える行の中身を見せる

    「🗑 削除」は、行を選んでいないと何も起きません。選んでいても「削除しますか?」としか出ません。何が消えるのかを見せてください。

    期待結果:「➕ 収支入力」の「直近の入力」で何も選ばずに「🗑 削除」を押す。いまは確認も警告も出ず、押しても無反応。行を選んで押すと 削除しますか? とだけ出る。直したあとは、選んでいなければ「削除する行を選んでください」と出て、選んでいれば日付・カテゴリ・金額が確認の文に出る。

    合格条件

    • 「いいえ」を選んだときは、これまでどおり何も消えない
    • 消したあとは、これまでどおりダッシュボードと一覧の数字が更新される
    • 「📋 一覧」タブの表示と絞り込みは変わらない

    ヒント①(どこを触るか): 触るのは _delete_transaction() の1か所です。if sel and messagebox.askyesno("確認", "削除しますか?"): の行を2段に分けます。
    ヒント②(使うもの): 選んだ行の中身は self.recent_tree.item(sel[0])["values"] で取れます。並びは _refresh_recent() が入れた順で、日付・種別・カテゴリ・金額・メモです。
    つまずきやすい点: この一覧は行の名前(iid)にデータベースの ID を入れており、削除に使っているのは int(sel[0]) のほうです。そこは変えないでください。values の先頭は日付で、ID は入っていません(ID の列があるのは「📋 一覧」タブのほうです)。消した行は元に戻せませんが、記録を全部消して起動し直すとサンプルが作り直されます。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。