Pertemuan 15 - Flask CRUD Penjualan

Pertemuan 15 - Flask CRUD Penjualan

Melengkapi aplikasi web dengan form untuk menambah, mengubah, dan menghapus data produk, pelanggan, dan transaksi, serta menyajikan dashboard.

Tujuan Praktikum

Setelah menyelesaikan praktikum ini, mahasiswa mampu:

  1. Membuat form HTML dan memproses datanya di Flask (request.form, POST).
  2. Menerapkan CRUD lengkap via web untuk data produk.
  3. Menggunakan redirect, url_for, dan flash untuk navigasi dan notifikasi.
  4. Membuat form transaksi yang menyimpan penjualan dari halaman web.
  5. Menyajikan dashboard berisi ringkasan angka dan grafik dari data yang ada.

Persiapan

Kebutuhan praktikum:

text
- VS Code
- Project toko-elektronik dengan Flask (dari Pertemuan 14)
- Semua repository (produk, transaksi) dari pertemuan sebelumnya
- Virtual environment aktif (venv)

Aktifkan venv, lalu pastikan Matplotlib terpasang (dipakai untuk grafik dashboard; sudah dipakai sejak Pertemuan 04):

bash
venv\Scripts\activate
pip install matplotlib seaborn
pip freeze > requirements.txt
macOS / Linux: gunakan source venv/bin/activate.

Pada pertemuan ini kita menambah beberapa repository dan banyak template. Struktur akhir project:

text
toko-elektronik/
├── database/toko.db
├── models/
├── repository/
│   ├── produk_repository.py
│   ├── pelanggan_repository.py      <- baru
│   ├── transaksi_repository.py
│   └── laporan_repository.py        <- baru (dashboard)
├── templates/                       <- banyak file baru
├── grafik.py                        <- baru (grafik dashboard)
├── seed_data.py                     <- baru (pengisi data contoh)
└── app.py

Konsep Singkat

Dari "Membaca" ke "Menulis"

Pertemuan 14 hanya menampilkan data (Read). Sekarang pengguna dapat menambah, mengubah, dan menghapus data langsung dari browser — lewat form.

Peta halaman: dashboard terhubung ke modul Produk, Pelanggan, dan Transaksi; setiap modul menyediakan halaman daftar dan form pengelolaan data.

Empat Istilah Baru

Istilah Fungsi
Form method="POST" Mengirim data dari browser ke server
request.form["nama"] Membaca data yang dikirim form
redirect(url_for("...")) Mengarahkan ke halaman lain setelah aksi
flash("pesan") Menampilkan notifikasi sekali muncul

GET vs POST

Satu alamat bisa menangani dua jenis request:

Method Kapan Contoh
GET Membuka/menampilkan halaman Menampilkan form kosong
POST Mengirim data Menyimpan isi form

Karena itu, route form ditulis methods=["GET", "POST"]: GET menampilkan formnya, POST memproses isiannya.

Dashboard: Menampilkan, Bukan Menganalisis

Dashboard di sini bertugas menampilkan data yang ada — bukan menganalisisnya. Angkanya dihitung oleh database dengan SQL agregasi (SUM, COUNT, GROUP BY) dari Pertemuan 13, lalu digambar menjadi grafik dengan Matplotlib — keterampilan dari Pertemuan 04–05. Jadi kita memakai kembali dua keterampilan lama untuk keperluan baru: menyajikan ringkasan yang rapi dan menarik.

Langkah 1 - Repository Pelanggan

Sebelum membuat CRUD web, lengkapi dulu repository pelanggan. Polanya sama persis dengan ProdukRepository (Pertemuan 12). Buat repository/pelanggan_repository.py:

python
import sqlite3
from models.pelanggan import Pelanggan

DB_PATH = "database/toko.db"


class PelangganRepository:
    def _koneksi(self):
        return sqlite3.connect(DB_PATH)

    def _ke_objek(self, b):
        return Pelanggan(id_pelanggan=b[0], nama=b[1], telepon=b[2], alamat=b[3])

    def ambil_semua(self):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("SELECT id_pelanggan, nama, telepon, alamat FROM pelanggan ORDER BY id_pelanggan")
        hasil = [self._ke_objek(b) for b in c.fetchall()]; conn.close(); return hasil

    def ambil_by_id(self, id_pelanggan):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("SELECT id_pelanggan, nama, telepon, alamat FROM pelanggan WHERE id_pelanggan = ?", (id_pelanggan,))
        b = c.fetchone(); conn.close()
        return self._ke_objek(b) if b else None

    def simpan(self, p):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("INSERT INTO pelanggan (nama, telepon, alamat) VALUES (?, ?, ?)",
                  (p.nama, p.telepon, p.alamat))
        p.id_pelanggan = c.lastrowid; conn.commit(); conn.close(); return p

    def update(self, p):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("UPDATE pelanggan SET nama = ?, telepon = ?, alamat = ? WHERE id_pelanggan = ?",
                  (p.nama, p.telepon, p.alamat, p.id_pelanggan))
        conn.commit(); ok = c.rowcount > 0; conn.close(); return ok

    def hapus(self, id_pelanggan):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("DELETE FROM pelanggan WHERE id_pelanggan = ?", (id_pelanggan,))
        conn.commit(); ok = c.rowcount > 0; conn.close(); return ok

