Tujuan Praktikum
Setelah menyelesaikan praktikum ini, mahasiswa mampu:
-
Membuat form HTML dan memproses datanya di
Flask (
request.form,POST). - Menerapkan CRUD lengkap via web untuk data produk.
-
Menggunakan
redirect,url_for, danflashuntuk navigasi dan notifikasi. - Membuat form transaksi yang menyimpan penjualan dari halaman web.
- Menyajikan dashboard berisi ringkasan angka dan grafik dari data yang ada.
Persiapan
Kebutuhan praktikum:
- 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):
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:
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:
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:
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(...)laluredirect(...)— 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:
<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>
Buat templates/produk_list.html dengan tombol aksi:
{% 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):
{% 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 '' }}". Bilapada (Edit), form terisi data lama; bilapkosong (Tambah), form kosong.name="nama_produk"pada input inilah yang nanti dibacarequest.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:
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:
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"))
Bagian penting templates/transaksi_form.html — tabel
produk dengan input jumlah:
<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>
Perhatikanname="qty_{{ p.id_produk }}". Setiap produk mendapat kolom qty dengan nama unik (qty_1,qty_2, ...). Diapp.py, kita membacanya satu per satu denganrequest.form.get(f"qty_{p.id_produk}"). Karenasimpan()sudah memakai rollback (Pertemuan 13), transaksi yang stoknya tidak cukup otomatis dibatalkan dan pengguna diberi pesan lewatflash.
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:
"""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:
python seed_data.py
Output:
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:
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:
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:
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:
{% 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:
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:
- Produk: dapat ditambah, diedit, dan dihapus lewat browser; setiap aksi memunculkan notifikasi.
- Pelanggan: CRUD berfungsi dengan pola yang sama.
- Transaksi: form memungkinkan memilih pelanggan, tanggal, dan jumlah tiap produk; setelah disimpan tampil halaman nota.
- Rollback: memesan melebihi stok memunculkan pesan "Gagal: stok tidak cukup", dan tidak ada nota tersimpan.
- Batal transaksi: stok kembali seperti semula.
-
Seeder: menjalankan
python seed_data.pymengisi data transaksi lintas tahun (2023-2025). - Dashboard: menampilkan kartu KPI beserta dashboard multi-panel (tren bulanan, proporsi kategori, heatmap tahun x bulan, produk terlaris) — mirip dashboard Pertemuan 06.
- 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:
-
Buat sendiri template
pelanggan_list.htmldanpelanggan_form.html(bila belum), meniru pola template produk. -
Tambahkan pencarian produk: sebuah form di
halaman daftar produk yang memanggil
repo.cari()(Pertemuan 12) dan menampilkan hasilnya. -
Tambahkan validasi: pada form produk, tolak
penyimpanan bila harga atau stok bernilai negatif, dan tampilkan
pesan lewat
flash. -
Tambahkan satu kartu KPI baru di dashboard:
rata-rata nilai transaksi (
total_penjualan / jml_transaksi), tangani kasus jumlah transaksi 0. -
Tambahkan satu panel grafik baru pada dashboard
(mis. jumlah transaksi per pelanggan, atau penjualan per tahun)
dengan menambah query
GROUP BYdiLaporanRepositorydan panel baru digrafik.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.