中級者向け No.20

請求書作成アプリ

顧客・商品を選択してPDF請求書を自動生成するアプリ。reportlabライブラリでPDF出力を実装します。

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

1. アプリ概要

顧客・商品を選択してPDF請求書を自動生成するアプリ。reportlabライブラリでPDF出力を実装します。

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

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+

4. 完全なソースコード

💡
コードのコピー方法

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

app20.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import sqlite3
import os
from datetime import datetime, date

try:
    from reportlab.lib.pagesizes import A4
    from reportlab.lib import colors
    from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
                                     Paragraph, Spacer)
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    REPORTLAB_AVAILABLE = True
except ImportError:
    REPORTLAB_AVAILABLE = False


class App20:
    """請求書作成アプリ"""

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

    def __init__(self, root):
        self.root = root
        self.root.title("請求書作成アプリ")
        self.root.geometry("900x620")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_clients()
        self._load_items()
        self._new_invoice()

        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "reportlab が必要です (PDF出力に必要)。\n"
                "pip install reportlab でインストールしてください。\n\n"
                "CSV形式での出力は可能です。")

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS clients (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, address TEXT, email TEXT, phone TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, unit_price REAL, unit TEXT DEFAULT '式'
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS invoices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                invoice_no TEXT, client_id INTEGER, issue_date TEXT,
                due_date TEXT, notes TEXT, created_at TEXT
            )
        """)
        self.conn.commit()
        # サンプルデータ
        if not self.conn.execute("SELECT 1 FROM clients").fetchone():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('株式会社ABC','東京都千代田区1-1','abc@example.com','03-1234-5678')")
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('山田商事','大阪府大阪市2-2','yamada@example.com','06-9876-5432')")
            self.conn.execute(
                "INSERT INTO items (name,unit_price,unit) VALUES "
                "('Webデザイン',50000,'式'),('システム開発',80000,'式'),"
                "('コンサルティング',30000,'時間'),('保守費用',20000,'月')")
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#f57f17", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🧾 請求書作成アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📄 PDF出力",
                   command=self._export_pdf).pack(side=tk.RIGHT, padx=8)
        ttk.Button(title_frame, text="📊 CSV出力",
                   command=self._export_csv).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="💾 保存",
                   command=self._save_invoice).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="🆕 新規",
                   command=self._new_invoice).pack(side=tk.RIGHT, padx=4)

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 請求書フォーム
        form = ttk.LabelFrame(paned, text="請求書", padding=12)
        # 請求書番号
        row_f = tk.Frame(form, bg=form.cget("background"))
        row_f.pack(fill=tk.X, pady=2)
        tk.Label(row_f, text="請求書番号:", bg=row_f.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.inv_no_var = tk.StringVar()
        ttk.Entry(row_f, textvariable=self.inv_no_var, width=18).pack(side=tk.LEFT, padx=4)
        # 発行日
        row_f2 = tk.Frame(form, bg=form.cget("background"))
        row_f2.pack(fill=tk.X, pady=2)
        tk.Label(row_f2, text="発行日:", bg=row_f2.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.issue_var = tk.StringVar(value=str(date.today()))
        ttk.Entry(row_f2, textvariable=self.issue_var, width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row_f2, text="支払期限:", bg=row_f2.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.due_var = tk.StringVar()
        ttk.Entry(row_f2, textvariable=self.due_var, width=12).pack(side=tk.LEFT, padx=4)
        # 顧客
        row_f3 = tk.Frame(form, bg=form.cget("background"))
        row_f3.pack(fill=tk.X, pady=2)
        tk.Label(row_f3, text="顧客:", bg=row_f3.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.client_var = tk.StringVar()
        self.client_cb = ttk.Combobox(row_f3, textvariable=self.client_var,
                                       state="readonly", width=24)
        self.client_cb.pack(side=tk.LEFT, padx=4)
        ttk.Button(row_f3, text="✏️ 顧客管理",
                   command=self._manage_clients).pack(side=tk.LEFT, padx=4)
        # 備考
        row_f4 = tk.Frame(form, bg=form.cget("background"))
        row_f4.pack(fill=tk.X, pady=2)
        tk.Label(row_f4, text="備考:", bg=row_f4.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT, anchor="n")
        self.notes_text = tk.Text(row_f4, height=2, width=30,
                                   font=("Arial", 10))
        self.notes_text.pack(side=tk.LEFT, padx=4)

        # 明細テーブル
        tk.Label(form, text="明細:", bg=form.cget("background"),
                 font=("Noto Sans JP", 11, "bold")).pack(anchor="w", pady=(8, 2))
        item_f = tk.Frame(form, bg=form.cget("background"))
        item_f.pack(fill=tk.X)
        self.item_name_var = tk.StringVar()
        self.item_cb = ttk.Combobox(item_f, textvariable=self.item_name_var,
                                     width=20)
        self.item_cb.pack(side=tk.LEFT, padx=2)
        self.item_cb.bind("<<ComboboxSelected>>", self._on_item_select)
        tk.Label(item_f, text="数量:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.qty_var = tk.IntVar(value=1)
        ttk.Spinbox(item_f, from_=1, to=9999,
                    textvariable=self.qty_var, width=5).pack(side=tk.LEFT, padx=2)
        tk.Label(item_f, text="単価:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.price_var = tk.StringVar(value="0")
        ttk.Entry(item_f, textvariable=self.price_var, width=10).pack(side=tk.LEFT, padx=2)
        ttk.Button(item_f, text="➕ 追加",
                   command=self._add_line).pack(side=tk.LEFT, padx=4)

        # 明細Treeview
        cols = ("no", "name", "qty", "unit_price", "amount")
        self.line_tree = ttk.Treeview(form, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("no", "#", 30), ("name", "品目", 200),
                         ("qty", "数量", 50), ("unit_price", "単価", 90),
                         ("amount", "金額", 90)]:
            self.line_tree.heading(c, text=h)
            self.line_tree.column(c, width=w, minwidth=30)
        line_sb = ttk.Scrollbar(form, command=self.line_tree.yview)
        self.line_tree.configure(yscrollcommand=line_sb.set)
        line_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.line_tree.pack(fill=tk.BOTH, expand=True)
        ttk.Button(form, text="🗑️ 選択行削除",
                   command=self._delete_line).pack(anchor="w", pady=2)

        # 合計
        total_f = tk.Frame(form, bg=form.cget("background"))
        total_f.pack(fill=tk.X, pady=4)
        self.subtotal_label = tk.Label(total_f, text="小計: ¥0",
                                       bg=form.cget("background"),
                                       font=("Arial", 11))
        self.subtotal_label.pack(side=tk.LEFT)
        self.tax_label = tk.Label(total_f, text="  消費税(10%): ¥0",
                                  bg=form.cget("background"),
                                  font=("Arial", 11))
        self.tax_label.pack(side=tk.LEFT)
        self.total_label = tk.Label(total_f, text="  合計: ¥0",
                                    bg=form.cget("background"),
                                    fg="#e65100",
                                    font=("Arial", 13, "bold"))
        self.total_label.pack(side=tk.LEFT)
        paned.add(form, weight=3)

        # 右: 請求書履歴
        hist_frame = ttk.LabelFrame(paned, text="請求書履歴", padding=4)
        hist_cols = ("inv_no", "client", "date", "total")
        self.hist_tree = ttk.Treeview(hist_frame, columns=hist_cols,
                                       show="headings", selectmode="browse")
        for c, h, w in [("inv_no", "番号", 80), ("client", "顧客", 100),
                         ("date", "発行日", 90), ("total", "金額", 80)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=30)
        hist_sb = ttk.Scrollbar(hist_frame, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=hist_sb.set)
        hist_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        paned.add(hist_frame, weight=1)

        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)
        self._line_items = []

    def _load_clients(self):
        rows = self.conn.execute(
            "SELECT id, name FROM clients ORDER BY name").fetchall()
        self._clients = {name: cid for cid, name in rows}
        self.client_cb.configure(values=list(self._clients.keys()))

    def _load_items(self):
        rows = self.conn.execute(
            "SELECT name, unit_price FROM items ORDER BY name").fetchall()
        self._items = {name: price for name, price in rows}
        self.item_cb.configure(values=list(self._items.keys()))

    def _new_invoice(self):
        now = datetime.now()
        self.inv_no_var.set(f"INV-{now.strftime('%Y%m%d-%H%M')}")
        self.issue_var.set(str(date.today()))
        self.due_var.set("")
        self.notes_text.delete("1.0", tk.END)
        self.line_tree.delete(*self.line_tree.get_children())
        self._line_items = []
        self._update_totals()

    def _on_item_select(self, event):
        name = self.item_name_var.get()
        price = self._items.get(name, 0)
        self.price_var.set(str(price))

    def _add_line(self):
        name = self.item_name_var.get().strip()
        if not name:
            return
        try:
            qty = self.qty_var.get()
            price = float(self.price_var.get())
        except ValueError:
            messagebox.showerror("エラー", "数量・単価を正しく入力してください")
            return
        amount = qty * price
        no = len(self._line_items) + 1
        self._line_items.append((name, qty, price, amount))
        self.line_tree.insert("", "end",
                              values=(no, name, qty,
                                      f"¥{price:,.0f}",
                                      f"¥{amount:,.0f}"))
        self._update_totals()

    def _delete_line(self):
        sel = self.line_tree.selection()
        if sel:
            idx = self.line_tree.index(sel[0])
            self._line_items.pop(idx)
            self.line_tree.delete(sel[0])
            self._renumber()
            self._update_totals()

    def _renumber(self):
        for idx, item in enumerate(self.line_tree.get_children()):
            vals = list(self.line_tree.item(item)["values"])
            vals[0] = idx + 1
            self.line_tree.item(item, values=vals)

    def _update_totals(self):
        subtotal = sum(a for _, _, _, a in self._line_items)
        tax = subtotal * 0.1
        total = subtotal + tax
        self.subtotal_label.config(text=f"小計: ¥{subtotal:,.0f}")
        self.tax_label.config(text=f"  消費税(10%): ¥{tax:,.0f}")
        self.total_label.config(text=f"  合計: ¥{total:,.0f}")
        self._total = total

    def _save_invoice(self):
        client_name = self.client_var.get()
        client_id = self._clients.get(client_name)
        if not client_id:
            messagebox.showwarning("警告", "顧客を選択してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO invoices "
            "(invoice_no, client_id, issue_date, due_date, notes, created_at) "
            "VALUES (?,?,?,?,?,?)",
            (self.inv_no_var.get(), client_id,
             self.issue_var.get(), self.due_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self.status_var.set(f"保存完了: {self.inv_no_var.get()}")

    def _export_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        import csv
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["請求書番号", "顧客", "発行日", "支払期限"])
            w.writerow([self.inv_no_var.get(), self.client_var.get(),
                        self.issue_var.get(), self.due_var.get()])
            w.writerow([])
            w.writerow(["品目", "数量", "単価", "金額"])
            for name, qty, price, amount in self._line_items:
                w.writerow([name, qty, price, amount])
            subtotal = sum(a for _, _, _, a in self._line_items)
            w.writerow(["小計", "", "", subtotal])
            w.writerow(["消費税(10%)", "", "", subtotal * 0.1])
            w.writerow(["合計", "", "", subtotal * 1.1])
        self.status_var.set(f"CSV出力: {path}")

    def _export_pdf(self):
        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning("警告",
                                   "pip install reportlab が必要です")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF", "*.pdf")])
        if not path:
            return
        try:
            doc = SimpleDocTemplate(path, pagesize=A4)
            styles = getSampleStyleSheet()
            story = []

            story.append(Paragraph(f"請求書  {self.inv_no_var.get()}",
                                   styles["Title"]))
            story.append(Spacer(1, 12))
            story.append(Paragraph(
                f"顧客: {self.client_var.get()}  "
                f"発行日: {self.issue_var.get()}  "
                f"支払期限: {self.due_var.get()}", styles["Normal"]))
            story.append(Spacer(1, 20))

            data = [["品目", "数量", "単価(円)", "金額(円)"]]
            for name, qty, price, amount in self._line_items:
                data.append([name, qty, f"{price:,.0f}", f"{amount:,.0f}"])
            subtotal = sum(a for _, _, _, a in self._line_items)
            data.append(["", "", "小計", f"{subtotal:,.0f}"])
            data.append(["", "", "消費税(10%)", f"{subtotal*0.1:,.0f}"])
            data.append(["", "", "合計", f"{subtotal*1.1:,.0f}"])

            t = Table(data, colWidths=[200, 60, 100, 100])
            t.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565c0")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
                ("GRID", (0, 0), (-1, -2), 0.5, colors.grey),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
            ]))
            story.append(t)
            if self.notes_text.get("1.0", tk.END).strip():
                story.append(Spacer(1, 20))
                story.append(Paragraph("備考: " + self.notes_text.get("1.0", tk.END).strip(),
                                       styles["Normal"]))
            doc.build(story)
            self.status_var.set(f"PDF出力: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _manage_clients(self):
        win = tk.Toplevel(self.root)
        win.title("顧客管理")
        win.geometry("500x350")
        # 顧客一覧
        cols = ("id", "name", "address", "email", "phone")
        tree = ttk.Treeview(win, columns=cols, show="headings")
        for c, h, w in [("id", "ID", 40), ("name", "名前", 120),
                         ("address", "住所", 150), ("email", "メール", 120),
                         ("phone", "電話", 100)]:
            tree.heading(c, text=h)
            tree.column(c, width=w)
        tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        for row in self.conn.execute("SELECT * FROM clients").fetchall():
            tree.insert("", "end", values=row)
        # 追加フォーム
        form = tk.Frame(win)
        form.pack(fill=tk.X, padx=4)
        vars_ = {}
        for i, key in enumerate(["名前", "住所", "メール", "電話"]):
            tk.Label(form, text=f"{key}:").grid(row=i//2, column=(i%2)*2, sticky="w", pady=2)
            v = tk.StringVar()
            ttk.Entry(form, textvariable=v, width=18).grid(
                row=i//2, column=(i%2)*2+1, padx=4)
            vars_[key] = v

        def add_client():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES (?,?,?,?)",
                (vars_["名前"].get(), vars_["住所"].get(),
                 vars_["メール"].get(), vars_["電話"].get()))
            self.conn.commit()
            for row in self.conn.execute("SELECT * FROM clients").fetchall():
                tree.insert("", "end", values=row)
            self._load_clients()
        ttk.Button(form, text="追加", command=add_client).grid(
            row=2, column=0, columnspan=4, pady=6)


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

5. コード解説

請求書作成アプリのコードを詳しく解説します。クラスベースの設計で各機能を整理して実装しています。

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

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

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

try:
    from reportlab.lib.pagesizes import A4
    from reportlab.lib import colors
    from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
                                     Paragraph, Spacer)
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    REPORTLAB_AVAILABLE = True
except ImportError:
    REPORTLAB_AVAILABLE = False


class App20:
    """請求書作成アプリ"""

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

    def __init__(self, root):
        self.root = root
        self.root.title("請求書作成アプリ")
        self.root.geometry("900x620")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_clients()
        self._load_items()
        self._new_invoice()

        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "reportlab が必要です (PDF出力に必要)。\n"
                "pip install reportlab でインストールしてください。\n\n"
                "CSV形式での出力は可能です。")

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS clients (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, address TEXT, email TEXT, phone TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, unit_price REAL, unit TEXT DEFAULT '式'
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS invoices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                invoice_no TEXT, client_id INTEGER, issue_date TEXT,
                due_date TEXT, notes TEXT, created_at TEXT
            )
        """)
        self.conn.commit()
        # サンプルデータ
        if not self.conn.execute("SELECT 1 FROM clients").fetchone():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('株式会社ABC','東京都千代田区1-1','abc@example.com','03-1234-5678')")
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('山田商事','大阪府大阪市2-2','yamada@example.com','06-9876-5432')")
            self.conn.execute(
                "INSERT INTO items (name,unit_price,unit) VALUES "
                "('Webデザイン',50000,'式'),('システム開発',80000,'式'),"
                "('コンサルティング',30000,'時間'),('保守費用',20000,'月')")
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#f57f17", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🧾 請求書作成アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📄 PDF出力",
                   command=self._export_pdf).pack(side=tk.RIGHT, padx=8)
        ttk.Button(title_frame, text="📊 CSV出力",
                   command=self._export_csv).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="💾 保存",
                   command=self._save_invoice).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="🆕 新規",
                   command=self._new_invoice).pack(side=tk.RIGHT, padx=4)

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 請求書フォーム
        form = ttk.LabelFrame(paned, text="請求書", padding=12)
        # 請求書番号
        row_f = tk.Frame(form, bg=form.cget("background"))
        row_f.pack(fill=tk.X, pady=2)
        tk.Label(row_f, text="請求書番号:", bg=row_f.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.inv_no_var = tk.StringVar()
        ttk.Entry(row_f, textvariable=self.inv_no_var, width=18).pack(side=tk.LEFT, padx=4)
        # 発行日
        row_f2 = tk.Frame(form, bg=form.cget("background"))
        row_f2.pack(fill=tk.X, pady=2)
        tk.Label(row_f2, text="発行日:", bg=row_f2.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.issue_var = tk.StringVar(value=str(date.today()))
        ttk.Entry(row_f2, textvariable=self.issue_var, width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row_f2, text="支払期限:", bg=row_f2.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.due_var = tk.StringVar()
        ttk.Entry(row_f2, textvariable=self.due_var, width=12).pack(side=tk.LEFT, padx=4)
        # 顧客
        row_f3 = tk.Frame(form, bg=form.cget("background"))
        row_f3.pack(fill=tk.X, pady=2)
        tk.Label(row_f3, text="顧客:", bg=row_f3.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.client_var = tk.StringVar()
        self.client_cb = ttk.Combobox(row_f3, textvariable=self.client_var,
                                       state="readonly", width=24)
        self.client_cb.pack(side=tk.LEFT, padx=4)
        ttk.Button(row_f3, text="✏️ 顧客管理",
                   command=self._manage_clients).pack(side=tk.LEFT, padx=4)
        # 備考
        row_f4 = tk.Frame(form, bg=form.cget("background"))
        row_f4.pack(fill=tk.X, pady=2)
        tk.Label(row_f4, text="備考:", bg=row_f4.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT, anchor="n")
        self.notes_text = tk.Text(row_f4, height=2, width=30,
                                   font=("Arial", 10))
        self.notes_text.pack(side=tk.LEFT, padx=4)

        # 明細テーブル
        tk.Label(form, text="明細:", bg=form.cget("background"),
                 font=("Noto Sans JP", 11, "bold")).pack(anchor="w", pady=(8, 2))
        item_f = tk.Frame(form, bg=form.cget("background"))
        item_f.pack(fill=tk.X)
        self.item_name_var = tk.StringVar()
        self.item_cb = ttk.Combobox(item_f, textvariable=self.item_name_var,
                                     width=20)
        self.item_cb.pack(side=tk.LEFT, padx=2)
        self.item_cb.bind("<<ComboboxSelected>>", self._on_item_select)
        tk.Label(item_f, text="数量:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.qty_var = tk.IntVar(value=1)
        ttk.Spinbox(item_f, from_=1, to=9999,
                    textvariable=self.qty_var, width=5).pack(side=tk.LEFT, padx=2)
        tk.Label(item_f, text="単価:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.price_var = tk.StringVar(value="0")
        ttk.Entry(item_f, textvariable=self.price_var, width=10).pack(side=tk.LEFT, padx=2)
        ttk.Button(item_f, text="➕ 追加",
                   command=self._add_line).pack(side=tk.LEFT, padx=4)

        # 明細Treeview
        cols = ("no", "name", "qty", "unit_price", "amount")
        self.line_tree = ttk.Treeview(form, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("no", "#", 30), ("name", "品目", 200),
                         ("qty", "数量", 50), ("unit_price", "単価", 90),
                         ("amount", "金額", 90)]:
            self.line_tree.heading(c, text=h)
            self.line_tree.column(c, width=w, minwidth=30)
        line_sb = ttk.Scrollbar(form, command=self.line_tree.yview)
        self.line_tree.configure(yscrollcommand=line_sb.set)
        line_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.line_tree.pack(fill=tk.BOTH, expand=True)
        ttk.Button(form, text="🗑️ 選択行削除",
                   command=self._delete_line).pack(anchor="w", pady=2)

        # 合計
        total_f = tk.Frame(form, bg=form.cget("background"))
        total_f.pack(fill=tk.X, pady=4)
        self.subtotal_label = tk.Label(total_f, text="小計: ¥0",
                                       bg=form.cget("background"),
                                       font=("Arial", 11))
        self.subtotal_label.pack(side=tk.LEFT)
        self.tax_label = tk.Label(total_f, text="  消費税(10%): ¥0",
                                  bg=form.cget("background"),
                                  font=("Arial", 11))
        self.tax_label.pack(side=tk.LEFT)
        self.total_label = tk.Label(total_f, text="  合計: ¥0",
                                    bg=form.cget("background"),
                                    fg="#e65100",
                                    font=("Arial", 13, "bold"))
        self.total_label.pack(side=tk.LEFT)
        paned.add(form, weight=3)

        # 右: 請求書履歴
        hist_frame = ttk.LabelFrame(paned, text="請求書履歴", padding=4)
        hist_cols = ("inv_no", "client", "date", "total")
        self.hist_tree = ttk.Treeview(hist_frame, columns=hist_cols,
                                       show="headings", selectmode="browse")
        for c, h, w in [("inv_no", "番号", 80), ("client", "顧客", 100),
                         ("date", "発行日", 90), ("total", "金額", 80)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=30)
        hist_sb = ttk.Scrollbar(hist_frame, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=hist_sb.set)
        hist_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        paned.add(hist_frame, weight=1)

        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)
        self._line_items = []

    def _load_clients(self):
        rows = self.conn.execute(
            "SELECT id, name FROM clients ORDER BY name").fetchall()
        self._clients = {name: cid for cid, name in rows}
        self.client_cb.configure(values=list(self._clients.keys()))

    def _load_items(self):
        rows = self.conn.execute(
            "SELECT name, unit_price FROM items ORDER BY name").fetchall()
        self._items = {name: price for name, price in rows}
        self.item_cb.configure(values=list(self._items.keys()))

    def _new_invoice(self):
        now = datetime.now()
        self.inv_no_var.set(f"INV-{now.strftime('%Y%m%d-%H%M')}")
        self.issue_var.set(str(date.today()))
        self.due_var.set("")
        self.notes_text.delete("1.0", tk.END)
        self.line_tree.delete(*self.line_tree.get_children())
        self._line_items = []
        self._update_totals()

    def _on_item_select(self, event):
        name = self.item_name_var.get()
        price = self._items.get(name, 0)
        self.price_var.set(str(price))

    def _add_line(self):
        name = self.item_name_var.get().strip()
        if not name:
            return
        try:
            qty = self.qty_var.get()
            price = float(self.price_var.get())
        except ValueError:
            messagebox.showerror("エラー", "数量・単価を正しく入力してください")
            return
        amount = qty * price
        no = len(self._line_items) + 1
        self._line_items.append((name, qty, price, amount))
        self.line_tree.insert("", "end",
                              values=(no, name, qty,
                                      f"¥{price:,.0f}",
                                      f"¥{amount:,.0f}"))
        self._update_totals()

    def _delete_line(self):
        sel = self.line_tree.selection()
        if sel:
            idx = self.line_tree.index(sel[0])
            self._line_items.pop(idx)
            self.line_tree.delete(sel[0])
            self._renumber()
            self._update_totals()

    def _renumber(self):
        for idx, item in enumerate(self.line_tree.get_children()):
            vals = list(self.line_tree.item(item)["values"])
            vals[0] = idx + 1
            self.line_tree.item(item, values=vals)

    def _update_totals(self):
        subtotal = sum(a for _, _, _, a in self._line_items)
        tax = subtotal * 0.1
        total = subtotal + tax
        self.subtotal_label.config(text=f"小計: ¥{subtotal:,.0f}")
        self.tax_label.config(text=f"  消費税(10%): ¥{tax:,.0f}")
        self.total_label.config(text=f"  合計: ¥{total:,.0f}")
        self._total = total

    def _save_invoice(self):
        client_name = self.client_var.get()
        client_id = self._clients.get(client_name)
        if not client_id:
            messagebox.showwarning("警告", "顧客を選択してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO invoices "
            "(invoice_no, client_id, issue_date, due_date, notes, created_at) "
            "VALUES (?,?,?,?,?,?)",
            (self.inv_no_var.get(), client_id,
             self.issue_var.get(), self.due_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self.status_var.set(f"保存完了: {self.inv_no_var.get()}")

    def _export_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        import csv
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["請求書番号", "顧客", "発行日", "支払期限"])
            w.writerow([self.inv_no_var.get(), self.client_var.get(),
                        self.issue_var.get(), self.due_var.get()])
            w.writerow([])
            w.writerow(["品目", "数量", "単価", "金額"])
            for name, qty, price, amount in self._line_items:
                w.writerow([name, qty, price, amount])
            subtotal = sum(a for _, _, _, a in self._line_items)
            w.writerow(["小計", "", "", subtotal])
            w.writerow(["消費税(10%)", "", "", subtotal * 0.1])
            w.writerow(["合計", "", "", subtotal * 1.1])
        self.status_var.set(f"CSV出力: {path}")

    def _export_pdf(self):
        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning("警告",
                                   "pip install reportlab が必要です")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF", "*.pdf")])
        if not path:
            return
        try:
            doc = SimpleDocTemplate(path, pagesize=A4)
            styles = getSampleStyleSheet()
            story = []

            story.append(Paragraph(f"請求書  {self.inv_no_var.get()}",
                                   styles["Title"]))
            story.append(Spacer(1, 12))
            story.append(Paragraph(
                f"顧客: {self.client_var.get()}  "
                f"発行日: {self.issue_var.get()}  "
                f"支払期限: {self.due_var.get()}", styles["Normal"]))
            story.append(Spacer(1, 20))

            data = [["品目", "数量", "単価(円)", "金額(円)"]]
            for name, qty, price, amount in self._line_items:
                data.append([name, qty, f"{price:,.0f}", f"{amount:,.0f}"])
            subtotal = sum(a for _, _, _, a in self._line_items)
            data.append(["", "", "小計", f"{subtotal:,.0f}"])
            data.append(["", "", "消費税(10%)", f"{subtotal*0.1:,.0f}"])
            data.append(["", "", "合計", f"{subtotal*1.1:,.0f}"])

            t = Table(data, colWidths=[200, 60, 100, 100])
            t.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565c0")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
                ("GRID", (0, 0), (-1, -2), 0.5, colors.grey),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
            ]))
            story.append(t)
            if self.notes_text.get("1.0", tk.END).strip():
                story.append(Spacer(1, 20))
                story.append(Paragraph("備考: " + self.notes_text.get("1.0", tk.END).strip(),
                                       styles["Normal"]))
            doc.build(story)
            self.status_var.set(f"PDF出力: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _manage_clients(self):
        win = tk.Toplevel(self.root)
        win.title("顧客管理")
        win.geometry("500x350")
        # 顧客一覧
        cols = ("id", "name", "address", "email", "phone")
        tree = ttk.Treeview(win, columns=cols, show="headings")
        for c, h, w in [("id", "ID", 40), ("name", "名前", 120),
                         ("address", "住所", 150), ("email", "メール", 120),
                         ("phone", "電話", 100)]:
            tree.heading(c, text=h)
            tree.column(c, width=w)
        tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        for row in self.conn.execute("SELECT * FROM clients").fetchall():
            tree.insert("", "end", values=row)
        # 追加フォーム
        form = tk.Frame(win)
        form.pack(fill=tk.X, padx=4)
        vars_ = {}
        for i, key in enumerate(["名前", "住所", "メール", "電話"]):
            tk.Label(form, text=f"{key}:").grid(row=i//2, column=(i%2)*2, sticky="w", pady=2)
            v = tk.StringVar()
            ttk.Entry(form, textvariable=v, width=18).grid(
                row=i//2, column=(i%2)*2+1, padx=4)
            vars_[key] = v

        def add_client():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES (?,?,?,?)",
                (vars_["名前"].get(), vars_["住所"].get(),
                 vars_["メール"].get(), vars_["電話"].get()))
            self.conn.commit()
            for row in self.conn.execute("SELECT * FROM clients").fetchall():
                tree.insert("", "end", values=row)
            self._load_clients()
        ttk.Button(form, text="追加", command=add_client).grid(
            row=2, column=0, columnspan=4, pady=6)


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

LabelFrameによるセクション分け

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

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

try:
    from reportlab.lib.pagesizes import A4
    from reportlab.lib import colors
    from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
                                     Paragraph, Spacer)
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    REPORTLAB_AVAILABLE = True
except ImportError:
    REPORTLAB_AVAILABLE = False


class App20:
    """請求書作成アプリ"""

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

    def __init__(self, root):
        self.root = root
        self.root.title("請求書作成アプリ")
        self.root.geometry("900x620")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_clients()
        self._load_items()
        self._new_invoice()

        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "reportlab が必要です (PDF出力に必要)。\n"
                "pip install reportlab でインストールしてください。\n\n"
                "CSV形式での出力は可能です。")

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS clients (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, address TEXT, email TEXT, phone TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, unit_price REAL, unit TEXT DEFAULT '式'
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS invoices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                invoice_no TEXT, client_id INTEGER, issue_date TEXT,
                due_date TEXT, notes TEXT, created_at TEXT
            )
        """)
        self.conn.commit()
        # サンプルデータ
        if not self.conn.execute("SELECT 1 FROM clients").fetchone():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('株式会社ABC','東京都千代田区1-1','abc@example.com','03-1234-5678')")
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('山田商事','大阪府大阪市2-2','yamada@example.com','06-9876-5432')")
            self.conn.execute(
                "INSERT INTO items (name,unit_price,unit) VALUES "
                "('Webデザイン',50000,'式'),('システム開発',80000,'式'),"
                "('コンサルティング',30000,'時間'),('保守費用',20000,'月')")
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#f57f17", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🧾 請求書作成アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📄 PDF出力",
                   command=self._export_pdf).pack(side=tk.RIGHT, padx=8)
        ttk.Button(title_frame, text="📊 CSV出力",
                   command=self._export_csv).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="💾 保存",
                   command=self._save_invoice).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="🆕 新規",
                   command=self._new_invoice).pack(side=tk.RIGHT, padx=4)

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 請求書フォーム
        form = ttk.LabelFrame(paned, text="請求書", padding=12)
        # 請求書番号
        row_f = tk.Frame(form, bg=form.cget("background"))
        row_f.pack(fill=tk.X, pady=2)
        tk.Label(row_f, text="請求書番号:", bg=row_f.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.inv_no_var = tk.StringVar()
        ttk.Entry(row_f, textvariable=self.inv_no_var, width=18).pack(side=tk.LEFT, padx=4)
        # 発行日
        row_f2 = tk.Frame(form, bg=form.cget("background"))
        row_f2.pack(fill=tk.X, pady=2)
        tk.Label(row_f2, text="発行日:", bg=row_f2.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.issue_var = tk.StringVar(value=str(date.today()))
        ttk.Entry(row_f2, textvariable=self.issue_var, width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row_f2, text="支払期限:", bg=row_f2.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.due_var = tk.StringVar()
        ttk.Entry(row_f2, textvariable=self.due_var, width=12).pack(side=tk.LEFT, padx=4)
        # 顧客
        row_f3 = tk.Frame(form, bg=form.cget("background"))
        row_f3.pack(fill=tk.X, pady=2)
        tk.Label(row_f3, text="顧客:", bg=row_f3.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.client_var = tk.StringVar()
        self.client_cb = ttk.Combobox(row_f3, textvariable=self.client_var,
                                       state="readonly", width=24)
        self.client_cb.pack(side=tk.LEFT, padx=4)
        ttk.Button(row_f3, text="✏️ 顧客管理",
                   command=self._manage_clients).pack(side=tk.LEFT, padx=4)
        # 備考
        row_f4 = tk.Frame(form, bg=form.cget("background"))
        row_f4.pack(fill=tk.X, pady=2)
        tk.Label(row_f4, text="備考:", bg=row_f4.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT, anchor="n")
        self.notes_text = tk.Text(row_f4, height=2, width=30,
                                   font=("Arial", 10))
        self.notes_text.pack(side=tk.LEFT, padx=4)

        # 明細テーブル
        tk.Label(form, text="明細:", bg=form.cget("background"),
                 font=("Noto Sans JP", 11, "bold")).pack(anchor="w", pady=(8, 2))
        item_f = tk.Frame(form, bg=form.cget("background"))
        item_f.pack(fill=tk.X)
        self.item_name_var = tk.StringVar()
        self.item_cb = ttk.Combobox(item_f, textvariable=self.item_name_var,
                                     width=20)
        self.item_cb.pack(side=tk.LEFT, padx=2)
        self.item_cb.bind("<<ComboboxSelected>>", self._on_item_select)
        tk.Label(item_f, text="数量:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.qty_var = tk.IntVar(value=1)
        ttk.Spinbox(item_f, from_=1, to=9999,
                    textvariable=self.qty_var, width=5).pack(side=tk.LEFT, padx=2)
        tk.Label(item_f, text="単価:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.price_var = tk.StringVar(value="0")
        ttk.Entry(item_f, textvariable=self.price_var, width=10).pack(side=tk.LEFT, padx=2)
        ttk.Button(item_f, text="➕ 追加",
                   command=self._add_line).pack(side=tk.LEFT, padx=4)

        # 明細Treeview
        cols = ("no", "name", "qty", "unit_price", "amount")
        self.line_tree = ttk.Treeview(form, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("no", "#", 30), ("name", "品目", 200),
                         ("qty", "数量", 50), ("unit_price", "単価", 90),
                         ("amount", "金額", 90)]:
            self.line_tree.heading(c, text=h)
            self.line_tree.column(c, width=w, minwidth=30)
        line_sb = ttk.Scrollbar(form, command=self.line_tree.yview)
        self.line_tree.configure(yscrollcommand=line_sb.set)
        line_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.line_tree.pack(fill=tk.BOTH, expand=True)
        ttk.Button(form, text="🗑️ 選択行削除",
                   command=self._delete_line).pack(anchor="w", pady=2)

        # 合計
        total_f = tk.Frame(form, bg=form.cget("background"))
        total_f.pack(fill=tk.X, pady=4)
        self.subtotal_label = tk.Label(total_f, text="小計: ¥0",
                                       bg=form.cget("background"),
                                       font=("Arial", 11))
        self.subtotal_label.pack(side=tk.LEFT)
        self.tax_label = tk.Label(total_f, text="  消費税(10%): ¥0",
                                  bg=form.cget("background"),
                                  font=("Arial", 11))
        self.tax_label.pack(side=tk.LEFT)
        self.total_label = tk.Label(total_f, text="  合計: ¥0",
                                    bg=form.cget("background"),
                                    fg="#e65100",
                                    font=("Arial", 13, "bold"))
        self.total_label.pack(side=tk.LEFT)
        paned.add(form, weight=3)

        # 右: 請求書履歴
        hist_frame = ttk.LabelFrame(paned, text="請求書履歴", padding=4)
        hist_cols = ("inv_no", "client", "date", "total")
        self.hist_tree = ttk.Treeview(hist_frame, columns=hist_cols,
                                       show="headings", selectmode="browse")
        for c, h, w in [("inv_no", "番号", 80), ("client", "顧客", 100),
                         ("date", "発行日", 90), ("total", "金額", 80)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=30)
        hist_sb = ttk.Scrollbar(hist_frame, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=hist_sb.set)
        hist_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        paned.add(hist_frame, weight=1)

        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)
        self._line_items = []

    def _load_clients(self):
        rows = self.conn.execute(
            "SELECT id, name FROM clients ORDER BY name").fetchall()
        self._clients = {name: cid for cid, name in rows}
        self.client_cb.configure(values=list(self._clients.keys()))

    def _load_items(self):
        rows = self.conn.execute(
            "SELECT name, unit_price FROM items ORDER BY name").fetchall()
        self._items = {name: price for name, price in rows}
        self.item_cb.configure(values=list(self._items.keys()))

    def _new_invoice(self):
        now = datetime.now()
        self.inv_no_var.set(f"INV-{now.strftime('%Y%m%d-%H%M')}")
        self.issue_var.set(str(date.today()))
        self.due_var.set("")
        self.notes_text.delete("1.0", tk.END)
        self.line_tree.delete(*self.line_tree.get_children())
        self._line_items = []
        self._update_totals()

    def _on_item_select(self, event):
        name = self.item_name_var.get()
        price = self._items.get(name, 0)
        self.price_var.set(str(price))

    def _add_line(self):
        name = self.item_name_var.get().strip()
        if not name:
            return
        try:
            qty = self.qty_var.get()
            price = float(self.price_var.get())
        except ValueError:
            messagebox.showerror("エラー", "数量・単価を正しく入力してください")
            return
        amount = qty * price
        no = len(self._line_items) + 1
        self._line_items.append((name, qty, price, amount))
        self.line_tree.insert("", "end",
                              values=(no, name, qty,
                                      f"¥{price:,.0f}",
                                      f"¥{amount:,.0f}"))
        self._update_totals()

    def _delete_line(self):
        sel = self.line_tree.selection()
        if sel:
            idx = self.line_tree.index(sel[0])
            self._line_items.pop(idx)
            self.line_tree.delete(sel[0])
            self._renumber()
            self._update_totals()

    def _renumber(self):
        for idx, item in enumerate(self.line_tree.get_children()):
            vals = list(self.line_tree.item(item)["values"])
            vals[0] = idx + 1
            self.line_tree.item(item, values=vals)

    def _update_totals(self):
        subtotal = sum(a for _, _, _, a in self._line_items)
        tax = subtotal * 0.1
        total = subtotal + tax
        self.subtotal_label.config(text=f"小計: ¥{subtotal:,.0f}")
        self.tax_label.config(text=f"  消費税(10%): ¥{tax:,.0f}")
        self.total_label.config(text=f"  合計: ¥{total:,.0f}")
        self._total = total

    def _save_invoice(self):
        client_name = self.client_var.get()
        client_id = self._clients.get(client_name)
        if not client_id:
            messagebox.showwarning("警告", "顧客を選択してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO invoices "
            "(invoice_no, client_id, issue_date, due_date, notes, created_at) "
            "VALUES (?,?,?,?,?,?)",
            (self.inv_no_var.get(), client_id,
             self.issue_var.get(), self.due_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self.status_var.set(f"保存完了: {self.inv_no_var.get()}")

    def _export_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        import csv
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["請求書番号", "顧客", "発行日", "支払期限"])
            w.writerow([self.inv_no_var.get(), self.client_var.get(),
                        self.issue_var.get(), self.due_var.get()])
            w.writerow([])
            w.writerow(["品目", "数量", "単価", "金額"])
            for name, qty, price, amount in self._line_items:
                w.writerow([name, qty, price, amount])
            subtotal = sum(a for _, _, _, a in self._line_items)
            w.writerow(["小計", "", "", subtotal])
            w.writerow(["消費税(10%)", "", "", subtotal * 0.1])
            w.writerow(["合計", "", "", subtotal * 1.1])
        self.status_var.set(f"CSV出力: {path}")

    def _export_pdf(self):
        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning("警告",
                                   "pip install reportlab が必要です")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF", "*.pdf")])
        if not path:
            return
        try:
            doc = SimpleDocTemplate(path, pagesize=A4)
            styles = getSampleStyleSheet()
            story = []

            story.append(Paragraph(f"請求書  {self.inv_no_var.get()}",
                                   styles["Title"]))
            story.append(Spacer(1, 12))
            story.append(Paragraph(
                f"顧客: {self.client_var.get()}  "
                f"発行日: {self.issue_var.get()}  "
                f"支払期限: {self.due_var.get()}", styles["Normal"]))
            story.append(Spacer(1, 20))

            data = [["品目", "数量", "単価(円)", "金額(円)"]]
            for name, qty, price, amount in self._line_items:
                data.append([name, qty, f"{price:,.0f}", f"{amount:,.0f}"])
            subtotal = sum(a for _, _, _, a in self._line_items)
            data.append(["", "", "小計", f"{subtotal:,.0f}"])
            data.append(["", "", "消費税(10%)", f"{subtotal*0.1:,.0f}"])
            data.append(["", "", "合計", f"{subtotal*1.1:,.0f}"])

            t = Table(data, colWidths=[200, 60, 100, 100])
            t.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565c0")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
                ("GRID", (0, 0), (-1, -2), 0.5, colors.grey),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
            ]))
            story.append(t)
            if self.notes_text.get("1.0", tk.END).strip():
                story.append(Spacer(1, 20))
                story.append(Paragraph("備考: " + self.notes_text.get("1.0", tk.END).strip(),
                                       styles["Normal"]))
            doc.build(story)
            self.status_var.set(f"PDF出力: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _manage_clients(self):
        win = tk.Toplevel(self.root)
        win.title("顧客管理")
        win.geometry("500x350")
        # 顧客一覧
        cols = ("id", "name", "address", "email", "phone")
        tree = ttk.Treeview(win, columns=cols, show="headings")
        for c, h, w in [("id", "ID", 40), ("name", "名前", 120),
                         ("address", "住所", 150), ("email", "メール", 120),
                         ("phone", "電話", 100)]:
            tree.heading(c, text=h)
            tree.column(c, width=w)
        tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        for row in self.conn.execute("SELECT * FROM clients").fetchall():
            tree.insert("", "end", values=row)
        # 追加フォーム
        form = tk.Frame(win)
        form.pack(fill=tk.X, padx=4)
        vars_ = {}
        for i, key in enumerate(["名前", "住所", "メール", "電話"]):
            tk.Label(form, text=f"{key}:").grid(row=i//2, column=(i%2)*2, sticky="w", pady=2)
            v = tk.StringVar()
            ttk.Entry(form, textvariable=v, width=18).grid(
                row=i//2, column=(i%2)*2+1, padx=4)
            vars_[key] = v

        def add_client():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES (?,?,?,?)",
                (vars_["名前"].get(), vars_["住所"].get(),
                 vars_["メール"].get(), vars_["電話"].get()))
            self.conn.commit()
            for row in self.conn.execute("SELECT * FROM clients").fetchall():
                tree.insert("", "end", values=row)
            self._load_clients()
        ttk.Button(form, text="追加", command=add_client).grid(
            row=2, column=0, columnspan=4, pady=6)


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

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

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

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

try:
    from reportlab.lib.pagesizes import A4
    from reportlab.lib import colors
    from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
                                     Paragraph, Spacer)
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    REPORTLAB_AVAILABLE = True
except ImportError:
    REPORTLAB_AVAILABLE = False


class App20:
    """請求書作成アプリ"""

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

    def __init__(self, root):
        self.root = root
        self.root.title("請求書作成アプリ")
        self.root.geometry("900x620")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_clients()
        self._load_items()
        self._new_invoice()

        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "reportlab が必要です (PDF出力に必要)。\n"
                "pip install reportlab でインストールしてください。\n\n"
                "CSV形式での出力は可能です。")

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS clients (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, address TEXT, email TEXT, phone TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, unit_price REAL, unit TEXT DEFAULT '式'
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS invoices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                invoice_no TEXT, client_id INTEGER, issue_date TEXT,
                due_date TEXT, notes TEXT, created_at TEXT
            )
        """)
        self.conn.commit()
        # サンプルデータ
        if not self.conn.execute("SELECT 1 FROM clients").fetchone():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('株式会社ABC','東京都千代田区1-1','abc@example.com','03-1234-5678')")
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('山田商事','大阪府大阪市2-2','yamada@example.com','06-9876-5432')")
            self.conn.execute(
                "INSERT INTO items (name,unit_price,unit) VALUES "
                "('Webデザイン',50000,'式'),('システム開発',80000,'式'),"
                "('コンサルティング',30000,'時間'),('保守費用',20000,'月')")
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#f57f17", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🧾 請求書作成アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📄 PDF出力",
                   command=self._export_pdf).pack(side=tk.RIGHT, padx=8)
        ttk.Button(title_frame, text="📊 CSV出力",
                   command=self._export_csv).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="💾 保存",
                   command=self._save_invoice).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="🆕 新規",
                   command=self._new_invoice).pack(side=tk.RIGHT, padx=4)

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 請求書フォーム
        form = ttk.LabelFrame(paned, text="請求書", padding=12)
        # 請求書番号
        row_f = tk.Frame(form, bg=form.cget("background"))
        row_f.pack(fill=tk.X, pady=2)
        tk.Label(row_f, text="請求書番号:", bg=row_f.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.inv_no_var = tk.StringVar()
        ttk.Entry(row_f, textvariable=self.inv_no_var, width=18).pack(side=tk.LEFT, padx=4)
        # 発行日
        row_f2 = tk.Frame(form, bg=form.cget("background"))
        row_f2.pack(fill=tk.X, pady=2)
        tk.Label(row_f2, text="発行日:", bg=row_f2.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.issue_var = tk.StringVar(value=str(date.today()))
        ttk.Entry(row_f2, textvariable=self.issue_var, width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row_f2, text="支払期限:", bg=row_f2.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.due_var = tk.StringVar()
        ttk.Entry(row_f2, textvariable=self.due_var, width=12).pack(side=tk.LEFT, padx=4)
        # 顧客
        row_f3 = tk.Frame(form, bg=form.cget("background"))
        row_f3.pack(fill=tk.X, pady=2)
        tk.Label(row_f3, text="顧客:", bg=row_f3.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.client_var = tk.StringVar()
        self.client_cb = ttk.Combobox(row_f3, textvariable=self.client_var,
                                       state="readonly", width=24)
        self.client_cb.pack(side=tk.LEFT, padx=4)
        ttk.Button(row_f3, text="✏️ 顧客管理",
                   command=self._manage_clients).pack(side=tk.LEFT, padx=4)
        # 備考
        row_f4 = tk.Frame(form, bg=form.cget("background"))
        row_f4.pack(fill=tk.X, pady=2)
        tk.Label(row_f4, text="備考:", bg=row_f4.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT, anchor="n")
        self.notes_text = tk.Text(row_f4, height=2, width=30,
                                   font=("Arial", 10))
        self.notes_text.pack(side=tk.LEFT, padx=4)

        # 明細テーブル
        tk.Label(form, text="明細:", bg=form.cget("background"),
                 font=("Noto Sans JP", 11, "bold")).pack(anchor="w", pady=(8, 2))
        item_f = tk.Frame(form, bg=form.cget("background"))
        item_f.pack(fill=tk.X)
        self.item_name_var = tk.StringVar()
        self.item_cb = ttk.Combobox(item_f, textvariable=self.item_name_var,
                                     width=20)
        self.item_cb.pack(side=tk.LEFT, padx=2)
        self.item_cb.bind("<<ComboboxSelected>>", self._on_item_select)
        tk.Label(item_f, text="数量:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.qty_var = tk.IntVar(value=1)
        ttk.Spinbox(item_f, from_=1, to=9999,
                    textvariable=self.qty_var, width=5).pack(side=tk.LEFT, padx=2)
        tk.Label(item_f, text="単価:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.price_var = tk.StringVar(value="0")
        ttk.Entry(item_f, textvariable=self.price_var, width=10).pack(side=tk.LEFT, padx=2)
        ttk.Button(item_f, text="➕ 追加",
                   command=self._add_line).pack(side=tk.LEFT, padx=4)

        # 明細Treeview
        cols = ("no", "name", "qty", "unit_price", "amount")
        self.line_tree = ttk.Treeview(form, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("no", "#", 30), ("name", "品目", 200),
                         ("qty", "数量", 50), ("unit_price", "単価", 90),
                         ("amount", "金額", 90)]:
            self.line_tree.heading(c, text=h)
            self.line_tree.column(c, width=w, minwidth=30)
        line_sb = ttk.Scrollbar(form, command=self.line_tree.yview)
        self.line_tree.configure(yscrollcommand=line_sb.set)
        line_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.line_tree.pack(fill=tk.BOTH, expand=True)
        ttk.Button(form, text="🗑️ 選択行削除",
                   command=self._delete_line).pack(anchor="w", pady=2)

        # 合計
        total_f = tk.Frame(form, bg=form.cget("background"))
        total_f.pack(fill=tk.X, pady=4)
        self.subtotal_label = tk.Label(total_f, text="小計: ¥0",
                                       bg=form.cget("background"),
                                       font=("Arial", 11))
        self.subtotal_label.pack(side=tk.LEFT)
        self.tax_label = tk.Label(total_f, text="  消費税(10%): ¥0",
                                  bg=form.cget("background"),
                                  font=("Arial", 11))
        self.tax_label.pack(side=tk.LEFT)
        self.total_label = tk.Label(total_f, text="  合計: ¥0",
                                    bg=form.cget("background"),
                                    fg="#e65100",
                                    font=("Arial", 13, "bold"))
        self.total_label.pack(side=tk.LEFT)
        paned.add(form, weight=3)

        # 右: 請求書履歴
        hist_frame = ttk.LabelFrame(paned, text="請求書履歴", padding=4)
        hist_cols = ("inv_no", "client", "date", "total")
        self.hist_tree = ttk.Treeview(hist_frame, columns=hist_cols,
                                       show="headings", selectmode="browse")
        for c, h, w in [("inv_no", "番号", 80), ("client", "顧客", 100),
                         ("date", "発行日", 90), ("total", "金額", 80)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=30)
        hist_sb = ttk.Scrollbar(hist_frame, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=hist_sb.set)
        hist_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        paned.add(hist_frame, weight=1)

        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)
        self._line_items = []

    def _load_clients(self):
        rows = self.conn.execute(
            "SELECT id, name FROM clients ORDER BY name").fetchall()
        self._clients = {name: cid for cid, name in rows}
        self.client_cb.configure(values=list(self._clients.keys()))

    def _load_items(self):
        rows = self.conn.execute(
            "SELECT name, unit_price FROM items ORDER BY name").fetchall()
        self._items = {name: price for name, price in rows}
        self.item_cb.configure(values=list(self._items.keys()))

    def _new_invoice(self):
        now = datetime.now()
        self.inv_no_var.set(f"INV-{now.strftime('%Y%m%d-%H%M')}")
        self.issue_var.set(str(date.today()))
        self.due_var.set("")
        self.notes_text.delete("1.0", tk.END)
        self.line_tree.delete(*self.line_tree.get_children())
        self._line_items = []
        self._update_totals()

    def _on_item_select(self, event):
        name = self.item_name_var.get()
        price = self._items.get(name, 0)
        self.price_var.set(str(price))

    def _add_line(self):
        name = self.item_name_var.get().strip()
        if not name:
            return
        try:
            qty = self.qty_var.get()
            price = float(self.price_var.get())
        except ValueError:
            messagebox.showerror("エラー", "数量・単価を正しく入力してください")
            return
        amount = qty * price
        no = len(self._line_items) + 1
        self._line_items.append((name, qty, price, amount))
        self.line_tree.insert("", "end",
                              values=(no, name, qty,
                                      f"¥{price:,.0f}",
                                      f"¥{amount:,.0f}"))
        self._update_totals()

    def _delete_line(self):
        sel = self.line_tree.selection()
        if sel:
            idx = self.line_tree.index(sel[0])
            self._line_items.pop(idx)
            self.line_tree.delete(sel[0])
            self._renumber()
            self._update_totals()

    def _renumber(self):
        for idx, item in enumerate(self.line_tree.get_children()):
            vals = list(self.line_tree.item(item)["values"])
            vals[0] = idx + 1
            self.line_tree.item(item, values=vals)

    def _update_totals(self):
        subtotal = sum(a for _, _, _, a in self._line_items)
        tax = subtotal * 0.1
        total = subtotal + tax
        self.subtotal_label.config(text=f"小計: ¥{subtotal:,.0f}")
        self.tax_label.config(text=f"  消費税(10%): ¥{tax:,.0f}")
        self.total_label.config(text=f"  合計: ¥{total:,.0f}")
        self._total = total

    def _save_invoice(self):
        client_name = self.client_var.get()
        client_id = self._clients.get(client_name)
        if not client_id:
            messagebox.showwarning("警告", "顧客を選択してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO invoices "
            "(invoice_no, client_id, issue_date, due_date, notes, created_at) "
            "VALUES (?,?,?,?,?,?)",
            (self.inv_no_var.get(), client_id,
             self.issue_var.get(), self.due_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self.status_var.set(f"保存完了: {self.inv_no_var.get()}")

    def _export_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        import csv
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["請求書番号", "顧客", "発行日", "支払期限"])
            w.writerow([self.inv_no_var.get(), self.client_var.get(),
                        self.issue_var.get(), self.due_var.get()])
            w.writerow([])
            w.writerow(["品目", "数量", "単価", "金額"])
            for name, qty, price, amount in self._line_items:
                w.writerow([name, qty, price, amount])
            subtotal = sum(a for _, _, _, a in self._line_items)
            w.writerow(["小計", "", "", subtotal])
            w.writerow(["消費税(10%)", "", "", subtotal * 0.1])
            w.writerow(["合計", "", "", subtotal * 1.1])
        self.status_var.set(f"CSV出力: {path}")

    def _export_pdf(self):
        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning("警告",
                                   "pip install reportlab が必要です")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF", "*.pdf")])
        if not path:
            return
        try:
            doc = SimpleDocTemplate(path, pagesize=A4)
            styles = getSampleStyleSheet()
            story = []

            story.append(Paragraph(f"請求書  {self.inv_no_var.get()}",
                                   styles["Title"]))
            story.append(Spacer(1, 12))
            story.append(Paragraph(
                f"顧客: {self.client_var.get()}  "
                f"発行日: {self.issue_var.get()}  "
                f"支払期限: {self.due_var.get()}", styles["Normal"]))
            story.append(Spacer(1, 20))

            data = [["品目", "数量", "単価(円)", "金額(円)"]]
            for name, qty, price, amount in self._line_items:
                data.append([name, qty, f"{price:,.0f}", f"{amount:,.0f}"])
            subtotal = sum(a for _, _, _, a in self._line_items)
            data.append(["", "", "小計", f"{subtotal:,.0f}"])
            data.append(["", "", "消費税(10%)", f"{subtotal*0.1:,.0f}"])
            data.append(["", "", "合計", f"{subtotal*1.1:,.0f}"])

            t = Table(data, colWidths=[200, 60, 100, 100])
            t.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565c0")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
                ("GRID", (0, 0), (-1, -2), 0.5, colors.grey),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
            ]))
            story.append(t)
            if self.notes_text.get("1.0", tk.END).strip():
                story.append(Spacer(1, 20))
                story.append(Paragraph("備考: " + self.notes_text.get("1.0", tk.END).strip(),
                                       styles["Normal"]))
            doc.build(story)
            self.status_var.set(f"PDF出力: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _manage_clients(self):
        win = tk.Toplevel(self.root)
        win.title("顧客管理")
        win.geometry("500x350")
        # 顧客一覧
        cols = ("id", "name", "address", "email", "phone")
        tree = ttk.Treeview(win, columns=cols, show="headings")
        for c, h, w in [("id", "ID", 40), ("name", "名前", 120),
                         ("address", "住所", 150), ("email", "メール", 120),
                         ("phone", "電話", 100)]:
            tree.heading(c, text=h)
            tree.column(c, width=w)
        tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        for row in self.conn.execute("SELECT * FROM clients").fetchall():
            tree.insert("", "end", values=row)
        # 追加フォーム
        form = tk.Frame(win)
        form.pack(fill=tk.X, padx=4)
        vars_ = {}
        for i, key in enumerate(["名前", "住所", "メール", "電話"]):
            tk.Label(form, text=f"{key}:").grid(row=i//2, column=(i%2)*2, sticky="w", pady=2)
            v = tk.StringVar()
            ttk.Entry(form, textvariable=v, width=18).grid(
                row=i//2, column=(i%2)*2+1, padx=4)
            vars_[key] = v

        def add_client():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES (?,?,?,?)",
                (vars_["名前"].get(), vars_["住所"].get(),
                 vars_["メール"].get(), vars_["電話"].get()))
            self.conn.commit()
            for row in self.conn.execute("SELECT * FROM clients").fetchall():
                tree.insert("", "end", values=row)
            self._load_clients()
        ttk.Button(form, text="追加", command=add_client).grid(
            row=2, column=0, columnspan=4, pady=6)


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

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

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

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

try:
    from reportlab.lib.pagesizes import A4
    from reportlab.lib import colors
    from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
                                     Paragraph, Spacer)
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    REPORTLAB_AVAILABLE = True
except ImportError:
    REPORTLAB_AVAILABLE = False


class App20:
    """請求書作成アプリ"""

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

    def __init__(self, root):
        self.root = root
        self.root.title("請求書作成アプリ")
        self.root.geometry("900x620")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_clients()
        self._load_items()
        self._new_invoice()

        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "reportlab が必要です (PDF出力に必要)。\n"
                "pip install reportlab でインストールしてください。\n\n"
                "CSV形式での出力は可能です。")

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS clients (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, address TEXT, email TEXT, phone TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, unit_price REAL, unit TEXT DEFAULT '式'
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS invoices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                invoice_no TEXT, client_id INTEGER, issue_date TEXT,
                due_date TEXT, notes TEXT, created_at TEXT
            )
        """)
        self.conn.commit()
        # サンプルデータ
        if not self.conn.execute("SELECT 1 FROM clients").fetchone():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('株式会社ABC','東京都千代田区1-1','abc@example.com','03-1234-5678')")
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('山田商事','大阪府大阪市2-2','yamada@example.com','06-9876-5432')")
            self.conn.execute(
                "INSERT INTO items (name,unit_price,unit) VALUES "
                "('Webデザイン',50000,'式'),('システム開発',80000,'式'),"
                "('コンサルティング',30000,'時間'),('保守費用',20000,'月')")
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#f57f17", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🧾 請求書作成アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📄 PDF出力",
                   command=self._export_pdf).pack(side=tk.RIGHT, padx=8)
        ttk.Button(title_frame, text="📊 CSV出力",
                   command=self._export_csv).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="💾 保存",
                   command=self._save_invoice).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="🆕 新規",
                   command=self._new_invoice).pack(side=tk.RIGHT, padx=4)

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 請求書フォーム
        form = ttk.LabelFrame(paned, text="請求書", padding=12)
        # 請求書番号
        row_f = tk.Frame(form, bg=form.cget("background"))
        row_f.pack(fill=tk.X, pady=2)
        tk.Label(row_f, text="請求書番号:", bg=row_f.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.inv_no_var = tk.StringVar()
        ttk.Entry(row_f, textvariable=self.inv_no_var, width=18).pack(side=tk.LEFT, padx=4)
        # 発行日
        row_f2 = tk.Frame(form, bg=form.cget("background"))
        row_f2.pack(fill=tk.X, pady=2)
        tk.Label(row_f2, text="発行日:", bg=row_f2.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.issue_var = tk.StringVar(value=str(date.today()))
        ttk.Entry(row_f2, textvariable=self.issue_var, width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row_f2, text="支払期限:", bg=row_f2.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.due_var = tk.StringVar()
        ttk.Entry(row_f2, textvariable=self.due_var, width=12).pack(side=tk.LEFT, padx=4)
        # 顧客
        row_f3 = tk.Frame(form, bg=form.cget("background"))
        row_f3.pack(fill=tk.X, pady=2)
        tk.Label(row_f3, text="顧客:", bg=row_f3.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.client_var = tk.StringVar()
        self.client_cb = ttk.Combobox(row_f3, textvariable=self.client_var,
                                       state="readonly", width=24)
        self.client_cb.pack(side=tk.LEFT, padx=4)
        ttk.Button(row_f3, text="✏️ 顧客管理",
                   command=self._manage_clients).pack(side=tk.LEFT, padx=4)
        # 備考
        row_f4 = tk.Frame(form, bg=form.cget("background"))
        row_f4.pack(fill=tk.X, pady=2)
        tk.Label(row_f4, text="備考:", bg=row_f4.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT, anchor="n")
        self.notes_text = tk.Text(row_f4, height=2, width=30,
                                   font=("Arial", 10))
        self.notes_text.pack(side=tk.LEFT, padx=4)

        # 明細テーブル
        tk.Label(form, text="明細:", bg=form.cget("background"),
                 font=("Noto Sans JP", 11, "bold")).pack(anchor="w", pady=(8, 2))
        item_f = tk.Frame(form, bg=form.cget("background"))
        item_f.pack(fill=tk.X)
        self.item_name_var = tk.StringVar()
        self.item_cb = ttk.Combobox(item_f, textvariable=self.item_name_var,
                                     width=20)
        self.item_cb.pack(side=tk.LEFT, padx=2)
        self.item_cb.bind("<<ComboboxSelected>>", self._on_item_select)
        tk.Label(item_f, text="数量:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.qty_var = tk.IntVar(value=1)
        ttk.Spinbox(item_f, from_=1, to=9999,
                    textvariable=self.qty_var, width=5).pack(side=tk.LEFT, padx=2)
        tk.Label(item_f, text="単価:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.price_var = tk.StringVar(value="0")
        ttk.Entry(item_f, textvariable=self.price_var, width=10).pack(side=tk.LEFT, padx=2)
        ttk.Button(item_f, text="➕ 追加",
                   command=self._add_line).pack(side=tk.LEFT, padx=4)

        # 明細Treeview
        cols = ("no", "name", "qty", "unit_price", "amount")
        self.line_tree = ttk.Treeview(form, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("no", "#", 30), ("name", "品目", 200),
                         ("qty", "数量", 50), ("unit_price", "単価", 90),
                         ("amount", "金額", 90)]:
            self.line_tree.heading(c, text=h)
            self.line_tree.column(c, width=w, minwidth=30)
        line_sb = ttk.Scrollbar(form, command=self.line_tree.yview)
        self.line_tree.configure(yscrollcommand=line_sb.set)
        line_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.line_tree.pack(fill=tk.BOTH, expand=True)
        ttk.Button(form, text="🗑️ 選択行削除",
                   command=self._delete_line).pack(anchor="w", pady=2)

        # 合計
        total_f = tk.Frame(form, bg=form.cget("background"))
        total_f.pack(fill=tk.X, pady=4)
        self.subtotal_label = tk.Label(total_f, text="小計: ¥0",
                                       bg=form.cget("background"),
                                       font=("Arial", 11))
        self.subtotal_label.pack(side=tk.LEFT)
        self.tax_label = tk.Label(total_f, text="  消費税(10%): ¥0",
                                  bg=form.cget("background"),
                                  font=("Arial", 11))
        self.tax_label.pack(side=tk.LEFT)
        self.total_label = tk.Label(total_f, text="  合計: ¥0",
                                    bg=form.cget("background"),
                                    fg="#e65100",
                                    font=("Arial", 13, "bold"))
        self.total_label.pack(side=tk.LEFT)
        paned.add(form, weight=3)

        # 右: 請求書履歴
        hist_frame = ttk.LabelFrame(paned, text="請求書履歴", padding=4)
        hist_cols = ("inv_no", "client", "date", "total")
        self.hist_tree = ttk.Treeview(hist_frame, columns=hist_cols,
                                       show="headings", selectmode="browse")
        for c, h, w in [("inv_no", "番号", 80), ("client", "顧客", 100),
                         ("date", "発行日", 90), ("total", "金額", 80)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=30)
        hist_sb = ttk.Scrollbar(hist_frame, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=hist_sb.set)
        hist_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        paned.add(hist_frame, weight=1)

        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)
        self._line_items = []

    def _load_clients(self):
        rows = self.conn.execute(
            "SELECT id, name FROM clients ORDER BY name").fetchall()
        self._clients = {name: cid for cid, name in rows}
        self.client_cb.configure(values=list(self._clients.keys()))

    def _load_items(self):
        rows = self.conn.execute(
            "SELECT name, unit_price FROM items ORDER BY name").fetchall()
        self._items = {name: price for name, price in rows}
        self.item_cb.configure(values=list(self._items.keys()))

    def _new_invoice(self):
        now = datetime.now()
        self.inv_no_var.set(f"INV-{now.strftime('%Y%m%d-%H%M')}")
        self.issue_var.set(str(date.today()))
        self.due_var.set("")
        self.notes_text.delete("1.0", tk.END)
        self.line_tree.delete(*self.line_tree.get_children())
        self._line_items = []
        self._update_totals()

    def _on_item_select(self, event):
        name = self.item_name_var.get()
        price = self._items.get(name, 0)
        self.price_var.set(str(price))

    def _add_line(self):
        name = self.item_name_var.get().strip()
        if not name:
            return
        try:
            qty = self.qty_var.get()
            price = float(self.price_var.get())
        except ValueError:
            messagebox.showerror("エラー", "数量・単価を正しく入力してください")
            return
        amount = qty * price
        no = len(self._line_items) + 1
        self._line_items.append((name, qty, price, amount))
        self.line_tree.insert("", "end",
                              values=(no, name, qty,
                                      f"¥{price:,.0f}",
                                      f"¥{amount:,.0f}"))
        self._update_totals()

    def _delete_line(self):
        sel = self.line_tree.selection()
        if sel:
            idx = self.line_tree.index(sel[0])
            self._line_items.pop(idx)
            self.line_tree.delete(sel[0])
            self._renumber()
            self._update_totals()

    def _renumber(self):
        for idx, item in enumerate(self.line_tree.get_children()):
            vals = list(self.line_tree.item(item)["values"])
            vals[0] = idx + 1
            self.line_tree.item(item, values=vals)

    def _update_totals(self):
        subtotal = sum(a for _, _, _, a in self._line_items)
        tax = subtotal * 0.1
        total = subtotal + tax
        self.subtotal_label.config(text=f"小計: ¥{subtotal:,.0f}")
        self.tax_label.config(text=f"  消費税(10%): ¥{tax:,.0f}")
        self.total_label.config(text=f"  合計: ¥{total:,.0f}")
        self._total = total

    def _save_invoice(self):
        client_name = self.client_var.get()
        client_id = self._clients.get(client_name)
        if not client_id:
            messagebox.showwarning("警告", "顧客を選択してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO invoices "
            "(invoice_no, client_id, issue_date, due_date, notes, created_at) "
            "VALUES (?,?,?,?,?,?)",
            (self.inv_no_var.get(), client_id,
             self.issue_var.get(), self.due_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self.status_var.set(f"保存完了: {self.inv_no_var.get()}")

    def _export_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        import csv
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["請求書番号", "顧客", "発行日", "支払期限"])
            w.writerow([self.inv_no_var.get(), self.client_var.get(),
                        self.issue_var.get(), self.due_var.get()])
            w.writerow([])
            w.writerow(["品目", "数量", "単価", "金額"])
            for name, qty, price, amount in self._line_items:
                w.writerow([name, qty, price, amount])
            subtotal = sum(a for _, _, _, a in self._line_items)
            w.writerow(["小計", "", "", subtotal])
            w.writerow(["消費税(10%)", "", "", subtotal * 0.1])
            w.writerow(["合計", "", "", subtotal * 1.1])
        self.status_var.set(f"CSV出力: {path}")

    def _export_pdf(self):
        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning("警告",
                                   "pip install reportlab が必要です")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF", "*.pdf")])
        if not path:
            return
        try:
            doc = SimpleDocTemplate(path, pagesize=A4)
            styles = getSampleStyleSheet()
            story = []

            story.append(Paragraph(f"請求書  {self.inv_no_var.get()}",
                                   styles["Title"]))
            story.append(Spacer(1, 12))
            story.append(Paragraph(
                f"顧客: {self.client_var.get()}  "
                f"発行日: {self.issue_var.get()}  "
                f"支払期限: {self.due_var.get()}", styles["Normal"]))
            story.append(Spacer(1, 20))

            data = [["品目", "数量", "単価(円)", "金額(円)"]]
            for name, qty, price, amount in self._line_items:
                data.append([name, qty, f"{price:,.0f}", f"{amount:,.0f}"])
            subtotal = sum(a for _, _, _, a in self._line_items)
            data.append(["", "", "小計", f"{subtotal:,.0f}"])
            data.append(["", "", "消費税(10%)", f"{subtotal*0.1:,.0f}"])
            data.append(["", "", "合計", f"{subtotal*1.1:,.0f}"])

            t = Table(data, colWidths=[200, 60, 100, 100])
            t.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565c0")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
                ("GRID", (0, 0), (-1, -2), 0.5, colors.grey),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
            ]))
            story.append(t)
            if self.notes_text.get("1.0", tk.END).strip():
                story.append(Spacer(1, 20))
                story.append(Paragraph("備考: " + self.notes_text.get("1.0", tk.END).strip(),
                                       styles["Normal"]))
            doc.build(story)
            self.status_var.set(f"PDF出力: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _manage_clients(self):
        win = tk.Toplevel(self.root)
        win.title("顧客管理")
        win.geometry("500x350")
        # 顧客一覧
        cols = ("id", "name", "address", "email", "phone")
        tree = ttk.Treeview(win, columns=cols, show="headings")
        for c, h, w in [("id", "ID", 40), ("name", "名前", 120),
                         ("address", "住所", 150), ("email", "メール", 120),
                         ("phone", "電話", 100)]:
            tree.heading(c, text=h)
            tree.column(c, width=w)
        tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        for row in self.conn.execute("SELECT * FROM clients").fetchall():
            tree.insert("", "end", values=row)
        # 追加フォーム
        form = tk.Frame(win)
        form.pack(fill=tk.X, padx=4)
        vars_ = {}
        for i, key in enumerate(["名前", "住所", "メール", "電話"]):
            tk.Label(form, text=f"{key}:").grid(row=i//2, column=(i%2)*2, sticky="w", pady=2)
            v = tk.StringVar()
            ttk.Entry(form, textvariable=v, width=18).grid(
                row=i//2, column=(i%2)*2+1, padx=4)
            vars_[key] = v

        def add_client():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES (?,?,?,?)",
                (vars_["名前"].get(), vars_["住所"].get(),
                 vars_["メール"].get(), vars_["電話"].get()))
            self.conn.commit()
            for row in self.conn.execute("SELECT * FROM clients").fetchall():
                tree.insert("", "end", values=row)
            self._load_clients()
        ttk.Button(form, text="追加", command=add_client).grid(
            row=2, column=0, columnspan=4, pady=6)


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

例外処理とmessagebox

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

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

try:
    from reportlab.lib.pagesizes import A4
    from reportlab.lib import colors
    from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
                                     Paragraph, Spacer)
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    REPORTLAB_AVAILABLE = True
except ImportError:
    REPORTLAB_AVAILABLE = False


class App20:
    """請求書作成アプリ"""

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

    def __init__(self, root):
        self.root = root
        self.root.title("請求書作成アプリ")
        self.root.geometry("900x620")
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_clients()
        self._load_items()
        self._new_invoice()

        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "reportlab が必要です (PDF出力に必要)。\n"
                "pip install reportlab でインストールしてください。\n\n"
                "CSV形式での出力は可能です。")

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS clients (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, address TEXT, email TEXT, phone TEXT
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS items (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT, unit_price REAL, unit TEXT DEFAULT '式'
            )
        """)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS invoices (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                invoice_no TEXT, client_id INTEGER, issue_date TEXT,
                due_date TEXT, notes TEXT, created_at TEXT
            )
        """)
        self.conn.commit()
        # サンプルデータ
        if not self.conn.execute("SELECT 1 FROM clients").fetchone():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('株式会社ABC','東京都千代田区1-1','abc@example.com','03-1234-5678')")
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES "
                "('山田商事','大阪府大阪市2-2','yamada@example.com','06-9876-5432')")
            self.conn.execute(
                "INSERT INTO items (name,unit_price,unit) VALUES "
                "('Webデザイン',50000,'式'),('システム開発',80000,'式'),"
                "('コンサルティング',30000,'時間'),('保守費用',20000,'月')")
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#f57f17", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="🧾 請求書作成アプリ",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#f57f17", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📄 PDF出力",
                   command=self._export_pdf).pack(side=tk.RIGHT, padx=8)
        ttk.Button(title_frame, text="📊 CSV出力",
                   command=self._export_csv).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="💾 保存",
                   command=self._save_invoice).pack(side=tk.RIGHT, padx=4)
        ttk.Button(title_frame, text="🆕 新規",
                   command=self._new_invoice).pack(side=tk.RIGHT, padx=4)

        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 請求書フォーム
        form = ttk.LabelFrame(paned, text="請求書", padding=12)
        # 請求書番号
        row_f = tk.Frame(form, bg=form.cget("background"))
        row_f.pack(fill=tk.X, pady=2)
        tk.Label(row_f, text="請求書番号:", bg=row_f.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.inv_no_var = tk.StringVar()
        ttk.Entry(row_f, textvariable=self.inv_no_var, width=18).pack(side=tk.LEFT, padx=4)
        # 発行日
        row_f2 = tk.Frame(form, bg=form.cget("background"))
        row_f2.pack(fill=tk.X, pady=2)
        tk.Label(row_f2, text="発行日:", bg=row_f2.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.issue_var = tk.StringVar(value=str(date.today()))
        ttk.Entry(row_f2, textvariable=self.issue_var, width=12).pack(side=tk.LEFT, padx=4)
        tk.Label(row_f2, text="支払期限:", bg=row_f2.cget("bg")).pack(side=tk.LEFT, padx=(12, 0))
        self.due_var = tk.StringVar()
        ttk.Entry(row_f2, textvariable=self.due_var, width=12).pack(side=tk.LEFT, padx=4)
        # 顧客
        row_f3 = tk.Frame(form, bg=form.cget("background"))
        row_f3.pack(fill=tk.X, pady=2)
        tk.Label(row_f3, text="顧客:", bg=row_f3.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT)
        self.client_var = tk.StringVar()
        self.client_cb = ttk.Combobox(row_f3, textvariable=self.client_var,
                                       state="readonly", width=24)
        self.client_cb.pack(side=tk.LEFT, padx=4)
        ttk.Button(row_f3, text="✏️ 顧客管理",
                   command=self._manage_clients).pack(side=tk.LEFT, padx=4)
        # 備考
        row_f4 = tk.Frame(form, bg=form.cget("background"))
        row_f4.pack(fill=tk.X, pady=2)
        tk.Label(row_f4, text="備考:", bg=row_f4.cget("bg"),
                 width=14, anchor="w").pack(side=tk.LEFT, anchor="n")
        self.notes_text = tk.Text(row_f4, height=2, width=30,
                                   font=("Arial", 10))
        self.notes_text.pack(side=tk.LEFT, padx=4)

        # 明細テーブル
        tk.Label(form, text="明細:", bg=form.cget("background"),
                 font=("Noto Sans JP", 11, "bold")).pack(anchor="w", pady=(8, 2))
        item_f = tk.Frame(form, bg=form.cget("background"))
        item_f.pack(fill=tk.X)
        self.item_name_var = tk.StringVar()
        self.item_cb = ttk.Combobox(item_f, textvariable=self.item_name_var,
                                     width=20)
        self.item_cb.pack(side=tk.LEFT, padx=2)
        self.item_cb.bind("<<ComboboxSelected>>", self._on_item_select)
        tk.Label(item_f, text="数量:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.qty_var = tk.IntVar(value=1)
        ttk.Spinbox(item_f, from_=1, to=9999,
                    textvariable=self.qty_var, width=5).pack(side=tk.LEFT, padx=2)
        tk.Label(item_f, text="単価:", bg=form.cget("background")).pack(side=tk.LEFT, padx=(6, 2))
        self.price_var = tk.StringVar(value="0")
        ttk.Entry(item_f, textvariable=self.price_var, width=10).pack(side=tk.LEFT, padx=2)
        ttk.Button(item_f, text="➕ 追加",
                   command=self._add_line).pack(side=tk.LEFT, padx=4)

        # 明細Treeview
        cols = ("no", "name", "qty", "unit_price", "amount")
        self.line_tree = ttk.Treeview(form, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("no", "#", 30), ("name", "品目", 200),
                         ("qty", "数量", 50), ("unit_price", "単価", 90),
                         ("amount", "金額", 90)]:
            self.line_tree.heading(c, text=h)
            self.line_tree.column(c, width=w, minwidth=30)
        line_sb = ttk.Scrollbar(form, command=self.line_tree.yview)
        self.line_tree.configure(yscrollcommand=line_sb.set)
        line_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.line_tree.pack(fill=tk.BOTH, expand=True)
        ttk.Button(form, text="🗑️ 選択行削除",
                   command=self._delete_line).pack(anchor="w", pady=2)

        # 合計
        total_f = tk.Frame(form, bg=form.cget("background"))
        total_f.pack(fill=tk.X, pady=4)
        self.subtotal_label = tk.Label(total_f, text="小計: ¥0",
                                       bg=form.cget("background"),
                                       font=("Arial", 11))
        self.subtotal_label.pack(side=tk.LEFT)
        self.tax_label = tk.Label(total_f, text="  消費税(10%): ¥0",
                                  bg=form.cget("background"),
                                  font=("Arial", 11))
        self.tax_label.pack(side=tk.LEFT)
        self.total_label = tk.Label(total_f, text="  合計: ¥0",
                                    bg=form.cget("background"),
                                    fg="#e65100",
                                    font=("Arial", 13, "bold"))
        self.total_label.pack(side=tk.LEFT)
        paned.add(form, weight=3)

        # 右: 請求書履歴
        hist_frame = ttk.LabelFrame(paned, text="請求書履歴", padding=4)
        hist_cols = ("inv_no", "client", "date", "total")
        self.hist_tree = ttk.Treeview(hist_frame, columns=hist_cols,
                                       show="headings", selectmode="browse")
        for c, h, w in [("inv_no", "番号", 80), ("client", "顧客", 100),
                         ("date", "発行日", 90), ("total", "金額", 80)]:
            self.hist_tree.heading(c, text=h)
            self.hist_tree.column(c, width=w, minwidth=30)
        hist_sb = ttk.Scrollbar(hist_frame, command=self.hist_tree.yview)
        self.hist_tree.configure(yscrollcommand=hist_sb.set)
        hist_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.hist_tree.pack(fill=tk.BOTH, expand=True)
        paned.add(hist_frame, weight=1)

        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)
        self._line_items = []

    def _load_clients(self):
        rows = self.conn.execute(
            "SELECT id, name FROM clients ORDER BY name").fetchall()
        self._clients = {name: cid for cid, name in rows}
        self.client_cb.configure(values=list(self._clients.keys()))

    def _load_items(self):
        rows = self.conn.execute(
            "SELECT name, unit_price FROM items ORDER BY name").fetchall()
        self._items = {name: price for name, price in rows}
        self.item_cb.configure(values=list(self._items.keys()))

    def _new_invoice(self):
        now = datetime.now()
        self.inv_no_var.set(f"INV-{now.strftime('%Y%m%d-%H%M')}")
        self.issue_var.set(str(date.today()))
        self.due_var.set("")
        self.notes_text.delete("1.0", tk.END)
        self.line_tree.delete(*self.line_tree.get_children())
        self._line_items = []
        self._update_totals()

    def _on_item_select(self, event):
        name = self.item_name_var.get()
        price = self._items.get(name, 0)
        self.price_var.set(str(price))

    def _add_line(self):
        name = self.item_name_var.get().strip()
        if not name:
            return
        try:
            qty = self.qty_var.get()
            price = float(self.price_var.get())
        except ValueError:
            messagebox.showerror("エラー", "数量・単価を正しく入力してください")
            return
        amount = qty * price
        no = len(self._line_items) + 1
        self._line_items.append((name, qty, price, amount))
        self.line_tree.insert("", "end",
                              values=(no, name, qty,
                                      f"¥{price:,.0f}",
                                      f"¥{amount:,.0f}"))
        self._update_totals()

    def _delete_line(self):
        sel = self.line_tree.selection()
        if sel:
            idx = self.line_tree.index(sel[0])
            self._line_items.pop(idx)
            self.line_tree.delete(sel[0])
            self._renumber()
            self._update_totals()

    def _renumber(self):
        for idx, item in enumerate(self.line_tree.get_children()):
            vals = list(self.line_tree.item(item)["values"])
            vals[0] = idx + 1
            self.line_tree.item(item, values=vals)

    def _update_totals(self):
        subtotal = sum(a for _, _, _, a in self._line_items)
        tax = subtotal * 0.1
        total = subtotal + tax
        self.subtotal_label.config(text=f"小計: ¥{subtotal:,.0f}")
        self.tax_label.config(text=f"  消費税(10%): ¥{tax:,.0f}")
        self.total_label.config(text=f"  合計: ¥{total:,.0f}")
        self._total = total

    def _save_invoice(self):
        client_name = self.client_var.get()
        client_id = self._clients.get(client_name)
        if not client_id:
            messagebox.showwarning("警告", "顧客を選択してください")
            return
        self.conn.execute(
            "INSERT OR REPLACE INTO invoices "
            "(invoice_no, client_id, issue_date, due_date, notes, created_at) "
            "VALUES (?,?,?,?,?,?)",
            (self.inv_no_var.get(), client_id,
             self.issue_var.get(), self.due_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self.status_var.set(f"保存完了: {self.inv_no_var.get()}")

    def _export_csv(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        import csv
        with open(path, "w", newline="", encoding="utf-8-sig") as f:
            w = csv.writer(f)
            w.writerow(["請求書番号", "顧客", "発行日", "支払期限"])
            w.writerow([self.inv_no_var.get(), self.client_var.get(),
                        self.issue_var.get(), self.due_var.get()])
            w.writerow([])
            w.writerow(["品目", "数量", "単価", "金額"])
            for name, qty, price, amount in self._line_items:
                w.writerow([name, qty, price, amount])
            subtotal = sum(a for _, _, _, a in self._line_items)
            w.writerow(["小計", "", "", subtotal])
            w.writerow(["消費税(10%)", "", "", subtotal * 0.1])
            w.writerow(["合計", "", "", subtotal * 1.1])
        self.status_var.set(f"CSV出力: {path}")

    def _export_pdf(self):
        if not REPORTLAB_AVAILABLE:
            messagebox.showwarning("警告",
                                   "pip install reportlab が必要です")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".pdf",
            filetypes=[("PDF", "*.pdf")])
        if not path:
            return
        try:
            doc = SimpleDocTemplate(path, pagesize=A4)
            styles = getSampleStyleSheet()
            story = []

            story.append(Paragraph(f"請求書  {self.inv_no_var.get()}",
                                   styles["Title"]))
            story.append(Spacer(1, 12))
            story.append(Paragraph(
                f"顧客: {self.client_var.get()}  "
                f"発行日: {self.issue_var.get()}  "
                f"支払期限: {self.due_var.get()}", styles["Normal"]))
            story.append(Spacer(1, 20))

            data = [["品目", "数量", "単価(円)", "金額(円)"]]
            for name, qty, price, amount in self._line_items:
                data.append([name, qty, f"{price:,.0f}", f"{amount:,.0f}"])
            subtotal = sum(a for _, _, _, a in self._line_items)
            data.append(["", "", "小計", f"{subtotal:,.0f}"])
            data.append(["", "", "消費税(10%)", f"{subtotal*0.1:,.0f}"])
            data.append(["", "", "合計", f"{subtotal*1.1:,.0f}"])

            t = Table(data, colWidths=[200, 60, 100, 100])
            t.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1565c0")),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                ("ALIGN", (1, 1), (-1, -1), "RIGHT"),
                ("GRID", (0, 0), (-1, -2), 0.5, colors.grey),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
            ]))
            story.append(t)
            if self.notes_text.get("1.0", tk.END).strip():
                story.append(Spacer(1, 20))
                story.append(Paragraph("備考: " + self.notes_text.get("1.0", tk.END).strip(),
                                       styles["Normal"]))
            doc.build(story)
            self.status_var.set(f"PDF出力: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _manage_clients(self):
        win = tk.Toplevel(self.root)
        win.title("顧客管理")
        win.geometry("500x350")
        # 顧客一覧
        cols = ("id", "name", "address", "email", "phone")
        tree = ttk.Treeview(win, columns=cols, show="headings")
        for c, h, w in [("id", "ID", 40), ("name", "名前", 120),
                         ("address", "住所", 150), ("email", "メール", 120),
                         ("phone", "電話", 100)]:
            tree.heading(c, text=h)
            tree.column(c, width=w)
        tree.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
        for row in self.conn.execute("SELECT * FROM clients").fetchall():
            tree.insert("", "end", values=row)
        # 追加フォーム
        form = tk.Frame(win)
        form.pack(fill=tk.X, padx=4)
        vars_ = {}
        for i, key in enumerate(["名前", "住所", "メール", "電話"]):
            tk.Label(form, text=f"{key}:").grid(row=i//2, column=(i%2)*2, sticky="w", pady=2)
            v = tk.StringVar()
            ttk.Entry(form, textvariable=v, width=18).grid(
                row=i//2, column=(i%2)*2+1, padx=4)
            vars_[key] = v

        def add_client():
            self.conn.execute(
                "INSERT INTO clients (name,address,email,phone) VALUES (?,?,?,?)",
                (vars_["名前"].get(), vars_["住所"].get(),
                 vars_["メール"].get(), vars_["電話"].get()))
            self.conn.commit()
            for row in self.conn.execute("SELECT * FROM clients").fetchall():
                tree.insert("", "end", values=row)
            self._load_clients()
        ttk.Button(form, text="追加", command=add_client).grid(
            row=2, column=0, columnspan=4, pady=6)


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

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

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

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

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

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

    App20クラスを定義し、__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.21に挑戦しましょう。