Langkah 2 - CRUD Produk via Web

Inilah pola inti yang akan diulang untuk pelanggan dan transaksi. Perbarui app.py:

python
from flask import Flask, render_template, request, redirect, url_for, flash
from models.produk import Produk
from repository.produk_repository import ProdukRepository

app = Flask(__name__)
app.secret_key = "rahasia-toko-elektronik"      # wajib untuk flash

produk_repo = ProdukRepository()


@app.route("/produk")
def produk_list():
    return render_template("produk_list.html", produk=produk_repo.ambil_semua())


@app.route("/produk/tambah", methods=["GET", "POST"])
def produk_tambah():
    if request.method == "POST":                # dikirim dari form
        p = Produk(
            request.form["nama_produk"],
            request.form["kategori"],
            int(request.form["harga"]),
            int(request.form["stok"]),
        )
        produk_repo.simpan(p)
        flash(f"Produk '{p.nama_produk}' berhasil ditambahkan.", "success")
        return redirect(url_for("produk_list"))
    return render_template("produk_form.html", judul="Tambah Produk", p=None)   # GET


@app.route("/produk/edit/<int:id_produk>", methods=["GET", "POST"])
def produk_edit(id_produk):
    p = produk_repo.ambil_by_id(id_produk)
    if p is None:
        flash("Produk tidak ditemukan.", "danger")
        return redirect(url_for("produk_list"))

    if request.method == "POST":
        p.nama_produk = request.form["nama_produk"]
        p.kategori = request.form["kategori"]
        p.harga = int(request.form["harga"])
        p.stok = int(request.form["stok"])
        produk_repo.update(p)
        flash("Produk berhasil diperbarui.", "success")
        return redirect(url_for("produk_list"))

    return render_template("produk_form.html", judul="Edit Produk", p=p)


@app.route("/produk/hapus/<int:id_produk>", methods=["POST"])
def produk_hapus(id_produk):
    produk_repo.hapus(id_produk)
    flash("Produk berhasil dihapus.", "success")
    return redirect(url_for("produk_list"))

Perhatikan pola berulang yang sangat penting:

  • Tambah & Edit memakai satu template form yang sama (produk_form.html). Bedanya: Edit mengisi form dengan data lama.
  • Setelah menyimpan, selalu flash(...) lalu redirect(...)jangan menampilkan halaman langsung. Pola "Post lalu Redirect" ini mencegah data terkirim dua kali saat halaman di-refresh.
  • Hapus hanya menerima POST (bukan GET), agar tidak terhapus tak sengaja lewat tautan biasa.

Langkah 3 - Template Form Produk

Perbarui templates/layout.html agar navbar memuat semua menu dan menampilkan notifikasi flash:

html
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
    <div class="container">
        <a class="navbar-brand" href="{{ url_for('dashboard') }}">Toko Elektronik</a>
        <div class="navbar-nav">
            <a class="nav-link" href="{{ url_for('dashboard') }}">Dashboard</a>
            <a class="nav-link" href="{{ url_for('produk_list') }}">Produk</a>
            <a class="nav-link" href="{{ url_for('pelanggan_list') }}">Pelanggan</a>
            <a class="nav-link" href="{{ url_for('transaksi_list') }}">Transaksi</a>
        </div>
    </div>
</nav>
<div class="container my-4">
    {% with messages = get_flashed_messages(with_categories=true) %}
        {% for kategori, pesan in messages %}
            <div class="alert alert-{{ kategori }}">{{ pesan }}</div>
        {% endfor %}
    {% endwith %}
    {% block content %}{% endblock %}
</div>
Daftar produk dengan tombol aksi

Buat templates/produk_list.html dengan tombol aksi:

