中級者向け No.060

顧客管理CRM

顧客情報・連絡先・商談履歴を管理するシンプルCRM。検索・ステータスフィルタ機能付き。

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

1. アプリ概要

顧客情報・連絡先・商談履歴を管理するシンプルCRM。検索・ステータスフィルタ機能付き。

このアプリはdbカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★★★ です。

このアプリは「データベース連携」カテゴリです。データベース連携 GUI は永続化と画面更新を結びつける典型例で、トランザクション・スキーマ設計・UI 反映の三点を一度に学べます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。

カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。

顧客管理CRM 実行画面(Windows)
実行画面(Windows)
顧客管理CRM 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「+ 新規顧客」ボタン(_new())・「💾 保存」ボタン(_save())・「🗑 削除」ボタン(_delete())・「記録」ボタン(_add_activity())で操作
  • イベント <<TreeviewSelect>> から _on_select() を実行
  • ttk.Treeview による表形式の一覧表示
  • sqlite3.connect() による SQLite データベース接続と SQL 実行
  • messagebox.askyesno()messagebox.showerror()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「顧客管理 CRM」・サイズ 1100x680 で起動

3. 事前準備・環境

ℹ️
動作確認環境

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

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

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

4. 完全なソースコード

💡
コードのコピー方法

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

追加インストール不要(標準ライブラリのみ使用)
app060.py
import tkinter as tk
from tkinter import ttk, messagebox
import sqlite3
import os
from datetime import datetime


