2025-12-15 09:25:17 +07:00

5610 lines
211 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""akun default buat login :
- admin / admin123 (role admin)
- kasir / kasir123 (role kasir)
- waiter / waiter123 (role waiter)
- user / user123 (role pembeli)
- owner / owner123 (role pemilik)
"""
import os
import csv
import tkinter as tk
from tkinter import ttk, messagebox, filedialog, simpledialog
from PIL import Image, ImageTk
# ========================================
# UI STYLING - TOTORO THEME COLORS + FONTS
# ========================================
COLORS = {
'bg_main': '#F5F5DC', # Beige/Krem lembut (background utama)
'bg_secondary': '#FFFFFF', # Putih untuk cards
'primary': '#8FBFA8', # Hijau Totoro (medium)
'primary_dark': '#6B9080', # Hijau tua
'accent': '#A8D5BA', # Hijau pastel lembut
'brown': '#8B7355', # Coklat kayu
'text_dark': '#3D3D3D', # Text gelap
'text_light': '#666666', # Text abu-abu
'success': '#6B9080', # Hijau untuk success
'warning': '#D4A574', # Coklat muda untuk warning
'danger': '#C9797A', # Merah lembut untuk danger
'border': '#D4C5B9', # Border coklat muda
'shadow': '#D0D0D0', # Shadow abu-abu lembut
}
# Font configuration untuk tema Totoro
FONTS = {
'header': ('Comic Sans MS', 16, 'bold'),
'subheader': ('Segoe UI', 12, 'bold'),
'body': ('Segoe UI', 10),
'body_bold': ('Segoe UI', 10, 'bold'),
'small': ('Segoe UI', 9),
'tiny': ('Segoe UI', 8),
'button': ('Segoe UI', 11, 'bold'),
'mono': ('Consolas', 9),
}
USERS_CSV = "users.csv"
MENU_CSV = "menu.csv"
PROMO_CSV = "promo.csv"
TRANSAKSI_CSV = "transaksi.csv"
DETAIL_TRANSAKSI_CSV = "detail_transaksi.csv"
FAVORITE_CSV = "favorite.csv"
MEJA_CSV = "meja.csv"
PEMBAYARAN_CSV = "pembayaran.csv"
IMG_PREVIEW_SIZE = (120, 80)
def create_placeholder_image():
"""Buat placeholder jika gambar belum ada"""
if not os.path.exists('img'):
os.makedirs('img')
if not os.path.exists('img/totoro_sitting.png'):
try:
from PIL import Image, ImageDraw
# Buat gambar placeholder 300x300
img = Image.new('RGB', (300, 300), color=COLORS['accent'])
draw = ImageDraw.Draw(img)
# Tulis text
draw.text((50, 140), "TOTORO\nPLACEHOLDER", fill=COLORS['brown'])
img.save('img/totoro_sitting.png')
except:
pass
# Panggil saat init
create_placeholder_image()
def ensure_file(path, fieldnames):
if not os.path.exists(path):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
def read_all(path):
if not os.path.exists(path):
return []
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
return list(reader)
def write_all(path, fieldnames, rows):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def next_int_id(rows, id_field="id"):
max_id = 0
for r in rows:
try:
v = int(r.get(id_field, 0) or 0)
if v > max_id:
max_id = v
except:
continue
return str(max_id + 1)
def init_db_csv():
ensure_file(USERS_CSV, ["id", "username", "password", "role"])
ensure_file(MENU_CSV, ["id", "nama", "kategori", "harga", "stok", "foto", "tersedia", "item_discount_pct"])
ensure_file(PROMO_CSV, ["code", "type", "value", "min_total"])
ensure_file(TRANSAKSI_CSV, ["id", "user_id", "nomor_meja", "total", "status", "promo_code", "subtotal", "item_discount", "promo_discount", "tanggal"])
ensure_file(DETAIL_TRANSAKSI_CSV, ["id", "transaksi_id", "menu_id", "qty", "harga_satuan", "subtotal_item"])
ensure_file(FAVORITE_CSV, ["user_id", "menu_id", "order_count", "last_ordered"])
ensure_file(MEJA_CSV, ["nomor_meja", "status", "transaksi_id"])
ensure_file(PEMBAYARAN_CSV, ["id", "transaksi_id", "metode_pembayaran", "jumlah_bayar", "status_pembayaran", "tanggal_bayar", "struk"])
seed_defaults()
# buat masukin data/sample ke database csv
def seed_defaults():
users = read_all(USERS_CSV)
if not users:
defaults = [
('admin','admin123','admin'),
('kasir','kasir123','kasir'),
('waiter','waiter123','waiter'),
('user','user123','pembeli'),
('owner','owner123','pemilik'),
]
rows = []
for i,(u,p,r) in enumerate(defaults, start=1):
rows.append({"id": str(i), "username": u, "password": p, "role": r})
write_all(USERS_CSV, ["id","username","password","role"], rows)
menu_rows = read_all(MENU_CSV)
if not menu_rows:
sample = [
('Americano','Minuman',20000,10,None,1,0),
('Latte','Minuman',25000,5,None,1,10),
('Banana Cake','Dessert',30000,2,None,1,0),
('Nasi Goreng','Makanan',35000,0,None,0,0),
]
rows = []
for i,(name,kategori,harga,stok,foto,tersedia,disc) in enumerate(sample, start=1):
rows.append({
"id": str(i),
"nama": name,
"kategori": kategori,
"harga": str(harga),
"stok": str(stok),
"foto": foto or "",
"tersedia": str(tersedia),
"item_discount_pct": str(disc)
})
write_all(MENU_CSV, ["id","nama","kategori","harga","stok","foto","tersedia","item_discount_pct"], rows)
promo_rows = read_all(PROMO_CSV)
if not promo_rows:
promos = [
('OPENING','percent',10,0),
('ANNIVERSARY','fixed',5000,20000),
]
rows = []
for code,ptype,val,min_total in promos:
rows.append({
"code": code,
"type": ptype,
"value": str(val),
"min_total": str(min_total)
})
write_all(PROMO_CSV, ["code","type","value","min_total"], rows)
# Seed meja (10 meja default)
meja_rows = read_all(MEJA_CSV)
if not meja_rows:
rows = []
for i in range(1, 11): # Meja 1-10
rows.append({
"nomor_meja": str(i),
"status": "kosong",
"transaksi_id": ""
})
write_all(MEJA_CSV, ["nomor_meja", "status", "transaksi_id"], rows)
def authenticate(username, password):
rows = read_all(USERS_CSV)
for r in rows:
if r.get("username") == username and r.get("password") == password:
return {'id': int(r.get("id")), 'username': r.get("username"), 'role': r.get("role")}
return None
# Wilayah dikuasai Menu
def menu_add(nama, kategori, harga, stok, foto, item_discount_pct=0):
rows = read_all(MENU_CSV)
new_id = next_int_id(rows, "id")
tersedia = "1" if int(stok) > 0 else "0"
rows.append({
"id": new_id,
"nama": nama,
"kategori": kategori,
"harga": str(float(harga)),
"stok": str(int(stok)),
"foto": foto or "",
"tersedia": tersedia,
"item_discount_pct": str(float(item_discount_pct))
})
write_all(MENU_CSV, ["id","nama","kategori","harga","stok","foto","tersedia","item_discount_pct"], rows)
def menu_update(menu_id, nama, kategori, harga, stok, foto, item_discount_pct=0):
rows = read_all(MENU_CSV)
found = False
for r in rows:
if r.get("id") == str(menu_id):
r["nama"] = nama
r["kategori"] = kategori
r["harga"] = str(float(harga))
r["stok"] = str(int(stok))
r["foto"] = foto or ""
r["tersedia"] = "1" if int(stok) > 0 else "0"
r["item_discount_pct"] = str(float(item_discount_pct))
found = True
break
if found:
write_all(MENU_CSV, ["id","nama","kategori","harga","stok","foto","tersedia","item_discount_pct"], rows)
else:
raise ValueError("Menu id tidak ditemukan")
def menu_delete(menu_id):
rows = read_all(MENU_CSV)
newrows = [r for r in rows if r.get("id") != str(menu_id)]
write_all(MENU_CSV, ["id","nama","kategori","harga","stok","foto","tersedia","item_discount_pct"], newrows)
def menu_list(kategori=None, available_only=False, search_text=None):
rows = read_all(MENU_CSV)
out = []
for r in rows:
if kategori and r.get("kategori") != kategori:
continue
if available_only and r.get("tersedia") != "1":
continue
if search_text:
s = search_text.lower()
if s not in (r.get("nama","").lower() or "") and s not in (r.get("kategori","").lower() or ""):
continue
try:
mid = int(r.get("id") or 0)
except:
mid = r.get("id")
try:
harga = float(r.get("harga") or 0.0)
except:
harga = 0.0
try:
stok = int(float(r.get("stok") or 0))
except:
stok = 0
foto = r.get("foto") or None
tersedia = 1 if r.get("tersedia") == "1" else 0
try:
item_disc = float(r.get("item_discount_pct") or 0.0)
except:
item_disc = 0.0
out.append((mid, r.get("nama"), r.get("kategori"), harga, stok, foto, tersedia, item_disc))
out.sort(key=lambda x: int(x[0]))
return out
def menu_get(menu_id):
rows = read_all(MENU_CSV)
for r in rows:
if r.get("id") == str(menu_id):
try:
mid = int(r.get("id") or 0)
except:
mid = r.get("id")
try:
harga = float(r.get("harga") or 0.0)
except:
harga = 0.0
try:
stok = int(float(r.get("stok") or 0))
except:
stok = 0
foto = r.get("foto") or None
tersedia = 1 if r.get("tersedia") == "1" else 0
try:
item_disc = float(r.get("item_discount_pct") or 0.0)
except:
item_disc = 0.0
return (mid, r.get("nama"), r.get("kategori"), harga, stok, foto, tersedia, item_disc)
return None
def menu_decrease_stock(menu_id, qty):
rows = read_all(MENU_CSV)
found = False
for r in rows:
if r.get("id") == str(menu_id):
found = True
try:
stok = int(float(r.get("stok") or 0))
except:
stok = 0
if stok < qty:
return False, "Stok tidak cukup"
newstok = stok - qty
r["stok"] = str(newstok)
r["tersedia"] = "1" if newstok > 0 else "0"
break
if not found:
return False, "Menu tidak ditemukan"
write_all(MENU_CSV, ["id","nama","kategori","harga","stok","foto","tersedia","item_discount_pct"], rows)
return True, newstok
# Reza balap liar
# wilayah dikuasai promo
def promo_add(code, ptype, value, min_total=0):
rows = read_all(PROMO_CSV)
for r in rows:
if r.get("code") == code:
raise ValueError("Kode promo sudah ada")
rows.append({
"code": code,
"type": ptype,
"value": str(float(value)),
"min_total": str(float(min_total))
})
write_all(PROMO_CSV, ["code","type","value","min_total"], rows)
def promo_update(code, ptype, value, min_total=0):
rows = read_all(PROMO_CSV)
found = False
for r in rows:
if r.get("code") == code:
r["type"] = ptype
r["value"] = str(float(value))
r["min_total"] = str(float(min_total))
found = True
break
if not found:
raise ValueError("Promo tidak ditemukan")
write_all(PROMO_CSV, ["code","type","value","min_total"], rows)
def promo_delete(code):
rows = read_all(PROMO_CSV)
newrows = [r for r in rows if r.get("code") != code]
write_all(PROMO_CSV, ["code","type","value","min_total"], newrows)
def promo_list():
rows = read_all(PROMO_CSV)
out = []
for r in rows:
try:
val = float(r.get("value") or 0.0)
except:
val = 0.0
try:
mt = float(r.get("min_total") or 0.0)
except:
mt = 0.0
out.append((r.get("code"), r.get("type"), val, mt))
out.sort(key=lambda x: x[0] or "")
return out
def promo_get(code):
rows = read_all(PROMO_CSV)
for r in rows:
if r.get("code") == code:
try:
val = float(r.get("value") or 0.0)
except:
val = 0.0
try:
mt = float(r.get("min_total") or 0.0)
except:
mt = 0.0
return (r.get("code"), r.get("type"), val, mt)
return None
# Wilayah dikuasai Transaksi
def transaksi_add(user_id, nomor_meja, cart_items, promo_code=None):
"""Simpan transaksi baru dengan status 'pending'"""
from datetime import datetime
if not cart_items:
return False, "Keranjang kosong"
# Hitung diskon dan total
calc = apply_discounts_and_promo(cart_items, promo_code)
# Buat transaksi
rows = read_all(TRANSAKSI_CSV)
transaksi_id = next_int_id(rows, "id")
tanggal = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
transaksi_row = {
"id": transaksi_id,
"user_id": str(user_id),
"nomor_meja": str(nomor_meja),
"total": str(calc['total']),
"status": "pending",
"promo_code": calc.get('promo_code') or "",
"subtotal": str(calc['subtotal']),
"item_discount": str(calc['item_discount']),
"promo_discount": str(calc['promo_discount']),
"tanggal": tanggal
}
rows.append(transaksi_row)
write_all(TRANSAKSI_CSV, ["id", "user_id", "nomor_meja", "total", "status", "promo_code", "subtotal", "item_discount", "promo_discount", "tanggal"], rows)
# Simpan detail transaksi
detail_rows = read_all(DETAIL_TRANSAKSI_CSV)
for item in cart_items:
detail_id = next_int_id(detail_rows, "id")
menu_data = menu_get(item['menu_id'])
if not menu_data:
continue
_, nama, kategori, harga, stok, foto, tersedia, item_disc = menu_data
qty = int(item['qty'])
subtotal_item = harga * qty
detail_rows.append({
"id": detail_id,
"transaksi_id": transaksi_id,
"menu_id": str(item['menu_id']),
"qty": str(qty),
"harga_satuan": str(harga),
"subtotal_item": str(subtotal_item)
})
# Kurangi stok
success, msg = menu_decrease_stock(item['menu_id'], qty)
if not success:
return False, f"Gagal mengurangi stok menu ID {item['menu_id']}: {msg}"
# Update favorite count
favorite_update(user_id, item['menu_id'])
write_all(DETAIL_TRANSAKSI_CSV, ["id", "transaksi_id", "menu_id", "qty", "harga_satuan", "subtotal_item"], detail_rows)
return True, transaksi_id
def transaksi_list(status=None, user_id=None):
"""Ambil daftar transaksi, bisa filter by status atau user_id"""
rows = read_all(TRANSAKSI_CSV)
out = []
for r in rows:
if status and r.get("status") != status:
continue
if user_id and r.get("user_id") != str(user_id):
continue
try:
tid = int(r.get("id") or 0)
except:
tid = r.get("id")
try:
uid = int(r.get("user_id") or 0)
except:
uid = r.get("user_id")
try:
meja = int(r.get("nomor_meja") or 0)
except:
meja = r.get("nomor_meja")
try:
total = float(r.get("total") or 0.0)
except:
total = 0.0
out.append((tid, uid, meja, total, r.get("status"), r.get("promo_code"), r.get("tanggal")))
out.sort(key=lambda x: int(x[0]), reverse=True)
return out
def transaksi_get(transaksi_id):
"""Ambil detail transaksi by ID"""
rows = read_all(TRANSAKSI_CSV)
for r in rows:
if r.get("id") == str(transaksi_id):
try:
tid = int(r.get("id") or 0)
except:
tid = r.get("id")
try:
uid = int(r.get("user_id") or 0)
except:
uid = r.get("user_id")
try:
meja = int(r.get("nomor_meja") or 0)
except:
meja = r.get("nomor_meja")
try:
total = float(r.get("total") or 0.0)
except:
total = 0.0
try:
subtotal = float(r.get("subtotal") or 0.0)
except:
subtotal = 0.0
try:
item_disc = float(r.get("item_discount") or 0.0)
except:
item_disc = 0.0
try:
promo_disc = float(r.get("promo_discount") or 0.0)
except:
promo_disc = 0.0
return (tid, uid, meja, total, r.get("status"), r.get("promo_code"), subtotal, item_disc, promo_disc, r.get("tanggal"))
return None
def transaksi_update_status(transaksi_id, new_status):
"""Update status transaksi"""
rows = read_all(TRANSAKSI_CSV)
found = False
for r in rows:
if r.get("id") == str(transaksi_id):
r["status"] = new_status
found = True
break
if found:
write_all(TRANSAKSI_CSV, ["id", "user_id", "nomor_meja", "total", "status", "promo_code", "subtotal", "item_discount", "promo_discount", "tanggal"], rows)
return True
return False
# ========================================
# TAMBAHAN FUNCTIONS - MEJA MODULE
# ========================================
def meja_get_all_with_transaksi():
"""Ambil semua meja dengan detail transaksi terkait"""
rows = read_all(MEJA_CSV)
out = []
for r in rows:
nomor = int(r.get("nomor_meja", 0))
status = r.get("status", "kosong")
transaksi_id = r.get("transaksi_id", "")
transaksi_info = None
if transaksi_id:
try:
transaksi_data = transaksi_get(int(transaksi_id))
if transaksi_data:
tid, uid, meja_num, total, trx_status, promo, subtotal, item_disc, promo_disc, tanggal = transaksi_data
transaksi_info = {
'id': tid,
'total': total,
'status': trx_status,
'tanggal': tanggal
}
except:
pass
out.append({
'nomor': nomor,
'status': status,
'transaksi_id': transaksi_id,
'transaksi_info': transaksi_info
})
return sorted(out, key=lambda x: x['nomor'])
def meja_get_status_summary():
"""Dapatkan ringkasan status meja"""
all_meja = meja_get_all_with_transaksi()
summary = {
'total': len(all_meja),
'kosong': sum(1 for m in all_meja if m['status'] == 'kosong'),
'terisi': sum(1 for m in all_meja if m['status'] == 'terisi'),
'meja_list': all_meja
}
return summary
def meja_reset_all_kosong():
"""Reset semua meja menjadi kosong (untuk admin/maintenance)"""
rows = read_all(MEJA_CSV)
for r in rows:
r['status'] = 'kosong'
r['transaksi_id'] = ''
write_all(MEJA_CSV, ["nomor_meja", "status", "transaksi_id"], rows)
return True
# ========================================
# TAMBAHAN FUNCTIONS - PAYMENT MODULE
# ========================================
def pembayaran_list_by_date_range(start_date, end_date, status_filter=None):
"""Ambil pembayaran dalam rentang tanggal"""
from datetime import datetime
rows = read_all(PEMBAYARAN_CSV)
out = []
for r in rows:
try:
payment_date = datetime.strptime(r.get("tanggal_bayar"), "%Y-%m-%d %H:%M:%S")
except:
continue
if payment_date < start_date or payment_date > end_date:
continue
if status_filter and r.get("status_pembayaran") != status_filter:
continue
try:
pid = int(r.get("id", 0))
tid = int(r.get("transaksi_id", 0))
jumlah = float(r.get("jumlah_bayar", 0.0))
except:
continue
out.append((
pid,
tid,
r.get("metode_pembayaran"),
jumlah,
r.get("status_pembayaran"),
r.get("tanggal_bayar")
))
return sorted(out, key=lambda x: x[5], reverse=True)
def pembayaran_get_summary(start_date, end_date, status_filter=None, method_filter=None):
"""Hitung ringkasan pembayaran - DENGAN FILTER METODE"""
payments = pembayaran_list_by_date_range(start_date, end_date,
status_filter=status_filter)
total_income = 0
total_count = 0
metode_breakdown = {}
for p in payments:
pid, tid, metode, jumlah, status, tanggal = p
# FILTER BY METODE
if method_filter and method_filter != 'semua':
# Normalize metode untuk comparison
metode_clean = metode.lower().replace(' ', '')
filter_clean = method_filter.lower().replace(' ', '')
if metode_clean != filter_clean:
continue # Skip jika tidak sesuai filter
total_income += jumlah
total_count += 1
metode = p[2]
metode_breakdown[metode] = metode_breakdown.get(metode, 0) + 1
return {
'total_income': total_income,
'total_count': total_count,
'avg_per_transaction': total_income / total_count if total_count > 0 else 0,
'metode_breakdown': metode_breakdown,
'payments': payments
}
def pembayaran_validate_cash(jumlah_bayar, total_transaksi):
"""Validasi pembayaran cash"""
if jumlah_bayar < total_transaksi:
return False, f"Uang kurang! Kurang: Rp {total_transaksi - jumlah_bayar:,.0f}"
return True, "Valid"
def pembayaran_calculate_change(jumlah_bayar, total_transaksi):
"""Hitung kembalian"""
if jumlah_bayar < total_transaksi:
return None
return jumlah_bayar - total_transaksi
# ========================================
# TAMBAHAN FUNCTIONS - REPORT MODULE
# ========================================
def report_get_daily_summary(target_date=None, method_filter=None):
"""Dapatkan ringkasan penjualan harian - DENGAN FILTER METODE"""
from datetime import datetime
if target_date is None:
target_date = datetime.now().date()
start_datetime = datetime.combine(target_date, datetime.min.time())
end_datetime = datetime.combine(target_date, datetime.max.time())
# PASS METHOD_FILTER KE FUNCTION INI
summary = pembayaran_get_summary(
start_datetime,
end_datetime,
status_filter='sukses',
method_filter=method_filter # ← TAMBAH INI
)
return {
'tanggal': target_date.strftime("%Y-%m-%d"),
'total_transaksi': summary['total_count'],
'total_pendapatan': summary['total_income'],
'rata_rata': summary['avg_per_transaction'],
'metode_breakdown': summary['metode_breakdown'],
'details': summary['payments']
}
def report_get_weekly_summary(week_start=None, method_filter=None):
"""Dapatkan ringkasan penjualan mingguan - DENGAN FILTER METODE"""
from datetime import datetime, timedelta
if week_start is None:
today = datetime.now()
week_start = today - timedelta(days=today.weekday())
week_start_dt = datetime.combine(week_start.date(), datetime.min.time())
week_end_dt = week_start_dt + timedelta(days=7)
# PASS METHOD_FILTER
summary = pembayaran_get_summary(
week_start_dt,
week_end_dt,
status_filter='sukses',
method_filter=method_filter # ← TAMBAH INI
)
return {
'minggu_mulai': week_start.strftime("%Y-%m-%d"),
'total_transaksi': summary['total_count'],
'total_pendapatan': summary['total_income'],
'rata_rata': summary['avg_per_transaction'],
'metode_breakdown': summary['metode_breakdown'],
'details': summary['payments']
}
def report_get_monthly_summary(year=None, month=None, method_filter=None):
"""Dapatkan ringkasan penjualan bulanan - DENGAN FILTER METODE"""
from datetime import datetime, date
import calendar
if year is None:
year = datetime.now().year
if month is None:
month = datetime.now().month
first_day = date(year, month, 1)
last_day = date(year, month, calendar.monthrange(year, month)[1])
start_datetime = datetime.combine(first_day, datetime.min.time())
end_datetime = datetime.combine(last_day, datetime.max.time())
# PASS METHOD_FILTER
summary = pembayaran_get_summary(
start_datetime,
end_datetime,
status_filter='sukses',
method_filter=method_filter # ← TAMBAH INI
)
return {
'bulan': f"{first_day.strftime('%B %Y')}",
'total_transaksi': summary['total_count'],
'total_pendapatan': summary['total_income'],
'rata_rata': summary['avg_per_transaction'],
'metode_breakdown': summary['metode_breakdown'],
'details': summary['payments']
}
def report_export_to_text(report_data, report_type='daily'):
"""Export laporan ke format text"""
text = "=" * 70 + "\n"
text += "LAPORAN PENJUALAN CAFÉ TOTORO \n"
text += "=" * 70 + "\n\n"
if report_type == 'daily':
text += f"PERIODE: {report_data['tanggal']}\n"
text += f"TIPE: Laporan Harian\n"
elif report_type == 'weekly':
text += f"PERIODE: Minggu dimulai {report_data['minggu_mulai']}\n"
text += f"TIPE: Laporan Mingguan\n"
else:
text += f"PERIODE: {report_data.get('bulan', 'Unknown')}\n"
text += f"TIPE: Laporan Bulanan\n"
text += "=" * 70 + "\n\n"
text += "RINGKASAN PENJUALAN:\n"
text += "-" * 70 + "\n"
text += f"Total Transaksi : {report_data['total_transaksi']} transaksi\n"
text += f"Total Pendapatan : Rp {report_data['total_pendapatan']:,.2f}\n"
text += f"Rata-rata : Rp {report_data['rata_rata']:,.2f} per transaksi\n"
text += "\n"
text += "BREAKDOWN METODE PEMBAYARAN:\n"
text += "-" * 70 + "\n"
for metode, count in sorted(report_data['metode_breakdown'].items()):
percentage = (count / report_data['total_transaksi'] * 100) if report_data['total_transaksi'] > 0 else 0
text += f"{metode.upper().ljust(20)} : {str(count).rjust(3)} transaksi ({percentage:.1f}%)\n"
text += "=" * 70 + "\n"
return text
# ========================================
# HELPER FUNCTIONS - FORMATTING
# ========================================
def format_currency(amount):
"""Format nilai menjadi Rupiah"""
return f"Rp {amount:,.0f}"
def format_datetime(dt_string):
"""Format datetime string menjadi readable"""
try:
from datetime import datetime
dt = datetime.strptime(dt_string, "%Y-%m-%d %H:%M:%S")
return dt.strftime("%d/%m/%Y %H:%M")
except:
return dt_string
def validate_meja_number(nomor_meja):
"""Validasi nomor meja"""
try:
nomor = int(nomor_meja)
if nomor < 1 or nomor > 99:
return False, "Nomor meja harus 1-99"
return True, "Valid"
except:
return False, "Nomor meja harus berupa angka"
def detail_transaksi_list(transaksi_id):
"""Ambil semua detail item dari transaksi tertentu"""
rows = read_all(DETAIL_TRANSAKSI_CSV)
out = []
for r in rows:
if r.get("transaksi_id") == str(transaksi_id):
try:
did = int(r.get("id") or 0)
except:
did = r.get("id")
try:
mid = int(r.get("menu_id") or 0)
except:
mid = r.get("menu_id")
try:
qty = int(r.get("qty") or 0)
except:
qty = 0
try:
harga = float(r.get("harga_satuan") or 0.0)
except:
harga = 0.0
try:
subtotal = float(r.get("subtotal_item") or 0.0)
except:
subtotal = 0.0
out.append((did, mid, qty, harga, subtotal))
return out
# 19 juta lapangan badmin
# Buat logika diskon + promok
def apply_discounts_and_promo(cart_items, promo_code=None):
subtotal = 0.0
item_discount_total = 0.0
menu_rows = read_all(MENU_CSV)
menu_dict = {r["id"]: r for r in menu_rows}
for it in cart_items:
mid = str(it.get('menu_id'))
r = menu_dict.get(mid)
if not r:
continue
try:
price = float(r.get("harga") or 0.0)
except:
price = 0.0
try:
item_disc_pct = float(r.get("item_discount_pct") or 0.0)
except:
item_disc_pct = 0.0
qty = int(it.get('qty', 1))
line = price * qty
subtotal += line
if item_disc_pct and item_disc_pct > 0:
item_discount_total += (price * qty) * (item_disc_pct / 100.0)
promo_discount = 0.0
promo_applied = None
if promo_code:
p = promo_get(promo_code)
if p:
_, ptype, val, min_total = p
if subtotal >= (min_total or 0.0):
if ptype == 'percent':
promo_discount = (subtotal - item_discount_total) * (val / 100.0)
else:
promo_discount = val
promo_applied = promo_code
total = subtotal - item_discount_total - promo_discount
if total < 0:
total = 0.0
return {
'subtotal': round(subtotal, 2),
'item_discount': round(item_discount_total, 2),
'promo_code': promo_applied,
'promo_discount': round(promo_discount, 2),
'total': round(total, 2)
}
# === FUNGSI FAVORITE ===
def favorite_update(user_id, menu_id):
"""Update atau tambah favorite count untuk user tertentu"""
from datetime import datetime
rows = read_all(FAVORITE_CSV)
found = False
for r in rows:
if r.get("user_id") == str(user_id) and r.get("menu_id") == str(menu_id):
try:
count = int(r.get("order_count") or 0)
except:
count = 0
r["order_count"] = str(count + 1)
r["last_ordered"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
found = True
break
if not found:
rows.append({
"user_id": str(user_id),
"menu_id": str(menu_id),
"order_count": "1",
"last_ordered": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
write_all(FAVORITE_CSV, ["user_id", "menu_id", "order_count", "last_ordered"], rows)
def favorite_list(user_id, limit=5):
"""Ambil menu favorit user, sorted by order_count descending"""
rows = read_all(FAVORITE_CSV)
out = []
for r in rows:
if r.get("user_id") == str(user_id):
try:
mid = int(r.get("menu_id") or 0)
except:
mid = r.get("menu_id")
try:
count = int(r.get("order_count") or 0)
except:
count = 0
last_ordered = r.get("last_ordered")
out.append((mid, count, last_ordered))
out.sort(key=lambda x: x[1], reverse=True)
if limit:
out = out[:limit]
return out
# === FUNGSI PEMBAYARAN ===
def pembayaran_add(transaksi_id, metode_pembayaran, jumlah_bayar, status_pembayaran='sukses', struk=''):
"""Simpan data pembayaran baru"""
from datetime import datetime
rows = read_all(PEMBAYARAN_CSV)
new_id = next_int_id(rows, "id")
tanggal_bayar = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rows.append({
"id": new_id,
"transaksi_id": str(transaksi_id),
"metode_pembayaran": metode_pembayaran,
"jumlah_bayar": str(float(jumlah_bayar)),
"status_pembayaran": status_pembayaran,
"tanggal_bayar": tanggal_bayar,
"struk": struk
})
write_all(PEMBAYARAN_CSV, ["id", "transaksi_id", "metode_pembayaran", "jumlah_bayar", "status_pembayaran", "tanggal_bayar", "struk"], rows)
return new_id
def pembayaran_get_by_transaksi(transaksi_id):
"""Ambil data pembayaran berdasarkan transaksi_id"""
rows = read_all(PEMBAYARAN_CSV)
for r in rows:
if r.get("transaksi_id") == str(transaksi_id):
try:
pid = int(r.get("id") or 0)
except:
pid = r.get("id")
try:
jumlah = float(r.get("jumlah_bayar") or 0.0)
except:
jumlah = 0.0
return (pid, r.get("metode_pembayaran"), jumlah, r.get("status_pembayaran"), r.get("tanggal_bayar"), r.get("struk"))
return None
# === FUNGSI MEJA ===
def meja_update_status(nomor_meja, new_status, transaksi_id=""):
"""Update status meja (kosong/terisi) dan link ke transaksi"""
rows = read_all(MEJA_CSV)
found = False
for r in rows:
if r.get("nomor_meja") == str(nomor_meja):
r["status"] = new_status
r["transaksi_id"] = str(transaksi_id) if transaksi_id else ""
found = True
break
if found:
write_all(MEJA_CSV, ["nomor_meja", "status", "transaksi_id"], rows)
return True
return False
def meja_tutup(nomor_meja):
"""Tutup meja (set ke kosong)"""
return meja_update_status(nomor_meja, "kosong", "")
def meja_get(nomor_meja):
"""Ambil data meja berdasarkan nomor"""
rows = read_all(MEJA_CSV)
for r in rows:
if r.get("nomor_meja") == str(nomor_meja):
try:
nomor = int(r.get("nomor_meja") or 0)
except:
nomor = r.get("nomor_meja")
transaksi_id = r.get("transaksi_id") or ""
return (nomor, r.get("status"), transaksi_id)
return None
# Wilayah dikuasai UI
class App:
def __init__(self, root):
self.root = root
self.root.title("Totoro Cafe")
self.session = None
self.img_cache = {}
self.setup_ui()
def create_hover_button(self, parent, text, command, style='primary', width=None):
"""Helper untuk membuat button dengan hover effect"""
bg_colors = {
'primary': (COLORS['primary'], COLORS['primary_dark']),
'success': (COLORS['success'], COLORS['primary_dark']),
'danger': (COLORS['danger'], '#B85555'),
'brown': (COLORS['brown'], '#6B5D48')
}
bg_normal, bg_hover = bg_colors.get(style, bg_colors['primary'])
btn = tk.Button(
parent,
text=text,
command=command,
bg=bg_normal,
fg='white',
font=FONTS['button'],
relief='flat',
cursor='hand2',
borderwidth=0,
padx=20,
pady=10
)
if width:
btn.config(width=width)
def on_enter(e):
btn['background'] = bg_hover
def on_leave(e):
btn['background'] = bg_normal
btn.bind('<Enter>', on_enter)
btn.bind('<Leave>', on_leave)
return btn
def setup_ui(self):
# UI STYLING - Window setup dengan tema Totoro
self.root.geometry("1200x750")
self.root.resizable(True, True) # Bisa maximize
self.root.configure(bg=COLORS['bg_main'])
# UI STYLING - Custom styles untuk tema Totoro
style = ttk.Style()
style.theme_use('clam')
# Background colors
style.configure(".", background=COLORS['bg_main'], foreground=COLORS['text_dark'])
# Frame styles
style.configure("TFrame", background=COLORS['bg_main'])
style.configure("TLabelframe", background=COLORS['bg_main'], bordercolor=COLORS['border'])
style.configure("TLabelframe.Label", background=COLORS['bg_main'], foreground=COLORS['brown'], font=("Arial", 10, "bold"))
# Button styles
style.configure("TButton",
background=COLORS['primary'],
foreground="white",
borderwidth=0,
focuscolor='none',
font=("Arial", 10),
padding=10)
style.map("TButton",
background=[('active', COLORS['primary_dark']), ('pressed', COLORS['primary_dark'])],
foreground=[('active', 'white')])
style.configure("Accent.TButton",
background=COLORS['brown'],
foreground="white",
font=("Arial", 11, "bold"),
padding=12)
style.map("Accent.TButton",
background=[('active', '#6B5D48'), ('pressed', '#6B5D48')])
# Label styles
style.configure("TLabel", background=COLORS['bg_main'], foreground=COLORS['text_dark'], font=("Arial", 10))
# Entry styles
style.configure("TEntry", fieldbackground="white", bordercolor=COLORS['border'])
# Treeview styles
style.configure("Treeview",
background="white",
foreground=COLORS['text_dark'],
fieldbackground="white",
borderwidth=0,
font=("Arial", 9))
style.configure("Treeview.Heading",
background=COLORS['primary'],
foreground="white",
font=("Arial", 10, "bold"))
style.map("Treeview.Heading",
background=[('active', COLORS['primary_dark'])])
# Notebook (tabs) styles
style.configure("TNotebook", background=COLORS['bg_main'], borderwidth=0)
style.configure("TNotebook.Tab",
background=COLORS['accent'],
foreground=COLORS['text_dark'],
padding=[20, 10],
font=("Arial", 10))
style.map("TNotebook.Tab",
background=[('selected', COLORS['primary'])],
foreground=[('selected', 'white')],
expand=[('selected', [1, 1, 1, 0])])
self.login_frame()
def login_frame(self):
for w in self.root.winfo_children():
w.destroy()
# ========================================
# TOTORO LOGIN - LANDSCAPE LAYOUT (FIXED)
# ========================================
# Main container dengan background
main_container = tk.Frame(self.root, bg=COLORS['bg_main'])
main_container.pack(fill='both', expand=True)
# Center frame untuk positioning
center_frame = tk.Frame(main_container, bg=COLORS['bg_main'])
center_frame.pack(expand=True, pady=50)
# ========================================
# MAIN CARD - LANDSCAPE (TANPA SHADOW DULU)
# ========================================
card = tk.Frame(
center_frame,
bg='white',
relief='ridge',
borderwidth=3,
padx=0,
pady=0
)
card.pack()
# ========================================
# CONTAINER HORIZONTAL (2 PANEL)
# ========================================
container = tk.Frame(card, bg='white')
container.pack()
# ========================================
# LEFT PANEL - TOTORO ILLUSTRATION
# ========================================
left_panel = tk.Frame(container, bg=COLORS['accent'], width=350)
left_panel.pack(side='left', fill='y', padx=0, pady=0)
# Spacer atas
tk.Label(left_panel, text="", bg=COLORS['accent'], height=2).pack()
# Totoro illustration
try:
totoro_img = Image.open("img/totoro_sitting.png")
totoro_img = totoro_img.resize((250, 250), Image.Resampling.LANCZOS)
totoro_photo = ImageTk.PhotoImage(totoro_img)
self.totoro_login_img = totoro_photo
img_label = tk.Label(left_panel, image=totoro_photo, bg=COLORS['accent'])
img_label.pack(pady=20)
except Exception as e:
print(f"⚠️ Gambar tidak ditemukan: {e}")
tk.Label(
left_panel,
text="🌿\n\n🍃\n\n🌳",
font=('Arial', 50),
bg=COLORS['accent'],
fg=COLORS['primary_dark']
).pack(pady=40)
# Welcome text
tk.Label(
left_panel,
text="Welome to Totoro Café\nTotoro is waiting for you!",
font=('Comic Sans MS', 11, 'italic'),
bg=COLORS['accent'],
fg=COLORS['brown']
).pack(pady=(10, 30))
# ========================================
# RIGHT PANEL - LOGIN FORM
# ========================================
right_panel = tk.Frame(container, bg='white', padx=40, pady=30)
right_panel.pack(side='right', fill='both')
# Header
tk.Label(
right_panel,
text="🌿 TOTORO CAFÉ 🌿",
font=('Comic Sans MS', 22, 'bold'),
bg='white',
fg=COLORS['primary']
).pack(pady=(10, 5))
tk.Label(
right_panel,
text="Café Management System",
font=('Segoe UI', 9),
bg='white',
fg=COLORS['text_light']
).pack(pady=(0, 20))
# Form container
form_frame = tk.Frame(right_panel, bg='white')
form_frame.pack(pady=10)
# Username
tk.Label(
form_frame,
text="Username:",
font=('Segoe UI', 10, 'bold'),
bg='white',
fg=COLORS['text_dark']
).grid(row=0, column=0, sticky='w', pady=(10, 5), padx=5)
self.username_var = tk.StringVar()
username_entry = tk.Entry(
form_frame,
textvariable=self.username_var,
width=32,
font=('Segoe UI', 10),
relief='solid',
borderwidth=1
)
username_entry.grid(row=1, column=0, pady=(0, 15), ipady=7, padx=5)
# Password
tk.Label(
form_frame,
text="Password:",
font=('Segoe UI', 10, 'bold'),
bg='white',
fg=COLORS['text_dark']
).grid(row=2, column=0, sticky='w', pady=(5, 5), padx=5)
self.password_var = tk.StringVar()
password_entry = tk.Entry(
form_frame,
textvariable=self.password_var,
show="",
width=32,
font=('Segoe UI', 10),
relief='solid',
borderwidth=1
)
password_entry.grid(row=3, column=0, pady=(0, 15), ipady=7, padx=5)
# Separator
separator = tk.Frame(form_frame, bg=COLORS['border'], height=2)
separator.grid(row=4, column=0, sticky='ew', pady=12, padx=5)
# Info box
info_frame = tk.Frame(
form_frame,
bg='#FFF9E6',
relief='solid',
borderwidth=1
)
info_frame.grid(row=5, column=0, pady=8, sticky='ew', padx=5)
tk.Label(
info_frame,
text=" Khusus untuk Pembeli",
font=('Segoe UI', 9, 'bold'),
bg='#FFF9E6',
fg=COLORS['brown']
).pack(anchor='w', padx=10, pady=(8, 3))
tk.Label(
info_frame,
text="Silakan isi Nama & Nomor Meja",
font=('Segoe UI', 8),
bg='#FFF9E6',
fg=COLORS['text_light']
).pack(anchor='w', padx=10, pady=(0, 8))
# Nama Lengkap
tk.Label(
form_frame,
text="Nama Lengkap:",
font=('Segoe UI', 9),
bg='white',
fg=COLORS['text_light']
).grid(row=6, column=0, sticky='w', pady=(10, 3), padx=5)
self.customer_name_var = tk.StringVar()
customer_entry = tk.Entry(
form_frame,
textvariable=self.customer_name_var,
width=32,
font=('Segoe UI', 9),
relief='solid',
borderwidth=1
)
customer_entry.grid(row=7, column=0, pady=(0, 10), ipady=6, padx=5)
# Nomor Meja
tk.Label(
form_frame,
text="Nomor Meja (1-10):",
font=('Segoe UI', 9),
bg='white',
fg=COLORS['text_light']
).grid(row=8, column=0, sticky='w', pady=(5, 3), padx=5)
self.customer_meja_var = tk.StringVar()
meja_entry = tk.Entry(
form_frame,
textvariable=self.customer_meja_var,
width=32,
font=('Segoe UI', 9),
relief='solid',
borderwidth=1
)
meja_entry.grid(row=9, column=0, pady=(0, 20), ipady=6, padx=5)
# Login button
login_btn = tk.Button(
form_frame,
text="LOGIN",
command=self.handle_login,
bg=COLORS['primary'],
fg='white',
font=('Segoe UI', 11, 'bold'),
relief='flat',
cursor='hand2',
width=28,
height=2,
borderwidth=0
)
login_btn.grid(row=10, column=0, pady=(10, 15), padx=5)
def on_enter(e):
login_btn['background'] = COLORS['primary_dark']
def on_leave(e):
login_btn['background'] = COLORS['primary']
login_btn.bind("<Enter>", on_enter)
login_btn.bind("<Leave>", on_leave)
# Footer
tk.Label(
right_panel,
text="© 2025 Café Totoro - UAS Informatika",
font=('Segoe UI', 7),
bg='white',
fg=COLORS['text_light']
).pack(side='bottom', pady=(20, 10))
def handle_login(self):
u = self.username_var.get().strip()
p = self.password_var.get().strip()
if not u or not p:
messagebox.showwarning("Input", "Masukkan username & password")
return
user = authenticate(u, p)
if not user:
messagebox.showerror("Gagal", "Username atau password salah")
return
# VALIDASI UNTUK PEMBELI
if user['role'] in ['pembeli', 'user']:
customer_name = self.customer_name_var.get().strip()
customer_meja = self.customer_meja_var.get().strip()
# Validasi nama
if not customer_name:
messagebox.showwarning("Nama Pembeli", "Pembeli harus mengisi Nama Lengkap!")
return
# Validasi nomor meja
if not customer_meja:
messagebox.showwarning("Nomor Meja", "Pembeli harus mengisi Nomor Meja!")
return
# Validasi format nomor meja
try:
nomor_meja = int(customer_meja)
if nomor_meja < 1 or nomor_meja > 10:
messagebox.showwarning("Nomor Meja Invalid", "Nomor meja harus antara 1-10")
return
except ValueError:
messagebox.showerror("Input Error", "Nomor meja harus berupa angka!")
return
# CEK APAKAH MEJA TERSEDIA
meja_data = meja_get(nomor_meja)
if meja_data:
status_meja = meja_data[1]
if status_meja == 'terisi':
messagebox.showwarning(
"Meja Tidak Tersedia",
f"❌ Maaf, Meja {nomor_meja} sedang terisi.\n\n"
f"Silakan pilih nomor meja lain."
)
return
# Simpan nama dan nomor meja di session
user['customer_name'] = customer_name
user['nomor_meja'] = nomor_meja
# RESERVASI MEJA (set status terisi tanpa transaksi_id dulu)
# Nanti saat checkout baru dikasih transaksi_id
meja_update_status(nomor_meja, 'terisi', '')
# Pesan selamat datang khusus pembeli
welcome_msg = f"✅ Selamat datang, {customer_name}!\n\n"
welcome_msg += f"📍 Anda duduk di Meja {nomor_meja}\n\n"
welcome_msg += f"🍽️ Silakan pilih menu dan pesan makanan Anda.\n"
welcome_msg += f"Selamat menikmati!"
messagebox.showinfo("Login Berhasil", welcome_msg)
else:
# Pesan untuk role lain (admin, kasir, waiter, pemilik)
messagebox.showinfo("Sukses", f"Login berhasil sebagai {user['role']}")
self.session = user
self.dashboard_frame()
def logout(self):
self.session = None
self.img_cache.clear()
self.notification_running = False
self.login_frame()
def dashboard_frame(self):
for w in self.root.winfo_children():
w.destroy()
# ========================================
# DASHBOARD HEADER - IMPROVED
# ========================================
top = tk.Frame(self.root, bg=COLORS['primary'], height=70)
top.pack(fill='x')
# Left side - Logo + Title
left_side = tk.Frame(top, bg=COLORS['primary'])
left_side.pack(side='left', padx=20, pady=10)
# Logo
try:
totoro_header = Image.open("img/totoro_logo.png")
totoro_header = totoro_header.resize((45, 45), Image.Resampling.LANCZOS)
totoro_header_photo = ImageTk.PhotoImage(totoro_header)
self.totoro_header_logo = totoro_header_photo
tk.Label(left_side, image=totoro_header_photo, bg=COLORS['primary']).pack(side='left', padx=(0, 10))
except:
tk.Label(left_side, text="🌿", font=('Arial', 28), bg=COLORS['primary']).pack(side='left', padx=(0, 10))
# Title + Subtitle
title_frame = tk.Frame(left_side, bg=COLORS['primary'])
title_frame.pack(side='left')
tk.Label(
title_frame,
text="TOTORO CAFÉ",
font=('Comic Sans MS', 16, 'bold'),
bg=COLORS['primary'],
fg='white'
).pack(anchor='w')
tk.Label(
title_frame,
text="Your Cozy Corner for Every Order",
font=FONTS['small'],
bg=COLORS['primary'],
fg='white'
).pack(anchor='w')
# Center - User info (IMPROVED)
center_info = tk.Frame(top, bg=COLORS['primary'])
center_info.pack(side='left', expand=True, padx=20)
if self.session['role'] in ['pembeli', 'user'] and 'customer_name' in self.session:
user_text = f"👤 {self.session['customer_name']}"
role_text = f"🪑 Meja {self.session.get('nomor_meja', '-')}{self.session['role'].title()}"
else:
user_text = f"👤 {self.session['username']}"
role_text = f"🎭 Role: {self.session['role'].title()}"
tk.Label(
center_info,
text=user_text,
font=FONTS['subheader'],
bg=COLORS['primary'],
fg='white'
).pack()
tk.Label(
center_info,
text=role_text,
font=FONTS['small'],
bg=COLORS['primary'],
fg='white'
).pack()
# Right side - Logout button dengan shadow
right_side = tk.Frame(top, bg=COLORS['primary'])
right_side.pack(side='right', padx=20, pady=10)
logout_btn = tk.Button(
right_side,
text="LOGOUT",
command=self.logout,
bg=COLORS['brown'],
fg='white',
font=FONTS['button'],
relief='flat',
cursor='hand2',
padx=25,
pady=10,
borderwidth=0
)
logout_btn.pack()
# Hover effect
def on_enter(e):
logout_btn['background'] = '#6B5D48'
def on_leave(e):
logout_btn['background'] = COLORS['brown']
logout_btn.bind("<Enter>", on_enter)
logout_btn.bind("<Leave>", on_leave)
# Tab container dengan styling
tab_container = tk.Frame(self.root, bg=COLORS['bg_main'])
tab_container.pack(fill='both', expand=True, padx=15, pady=10)
main = ttk.Notebook(tab_container)
main.pack(fill='both', expand=True)
self.tab_menu_manage = ttk.Frame(main)
self.tab_menu_view = ttk.Frame(main)
self.tab_promo = ttk.Frame(main)
self.tab_order = ttk.Frame(main)
self.tab_waiter = ttk.Frame(main)
self.tab_favorite = ttk.Frame(main)
# ========================================
# KONDISI TAB BERDASARKAN ROLE
# ========================================
# Tab untuk SEMUA role
main.add(self.tab_menu_view, text="📖 Menu - View")
# ==========================================
# ROLE: PEMBELI / USER
# ==========================================
if self.session['role'] in ['pembeli', 'user']:
main.add(self.tab_order, text="🛒 Order Menu")
main.add(self.tab_favorite, text="⭐ Favorit")
# ==========================================
# ROLE: WAITER
# ==========================================
if self.session['role'] == 'waiter':
main.add(self.tab_waiter, text="🍽️ Kelola Pesanan")
self.tab_meja = ttk.Frame(main)
main.add(self.tab_meja, text="🪑 Kelola Meja")
# ==========================================
# ROLE: KASIR (Order + Transaksi SAJA)
# ==========================================
if self.session['role'] == 'kasir':
self.tab_payment = ttk.Frame(main)
main.add(self.tab_payment, text="💰 Transaksi")
self.tab_meja = ttk.Frame(main)
main.add(self.tab_meja, text="🪑 Kelola Meja")
# ==========================================
# ROLE: PEMILIK (Laporan SAJA)
# ==========================================
if self.session['role'] == 'pemilik':
self.tab_report = ttk.Frame(main)
main.add(self.tab_report, text="📊 Laporan")
# ==========================================
# ROLE: ADMIN (Full Setup + Monitoring Read-Only)
# ==========================================
if self.session['role'] == 'admin':
# FULL ACCESS (CRUD)
main.add(self.tab_menu_manage, text="⚙️ Kelola Menu")
main.add(self.tab_promo, text="🎁 Kelola Promo")
self.tab_user_manage = ttk.Frame(main)
main.add(self.tab_user_manage, text="👥 Kelola User")
self.tab_meja = ttk.Frame(main)
main.add(self.tab_meja, text="🪑 Kelola Meja")
# READ-ONLY MONITORING
main.add(self.tab_waiter, text="👁️ Monitor Pesanan") # ← READ-ONLY
self.tab_payment = ttk.Frame(main)
main.add(self.tab_payment, text="👁️ Monitor Transaksi") # ← READ-ONLY
self.tab_report = ttk.Frame(main)
main.add(self.tab_report, text="📊 Laporan")
# ========================================
# BUILD TAB BERDASARKAN ROLE
# ========================================
# Menu View - untuk SEMUA role
self.build_menu_view_tab(self.tab_menu_view)
# Pembeli/User
if self.session['role'] in ['pembeli', 'user']:
self.build_order_tab(self.tab_order)
self.build_favorite_tab(self.tab_favorite)
# Waiter
if self.session['role'] == 'waiter':
self.build_waiter_tab(self.tab_waiter)
self.build_meja_tab(self.tab_meja)
# Kasir
if self.session['role'] == 'kasir':
self.build_order_tab(self.tab_order)
self.build_payment_tab(self.tab_payment)
self.build_meja_tab(self.tab_meja)
# Pemilik
if self.session['role'] == 'pemilik':
self.build_report_tab(self.tab_report)
# Admin - PERBAIKAN DI SINI!
if self.session['role'] == 'admin':
# Master Data (Full CRUD)
self.build_menu_manage_tab(self.tab_menu_manage)
self.build_promo_tab(self.tab_promo)
self.build_user_manage_tab(self.tab_user_manage)
self.build_meja_tab(self.tab_meja)
# Monitoring (Read-Only)
self.build_waiter_tab(self.tab_waiter, readonly=True)
self.build_payment_tab(self.tab_payment, readonly=True)
self.build_report_tab(self.tab_report)
def build_menu_view_tab(self, parent):
for w in parent.winfo_children():
w.destroy()
left = ttk.Frame(parent, width=600)
right = ttk.Frame(parent, width=380)
left.pack(side='left', fill='both', expand=True, padx=6, pady=6)
right.pack(side='right', fill='y', padx=6, pady=6)
filter_frame = ttk.Frame(left)
filter_frame.pack(fill='x', pady=6)
ttk.Label(filter_frame, text="Cari / Nama atau Kategori:").pack(side='left', padx=3)
self.view_search_var = tk.StringVar()
ttk.Entry(filter_frame, textvariable=self.view_search_var, width=30).pack(side='left', padx=3)
ttk.Button(filter_frame, text="Cari", command=self.reload_view_table).pack(side='left', padx=3)
ttk.Button(filter_frame, text="Reset", command=self.reset_view_filters).pack(side='left', padx=3)
ttk.Button(filter_frame, text="Hanya Tersedia", command=lambda: self.reload_view_table(available_only=True)).pack(side='left', padx=6)
cols = ("ID","Nama","Kategori","Harga","Stok","Tersedia","ItemDisc%")
self.view_tree = ttk.Treeview(left, columns=cols, show='headings', height=18)
for c in cols:
self.view_tree.heading(c, text=c)
self.view_tree.column(c, width=90 if c!="Nama" else 200)
self.view_tree.pack(fill='both', expand=True)
self.view_tree.bind("<<TreeviewSelect>>", self.on_view_select)
ttk.Label(right, text="Preview Item", font=("Arial", 12, "bold")).pack(pady=6)
self.preview_label = ttk.Label(right, text="Pilih menu di kiri")
self.preview_label.pack()
self.preview_img_label = ttk.Label(right)
self.preview_img_label.pack(pady=6)
self.preview_detail = tk.Text(right, width=45, height=12)
self.preview_detail.pack()
self.reload_view_table()
def reload_view_table(self, available_only=False):
s = self.view_search_var.get().strip() if hasattr(self, 'view_search_var') else ""
results = menu_list(search_text=s or None, available_only=available_only)
for r in self.view_tree.get_children():
self.view_tree.delete(r)
for row in results:
mid,nama,kategori,harga,stok,foto,tersedia,item_disc = row
self.view_tree.insert("", tk.END, values=(mid,nama,kategori,harga,stok, "Yes" if tersedia else "No", item_disc))
def reset_view_filters(self):
self.view_search_var.set("")
self.reload_view_table()
def on_view_select(self, event):
sel = self.view_tree.selection()
if not sel:
return
item = self.view_tree.item(sel)['values']
menu_id = item[0]
data = menu_get(menu_id)
if not data:
return
mid,nama,kategori,harga,stok,foto,tersedia,item_disc = data
self.preview_detail.delete('1.0', tk.END)
txt = f"ID: {mid}\nNama: {nama}\nKategori: {kategori}\nHarga: {harga}\nStok: {stok}\nTersedia: {'Yes' if tersedia else 'No'}\nItem Discount: {item_disc}%\nFoto path: {foto}\n"
self.preview_detail.insert(tk.END, txt)
if foto and os.path.exists(foto):
try:
img = Image.open(foto)
img.thumbnail(IMG_PREVIEW_SIZE)
tkimg = ImageTk.PhotoImage(img)
self.img_cache['preview'] = tkimg
self.preview_img_label.config(image=tkimg)
except Exception as e:
self.preview_img_label.config(image='')
else:
self.preview_img_label.config(image='')
def build_menu_manage_tab(self, parent):
for w in parent.winfo_children():
w.destroy()
topfrm = ttk.Frame(parent)
topfrm.pack(fill='x', padx=6, pady=6)
ttk.Label(topfrm, text="Kelola Menu", font=("Arial", 14, "bold")).pack(side='left')
ttk.Button(topfrm, text="Tambah Menu", command=self.open_add_menu_window).pack(side='right', padx=6)
cols = ("ID","Nama","Kategori","Harga","Stok","Tersedia","ItemDisc%")
self.manage_tree = ttk.Treeview(parent, columns=cols, show='headings', height=18)
for c in cols:
self.manage_tree.heading(c, text=c)
self.manage_tree.column(c, width=100 if c!="Nama" else 220)
self.manage_tree.pack(fill='both', padx=6, pady=6)
btnfrm = ttk.Frame(parent)
btnfrm.pack(pady=6)
ttk.Button(btnfrm, text="Edit Terpilih", command=self.open_edit_menu_window).pack(side='left', padx=6)
ttk.Button(btnfrm, text="Hapus Terpilih", command=self.delete_selected_menu).pack(side='left', padx=6)
ttk.Button(btnfrm, text="Reload", command=self.reload_manage_table).pack(side='left', padx=6)
self.reload_manage_table()
def reload_manage_table(self):
for r in self.manage_tree.get_children():
self.manage_tree.delete(r)
rows = menu_list()
for row in rows:
mid,nama,kategori,harga,stok,foto,tersedia,item_disc = row
self.manage_tree.insert("", tk.END, values=(mid,nama,kategori,harga,stok,"Yes" if tersedia else "No", item_disc))
def open_add_menu_window(self):
w = tk.Toplevel(self.root)
w.title("Tambah Menu")
frm = ttk.Frame(w,padding=10)
frm.pack()
labels = ["Nama","Kategori","Harga","Stok","Foto path","Item Discount (%)"]
vars = {}
for i,lab in enumerate(labels):
ttk.Label(frm, text=lab).grid(row=i, column=0, sticky='e', pady=4)
vars[lab] = tk.StringVar()
ttk.Entry(frm, textvariable=vars[lab], width=40).grid(row=i, column=1, pady=4)
ttk.Button(frm, text="Pilih Foto", command=lambda: self.select_file(vars["Foto path"])).grid(row=4, column=2, padx=6)
def save():
try:
nama = vars["Nama"].get().strip()
kategori = vars["Kategori"].get().strip()
harga = float(vars["Harga"].get())
stok = int(vars["Stok"].get())
foto = vars["Foto path"].get().strip() or None
item_disc = float(vars["Item Discount (%)"].get() or 0)
except Exception as e:
messagebox.showerror("Input error", "Periksa kembali input (Harga/Stok harus angka)")
return
menu_add(nama,kategori,harga,stok,foto,item_disc)
messagebox.showinfo("Sukses","Menu ditambahkan")
w.destroy()
self.reload_manage_table()
self.reload_view_table()
ttk.Button(frm, text="Simpan", command=save).grid(row=len(labels), column=1, pady=8)
def open_edit_menu_window(self):
sel = self.manage_tree.selection()
if not sel:
messagebox.showwarning("Pilih", "Pilih menu terlebih dahulu")
return
item = self.manage_tree.item(sel)['values']
menu_id = item[0]
data = menu_get(menu_id)
if not data:
messagebox.showerror("Error", "Data menu tidak ditemukan")
return
mid,nama,kategori,harga,stok,foto,tersedia,item_disc = data
w = tk.Toplevel(self.root)
w.title("Edit Menu")
frm = ttk.Frame(w,padding=10)
frm.pack()
labels = ["Nama","Kategori","Harga","Stok","Foto path","Item Discount (%)"]
vars = {}
defaults = [nama,kategori,str(harga),str(stok),foto or "",str(item_disc or 0)]
for i,lab in enumerate(labels):
ttk.Label(frm, text=lab).grid(row=i, column=0, sticky='e', pady=4)
vars[lab] = tk.StringVar(value=defaults[i])
ttk.Entry(frm, textvariable=vars[lab], width=40).grid(row=i, column=1, pady=4)
ttk.Button(frm, text="Pilih Foto", command=lambda: self.select_file(vars["Foto path"])).grid(row=4, column=2, padx=6)
def save():
try:
nama = vars["Nama"].get().strip()
kategori = vars["Kategori"].get().strip()
harga = float(vars["Harga"].get())
stok = int(vars["Stok"].get())
foto = vars["Foto path"].get().strip() or None
item_disc = float(vars["Item Discount (%)"].get() or 0)
except:
messagebox.showerror("Input error", "Periksa input")
return
menu_update(menu_id, nama, kategori, harga, stok, foto, item_disc)
messagebox.showinfo("Sukses","Menu diperbarui")
w.destroy()
self.reload_manage_table()
self.reload_view_table()
ttk.Button(frm, text="Update", command=save).grid(row=len(labels), column=1, pady=8)
def delete_selected_menu(self):
sel = self.manage_tree.selection()
if not sel:
messagebox.showwarning("Pilih", "Pilih menu untuk dihapus")
return
item = self.manage_tree.item(sel)['values']
menu_id = item[0]
if messagebox.askyesno("Konfirmasi", "Hapus menu terpilih?"):
menu_delete(menu_id)
messagebox.showinfo("Dihapus", "Menu berhasil dihapus")
self.reload_manage_table()
self.reload_view_table()
def select_file(self, var):
p = filedialog.askopenfilename(title="Pilih file gambar",
filetypes=[("Image files","*.png;*.jpg;*.jpeg;*.gif;*.bmp"),("All files","*.*")])
if p:
var.set(p)
def build_promo_tab(self, parent):
for w in parent.winfo_children():
w.destroy()
top = ttk.Frame(parent)
top.pack(fill='x', pady=6)
ttk.Label(top, text="Promo Codes", font=("Arial", 14, "bold")).pack(side='left', padx=6)
ttk.Button(top, text="Tambah Promo", command=self.open_add_promo).pack(side='right', padx=6)
cols = ("Code","Type","Value","MinTotal")
self.promo_tree = ttk.Treeview(parent, columns=cols, show='headings', height=12)
for c in cols:
self.promo_tree.heading(c, text=c)
self.promo_tree.column(c, width=120)
self.promo_tree.pack(fill='x', padx=6, pady=6)
btnfrm = ttk.Frame(parent)
btnfrm.pack(pady=6)
ttk.Button(btnfrm, text="Edit Promo", command=self.open_edit_promo).pack(side='left', padx=6)
ttk.Button(btnfrm, text="Hapus Promo", command=self.delete_selected_promo).pack(side='left', padx=6)
ttk.Button(btnfrm, text="Reload", command=self.reload_promo_table).pack(side='left', padx=6)
self.reload_promo_table()
def reload_promo_table(self):
for r in self.promo_tree.get_children():
self.promo_tree.delete(r)
for p in promo_list():
self.promo_tree.insert("", tk.END, values=p)
def open_add_promo(self):
w = tk.Toplevel(self.root)
w.title("Tambah Promo")
w.geometry("350x230")
w.transient(self.root)
w.grab_set()
frm = ttk.Frame(w, padding=15)
frm.pack(fill="both", expand=True)
vars = {
'code': tk.StringVar(),
'type': tk.StringVar(value='percent'),
'value': tk.StringVar(),
'min_total': tk.StringVar(value='0')
}
ttk.Label(frm, text="Code:").grid(row=0, column=0, sticky='e', pady=5)
ttk.Entry(frm, textvariable=vars['code'], width=20).grid(row=0, column=1)
ttk.Label(frm, text="Type (percent/fixed):").grid(row=1, column=0, sticky='e', pady=5)
ttk.Entry(frm, textvariable=vars['type'], width=20).grid(row=1, column=1)
ttk.Label(frm, text="Value:").grid(row=2, column=0, sticky='e', pady=5)
ttk.Entry(frm, textvariable=vars['value'], width=20).grid(row=2, column=1)
ttk.Label(frm, text="Min Total:").grid(row=3, column=0, sticky='e', pady=5)
ttk.Entry(frm, textvariable=vars['min_total'], width=20).grid(row=3, column=1)
def save():
try:
code = vars['code'].get().strip().upper()
ptype = vars['type'].get().strip()
val = float(vars['value'].get())
mt = float(vars['min_total'].get() or 0)
if ptype not in ('percent', 'fixed'):
raise ValueError("type harus 'percent' atau 'fixed'")
except Exception as e:
messagebox.showerror("Error", f"Input salah: {e}")
return
try:
promo_add(code, ptype, val, mt)
messagebox.showinfo("Sukses", "Promo ditambahkan")
w.destroy()
self.reload_promo_table()
except Exception as e:
messagebox.showerror("Error", f"Kode promo sudah ada atau error: {e}")
ttk.Button(frm, text="Simpan", command=save).grid(row=4, column=1, pady=12)
def open_edit_promo(self):
sel = self.promo_tree.selection()
if not sel:
messagebox.showwarning("Pilih", "Pilih promo untuk diedit")
return
code = self.promo_tree.item(sel)['values'][0]
row = promo_get(code)
if not row:
messagebox.showerror("Error", "Promo tidak ditemukan")
return
code, ptype, val, min_total = row
w = tk.Toplevel(self.root)
w.title("Edit Promo")
w.geometry("350x230")
w.transient(self.root)
w.grab_set()
frm = ttk.Frame(w, padding=15)
frm.pack(fill="both", expand=True)
vars = {
'type': tk.StringVar(value=ptype),
'value': tk.StringVar(value=str(val)),
'min_total': tk.StringVar(value=str(min_total))
}
ttk.Label(frm, text=f"Code: {code}", font=("Arial", 10, "bold")).grid(row=0, column=0, columnspan=2, pady=5)
ttk.Label(frm, text="Type (percent/fixed):").grid(row=1, column=0, sticky='e', pady=5)
ttk.Entry(frm, textvariable=vars['type'], width=20).grid(row=1, column=1)
ttk.Label(frm, text="Value:").grid(row=2, column=0, sticky='e', pady=5)
ttk.Entry(frm, textvariable=vars['value'], width=20).grid(row=2, column=1)
ttk.Label(frm, text="Min Total:").grid(row=3, column=0, sticky='e', pady=5)
ttk.Entry(frm, textvariable=vars['min_total'], width=20).grid(row=3, column=1)
def save():
try:
ptype = vars['type'].get().strip()
val = float(vars['value'].get())
mt = float(vars['min_total'].get() or 0)
if ptype not in ('percent', 'fixed'):
raise ValueError("type harus 'percent' atau 'fixed'")
except Exception as e:
messagebox.showerror("Error", f"Input salah: {e}")
return
promo_update(code, ptype, val, mt)
messagebox.showinfo("Sukses", "Promo diperbarui")
w.destroy()
self.reload_promo_table()
ttk.Button(frm, text="Update", command=save).grid(row=4, column=1, pady=12)
def delete_selected_promo(self):
sel = self.promo_tree.selection()
if not sel:
messagebox.showwarning("Pilih", "Pilih promo")
return
code = self.promo_tree.item(sel)['values'][0]
if messagebox.askyesno("Konfirmasi", f"Hapus promo {code}?"):
promo_delete(code)
messagebox.showinfo("Dihapus","Promo terhapus")
self.reload_promo_table()
def build_order_tab(self, parent):
"""Tab untuk order menu dengan tampilan card seperti GrabFood/Gojek"""
for w in parent.winfo_children():
w.destroy()
# Split jadi 2 panel: kiri = menu cards, kanan = cart
left = ttk.Frame(parent, width=600)
right = ttk.Frame(parent, width=380)
left.pack(side='left', fill='both', expand=True, padx=6, pady=6)
right.pack(side='right', fill='both', padx=6, pady=6)
# === PANEL KIRI: Daftar Menu dengan Card ===
ttk.Label(left, text="Daftar Menu", font=("Arial", 12, "bold")).pack(pady=4)
# Filter search
search_frame = ttk.Frame(left)
search_frame.pack(fill='x', pady=4)
ttk.Label(search_frame, text="Cari:").pack(side='left', padx=3)
self.order_search_var = tk.StringVar()
ttk.Entry(search_frame, textvariable=self.order_search_var, width=25).pack(side='left', padx=3)
ttk.Button(search_frame, text="Cari", command=self.reload_order_menu_cards).pack(side='left', padx=3)
ttk.Button(search_frame, text="Reset", command=self.reset_order_search).pack(side='left', padx=3)
# Scrollable frame untuk cards (FULL WIDTH FIX)
canvas = tk.Canvas(left, bg='#f5f5f5', highlightthickness=0)
scrollbar = ttk.Scrollbar(left, orient="vertical", command=canvas.yview)
self.menu_cards_frame = ttk.Frame(canvas)
# 1. Simpan ID Window saat create_window
frame_window_id = canvas.create_window((0, 0), window=self.menu_cards_frame, anchor="nw")
# 2. Fungsi agar scroll region update saat konten bertambah
def configure_scroll_region(event):
canvas.configure(scrollregion=canvas.bbox("all"))
self.menu_cards_frame.bind("<Configure>", configure_scroll_region)
# 3. FUNGSI KUNCI: Paksa frame selebar canvas (Full Width)
def configure_frame_width(event):
canvas.itemconfig(frame_window_id, width=event.width)
canvas.bind("<Configure>", configure_frame_width)
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Bind mouse wheel untuk scroll
def _on_mousewheel(event):
canvas.yview_scroll(int(-1*(event.delta/120)), "units")
canvas.bind_all("<MouseWheel>", _on_mousewheel)
# === PANEL KANAN: Keranjang ===
ttk.Label(right, text="Keranjang Belanja", font=("Arial", 12, "bold")).pack(pady=4)
# Treeview cart
cart_cols = ("Menu", "Qty", "Harga", "Subtotal")
self.cart_tree = ttk.Treeview(right, columns=cart_cols, show='headings', height=8)
for c in cart_cols:
self.cart_tree.heading(c, text=c)
if c == "Menu":
self.cart_tree.column(c, width=150)
else:
self.cart_tree.column(c, width=70)
self.cart_tree.pack(fill='x', pady=4)
# Tombol hapus item atau kosongkan cart
cart_btn_frame = ttk.Frame(right)
cart_btn_frame.pack(pady=4)
ttk.Button(cart_btn_frame, text="Hapus Item", command=self.remove_cart_item).pack(side='left', padx=3)
ttk.Button(cart_btn_frame, text="Kosongkan", command=self.clear_cart).pack(side='left', padx=3)
# Info total
total_frame = ttk.Frame(right)
total_frame.pack(fill='x', pady=4)
self.cart_subtotal_label = ttk.Label(total_frame, text="Subtotal: Rp 0", font=("Arial", 9))
self.cart_subtotal_label.pack()
self.cart_discount_label = ttk.Label(total_frame, text="Diskon Item: Rp 0", font=("Arial", 9))
self.cart_discount_label.pack()
self.cart_promo_label = ttk.Label(total_frame, text="Diskon Promo: Rp 0", font=("Arial", 9))
self.cart_promo_label.pack()
self.cart_total_label = ttk.Label(total_frame, text="TOTAL: Rp 0", font=("Arial", 11, "bold"))
self.cart_total_label.pack(pady=2)
# Input nomor meja dan promo (LAYOUT RAPI)
checkout_frame = ttk.Frame(right)
checkout_frame.pack(fill='x', pady=6, padx=10)
# Row 0: No. Meja (AUTO-FILLED untuk pembeli) ← DIPERBAIKI
ttk.Label(checkout_frame, text="No. Meja:", font=("Arial", 9)).grid(row=0, column=0, sticky='w', padx=3, pady=3)
self.order_meja_var = tk.StringVar()
# ✅ AUTO-FILL jika user adalah pembeli
if self.session['role'] in ['pembeli', 'user'] and 'nomor_meja' in self.session:
self.order_meja_var.set(str(self.session['nomor_meja']))
meja_entry = ttk.Entry(checkout_frame, textvariable=self.order_meja_var, width=20, state='disabled')
# Tambah label info
ttk.Label(checkout_frame, text="", font=("Arial", 10), foreground='green').grid(row=0, column=2, padx=3)
else:
meja_entry = ttk.Entry(checkout_frame, textvariable=self.order_meja_var, width=20)
meja_entry.grid(row=0, column=1, pady=3, sticky='ew')
# Row 1: Kode Promo
ttk.Label(checkout_frame, text="Kode Promo:", font=("Arial", 9)).grid(row=1, column=0, sticky='w', padx=3, pady=3)
self.order_promo_var = tk.StringVar()
ttk.Entry(checkout_frame, textvariable=self.order_promo_var, width=12).grid(row=1, column=1, pady=3, sticky='ew')
ttk.Button(checkout_frame, text="Terapkan", command=self.update_cart_display, width=10).grid(row=1, column=2, padx=3, sticky='e')
# Configure columns
checkout_frame.columnconfigure(1, weight=1)
# Tombol checkout (PASTI KELIHATAN)
checkout_btn_frame = ttk.Frame(right)
checkout_btn_frame.pack(fill='x', pady=10, padx=20)
ttk.Button(
checkout_btn_frame,
text="🛒 CHECKOUT PESANAN",
command=self.checkout_order,
width=30
).pack()
# Init cart data
self.cart_items = []
# Load menu cards
self.reload_order_menu_cards()
def reload_order_menu_cards(self):
"""Load menu dalam bentuk cards 4 kolom FULL WIDTH"""
# Clear existing cards
for widget in self.menu_cards_frame.winfo_children():
widget.destroy()
search = self.order_search_var.get().strip() or None
results = menu_list(search_text=search, available_only=True)
cart_dict = {item['menu_id']: item['qty'] for item in self.cart_items}
# === KONFIGURASI 4 KOLOM AGAR RATA (UNIFORM) ===
total_columns = 4
# 'uniform' membuat lebar setiap kolom identik/sama persis
for i in range(total_columns):
self.menu_cards_frame.columnconfigure(i, weight=1, uniform='menu_col')
# ===============================================
row = 0
col = 0
for menu_data in results:
mid, nama, kategori, harga, stok, foto, tersedia, item_disc = menu_data
# Buat Card (HAPUS width/height fix agar responsif)
card = tk.Frame(
self.menu_cards_frame,
relief='solid',
borderwidth=1,
bg='white',
padx=5, pady=5
)
# sticky='nsew' MEMAKSA card memenuhi kotak grid-nya
card.grid(row=row, column=col, padx=5, pady=5, sticky='nsew')
# --- Bagian Gambar ---
if foto and os.path.exists(foto):
try:
img = Image.open(foto)
# Resize agak kecil agar muat 4 kolom
img = img.resize((120, 85), Image.Resampling.LANCZOS)
photo = ImageTk.PhotoImage(img)
lbl = tk.Label(card, image=photo, bg='white')
lbl.image = photo
lbl.pack(pady=5)
except:
tk.Label(card, text="[No Image]", bg='#eee', height=4).pack(fill='x')
else:
tk.Label(card, text="[No Image]", bg='#eee', height=4).pack(fill='x')
# --- Bagian Info ---
tk.Label(card, text=nama, font=("Arial", 10, "bold"), bg='white', wraplength=110).pack()
tk.Label(card, text=kategori, font=("Arial", 8), fg='gray', bg='white').pack()
txt_harga = f"Rp {harga:,.0f}"
if item_disc > 0:
txt_harga += f"\n(-{item_disc}%)"
fg = '#4CAF50'
else:
fg = 'green'
tk.Label(card, text=txt_harga, font=("Arial", 9, "bold"), fg=fg, bg='white').pack(pady=2)
color = 'blue' if stok > 0 else 'red'
tk.Label(card, text=f"Stok: {stok}", font=("Arial", 8), fg=color, bg='white').pack()
# --- Bagian Tombol ---
qty = cart_dict.get(mid, 0)
act_frame = tk.Frame(card, bg='white')
# Pack side bottom agar tombol selalu di bawah card
act_frame.pack(pady=5, fill='x', side='bottom')
if qty > 0:
tk.Button(act_frame, text="-", bg='#FF5722', fg='white', width=2, borderwidth=0,
command=lambda m=mid: self.decrease_from_card(m)).pack(side='left', padx=2)
tk.Label(act_frame, text=str(qty), bg='white').pack(side='left', expand=True)
tk.Button(act_frame, text="+", bg='#4CAF50', fg='white', width=2, borderwidth=0,
command=lambda m=mid, s=stok: self.increase_from_card(m, s)).pack(side='right', padx=2)
else:
tk.Button(act_frame, text="+ Tambah", bg='#4CAF50', fg='white', borderwidth=0,
command=lambda m=mid, s=stok: self.increase_from_card(m, s)).pack(fill='x')
# --- Logika Pindah Baris ---
col += 1
if col >= total_columns:
col = 0
row += 1
# Reset row weight
for i in range(row + 1):
self.menu_cards_frame.rowconfigure(i, weight=0)
def reset_order_search(self):
self.order_search_var.set("")
self.reload_order_menu_cards()
def increase_from_card(self, menu_id, stok):
"""Tambah qty dari tombol + di card"""
# Cek qty saat ini
current_qty = 0
cart_item_found = None
for cart_item in self.cart_items:
if cart_item['menu_id'] == menu_id:
current_qty = cart_item['qty']
cart_item_found = cart_item
break
# Cek stok
if current_qty >= stok:
messagebox.showwarning("Stok Habis", f"Stok hanya tersisa {stok}")
return
# Tambah qty
if cart_item_found:
cart_item_found['qty'] += 1
else:
self.cart_items.append({'menu_id': menu_id, 'qty': 1})
# Update tampilan
self.reload_order_menu_cards()
self.update_cart_display()
def decrease_from_card(self, menu_id):
"""Kurangi qty dari tombol - di card"""
for i, cart_item in enumerate(self.cart_items):
if cart_item['menu_id'] == menu_id:
cart_item['qty'] -= 1
# Kalau qty jadi 0, hapus dari cart
if cart_item['qty'] <= 0:
del self.cart_items[i]
# Update tampilan
self.reload_order_menu_cards()
self.update_cart_display()
return
# Wilayah dikuasai Favorite
def build_favorite_tab(self, parent):
"""Tab untuk melihat menu favorit dan history pesanan"""
for w in parent.winfo_children():
w.destroy()
# Header
header = ttk.Frame(parent)
header.pack(fill='x', padx=10, pady=8)
ttk.Label(header, text="🌟 Menu Favorit & History", font=("Arial", 14, "bold")).pack(side='left')
ttk.Button(header, text="🔄 Refresh", command=self.reload_favorite_tab).pack(side='right', padx=6)
# Split 2 panel: kiri = favorit, kanan = history
left = ttk.LabelFrame(parent, text="⭐ Menu Favorit (Top 5)", padding=10)
left.pack(side='left', fill='both', expand=True, padx=10, pady=6)
right = ttk.LabelFrame(parent, text="📜 History Pesanan Terakhir", padding=10)
right.pack(side='right', fill='both', expand=True, padx=10, pady=6)
# === PANEL KIRI: Menu Favorit ===
# Treeview favorit
fav_cols = ("Rank", "Menu", "Kategori", "Harga", "Dipesan", "Terakhir")
self.favorite_tree = ttk.Treeview(left, columns=fav_cols, show='headings', height=12)
self.favorite_tree.heading("Rank", text="#")
self.favorite_tree.heading("Menu", text="Menu")
self.favorite_tree.heading("Kategori", text="Kategori")
self.favorite_tree.heading("Harga", text="Harga")
self.favorite_tree.heading("Dipesan", text="Dipesan")
self.favorite_tree.heading("Terakhir", text="Terakhir")
self.favorite_tree.column("Rank", width=40)
self.favorite_tree.column("Menu", width=150)
self.favorite_tree.column("Kategori", width=90)
self.favorite_tree.column("Harga", width=80)
self.favorite_tree.column("Dipesan", width=70)
self.favorite_tree.column("Terakhir", width=140)
self.favorite_tree.pack(fill='both', expand=True, pady=6)
# Tombol quick order
fav_btn_frame = ttk.Frame(left)
fav_btn_frame.pack(pady=6)
ttk.Label(fav_btn_frame, text="Quick Order:").pack(side='left', padx=6)
ttk.Button(fav_btn_frame, text="🛒 Pesan Lagi", command=self.quick_order_favorite).pack(side='left', padx=3)
# === PANEL KANAN: History Transaksi ===
# Treeview history
hist_cols = ("ID", "Tanggal", "Meja", "Total", "Status")
self.history_tree = ttk.Treeview(right, columns=hist_cols, show='headings', height=12)
self.history_tree.heading("ID", text="ID")
self.history_tree.heading("Tanggal", text="Tanggal")
self.history_tree.heading("Meja", text="Meja")
self.history_tree.heading("Total", text="Total")
self.history_tree.heading("Status", text="Status")
self.history_tree.column("ID", width=40)
self.history_tree.column("Tanggal", width=140)
self.history_tree.column("Meja", width=60)
self.history_tree.column("Total", width=100)
self.history_tree.column("Status", width=90)
self.history_tree.pack(fill='both', expand=True, pady=6)
# Bind event untuk lihat detail
self.history_tree.bind("<<TreeviewSelect>>", self.on_history_select)
# Detail history
detail_frame = ttk.Frame(right)
detail_frame.pack(fill='x', pady=6)
self.history_detail_text = tk.Text(detail_frame, height=8, font=("Courier New", 8), wrap='word')
hist_scroll = ttk.Scrollbar(detail_frame, orient='vertical', command=self.history_detail_text.yview)
self.history_detail_text.configure(yscrollcommand=hist_scroll.set)
self.history_detail_text.pack(side='left', fill='both', expand=True)
hist_scroll.pack(side='right', fill='y')
# Tombol history action
hist_btn_frame = ttk.Frame(right)
hist_btn_frame.pack(pady=6)
ttk.Button(hist_btn_frame, text="🔁 Pesan Ulang", command=self.reorder_from_history).pack(side='left', padx=3)
# Load data
self.reload_favorite_tab()
def reload_favorite_tab(self):
"""Load data favorit dan history"""
# Clear trees
for r in self.favorite_tree.get_children():
self.favorite_tree.delete(r)
for r in self.history_tree.get_children():
self.history_tree.delete(r)
# Load favorit
favorites = favorite_list(self.session['id'], limit=5)
rank = 1
for fav in favorites:
menu_id, count, last_ordered = fav
menu_data = menu_get(menu_id)
if not menu_data:
continue
_, nama, kategori, harga, stok, foto, tersedia, item_disc = menu_data
self.favorite_tree.insert("", tk.END, values=(
rank,
nama,
kategori,
f"Rp {harga:,.0f}",
f"{count}x",
last_ordered
))
rank += 1
# Load history transaksi
history = transaksi_list(user_id=self.session['id'])
for h in history:
tid, uid, meja, total, status, promo_code, tanggal = h
self.history_tree.insert("", tk.END, values=(
tid,
tanggal,
meja,
f"Rp {total:,.0f}",
status
))
def on_history_select(self, event):
"""Tampilkan detail history saat dipilih"""
sel = self.history_tree.selection()
if not sel:
return
item = self.history_tree.item(sel)['values']
transaksi_id = item[0]
# Get detail transaksi
transaksi_data = transaksi_get(transaksi_id)
if not transaksi_data:
return
tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data
detail_items = detail_transaksi_list(transaksi_id)
# Format detail
detail_text = f"TRANSAKSI #{tid} - {status.upper()}\n"
detail_text += f"{'='*40}\n"
detail_text += f"Tanggal: {tanggal}\n"
detail_text += f"Meja: {meja}\n\n"
detail_text += f"Item Pesanan:\n"
detail_text += f"{'-'*40}\n"
for detail in detail_items:
did, mid, qty, harga, subtotal_item = detail
menu_data = menu_get(mid)
if menu_data:
_, nama, kategori, _, _, _, _, _ = menu_data
detail_text += f"{nama}\n"
detail_text += f" {qty} x Rp {harga:,.0f} = Rp {subtotal_item:,.0f}\n"
detail_text += f"{'-'*40}\n"
detail_text += f"Subtotal: Rp {subtotal:,.0f}\n"
detail_text += f"Diskon: Rp {item_disc + promo_disc:,.0f}\n"
detail_text += f"TOTAL: Rp {total:,.0f}\n"
self.history_detail_text.delete('1.0', tk.END)
self.history_detail_text.insert('1.0', detail_text)
def quick_order_favorite(self):
"""Pesan ulang dari menu favorit yang dipilih"""
sel = self.favorite_tree.selection()
if not sel:
messagebox.showwarning("Pilih Menu", "Pilih menu favorit terlebih dahulu")
return
item = self.favorite_tree.item(sel)['values']
menu_name = item[1]
# Cari menu_id dari nama
all_menus = menu_list()
menu_id = None
for m in all_menus:
if m[1] == menu_name:
menu_id = m[0]
break
if not menu_id:
messagebox.showerror("Error", "Menu tidak ditemukan")
return
# Tambah ke cart (qty 1)
menu_data = menu_get(menu_id)
if not menu_data:
return
_, nama, kategori, harga, stok, foto, tersedia, item_disc = menu_data
if stok < 1:
messagebox.showwarning("Stok Habis", f"Stok {nama} habis")
return
# Cek apakah sudah ada di cart
found = False
for cart_item in self.cart_items:
if cart_item['menu_id'] == menu_id:
if cart_item['qty'] < stok:
cart_item['qty'] += 1
found = True
else:
messagebox.showwarning("Stok Habis", f"Stok {nama} hanya {stok}")
return
break
if not found:
self.cart_items.append({'menu_id': menu_id, 'qty': 1})
messagebox.showinfo("Ditambahkan", f"{nama} ditambahkan ke keranjang!\n\nSilakan ke tab 'Order Menu' untuk checkout.")
def reorder_from_history(self):
"""Pesan ulang semua item dari history yang dipilih"""
sel = self.history_tree.selection()
if not sel:
messagebox.showwarning("Pilih History", "Pilih history pesanan terlebih dahulu")
return
item = self.history_tree.item(sel)['values']
transaksi_id = item[0]
# Get detail items
detail_items = detail_transaksi_list(transaksi_id)
if not detail_items:
messagebox.showerror("Error", "Tidak ada detail pesanan")
return
# Tambah semua item ke cart
added_count = 0
for detail in detail_items:
did, mid, qty, harga, subtotal_item = detail
# Cek stok
menu_data = menu_get(mid)
if not menu_data:
continue
_, nama, kategori, harga_now, stok, foto, tersedia, item_disc = menu_data
if stok < qty:
messagebox.showwarning("Stok Kurang", f"Stok {nama} hanya {stok}, pesanan asli {qty}")
qty = stok
if qty <= 0:
continue
# Tambah ke cart
found = False
for cart_item in self.cart_items:
if cart_item['menu_id'] == mid:
new_qty = cart_item['qty'] + qty
if new_qty <= stok:
cart_item['qty'] = new_qty
found = True
added_count += 1
break
if not found:
self.cart_items.append({'menu_id': mid, 'qty': qty})
added_count += 1
if added_count > 0:
messagebox.showinfo("Berhasil", f"{added_count} item ditambahkan ke keranjang!\n\nSilakan ke tab 'Order Menu' untuk checkout.")
else:
messagebox.showwarning("Gagal", "Tidak ada item yang bisa ditambahkan (stok habis)")
def update_cart_display(self):
"""Update tampilan keranjang dan hitung total"""
# Clear tree
for r in self.cart_tree.get_children():
self.cart_tree.delete(r)
# Tampilkan item
for cart_item in self.cart_items:
menu_data = menu_get(cart_item['menu_id'])
if not menu_data:
print(f"WARNING: Menu ID {cart_item['menu_id']} tidak ditemukan!")
continue
_, nama, kategori, harga, stok, foto, tersedia, item_disc = menu_data
qty = cart_item['qty']
subtotal = harga * qty
# DEBUG
print(f"Menambahkan ke tree: {nama} x{qty} = {subtotal}")
self.cart_tree.insert("", tk.END, values=(nama, qty, f"{harga:,.0f}", f"{subtotal:,.0f}"))
# Hitung total dengan diskon
promo_code = self.order_promo_var.get().strip() or None
if self.cart_items: # PENTING: Hanya hitung kalau ada item
calc = apply_discounts_and_promo(self.cart_items, promo_code)
self.cart_subtotal_label.config(text=f"Subtotal: Rp {calc['subtotal']:,.0f}")
self.cart_discount_label.config(text=f"Diskon Item: Rp {calc['item_discount']:,.0f}")
self.cart_promo_label.config(text=f"Diskon Promo: Rp {calc['promo_discount']:,.0f}")
self.cart_total_label.config(text=f"TOTAL: Rp {calc['total']:,.0f}")
else:
# Reset label kalau cart kosong
self.cart_subtotal_label.config(text="Subtotal: Rp 0")
self.cart_discount_label.config(text="Diskon Item: Rp 0")
self.cart_promo_label.config(text="Diskon Promo: Rp 0")
self.cart_total_label.config(text="TOTAL: Rp 0")
def remove_cart_item(self):
"""Hapus item dari cart"""
sel = self.cart_tree.selection()
if not sel:
messagebox.showwarning("Pilih Item", "Pilih item di keranjang")
return
idx = self.cart_tree.index(sel)
if idx >= len(self.cart_items):
return
del self.cart_items[idx]
self.update_cart_display()
self.reload_order_menu_cards()
def clear_cart(self):
"""Kosongkan keranjang"""
if not self.cart_items:
return
if messagebox.askyesno("Konfirmasi", "Kosongkan keranjang?"):
self.cart_items = []
self.update_cart_display()
self.reload_order_menu_cards()
def checkout_order(self):
"""Simpan pesanan ke database"""
if not self.cart_items:
messagebox.showwarning("Keranjang Kosong", "Tambahkan item terlebih dahulu")
return
nomor_meja = self.order_meja_var.get().strip()
if not nomor_meja:
messagebox.showwarning("Input Error", "Masukkan nomor meja")
return
try:
nomor_meja = int(nomor_meja)
except:
messagebox.showerror("Input Error", "Nomor meja harus angka")
return
promo_code = self.order_promo_var.get().strip() or None
# Validasi promo
if promo_code:
promo_data = promo_get(promo_code)
if not promo_data:
messagebox.showwarning("Promo Invalid", "Kode promo tidak ditemukan")
return
# Simpan transaksi
success, result = transaksi_add(self.session['id'], nomor_meja, self.cart_items, promo_code)
if success:
meja_update_status(nomor_meja, "terisi", result)
messagebox.showinfo("Sukses", f"Pesanan berhasil! ID Transaksi: {result}\nStatus: Pending")
# Reset
self.cart_items = []
self.order_meja_var.set("")
self.order_promo_var.set("")
self.update_cart_display()
self.reload_order_menu_cards() # Refresh stok
else:
messagebox.showerror("Error", f"Gagal menyimpan pesanan: {result}")
# Wilayah dikuasai Waiter
def build_waiter_tab(self, parent, readonly=False):
"""Tab untuk waiter mengelola pesanan
Status Flow yang Disederhanakan:
pending → menunggu → selesai
"""
for w in parent.winfo_children():
w.destroy()
# Header
header = ttk.Frame(parent)
header.pack(fill='x', padx=10, pady=6)
if readonly:
ttk.Label(
header,
text="👁️ Monitor Pesanan (Read-Only)",
font=("Arial", 13, "bold"),
foreground='orange'
).pack(side='left')
else:
ttk.Label(
header,
text="🍽️ Dashboard Waiter - Kelola Pesanan",
font=("Arial", 13, "bold")
).pack(side='left')
ttk.Button(
header,
text="🔄 Refresh",
command=self.reload_waiter_orders
).pack(side='right', padx=6)
# Filter status
filter_frame = ttk.Frame(parent)
filter_frame.pack(fill='x', padx=10, pady=4)
ttk.Label(filter_frame, text="Filter Status:").pack(side='left', padx=6)
ttk.Button(filter_frame, text="Semua", command=lambda: self.reload_waiter_orders(None), width=8).pack(side='left', padx=2)
ttk.Button(filter_frame, text="Pending", command=lambda: self.reload_waiter_orders('pending'), width=8).pack(side='left', padx=2)
ttk.Button(filter_frame, text="Diproses", command=lambda: self.reload_waiter_orders('menunggu'), width=10).pack(side='left', padx=2)
ttk.Button(filter_frame, text="Selesai", command=lambda: self.reload_waiter_orders('selesai'), width=8).pack(side='left', padx=2)
# Treeview pesanan
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill='x', padx=10, pady=4)
cols = ("ID", "User ID", "No.Meja", "Total", "Status", "Promo", "Tanggal")
self.waiter_tree = ttk.Treeview(tree_frame, columns=cols, show='headings', height=8)
self.waiter_tree.heading("ID", text="ID")
self.waiter_tree.heading("User ID", text="User ID")
self.waiter_tree.heading("No.Meja", text="No.Meja")
self.waiter_tree.heading("Total", text="Total")
self.waiter_tree.heading("Status", text="Status")
self.waiter_tree.heading("Promo", text="Promo")
self.waiter_tree.heading("Tanggal", text="Tanggal")
self.waiter_tree.column("ID", width=40)
self.waiter_tree.column("User ID", width=60)
self.waiter_tree.column("No.Meja", width=70)
self.waiter_tree.column("Total", width=100)
self.waiter_tree.column("Status", width=90)
self.waiter_tree.column("Promo", width=80)
self.waiter_tree.column("Tanggal", width=140)
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical', command=self.waiter_tree.yview)
self.waiter_tree.configure(yscrollcommand=tree_scroll.set)
self.waiter_tree.pack(side='left', fill='both', expand=True)
tree_scroll.pack(side='right', fill='y')
self.waiter_tree.bind("<<TreeviewSelect>>", self.on_waiter_select)
# Detail pesanan
detail_frame = ttk.LabelFrame(parent, text="📋 Detail Pesanan", padding=8)
detail_frame.pack(fill='x', padx=10, pady=4)
text_frame = ttk.Frame(detail_frame)
text_frame.pack(fill='both', expand=True)
self.waiter_detail_text = tk.Text(
text_frame,
height=6,
width=100,
font=("Courier New", 8),
wrap='word'
)
detail_scroll = ttk.Scrollbar(text_frame, orient='vertical', command=self.waiter_detail_text.yview)
self.waiter_detail_text.configure(yscrollcommand=detail_scroll.set)
detail_scroll.pack(side='right', fill='y')
self.waiter_detail_text.pack(side='left', fill='both', expand=True)
# TOMBOL AKSI - DENGAN STATE MANAGEMENT
action_frame = ttk.LabelFrame(parent, text="🎯 Update Status Pesanan", padding=12)
action_frame.pack(fill='x', padx=10, pady=8)
if readonly:
warning_label = ttk.Label(
action_frame,
text="⚠️ MODE READ-ONLY: Anda hanya bisa melihat data, tidak bisa mengubah status.\n"
"Hanya Waiter yang bisa mengubah status pesanan.",
font=("Arial", 9),
foreground='red',
justify='center'
)
warning_label.pack(pady=10)
# Grid untuk 2 tombol
btn_frame = ttk.Frame(action_frame)
btn_frame.pack(fill='x', pady=10)
self.waiter_btn_terima = tk.Button(
btn_frame,
text="✅ Terima Pesanan (Pending → Diproses)",
command=lambda: self.update_order_status('menunggu') if not readonly else None,
font=("Arial", 10, "bold"),
relief='raised',
cursor='hand2',
state='disabled' if readonly else 'normal',
bg='#4CAF50',
fg='white',
activebackground='#45a049',
padx=10,
pady=10
)
self.waiter_btn_terima.pack(fill='x', padx=5, pady=5)
self.waiter_btn_proses = tk.Button(
btn_frame,
text="🍳 Pesanan Siap Disajikan (Diproses → Selesai)",
command=lambda: self.update_order_status('selesai') if not readonly else None,
font=("Arial", 10, "bold"),
relief='raised',
cursor='hand2',
state='disabled' if readonly else 'normal',
bg='#2196F3',
fg='white',
activebackground='#0b7dda',
padx=10,
pady=10
)
self.waiter_btn_proses.pack(fill='x', padx=5, pady=5)
# ===== INFO BOX - STATUS FLOW =====
info_box = tk.Frame(
action_frame,
bg='#E3F2FD',
relief='solid',
borderwidth=2,
padx=15,
pady=12
)
info_box.pack(fill='x', pady=10)
tk.Label(
info_box,
text="📌 ALUR STATUS PESANAN",
font=("Arial", 10, "bold"),
bg='#E3F2FD',
fg='#1565C0'
).pack(anchor='w', pady=(0, 8))
tk.Label(
info_box,
text="Pending (Baru) → Diproses (Sedang Disiapkan) → Selesai (Siap Disajikan)",
font=("Arial", 9, "bold"),
bg='#E3F2FD',
fg='#0D47A1',
wraplength=500,
justify='center'
).pack(anchor='w', pady=(0, 8))
tk.Label(
info_box,
text="💡 Tombol akan otomatis berubah warna sesuai status pesanan.\n"
"Tombol berwarna cerah bisa diklik, yang abu-abu tidak.",
font=("Arial", 8),
bg='#E3F2FD',
fg='#424242',
justify='center',
wraplength=500
).pack(anchor='w')
# Load data
self.reload_waiter_orders()
def reload_waiter_orders(self, status_filter=None):
"""Load pesanan untuk waiter"""
# Clear tree
for r in self.waiter_tree.get_children():
self.waiter_tree.delete(r)
# Get transaksi
if status_filter:
orders = transaksi_list(status=status_filter)
else:
# Tampilkan semua kecuali yang sudah dibayar
all_orders = transaksi_list()
orders = [o for o in all_orders if o[4] != 'dibayar']
# Insert ke tree
for order in orders:
tid, uid, meja, total, status, promo_code, tanggal = order
promo_display = promo_code if promo_code else "-"
self.waiter_tree.insert("", tk.END, values=(
tid, uid, meja, f"Rp {total:,.0f}", status, promo_display, tanggal
))
def on_waiter_select(self, event):
"""Tampilkan detail pesanan saat dipilih"""
sel = self.waiter_tree.selection()
if not sel:
return
item = self.waiter_tree.item(sel)['values']
transaksi_id = item[0]
# Get detail transaksi
transaksi_data = transaksi_get(transaksi_id)
if not transaksi_data:
return
tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data
# Get detail items
detail_items = detail_transaksi_list(transaksi_id)
# Format detail
detail_text = f"═══════════════════════════════════════════════\n"
detail_text += f"TRANSAKSI ID: {tid}\n"
detail_text += f"═══════════════════════════════════════════════\n\n"
detail_text += f"User ID : {uid}\n"
detail_text += f"Nomor Meja : {meja}\n"
detail_text += f"Status : {status.upper()}\n"
detail_text += f"Tanggal : {tanggal}\n"
detail_text += f"Kode Promo : {promo_code if promo_code else '-'}\n\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"ITEM PESANAN:\n"
detail_text += f"───────────────────────────────────────────────\n"
for detail in detail_items:
did, mid, qty, harga, subtotal_item = detail
# Get nama menu
menu_data = menu_get(mid)
if menu_data:
_, nama, kategori, _, _, _, _, _ = menu_data
detail_text += f"{nama} ({kategori})\n"
detail_text += f" {qty} x Rp {harga:,.0f} = Rp {subtotal_item:,.0f}\n\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"Subtotal : Rp {subtotal:,.0f}\n"
detail_text += f"Diskon Item : Rp {item_disc:,.0f}\n"
detail_text += f"Diskon Promo : Rp {promo_disc:,.0f}\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"TOTAL BAYAR : Rp {total:,.0f}\n"
detail_text += f"═══════════════════════════════════════════════\n"
# Tampilkan di text widget
self.waiter_detail_text.delete('1.0', tk.END)
self.waiter_detail_text.insert('1.0', detail_text)
def update_order_status(self, new_status):
"""Update status pesanan dengan visual feedback
Status Flow: pending → menunggu → selesai
"""
sel = self.waiter_tree.selection()
if not sel:
messagebox.showwarning("Pilih Pesanan", "Pilih pesanan terlebih dahulu")
return
item = self.waiter_tree.item(sel)['values']
transaksi_id = item[0]
current_status = item[4]
# Validasi flow status
valid_transitions = {
'pending': ['menunggu'], # Pending → Diproses
'menunggu': ['selesai'], # Diproses → Selesai
'selesai': [] # Selesai = FINAL
}
# Cek current status valid
if current_status not in valid_transitions:
messagebox.showerror(
"Error",
f"❌ Status '{current_status}' sudah final!\n\n"
f"Pesanan ini sudah siap disajikan."
)
return
# Cek status sudah final
if not valid_transitions[current_status]:
messagebox.showerror(
"Status Final",
f"❌ Pesanan sudah mencapai status 'SELESAI'\n\n"
f"Tidak ada lagi yang perlu dilakukan waiter."
)
return
# Cek new_status valid
if new_status not in valid_transitions[current_status]:
expected = ', '.join(valid_transitions[current_status])
messagebox.showwarning(
"Status Tidak Valid",
f"❌ Tidak bisa ubah dari '{current_status}' ke '{new_status}'.\n\n"
f"✅ Status yang benar setelah '{current_status}' adalah:\n"
f"{expected}"
)
return
# Mapping status untuk pesan
status_messages = {
'pending': 'Pesanan Baru',
'menunggu': 'Sedang Disiapkan',
'selesai': 'Siap Disajikan'
}
current_msg = status_messages.get(current_status, current_status.upper())
new_msg = status_messages.get(new_status, new_status.upper())
# Konfirmasi
confirm_msg = f"Ubah status pesanan #{transaksi_id}?\n\n"
confirm_msg += f"Dari: {current_msg}\n"
confirm_msg += f"Ke: {new_msg}\n\n"
if new_status == 'selesai':
confirm_msg += "⚠️ Setelah ini, pesanan siap disajikan ke customer.\n"
confirm_msg += "Customer dapat langsung diminta untuk bayar."
elif new_status == 'menunggu':
confirm_msg += "Pesanan akan dimasukkan ke daftar yang sedang disiapkan."
confirm_msg += "\n\nLanjutkan?"
if not messagebox.askyesno("Konfirmasi", confirm_msg):
return
# Update status di database
success = transaksi_update_status(transaksi_id, new_status)
if success:
# Pesan sukses
if new_status == 'menunggu':
success_msg = (
f"✅ Pesanan #{transaksi_id} sedang disiapkan.\n\n"
f"Tim dapur: mulai siapkan pesanan ini!"
)
elif new_status == 'selesai':
success_msg = (
f"✅ Pesanan #{transaksi_id} siap disajikan!\n\n"
f"Silakan bawa ke meja customer."
)
else:
success_msg = f"✅ Status berhasil diubah menjadi '{new_status.upper()}'"
messagebox.showinfo("Berhasil", success_msg)
# ✅ RELOAD DAN UPDATE BUTTON STATE
self.reload_waiter_orders()
self.update_waiter_button_state()
# Auto select row yang sama
for item_id in self.waiter_tree.get_children():
if self.waiter_tree.item(item_id)['values'][0] == transaksi_id:
self.waiter_tree.selection_set(item_id)
self.waiter_tree.see(item_id)
self.on_waiter_select(None)
break
else:
messagebox.showerror("❌ Gagal", "Gagal mengubah status pesanan")
def update_waiter_button_state(self):
"""Update state tombol berdasarkan status pesanan yang dipilih
Logika:
- Pending: Tombol TERIMA aktif, tombol PROSES disabled
- Diproses: Tombol TERIMA disabled, tombol PROSES aktif
- Selesai: Semua tombol disabled (pesanan sudah final)
"""
sel = self.waiter_tree.selection()
if not sel:
# Tidak ada pesanan dipilih: semua disabled
self.waiter_btn_terima.config(state='disabled', bg='#cccccc', fg='#666666')
self.waiter_btn_proses.config(state='disabled', bg='#cccccc', fg='#666666')
return
item = self.waiter_tree.item(sel)['values']
current_status = item[4] # Status adalah field ke-4 (index 4)
# ===== LOGIC UNTUK SET STATE TOMBOL =====
if current_status == 'pending':
# Status PENDING: Tombol TERIMA aktif, PROSES disabled
# Tombol TERIMA - AKTIF (Hijau)
self.waiter_btn_terima.config(
state='normal',
bg='#4CAF50',
fg='white',
cursor='hand2',
relief='raised',
activebackground='#45a049'
)
# Tombol PROSES - DISABLED (Gelap)
self.waiter_btn_proses.config(
state='disabled',
bg='#A9A9A9',
fg='#555555',
cursor='arrow',
relief='sunken'
)
elif current_status == 'menunggu':
# Status DIPROSES: Tombol TERIMA disabled, PROSES aktif
# Tombol TERIMA - DISABLED
self.waiter_btn_terima.config(
state='disabled',
bg='#A9A9A9',
fg='#555555',
cursor='arrow',
relief='sunken'
)
# Tombol PROSES - AKTIF (Biru)
self.waiter_btn_proses.config(
state='normal',
bg='#2196F3',
fg='white',
cursor='hand2',
relief='raised',
activebackground='#0b7dda'
)
elif current_status == 'selesai':
# Status SELESAI: Semua tombol DISABLED (FINAL STATE)
self.waiter_btn_terima.config(
state='disabled',
bg='#D3D3D3',
fg='#777777',
cursor='arrow',
relief='sunken'
)
self.waiter_btn_proses.config(
state='disabled',
bg='#D3D3D3',
fg='#777777',
cursor='arrow',
relief='sunken'
)
# ========================================
# ON WAITER SELECT - CALL UPDATE BUTTON STATE
# ========================================
def on_waiter_select(self, event):
"""Tampilkan detail pesanan + UPDATE BUTTON STATE"""
sel = self.waiter_tree.selection()
if not sel:
return
item = self.waiter_tree.item(sel)['values']
transaksi_id = item[0]
# Get detail transaksi
transaksi_data = transaksi_get(transaksi_id)
if not transaksi_data:
return
tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data
detail_items = detail_transaksi_list(transaksi_id)
# Format detail
detail_text = f"═══════════════════════════════════════════════\n"
detail_text += f"TRANSAKSI ID: {tid}\n"
detail_text += f"═══════════════════════════════════════════════\n\n"
detail_text += f"User ID : {uid}\n"
detail_text += f"Nomor Meja : {meja}\n"
detail_text += f"Status : {status.upper()}\n"
detail_text += f"Tanggal : {tanggal}\n"
detail_text += f"Kode Promo : {promo_code if promo_code else '-'}\n\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"ITEM PESANAN:\n"
detail_text += f"───────────────────────────────────────────────\n"
for detail in detail_items:
did, mid, qty, harga, subtotal_item = detail
menu_data = menu_get(mid)
if menu_data:
_, nama, kategori, _, _, _, _, _ = menu_data
detail_text += f"{nama} ({kategori})\n"
detail_text += f" {qty} x Rp {harga:,.0f} = Rp {subtotal_item:,.0f}\n\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"Subtotal : Rp {subtotal:,.0f}\n"
detail_text += f"Diskon Item : Rp {item_disc:,.0f}\n"
detail_text += f"Diskon Promo : Rp {promo_disc:,.0f}\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"TOTAL BAYAR : Rp {total:,.0f}\n"
detail_text += f"═══════════════════════════════════════════════\n"
# Tampilkan di text widget
self.waiter_detail_text.delete('1.0', tk.END)
self.waiter_detail_text.insert('1.0', detail_text)
# ✅ UPDATE BUTTON STATE SESUAI STATUS PESANAN
self.update_waiter_button_state()
# Wilayah dikuasai Favorite
def favorite_update(user_id, menu_id):
"""Update atau tambah favorite count untuk user tertentu"""
from datetime import datetime
rows = read_all(FAVORITE_CSV)
found = False
for r in rows:
if r.get("user_id") == str(user_id) and r.get("menu_id") == str(menu_id):
# Update count
try:
count = int(r.get("order_count") or 0)
except:
count = 0
r["order_count"] = str(count + 1)
r["last_ordered"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
found = True
break
if not found:
# Tambah baru
rows.append({
"user_id": str(user_id),
"menu_id": str(menu_id),
"order_count": "1",
"last_ordered": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
write_all(FAVORITE_CSV, ["user_id", "menu_id", "order_count", "last_ordered"], rows)
def favorite_list(user_id, limit=5):
"""Ambil menu favorit user, sorted by order_count descending"""
rows = read_all(FAVORITE_CSV)
out = []
for r in rows:
if r.get("user_id") == str(user_id):
try:
mid = int(r.get("menu_id") or 0)
except:
mid = r.get("menu_id")
try:
count = int(r.get("order_count") or 0)
except:
count = 0
last_ordered = r.get("last_ordered")
out.append((mid, count, last_ordered))
# Sort by count descending
out.sort(key=lambda x: x[1], reverse=True)
# Limit results
if limit:
out = out[:limit]
return out
def favorite_all(limit=10):
"""Ambil menu paling populer dari semua user"""
rows = read_all(FAVORITE_CSV)
menu_counts = {}
for r in rows:
menu_id = r.get("menu_id")
try:
count = int(r.get("order_count") or 0)
except:
count = 0
if menu_id in menu_counts:
menu_counts[menu_id] += count
else:
menu_counts[menu_id] = count
# Convert to list dan sort
out = []
for menu_id, total_count in menu_counts.items():
try:
mid = int(menu_id)
except:
mid = menu_id
out.append((mid, total_count))
out.sort(key=lambda x: x[1], reverse=True)
if limit:
out = out[:limit]
return out
# WILAYAH DIKUASAI PEMBAYARAN & MEJA
# === FUNGSI PEMBAYARAN ===
def pembayaran_add(transaksi_id, metode_pembayaran, jumlah_bayar, status_pembayaran='sukses', struk=''):
"""Simpan data pembayaran baru"""
from datetime import datetime
rows = read_all(PEMBAYARAN_CSV)
new_id = next_int_id(rows, "id")
tanggal_bayar = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rows.append({
"id": new_id,
"transaksi_id": str(transaksi_id),
"metode_pembayaran": metode_pembayaran,
"jumlah_bayar": str(float(jumlah_bayar)),
"status_pembayaran": status_pembayaran,
"tanggal_bayar": tanggal_bayar,
"struk": struk
})
write_all(PEMBAYARAN_CSV, ["id", "transaksi_id", "metode_pembayaran", "jumlah_bayar", "status_pembayaran", "tanggal_bayar", "struk"], rows)
return new_id
def pembayaran_get_by_transaksi(transaksi_id):
"""Ambil data pembayaran berdasarkan transaksi_id"""
rows = read_all(PEMBAYARAN_CSV)
for r in rows:
if r.get("transaksi_id") == str(transaksi_id):
try:
pid = int(r.get("id") or 0)
except:
pid = r.get("id")
try:
jumlah = float(r.get("jumlah_bayar") or 0.0)
except:
jumlah = 0.0
return (pid, r.get("metode_pembayaran"), jumlah, r.get("status_pembayaran"), r.get("tanggal_bayar"), r.get("struk"))
return None
def pembayaran_list(status=None, metode=None):
"""Ambil semua pembayaran dengan filter opsional"""
rows = read_all(PEMBAYARAN_CSV)
out = []
for r in rows:
if status and r.get("status_pembayaran") != status:
continue
if metode and r.get("metode_pembayaran") != metode:
continue
try:
pid = int(r.get("id") or 0)
except:
pid = r.get("id")
try:
tid = int(r.get("transaksi_id") or 0)
except:
tid = r.get("transaksi_id")
try:
jumlah = float(r.get("jumlah_bayar") or 0.0)
except:
jumlah = 0.0
out.append((pid, tid, r.get("metode_pembayaran"), jumlah, r.get("status_pembayaran"), r.get("tanggal_bayar")))
out.sort(key=lambda x: int(x[0]), reverse=True)
return out
# === FUNGSI MEJA ===
def meja_list(status=None):
"""Ambil daftar meja dengan filter status opsional"""
rows = read_all(MEJA_CSV)
out = []
for r in rows:
if status and r.get("status") != status:
continue
try:
nomor = int(r.get("nomor_meja") or 0)
except:
nomor = r.get("nomor_meja")
transaksi_id = r.get("transaksi_id") or ""
out.append((nomor, r.get("status"), transaksi_id))
out.sort(key=lambda x: int(x[0]) if isinstance(x[0], int) else 0)
return out
def meja_get(nomor_meja):
"""Ambil data meja berdasarkan nomor"""
rows = read_all(MEJA_CSV)
for r in rows:
if r.get("nomor_meja") == str(nomor_meja):
try:
nomor = int(r.get("nomor_meja") or 0)
except:
nomor = r.get("nomor_meja")
transaksi_id = r.get("transaksi_id") or ""
return (nomor, r.get("status"), transaksi_id)
return None
def meja_update_status(nomor_meja, new_status, transaksi_id=""):
"""Update status meja (kosong/terisi) dan link ke transaksi"""
rows = read_all(MEJA_CSV)
found = False
for r in rows:
if r.get("nomor_meja") == str(nomor_meja):
r["status"] = new_status
r["transaksi_id"] = str(transaksi_id) if transaksi_id else ""
found = True
break
if found:
write_all(MEJA_CSV, ["nomor_meja", "status", "transaksi_id"], rows)
return True
return False
def meja_tutup(nomor_meja):
"""Tutup meja (set ke kosong)"""
return meja_update_status(nomor_meja, "kosong", "")
def meja_buka(nomor_meja, transaksi_id):
"""Buka meja (set ke terisi dengan transaksi_id)"""
return meja_update_status(nomor_meja, "terisi", transaksi_id)
def build_payment_tab(self, parent, readonly=False):
"""Tab pembayaran untuk kasir & admin
Args:
parent: Parent frame
readonly: Jika True, form disabled (untuk admin monitoring)
"""
for w in parent.winfo_children():
w.destroy()
# Header
header = ttk.Frame(parent)
header.pack(fill='x', padx=10, pady=8)
# ✅ UBAH JUDUL SESUAI MODE
if readonly:
ttk.Label(
header,
text="👁️ Monitor Transaksi (Read-Only)",
font=("Arial", 14, "bold"),
foreground='orange'
).pack(side='left')
else:
ttk.Label(
header,
text="💰 Pembayaran Transaksi",
font=("Arial", 14, "bold")
).pack(side='left')
ttk.Button(
header,
text="🔄 Refresh",
command=self.reload_payment_orders
).pack(side='right', padx=6)
# Search by nomor meja (TETAP AKTIF untuk admin)
search_frame = ttk.LabelFrame(parent, text="🔍 Cari Transaksi by Nomor Meja", padding=10)
search_frame.pack(fill='x', padx=10, pady=6)
search_inner = ttk.Frame(search_frame)
search_inner.pack()
ttk.Label(search_inner, text="Nomor Meja:", font=("Arial", 9)).grid(row=0, column=0, padx=5)
self.search_meja_var = tk.StringVar()
ttk.Entry(search_inner, textvariable=self.search_meja_var, width=15).grid(row=0, column=1, padx=5)
ttk.Button(
search_inner,
text="🔍 Cari Tagihan",
command=self.search_by_meja,
style="Accent.TButton"
).grid(row=0, column=2, padx=5)
ttk.Button(
search_inner,
text="🔄 Tampilkan Semua",
command=self.reload_payment_orders
).grid(row=0, column=3, padx=5)
# Summary penjualan hari ini (untuk kasir & admin)
if self.session['role'] in ['kasir', 'admin']:
summary_frame = ttk.LabelFrame(parent, text="📊 Penjualan Hari Ini", padding=10)
summary_frame.pack(fill='x', padx=10, pady=6)
summary_inner = ttk.Frame(summary_frame)
summary_inner.pack()
from datetime import datetime
today_income = 0
today_count = 0
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
all_transaksi = transaksi_list(status='dibayar')
for trx in all_transaksi:
tid, uid, meja, total, status, promo_code, tanggal = trx
try:
trx_date = datetime.strptime(tanggal, "%Y-%m-%d %H:%M:%S")
if trx_date >= today_start:
today_income += total
today_count += 1
except:
pass
ttk.Label(summary_inner, text="Total Transaksi Hari Ini:", font=("Arial", 10)).grid(row=0, column=0, sticky='w', padx=10, pady=3)
ttk.Label(summary_inner, text=str(today_count), font=("Arial", 10, "bold"), foreground='blue').grid(row=0, column=1, sticky='w', padx=10, pady=3)
ttk.Label(summary_inner, text="Total Pendapatan Hari Ini:", font=("Arial", 10)).grid(row=1, column=0, sticky='w', padx=10, pady=3)
ttk.Label(summary_inner, text=f"Rp {today_income:,.0f}", font=("Arial", 10, "bold"), foreground='green').grid(row=1, column=1, sticky='w', padx=10, pady=3)
# Container utama
main_container = ttk.Frame(parent)
main_container.pack(fill='both', expand=True, padx=10, pady=6)
# Panel Kiri: Daftar & Detail Transaksi (Menggunakan PanedWindow)
left = ttk.LabelFrame(main_container, text="📋 Daftar & Detail Transaksi", padding=5)
left.pack(side='left', fill='both', expand=True, padx=(0, 5))
# 1. Buat PanedWindow (Splitter Vertical)
paned = ttk.PanedWindow(left, orient='vertical')
paned.pack(fill='both', expand=True)
# --- BAGIAN ATAS: TREEVIEW (DAFTAR TRANSAKSI) ---
tree_frame = ttk.Frame(paned)
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical')
tree_scroll.pack(side='right', fill='y')
cols = ("ID", "Meja", "Total", "Status", "Tanggal")
self.payment_tree = ttk.Treeview(
tree_frame,
columns=cols,
show='headings',
height=8,
yscrollcommand=tree_scroll.set
)
tree_scroll.config(command=self.payment_tree.yview)
self.payment_tree.heading("ID", text="ID")
self.payment_tree.heading("Meja", text="Meja")
self.payment_tree.heading("Total", text="Total")
self.payment_tree.heading("Status", text="Status")
self.payment_tree.heading("Tanggal", text="Tanggal")
self.payment_tree.column("ID", width=50)
self.payment_tree.column("Meja", width=60)
self.payment_tree.column("Total", width=100)
self.payment_tree.column("Status", width=80)
self.payment_tree.column("Tanggal", width=120)
self.payment_tree.pack(side='left', fill='both', expand=True)
self.payment_tree.bind("<<TreeviewSelect>>", self.on_payment_select)
# Tambahkan frame tree ke panel atas (bobot besar)
paned.add(tree_frame, weight=3)
# --- BAGIAN BAWAH: DETAIL TRANSAKSI ---
detail_frame = ttk.Frame(paned)
# Label instruksi kecil
ttk.Label(detail_frame, text="Detail Transaksi (Geser batas atas untuk memperbesar):", font=("Arial", 9, "bold")).pack(anchor='w', pady=(5,0))
# Area Text & Scrollbar
text_container = ttk.Frame(detail_frame)
text_container.pack(fill='both', expand=True)
# Scrollbar WAJIB dipack duluan sebelum Text
detail_scroll = ttk.Scrollbar(text_container, orient='vertical')
detail_scroll.pack(side='right', fill='y')
self.payment_detail_text = tk.Text(text_container, height=8, font=("Courier New", 9), wrap='word')
self.payment_detail_text.configure(yscrollcommand=detail_scroll.set)
detail_scroll.config(command=self.payment_detail_text.yview)
self.payment_detail_text.pack(side='left', fill='both', expand=True)
# Tambahkan frame detail ke panel bawah (bobot kecil)
paned.add(detail_frame, weight=1)
# ✅ JIKA READ-ONLY, HANYA TAMPILKAN INFO, TANPA FORM PEMBAYARAN
if readonly:
readonly_frame = ttk.LabelFrame(main_container, text=" Informasi", padding=20)
readonly_frame.pack(side='right', fill='both', expand=True, padx=(5, 0))
warning_text = (
"⚠️ MODE READ-ONLY\n\n"
"Anda hanya bisa melihat data transaksi.\n\n"
"Untuk memproses pembayaran, silakan login sebagai KASIR.\n\n"
"Admin hanya bisa monitoring transaksi untuk audit dan kontrol."
)
ttk.Label(
readonly_frame,
text=warning_text,
font=("Arial", 10),
foreground='red',
justify='center'
).pack(expand=True)
else:
# Panel Kanan: Form Pembayaran (HANYA UNTUK KASIR)
right_outer = ttk.LabelFrame(main_container, text="💳 Form Pembayaran", padding=5)
right_outer.pack(side='right', fill='both', expand=True, padx=(5, 0))
wrapper = ttk.Frame(right_outer)
wrapper.pack(fill='both', expand=True)
scrollbar = ttk.Scrollbar(wrapper, orient='vertical')
scrollbar.pack(side='right', fill='y')
canvas = tk.Canvas(wrapper, yscrollcommand=scrollbar.set, highlightthickness=0, height=450)
canvas.pack(side='left', fill='both', expand=True)
scrollbar.config(command=canvas.yview)
right = ttk.Frame(canvas)
canvas_window = canvas.create_window((0, 0), window=right, anchor='nw')
def configure_scroll(event):
canvas.configure(scrollregion=canvas.bbox('all'))
right.bind('<Configure>', configure_scroll)
def configure_canvas(event):
canvas.itemconfig(canvas_window, width=event.width)
canvas.bind('<Configure>', configure_canvas)
def on_mousewheel(event):
canvas.yview_scroll(-1 * int(event.delta / 120), "units")
canvas.bind_all('<MouseWheel>', on_mousewheel)
# Info transaksi
info_frame = ttk.Frame(right)
info_frame.pack(fill='x', pady=15, padx=15)
self.selected_transaksi_label = ttk.Label(
info_frame,
text="Belum ada transaksi dipilih",
font=("Arial", 10, "bold"),
foreground='red'
)
self.selected_transaksi_label.pack()
self.selected_total_label = ttk.Label(
info_frame,
text="Total: Rp 0",
font=("Arial", 12, "bold"),
foreground='green'
)
self.selected_total_label.pack(pady=4)
ttk.Separator(right, orient='horizontal').pack(fill='x', pady=15, padx=15)
# Metode pembayaran
method_frame = ttk.Frame(right)
method_frame.pack(fill='x', pady=10, padx=15)
ttk.Label(method_frame, text="💳 Pilih Metode Pembayaran:", font=("Arial", 10, "bold")).pack(anchor='w', pady=6)
self.payment_method_var = tk.StringVar(value='cash')
ttk.Radiobutton(method_frame, text="💵 Cash", variable=self.payment_method_var, value='cash', command=self.on_payment_method_change).pack(anchor='w', pady=3)
ttk.Radiobutton(method_frame, text="📱 QRIS", variable=self.payment_method_var, value='qris', command=self.on_payment_method_change).pack(anchor='w', pady=3)
ttk.Radiobutton(method_frame, text="💳 E-Wallet (GoPay/OVO/Dana)", variable=self.payment_method_var, value='ewallet', command=self.on_payment_method_change).pack(anchor='w', pady=3)
ttk.Separator(right, orient='horizontal').pack(fill='x', pady=15, padx=15)
# Frame input dinamis
self.payment_input_frame = ttk.Frame(right)
self.payment_input_frame.pack(fill='x', pady=10, padx=15)
self.build_cash_input()
ttk.Separator(right, orient='horizontal').pack(fill='x', pady=15, padx=15)
# Tombol
btn_frame = ttk.Frame(right)
btn_frame.pack(fill='x', pady=20, padx=15)
ttk.Button(
btn_frame,
text="✅ PROSES PEMBAYARAN",
command=self.process_payment,
style="Accent.TButton"
).pack(ipadx=30, ipady=10)
for i in range(20):
ttk.Label(right, text="").pack()
# Load data
self.reload_payment_orders()
def search_by_meja(self):
"""Cari transaksi berdasarkan nomor meja - FITUR BARU!"""
nomor_meja = self.search_meja_var.get().strip()
if not nomor_meja:
messagebox.showwarning("Input Error", "Masukkan nomor meja")
return
try:
nomor_meja = int(nomor_meja)
except:
messagebox.showerror("Input Error", "Nomor meja harus angka")
return
# Validasi range meja
if nomor_meja < 1 or nomor_meja > 10:
messagebox.showwarning("Invalid", "Nomor meja harus 1-10")
return
# Clear tree
for r in self.payment_tree.get_children():
self.payment_tree.delete(r)
# Get transaksi selesai untuk meja ini
orders = transaksi_list(status='selesai')
found = False
for order in orders:
tid, uid, meja, total, status, promo_code, tanggal = order
# Filter by nomor meja
if meja != nomor_meja:
continue
# Cek belum dibayar
payment_data = pembayaran_get_by_transaksi(tid)
if payment_data:
continue # Skip yang sudah dibayar
self.payment_tree.insert(
"",
tk.END,
values=(tid, meja, f"Rp {total:,.0f}", status, tanggal)
)
found = True
if not found:
messagebox.showinfo(
"Tidak Ditemukan",
f"❌ Tidak ada tagihan aktif untuk Meja {nomor_meja}\n\n"
f"Kemungkinan:\n"
f"• Meja belum pesan atau pesanan belum selesai\n"
f"• Tagihan sudah dibayar\n"
f"• Meja tidak ada pesanan"
)
# Tampilkan semua transaksi lagi
self.reload_payment_orders()
else:
messagebox.showinfo(
"✅ Ditemukan",
f"Tagihan untuk Meja {nomor_meja} berhasil ditemukan!\n\n"
f"Silakan pilih transaksi dan proses pembayaran."
)
def build_report_tab(self, parent):
"""Tab laporan penjualan untuk admin/pemilik"""
for w in parent.winfo_children():
w.destroy()
# Header
header = ttk.Frame(parent)
header.pack(fill='x', padx=10, pady=8)
ttk.Label(header, text="📊 Laporan Penjualan", font=("Arial", 14, "bold")).pack(side='left')
ttk.Button(header, text="🔄 Refresh", command=self.reload_report).pack(side='right', padx=6)
# Filter frame
filter_frame = ttk.LabelFrame(parent, text="🔍 Filter Laporan", padding=10)
filter_frame.pack(fill='x', padx=10, pady=6)
# Row 0: Periode
ttk.Label(filter_frame, text="Periode:").grid(row=0, column=0, sticky='w', padx=5, pady=4)
self.report_period_var = tk.StringVar(value='harian')
period_frame = ttk.Frame(filter_frame)
period_frame.grid(row=0, column=1, sticky='w', padx=5, pady=4)
ttk.Radiobutton(period_frame, text="Hari Ini", variable=self.report_period_var, value='harian').pack(side='left', padx=5)
ttk.Radiobutton(period_frame, text="Minggu Ini", variable=self.report_period_var, value='mingguan').pack(side='left', padx=5)
ttk.Radiobutton(period_frame, text="Bulan Ini", variable=self.report_period_var, value='bulanan').pack(side='left', padx=5)
# Row 1: Metode Pembayaran
ttk.Label(filter_frame, text="Metode:").grid(row=1, column=0, sticky='w', padx=5, pady=4)
self.report_method_var = tk.StringVar(value='semua')
method_combo = ttk.Combobox(filter_frame, textvariable=self.report_method_var, width=20, state='readonly')
method_combo['values'] = (
'Semua',
'Cash',
'Qris',
'Ewallet-gopay',
'Ewallet-ovo',
'Ewallet-dana',
'Ewallet-shopeepay'
)
method_combo.grid(row=1, column=1, sticky='w', padx=5, pady=4)
# Tombol generate
ttk.Button(
filter_frame,
text="📊 Generate Laporan",
command=self.generate_report,
style="Accent.TButton"
).grid(row=2, column=0, columnspan=2, pady=10)
ttk.Button(
filter_frame,
text="💾 Export Laporan",
command=self.export_report_to_file,
style="Accent.TButton"
).grid(row=3, column=0, pady=(10, 5))
ttk.Button(
filter_frame,
text="🖼️ Export Grafik (PNG)",
command=self.export_report_chart,
style="Accent.TButton"
).grid(row=4, column=0, pady=5)
# Summary frame
summary_frame = ttk.LabelFrame(parent, text="📈 Ringkasan", padding=10)
summary_frame.pack(fill='x', padx=10, pady=6)
summary_inner = ttk.Frame(summary_frame)
summary_inner.pack()
# Labels untuk ringkasan
ttk.Label(summary_inner, text="Total Transaksi:").grid(row=0, column=0, sticky='w', padx=10, pady=3)
self.report_total_trx_label = ttk.Label(summary_inner, text="0", font=("Arial", 10, "bold"))
self.report_total_trx_label.grid(row=0, column=1, sticky='w', padx=10, pady=3)
ttk.Label(summary_inner, text="Total Pendapatan:").grid(row=1, column=0, sticky='w', padx=10, pady=3)
self.report_total_income_label = ttk.Label(
summary_inner,
text="Rp 0",
font=("Arial", 10, "bold"),
foreground='green'
)
self.report_total_income_label.grid(row=1, column=1, sticky='w', padx=10, pady=3)
ttk.Label(summary_inner, text="Rata-rata/Transaksi:").grid(row=2, column=0, sticky='w', padx=10, pady=3)
self.report_avg_label = ttk.Label(summary_inner, text="Rp 0", font=("Arial", 10, "bold"))
self.report_avg_label.grid(row=2, column=1, sticky='w', padx=10, pady=3)
# Detail tabel
detail_frame = ttk.LabelFrame(parent, text="📋 Detail Transaksi", padding=10)
detail_frame.pack(fill='both', expand=True, padx=10, pady=6)
# Treeview
tree_frame = ttk.Frame(detail_frame)
tree_frame.pack(fill='both', expand=True)
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical')
tree_scroll.pack(side='right', fill='y')
cols = ("ID", "Tanggal", "Meja", "Total", "Metode", "Status")
self.report_tree = ttk.Treeview(
tree_frame,
columns=cols,
show='headings',
height=10,
yscrollcommand=tree_scroll.set
)
tree_scroll.config(command=self.report_tree.yview)
self.report_tree.heading("ID", text="ID")
self.report_tree.heading("Tanggal", text="Tanggal")
self.report_tree.heading("Meja", text="Meja")
self.report_tree.heading("Total", text="Total")
self.report_tree.heading("Metode", text="Metode")
self.report_tree.heading("Status", text="Status")
self.report_tree.column("ID", width=50)
self.report_tree.column("Tanggal", width=140)
self.report_tree.column("Meja", width=60)
self.report_tree.column("Total", width=100)
self.report_tree.column("Metode", width=120)
self.report_tree.column("Status", width=80)
self.report_tree.pack(side='left', fill='both', expand=True)
# Tombol grafik
btn_frame = ttk.Frame(parent)
btn_frame.pack(pady=10)
ttk.Button(btn_frame, text="📊 Tampilkan Grafik", command=self.show_report_chart).pack()
def build_user_manage_tab(self, parent):
"""Tab kelola user (admin bisa tambah/edit kasir)"""
for w in parent.winfo_children():
w.destroy()
# Header
header = ttk.Frame(parent)
header.pack(fill='x', padx=10, pady=8)
ttk.Label(header, text="👥 Kelola User & Kasir", font=("Arial", 14, "bold")).pack(side='left')
ttk.Button(header, text=" Tambah User", command=self.open_add_user_window).pack(side='right', padx=6)
# Treeview user
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill='both', expand=True, padx=10, pady=6)
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical')
tree_scroll.pack(side='right', fill='y')
cols = ("ID", "Username", "Role")
self.user_tree = ttk.Treeview(tree_frame, columns=cols, show='headings', height=15, yscrollcommand=tree_scroll.set)
tree_scroll.config(command=self.user_tree.yview)
self.user_tree.heading("ID", text="ID")
self.user_tree.heading("Username", text="Username")
self.user_tree.heading("Role", text="Role")
self.user_tree.column("ID", width=80)
self.user_tree.column("Username", width=200)
self.user_tree.column("Role", width=150)
self.user_tree.pack(side='left', fill='both', expand=True)
# Tombol aksi
btn_frame = ttk.Frame(parent)
btn_frame.pack(pady=10)
ttk.Button(btn_frame, text="✏️ Edit User", command=self.open_edit_user_window).pack(side='left', padx=5)
ttk.Button(btn_frame, text="🗑️ Hapus User", command=self.delete_selected_user).pack(side='left', padx=5)
ttk.Button(btn_frame, text="🔄 Refresh", command=self.reload_user_table).pack(side='left', padx=5)
# Load data
self.reload_user_table()
def reload_user_table(self):
"""Reload tabel user"""
# Clear tree
for r in self.user_tree.get_children():
self.user_tree.delete(r)
# Get users
users = read_all(USERS_CSV)
for user in users:
uid = user.get('id')
username = user.get('username')
role = user.get('role')
self.user_tree.insert("", tk.END, values=(uid, username, role))
def open_add_user_window(self):
"""Popup untuk tambah user baru"""
w = tk.Toplevel(self.root)
w.title(" Tambah User Baru")
w.geometry("400x280")
w.transient(self.root)
w.grab_set()
frm = ttk.Frame(w, padding=15)
frm.pack(fill='both', expand=True)
ttk.Label(frm, text="Tambah User Baru", font=("Arial", 12, "bold")).pack(pady=10)
# Username
ttk.Label(frm, text="Username:").pack(anchor='w', pady=(10, 2))
username_var = tk.StringVar()
ttk.Entry(frm, textvariable=username_var, width=30).pack(fill='x', pady=(0, 10))
# Password
ttk.Label(frm, text="Password:").pack(anchor='w', pady=2)
password_var = tk.StringVar()
ttk.Entry(frm, textvariable=password_var, show="*", width=30).pack(fill='x', pady=(0, 10))
# Role
ttk.Label(frm, text="Role:").pack(anchor='w', pady=2)
role_var = tk.StringVar(value='kasir')
role_frame = ttk.Frame(frm)
role_frame.pack(fill='x', pady=(0, 10))
ttk.Radiobutton(role_frame, text="Kasir", variable=role_var, value='kasir').pack(side='left', padx=5)
ttk.Radiobutton(role_frame, text="Waiter", variable=role_var, value='waiter').pack(side='left', padx=5)
ttk.Radiobutton(role_frame, text="Pembeli", variable=role_var, value='pembeli').pack(side='left', padx=5)
def save_user():
username = username_var.get().strip()
password = password_var.get().strip()
role = role_var.get()
if not username or not password:
messagebox.showerror("Error", "Username dan password harus diisi!")
return
# Cek username sudah ada
users = read_all(USERS_CSV)
for u in users:
if u.get('username') == username:
messagebox.showerror("Error", f"Username '{username}' sudah digunakan!")
return
# Tambah user
new_id = next_int_id(users, 'id')
users.append({
'id': new_id,
'username': username,
'password': password,
'role': role
})
write_all(USERS_CSV, ["id", "username", "password", "role"], users)
messagebox.showinfo("Sukses", f"User '{username}' berhasil ditambahkan!")
w.destroy()
self.reload_user_table()
ttk.Button(frm, text="💾 Simpan", command=save_user, style="Accent.TButton").pack(pady=15)
def open_edit_user_window(self):
"""Popup untuk edit user"""
sel = self.user_tree.selection()
if not sel:
messagebox.showwarning("Pilih User", "Pilih user yang akan diedit")
return
item = self.user_tree.item(sel)['values']
user_id = item[0]
# Get user data
users = read_all(USERS_CSV)
user_data = None
for u in users:
if u.get('id') == str(user_id):
user_data = u
break
if not user_data:
messagebox.showerror("Error", "User tidak ditemukan")
return
w = tk.Toplevel(self.root)
w.title("✏️ Edit User")
w.geometry("400x280")
w.transient(self.root)
w.grab_set()
frm = ttk.Frame(w, padding=15)
frm.pack(fill='both', expand=True)
ttk.Label(frm, text=f"Edit User: {user_data.get('username')}", font=("Arial", 12, "bold")).pack(pady=10)
# Password baru
ttk.Label(frm, text="Password Baru (kosongkan jika tidak diubah):").pack(anchor='w', pady=2)
password_var = tk.StringVar()
ttk.Entry(frm, textvariable=password_var, show="*", width=30).pack(fill='x', pady=(0, 10))
# Role
ttk.Label(frm, text="Role:").pack(anchor='w', pady=2)
role_var = tk.StringVar(value=user_data.get('role'))
role_frame = ttk.Frame(frm)
role_frame.pack(fill='x', pady=(0, 10))
ttk.Radiobutton(role_frame, text="Kasir", variable=role_var, value='kasir').pack(side='left', padx=5)
ttk.Radiobutton(role_frame, text="Waiter", variable=role_var, value='waiter').pack(side='left', padx=5)
ttk.Radiobutton(role_frame, text="Pembeli", variable=role_var, value='pembeli').pack(side='left', padx=5)
ttk.Radiobutton(role_frame, text="Admin", variable=role_var, value='admin').pack(side='left', padx=5)
def update_user():
new_password = password_var.get().strip()
new_role = role_var.get()
# Update user
for u in users:
if u.get('id') == str(user_id):
if new_password:
u['password'] = new_password
u['role'] = new_role
break
write_all(USERS_CSV, ["id", "username", "password", "role"], users)
messagebox.showinfo("Sukses", "User berhasil diupdate!")
w.destroy()
self.reload_user_table()
ttk.Button(frm, text="💾 Update", command=update_user, style="Accent.TButton").pack(pady=15)
def delete_selected_user(self):
"""Hapus user yang dipilih"""
sel = self.user_tree.selection()
if not sel:
messagebox.showwarning("Pilih User", "Pilih user yang akan dihapus")
return
item = self.user_tree.item(sel)['values']
user_id = item[0]
username = item[1]
# Cek jangan hapus admin
if username == 'admin':
messagebox.showerror("Error", "Tidak bisa menghapus user admin!")
return
# Konfirmasi
if not messagebox.askyesno("Konfirmasi", f"Hapus user '{username}'?"):
return
# Hapus
users = read_all(USERS_CSV)
users = [u for u in users if u.get('id') != str(user_id)]
write_all(USERS_CSV, ["id", "username", "password", "role"], users)
messagebox.showinfo("Sukses", f"User '{username}' berhasil dihapus!")
self.reload_user_table()
def reload_report(self):
"""Reload data laporan"""
self.generate_report()
def generate_report(self):
"""Generate laporan dengan data terstruktur"""
from datetime import datetime
# Clear tree lama
for r in self.report_tree.get_children():
self.report_tree.delete(r)
period = self.report_period_var.get()
method_filter = self.report_method_var.get().lower()
try:
# GUNAKAN HELPER FUNCTIONS
if period == 'harian':
report_data = report_get_daily_summary(method_filter=method_filter)
elif period == 'mingguan':
report_data = report_get_weekly_summary(method_filter=method_filter)
else: # bulanan
report_data = report_get_monthly_summary(method_filter=method_filter)
except Exception as e:
messagebox.showerror("Error", f"Gagal generate laporan: {e}")
return
# Update summary labels dari report_data (BUKAN manual count!)
self.report_total_trx_label.config(
text=str(report_data['total_transaksi'])
)
self.report_total_income_label.config(
text=format_currency(report_data['total_pendapatan'])
)
self.report_avg_label.config(
text=format_currency(report_data['rata_rata'])
)
# Populate tree dari payment details
for payment in report_data.get('details', []):
pid, tid, metode, jumlah, status, tanggal = payment
nomor_meja = "-"
trx_data = transaksi_get(tid) # Ambil data transaksi berdasarkan ID
if trx_data:
# trx_data = (tid, uid, meja, total, status, ...)
nomor_meja = trx_data[2] # Index 2 adalah nomor meja
# ----------------------------------
self.report_tree.insert("", tk.END, values=(
tid,
format_datetime(tanggal),
nomor_meja, # <--- Ganti "-" dengan variabel nomor_meja
format_currency(jumlah),
metode.upper() if metode else "-",
status
))
def export_report_to_file(self):
"""Export laporan ke file text - FUNGSI BARU"""
from datetime import datetime
period = self.report_period_var.get()
try:
# Get report data
if period == 'harian':
report_data = report_get_daily_summary()
elif period == 'mingguan':
report_data = report_get_weekly_summary()
else:
report_data = report_get_monthly_summary()
except:
messagebox.showerror("Error", "Belum ada data laporan. Generate terlebih dahulu!")
return
# Export ke text
text = report_export_to_text(report_data, report_type=period)
# Save ke file
filename = f"LAPORAN_{period}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(text)
messagebox.showinfo(
"✅ Berhasil",
f"Laporan berhasil disimpan:\n{filename}\n\nBuka file untuk melihat detail lengkap."
)
except Exception as e:
messagebox.showerror("❌ Error", f"Gagal menyimpan laporan: {e}")
def show_report_chart(self):
"""Tampilkan grafik laporan dengan breakdown metode + status"""
try:
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from datetime import datetime
# Get data from tree
if not self.report_tree.get_children():
messagebox.showwarning("Tidak Ada Data", "Generate laporan terlebih dahulu")
return
# Collect data untuk breakdown
metode_counts = {}
status_counts = {} # ← TAMBAH INI (NEW)
total_per_day = {}
for item_id in self.report_tree.get_children():
values = self.report_tree.item(item_id)['values']
tid, tanggal, meja, total_str, metode, status = values
# Parse total
total = float(total_str.replace('Rp ', '').replace(',', '').replace('.', ''))
# Count by metode
metode_counts[metode] = metode_counts.get(metode, 0) + 1
# NEW: Count by status pembayaran
status_key = status.upper()
status_counts[status_key] = status_counts.get(status_key, 0) + 1
# Sum by date
try:
date_obj = datetime.strptime(tanggal, "%Y-%m-%d %H:%M:%S")
date_key = date_obj.strftime("%Y-%m-%d")
total_per_day[date_key] = total_per_day.get(date_key, 0) + total
except:
pass
# Create window
chart_window = tk.Toplevel(self.root)
chart_window.title("📊 Grafik Laporan Penjualan")
chart_window.geometry("1000x650")
# Create figure with 3 subplots (atau 2 baris x 2 kolom)
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle('📊 LAPORAN PENJUALAN - DASHBOARD GRAFIK', fontsize=16, fontweight='bold')
# ===== CHART 1: Pie chart metode pembayaran (KIRI ATAS) =====
if metode_counts:
labels = list(metode_counts.keys())
sizes = list(metode_counts.values())
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8']
explode = (0.05,) * len(labels) # Sedikit separation
ax1.pie(sizes, labels=labels, autopct='%1.1f%%', colors=colors,
startangle=90, explode=explode, shadow=True)
ax1.set_title('💳 Transaksi per Metode Pembayaran',
fontsize=12, fontweight='bold', pad=20)
# ===== CHART 2: Pie chart status pembayaran (KANAN ATAS) - NEW! =====
if status_counts:
labels_status = list(status_counts.keys())
sizes_status = list(status_counts.values())
# Warna per status
color_map = {
'SUKSES': '#4CAF50',
'GAGAL': '#F44336',
'PENDING': '#FFC107',
'DIBAYAR': '#2196F3'
}
colors_status = [color_map.get(label, '#757575') for label in labels_status]
ax2.pie(sizes_status, labels=labels_status, autopct='%1.1f%%',
colors=colors_status, startangle=90, shadow=True)
ax2.set_title('✓ Breakdown Status Pembayaran',
fontsize=12, fontweight='bold', pad=20)
# ===== CHART 3: Bar chart pendapatan per hari (KIRI BAWAH) =====
if total_per_day:
dates = sorted(total_per_day.keys())
totals = [total_per_day[d] for d in dates]
date_labels = [
datetime.strptime(d, "%Y-%m-%d").strftime("%d/%m")
for d in dates
]
bars = ax3.bar(date_labels, totals, color='#4ECDC4', edgecolor='#00796B', linewidth=1.5)
# Add value labels on top of bars
for bar in bars:
height = bar.get_height()
ax3.text(bar.get_x() + bar.get_width()/2., height,
f'Rp{height/1000:.0f}K',
ha='center', va='bottom', fontsize=9)
ax3.set_xlabel('Tanggal', fontweight='bold')
ax3.set_ylabel('Pendapatan (Rp)', fontweight='bold')
ax3.set_title('💰 Pendapatan Harian', fontsize=12, fontweight='bold', pad=20)
ax3.tick_params(axis='x', rotation=45)
ax3.yaxis.set_major_formatter(
plt.FuncFormatter(lambda x, p: f'Rp {x/1000:.0f}K')
)
ax3.grid(axis='y', alpha=0.3, linestyle='--')
# ===== CHART 4: Kombinasi metode + status (KANAN BAWAH) - NEW! =====
# Buat tabel summary
ax4.axis('off')
summary_text = "📋 RINGKASAN TRANSAKSI\n"
summary_text += "" * 40 + "\n\n"
summary_text += "💳 METODE PEMBAYARAN:\n"
summary_text += "" * 40 + "\n"
for metode, count in sorted(metode_counts.items()):
pct = (count / sum(metode_counts.values()) * 100) if metode_counts else 0
summary_text += f" {metode:.<25} {count:>3} ({pct:>5.1f}%)\n"
summary_text += "\n✓ STATUS PEMBAYARAN:\n"
summary_text += "" * 40 + "\n"
for status, count in sorted(status_counts.items()):
pct = (count / sum(status_counts.values()) * 100) if status_counts else 0
summary_text += f" {status:.<25} {count:>3} ({pct:>5.1f}%)\n"
summary_text += "\n" + "" * 40 + "\n"
summary_text += f"TOTAL TRANSAKSI: {sum(metode_counts.values())}\n"
ax4.text(0.05, 0.95, summary_text, transform=ax4.transAxes,
fontsize=9, verticalalignment='top', fontfamily='monospace',
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.3))
plt.tight_layout()
# Embed in Tkinter
canvas = FigureCanvasTkAgg(fig, master=chart_window)
canvas.draw()
canvas.get_tk_widget().pack(fill='both', expand=True)
ttk.Button(chart_window, text="Tutup", command=chart_window.destroy).pack(pady=10)
except ImportError:
# Fallback: Tampilkan grafik ASCII sederhana
self.show_text_chart()
def export_report_chart(self):
"""Export grafik laporan ke file gambar (PNG) - REVISI FREKUENSI TRANSAKSI"""
try:
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator # Untuk sumbu Y bilangan bulat
from datetime import datetime
except ImportError:
messagebox.showerror("Error", "Modul 'matplotlib' belum terinstall.\nSilakan install dengan: pip install matplotlib")
return
# 1. Cek Data
if not self.report_tree.get_children():
messagebox.showwarning("Data Kosong", "Generate laporan terlebih dahulu sebelum export grafik.")
return
# Ambil periode saat ini ('harian', 'mingguan', atau 'bulanan')
periode_pilihan = self.report_period_var.get()
# 2. Kumpulkan Data dari Treeview
metode_counts = {}
trend_data = {} # Bisa per jam atau per hari tergantung periode
for item_id in self.report_tree.get_children():
values = self.report_tree.item(item_id)['values']
# Format values: (tid, tanggal, meja, total_str, metode, status)
tanggal_str = values[1] # Format: DD/MM/YYYY HH:MM
metode = values[4]
# Hitung Pie Chart (Metode)
metode_counts[metode] = metode_counts.get(metode, 0) + 1
# Hitung Bar Chart (Trend Transaksi)
try:
dt_obj = datetime.strptime(tanggal_str, "%d/%m/%Y %H:%M")
if periode_pilihan == 'harian':
# Jika Harian: Group by JAM (misal "14:00")
key = dt_obj.strftime("%H:00")
else:
# Jika Mingguan/Bulanan: Group by TANGGAL (misal "2025-12-14")
key = dt_obj.strftime("%Y-%m-%d")
# Kita hitung JUMLAH TRANSAKSI (+1), bukan total uang
trend_data[key] = trend_data.get(key, 0) + 1
except ValueError:
pass
# 3. Buat Figure & Plotting
try:
# Setup layout: 1 Baris, 2 Kolom
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 7))
judul_periode = periode_pilihan.upper()
fig.suptitle(f'LAPORAN TRANSAKSI - {judul_periode}', fontsize=16, fontweight='bold')
# --- PLOT 1: PIE CHART (Metode Pembayaran) ---
if metode_counts:
labels = list(metode_counts.keys())
sizes = list(metode_counts.values())
# Warna-warni pastel agar cantik
colors = ['#ff9999','#66b3ff','#99ff99','#ffcc99', '#c2c2f0']
ax1.pie(sizes, labels=labels, autopct='%1.0f%%', colors=colors[:len(labels)], startangle=90, shadow=True)
ax1.set_title('Proporsi Metode Pembayaran')
else:
ax1.text(0.5, 0.5, "Tidak ada data", ha='center')
# --- PLOT 2: BAR CHART (Trend Frekuensi Transaksi) ---
if trend_data:
keys = sorted(trend_data.keys())
counts = [trend_data[k] for k in keys]
# Label X-Axis (Formatting agar rapi)
if periode_pilihan == 'harian':
# Label jam tetap apa adanya
x_labels = keys
x_title = "Jam Transaksi"
chart_title = "Jumlah Transaksi per Jam (Busy Hours)"
else:
# Format tanggal jadi lebih pendek (DD/MM)
x_labels = [datetime.strptime(k, "%Y-%m-%d").strftime("%d/%m") for k in keys]
x_title = "Tanggal"
chart_title = "Total Transaksi per Hari"
bars = ax2.bar(x_labels, counts, color='#4ECDC4', zorder=3, edgecolor='black', linewidth=0.7)
ax2.set_title(chart_title)
ax2.set_xlabel(x_title)
ax2.set_ylabel('Jumlah Transaksi (Struk)')
# Grid garis putus-putus di belakang
ax2.grid(axis='y', linestyle='--', alpha=0.5, zorder=0)
# Pastikan Sumbu Y hanya menampilkan Angka Bulat (Integer)
# Karena tidak mungkin ada 1.5 transaksi
ax2.yaxis.set_major_locator(MaxNLocator(integer=True))
# Tambah label angka di atas setiap batang
for bar in bars:
height = bar.get_height()
ax2.text(bar.get_x() + bar.get_width()/2., height,
f'{int(height)}',
ha='center', va='bottom', fontsize=10, fontweight='bold')
# Putar label x jika data banyak agar tidak tabrakan
if len(x_labels) > 5:
plt.setp(ax2.get_xticklabels(), rotation=45, ha="right")
else:
ax2.text(0.5, 0.5, "Tidak ada data transaksi", ha='center')
plt.tight_layout()
# 4. Dialog Simpan File
filename = filedialog.asksaveasfilename(
defaultextension=".png",
filetypes=[("PNG Image", "*.png"), ("JPEG Image", "*.jpg")],
title="Simpan Grafik Statistik"
)
if filename:
plt.savefig(filename, dpi=150, bbox_inches='tight')
messagebox.showinfo("Sukses", f"Grafik berhasil disimpan:\n{filename}")
plt.close(fig)
except Exception as e:
messagebox.showerror("Export Gagal", f"Terjadi kesalahan: {e}")
def reload_payment_orders(self):
"""Load transaksi dengan status 'selesai' yang belum dibayar"""
# Clear tree
for r in self.payment_tree.get_children():
self.payment_tree.delete(r)
# Get transaksi selesai
orders = transaksi_list(status='selesai')
# Filter yang belum ada pembayaran
for order in orders:
tid, uid, meja, total, status, promo_code, tanggal = order
# Cek apakah sudah ada pembayaran
payment_data = pembayaran_get_by_transaksi(tid)
if payment_data:
continue # Skip jika sudah dibayar
self.payment_tree.insert("", tk.END, values=(
tid, meja, f"Rp {total:,.0f}", status, tanggal
))
def on_payment_select(self, event):
"""Tampilkan detail transaksi saat dipilih"""
sel = self.payment_tree.selection()
if not sel:
return
item = self.payment_tree.item(sel)['values']
transaksi_id = item[0]
# Get detail transaksi
transaksi_data = transaksi_get(transaksi_id)
if not transaksi_data:
return
tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data
detail_items = detail_transaksi_list(transaksi_id)
# Update label (HANYA JIKA ADA / MODE KASIR)
if hasattr(self, 'selected_transaksi_label'):
self.selected_transaksi_label.config(text=f"Transaksi #{tid} - Meja {meja}", foreground='blue')
self.selected_total_label.config(text=f"Total: Rp {total:,.0f}")
# Format detail
detail_text = f"═══════════════════════════════════════════════\n"
detail_text += f"TRANSAKSI #{tid}\n"
detail_text += f"═══════════════════════════════════════════════\n\n"
detail_text += f"Meja : {meja}\n"
detail_text += f"Tanggal : {tanggal}\n"
detail_text += f"Status : {status.upper()}\n"
detail_text += f"Promo : {promo_code if promo_code else '-'}\n\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"ITEM PESANAN:\n"
detail_text += f"───────────────────────────────────────────────\n"
for detail in detail_items:
did, mid, qty, harga, subtotal_item = detail
menu_data = menu_get(mid)
if menu_data:
_, nama, kategori, _, _, _, _, _ = menu_data
detail_text += f"{nama}\n"
detail_text += f" {qty} x Rp {harga:,.0f} = Rp {subtotal_item:,.0f}\n\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"Subtotal : Rp {subtotal:,.0f}\n"
detail_text += f"Diskon : Rp {item_disc + promo_disc:,.0f}\n"
detail_text += f"───────────────────────────────────────────────\n"
detail_text += f"TOTAL BAYAR : Rp {total:,.0f}\n"
detail_text += f"═══════════════════════════════════════════════\n"
self.payment_detail_text.delete('1.0', tk.END)
self.payment_detail_text.insert('1.0', detail_text)
def on_payment_method_change(self):
"""Ganti input form sesuai metode pembayaran"""
method = self.payment_method_var.get()
# Clear frame
for w in self.payment_input_frame.winfo_children():
w.destroy()
if method == 'cash':
self.build_cash_input()
elif method == 'qris':
self.build_qris_input()
elif method == 'ewallet':
self.build_ewallet_input()
def build_cash_input(self):
"""Form input untuk pembayaran cash"""
ttk.Label(self.payment_input_frame, text="💵 Pembayaran Cash", font=("Arial", 10, "bold")).pack(pady=6)
input_frame = ttk.Frame(self.payment_input_frame)
input_frame.pack(fill='x', pady=6)
ttk.Label(input_frame, text="Jumlah Bayar:").grid(row=0, column=0, sticky='w', pady=4)
self.cash_amount_var = tk.StringVar()
ttk.Entry(input_frame, textvariable=self.cash_amount_var, width=20).grid(row=0, column=1, pady=4, sticky='ew')
input_frame.columnconfigure(1, weight=1)
# Label kembalian
self.cash_change_label = ttk.Label(self.payment_input_frame, text="Kembalian: Rp 0", font=("Arial", 10), foreground='green')
self.cash_change_label.pack(pady=6)
# Bind event untuk hitung kembalian real-time
self.cash_amount_var.trace('w', self.calculate_cash_change)
def calculate_cash_change(self, *args):
"""Hitung kembalian cash secara real-time"""
sel = self.payment_tree.selection()
if not sel:
return
item = self.payment_tree.item(sel)['values']
transaksi_id = item[0]
transaksi_data = transaksi_get(transaksi_id)
if not transaksi_data:
return
total = transaksi_data[3]
try:
cash_input = float(self.cash_amount_var.get() or 0)
change = cash_input - total
if change < 0:
self.cash_change_label.config(text=f"Kembalian: Kurang Rp {abs(change):,.0f}", foreground='red')
else:
self.cash_change_label.config(text=f"Kembalian: Rp {change:,.0f}", foreground='green')
except:
self.cash_change_label.config(text="Kembalian: Rp 0", foreground='gray')
def build_qris_input(self):
"""Form input untuk pembayaran QRIS (simulasi QR Code)"""
ttk.Label(self.payment_input_frame, text="📱 Pembayaran QRIS", font=("Arial", 10, "bold")).pack(pady=6)
ttk.Label(self.payment_input_frame, text="Scan QR Code di bawah untuk bayar:", font=("Arial", 9)).pack(pady=4)
# Generate QR Code
sel = self.payment_tree.selection()
if sel:
item = self.payment_tree.item(sel)['values']
transaksi_id = item[0]
transaksi_data = transaksi_get(transaksi_id)
if transaksi_data:
total = transaksi_data[3]
# QR Code data (simulasi)
qr_data = f"QRIS-CAFE-TOTORO-TRX{transaksi_id}-TOTAL{int(total)}"
try:
import qrcode
qr = qrcode.QRCode(version=1, box_size=5, border=2)
qr.add_data(qr_data)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_img = qr_img.resize((200, 200), Image.Resampling.LANCZOS)
qr_photo = ImageTk.PhotoImage(qr_img)
self.img_cache['qris'] = qr_photo
qr_label = ttk.Label(self.payment_input_frame, image=qr_photo)
qr_label.pack(pady=10)
except ImportError:
ttk.Label(self.payment_input_frame, text="[QR Code - Install qrcode library]", background='#e0e0e0', font=("Arial", 9)).pack(pady=10)
ttk.Label(self.payment_input_frame, text="⚠️ Ini adalah SIMULASI QRIS", font=("Arial", 8), foreground='orange').pack(pady=6)
ttk.Label(self.payment_input_frame, text="Klik 'PROSES PEMBAYARAN' untuk konfirmasi", font=("Arial", 8)).pack()
def build_ewallet_input(self):
"""Form input untuk pembayaran E-Wallet (simulasi)"""
ttk.Label(self.payment_input_frame, text="💳 Pembayaran E-Wallet", font=("Arial", 10, "bold")).pack(pady=6)
ttk.Label(self.payment_input_frame, text="Pilih E-Wallet:", font=("Arial", 9)).pack(pady=4)
self.ewallet_type_var = tk.StringVar(value='gopay')
ewallet_frame = ttk.Frame(self.payment_input_frame)
ewallet_frame.pack(pady=6)
ttk.Radiobutton(ewallet_frame, text="GoPay", variable=self.ewallet_type_var, value='gopay').pack(anchor='w', pady=2)
ttk.Radiobutton(ewallet_frame, text="OVO", variable=self.ewallet_type_var, value='ovo').pack(anchor='w', pady=2)
ttk.Radiobutton(ewallet_frame, text="Dana", variable=self.ewallet_type_var, value='dana').pack(anchor='w', pady=2)
ttk.Radiobutton(ewallet_frame, text="ShopeePay", variable=self.ewallet_type_var, value='shopeepay').pack(anchor='w', pady=2)
ttk.Label(self.payment_input_frame, text="⚠️ Ini adalah SIMULASI E-Wallet", font=("Arial", 8), foreground='orange').pack(pady=6)
ttk.Label(self.payment_input_frame, text="Klik 'PROSES PEMBAYARAN' untuk konfirmasi", font=("Arial", 8)).pack()
def process_payment(self):
"""Proses pembayaran sesuai metode yang dipilih"""
# Cek apakah ada transaksi terpilih
sel = self.payment_tree.selection()
if not sel:
messagebox.showwarning("Pilih Transaksi", "Pilih transaksi yang akan dibayar")
return
item = self.payment_tree.item(sel)['values']
transaksi_id = item[0]
transaksi_data = transaksi_get(transaksi_id)
if not transaksi_data:
messagebox.showerror("Error", "Data transaksi tidak ditemukan")
return
tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data
# VALIDASI 1: Status harus 'selesai'
if status != 'selesai':
messagebox.showerror("Status Error",
f"❌ Status transaksi harus 'SELESAI'\n\n"
f"Status saat ini: {status.upper()}\n\n"
f"Silakan tunggu pesanan selesai disiapkan dulu.")
return
# VALIDASI 2: Cek belum dibayar sebelumnya
existing_payment = pembayaran_get_by_transaksi(transaksi_id)
if existing_payment:
messagebox.showwarning("Sudah Dibayar",
f"⚠️ Transaksi ini sudah dibayar sebelumnya!\n\n"
f"Metode: {existing_payment[1].upper()}\n"
f"Tanggal: {existing_payment[4]}")
return
# Get metode pembayaran
method = self.payment_method_var.get()
# Validasi & proses sesuai metode
if method == 'cash':
success, message = self.process_cash_payment(tid, total)
elif method == 'qris':
success, message = self.process_qris_payment(tid, total)
elif method == 'ewallet':
success, message = self.process_ewallet_payment(tid, total)
else:
messagebox.showerror("Error", "Metode pembayaran tidak valid")
return
if success:
# Generate struk
struk = self.generate_struk(tid)
# Update status transaksi jadi 'dibayar'
transaksi_update_status(tid, 'dibayar')
# Tutup meja
meja_tutup(meja)
# Tampilkan struk
self.show_struk(struk)
# Reload data
self.reload_payment_orders()
# Reset selection
self.selected_transaksi_label.config(text="Belum ada transaksi dipilih", foreground='red')
self.selected_total_label.config(text="Total: Rp 0")
self.payment_detail_text.delete('1.0', tk.END)
else:
messagebox.showerror("Pembayaran Gagal", message)
def process_cash_payment(self, transaksi_id, total):
"""Proses pembayaran cash - VERSI BARU"""
try:
cash_input = float(self.cash_amount_var.get() or 0)
except:
return False, "Jumlah bayar tidak valid (harus angka)"
# GUNAKAN HELPER FUNCTION untuk validasi
is_valid, msg = pembayaran_validate_cash(cash_input, total)
if not is_valid:
return False, msg
# Hitung kembalian
change = pembayaran_calculate_change(cash_input, total)
# Simpan pembayaran
pembayaran_add(transaksi_id, 'cash', cash_input, 'sukses', '')
return True, f"✅ Pembayaran cash berhasil. Kembalian: {format_currency(change)}"
def process_qris_payment(self, transaksi_id, total):
"""Proses pembayaran QRIS (simulasi)"""
# Konfirmasi
confirm = messagebox.askyesno("Konfirmasi QRIS",
f"Pembayaran QRIS sebesar Rp {total:,.0f}\n\n"
f"⚠️ SIMULASI: Anggap customer sudah scan QR Code\n\n"
f"Lanjutkan pembayaran?")
if not confirm:
return False, "Pembayaran dibatalkan"
# Simpan pembayaran
pembayaran_add(transaksi_id, 'qris', total, 'sukses', '')
return True, "Pembayaran QRIS berhasil"
def process_ewallet_payment(self, transaksi_id, total):
"""Proses pembayaran E-Wallet (simulasi)"""
ewallet_type = self.ewallet_type_var.get()
ewallet_name = ewallet_type.upper()
# Konfirmasi
confirm = messagebox.askyesno("Konfirmasi E-Wallet",
f"Pembayaran {ewallet_name} sebesar Rp {total:,.0f}\n\n"
f"⚠️ SIMULASI: Anggap customer sudah konfirmasi di app\n\n"
f"Lanjutkan pembayaran?")
if not confirm:
return False, "Pembayaran dibatalkan"
# Simpan pembayaran
pembayaran_add(transaksi_id, f'ewallet-{ewallet_type}', total, 'sukses', '')
return True, f"Pembayaran {ewallet_name} berhasil"
def generate_struk(self, transaksi_id):
"""Generate struk dengan format professional - VERSI BARU"""
from datetime import datetime
transaksi_data = transaksi_get(transaksi_id)
if not transaksi_data:
return ""
tid, uid, meja, total, status, promo_code, subtotal, item_disc, promo_disc, tanggal = transaksi_data
detail_items = detail_transaksi_list(transaksi_id)
payment_data = pembayaran_get_by_transaksi(transaksi_id)
# Build struk string
struk = "═════════════════════════════════════════\n"
struk += " TOTORO CAFE\n"
struk += " Jl. Raya Totoro No. 123, Surabaya\n"
struk += " Telp: 031-123456\n"
struk += "═════════════════════════════════════════\n\n"
struk += f"No. Struk : STR-{tid}-{datetime.now().strftime('%Y%m%d')}\n"
struk += f"Tanggal : {format_datetime(tanggal)}\n"
struk += f"Meja : {meja}\n"
struk += f"Kasir : {self.session['username']}\n"
if payment_data:
metode = payment_data[1]
struk += f"Pembayaran : {metode.upper()}\n"
struk += "─────────────────────────────────────────\n"
struk += "ITEM PESANAN:\n"
struk += "─────────────────────────────────────────\n"
for detail in detail_items:
did, mid, qty, harga, subtotal_item = detail
menu_data = menu_get(mid)
if menu_data:
_, nama, kategori, _, _, _, _, _ = menu_data
struk += f"{nama}\n"
struk += f" {qty} x {format_currency(harga)}".ljust(30)
struk += f"{format_currency(subtotal_item)}\n"
struk += "─────────────────────────────────────────\n"
struk += f"Subtotal : {format_currency(subtotal)}\n"
if item_disc > 0:
struk += f"Diskon Item : {format_currency(item_disc)}\n"
if promo_disc > 0:
struk += f"Diskon Promo : {format_currency(promo_disc)}\n"
struk += "─────────────────────────────────────────\n"
struk += f"TOTAL BAYAR : {format_currency(total)}\n"
# Info pembayaran jika cash
if payment_data and payment_data[1] == 'cash':
jumlah_bayar = payment_data[2]
kembalian = jumlah_bayar - total
struk += f"Bayar : {format_currency(jumlah_bayar)}\n"
struk += f"Kembalian : {format_currency(kembalian)}\n"
struk += "═════════════════════════════════════════\n"
struk += " TERIMA KASIH ATAS KUNJUNGAN ANDA\n"
struk += " SAMPAI JUMPA LAGI!\n"
struk += "═════════════════════════════════════════\n"
# SIMPAN KE FILE untuk audit trail
self.save_struk_to_file(transaksi_id, struk)
return struk
def save_struk_to_file(self, transaksi_id, struk_content):
"""Simpan struk ke file - FUNGSI BARU"""
from datetime import datetime
import os
# Buat folder 'struk' jika belum ada
if not os.path.exists('struk'):
os.makedirs('struk')
# Format nama file dengan timestamp
filename = f"struk/STR-{transaksi_id}-{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(struk_content)
return True
except Exception as e:
print(f"⚠️ Warning: Gagal save struk ke file: {e}")
return False
def show_struk(self, struk):
"""Tampilkan struk dalam popup window"""
w = tk.Toplevel(self.root)
w.title("✅ Pembayaran Berhasil - Struk Transaksi")
w.geometry("500x600")
w.transient(self.root)
w.grab_set()
# Frame untuk struk
frm = ttk.Frame(w, padding=15)
frm.pack(fill='both', expand=True)
ttk.Label(frm, text="✅ PEMBAYARAN BERHASIL!", font=("Arial", 14, "bold"), foreground='green').pack(pady=10)
# Text widget untuk struk
struk_text = tk.Text(frm, width=50, height=28, font=("Courier New", 9), wrap='word')
struk_scroll = ttk.Scrollbar(frm, orient='vertical', command=struk_text.yview)
struk_text.configure(yscrollcommand=struk_scroll.set)
struk_text.insert('1.0', struk)
struk_text.config(state='disabled') # Read-only
struk_text.pack(side='left', fill='both', expand=True)
struk_scroll.pack(side='right', fill='y')
# Tombol
btn_frame = ttk.Frame(w)
btn_frame.pack(pady=10)
ttk.Button(btn_frame, text="🖨️ Print (Simulasi)", command=lambda: self.print_struk_simulation(struk)).pack(side='left', padx=6)
ttk.Button(btn_frame, text="✅ Tutup", command=w.destroy).pack(side='left', padx=6)
def print_struk_simulation(self, struk):
"""Simulasi print struk (save to file)"""
from datetime import datetime
filename = f"struk_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(struk)
messagebox.showinfo("Print Simulasi", f"Struk berhasil disimpan ke file:\n{filename}")
except Exception as e:
messagebox.showerror("Error", f"Gagal menyimpan struk: {e}")
def check_new_orders(self):
"""Cek pesanan baru yang perlu perhatian waiter/kasir"""
if self.session['role'] not in ['waiter', 'kasir', 'admin']:
return 0
# Hitung pesanan pending untuk waiter
if self.session['role'] in ['waiter', 'admin']:
pending_orders = transaksi_list(status='pending')
return len(pending_orders)
# Hitung pesanan selesai untuk kasir
if self.session['role'] in ['kasir', 'admin']:
selesai_orders = transaksi_list(status='selesai')
# Filter yang belum dibayar
unpaid_count = 0
for order in selesai_orders:
tid = order[0]
payment = pembayaran_get_by_transaksi(tid)
if not payment:
unpaid_count += 1
return unpaid_count
return 0
def start_notification_check(self):
"""Start auto-refresh untuk notifikasi (setiap 10 detik)"""
if not hasattr(self, 'notification_running'):
self.notification_running = True
self.update_notification_badge()
def update_notification_badge(self):
"""Update badge notifikasi"""
if not self.notification_running:
return
try:
count = self.check_new_orders()
# Update badge di tab yang sesuai
if self.session['role'] in ['waiter', 'admin']:
if hasattr(self, 'tab_waiter'):
for tab_id in range(self.root.nametowidget('.!notebook').index('end')):
tab_text = self.root.nametowidget('.!notebook').tab(tab_id, 'text')
if 'Kelola Pesanan' in tab_text:
if count > 0:
self.root.nametowidget('.!notebook').tab(tab_id, text=f"🍽️ Kelola Pesanan ({count})")
else:
self.root.nametowidget('.!notebook').tab(tab_id, text="🍽️ Kelola Pesanan")
break
if self.session['role'] in ['kasir', 'admin']:
if hasattr(self, 'tab_payment'):
for tab_id in range(self.root.nametowidget('.!notebook').index('end')):
tab_text = self.root.nametowidget('.!notebook').tab(tab_id, 'text')
if 'Transaksi' in tab_text:
if count > 0:
self.root.nametowidget('.!notebook').tab(tab_id, text=f"💰 Transaksi ({count})")
else:
self.root.nametowidget('.!notebook').tab(tab_id, text="💰 Transaksi")
break
except:
pass
# Schedule next check (10 seconds)
if self.notification_running:
self.root.after(10000, self.update_notification_badge)
def show_text_chart(self):
"""Tampilkan grafik ASCII sebagai fallback jika matplotlib tidak ada"""
# Get data from tree
if not self.report_tree.get_children():
messagebox.showwarning("Tidak Ada Data", "Generate laporan terlebih dahulu")
return
# Collect data
metode_counts = {}
status_counts = {} # ← TAMBAH INI (NEW)
for item_id in self.report_tree.get_children():
values = self.report_tree.item(item_id)['values']
tid, tanggal, meja, total_str, metode, status = values
# Count by metode
metode_counts[metode] = metode_counts.get(metode, 0) + 1
# NEW: Count by status
status_key = status.upper()
status_counts[status_key] = status_counts.get(status_key, 0) + 1
# ===== BUAT ASCII CHART METODE =====
chart_text = "=" * 70 + "\n"
chart_text += "📊 GRAFIK PENJUALAN - TEXT MODE (Matplotlib tidak terinstall)\n"
chart_text += "=" * 70 + "\n\n"
chart_text += "💳 GRAFIK TRANSAKSI PER METODE PEMBAYARAN\n"
chart_text += "" * 70 + "\n"
if metode_counts:
max_count = max(metode_counts.values())
for metode, count in sorted(metode_counts.items()):
# Calculate bar length (max 50 chars)
bar_length = int((count / max_count) * 50) if max_count > 0 else 0
bar = "" * bar_length
percentage = (count / sum(metode_counts.values())) * 100 if sum(metode_counts.values()) > 0 else 0
# Format: METODE | ███████ COUNT (PCT%)
chart_text += f"{metode.ljust(15)} | {bar:<50} {count:>3} ({percentage:>5.1f}%)\n"
chart_text += "\n" + "=" * 70 + "\n\n"
# ===== BUAT ASCII CHART STATUS (NEW!) =====
chart_text += "✓ GRAFIK STATUS PEMBAYARAN\n"
chart_text += "" * 70 + "\n"
if status_counts:
max_count_status = max(status_counts.values())
for status, count in sorted(status_counts.items()):
# Calculate bar length (max 50 chars)
bar_length = int((count / max_count_status) * 50) if max_count_status > 0 else 0
# Icon per status
icon_map = {
'SUKSES': '',
'GAGAL': '',
'PENDING': '',
'DIBAYAR': ''
}
icon = icon_map.get(status, '')
# Warna ASCII (menggunakan symbols)
if status == 'SUKSES':
bar = "" * bar_length
elif status == 'GAGAL':
bar = "" * bar_length
elif status == 'PENDING':
bar = "" * bar_length
else:
bar = "" * bar_length
percentage = (count / sum(status_counts.values())) * 100 if sum(status_counts.values()) > 0 else 0
# Format
chart_text += f"{icon} {status.ljust(12)} | {bar:<50} {count:>3} ({percentage:>5.1f}%)\n"
chart_text += "\n" + "=" * 70 + "\n"
chart_text += f"📌 TOTAL TRANSAKSI: {sum(metode_counts.values())}\n"
chart_text += f"✓ SUKSES: {status_counts.get('SUKSES', 0)}\n"
chart_text += f"✗ GAGAL: {status_counts.get('GAGAL', 0)}\n"
chart_text += f"⏳ PENDING: {status_counts.get('PENDING', 0)}\n"
chart_text += f"✔ DIBAYAR: {status_counts.get('DIBAYAR', 0)}\n"
chart_text += "=" * 70
# Show in window
w = tk.Toplevel(self.root)
w.title("📊 Grafik Laporan (Text Mode)")
w.geometry("800x650")
frm = ttk.Frame(w, padding=15)
frm.pack(fill='both', expand=True)
ttk.Label(frm, text=" Matplotlib tidak terinstall - Mode Text Chart",
font=("Arial", 10), foreground='orange').pack(pady=10)
text = tk.Text(frm, width=100, height=32, font=("Courier New", 9))
text_scroll = ttk.Scrollbar(frm, orient='vertical', command=text.yview)
text.configure(yscrollcommand=text_scroll.set)
text.insert('1.0', chart_text)
text.config(state='disabled')
text.pack(side='left', fill='both', expand=True)
text_scroll.pack(side='right', fill='y')
# Tombol
btn_frame = ttk.Frame(w)
btn_frame.pack(pady=10)
ttk.Button(btn_frame, text="💾 Save ke File", command=lambda: self.save_text_chart(chart_text)).pack(side='left', padx=5)
ttk.Button(btn_frame, text="Tutup", command=w.destroy).pack(side='left', padx=5)
def save_text_chart(self, chart_text):
"""Helper untuk save text chart ke file"""
from datetime import datetime
filename = f"GRAFIK_TEXT_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(chart_text)
messagebox.showinfo("✅ Berhasil", f"Grafik tersimpan:\n{filename}")
except Exception as e:
messagebox.showerror("Error", f"Gagal save: {e}")
# MEJA
def build_meja_tab(self, parent):
"""Tab untuk kelola status meja - VERSI BARU"""
for w in parent.winfo_children():
w.destroy()
# Header
header = ttk.Frame(parent)
header.pack(fill='x', padx=10, pady=8)
ttk.Label(header, text="🪑 Manajemen Meja Cafe", font=("Arial", 14, "bold")).pack(side='left')
ttk.Button(header, text="🔄 Refresh", command=self.reload_meja_status).pack(side='right', padx=6)
# Info panel - DENGAN OCCUPANCY
info_frame = ttk.LabelFrame(parent, text="📊 Info Meja", padding=10)
info_frame.pack(fill='x', padx=10, pady=6)
info_inner = ttk.Frame(info_frame)
info_inner.pack()
ttk.Label(info_inner, text="🟢 Kosong:", font=("Arial", 10)).grid(row=0, column=0, padx=15, pady=3)
self.meja_kosong_label = ttk.Label(info_inner, text="0", font=("Arial", 10, "bold"), foreground='green')
self.meja_kosong_label.grid(row=0, column=1, padx=5, pady=3)
ttk.Label(info_inner, text="🔴 Terisi:", font=("Arial", 10)).grid(row=0, column=2, padx=15, pady=3)
self.meja_terisi_label = ttk.Label(info_inner, text="0", font=("Arial", 10, "bold"), foreground='red')
self.meja_terisi_label.grid(row=0, column=3, padx=5, pady=3)
ttk.Label(info_inner, text="📈 Okupansi:", font=("Arial", 10)).grid(row=0, column=4, padx=15, pady=3)
self.meja_occupancy_label = ttk.Label(info_inner, text="0%", font=("Arial", 10, "bold"))
self.meja_occupancy_label.grid(row=0, column=5, padx=5, pady=3)
# Canvas untuk meja cards
canvas_frame = ttk.Frame(parent)
canvas_frame.pack(fill='both', expand=True, padx=10, pady=6)
canvas = tk.Canvas(canvas_frame, bg='#f5f5f5', highlightthickness=0)
scrollbar = ttk.Scrollbar(canvas_frame, orient="vertical", command=canvas.yview)
self.meja_cards_frame = ttk.Frame(canvas)
self.meja_cards_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=self.meja_cards_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
def _on_mousewheel(event):
canvas.yview_scroll(int(-1*(event.delta/120)), "units")
canvas.bind_all("<MouseWheel>", _on_mousewheel)
self.reload_meja_status()
def reload_meja_status(self):
"""Load semua meja dalam bentuk cards - VERSI BARU"""
# Clear cards lama
for widget in self.meja_cards_frame.winfo_children():
widget.destroy()
# GUNAKAN HELPER FUNCTION - Ini yang baru!
summary = meja_get_status_summary()
# Update info labels dari summary
self.meja_kosong_label.config(text=str(summary['kosong']))
self.meja_terisi_label.config(text=str(summary['terisi']))
# Hitung & display occupancy percentage
occupancy = (summary['terisi'] / summary['total'] * 100) if summary['total'] > 0 else 0
self.meja_occupancy_label.config(text=f"{occupancy:.0f}%")
# Render meja cards (5 per row)
row = 0
col = 0
for meja in summary['meja_list']:
nomor = meja['nomor']
status = meja['status']
transaksi_info = meja['transaksi_info']
# Tentukan warna berdasarkan status
if status == 'kosong':
bg_color = '#C8E6C9'
status_color = '#4CAF50'
status_text = '🟢 KOSONG'
elif transaksi_info:
trx_status = transaksi_info['status']
if trx_status == 'selesai':
bg_color = '#FFCDD2'
status_color = '#F44336'
status_text = '🔴 SELESAI'
elif trx_status == 'dibayar':
bg_color = '#BBDEFB'
status_color = '#1976D2'
status_text = '✔️ DIBAYAR'
else:
bg_color = '#FFF9C4'
status_color = '#FBC02D'
status_text = '🟡 TERISI'
else:
bg_color = '#E0E0E0'
status_color = '#757575'
status_text = '⚪ UNKNOWN'
# BUAT CARD
card = tk.Frame(
self.meja_cards_frame,
relief='solid',
borderwidth=2,
bg=bg_color,
padx=15,
pady=15
)
card.grid(row=row, column=col, padx=8, pady=8, sticky='nsew')
# Nomor meja (besar)
tk.Label(
card,
text=f"MEJA {nomor}",
font=("Arial", 16, "bold"),
bg=bg_color
).pack(pady=(0, 5))
# Status
tk.Label(
card,
text=status_text,
font=("Arial", 10, "bold"),
fg=status_color,
bg=bg_color
).pack(pady=5)
# Detail transaksi jika ada
if transaksi_info:
tk.Label(
card,
text=f"Transaksi: #{transaksi_info['id']}",
font=("Arial", 8),
bg=bg_color
).pack(pady=2)
tk.Label(
card,
text=f"Total: {format_currency(transaksi_info['total'])}",
font=("Arial", 8, "bold"),
bg=bg_color,
fg='#1976D2'
).pack(pady=2)
tk.Label(
card,
text=f"Status: {transaksi_info['status'].upper()}",
font=("Arial", 7),
bg=bg_color
).pack(pady=2)
# Tombol action
btn_frame = tk.Frame(card, bg=bg_color)
btn_frame.pack(pady=(10, 0))
if status_text == '⚪ UNKNOWN':
tk.Button(
btn_frame,
text="⚠️ Reset Paksa",
font=("Arial", 8, "bold"),
bg='red',
fg='white',
width=12,
command=lambda n=nomor: self.tutup_meja(n)
).pack()
if status == 'terisi' and transaksi_info:
if transaksi_info['status'] == 'dibayar':
tk.Button(
btn_frame,
text="✅ Tutup Meja",
font=("Arial", 9, "bold"),
bg='#4CAF50',
fg='white',
width=12,
borderwidth=0,
cursor='hand2',
command=lambda n=nomor: self.tutup_meja(n)
).pack()
else:
tk.Label(
btn_frame,
text="⏳ Menunggu Pembayaran",
font=("Arial", 8),
bg=bg_color,
fg='orange'
).pack()
else:
tk.Label(
btn_frame,
text="Siap digunakan",
font=("Arial", 8),
bg=bg_color,
fg='gray'
).pack()
# Next column
col += 1
if col >= 5: # 5 meja per row
col = 0
row += 1
def tutup_meja(self, nomor_meja):
"""Tutup meja dengan validasi - VERSI BARU"""
# Validasi nomor meja
is_valid, msg = validate_meja_number(nomor_meja)
if not is_valid:
messagebox.showerror("Invalid Input", msg)
return
# Cek status transaksi
meja_data = meja_get(nomor_meja)
if meja_data and meja_data[2]: # ada transaksi_id
try:
transaksi_data = transaksi_get(int(meja_data[2]))
if transaksi_data and transaksi_data[4] != 'dibayar':
messagebox.showwarning(
"Belum Dibayar",
f"Transaksi belum dibayar!\n"
f"Status saat ini: {transaksi_data[4].upper()}\n\n"
f"Silakan proses pembayaran dulu."
)
return
except:
pass
# Konfirmasi
if messagebox.askyesno(
"Konfirmasi",
f"Tutup meja {nomor_meja}?\n\nMeja akan direset dan siap digunakan lagi."
):
success = meja_tutup(nomor_meja)
if success:
messagebox.showinfo(
"✅ Berhasil",
f"Meja {nomor_meja} berhasil direset dan siap digunakan"
)
self.reload_meja_status()
else:
messagebox.showerror("❌ Gagal", f"Gagal menutup meja {nomor_meja}")
# ========================================
# FUNGSI TAMBAHAN UNTUK BACKEND
# (sudah ada tapi ditambahkan untuk kelengkapan)
# ========================================
def meja_list_all():
"""Ambil semua data meja"""
rows = read_all(MEJA_CSV)
out = []
for r in rows:
try:
nomor = int(r.get("nomor_meja") or 0)
except:
nomor = r.get("nomor_meja")
transaksi_id = r.get("transaksi_id") or ""
out.append((nomor, r.get("status"), transaksi_id))
out.sort(key=lambda x: int(x[0]) if isinstance(x[0], int) else 0)
return out
# Done
if __name__ == "__main__":
init_db_csv()
root = tk.Tk()
app = App(root)
root.mainloop()