html
{% extends "layout.html" %}
{% block title %}Produk{% endblock %}
{% block content %}
<div class="d-flex justify-content-between mb-3">
    <h1>Daftar Produk</h1>
    <a href="{{ url_for('produk_tambah') }}" class="btn btn-primary">+ Tambah Produk</a>
</div>
<table class="table table-striped bg-white shadow-sm">
    <thead class="table-dark"><tr>
        <th>ID</th><th>Nama</th><th>Kategori</th><th class="text-end">Harga</th>
        <th class="text-center">Stok</th><th class="text-center">Aksi</th>
    </tr></thead>
    <tbody>
    {% for p in produk %}
        <tr>
            <td>{{ p.id_produk }}</td><td>{{ p.nama_produk }}</td><td>{{ p.kategori }}</td>
            <td class="text-end">Rp {{ "{:,}".format(p.harga) }}</td>
            <td class="text-center">
                {% if p.stok < 10 %}<span class="badge bg-danger">{{ p.stok }}</span>
                {% else %}<span class="badge bg-success">{{ p.stok }}</span>{% endif %}
            </td>
            <td class="text-center">
                <a href="{{ url_for('produk_edit', id_produk=p.id_produk) }}" class="btn btn-sm btn-warning">Edit</a>
                <form method="POST" action="{{ url_for('produk_hapus', id_produk=p.id_produk) }}"
                      class="d-inline" onsubmit="return confirm('Hapus produk ini?')">
                    <button class="btn btn-sm btn-danger">Hapus</button>
                </form>
            </td>
        </tr>
    {% endfor %}
    </tbody>
</table>
{% endblock %}
Form produk yang sama dipakai untuk tambah dan edit; nilai lama diisikan otomatis saat mode edit.

Buat templates/produk_form.html (dipakai untuk Tambah dan Edit):

html
{% extends "layout.html" %}
{% block title %}{{ judul }}{% endblock %}
{% block content %}
<h1 class="mb-4">{{ judul }}</h1>
<form method="POST" class="card card-body shadow-sm" style="max-width:500px">
    <div class="mb-3">
        <label class="form-label">Nama Produk</label>
        <input name="nama_produk" class="form-control" required value="{{ p.nama_produk if p else '' }}">
    </div>
    <div class="mb-3">
        <label class="form-label">Kategori</label>
        <input name="kategori" class="form-control" required value="{{ p.kategori if p else '' }}">
    </div>
    <div class="mb-3">
        <label class="form-label">Harga</label>
        <input name="harga" type="number" min="0" class="form-control" required value="{{ p.harga if p else '' }}">
    </div>
    <div class="mb-3">
        <label class="form-label">Stok</label>
        <input name="stok" type="number" min="0" class="form-control" required value="{{ p.stok if p else '' }}">
    </div>
    <div>
        <button class="btn btn-primary">Simpan</button>
        <a href="{{ url_for('produk_list') }}" class="btn btn-secondary">Batal</a>
    </div>
</form>
{% endblock %}
Trik satu form untuk dua tugas: value="{{ p.nama_produk if p else '' }}". Bila p ada (Edit), form terisi data lama; bila p kosong (Tambah), form kosong. name="nama_produk" pada input inilah yang nanti dibaca request.form["nama_produk"].

Langkah 4 - CRUD Pelanggan (Ikuti Pola yang Sama)

CRUD pelanggan mengikuti pola yang persis sama dengan produk. Tambahkan route ke app.py:

python
from models.pelanggan import Pelanggan
from repository.pelanggan_repository import PelangganRepository

pelanggan_repo = PelangganRepository()


@app.route("/pelanggan")
def pelanggan_list():
    return render_template("pelanggan_list.html", pelanggan=pelanggan_repo.ambil_semua())


@app.route("/pelanggan/tambah", methods=["GET", "POST"])
def pelanggan_tambah():
    if request.method == "POST":
        p = Pelanggan(request.form["nama"], request.form["telepon"], request.form["alamat"])
        pelanggan_repo.simpan(p)
        flash(f"Pelanggan '{p.nama}' berhasil ditambahkan.", "success")
        return redirect(url_for("pelanggan_list"))
    return render_template("pelanggan_form.html", judul="Tambah Pelanggan", p=None)


@app.route("/pelanggan/edit/<int:id_pelanggan>", methods=["GET", "POST"])
def pelanggan_edit(id_pelanggan):
    p = pelanggan_repo.ambil_by_id(id_pelanggan)
    if p is None:
        flash("Pelanggan tidak ditemukan.", "danger")
        return redirect(url_for("pelanggan_list"))
    if request.method == "POST":
        p.nama = request.form["nama"]
        p.telepon = request.form["telepon"]
        p.alamat = request.form["alamat"]
        pelanggan_repo.update(p)
        flash("Pelanggan berhasil diperbarui.", "success")
        return redirect(url_for("pelanggan_list"))
    return render_template("pelanggan_form.html", judul="Edit Pelanggan", p=p)


