予算ダッシュボード
収支・カテゴリ別・月別グラフを表示する予算管理ダッシュボード。matplotlibの埋め込みと複数グラフ表示を学びます。
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ライブラリ一覧 でも用途別に解説しています。
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. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
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.Notebook・ttk.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 で ImportError・ValueError を捕捉しています。あわせて messagebox.askyesno()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app44.py と保存します。外部ライブラリ
matplotlibを使います。先にpip install matplotlibを実行してください。 -
2クラスの骨格を作る
App44クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("予算ダッシュボード")・geometry("1060x721")・configure(bg="#f8f9fc")・minsize(1060, 721)をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×10(ほかにループでも生成)・tk.Label×11(ほかにループでも生成)・ttk.Notebook・ttk.Button×5・ttk.LabelFrame×3・ttk.Style×3(ほかにループでも生成)・tk.Radiobutton(ループで生成)・ttk.Entry×2(ほかにループでも生成)・ttk.Combobox×3・ttk.Treeview×3・ttk.Scrollbar×2 を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_refresh_dashboard()・_add_transaction()・_delete_transaction()・_refresh_list()・_save_budget())につなぎます。bind("<<NotebookTabChanged>>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_refresh_dashboard()(103行)・_refresh_list()(19行)・_add_transaction()(18行)など を実装します。SQL の実行結果はexecute()の戻り値から取り出します。 -
7動作確認する
python app44.pyで起動し、各ボタンが反応すること・データベースファイルを開いて内容が表示されることを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 クラス定数を書き換えて動きを変える
データはクラス定数(DB_PATH・CATEGORIES)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。
💡 配色をまとめて管理する
背景色 #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:日付の書き方が違う入力を弾く
日付の欄は何を書いても登録できます。形が違うと、記録は残るのに集計から外れます。登録する前に形を確かめてください。
期待結果:「➕ 収支入力」で日付の欄を空にし、金額に
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:削除する前に、消える行の中身を見せる
「🗑 削除」は、行を選んでいないと何も起きません。選んでいても「削除しますか?」としか出ません。何が消えるのかを見せてください。
期待結果:「➕ 収支入力」の「直近の入力」で何も選ばずに「🗑 削除」を押す。いまは確認も警告も出ず、押しても無反応。行を選んで押すと
削除しますか?とだけ出る。直したあとは、選んでいなければ「削除する行を選んでください」と出て、選んでいれば日付・カテゴリ・金額が確認の文に出る。合格条件
- 「いいえ」を選んだときは、これまでどおり何も消えない
- 消したあとは、これまでどおりダッシュボードと一覧の数字が更新される
- 「📋 一覧」タブの表示と絞り込みは変わらない
ヒント①(どこを触るか): 触るのは
_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:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。