4942 lines
188 KiB
Python
4942 lines
188 KiB
Python
"""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
|
||
|
||
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 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 CAFE TOTORO MANIA\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("Cafe Totoro Mania")
|
||
self.session = None
|
||
self.img_cache = {}
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.root.geometry("1000x650")
|
||
self.root.resizable(False, False)
|
||
self.login_frame()
|
||
style = ttk.Style()
|
||
style.configure("Accent.TButton", font=("Arial", 11, "bold"))
|
||
style.configure("TButton", padding=6) # Padding tombol lebih besar
|
||
|
||
def login_frame(self):
|
||
for w in self.root.winfo_children():
|
||
w.destroy()
|
||
frame = ttk.Frame(self.root, padding=20)
|
||
frame.pack(expand=True)
|
||
|
||
ttk.Label(frame, text="Login kak", font=("Arial", 24)).grid(row=0, column=0, columnspan=2, pady=10)
|
||
|
||
ttk.Label(frame, text="Username:").grid(row=1, column=0, sticky='e', pady=5)
|
||
self.username_var = tk.StringVar()
|
||
ttk.Entry(frame, textvariable=self.username_var, width=30).grid(row=1, column=1, pady=5)
|
||
|
||
ttk.Label(frame, text="Password:").grid(row=2, column=0, sticky='e', pady=5)
|
||
self.password_var = tk.StringVar()
|
||
ttk.Entry(frame, textvariable=self.password_var, show="*", width=30).grid(row=2, column=1, pady=5)
|
||
|
||
# TAMBAHAN: Input nama pembeli (untuk role pembeli)
|
||
ttk.Label(frame, text="Nama Lengkap:", font=("Arial", 9)).grid(row=3, column=0, sticky='e', pady=5)
|
||
self.customer_name_var = tk.StringVar()
|
||
customer_entry = ttk.Entry(frame, textvariable=self.customer_name_var, width=30)
|
||
customer_entry.grid(row=3, column=1, pady=5)
|
||
ttk.Label(frame, text="(Khusus untuk Pembeli)", font=("Arial", 7), foreground='gray').grid(row=4, column=1, sticky='w')
|
||
|
||
# TAMBAHAN BARU: Input nomor meja (untuk role pembeli)
|
||
ttk.Label(frame, text="Nomor Meja:", font=("Arial", 9)).grid(row=5, column=0, sticky='e', pady=5)
|
||
self.customer_meja_var = tk.StringVar()
|
||
meja_entry = ttk.Entry(frame, textvariable=self.customer_meja_var, width=30)
|
||
meja_entry.grid(row=5, column=1, pady=5)
|
||
ttk.Label(frame, text="(Khusus untuk Pembeli - Meja 1-10)", font=("Arial", 7), foreground='gray').grid(row=6, column=1, sticky='w')
|
||
|
||
ttk.Button(frame, text="Login", command=self.handle_login).grid(row=7, column=0, columnspan=2, pady=12)
|
||
|
||
|
||
|
||
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()
|
||
top = ttk.Frame(self.root)
|
||
top.pack(fill='x')
|
||
|
||
# Text untuk header
|
||
if self.session['role'] in ['pembeli', 'user'] and 'customer_name' in self.session:
|
||
header_text = f"👤 {self.session['customer_name']} | User: {self.session['username']} | Role: {self.session['role']}"
|
||
else:
|
||
header_text = f"User: {self.session['username']} | Role: {self.session['role']}"
|
||
|
||
ttk.Label(
|
||
top,
|
||
text=header_text,
|
||
font=("Arial", 12)
|
||
).pack(side='left', padx=10, pady=6)
|
||
ttk.Button(top, text="Logout", command=self.logout).pack(side='right', padx=10)
|
||
main = ttk.Notebook(self.root)
|
||
main.pack(fill='both', expand=True, padx=10, pady=8)
|
||
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 Saya")
|
||
|
||
# ==========================================
|
||
# 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 (DENGAN BACKGROUND PUTIH)
|
||
canvas = tk.Canvas(left, bg='#f5f5f5', height=450, highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(left, orient="vertical", command=canvas.yview)
|
||
self.menu_cards_frame = ttk.Frame(canvas)
|
||
|
||
self.menu_cards_frame.bind(
|
||
"<Configure>",
|
||
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||
)
|
||
|
||
canvas.create_window((0, 0), window=self.menu_cards_frame, anchor="nw")
|
||
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 dengan gambar + tombol +/-"""
|
||
# Clear existing cards
|
||
for widget in self.menu_cards_frame.winfo_children():
|
||
widget.destroy()
|
||
|
||
# Get menu data
|
||
search = self.order_search_var.get().strip() or None
|
||
results = menu_list(search_text=search, available_only=True)
|
||
|
||
# Buat dict untuk qty di cart
|
||
cart_dict = {}
|
||
for cart_item in self.cart_items:
|
||
cart_dict[cart_item['menu_id']] = cart_item['qty']
|
||
|
||
# Render cards dalam grid (2 kolom)
|
||
row = 0
|
||
col = 0
|
||
for menu_data in results:
|
||
mid, nama, kategori, harga, stok, foto, tersedia, item_disc = menu_data
|
||
|
||
# Create card frame (DENGAN BACKGROUND PUTIH + BORDER)
|
||
card = tk.Frame(
|
||
self.menu_cards_frame,
|
||
relief='solid',
|
||
borderwidth=1,
|
||
bg='white',
|
||
padx=10,
|
||
pady=10
|
||
)
|
||
card.grid(row=row, column=col, padx=8, pady=8, sticky='nsew')
|
||
|
||
# Gambar
|
||
if foto and os.path.exists(foto):
|
||
try:
|
||
img = Image.open(foto)
|
||
img = img.resize((150, 100), Image.Resampling.LANCZOS)
|
||
photo = ImageTk.PhotoImage(img)
|
||
|
||
img_label = tk.Label(card, image=photo, bg='white')
|
||
img_label.image = photo
|
||
img_label.pack()
|
||
except:
|
||
tk.Label(card, text="[No Image]", bg='#e0e0e0', width=20, height=6).pack()
|
||
else:
|
||
tk.Label(card, text="[No Image]", bg='#e0e0e0', width=20, height=6).pack()
|
||
|
||
# Nama menu
|
||
tk.Label(card, text=nama, font=("Arial", 11, "bold"), bg='white', wraplength=150).pack(pady=(5, 2))
|
||
|
||
# Kategori
|
||
tk.Label(card, text=kategori, font=("Arial", 9), fg='gray', bg='white').pack()
|
||
|
||
# Harga
|
||
harga_text = f"Rp {harga:,.0f}"
|
||
if item_disc > 0:
|
||
harga_text += f" (-{item_disc}%)"
|
||
tk.Label(card, text=harga_text, font=("Arial", 10, "bold"), fg='#4CAF50', bg='white').pack(pady=2)
|
||
else:
|
||
tk.Label(card, text=harga_text, font=("Arial", 10, "bold"), fg='green', bg='white').pack(pady=2)
|
||
|
||
# Stok info
|
||
tk.Label(card, text=f"Stok: {stok}", font=("Arial", 8), fg='blue', bg='white').pack(pady=2)
|
||
|
||
# Tombol +/- atau + saja
|
||
qty_in_cart = cart_dict.get(mid, 0)
|
||
|
||
btn_frame = tk.Frame(card, bg='white')
|
||
btn_frame.pack(pady=5)
|
||
|
||
if qty_in_cart > 0:
|
||
# Tampilkan - [qty] +
|
||
tk.Button(
|
||
btn_frame,
|
||
text="➖",
|
||
font=("Arial", 12, "bold"),
|
||
bg='#FF5722',
|
||
fg='white',
|
||
width=3,
|
||
borderwidth=0,
|
||
cursor='hand2',
|
||
command=lambda m=mid: self.decrease_from_card(m)
|
||
).pack(side='left', padx=2)
|
||
|
||
tk.Label(
|
||
btn_frame,
|
||
text=str(qty_in_cart),
|
||
font=("Arial", 12, "bold"),
|
||
bg='white',
|
||
width=3
|
||
).pack(side='left', padx=5)
|
||
|
||
tk.Button(
|
||
btn_frame,
|
||
text="➕",
|
||
font=("Arial", 12, "bold"),
|
||
bg='#4CAF50',
|
||
fg='white',
|
||
width=3,
|
||
borderwidth=0,
|
||
cursor='hand2',
|
||
command=lambda m=mid, s=stok: self.increase_from_card(m, s)
|
||
).pack(side='left', padx=2)
|
||
else:
|
||
# Tampilkan tombol + aja
|
||
tk.Button(
|
||
btn_frame,
|
||
text="➕ Tambah",
|
||
font=("Arial", 10, "bold"),
|
||
bg='#4CAF50',
|
||
fg='white',
|
||
width=12,
|
||
borderwidth=0,
|
||
cursor='hand2',
|
||
command=lambda m=mid, s=stok: self.increase_from_card(m, s)
|
||
).pack()
|
||
|
||
# Next column
|
||
col += 1
|
||
if col >= 2:
|
||
col = 0
|
||
row += 1
|
||
|
||
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 Saya (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()
|
||
|
||
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()
|
||
|
||
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
|
||
|
||
Args:
|
||
parent: Parent frame
|
||
readonly: Jika True, tombol aksi disabled (untuk admin monitoring)
|
||
"""
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
# Header
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=6)
|
||
|
||
# ✅ UBAH JUDUL SESUAI MODE
|
||
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="Menunggu", command=lambda: self.reload_waiter_orders('menunggu'), width=10).pack(side='left', padx=2)
|
||
ttk.Button(filter_frame, text="Diproses", command=lambda: self.reload_waiter_orders('diproses'), width=8).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)
|
||
|
||
# PACK SCROLLBAR DULUAN
|
||
detail_scroll.pack(side='right', fill='y')
|
||
|
||
# BARU TEXT AREA
|
||
self.waiter_detail_text.pack(side='left', fill='both', expand=True)
|
||
|
||
# TOMBOL AKSI
|
||
action_frame = ttk.LabelFrame(parent, text="🎯 Ubah Status Pesanan", padding=12)
|
||
action_frame.pack(fill='x', padx=10, pady=8)
|
||
|
||
# ✅ JIKA READ-ONLY, TAMPILKAN WARNING & DISABLE TOMBOL
|
||
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.grid(row=2, column=0, columnspan=2, pady=10)
|
||
|
||
# Grid 2x2 untuk 4 tombol
|
||
btn1 = ttk.Button(
|
||
action_frame,
|
||
text="✅ Terima (Pending → Menunggu)",
|
||
command=lambda: self.update_order_status('menunggu') if not readonly else None,
|
||
width=35,
|
||
state='disabled' if readonly else 'normal'
|
||
)
|
||
btn1.grid(row=0, column=0, padx=4, pady=4, sticky='ew')
|
||
|
||
btn2 = ttk.Button(
|
||
action_frame,
|
||
text="🍳 Proses (Menunggu → Diproses)",
|
||
command=lambda: self.update_order_status('diproses') if not readonly else None,
|
||
width=35,
|
||
state='disabled' if readonly else 'normal'
|
||
)
|
||
btn2.grid(row=0, column=1, padx=4, pady=4, sticky='ew')
|
||
|
||
btn3 = ttk.Button(
|
||
action_frame,
|
||
text="🍽️ Selesai (Diproses → Selesai)",
|
||
command=lambda: self.update_order_status('selesai') if not readonly else None,
|
||
width=35,
|
||
state='disabled' if readonly else 'normal'
|
||
)
|
||
btn3.grid(row=1, column=0, padx=4, pady=4, sticky='ew')
|
||
|
||
btn4 = ttk.Button(
|
||
action_frame,
|
||
text="💰 Dibayar (Selesai → Dibayar)",
|
||
command=lambda: self.update_order_status('dibayar') if not readonly else None,
|
||
width=35,
|
||
state='disabled' if readonly else 'normal'
|
||
)
|
||
btn4.grid(row=1, column=1, padx=4, pady=4, sticky='ew')
|
||
|
||
action_frame.columnconfigure(0, weight=1)
|
||
action_frame.columnconfigure(1, weight=1)
|
||
|
||
# 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 yang dipilih"""
|
||
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 yang BENAR
|
||
valid_transitions = {
|
||
'pending': ['menunggu'],
|
||
'menunggu': ['diproses'],
|
||
'diproses': ['selesai'],
|
||
'selesai': ['dibayar']
|
||
}
|
||
|
||
# Cek apakah current status valid
|
||
if current_status not in valid_transitions:
|
||
messagebox.showerror("Error", f"Status '{current_status}' sudah final, tidak bisa diubah lagi")
|
||
return
|
||
|
||
# Cek apakah new_status valid untuk current_status
|
||
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
|
||
|
||
# Konfirmasi
|
||
if not messagebox.askyesno("Konfirmasi",
|
||
f"Ubah status pesanan #{transaksi_id}\n\n"
|
||
f"Dari: {current_status.upper()}\n"
|
||
f"Ke: {new_status.upper()}\n\n"
|
||
f"Lanjutkan?"):
|
||
return
|
||
|
||
# Update status
|
||
success = transaksi_update_status(transaksi_id, new_status)
|
||
|
||
if success:
|
||
messagebox.showinfo("✅ Berhasil", f"Status berhasil diubah menjadi '{new_status.upper()}'")
|
||
self.reload_waiter_orders()
|
||
# 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)
|
||
# Trigger event untuk reload detail
|
||
self.on_waiter_select(None)
|
||
break
|
||
else:
|
||
messagebox.showerror("❌ Gagal", "Gagal mengubah status pesanan")
|
||
|
||
# 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 += " CAFE TOTORO MANIA\n"
|
||
struk += " Jl. Raya Kampus 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'
|
||
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()
|
||
|
||
# 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 == '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() |