@app.route("/pelanggan/hapus/<int:id_pelanggan>", methods=["POST"])
def pelanggan_hapus(id_pelanggan):
    pelanggan_repo.hapus(id_pelanggan)
    flash("Pelanggan berhasil dihapus.", "success")
    return redirect(url_for("pelanggan_list"))
Halaman pelanggan mengikuti pola daftar, tambah, edit, dan hapus yang sama dengan halaman produk.

Template pelanggan_list.html dan pelanggan_form.html dibuat dengan meniru versi produk, cukup ganti kolomnya menjadi nama, telepon, dan alamat.

Tugas latihan mandiri: buat sendiri kedua template pelanggan tersebut dengan meniru template produk. Ini melatih pemahaman pola, bukan sekadar menyalin.

Langkah 5 - Transaksi via Web

Form transaksi sedikit berbeda: satu form memuat daftar produk dengan kolom jumlah untuk masing-masing. Tambahkan route:

python
from repository.transaksi_repository import TransaksiRepository

trx_repo = TransaksiRepository()


@app.route("/transaksi")
def transaksi_list():
    return render_template("transaksi_list.html", transaksi=trx_repo.ambil_semua())


@app.route("/transaksi/baru", methods=["GET", "POST"])
def transaksi_baru():
    if request.method == "POST":
        id_pelanggan = int(request.form["id_pelanggan"])
        tanggal = request.form["tanggal"]

        # kumpulkan produk yang qty-nya diisi > 0
        keranjang = []
        for p in produk_repo.ambil_semua():
            qty = request.form.get(f"qty_{p.id_produk}", "0")
            if qty and int(qty) > 0:
                keranjang.append((p.id_produk, int(qty)))

        if not keranjang:
            flash("Pilih minimal satu produk.", "warning")
            return redirect(url_for("transaksi_baru"))

        try:
            id_nota = trx_repo.simpan(tanggal, id_pelanggan, keranjang)
            flash(f"Transaksi #{id_nota} berhasil disimpan.", "success")
            return redirect(url_for("transaksi_detail", id_penjualan=id_nota))
        except ValueError as e:
            flash(f"Gagal: {e}", "danger")           # rollback -> pesan jelas
            return redirect(url_for("transaksi_baru"))

    return render_template("transaksi_form.html",
                           produk=produk_repo.ambil_semua(),
                           pelanggan=pelanggan_repo.ambil_semua())


@app.route("/transaksi/<int:id_penjualan>")
def transaksi_detail(id_penjualan):
    nota, items = trx_repo.ambil_detail(id_penjualan)
    if nota is None:
        flash("Nota tidak ditemukan.", "danger")
        return redirect(url_for("transaksi_list"))
    return render_template("transaksi_detail.html", nota=nota, items=items)


@app.route("/transaksi/hapus/<int:id_penjualan>", methods=["POST"])
def transaksi_hapus(id_penjualan):
    trx_repo.hapus(id_penjualan)
    flash("Transaksi dibatalkan, stok dikembalikan.", "success")
    return redirect(url_for("transaksi_list"))
Form transaksi baru

Bagian penting templates/transaksi_form.html — tabel produk dengan input jumlah:

html
<form method="POST" class="card card-body shadow-sm">
    <div class="row mb-3">
        <div class="col-md-6">
            <label class="form-label">Pelanggan</label>
            <select name="id_pelanggan" class="form-select" required>
                {% for p in pelanggan %}
                    <option value="{{ p.id_pelanggan }}">{{ p.nama }}</option>
                {% endfor %}
            </select>
        </div>
        <div class="col-md-6">
            <label class="form-label">Tanggal</label>
            <input name="tanggal" type="date" class="form-control" required>
        </div>
    </div>
    <table class="table">
        <thead><tr><th>Produk</th><th>Harga</th><th>Stok</th><th>Qty</th></tr></thead>
        <tbody>
        {% for p in produk %}
            <tr>
                <td>{{ p.nama_produk }}</td>
                <td>Rp {{ "{:,}".format(p.harga) }}</td>
                <td>{{ p.stok }}</td>
                <td><input name="qty_{{ p.id_produk }}" type="number" min="0" max="{{ p.stok }}"
                           value="0" class="form-control form-control-sm"></td>
            </tr>
        {% endfor %}
        </tbody>
    </table>
    <button class="btn btn-primary">Simpan Transaksi</button>