class App060:
    """顧客管理CRM"""

    def __init__(self, root):
        self.root = root
        self.root.title("顧客管理 CRM")
        self.root.geometry("1100x680")
        self.root.configure(bg="#1e1e1e")
        db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                               "crm.db")
        self._conn = sqlite3.connect(db_path)
        self._init_db()
        self._current_id = None
        self._build_ui()
        self._load_list()

    def _init_db(self):
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS customers (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                company TEXT DEFAULT '',
                email TEXT DEFAULT '',
                phone TEXT DEFAULT '',
                address TEXT DEFAULT '',
                status TEXT DEFAULT '見込み',
                tags TEXT DEFAULT '',
                note TEXT DEFAULT '',
                created_at TEXT,
                updated_at TEXT
            )
        """)
        self._conn.execute("""
            CREATE TABLE IF NOT EXISTS activities (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                customer_id INTEGER,
                type TEXT,
                content TEXT,
                created_at TEXT
            )
        """)
        self._conn.commit()
        cnt = self._conn.execute("SELECT COUNT(*) FROM customers").fetchone()[0]
        if cnt == 0:
            now = datetime.now().isoformat(timespec="seconds")
            samples = [
                ("山田 太郎", "株式会社ABC", "yamada@abc.co.jp", "03-1234-5678",
                 "東京都渋谷区", "顧客", "VIP,IT"),
                ("鈴木 花子", "有限会社DEF", "suzuki@def.co.jp", "06-2345-6789",
                 "大阪府梅田", "見込み", "製造"),
                ("佐藤 次郎", "", "sato@example.com", "090-3456-7890",
                 "", "顧客", "個人"),
            ]
            for s in samples:
                self._conn.execute(
                    "INSERT INTO customers (name,company,email,phone,"
                    "address,status,tags,created_at,updated_at)"
                    " VALUES (?,?,?,?,?,?,?,?,?)",
                    (*s, now, now))
            self._conn.commit()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="👥 顧客管理 CRM",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)
        ttk.Button(header, text="+ 新規顧客",
                   command=self._new).pack(side=tk.LEFT, padx=4)
        ttk.Button(header, text="💾 保存",
                   command=self._save).pack(side=tk.LEFT, padx=2)
        ttk.Button(header, text="🗑 削除",
                   command=self._delete).pack(side=tk.LEFT, padx=2)

        # 検索・フィルター
        sf = tk.Frame(self.root, bg="#1e1e1e", pady=4)
        sf.pack(fill=tk.X, padx=8)
        tk.Label(sf, text="🔍", bg="#1e1e1e", fg="#ccc").pack(side=tk.LEFT)
        self.search_var = tk.StringVar()
        self.search_var.trace_add("write", lambda *a: self._load_list())
        ttk.Entry(sf, textvariable=self.search_var,
                  width=24).pack(side=tk.LEFT, padx=4)
        tk.Label(sf, text="ステータス:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 4))
        self.status_filter_var = tk.StringVar(value="すべて")
        ttk.Combobox(sf, textvariable=self.status_filter_var,
                     values=["すべて", "見込み", "商談中", "顧客", "休眠"],
                     state="readonly", width=10).pack(side=tk.LEFT)
        self.status_filter_var.trace_add("write", lambda *a: self._load_list())

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 顧客リスト
        left = tk.Frame(paned, bg="#1e1e1e")
        paned.add(left, weight=1)
        cols = ("name", "company", "status", "tags")
        self.tree = ttk.Treeview(left, columns=cols, show="headings",
                                  selectmode="browse")
        self.tree.heading("name",    text="氏名")
        self.tree.heading("company", text="会社")
        self.tree.heading("status",  text="ステータス")
        self.tree.heading("tags",    text="タグ")
        self.tree.column("name",    width=120, anchor="w")
        self.tree.column("company", width=120, anchor="w")
        self.tree.column("status",  width=70,  anchor="center")
        self.tree.column("tags",    width=80,  anchor="w")
        sb = ttk.Scrollbar(left, command=self.tree.yview)
        self.tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.bind("<<TreeviewSelect>>", self._on_select)
        self.count_lbl = tk.Label(left, text="0 件", bg="#1e1e1e",
                                   fg="#555", font=("Arial", 8))
        self.count_lbl.pack(anchor="e")

        # 右: 詳細フォーム + アクティビティ
        right = tk.Frame(paned, bg="#1e1e1e")
        paned.add(right, weight=2)
        self._build_detail(right)

        self.status_var = tk.StringVar(value="顧客を選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#252526", fg="#858585", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _build_detail(self, parent):
        nb = ttk.Notebook(parent)
        nb.pack(fill=tk.BOTH, expand=True)

        # 基本情報タブ
        info_tab = tk.Frame(nb, bg="#1e1e1e")
        nb.add(info_tab, text="基本情報")
        form = tk.Frame(info_tab, bg="#252526", padx=12, pady=8)
        form.pack(fill=tk.X)

        fields = [
            ("氏名 *",       "name_var"),
            ("会社名",        "company_var"),
            ("メール",        "email_var"),
            ("電話",          "phone_var"),
            ("住所",          "address_var"),
        ]
        for i, (label, attr) in enumerate(fields):
            tk.Label(form, text=label, bg="#252526", fg="#ccc",
                     font=("Arial", 9), width=10, anchor="e").grid(
                row=i, column=0, sticky="e", pady=2)
            var = tk.StringVar()
            setattr(self, attr, var)
            ttk.Entry(form, textvariable=var,
                      width=36).grid(row=i, column=1, padx=6, sticky="ew")
        form.columnconfigure(1, weight=1)

        # ステータス
        tk.Label(form, text="ステータス", bg="#252526", fg="#ccc",
                 font=("Arial", 9), width=10, anchor="e").grid(
            row=5, column=0, sticky="e", pady=2)
        self.cust_status_var = tk.StringVar(value="見込み")
        ttk.Combobox(form, textvariable=self.cust_status_var,
                     values=["見込み", "商談中", "顧客", "休眠"],
                     state="readonly", width=12).grid(
            row=5, column=1, padx=6, sticky="w")

        # タグ
        tk.Label(form, text="タグ", bg="#252526", fg="#ccc",
                 font=("Arial", 9), width=10, anchor="e").grid(
            row=6, column=0, sticky="e", pady=2)
        self.tags_var = tk.StringVar()
        ttk.Entry(form, textvariable=self.tags_var,
                  width=36).grid(row=6, column=1, padx=6, sticky="ew")

        # メモ
        note_f = tk.Frame(info_tab, bg="#1e1e1e", padx=8, pady=4)
        note_f.pack(fill=tk.BOTH, expand=True)
        tk.Label(note_f, text="メモ:", bg="#1e1e1e", fg="#888",
                 font=("Arial", 9)).pack(anchor="w")
        self.note_text = tk.Text(note_f, height=5, bg="#0d1117", fg="#d4d4d4",
                                  font=("Arial", 10), relief=tk.FLAT,
                                  insertbackground="#fff")
        self.note_text.pack(fill=tk.BOTH, expand=True)

        # アクティビティタブ
        act_tab = tk.Frame(nb, bg="#1e1e1e")
        nb.add(act_tab, text="アクティビティ")
        act_ctrl = tk.Frame(act_tab, bg="#252526", pady=4)
        act_ctrl.pack(fill=tk.X)
        tk.Label(act_ctrl, text="種別:", bg="#252526", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=4)
        self.act_type_var = tk.StringVar(value="電話")
        ttk.Combobox(act_ctrl, textvariable=self.act_type_var,
                     values=["電話", "メール", "訪問", "商談", "その他"],
                     state="readonly", width=8).pack(side=tk.LEFT)
        self.act_entry = ttk.Entry(act_ctrl, width=30)
        self.act_entry.pack(side=tk.LEFT, padx=4)
        ttk.Button(act_ctrl, text="記録",
                   command=self._add_activity).pack(side=tk.LEFT)
        self.act_list = tk.Text(act_tab, bg="#0d1117", fg="#8b949e",
                                 font=("Courier New", 9), relief=tk.FLAT,
                                 state=tk.DISABLED)
        self.act_list.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

    # ── データ操作 ────────────────────────────────────────────────

    def _load_list(self):
        q = self.search_var.get().strip().lower()
        sf = self.status_filter_var.get()
        sql = "SELECT id,name,company,status,tags FROM customers WHERE 1=1"
        params = []
        if q:
            sql += " AND (LOWER(name) LIKE ? OR LOWER(company) LIKE ? OR LOWER(email) LIKE ?)"
            params += [f"%{q}%"] * 3
        if sf != "すべて":
            sql += " AND status=?"
            params.append(sf)
        sql += " ORDER BY name"
        rows = self._conn.execute(sql, params).fetchall()
        self.tree.delete(*self.tree.get_children())
        for cid, name, company, status, tags in rows:
            self.tree.insert("", tk.END, iid=str(cid),
                              values=(name, company, status, tags))
        self.count_lbl.config(text=f"{len(rows)} 件")

    def _on_select(self, event=None):
        sel = self.tree.selection()
        if not sel:
            return
        cid = int(sel[0])
        row = self._conn.execute(
            "SELECT id,name,company,email,phone,address,status,tags,note"
            " FROM customers WHERE id=?", (cid,)).fetchone()
        if not row:
            return
        self._current_id = row[0]
        self.name_var.set(row[1])
        self.company_var.set(row[2])
        self.email_var.set(row[3])
        self.phone_var.set(row[4])
        self.address_var.set(row[5])
        self.cust_status_var.set(row[6])
        self.tags_var.set(row[7])
        self.note_text.delete("1.0", tk.END)
        self.note_text.insert(tk.END, row[8] or "")
        self._load_activities(cid)
        self.status_var.set(f"ID={cid}  {row[1]}")

    def _new(self):
        self._current_id = None
        for attr in ("name_var", "company_var", "email_var",
                     "phone_var", "address_var", "tags_var"):
            getattr(self, attr).set("")
        self.cust_status_var.set("見込み")
        self.note_text.delete("1.0", tk.END)
        self.act_list.configure(state=tk.NORMAL)
        self.act_list.delete("1.0", tk.END)
        self.act_list.configure(state=tk.DISABLED)
        self.status_var.set("新規顧客")

    def _save(self):
        name = self.name_var.get().strip()
        if not name:
            messagebox.showerror("エラー", "氏名は必須です")
            return
        now = datetime.now().isoformat(timespec="seconds")
        vals = (name, self.company_var.get(), self.email_var.get(),
                self.phone_var.get(), self.address_var.get(),
                self.cust_status_var.get(), self.tags_var.get(),
                self.note_text.get("1.0", tk.END).rstrip())
        if self._current_id is None:
            cur = self._conn.execute(
                "INSERT INTO customers (name,company,email,phone,address,"
                "status,tags,note,created_at,updated_at)"
                " VALUES (?,?,?,?,?,?,?,?,?,?)", (*vals, now, now))
            self._current_id = cur.lastrowid
        else:
            self._conn.execute(
                "UPDATE customers SET name=?,company=?,email=?,phone=?,"
                "address=?,status=?,tags=?,note=?,updated_at=? WHERE id=?",
                (*vals, now, self._current_id))
        self._conn.commit()
        self._load_list()
        try:
            self.tree.selection_set(str(self._current_id))
        except Exception:
            pass
        self.status_var.set(f"保存完了: {name}")

    def _delete(self):
        if self._current_id is None:
            return
        name = self.name_var.get() or f"ID={self._current_id}"
        if not messagebox.askyesno("削除確認", f"「{name}」を削除しますか?"):
            return
        self._conn.execute("DELETE FROM customers WHERE id=?",
                            (self._current_id,))
        self._conn.execute("DELETE FROM activities WHERE customer_id=?",
                            (self._current_id,))
        self._conn.commit()
        self._current_id = None
        self._new()
        self._load_list()

    def _add_activity(self):
        if self._current_id is None:
            messagebox.showwarning("警告", "先に顧客を選択または保存してください")
            return
        content = self.act_entry.get().strip()
        if not content:
            return
        now = datetime.now().isoformat(timespec="seconds")
        self._conn.execute(
            "INSERT INTO activities (customer_id,type,content,created_at)"
            " VALUES (?,?,?,?)",
            (self._current_id, self.act_type_var.get(), content, now))
        self._conn.commit()
        self.act_entry.delete(0, tk.END)
        self._load_activities(self._current_id)

    def _load_activities(self, cid):
        rows = self._conn.execute(
            "SELECT created_at,type,content FROM activities"
            " WHERE customer_id=? ORDER BY created_at DESC",
            (cid,)).fetchall()
        self.act_list.configure(state=tk.NORMAL)
        self.act_list.delete("1.0", tk.END)
        for created_at, atype, content in rows:
            self.act_list.insert(
                tk.END, f"[{created_at}] [{atype}] {content}\n")
        self.act_list.configure(state=tk.DISABLED)


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

5. コード解説

顧客管理CRMのコードを、実際に書かれている実装に沿って解説します。

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

App060 クラスにアプリの全機能をまとめています(メソッド11個・全342行)。__init__ ではタイトル「顧客管理 CRM」とウィンドウサイズ 1100x680 を設定します。あわせて状態を保持する変数(self._current_id)を初期化し、そのあと _init_db()_build_ui()_load_list() の順に呼び出して初期状態を作ります。

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

ウィジェット構成

画面は tk.Frame×9・tk.Label×9(ほかにループでも生成)・ttk.Button×4・ttk.Entry×3(ほかにループでも生成)・ttk.Combobox×3・ttk.PanedWindowttk.Treeviewttk.Scrollbarttk.Notebooktk.Text×2 で構成しています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

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

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

「+ 新規顧客」ボタン(_new())・「💾 保存」ボタン(_save())・「🗑 削除」ボタン(_delete())・「記録」ボタン(_add_activity())を command= で接続しています。また bind("<<TreeviewSelect>>", ...) から _on_select() を呼べるようにイベントも登録しています。

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

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

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

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

例外処理

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

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

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

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

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

    新しいファイルを作成して app060.py と保存します。使うのは datetimeossqlite3tkinter だけなので、追加インストールは不要です。

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

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

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

    title("顧客管理 CRM")geometry("1100x680")configure(bg="#1e1e1e") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

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

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

    アプリの本体である _save()(28行)・_on_select()(22行)・_add_activity()(15行)など を実装します。SQL の実行結果は execute() の戻り値から取り出します。

  7. 7
    動作確認する

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

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

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

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

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

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

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

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

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

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

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

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

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

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

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

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

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

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。顧客データはコードと同じフォルダの crm.db に保存されます。実在の取引先や個人情報ではなく、初期表示のサンプル顧客で試してください。

  1. 課題1:タグと電話番号でも検索できるようにする

    検索欄に VIP と入れても、タグに VIP,IT を持つサンプル顧客は見つかりません。_load_list() の絞り込みが氏名・会社名・メールの3列しか見ていないためです。タグと電話番号も検索の対象に加えてください。

    期待結果:検索欄に VIP と入れると、タグに VIP を含む顧客だけが一覧に残る。

    合格条件

    • 氏名・会社名・メールでの検索は、これまでどおり効く
    • 検索欄を空に戻すと全件の表示に戻る
    • 「ステータス」の絞り込みと同時に使っても、両方の条件が効く

    ヒント①(どこを触るか): 触るのは _load_list() の2行です。sql += " AND (LOWER(name) LIKE ? OR ...)" に列を足し、すぐ下の params += [f"%{q}%"] * 3 の個数をそろえます。
    ヒント②(使うもの): 列名は customers テーブルの tagsphone です。q は先頭で .lower() 済みなので、LOWER() は SQL 側の列にかけるだけで足ります。
    つまずきやすい点: ? の数と params の個数が合わないと sqlite3.ProgrammingError になります。電話番号は 03-1234-5678 のようにハイフン込みで保存されるため、ハイフン無しで打つと一致しません。

  2. 課題2:保存していない変更があることを画面下の帯に出す

    フォームを書き換えても「💾 保存」を押すまで crm.db には何も入りません。いまは書き換えたかどうかが画面から分からず、別の顧客を選ぶと入力が黙って消えます。編集中は画面下の帯に印を出してください。

    期待結果:氏名やメモを書き換えると帯に「未保存の変更があります」と出て、「💾 保存」を押すと消える。

    合格条件

    • 顧客を選び直した直後は、未保存の表示が出ていない
    • 「+ 新規顧客」で入力欄を空にした直後も、未保存の表示は出ていない
    • 保存後に表示が消え、そこからまた書き換えると再び出る

    ヒント①(どこを触るか): 触るのは4か所です。_build_detail() でフォームの StringVar を作っている箇所に見張りを付け、_on_select()_new()_save() の末尾で印を消します。
    ヒント②(使うもの): 見張りの手本は _build_ui()self.search_var.trace_add("write", lambda *a: self._load_list()) です。メモ欄は tk.TextStringVar を持たないため、self.note_text.bind("<KeyRelease>", ...) で拾います。
    つまずきやすい点: _on_select()set() でフォームを埋めるので、その書き込みでも見張りの関数が動きます。読み込み中は反応しないよう、self._loading = True のような目印を立てて避けてください。

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

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

🚀
次に挑戦するアプリ

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

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

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

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

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