</form>
Perhatikan name="qty_{{ p.id_produk }}". Setiap produk mendapat kolom qty dengan nama unik (qty_1, qty_2, ...). Di app.py, kita membacanya satu per satu dengan request.form.get(f"qty_{p.id_produk}"). Karena simpan() sudah memakai rollback (Pertemuan 13), transaksi yang stoknya tidak cukup otomatis dibatalkan dan pengguna diberi pesan lewat flash.

Template transaksi_list.html dan transaksi_detail.html menampilkan riwayat nota dan isinya, meniru bentuk struk pada Pertemuan 13. Tombol Batal pada daftar memanggil transaksi_hapus (yang mengembalikan stok).

Riwayat transaksi menampilkan daftar nota, sedangkan halaman detail menampilkan produk, jumlah, subtotal, dan total setiap nota.

Langkah 6 - Menyiapkan Data untuk Dashboard

Dashboard yang kaya membutuhkan data yang cukup. Karena aplikasi ini baru diisi sedikit transaksi lewat form, dashboard akan terlihat sepi. Untuk itu, kita sediakan skrip pengisi data contoh yang membuat transaksi lintas tahun (2023–2025) sekaligus.

Buat file seed_data.py di folder utama project:

python
"""Mengisi data transaksi contoh (lintas tahun) agar dashboard tampil penuh."""
import sqlite3
import random

random.seed(42)
conn = sqlite3.connect("database/toko.db")
c = conn.cursor()

# kosongkan transaksi lama, dan besarkan stok agar cukup
c.execute("DELETE FROM detail_penjualan")
c.execute("DELETE FROM penjualan")
c.execute("UPDATE produk SET stok = 99999")
conn.commit()

produk = c.execute("SELECT id_produk, harga FROM produk").fetchall()
pelanggan = [r[0] for r in c.execute("SELECT id_pelanggan FROM pelanggan").fetchall()]

base = {2023: 6, 2024: 9, 2025: 13}                 # transaksi/bulan, tumbuh tiap tahun
musiman = {11: 1.6, 12: 1.9, 6: 1.3, 7: 1.3}        # ramai akhir & tengah tahun

jumlah = 0
for tahun in (2023, 2024, 2025):
    for bulan in range(1, 13):
        n = int(base[tahun] * musiman.get(bulan, 1.0) * random.uniform(0.8, 1.2))
        for _ in range(n):
            tanggal = f"{tahun}-{bulan:02d}-{random.randint(1, 28):02d}"
            id_pelanggan = random.choice(pelanggan)
            c.execute("INSERT INTO penjualan (tanggal, id_pelanggan, total) VALUES (?, ?, 0)",
                      (tanggal, id_pelanggan))
            id_jual = c.lastrowid
            total = 0
            for id_produk, harga in random.sample(produk, random.randint(1, 3)):
                qty = random.randint(1, 5)
                subtotal = harga * qty
                total += subtotal
                c.execute("INSERT INTO detail_penjualan (id_penjualan, id_produk, qty, subtotal) "
                          "VALUES (?, ?, ?, ?)", (id_jual, id_produk, qty, subtotal))
            c.execute("UPDATE penjualan SET total = ? WHERE id_penjualan = ?", (total, id_jual))
            jumlah += 1

conn.commit()
print(f"{jumlah} transaksi contoh berhasil dibuat.")
conn.close()

Jalankan sekali untuk mengisi data:

bash
python seed_data.py

Output:

text
375 transaksi contoh berhasil dibuat.
Catatan: skrip ini hanya untuk mengisi data demo agar dashboard menarik. Di aplikasi nyata, data terisi dari transaksi yang diinput lewat form. Skrip ini mengosongkan transaksi lama, jadi jalankan hanya saat ingin mereset data contoh.

Langkah 7 - Data Dashboard (SQL Agregasi)

Semua angka dashboard dihitung oleh database dengan SQL agregasi (Pertemuan 13) — bukan dianalisis. Buat repository/laporan_repository.py:

python
import sqlite3

DB_PATH = "database/toko.db"


class LaporanRepository:
    def _koneksi(self):
        return sqlite3.connect(DB_PATH)

    def ringkasan(self):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("SELECT COUNT(*), COALESCE(SUM(total), 0) FROM penjualan")
        jml_transaksi, total_penjualan = c.fetchone()
        c.execute("SELECT COUNT(*) FROM produk")
        jml_produk = c.fetchone()[0]
        c.execute("SELECT COUNT(*) FROM pelanggan")
        jml_pelanggan = c.fetchone()[0]
        conn.close()
        return {"jml_transaksi": jml_transaksi, "total_penjualan": total_penjualan,
                "jml_produk": jml_produk, "jml_pelanggan": jml_pelanggan}

    def penjualan_per_bulan(self):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("""
            SELECT strftime('%Y-%m', tanggal) AS bulan, SUM(total)
            FROM penjualan GROUP BY bulan ORDER BY bulan
        """)
        hasil = c.fetchall(); conn.close(); return hasil

    def produk_terlaris(self, batas=5):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("""
            SELECT pr.nama_produk, SUM(d.qty) AS qty
            FROM detail_penjualan d JOIN produk pr ON d.id_produk = pr.id_produk
            GROUP BY pr.nama_produk ORDER BY qty DESC LIMIT ?
        """, (batas,))
        hasil = c.fetchall(); conn.close(); return hasil

    def penjualan_per_kategori(self):
        conn = self._koneksi(); c = conn.cursor()
        c.execute("""
            SELECT pr.kategori, SUM(d.subtotal)
            FROM detail_penjualan d JOIN produk pr ON d.id_produk = pr.id_produk
            GROUP BY pr.kategori ORDER BY 2 DESC
        """)
        hasil = c.fetchall(); conn.close(); return hasil

    def penjualan_tahun_bulan(self):
        """Data untuk heatmap: (tahun, bulan, total)."""
        conn = self._koneksi(); c = conn.cursor()
        c.execute("""
            SELECT CAST(strftime('%Y', tanggal) AS INTEGER),
                   CAST(strftime('%m', tanggal) AS INTEGER),
                   SUM(total)
            FROM penjualan GROUP BY 1, 2
        """)
        hasil = c.fetchall(); conn.close(); return hasil

Langkah 8 - Dashboard Kaya dengan Matplotlib

Sekarang kita buat dashboard multi-panel seperti pada Pertemuan 06: tren bulanan, proporsi kategori, heatmap tahun × bulan, dan produk terlaris — semua dalam satu gambar. Ini memakai subplot_mosaic (Pertemuan 06) dan Seaborn (Pertemuan 05).

Buat grafik.py:

python
import matplotlib
matplotlib.use("Agg")                 # WAJIB: mode tanpa layar, sebelum import pyplot
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import io
import base64


def _ke_base64(fig):
    """Mengubah gambar Matplotlib menjadi teks base64 agar bisa ditempel di HTML."""
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=90, bbox_inches="tight")
    plt.close(fig)
    return base64.b64encode(buf.getvalue()).decode("utf-8")


def dashboard_lengkap(per_bulan, terlaris, kategori, tahun_bulan):
    sns.set_theme(style="whitegrid")
    fig, axd = plt.subplot_mosaic(
        [["tren", "tren", "kategori"],
         ["heat", "heat", "terlaris"]],
        figsize=(15, 8),
    )
    BLU, GRN, ORG = "#0d6efd", "#198754", "#fd7e14"

    # 1. tren penjualan bulanan
    bulan = [d[0] for d in per_bulan]
    total = [d[1] / 1_000_000 for d in per_bulan]
    axd["tren"].plot(bulan, total, marker="o", color=BLU, lw=2)
    axd["tren"].set_title("Tren Penjualan per Bulan", fontweight="bold")
    axd["tren"].set_ylabel("Juta Rp")
    axd["tren"].tick_params(axis="x", rotation=45)

    # 2. proporsi kategori (donut)
    axd["kategori"].pie([k[1] for k in kategori], labels=[k[0] for k in kategori],
                        autopct="%1.0f%%", colors=[BLU, ORG], startangle=90,
                        wedgeprops=dict(width=0.45))
    axd["kategori"].set_title("Proporsi Kategori", fontweight="bold")

    # 3. heatmap tahun x bulan
    tahun_unik = sorted(set(r[0] for r in tahun_bulan))
    grid = np.zeros((len(tahun_unik), 12))
    for tahun, bln, tot in tahun_bulan:
        grid[tahun_unik.index(tahun)][bln - 1] = tot / 1_000_000
    sns.heatmap(grid, annot=True, fmt=".0f", cmap="Blues",
                xticklabels=range(1, 13), yticklabels=tahun_unik,
                ax=axd["heat"], cbar_kws={"label": "Juta Rp"})
    axd["heat"].set_title("Heatmap Penjualan: Tahun x Bulan (Juta Rp)", fontweight="bold")
    axd["heat"].set_xlabel("Bulan"); axd["heat"].set_ylabel("Tahun")

    # 4. produk terlaris
    nama = [d[0] for d in terlaris][::-1]
    qty = [d[1] for d in terlaris][::-1]
    axd["terlaris"].barh(nama, qty, color=GRN)
    axd["terlaris"].set_title("Produk Terlaris", fontweight="bold")
    axd["terlaris"].set_xlabel("Qty")

    plt.tight_layout()
    return _ke_base64(fig)

Route dashboard di app.py:

python
from repository.laporan_repository import LaporanRepository
import grafik

laporan_repo = LaporanRepository()


@app.route("/")
def dashboard():
    ringkasan = laporan_repo.ringkasan()
    terlaris = laporan_repo.produk_terlaris()
    per_bulan = laporan_repo.penjualan_per_bulan()
    kategori = laporan_repo.penjualan_per_kategori()
    tahun_bulan = laporan_repo.penjualan_tahun_bulan()
    chart = grafik.dashboard_lengkap(per_bulan, terlaris, kategori, tahun_bulan)
    return render_template("dashboard.html", r=ringkasan, chart=chart)

templates/dashboard.html — kartu KPI di atas, lalu satu gambar dashboard lengkap:

html
{% extends "layout.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h1 class="mb-4">Dashboard Penjualan</h1>

<div class="row g-3 mb-4">
    <div class="col-md-3"><div class="card text-bg-primary"><div class="card-body">
        <h6>Total Penjualan</h6><p class="fs-4 mb-0">Rp {{ "{:,}".format(r.total_penjualan) }}</p>
    </div></div></div>
    <div class="col-md-3"><div class="card text-bg-success"><div class="card-body">
        <h6>Transaksi</h6><p class="fs-4 mb-0">{{ r.jml_transaksi }}</p>
    </div></div></div>
    <div class="col-md-3"><div class="card text-bg-warning"><div class="card-body">
        <h6>Produk</h6><p class="fs-4 mb-0">{{ r.jml_produk }}</p>
    </div></div></div>
    <div class="col-md-3"><div class="card text-bg-info"><div class="card-body">
        <h6>Pelanggan</h6><p class="fs-4 mb-0">{{ r.jml_pelanggan }}</p>
    </div></div></div>
</div>

<div class="card shadow-sm"><div class="card-body">
    <img src="data:image/png;base64,{{ chart }}" class="img-fluid w-100" alt="Grafik dashboard">
</div></div>
{% endblock %}

Setelah menjalankan seed_data.py, buka dashboard di http://127.0.0.1:5000:

Halaman dashboard
Perhatikan: dashboard ini menggabungkan hampir seluruh keterampilan visualisasi dari paruh pertama — subplot_mosaic dan heatmap (Pertemuan 06), Seaborn (Pertemuan 05), bar/pie/line (Pertemuan 04) — kini tampil di aplikasi web nyata. Semua angkanya dihitung oleh SQL, dan Matplotlib hanya menggambarkannya. Tidak ada analisis; murni menyajikan.

Referensi Fungsi Penting

Flask Form dan Navigasi

Perintah Fungsi
methods=["GET", "POST"] Route menerima dua jenis request
request.method == "POST" Memeriksa apakah data form dikirim
request.form["nama"] Membaca isi form (wajib ada)
request.form.get("nama", "0") Membaca form (dengan nilai bawaan)
redirect(url_for("nama_fungsi")) Pindah ke halaman lain
url_for("fungsi", id=5) Membuat URL dari nama fungsi
flash("pesan", "success") Notifikasi sekali muncul
app.secret_key = "..." Wajib agar flash berfungsi

Pola CRUD Web (berlaku untuk semua entitas)

Aksi Method Yang dilakukan
List GET ambil_semua() → tabel
Tambah GET tampilkan form kosong
Tambah POST baca form → simpan() → redirect
Edit GET tampilkan form berisi data lama
Edit POST baca form → update() → redirect
Hapus POST hapus() → redirect

SQL Dashboard

SQL Fungsi
COUNT(*) Menghitung jumlah baris
SUM(total) Menjumlahkan nilai
COALESCE(SUM(x), 0) Ganti kosong dengan 0
GROUP BY Mengelompokkan
strftime('%Y-%m', tanggal) Mengambil tahun-bulan

Grafik Matplotlib/Seaborn di Web

Perintah Fungsi
matplotlib.use("Agg") Mode tanpa layar (wajib di server), sebelum import pyplot
plt.subplot_mosaic([...]) Menyusun banyak panel dalam satu gambar (Pertemuan 06)
sns.heatmap(grid, annot=True) Peta warna tahun x bulan (Pertemuan 05-06)
fig.savefig(buf, format="png") Menyimpan grafik ke memori, bukan file
base64.b64encode(...) Mengubah gambar menjadi teks
<img src="data:image/png;base64,..."> Menampilkan gambar base64 di HTML

Pengujian

Pastikan hasil praktikum sesuai berikut:

  1. Produk: dapat ditambah, diedit, dan dihapus lewat browser; setiap aksi memunculkan notifikasi.
  2. Pelanggan: CRUD berfungsi dengan pola yang sama.
  3. Transaksi: form memungkinkan memilih pelanggan, tanggal, dan jumlah tiap produk; setelah disimpan tampil halaman nota.
  4. Rollback: memesan melebihi stok memunculkan pesan "Gagal: stok tidak cukup", dan tidak ada nota tersimpan.
  5. Batal transaksi: stok kembali seperti semula.
  6. Seeder: menjalankan python seed_data.py mengisi data transaksi lintas tahun (2023-2025).
  7. Dashboard: menampilkan kartu KPI beserta dashboard multi-panel (tren bulanan, proporsi kategori, heatmap tahun x bulan, produk terlaris) — mirip dashboard Pertemuan 06.
  8. Refresh halaman setelah menyimpan tidak menyimpan data dua kali (berkat pola redirect).

Troubleshooting

Masalah Penyebab Solusi
RuntimeError: ... secret key flash dipakai tanpa secret key Tambahkan app.secret_key = "..."
400 Bad Request: KeyError 'nama' Nama input form tidak cocok Samakan name="..." dengan request.form["..."]
BuildError: url_for Nama fungsi route salah Cocokkan nama fungsi dengan yang dipakai di url_for
Data tersimpan dua kali saat refresh Menampilkan halaman langsung, bukan redirect Selalu redirect(url_for(...)) setelah POST
Tombol hapus terhapus lewat klik biasa Hapus memakai GET Gunakan <form method="POST"> untuk hapus
Transaksi tersimpan padahal stok kurang Tidak menangkap error Bungkus simpan() dengan try ... except ValueError
Dashboard error saat data kosong SUM menghasilkan None Gunakan COALESCE(SUM(x), 0)
Grafik tidak muncul / error di server Lupa matplotlib.use("Agg") Tulis sebelum import matplotlib.pyplot
RuntimeError: main thread is not in main loop Backend Matplotlib salah Pastikan memakai backend Agg

Tugas Praktikum

Kembangkan aplikasi:

  1. Buat sendiri template pelanggan_list.html dan pelanggan_form.html (bila belum), meniru pola template produk.
  2. Tambahkan pencarian produk: sebuah form di halaman daftar produk yang memanggil repo.cari() (Pertemuan 12) dan menampilkan hasilnya.
  3. Tambahkan validasi: pada form produk, tolak penyimpanan bila harga atau stok bernilai negatif, dan tampilkan pesan lewat flash.
  4. Tambahkan satu kartu KPI baru di dashboard: rata-rata nilai transaksi (total_penjualan / jml_transaksi), tangani kasus jumlah transaksi 0.
  5. Tambahkan satu panel grafik baru pada dashboard (mis. jumlah transaksi per pelanggan, atau penjualan per tahun) dengan menambah query GROUP BY di LaporanRepository dan panel baru di grafik.py.

Kumpulkan seluruh file .py dan .html beserta tangkapan layar tiap halaman (format PDF).

Kesimpulan

Pada pertemuan ini aplikasi penjualan menjadi lengkap dan interaktif di web. Mahasiswa membuat form untuk menambah, mengubah, dan menghapus data (request.form, POST), menerapkan pola "Post lalu Redirect" dengan redirect, url_for, dan flash, serta membangun form transaksi yang tetap dilindungi rollback dari Pertemuan 13. Sebuah dashboard multi-panel menyajikan ringkasan angka (dari SQL agregasi) beserta grafik yang digambar dengan Matplotlib dan Seaborn — memakai kembali hampir seluruh keterampilan visualisasi paruh pertama (termasuk subplot_mosaic dan heatmap dari Pertemuan 06) untuk menampilkan, bukan menganalisis. Sebuah skrip seeder disediakan agar dashboard langsung tampil penuh dengan data lintas tahun. Yang perlu diingat, seluruh kemampuan ini berdiri di atas repository yang dibangun jauh sebelumnya tanpa perubahan berarti. Pada pertemuan terakhir, seluruh keterampilan ini dirangkum dalam UAS berupa aplikasi mini sistem penjualan yang utuh.