4711 lines
190 KiB
Python
4711 lines
190 KiB
Python
"""
|
||
SISTEM MANAGEMENT CAFE - ULTIMATE EDITION (FIXED & COMPLETE)
|
||
==============================================================
|
||
|
||
✅ SEMUA FITUR SUDAH DIIMPLEMENTASI & TERINTEGRASI
|
||
✅ SEMUA BUG SUDAH DIPERBAIKI
|
||
✅ PEMBELI SUDAH BISA MEMESAN
|
||
✅ KASIR SUDAH BISA TERIMA PEMBAYARAN (Cash/QRIS/E-Wallet)
|
||
|
||
""Akun Default:
|
||
- admin / admin123
|
||
- kasir / kasir123
|
||
- waiter / waiter123
|
||
- pembeli / user123
|
||
- owner / owner123
|
||
"""
|
||
|
||
import os
|
||
import csv
|
||
import shutil
|
||
import datetime
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, simpledialog, filedialog
|
||
from collections import defaultdict
|
||
import hashlib
|
||
|
||
# Image support
|
||
try:
|
||
from PIL import Image, ImageTk
|
||
PIL_AVAILABLE = True
|
||
except:
|
||
PIL_AVAILABLE = False
|
||
|
||
# QRCode support
|
||
try:
|
||
import qrcode
|
||
from io import BytesIO
|
||
QRCODE_AVAILABLE = True
|
||
except:
|
||
QRCODE_AVAILABLE = False
|
||
|
||
# Matplotlib support
|
||
try:
|
||
import matplotlib
|
||
matplotlib.use('TkAgg')
|
||
from matplotlib.figure import Figure
|
||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||
MATPLOTLIB_AVAILABLE = True
|
||
except:
|
||
MATPLOTLIB_AVAILABLE = False
|
||
|
||
# ================================
|
||
# KONFIGURASI
|
||
# ================================
|
||
|
||
DATA_DIR = "data"
|
||
IMAGES_DIR = "images"
|
||
|
||
CSV_USERS = os.path.join(DATA_DIR, "users.csv")
|
||
CSV_MENU = os.path.join(DATA_DIR, "menu.csv")
|
||
CSV_BAHAN = os.path.join(DATA_DIR, "bahan.csv")
|
||
CSV_RESEP = os.path.join(DATA_DIR, "resep.csv")
|
||
CSV_TRANSAKSI = os.path.join(DATA_DIR, "transaksi.csv")
|
||
CSV_DETAIL_TRANSAKSI = os.path.join(DATA_DIR, "detail_transaksi.csv")
|
||
CSV_MEJA = os.path.join(DATA_DIR, "meja.csv")
|
||
CSV_FAVORITES = os.path.join(DATA_DIR, "favorites.csv")
|
||
CSV_NOTIFIKASI = os.path.join(DATA_DIR, "notifikasi.csv")
|
||
CSV_PROMO_CODES = os.path.join(DATA_DIR, "promo_codes.csv")
|
||
CSV_PEMBAYARAN = os.path.join(DATA_DIR, "pembayaran.csv")
|
||
|
||
os.makedirs(DATA_DIR, exist_ok=True)
|
||
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||
|
||
# ================================
|
||
# GLOBAL DATABASE
|
||
# ================================
|
||
|
||
users = []
|
||
menu = []
|
||
bahan = {}
|
||
resep = {}
|
||
transaksi = []
|
||
detail_transaksi = []
|
||
data_meja = {}
|
||
favorites = {}
|
||
notifikasi = []
|
||
promo_codes = {}
|
||
pembayaran = []
|
||
_image_refs = {}
|
||
|
||
# ================================
|
||
# CSV UTILITY
|
||
# ================================
|
||
|
||
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_csv(path):
|
||
if not os.path.exists(path):
|
||
return []
|
||
try:
|
||
with open(path, newline="", encoding="utf-8") as f:
|
||
return list(csv.DictReader(f))
|
||
except:
|
||
return []
|
||
|
||
def write_csv(path, fieldnames, rows):
|
||
try:
|
||
with open(path, "w", newline="", encoding="utf-8") as f:
|
||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
except Exception as e:
|
||
print(f"Error writing {path}: {e}")
|
||
|
||
# ================================
|
||
# INIT DATA
|
||
# ================================
|
||
|
||
def init_default_data():
|
||
global users, menu, bahan, resep, data_meja, promo_codes
|
||
|
||
if not users:
|
||
users.extend([
|
||
{"id": "1", "username": "admin", "password": "admin123", "role": "admin"},
|
||
{"id": "2", "username": "kasir", "password": "kasir123", "role": "kasir"},
|
||
{"id": "3", "username": "waiter", "password": "waiter123", "role": "waiter"},
|
||
{"id": "4", "username": "pembeli", "password": "user123", "role": "pembeli"},
|
||
{"id": "5", "username": "owner", "password": "owner123", "role": "owner"},
|
||
])
|
||
|
||
if not menu:
|
||
menu.extend([
|
||
{"id": 1, "nama": "Americano", "harga": 20000, "kategori": "Minuman", "stok": 50, "foto": "", "promo": "", "item_discount_pct": 0},
|
||
{"id": 2, "nama": "Latte", "harga": 25000, "kategori": "Minuman", "stok": 45, "foto": "", "promo": "Diskon 10%", "item_discount_pct": 10},
|
||
{"id": 3, "nama": "Nasi Goreng", "harga": 35000, "kategori": "Makanan", "stok": 30, "foto": "", "promo": "", "item_discount_pct": 0},
|
||
{"id": 4, "nama": "Mie Goreng", "harga": 30000, "kategori": "Makanan", "stok": 35, "foto": "", "promo": "", "item_discount_pct": 0},
|
||
{"id": 5, "nama": "Es Teh", "harga": 5000, "kategori": "Minuman", "stok": 100, "foto": "", "promo": "", "item_discount_pct": 0},
|
||
{"id": 6, "nama": "Kopi Susu", "harga": 15000, "kategori": "Minuman", "stok": 60, "foto": "", "promo": "Diskon 5%", "item_discount_pct": 5},
|
||
{"id": 7, "nama": "Cappuccino", "harga": 28000, "kategori": "Minuman", "stok": 40, "foto": "", "promo": "", "item_discount_pct": 0},
|
||
{"id": 8, "nama": "Roti Bakar", "harga": 15000, "kategori": "Makanan", "stok": 25, "foto": "", "promo": "Diskon 15%", "item_discount_pct": 15},
|
||
{"id": 9, "nama": "Pasta Carbonara", "harga": 45000, "kategori": "Makanan", "stok": 20, "foto": "", "promo": "", "item_discount_pct": 0},
|
||
{"id": 10, "nama": "Smoothie Buah", "harga": 22000, "kategori": "Minuman", "stok": 35, "foto": "", "promo": "", "item_discount_pct": 0},
|
||
])
|
||
|
||
if not bahan:
|
||
bahan.update({
|
||
"kopi": 50, "susu": 30, "teh": 40, "nasi": 20,
|
||
"mie": 15, "telur": 25, "bumbu": 30
|
||
})
|
||
|
||
if not resep:
|
||
resep.update({
|
||
1: {"kopi": 1},
|
||
2: {"kopi": 1, "susu": 1},
|
||
3: {"nasi": 1, "telur": 1, "bumbu": 1},
|
||
4: {"mie": 1, "telur": 1, "bumbu": 1},
|
||
5: {"teh": 1},
|
||
6: {"kopi": 1, "susu": 1},
|
||
})
|
||
|
||
if not data_meja:
|
||
data_meja.update({i: "Kosong" for i in range(1, 11)})
|
||
|
||
if not promo_codes:
|
||
promo_codes.update({
|
||
"CAFE10": 10,
|
||
"CAFE20": 20,
|
||
"WELCOME": 15,
|
||
"SPECIAL25": 25
|
||
})
|
||
|
||
# ================================
|
||
# LOAD/SAVE FUNCTIONS
|
||
# ================================
|
||
|
||
def save_users():
|
||
write_csv(CSV_USERS, ["id", "username", "password", "role"], users)
|
||
|
||
def load_users():
|
||
global users
|
||
users = read_csv(CSV_USERS)
|
||
if not users:
|
||
init_default_data()
|
||
save_users()
|
||
|
||
def save_menu():
|
||
rows = []
|
||
for m in menu:
|
||
rows.append({
|
||
"id": str(m["id"]),
|
||
"nama": m["nama"],
|
||
"harga": str(m["harga"]),
|
||
"kategori": m["kategori"],
|
||
"stok": str(m["stok"]),
|
||
"foto": m.get("foto", ""),
|
||
"promo": m.get("promo", ""),
|
||
"item_discount_pct": str(m.get("item_discount_pct", 0))
|
||
})
|
||
write_csv(CSV_MENU, ["id", "nama", "harga", "kategori", "stok", "foto", "promo", "item_discount_pct"], rows)
|
||
|
||
def load_menu():
|
||
global menu
|
||
rows = read_csv(CSV_MENU)
|
||
menu = []
|
||
for r in rows:
|
||
try:
|
||
menu.append({
|
||
"id": int(r["id"]),
|
||
"nama": r["nama"],
|
||
"harga": int(float(r["harga"])),
|
||
"kategori": r["kategori"],
|
||
"stok": int(float(r["stok"])),
|
||
"foto": r.get("foto", ""),
|
||
"promo": r.get("promo", ""),
|
||
"item_discount_pct": float(r.get("item_discount_pct", 0))
|
||
})
|
||
except:
|
||
pass
|
||
if not menu:
|
||
init_default_data()
|
||
save_menu()
|
||
|
||
def save_bahan():
|
||
rows = [{"nama": k, "jumlah": str(v)} for k, v in bahan.items()]
|
||
write_csv(CSV_BAHAN, ["nama", "jumlah"], rows)
|
||
|
||
def load_bahan():
|
||
global bahan
|
||
rows = read_csv(CSV_BAHAN)
|
||
bahan = {}
|
||
for r in rows:
|
||
try:
|
||
bahan[r["nama"]] = int(float(r["jumlah"]))
|
||
except:
|
||
pass
|
||
if not bahan:
|
||
init_default_data()
|
||
save_bahan()
|
||
|
||
def save_resep():
|
||
rows = []
|
||
for menu_id, ingredients in resep.items():
|
||
for bahan_nama, qty in ingredients.items():
|
||
rows.append({"menu_id": str(menu_id), "bahan": bahan_nama, "jumlah": str(qty)})
|
||
write_csv(CSV_RESEP, ["menu_id", "bahan", "jumlah"], rows)
|
||
|
||
def load_resep():
|
||
global resep
|
||
rows = read_csv(CSV_RESEP)
|
||
resep = {}
|
||
for r in rows:
|
||
try:
|
||
mid = int(r["menu_id"])
|
||
if mid not in resep:
|
||
resep[mid] = {}
|
||
resep[mid][r["bahan"]] = int(float(r["jumlah"]))
|
||
except:
|
||
pass
|
||
if not resep:
|
||
init_default_data()
|
||
save_resep()
|
||
|
||
def save_transaksi():
|
||
rows = []
|
||
for t in transaksi:
|
||
items_str = ";".join([f"{it['nama']}x{it['qty']}@{it['harga_satuan']}" for it in t['items']])
|
||
rows.append({
|
||
"id": str(t['id']),
|
||
"tanggal": t['tanggal'],
|
||
"waktu": t['waktu'].strftime('%Y-%m-%d %H:%M:%S'),
|
||
"user": t['user'],
|
||
"items": items_str,
|
||
"subtotal": str(t['subtotal']),
|
||
"diskon": str(t['diskon']),
|
||
"total": str(t['total']),
|
||
"meja": str(t.get('meja', '')),
|
||
"status": t['status'],
|
||
"payment_status": t.get('payment_status', 'Pending'),
|
||
"payment_method": t.get('payment_method', ''),
|
||
"paid_amount": str(t.get('paid_amount', 0)),
|
||
"change": str(t.get('change', 0)),
|
||
"promo_code": t.get('promo_code', '')
|
||
})
|
||
write_csv(CSV_TRANSAKSI, ["id", "tanggal", "waktu", "user", "items", "subtotal", "diskon", "total", "meja", "status", "payment_status", "payment_method", "paid_amount", "change", "promo_code"], rows)
|
||
|
||
def load_transaksi():
|
||
global transaksi
|
||
rows = read_csv(CSV_TRANSAKSI)
|
||
transaksi = []
|
||
for r in rows:
|
||
try:
|
||
items = []
|
||
if r['items']:
|
||
for item_str in r['items'].split(';'):
|
||
parts = item_str.split('x')
|
||
nama = parts[0]
|
||
qty_price = parts[1].split('@')
|
||
items.append({
|
||
'nama': nama,
|
||
'qty': int(qty_price[0]),
|
||
'harga_satuan': int(qty_price[1]),
|
||
'subtotal': int(qty_price[0]) * int(qty_price[1])
|
||
})
|
||
|
||
transaksi.append({
|
||
'id': int(r['id']),
|
||
'tanggal': r['tanggal'],
|
||
'waktu': datetime.datetime.strptime(r['waktu'], '%Y-%m-%d %H:%M:%S'),
|
||
'user': r['user'],
|
||
'items': items,
|
||
'subtotal': int(float(r['subtotal'])),
|
||
'diskon': int(float(r['diskon'])),
|
||
'total': int(float(r['total'])),
|
||
'meja': int(r['meja']) if r['meja'] else None,
|
||
'status': r['status'],
|
||
'payment_status': r.get('payment_status', 'Pending'),
|
||
'payment_method': r.get('payment_method', ''),
|
||
'paid_amount': int(float(r.get('paid_amount', 0))),
|
||
'change': int(float(r.get('change', 0))),
|
||
'promo_code': r.get('promo_code', '')
|
||
})
|
||
except:
|
||
pass
|
||
|
||
def save_detail_transaksi():
|
||
rows = []
|
||
for d in detail_transaksi:
|
||
rows.append({
|
||
"id": str(d['id']),
|
||
"transaksi_id": str(d['transaksi_id']),
|
||
"menu_id": str(d['menu_id']),
|
||
"nama_menu": d['nama_menu'],
|
||
"jumlah": str(d['jumlah']),
|
||
"harga_satuan": str(d['harga_satuan']),
|
||
"subtotal": str(d['subtotal']),
|
||
"diskon": str(d['diskon'])
|
||
})
|
||
write_csv(CSV_DETAIL_TRANSAKSI, ["id", "transaksi_id", "menu_id", "nama_menu", "jumlah", "harga_satuan", "subtotal", "diskon"], rows)
|
||
|
||
def load_detail_transaksi():
|
||
global detail_transaksi
|
||
rows = read_csv(CSV_DETAIL_TRANSAKSI)
|
||
detail_transaksi = []
|
||
for r in rows:
|
||
try:
|
||
detail_transaksi.append({
|
||
'id': int(r['id']),
|
||
'transaksi_id': int(r['transaksi_id']),
|
||
'menu_id': int(r['menu_id']),
|
||
'nama_menu': r['nama_menu'],
|
||
'jumlah': int(r['jumlah']),
|
||
'harga_satuan': int(float(r['harga_satuan'])),
|
||
'subtotal': int(float(r['subtotal'])),
|
||
'diskon': int(float(r['diskon']))
|
||
})
|
||
except:
|
||
pass
|
||
|
||
def save_meja():
|
||
rows = [{"nomor": str(k), "status": v} for k, v in data_meja.items()]
|
||
write_csv(CSV_MEJA, ["nomor", "status"], rows)
|
||
|
||
def load_meja():
|
||
global data_meja
|
||
rows = read_csv(CSV_MEJA)
|
||
data_meja = {}
|
||
for r in rows:
|
||
try:
|
||
data_meja[int(r['nomor'])] = r['status']
|
||
except:
|
||
pass
|
||
if not data_meja:
|
||
init_default_data()
|
||
save_meja()
|
||
|
||
def save_favorites():
|
||
rows = []
|
||
for username, menu_ids in favorites.items():
|
||
rows.append({"username": username, "menu_ids": ",".join(map(str, menu_ids))})
|
||
write_csv(CSV_FAVORITES, ["username", "menu_ids"], rows)
|
||
|
||
def load_favorites():
|
||
global favorites
|
||
rows = read_csv(CSV_FAVORITES)
|
||
favorites = {}
|
||
for r in rows:
|
||
try:
|
||
if r['menu_ids']:
|
||
favorites[r['username']] = [int(x) for x in r['menu_ids'].split(',')]
|
||
else:
|
||
favorites[r['username']] = []
|
||
except:
|
||
favorites[r['username']] = []
|
||
|
||
def save_notifikasi():
|
||
write_csv(CSV_NOTIFIKASI, ["id", "timestamp", "type", "message", "read"], notifikasi)
|
||
|
||
def load_notifikasi():
|
||
global notifikasi
|
||
rows = read_csv(CSV_NOTIFIKASI)
|
||
notifikasi = []
|
||
for r in rows:
|
||
try:
|
||
notifikasi.append({
|
||
'id': int(r['id']),
|
||
'timestamp': r['timestamp'],
|
||
'type': r['type'],
|
||
'message': r['message'],
|
||
'read': r['read'] == 'True'
|
||
})
|
||
except:
|
||
pass
|
||
|
||
def save_promo_codes():
|
||
rows = [{"code": code, "discount_percent": str(discount)} for code, discount in promo_codes.items()]
|
||
write_csv(CSV_PROMO_CODES, ["code", "discount_percent"], rows)
|
||
|
||
def load_promo_codes():
|
||
global promo_codes
|
||
rows = read_csv(CSV_PROMO_CODES)
|
||
promo_codes = {}
|
||
for r in rows:
|
||
try:
|
||
promo_codes[r['code']] = int(r['discount_percent'])
|
||
except:
|
||
pass
|
||
if not promo_codes:
|
||
init_default_data()
|
||
save_promo_codes()
|
||
|
||
def save_pembayaran():
|
||
rows = []
|
||
for p in pembayaran:
|
||
rows.append({
|
||
"id": str(p['id']),
|
||
"transaksi_id": str(p['transaksi_id']),
|
||
"metode": p['metode'],
|
||
"jumlah": str(p['jumlah']),
|
||
"status": p['status'],
|
||
"tanggal": p['tanggal']
|
||
})
|
||
write_csv(CSV_PEMBAYARAN, ["id", "transaksi_id", "metode", "jumlah", "status", "tanggal"], rows)
|
||
|
||
def load_pembayaran():
|
||
global pembayaran
|
||
rows = read_csv(CSV_PEMBAYARAN)
|
||
pembayaran = []
|
||
for r in rows:
|
||
try:
|
||
pembayaran.append({
|
||
'id': int(r['id']),
|
||
'transaksi_id': int(r['transaksi_id']),
|
||
'metode': r['metode'],
|
||
'jumlah': int(float(r['jumlah'])),
|
||
'status': r['status'],
|
||
'tanggal': r['tanggal']
|
||
})
|
||
except:
|
||
pass
|
||
|
||
def load_all_data():
|
||
ensure_file(CSV_USERS, ["id", "username", "password", "role"])
|
||
ensure_file(CSV_MENU, ["id", "nama", "harga", "kategori", "stok", "foto", "promo", "item_discount_pct"])
|
||
ensure_file(CSV_BAHAN, ["nama", "jumlah"])
|
||
ensure_file(CSV_RESEP, ["menu_id", "bahan", "jumlah"])
|
||
ensure_file(CSV_TRANSAKSI, ["id", "tanggal", "waktu", "user", "items", "subtotal", "diskon", "total", "meja", "status", "payment_status", "payment_method", "paid_amount", "change", "promo_code"])
|
||
ensure_file(CSV_DETAIL_TRANSAKSI, ["id", "transaksi_id", "menu_id", "nama_menu", "jumlah", "harga_satuan", "subtotal", "diskon"])
|
||
ensure_file(CSV_MEJA, ["nomor", "status"])
|
||
ensure_file(CSV_FAVORITES, ["username", "menu_ids"])
|
||
ensure_file(CSV_NOTIFIKASI, ["id", "timestamp", "type", "message", "read"])
|
||
ensure_file(CSV_PROMO_CODES, ["code", "discount_percent"])
|
||
ensure_file(CSV_PEMBAYARAN, ["id", "transaksi_id", "metode", "jumlah", "status", "tanggal"])
|
||
|
||
load_users()
|
||
load_menu()
|
||
load_bahan()
|
||
load_resep()
|
||
load_transaksi()
|
||
load_detail_transaksi()
|
||
load_meja()
|
||
load_favorites()
|
||
load_notifikasi()
|
||
load_promo_codes()
|
||
load_pembayaran()
|
||
add_sample_transactions()
|
||
|
||
# ================================
|
||
# HELPER FUNCTIONS
|
||
# ================================
|
||
|
||
def find_menu_by_id(mid):
|
||
for it in menu:
|
||
if it["id"] == mid:
|
||
return it
|
||
return None
|
||
|
||
def format_currency(n):
|
||
try:
|
||
return f"Rp{int(n):,}".replace(",", ".")
|
||
except:
|
||
return f"Rp{n}"
|
||
|
||
def calculate_discount(harga, promo_text):
|
||
if not promo_text:
|
||
return 0
|
||
try:
|
||
import re
|
||
match = re.search(r'(\d+)%', promo_text)
|
||
if match:
|
||
percent = int(match.group(1))
|
||
return int(harga * percent / 100)
|
||
except:
|
||
pass
|
||
return 0
|
||
|
||
def add_notification(ntype, message):
|
||
global notifikasi
|
||
new_id = max([n['id'] for n in notifikasi], default=0) + 1
|
||
notifikasi.append({
|
||
'id': new_id,
|
||
'timestamp': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||
'type': ntype,
|
||
'message': message,
|
||
'read': False
|
||
})
|
||
save_notifikasi()
|
||
|
||
def copy_image_to_project(src_path):
|
||
if not src_path or not os.path.exists(src_path):
|
||
return ""
|
||
try:
|
||
filename = os.path.basename(src_path)
|
||
name, ext = os.path.splitext(filename)
|
||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
new_filename = f"{name}_{timestamp}{ext}"
|
||
dest_path = os.path.join(IMAGES_DIR, new_filename)
|
||
shutil.copy2(src_path, dest_path)
|
||
return dest_path
|
||
except Exception as e:
|
||
print(f"Error copying image: {e}")
|
||
return ""
|
||
|
||
def ensure_image(path, maxsize=(240, 160)):
|
||
if not path or not os.path.exists(path):
|
||
return None
|
||
key = (path, maxsize)
|
||
if key in _image_refs:
|
||
return _image_refs[key]
|
||
try:
|
||
if PIL_AVAILABLE:
|
||
img = Image.open(path)
|
||
img.thumbnail(maxsize)
|
||
tkimg = ImageTk.PhotoImage(img)
|
||
else:
|
||
tkimg = tk.PhotoImage(file=path)
|
||
_image_refs[key] = tkimg
|
||
return tkimg
|
||
except:
|
||
return None
|
||
|
||
def reset_image_refs():
|
||
_image_refs.clear()
|
||
|
||
def get_daily_report(date_str):
|
||
trans = [t for t in transaksi if t['tanggal'] == date_str and t.get('payment_status') == 'Berhasil']
|
||
total_omzet = sum(t['total'] for t in trans)
|
||
total_transaksi = len(trans)
|
||
|
||
menu_count = defaultdict(int)
|
||
for t in trans:
|
||
for item in t['items']:
|
||
menu_count[item['nama']] += item['qty']
|
||
|
||
return {
|
||
'date': date_str,
|
||
'total_omzet': total_omzet,
|
||
'total_transaksi': total_transaksi,
|
||
'menu_terlaris': dict(menu_count)
|
||
}
|
||
|
||
def add_sample_transactions():
|
||
"""Tambahkan data transaksi sample untuk demo"""
|
||
global transaksi, detail_transaksi
|
||
|
||
if len(transaksi) < 5:
|
||
today = str(datetime.date.today())
|
||
yesterday = str(datetime.date.today() - datetime.timedelta(days=1))
|
||
two_days_ago = str(datetime.date.today() - datetime.timedelta(days=2))
|
||
|
||
sample_data = [
|
||
{'tanggal': today, 'items': [{'nama': 'Americano', 'qty': 2, 'harga_satuan': 20000}, {'nama': 'Es Teh', 'qty': 1, 'harga_satuan': 5000}], 'total': 45000, 'meja': 1},
|
||
{'tanggal': today, 'items': [{'nama': 'Latte', 'qty': 3, 'harga_satuan': 25000}, {'nama': 'Roti Bakar', 'qty': 2, 'harga_satuan': 15000}], 'total': 105000, 'meja': 2},
|
||
{'tanggal': today, 'items': [{'nama': 'Nasi Goreng', 'qty': 2, 'harga_satuan': 35000}, {'nama': 'Kopi Susu', 'qty': 2, 'harga_satuan': 15000}], 'total': 100000, 'meja': 3},
|
||
{'tanggal': today, 'items': [{'nama': 'Mie Goreng', 'qty': 1, 'harga_satuan': 30000}, {'nama': 'Cappuccino', 'qty': 1, 'harga_satuan': 28000}], 'total': 58000, 'meja': 4},
|
||
{'tanggal': yesterday, 'items': [{'nama': 'Pasta Carbonara', 'qty': 2, 'harga_satuan': 45000}, {'nama': 'Smoothie Buah', 'qty': 3, 'harga_satuan': 22000}], 'total': 156000, 'meja': 5},
|
||
{'tanggal': yesterday, 'items': [{'nama': 'Americano', 'qty': 4, 'harga_satuan': 20000}, {'nama': 'Es Teh', 'qty': 2, 'harga_satuan': 5000}], 'total': 90000, 'meja': 6},
|
||
{'tanggal': two_days_ago, 'items': [{'nama': 'Latte', 'qty': 2, 'harga_satuan': 25000}, {'nama': 'Nasi Goreng', 'qty': 1, 'harga_satuan': 35000}], 'total': 85000, 'meja': 7},
|
||
]
|
||
|
||
for idx, data in enumerate(sample_data, start=1):
|
||
trans_id = idx
|
||
transaksi.append({
|
||
'id': trans_id,
|
||
'tanggal': data['tanggal'],
|
||
'waktu': datetime.datetime.now() - datetime.timedelta(hours=idx),
|
||
'user': 'waiter',
|
||
'items': data['items'],
|
||
'subtotal': data['total'],
|
||
'diskon': 0,
|
||
'total': data['total'],
|
||
'meja': data['meja'],
|
||
'status': 'Selesai',
|
||
'payment_status': 'Berhasil',
|
||
'payment_method': 'cash' if idx % 2 == 0 else 'qris',
|
||
'paid_amount': data['total'],
|
||
'change': 0,
|
||
'promo_code': ''
|
||
})
|
||
|
||
def get_payment_summary(start_date, end_date):
|
||
trans = [t for t in transaksi
|
||
if start_date <= t['tanggal'] <= end_date
|
||
and t.get('payment_status') == 'Berhasil']
|
||
|
||
total = sum(t['total'] for t in trans)
|
||
count = len(trans)
|
||
|
||
method_breakdown = defaultdict(int)
|
||
for t in trans:
|
||
method = t.get('payment_method', 'Unknown')
|
||
method_breakdown[method] += 1
|
||
|
||
return {
|
||
'total_income': total,
|
||
'total_count': count,
|
||
'avg': total / count if count > 0 else 0,
|
||
'method_breakdown': dict(method_breakdown)
|
||
}
|
||
|
||
# ================================
|
||
# MAIN APP CLASS
|
||
# ================================
|
||
|
||
class CafeApp:
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title("Sistem Management Cafe - Ultimate Edition (FIXED)")
|
||
self.root.geometry("1200x700")
|
||
self.root.minsize(1000, 600)
|
||
|
||
# Session
|
||
self.current_user = None
|
||
self.cart_items = []
|
||
|
||
# Load data
|
||
load_all_data()
|
||
|
||
# Style
|
||
self.setup_styles()
|
||
|
||
# Start
|
||
self.show_welcome_screen()
|
||
|
||
def setup_styles(self):
|
||
style = ttk.Style()
|
||
style.theme_use('clam')
|
||
style.configure("Accent.TButton",
|
||
background="#8B7355",
|
||
foreground="white",
|
||
font=("Arial", 10, "bold"))
|
||
style.map("Accent.TButton",
|
||
background=[('active', '#6B5335')])
|
||
|
||
# ================================
|
||
# WELCOME & LOGIN
|
||
# ================================
|
||
|
||
def show_welcome_screen(self):
|
||
self._clear_root()
|
||
self.root.configure(bg="#F5F5F0")
|
||
|
||
main_frame = tk.Frame(self.root, bg="#F5F5F0")
|
||
main_frame.place(relx=0.5, rely=0.5, anchor="center")
|
||
|
||
title_frame = tk.Frame(main_frame, bg="#F5F5F0")
|
||
title_frame.pack(pady=(0, 30))
|
||
|
||
tk.Label(title_frame, text="☕", font=("Segoe UI", 64), bg="#F5F5F0", fg="#8B7355").pack()
|
||
tk.Label(title_frame, text="CAFE MANAGEMENT", font=("Segoe UI", 32, "bold"),
|
||
bg="#F5F5F0", fg="#5C4033").pack(pady=(10, 0))
|
||
tk.Label(title_frame, text="Ultimate Edition - FIXED VERSION", font=("Segoe UI", 14),
|
||
bg="#F5F5F0", fg="#999999").pack()
|
||
|
||
btn_frame = tk.Frame(main_frame, bg="#F5F5F0")
|
||
btn_frame.pack(pady=40)
|
||
|
||
guest_btn = tk.Button(btn_frame, text="🌐 Browse Sebagai Guest",
|
||
command=self.enter_as_guest,
|
||
font=("Segoe UI", 12, "bold"),
|
||
bg="#27AE60", fg="white",
|
||
relief="flat", bd=0,
|
||
padx=40, pady=15, cursor="hand2")
|
||
guest_btn.pack(pady=10)
|
||
guest_btn.bind("<Enter>", lambda e: guest_btn.configure(bg="#229954"))
|
||
guest_btn.bind("<Leave>", lambda e: guest_btn.configure(bg="#27AE60"))
|
||
|
||
login_btn = tk.Button(btn_frame, text="🔐 Login Sebagai Staff",
|
||
command=self.show_login_screen,
|
||
font=("Segoe UI", 12, "bold"),
|
||
bg="#8B7355", fg="white",
|
||
relief="flat", bd=0,
|
||
padx=40, pady=15, cursor="hand2")
|
||
login_btn.pack(pady=10)
|
||
login_btn.bind("<Enter>", lambda e: login_btn.configure(bg="#6B5335"))
|
||
login_btn.bind("<Leave>", lambda e: login_btn.configure(bg="#8B7355"))
|
||
|
||
tk.Label(main_frame,
|
||
text="Guest dapat melihat menu tanpa login\nStaff perlu login untuk akses penuh",
|
||
font=("Segoe UI", 9), bg="#F5F5F0", fg="#999999",
|
||
justify="center").pack(pady=(20, 5))
|
||
|
||
def enter_as_guest(self):
|
||
self.current_user = {
|
||
'id': 0,
|
||
'username': 'Guest',
|
||
'role': 'guest'
|
||
}
|
||
messagebox.showinfo("Welcome", "Selamat datang, Guest!\n\nAnda dapat melihat menu kami.")
|
||
self.build_main_ui()
|
||
|
||
def show_login_screen(self):
|
||
self._clear_root()
|
||
self.root.configure(bg="#F5F5F0")
|
||
|
||
main_frame = tk.Frame(self.root, bg="#F5F5F0")
|
||
main_frame.place(relx=0.5, rely=0.5, anchor="center")
|
||
|
||
title_frame = tk.Frame(main_frame, bg="#F5F5F0")
|
||
title_frame.pack(pady=(0, 30))
|
||
|
||
tk.Label(title_frame, text="☕", font=("Segoe UI", 48), bg="#F5F5F0", fg="#8B7355").pack()
|
||
tk.Label(title_frame, text="STAFF LOGIN", font=("Segoe UI", 24, "bold"),
|
||
bg="#F5F5F0", fg="#5C4033").pack(pady=(5, 0))
|
||
|
||
form_frame = tk.Frame(main_frame, bg="white", relief="flat", bd=0)
|
||
form_frame.pack(pady=20, padx=40)
|
||
|
||
inner_form = tk.Frame(form_frame, bg="white")
|
||
inner_form.pack(padx=50, pady=40)
|
||
|
||
tk.Label(inner_form, text="Username", font=("Segoe UI", 10),
|
||
bg="white", fg="#666666", anchor="w").grid(row=0, column=0, sticky="w", pady=(0, 5))
|
||
self.e_user = tk.Entry(inner_form, font=("Segoe UI", 11), relief="solid",
|
||
bd=1, bg="#FAFAFA", fg="#333333", width=28)
|
||
self.e_user.grid(row=1, column=0, pady=(0, 20), ipady=8)
|
||
|
||
tk.Label(inner_form, text="Password", font=("Segoe UI", 10),
|
||
bg="white", fg="#666666", anchor="w").grid(row=2, column=0, sticky="w", pady=(0, 5))
|
||
self.e_pass = tk.Entry(inner_form, show="●", font=("Segoe UI", 11),
|
||
relief="solid", bd=1, bg="#FAFAFA", fg="#333333", width=28)
|
||
self.e_pass.grid(row=3, column=0, pady=(0, 25), ipady=8)
|
||
|
||
login_btn = tk.Button(inner_form, text="Login", font=("Segoe UI", 11, "bold"),
|
||
bg="#8B7355", fg="white", relief="flat", bd=0,
|
||
command=self.handle_login, cursor="hand2", width=28, pady=12)
|
||
login_btn.grid(row=4, column=0)
|
||
login_btn.bind("<Enter>", lambda e: login_btn.configure(bg="#6B5335"))
|
||
login_btn.bind("<Leave>", lambda e: login_btn.configure(bg="#8B7355"))
|
||
|
||
self.e_pass.bind("<Return>", lambda e: self.handle_login())
|
||
|
||
back_btn = tk.Button(main_frame, text="← Kembali",
|
||
command=self.show_welcome_screen,
|
||
font=("Segoe UI", 9), bg="#E8E0D5", fg="#5C4033",
|
||
relief="flat", bd=0, padx=20, pady=8, cursor="hand2")
|
||
back_btn.pack(pady=(15, 0))
|
||
|
||
tk.Label(main_frame, text="Default: admin/admin123, kasir/kasir123, pembeli/user123",
|
||
font=("Segoe UI", 9), bg="#F5F5F0", fg="#999999").pack(pady=(10, 0))
|
||
|
||
def handle_login(self):
|
||
u = self.e_user.get().strip()
|
||
p = self.e_pass.get().strip()
|
||
|
||
for usr in users:
|
||
if usr["username"] == u and usr["password"] == p:
|
||
self.current_user = {
|
||
'id': int(usr['id']),
|
||
'username': usr['username'],
|
||
'role': usr['role']
|
||
}
|
||
messagebox.showinfo("Login", f"Berhasil login sebagai {u} ({usr['role']})")
|
||
self.build_main_ui()
|
||
return
|
||
|
||
messagebox.showerror("Login Gagal", "Username atau password salah.")
|
||
|
||
def _clear_root(self):
|
||
for w in self.root.winfo_children():
|
||
w.destroy()
|
||
|
||
def logout(self):
|
||
self.current_user = None
|
||
self.cart_items = []
|
||
self.show_welcome_screen()
|
||
|
||
# ================================
|
||
# MAIN UI
|
||
# ================================
|
||
|
||
def build_main_ui(self):
|
||
self._clear_root()
|
||
self.root.configure(bg="#F5F5F0")
|
||
|
||
# Top bar
|
||
topbar = tk.Frame(self.root, bg="#5C4033", height=60)
|
||
topbar.pack(fill="x")
|
||
topbar.pack_propagate(False)
|
||
|
||
left_header = tk.Frame(topbar, bg="#5C4033")
|
||
left_header.pack(side="left", padx=20, pady=10)
|
||
tk.Label(left_header, text="☕ CAFE MANAGEMENT", font=("Segoe UI", 14, "bold"),
|
||
bg="#5C4033", fg="white").pack(side="left")
|
||
|
||
role_text = self.current_user['role'].upper() if self.current_user['role'] != 'guest' else 'GUEST MODE'
|
||
tk.Label(left_header, text=f" | {self.current_user['username']} ({role_text})",
|
||
font=("Segoe UI", 10), bg="#5C4033", fg="#D4AF77").pack(side="left")
|
||
|
||
right_header = tk.Frame(topbar, bg="#5C4033")
|
||
right_header.pack(side="right", padx=20, pady=10)
|
||
|
||
# Notifikasi button (untuk staff saja)
|
||
if self.current_user['role'] != 'guest':
|
||
unread = len([n for n in notifikasi if not n['read']])
|
||
notif_text = f"🔔 ({unread})" if unread > 0 else "🔔"
|
||
notif_btn = tk.Button(right_header, text=notif_text,
|
||
command=self.open_notifikasi_window,
|
||
font=("Arial", 9), bg="#6B5335", fg="white",
|
||
relief="flat", bd=0, padx=15, pady=8, cursor="hand2")
|
||
notif_btn.pack(side="right", padx=3)
|
||
|
||
# Logout button
|
||
logout_btn = tk.Button(right_header, text="🚪 Logout", command=self.logout,
|
||
font=("Arial", 9), bg="#A0522D", fg="white",
|
||
relief="flat", bd=0, padx=15, pady=8, cursor="hand2")
|
||
logout_btn.pack(side="right", padx=3)
|
||
|
||
# Main content
|
||
main = ttk.Notebook(self.root)
|
||
main.pack(fill='both', expand=True, padx=10, pady=10)
|
||
|
||
# Build tabs based on role
|
||
if self.current_user['role'] == 'guest':
|
||
self.build_guest_tabs(main)
|
||
elif self.current_user['role'] == 'pembeli':
|
||
self.build_pembeli_tabs(main)
|
||
elif self.current_user['role'] == 'kasir':
|
||
self.build_kasir_tabs(main)
|
||
elif self.current_user['role'] == 'waiter':
|
||
self.build_waiter_tabs(main)
|
||
elif self.current_user['role'] == 'owner':
|
||
self.build_owner_tabs(main)
|
||
elif self.current_user['role'] == 'admin':
|
||
self.build_admin_tabs(main)
|
||
|
||
# ================================
|
||
# TAB BUILDERS
|
||
# ================================
|
||
|
||
def build_guest_tabs(self, notebook):
|
||
tab_menu = ttk.Frame(notebook)
|
||
notebook.add(tab_menu, text="📖 Lihat Menu")
|
||
self.build_menu_browse_tab(tab_menu, guest_mode=True)
|
||
|
||
def build_pembeli_tabs(self, notebook):
|
||
tab_menu = ttk.Frame(notebook)
|
||
tab_order = ttk.Frame(notebook)
|
||
tab_fav = ttk.Frame(notebook)
|
||
|
||
notebook.add(tab_menu, text="📖 Menu")
|
||
notebook.add(tab_order, text="🛒 Order")
|
||
notebook.add(tab_fav, text="⭐ Favorit")
|
||
|
||
self.build_menu_browse_tab(tab_menu)
|
||
self.build_order_tab(tab_order)
|
||
self.build_favorite_tab(tab_fav)
|
||
|
||
def build_kasir_tabs(self, notebook):
|
||
tab_payment = ttk.Frame(notebook)
|
||
tab_riwayat = ttk.Frame(notebook)
|
||
|
||
notebook.add(tab_payment, text="💰 Pembayaran")
|
||
notebook.add(tab_riwayat, text="📜 Riwayat")
|
||
|
||
self.build_payment_tab(tab_payment)
|
||
self.build_riwayat_tab(tab_riwayat)
|
||
|
||
def build_waiter_tabs(self, notebook):
|
||
tab_pesanan = ttk.Frame(notebook)
|
||
tab_meja = ttk.Frame(notebook)
|
||
|
||
notebook.add(tab_pesanan, text="🍽️ Pesanan")
|
||
notebook.add(tab_meja, text="🪑 Meja")
|
||
|
||
self.build_waiter_pesanan_tab(tab_pesanan)
|
||
self.build_meja_tab(tab_meja)
|
||
|
||
def build_owner_tabs(self, notebook):
|
||
tab_laporan = ttk.Frame(notebook)
|
||
tab_riwayat = ttk.Frame(notebook)
|
||
|
||
notebook.add(tab_laporan, text="📊 Laporan")
|
||
notebook.add(tab_riwayat, text="📜 Riwayat")
|
||
|
||
self.build_laporan_tab(tab_laporan)
|
||
self.build_riwayat_tab(tab_riwayat)
|
||
|
||
def build_admin_tabs(self, notebook):
|
||
tab_menu_manage = ttk.Frame(notebook)
|
||
tab_bahan = ttk.Frame(notebook)
|
||
tab_promo = ttk.Frame(notebook)
|
||
tab_user = ttk.Frame(notebook)
|
||
tab_laporan = ttk.Frame(notebook)
|
||
|
||
notebook.add(tab_menu_manage, text="⚙️ Kelola Menu")
|
||
notebook.add(tab_bahan, text="📦 Stok Bahan")
|
||
notebook.add(tab_promo, text="🎁 Promo")
|
||
notebook.add(tab_user, text="👥 User")
|
||
notebook.add(tab_laporan, text="📊 Laporan")
|
||
|
||
self.build_menu_manage_tab(tab_menu_manage)
|
||
self.build_bahan_tab(tab_bahan)
|
||
self.build_promo_tab(tab_promo)
|
||
self.build_user_tab(tab_user)
|
||
self.build_laporan_tab(tab_laporan)
|
||
|
||
# ================================
|
||
# TAB: MENU BROWSE
|
||
# ================================
|
||
|
||
def build_menu_browse_tab(self, parent, guest_mode=False):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = tk.Frame(parent, bg="#F5F5F0")
|
||
header.pack(fill="x", padx=15, pady=10)
|
||
|
||
tk.Label(header, text="🍽️ Menu Cafe", font=("Arial", 18, "bold"),
|
||
bg="#F5F5F0", fg="#5C4033").pack(side="left")
|
||
|
||
if guest_mode:
|
||
info = tk.Label(header,
|
||
text="ℹ️ Mode Guest - Login untuk memesan",
|
||
font=("Arial", 10), bg="#FFF3CD", fg="#856404",
|
||
padx=10, pady=5)
|
||
info.pack(side="right")
|
||
|
||
search_frame = tk.Frame(parent, bg="white", relief="solid", bd=1)
|
||
search_frame.pack(fill="x", padx=15, pady=5)
|
||
|
||
inner_search = tk.Frame(search_frame, bg="white")
|
||
inner_search.pack(padx=10, pady=8)
|
||
|
||
tk.Label(inner_search, text="🔍 Cari:", font=("Arial", 9),
|
||
bg="white").pack(side="left", padx=5)
|
||
|
||
self.menu_search_var = tk.StringVar()
|
||
tk.Entry(inner_search, textvariable=self.menu_search_var,
|
||
font=("Arial", 10), width=25).pack(side="left", padx=5)
|
||
|
||
tk.Button(inner_search, text="Cari", command=self.refresh_menu_browse,
|
||
font=("Arial", 9), bg="#8B7355", fg="white",
|
||
relief="flat", padx=15, pady=5).pack(side="left", padx=5)
|
||
|
||
tk.Label(inner_search, text="📂 Kategori:", font=("Arial", 9),
|
||
bg="white").pack(side="left", padx=(15, 5))
|
||
|
||
self.menu_filter_var = tk.StringVar(value="Semua")
|
||
categories = ["Semua"] + sorted(set(m['kategori'] for m in menu))
|
||
filter_cb = ttk.Combobox(inner_search, textvariable=self.menu_filter_var,
|
||
values=categories, state="readonly", width=15)
|
||
filter_cb.pack(side="left", padx=5)
|
||
filter_cb.bind("<<ComboboxSelected>>", lambda e: self.refresh_menu_browse())
|
||
|
||
container = tk.Frame(parent, bg="white")
|
||
container.pack(fill="both", expand=True, padx=15, pady=5)
|
||
|
||
canvas = tk.Canvas(container, bg="white", highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(container, orient="vertical", command=canvas.yview)
|
||
self.menu_browse_frame = tk.Frame(canvas, bg="white")
|
||
|
||
canvas.configure(yscrollcommand=scrollbar.set)
|
||
scrollbar.pack(side="right", fill="y")
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
canvas_window = canvas.create_window((0, 0), window=self.menu_browse_frame, anchor="nw")
|
||
|
||
def configure_scroll(event):
|
||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||
|
||
self.menu_browse_frame.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)
|
||
|
||
self.refresh_menu_browse()
|
||
|
||
def refresh_menu_browse(self):
|
||
for widget in self.menu_browse_frame.winfo_children():
|
||
widget.destroy()
|
||
|
||
search = self.menu_search_var.get().lower() if hasattr(self, 'menu_search_var') else ""
|
||
kategori = self.menu_filter_var.get() if hasattr(self, 'menu_filter_var') else "Semua"
|
||
|
||
filtered = []
|
||
for m in menu:
|
||
if search and search not in m['nama'].lower():
|
||
continue
|
||
if kategori != "Semua" and m['kategori'] != kategori:
|
||
continue
|
||
filtered.append(m)
|
||
|
||
cols = 3
|
||
row = 0
|
||
col = 0
|
||
|
||
for m in filtered:
|
||
card = tk.Frame(self.menu_browse_frame, bg="white", relief="solid",
|
||
bd=1, highlightthickness=2, highlightbackground="#E0E0E0")
|
||
card.grid(row=row, column=col, padx=15, pady=15, sticky="nsew")
|
||
|
||
img_frame = tk.Frame(card, bg="#F5F5F5", width=220, height=180)
|
||
img_frame.pack(fill="x", padx=10, pady=(10, 5))
|
||
img_frame.pack_propagate(False)
|
||
|
||
foto = m.get('foto')
|
||
if foto and os.path.exists(foto):
|
||
img = ensure_image(foto, maxsize=(200, 160))
|
||
if img:
|
||
img_label = tk.Label(img_frame, image=img, bg="#F5F5F5")
|
||
img_label.image = img
|
||
img_label.pack(expand=True)
|
||
else:
|
||
tk.Label(img_frame, text="🍽️\nNo Image", font=("Arial", 16),
|
||
bg="#F5F5F5", fg="#CCCCCC").pack(expand=True)
|
||
else:
|
||
tk.Label(img_frame, text="🍽️\nNo Image", font=("Arial", 16),
|
||
bg="#F5F5F5", fg="#CCCCCC").pack(expand=True)
|
||
|
||
info_frame = tk.Frame(card, bg="white")
|
||
info_frame.pack(fill="x", padx=15, pady=10)
|
||
|
||
tk.Label(info_frame, text=m['nama'], font=("Arial", 13, "bold"),
|
||
bg="white", fg="#5C4033", anchor="w").pack(fill="x")
|
||
|
||
tk.Label(info_frame, text=f"📂 {m['kategori']}",
|
||
font=("Arial", 9), bg="white", fg="#999999",
|
||
anchor="w").pack(fill="x", pady=(3, 5))
|
||
|
||
price_frame = tk.Frame(info_frame, bg="white")
|
||
price_frame.pack(fill="x", pady=(0, 5))
|
||
|
||
harga = m['harga']
|
||
promo = m.get('promo', '')
|
||
|
||
if promo:
|
||
diskon = calculate_discount(harga, promo)
|
||
harga_after = harga - diskon
|
||
tk.Label(price_frame, text=format_currency(harga),
|
||
font=("Arial", 10), bg="white", fg="#999999",
|
||
anchor="w").pack(side="left")
|
||
tk.Label(price_frame, text=" → ", font=("Arial", 10),
|
||
bg="white", fg="#999999").pack(side="left")
|
||
tk.Label(price_frame, text=format_currency(harga_after),
|
||
font=("Arial", 12, "bold"), bg="white", fg="#D2691E",
|
||
anchor="w").pack(side="left")
|
||
tk.Label(info_frame, text=f"🎁 {promo}",
|
||
font=("Arial", 9), bg="white", fg="#D2691E",
|
||
anchor="w").pack(fill="x")
|
||
else:
|
||
tk.Label(price_frame, text=format_currency(harga),
|
||
font=("Arial", 13, "bold"), bg="white", fg="#8B7355",
|
||
anchor="w").pack(side="left")
|
||
|
||
stok = m.get('stok', 0)
|
||
stock_color = "#E74C3C" if stok < 5 else "#27AE60"
|
||
tk.Label(info_frame, text=f"📦 Stok: {stok}",
|
||
font=("Arial", 9), bg="white", fg=stock_color,
|
||
anchor="w").pack(fill="x", pady=(3, 0))
|
||
|
||
col += 1
|
||
if col >= cols:
|
||
col = 0
|
||
row += 1
|
||
|
||
# ================================
|
||
# TAB: ORDER (FIXED & INTEGRATED)
|
||
# ================================
|
||
|
||
def build_order_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='both', padx=6, pady=6)
|
||
|
||
tk.Label(left, text="🍽️ Daftar Menu", font=("Arial", 12, "bold")).pack(pady=4)
|
||
|
||
search_frame = ttk.Frame(left)
|
||
search_frame.pack(fill='x', pady=4)
|
||
tk.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)
|
||
|
||
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")
|
||
|
||
def _on_mousewheel(event):
|
||
canvas.yview_scroll(int(-1*(event.delta/120)), "units")
|
||
canvas.bind_all("<MouseWheel>", _on_mousewheel)
|
||
|
||
tk.Label(right, text="🛒 Keranjang Belanja", font=("Arial", 12, "bold")).pack(pady=4)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
checkout_frame = ttk.Frame(right)
|
||
checkout_frame.pack(fill='x', pady=6, padx=10)
|
||
|
||
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()
|
||
meja_entry = ttk.Entry(checkout_frame, textvariable=self.order_meja_var, width=20)
|
||
meja_entry.grid(row=0, column=1, pady=3, sticky='ew')
|
||
|
||
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')
|
||
|
||
checkout_frame.columnconfigure(1, weight=1)
|
||
|
||
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()
|
||
|
||
self.cart_items = []
|
||
self.reload_order_menu_cards()
|
||
|
||
def reload_order_menu_cards(self):
|
||
for widget in self.menu_cards_frame.winfo_children():
|
||
widget.destroy()
|
||
|
||
search = self.order_search_var.get().strip() or None
|
||
results = [m for m in menu if m.get('stok', 0) > 0]
|
||
|
||
if search:
|
||
results = [m for m in results if search.lower() in m['nama'].lower()]
|
||
|
||
cart_dict = {}
|
||
for cart_item in self.cart_items:
|
||
cart_dict[cart_item['menu_id']] = cart_item['qty']
|
||
|
||
row = 0
|
||
col = 0
|
||
for m in results:
|
||
mid = m['id']
|
||
nama = m['nama']
|
||
kategori = m['kategori']
|
||
harga = m['harga']
|
||
stok = m['stok']
|
||
foto = m.get('foto')
|
||
item_disc = m.get('item_discount_pct', 0)
|
||
promo = m.get('promo', '')
|
||
|
||
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')
|
||
|
||
if foto and os.path.exists(foto):
|
||
try:
|
||
if PIL_AVAILABLE:
|
||
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()
|
||
else:
|
||
tk.Label(card, text="[No Image]", bg='#e0e0e0', width=20, height=6).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()
|
||
|
||
tk.Label(card, text=nama, font=("Arial", 11, "bold"), bg='white', wraplength=150).pack(pady=(5, 2))
|
||
tk.Label(card, text=kategori, font=("Arial", 9), fg='gray', bg='white').pack()
|
||
|
||
harga_text = f"Rp {harga:,}"
|
||
if item_disc > 0:
|
||
harga_after = harga - (harga * item_disc / 100)
|
||
harga_text = f"Rp {harga:,} → Rp {int(harga_after):,}"
|
||
tk.Label(card, text=harga_text, font=("Arial", 9, "bold"), fg='#E67E22', bg='white').pack(pady=2)
|
||
if promo:
|
||
tk.Label(card, text=f"🎁 {promo}", font=("Arial", 8), fg='#D35400', bg='white').pack()
|
||
else:
|
||
tk.Label(card, text=harga_text, font=("Arial", 10, "bold"), fg='green', bg='white').pack(pady=2)
|
||
|
||
tk.Label(card, text=f"📦 Stok: {stok}", font=("Arial", 8), fg='blue', bg='white').pack(pady=2)
|
||
|
||
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:
|
||
tk.Button(
|
||
btn_frame,
|
||
text="➖",
|
||
font=("Arial", 12, "bold"),
|
||
bg='#E74C3C',
|
||
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='#27AE60',
|
||
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:
|
||
tk.Button(
|
||
btn_frame,
|
||
text="➕ Tambah",
|
||
font=("Arial", 10, "bold"),
|
||
bg='#27AE60',
|
||
fg='white',
|
||
width=12,
|
||
borderwidth=0,
|
||
cursor='hand2',
|
||
command=lambda m=mid, s=stok: self.increase_from_card(m, s)
|
||
).pack()
|
||
|
||
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):
|
||
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
|
||
|
||
if current_qty >= stok:
|
||
messagebox.showwarning("Stok Habis", f"Stok hanya tersisa {stok}")
|
||
return
|
||
|
||
if cart_item_found:
|
||
cart_item_found['qty'] += 1
|
||
else:
|
||
self.cart_items.append({'menu_id': menu_id, 'qty': 1})
|
||
|
||
self.reload_order_menu_cards()
|
||
self.update_cart_display()
|
||
|
||
def decrease_from_card(self, menu_id):
|
||
for i, cart_item in enumerate(self.cart_items):
|
||
if cart_item['menu_id'] == menu_id:
|
||
cart_item['qty'] -= 1
|
||
|
||
if cart_item['qty'] <= 0:
|
||
del self.cart_items[i]
|
||
|
||
self.reload_order_menu_cards()
|
||
self.update_cart_display()
|
||
return
|
||
|
||
def update_cart_display(self):
|
||
for r in self.cart_tree.get_children():
|
||
self.cart_tree.delete(r)
|
||
|
||
if not self.cart_items:
|
||
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")
|
||
return
|
||
|
||
subtotal = 0
|
||
item_discount_total = 0
|
||
|
||
for cart_item in self.cart_items:
|
||
menu_data = find_menu_by_id(cart_item['menu_id'])
|
||
if not menu_data:
|
||
continue
|
||
|
||
harga = menu_data['harga']
|
||
qty = cart_item['qty']
|
||
item_disc_pct = menu_data.get('item_discount_pct', 0)
|
||
|
||
line_subtotal = harga * qty
|
||
subtotal += line_subtotal
|
||
|
||
if item_disc_pct > 0:
|
||
item_discount = int(line_subtotal * item_disc_pct / 100)
|
||
item_discount_total += item_discount
|
||
|
||
self.cart_tree.insert("", tk.END, values=(
|
||
menu_data['nama'],
|
||
qty,
|
||
f"{harga:,}",
|
||
f"{line_subtotal:,}"
|
||
))
|
||
|
||
promo_code = self.order_promo_var.get().strip().upper()
|
||
promo_discount = 0
|
||
|
||
if promo_code and promo_code in promo_codes:
|
||
discount_pct = promo_codes[promo_code]
|
||
promo_discount = int((subtotal - item_discount_total) * discount_pct / 100)
|
||
|
||
total = subtotal - item_discount_total - promo_discount
|
||
|
||
self.cart_subtotal_label.config(text=f"Subtotal: Rp {subtotal:,}")
|
||
self.cart_discount_label.config(text=f"Diskon Item: Rp {item_discount_total:,}")
|
||
self.cart_promo_label.config(text=f"Diskon Promo: Rp {promo_discount:,}")
|
||
self.cart_total_label.config(text=f"TOTAL: Rp {total:,}")
|
||
|
||
def remove_cart_item(self):
|
||
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.reload_order_menu_cards()
|
||
self.update_cart_display()
|
||
|
||
def clear_cart(self):
|
||
if not self.cart_items:
|
||
return
|
||
|
||
if messagebox.askyesno("Konfirmasi", "Kosongkan keranjang?"):
|
||
self.cart_items = []
|
||
self.reload_order_menu_cards()
|
||
self.update_cart_display()
|
||
|
||
def checkout_order(self):
|
||
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
|
||
|
||
if nomor_meja < 1 or nomor_meja > 10:
|
||
messagebox.showerror("Invalid", "Nomor meja harus 1-10")
|
||
return
|
||
|
||
promo_code = self.order_promo_var.get().strip().upper() or None
|
||
|
||
if promo_code and promo_code not in promo_codes:
|
||
messagebox.showwarning("Promo Invalid", "Kode promo tidak ditemukan")
|
||
return
|
||
|
||
# Cek stok bahan
|
||
kurang_bahan = []
|
||
for cart_item in self.cart_items:
|
||
mid = cart_item['menu_id']
|
||
qty = cart_item['qty']
|
||
|
||
if mid in resep:
|
||
for bahan_nama, need in resep[mid].items():
|
||
if bahan.get(bahan_nama, 0) < need * qty:
|
||
kurang_bahan.append((bahan_nama, bahan.get(bahan_nama, 0), need * qty))
|
||
|
||
if kurang_bahan:
|
||
msg = "❌ Stok bahan tidak cukup untuk pesanan:\n\n"
|
||
for bn, avail, need in kurang_bahan:
|
||
msg += f"• {bn}: tersedia {avail}, dibutuhkan {need}\n"
|
||
messagebox.showerror("Stok Bahan Kurang", msg)
|
||
add_notification("warning", f"Stok bahan kurang untuk pesanan dari {self.current_user['username']}")
|
||
return
|
||
|
||
# Hitung total
|
||
subtotal = 0
|
||
item_discount_total = 0
|
||
items_for_trans = []
|
||
|
||
for cart_item in self.cart_items:
|
||
menu_data = find_menu_by_id(cart_item['menu_id'])
|
||
if not menu_data:
|
||
continue
|
||
|
||
qty = cart_item['qty']
|
||
harga = menu_data['harga']
|
||
item_disc_pct = menu_data.get('item_discount_pct', 0)
|
||
|
||
line_subtotal = harga * qty
|
||
subtotal += line_subtotal
|
||
|
||
if item_disc_pct > 0:
|
||
item_discount = int(line_subtotal * item_disc_pct / 100)
|
||
item_discount_total += item_discount
|
||
|
||
menu_data['stok'] -= qty
|
||
if menu_data['stok'] < 0:
|
||
menu_data['stok'] = 0
|
||
|
||
if menu_data['stok'] < 5:
|
||
add_notification("warning", f"Stok {menu_data['nama']} tinggal {menu_data['stok']}")
|
||
|
||
if menu_data['id'] in resep:
|
||
for bahan_nama, kebutuhan in resep[menu_data['id']].items():
|
||
if bahan_nama in bahan:
|
||
bahan[bahan_nama] -= kebutuhan * qty
|
||
if bahan[bahan_nama] < 0:
|
||
bahan[bahan_nama] = 0
|
||
if bahan[bahan_nama] < 10:
|
||
add_notification("warning", f"Stok bahan {bahan_nama} tinggal {bahan[bahan_nama]}")
|
||
|
||
items_for_trans.append({
|
||
'menu_id': menu_data['id'],
|
||
'nama': menu_data['nama'],
|
||
'qty': qty,
|
||
'harga_satuan': harga,
|
||
'subtotal': line_subtotal
|
||
})
|
||
|
||
promo_discount = 0
|
||
if promo_code:
|
||
discount_pct = promo_codes[promo_code]
|
||
promo_discount = int((subtotal - item_discount_total) * discount_pct / 100)
|
||
|
||
total = subtotal - item_discount_total - promo_discount
|
||
|
||
new_trans_id = max([t['id'] for t in transaksi], default=0) + 1
|
||
|
||
transaksi.append({
|
||
'id': new_trans_id,
|
||
'tanggal': str(datetime.date.today()),
|
||
'waktu': datetime.datetime.now(),
|
||
'user': self.current_user['username'],
|
||
'items': items_for_trans,
|
||
'subtotal': subtotal,
|
||
'diskon': item_discount_total + promo_discount,
|
||
'total': total,
|
||
'meja': nomor_meja,
|
||
'status': 'Baru',
|
||
'payment_status': 'Pending',
|
||
'payment_method': None,
|
||
'paid_amount': 0,
|
||
'change': 0,
|
||
'promo_code': promo_code or ''
|
||
})
|
||
|
||
detail_id = max([d['id'] for d in detail_transaksi], default=0) + 1
|
||
for item in items_for_trans:
|
||
detail_transaksi.append({
|
||
'id': detail_id,
|
||
'transaksi_id': new_trans_id,
|
||
'menu_id': item['menu_id'],
|
||
'nama_menu': item['nama'],
|
||
'jumlah': item['qty'],
|
||
'harga_satuan': item['harga_satuan'],
|
||
'subtotal': item['subtotal'],
|
||
'diskon': 0
|
||
})
|
||
detail_id += 1
|
||
|
||
data_meja[nomor_meja] = "Terisi"
|
||
|
||
add_notification("info", f"Pesanan baru dari {self.current_user['username']} - Total: Rp {total:,}")
|
||
|
||
save_menu()
|
||
save_bahan()
|
||
save_transaksi()
|
||
save_detail_transaksi()
|
||
save_meja()
|
||
|
||
self.cart_items = []
|
||
self.order_promo_var.set("")
|
||
self.reload_order_menu_cards()
|
||
self.update_cart_display()
|
||
|
||
messagebox.showinfo("✅ Sukses",
|
||
f"Pesanan berhasil dibuat!\n\n"
|
||
f"ID Transaksi: #{new_trans_id}\n"
|
||
f"Total: Rp {total:,}\n\n"
|
||
f"Silakan lakukan pembayaran di kasir.")
|
||
|
||
# ================================
|
||
# TAB: PAYMENT (FIXED & INTEGRATED)
|
||
# ================================
|
||
|
||
def build_payment_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
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_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).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_frame = ttk.LabelFrame(parent, text="📊 TOTAL PERHITUNGAN - Penjualan Hari Ini", padding=12)
|
||
summary_frame.pack(fill='x', padx=10, pady=6)
|
||
|
||
summary_inner = ttk.Frame(summary_frame)
|
||
summary_inner.pack()
|
||
|
||
today = str(datetime.date.today())
|
||
today_report = get_daily_report(today)
|
||
|
||
ttk.Label(summary_inner, text="Total Transaksi Hari Ini:",
|
||
font=("Arial", 11, "bold")).grid(row=0, column=0, sticky='w', padx=10, pady=5)
|
||
ttk.Label(summary_inner, text=str(today_report['total_transaksi']),
|
||
font=("Arial", 12, "bold"), foreground='blue').grid(row=0, column=1, sticky='w', padx=10, pady=5)
|
||
|
||
ttk.Label(summary_inner, text="Total Pendapatan Hari Ini:",
|
||
font=("Arial", 11, "bold")).grid(row=1, column=0, sticky='w', padx=10, pady=5)
|
||
ttk.Label(summary_inner, text=format_currency(today_report['total_omzet']),
|
||
font=("Arial", 12, "bold"), foreground='green').grid(row=1, column=1, sticky='w', padx=10, pady=5)
|
||
|
||
pending_total = sum(t['total'] for t in transaksi if t.get('payment_status') == 'Pending' and t['tanggal'] == today)
|
||
ttk.Label(summary_inner, text="Total Tagihan Pending:",
|
||
font=("Arial", 11, "bold")).grid(row=2, column=0, sticky='w', padx=10, pady=5)
|
||
ttk.Label(summary_inner, text=format_currency(pending_total),
|
||
font=("Arial", 12, "bold"), foreground='orange').grid(row=2, column=1, sticky='w', padx=10, pady=5)
|
||
|
||
# Breakdown QRIS vs CASH
|
||
today_berhasil = [t for t in transaksi if t['tanggal'] == today and t.get('payment_status') == 'Berhasil']
|
||
qris_total = sum(t['total'] for t in today_berhasil if t.get('payment_method', '').lower() == 'qris')
|
||
cash_total = sum(t['total'] for t in today_berhasil if t.get('payment_method', '').lower() == 'cash')
|
||
qris_count = len([t for t in today_berhasil if t.get('payment_method', '').lower() == 'qris'])
|
||
cash_count = len([t for t in today_berhasil if t.get('payment_method', '').lower() == 'cash'])
|
||
|
||
ttk.Separator(summary_inner, orient='horizontal').grid(row=3, column=0, columnspan=2, sticky='ew', padx=10, pady=8)
|
||
|
||
ttk.Label(summary_inner, text="📱 QRIS:",
|
||
font=("Arial", 10, "bold"), foreground='#FF6B6B').grid(row=4, column=0, sticky='w', padx=10, pady=3)
|
||
ttk.Label(summary_inner, text=f"{format_currency(qris_total)} ({qris_count}x)",
|
||
font=("Arial", 10, "bold"), foreground='#FF6B6B').grid(row=4, column=1, sticky='w', padx=10, pady=3)
|
||
|
||
ttk.Label(summary_inner, text="💵 CASH:",
|
||
font=("Arial", 10, "bold"), foreground='#4CAF50').grid(row=5, column=0, sticky='w', padx=10, pady=3)
|
||
ttk.Label(summary_inner, text=f"{format_currency(cash_total)} ({cash_count}x)",
|
||
font=("Arial", 10, "bold"), foreground='#4CAF50').grid(row=5, column=1, sticky='w', padx=10, pady=3)
|
||
|
||
main_container = ttk.Frame(parent)
|
||
main_container.pack(fill='both', expand=True, padx=10, pady=6)
|
||
|
||
left = ttk.LabelFrame(main_container, text="📋 Daftar Transaksi Belum Dibayar", padding=5)
|
||
left.pack(side='left', fill='both', expand=True, padx=(0, 5))
|
||
|
||
tree_scroll = ttk.Scrollbar(left, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("ID", "Meja", "Total", "Status", "Tanggal")
|
||
self.payment_tree = ttk.Treeview(
|
||
left,
|
||
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)
|
||
|
||
detail_label = ttk.Label(left, text="📄 Detail Transaksi", font=("Arial", 10, "bold"))
|
||
detail_label.pack(anchor='w', pady=(10, 5))
|
||
|
||
detail_frame = ttk.Frame(left)
|
||
detail_frame.pack(fill='both', expand=True)
|
||
|
||
detail_scroll = ttk.Scrollbar(detail_frame, orient='vertical')
|
||
detail_scroll.pack(side='right', fill='y')
|
||
|
||
self.payment_detail_text = tk.Text(detail_frame, height=10, font=("Courier New", 9),
|
||
wrap='word', yscrollcommand=detail_scroll.set)
|
||
detail_scroll.config(command=self.payment_detail_text.yview)
|
||
self.payment_detail_text.pack(side='left', fill='both', expand=True)
|
||
|
||
right = ttk.LabelFrame(main_container, text="💳 Form Pembayaran", padding=10)
|
||
right.pack(side='right', fill='both', expand=True, padx=(5, 0))
|
||
|
||
self.selected_transaksi_label = ttk.Label(
|
||
right,
|
||
text="❌ Belum ada transaksi dipilih",
|
||
font=("Arial", 10, "bold"),
|
||
foreground='red'
|
||
)
|
||
self.selected_transaksi_label.pack(pady=5)
|
||
|
||
self.selected_total_label = ttk.Label(
|
||
right,
|
||
text="Total: Rp 0",
|
||
font=("Arial", 14, "bold"),
|
||
foreground='green'
|
||
)
|
||
self.selected_total_label.pack(pady=5)
|
||
|
||
ttk.Separator(right, orient='horizontal').pack(fill='x', pady=10)
|
||
|
||
method_label = ttk.Label(right, text="💳 Pilih Metode Pembayaran:",
|
||
font=("Arial", 10, "bold"))
|
||
method_label.pack(anchor='w', pady=5)
|
||
|
||
self.payment_method_var = tk.StringVar(value='cash')
|
||
|
||
method_frame = ttk.Frame(right)
|
||
method_frame.pack(fill='x', pady=5)
|
||
|
||
ttk.Radiobutton(method_frame, text="💵 Cash",
|
||
variable=self.payment_method_var, value='cash',
|
||
command=self.on_payment_method_change).pack(anchor='w', pady=2)
|
||
ttk.Radiobutton(method_frame, text="📱 QRIS",
|
||
variable=self.payment_method_var, value='qris',
|
||
command=self.on_payment_method_change).pack(anchor='w', pady=2)
|
||
ttk.Radiobutton(method_frame, text="💳 E-Wallet",
|
||
variable=self.payment_method_var, value='ewallet',
|
||
command=self.on_payment_method_change).pack(anchor='w', pady=2)
|
||
|
||
ttk.Separator(right, orient='horizontal').pack(fill='x', pady=10)
|
||
|
||
# Container untuk payment input + button
|
||
payment_container = tk.Frame(right, bg='white')
|
||
payment_container.pack(fill='both', expand=True, pady=5)
|
||
|
||
# Canvas dengan scrollbar untuk payment input (max height 250px)
|
||
self.payment_canvas = tk.Canvas(payment_container, bg='white', highlightthickness=0, height=250)
|
||
scrollbar = ttk.Scrollbar(payment_container, orient='vertical', command=self.payment_canvas.yview)
|
||
self.payment_input_frame = tk.Frame(self.payment_canvas, bg='white', relief='solid', bd=1)
|
||
|
||
self.payment_input_frame.bind(
|
||
"<Configure>",
|
||
lambda e: self.payment_canvas.configure(scrollregion=self.payment_canvas.bbox("all"))
|
||
)
|
||
|
||
self.payment_canvas.create_window((0, 0), window=self.payment_input_frame, anchor='nw')
|
||
self.payment_canvas.configure(yscrollcommand=scrollbar.set)
|
||
|
||
self.payment_canvas.pack(fill='both', expand=False, padx=2, pady=5, side='left')
|
||
scrollbar.pack(fill='y', side='right', pady=5)
|
||
|
||
# Mouse wheel scrolling
|
||
def _on_mousewheel(event):
|
||
self.payment_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
|
||
self.payment_canvas.bind_all("<MouseWheel>", _on_mousewheel)
|
||
|
||
self.build_cash_input()
|
||
|
||
# Separator
|
||
ttk.Separator(payment_container, orient='horizontal').pack(fill='x', pady=8)
|
||
|
||
# Button PROSES PEMBAYARAN
|
||
self.process_btn = tk.Button(
|
||
payment_container,
|
||
text="✅ PROSES PEMBAYARAN",
|
||
command=self.process_payment,
|
||
state='disabled',
|
||
font=("Arial", 11, "bold"),
|
||
bg='#4CAF50',
|
||
fg='white',
|
||
relief='raised',
|
||
bd=2,
|
||
padx=15,
|
||
pady=10,
|
||
cursor='hand2'
|
||
)
|
||
self.process_btn.pack(fill='x', padx=5, pady=10)
|
||
|
||
self.reload_payment_orders()
|
||
|
||
def reload_payment_orders(self):
|
||
for r in self.payment_tree.get_children():
|
||
self.payment_tree.delete(r)
|
||
|
||
for t in transaksi:
|
||
if t.get('payment_status') == 'Pending':
|
||
self.payment_tree.insert("", tk.END, values=(
|
||
t['id'],
|
||
t.get('meja', '-'),
|
||
format_currency(t['total']),
|
||
t['status'],
|
||
t['tanggal']
|
||
))
|
||
|
||
def search_by_meja(self):
|
||
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
|
||
|
||
if nomor_meja < 1 or nomor_meja > 10:
|
||
messagebox.showwarning("Invalid", "Nomor meja harus 1-10")
|
||
return
|
||
|
||
for r in self.payment_tree.get_children():
|
||
self.payment_tree.delete(r)
|
||
|
||
found = False
|
||
for t in transaksi:
|
||
if t.get('meja') == nomor_meja and t.get('payment_status') == 'Pending':
|
||
self.payment_tree.insert("", tk.END, values=(
|
||
t['id'],
|
||
t.get('meja', '-'),
|
||
format_currency(t['total']),
|
||
t['status'],
|
||
t['tanggal']
|
||
))
|
||
found = True
|
||
|
||
if not found:
|
||
messagebox.showinfo("Tidak Ditemukan",
|
||
f"❌ Tidak ada tagihan aktif untuk Meja {nomor_meja}")
|
||
self.reload_payment_orders()
|
||
|
||
def on_payment_select(self, event):
|
||
sel = self.payment_tree.selection()
|
||
if not sel:
|
||
return
|
||
|
||
item = self.payment_tree.item(sel)['values']
|
||
transaksi_id = item[0]
|
||
|
||
t = None
|
||
for trans in transaksi:
|
||
if trans['id'] == transaksi_id:
|
||
t = trans
|
||
break
|
||
|
||
if not t:
|
||
return
|
||
|
||
self.selected_transaksi_label.config(
|
||
text=f"✅ Transaksi #{t['id']} - Meja {t.get('meja', '-')}",
|
||
foreground='green'
|
||
)
|
||
self.selected_total_label.config(text=f"Total: {format_currency(t['total'])}")
|
||
|
||
self.process_btn.config(state='normal')
|
||
|
||
detail_text = f"═══════════════════════════════════════\n"
|
||
detail_text += f"TRANSAKSI #{t['id']}\n"
|
||
detail_text += f"═══════════════════════════════════════\n\n"
|
||
detail_text += f"Tanggal : {t['tanggal']}\n"
|
||
detail_text += f"User : {t['user']}\n"
|
||
detail_text += f"Meja : {t.get('meja', '-')}\n"
|
||
detail_text += f"Status : {t['status']}\n\n"
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
detail_text += f"ITEM PESANAN:\n"
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
|
||
for item in t['items']:
|
||
detail_text += f"• {item['nama']}\n"
|
||
detail_text += f" {item['qty']} x {format_currency(item['harga_satuan'])} = {format_currency(item['subtotal'])}\n\n"
|
||
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
detail_text += f"Subtotal : {format_currency(t['subtotal'])}\n"
|
||
detail_text += f"Diskon : {format_currency(t['diskon'])}\n"
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
detail_text += f"TOTAL : {format_currency(t['total'])}\n"
|
||
detail_text += f"═══════════════════════════════════════\n"
|
||
|
||
self.payment_detail_text.delete('1.0', tk.END)
|
||
self.payment_detail_text.insert('1.0', detail_text)
|
||
|
||
self.selected_payment_transaksi = t
|
||
|
||
def on_payment_method_change(self):
|
||
for widget in self.payment_input_frame.winfo_children():
|
||
widget.destroy()
|
||
|
||
method = self.payment_method_var.get()
|
||
|
||
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):
|
||
ttk.Label(self.payment_input_frame, text="💵 PEMBAYARAN CASH",
|
||
font=("Arial", 11, "bold"), background='white').pack(pady=8)
|
||
|
||
ttk.Label(self.payment_input_frame, text="Jumlah Bayar:", font=("Arial", 9),
|
||
background='white').pack(anchor='w', padx=10, pady=(5, 2))
|
||
|
||
self.cash_amount_var = tk.StringVar()
|
||
cash_entry = ttk.Entry(self.payment_input_frame, textvariable=self.cash_amount_var,
|
||
font=("Arial", 11), width=25)
|
||
cash_entry.pack(pady=5, padx=10)
|
||
|
||
self.cash_change_label = ttk.Label(self.payment_input_frame, text="Kembalian: Rp 0",
|
||
font=("Arial", 10, "bold"), foreground='blue',
|
||
background='white')
|
||
self.cash_change_label.pack(pady=8, padx=10)
|
||
|
||
def calculate_change(*args):
|
||
if not hasattr(self, 'selected_payment_transaksi'):
|
||
return
|
||
|
||
try:
|
||
paid = int(self.cash_amount_var.get())
|
||
total = self.selected_payment_transaksi['total']
|
||
change = paid - total
|
||
|
||
if change >= 0:
|
||
self.cash_change_label.config(
|
||
text=f"Kembalian: {format_currency(change)}",
|
||
foreground='green'
|
||
)
|
||
else:
|
||
self.cash_change_label.config(
|
||
text=f"Kurang: {format_currency(abs(change))}",
|
||
foreground='red'
|
||
)
|
||
except:
|
||
self.cash_change_label.config(text="Kembalian: Rp 0", foreground='blue')
|
||
|
||
self.cash_amount_var.trace('w', calculate_change)
|
||
|
||
def build_qris_input(self):
|
||
ttk.Label(self.payment_input_frame, text="📱 PEMBAYARAN QRIS",
|
||
font=("Arial", 9, "bold"), background='white').pack(pady=2)
|
||
|
||
if not hasattr(self, 'selected_payment_transaksi'):
|
||
ttk.Label(self.payment_input_frame, text="Pilih transaksi",
|
||
foreground='red', background='white', font=("Arial", 8)).pack()
|
||
return
|
||
|
||
t = self.selected_payment_transaksi
|
||
|
||
if QRCODE_AVAILABLE:
|
||
ttk.Label(self.payment_input_frame, text="📲 Scan QR Code:",
|
||
font=("Arial", 8), background='white').pack(pady=1)
|
||
|
||
qr_data = f"CAFE-TRX-{t['id']}-{t['total']}"
|
||
|
||
try:
|
||
qr = qrcode.QRCode(version=1, box_size=4, border=1)
|
||
qr.add_data(qr_data)
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color="black", back_color="white")
|
||
|
||
buffer = BytesIO()
|
||
img.save(buffer, format='PNG')
|
||
buffer.seek(0)
|
||
|
||
if PIL_AVAILABLE:
|
||
qr_img = Image.open(buffer)
|
||
qr_img = qr_img.resize((120, 120))
|
||
qr_photo = ImageTk.PhotoImage(qr_img)
|
||
|
||
qr_label = tk.Label(self.payment_input_frame, image=qr_photo, bg='white')
|
||
qr_label.image = qr_photo
|
||
qr_label.pack(pady=2)
|
||
|
||
ttk.Label(self.payment_input_frame,
|
||
text=f"Total: Rp {t['total']:,}".replace(",", "."),
|
||
font=("Arial", 8, "bold"), background='white', foreground='green').pack(pady=1)
|
||
|
||
except Exception as e:
|
||
ttk.Label(self.payment_input_frame,
|
||
text=f"Error: {str(e)[:20]}",
|
||
foreground='red', font=("Arial", 8), background='white').pack()
|
||
else:
|
||
ttk.Label(self.payment_input_frame,
|
||
text="qrcode tidak tersedia",
|
||
foreground='red', font=("Arial", 8), background='white').pack()
|
||
|
||
def build_ewallet_input(self):
|
||
ttk.Label(self.payment_input_frame, text="💳 PEMBAYARAN E-WALLET",
|
||
font=("Arial", 10, "bold"), background='white').pack(pady=3)
|
||
|
||
ttk.Label(self.payment_input_frame, text="Pilih E-Wallet:", font=("Arial", 8),
|
||
background='white').pack(anchor='w', padx=5, pady=(3, 2))
|
||
|
||
self.ewallet_type_var = tk.StringVar(value='gopay')
|
||
|
||
wallets = [
|
||
('GoPay', 'gopay'),
|
||
('OVO', 'ovo'),
|
||
('DANA', 'dana'),
|
||
('ShopeePay', 'shopeepay')
|
||
]
|
||
|
||
wallet_frame = tk.Frame(self.payment_input_frame, bg='white')
|
||
wallet_frame.pack(fill='x', padx=5, pady=2)
|
||
|
||
for label, value in wallets:
|
||
ttk.Radiobutton(wallet_frame, text=f"💰 {label}",
|
||
variable=self.ewallet_type_var, value=value).pack(anchor='w', pady=2)
|
||
|
||
if hasattr(self, 'selected_payment_transaksi'):
|
||
t = self.selected_payment_transaksi
|
||
ttk.Label(self.payment_input_frame,
|
||
text=f"Total Bayar: {format_currency(t['total'])}",
|
||
font=("Arial", 10, "bold"),
|
||
foreground='green', background='white').pack(pady=10)
|
||
|
||
def process_payment(self):
|
||
if not hasattr(self, 'selected_payment_transaksi'):
|
||
messagebox.showwarning("Error", "Pilih transaksi terlebih dahulu")
|
||
return
|
||
|
||
t = self.selected_payment_transaksi
|
||
method = self.payment_method_var.get()
|
||
|
||
if method == 'cash':
|
||
try:
|
||
paid = int(self.cash_amount_var.get())
|
||
except:
|
||
messagebox.showerror("Input Error", "Masukkan jumlah pembayaran yang valid")
|
||
return
|
||
|
||
if paid < t['total']:
|
||
messagebox.showerror("Pembayaran Kurang",
|
||
f"Pembayaran kurang!\nTotal: {format_currency(t['total'])}\nBayar: {format_currency(paid)}")
|
||
return
|
||
|
||
change = paid - t['total']
|
||
|
||
t['payment_status'] = 'Berhasil'
|
||
t['payment_method'] = 'Cash'
|
||
t['paid_amount'] = paid
|
||
t['change'] = change
|
||
t['status'] = 'Selesai'
|
||
|
||
if t.get('meja'):
|
||
data_meja[t['meja']] = 'Kosong'
|
||
|
||
payment_id = max([p['id'] for p in pembayaran], default=0) + 1
|
||
pembayaran.append({
|
||
'id': payment_id,
|
||
'transaksi_id': t['id'],
|
||
'metode': 'Cash',
|
||
'jumlah': t['total'],
|
||
'status': 'Berhasil',
|
||
'tanggal': str(datetime.date.today())
|
||
})
|
||
|
||
save_transaksi()
|
||
save_meja()
|
||
save_pembayaran()
|
||
|
||
add_notification("success", f"Pembayaran Cash #{t['id']} berhasil - Total: {format_currency(t['total'])}")
|
||
|
||
messagebox.showinfo("✅ Pembayaran Berhasil",
|
||
f"Transaksi #{t['id']}\n\n"
|
||
f"Metode: Cash\n"
|
||
f"Total: {format_currency(t['total'])}\n"
|
||
f"Bayar: {format_currency(paid)}\n"
|
||
f"Kembalian: {format_currency(change)}")
|
||
|
||
self.reload_payment_orders()
|
||
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)
|
||
self.process_btn.config(state='disabled')
|
||
self.cash_amount_var.set("")
|
||
|
||
elif method == 'qris':
|
||
confirm = messagebox.askyesno("Konfirmasi QRIS",
|
||
f"Apakah pembayaran QRIS sebesar {format_currency(t['total'])} sudah diterima?")
|
||
|
||
if not confirm:
|
||
return
|
||
|
||
t['payment_status'] = 'Berhasil'
|
||
t['payment_method'] = 'QRIS'
|
||
t['paid_amount'] = t['total']
|
||
t['change'] = 0
|
||
t['status'] = 'Selesai'
|
||
|
||
if t.get('meja'):
|
||
data_meja[t['meja']] = 'Kosong'
|
||
|
||
payment_id = max([p['id'] for p in pembayaran], default=0) + 1
|
||
pembayaran.append({
|
||
'id': payment_id,
|
||
'transaksi_id': t['id'],
|
||
'metode': 'QRIS',
|
||
'jumlah': t['total'],
|
||
'status': 'Berhasil',
|
||
'tanggal': str(datetime.date.today())
|
||
})
|
||
|
||
save_transaksi()
|
||
save_meja()
|
||
save_pembayaran()
|
||
|
||
add_notification("success", f"Pembayaran QRIS #{t['id']} berhasil - Total: {format_currency(t['total'])}")
|
||
|
||
messagebox.showinfo("✅ Pembayaran Berhasil",
|
||
f"Transaksi #{t['id']}\n\n"
|
||
f"Metode: QRIS\n"
|
||
f"Total: {format_currency(t['total'])}")
|
||
|
||
self.reload_payment_orders()
|
||
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)
|
||
self.process_btn.config(state='disabled')
|
||
|
||
elif method == 'ewallet':
|
||
wallet_type = self.ewallet_type_var.get()
|
||
wallet_names = {
|
||
'gopay': 'GoPay',
|
||
'ovo': 'OVO',
|
||
'dana': 'DANA',
|
||
'shopeepay': 'ShopeePay'
|
||
}
|
||
wallet_name = wallet_names.get(wallet_type, 'E-Wallet')
|
||
|
||
confirm = messagebox.askyesno("Konfirmasi E-Wallet",
|
||
f"Apakah pembayaran {wallet_name} sebesar {format_currency(t['total'])} sudah diterima?")
|
||
|
||
if not confirm:
|
||
return
|
||
|
||
t['payment_status'] = 'Berhasil'
|
||
t['payment_method'] = wallet_name
|
||
t['paid_amount'] = t['total']
|
||
t['change'] = 0
|
||
t['status'] = 'Selesai'
|
||
|
||
if t.get('meja'):
|
||
data_meja[t['meja']] = 'Kosong'
|
||
|
||
payment_id = max([p['id'] for p in pembayaran], default=0) + 1
|
||
pembayaran.append({
|
||
'id': payment_id,
|
||
'transaksi_id': t['id'],
|
||
'metode': wallet_name,
|
||
'jumlah': t['total'],
|
||
'status': 'Berhasil',
|
||
'tanggal': str(datetime.date.today())
|
||
})
|
||
|
||
save_transaksi()
|
||
save_meja()
|
||
save_pembayaran()
|
||
|
||
add_notification("success", f"Pembayaran {wallet_name} #{t['id']} berhasil - Total: {format_currency(t['total'])}")
|
||
|
||
messagebox.showinfo("✅ Pembayaran Berhasil",
|
||
f"Transaksi #{t['id']}\n\n"
|
||
f"Metode: {wallet_name}\n"
|
||
f"Total: {format_currency(t['total'])}")
|
||
|
||
self.reload_payment_orders()
|
||
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)
|
||
self.process_btn.config(state='disabled')
|
||
|
||
# ================================
|
||
# TAB: FAVORITE
|
||
# ================================
|
||
|
||
def build_favorite_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = tk.Frame(parent, bg="#F5F5F0")
|
||
header.pack(fill="x", padx=15, pady=10)
|
||
|
||
tk.Label(header, text="⭐ Menu Favorit Saya", font=("Arial", 18, "bold"),
|
||
bg="#F5F5F0", fg="#5C4033").pack(side="left")
|
||
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_favorite_tab(parent)).pack(side="right")
|
||
|
||
username = self.current_user['username']
|
||
user_favs = favorites.get(username, [])
|
||
|
||
if not user_favs:
|
||
empty_frame = tk.Frame(parent, bg="white")
|
||
empty_frame.pack(fill="both", expand=True, padx=20, pady=20)
|
||
|
||
tk.Label(empty_frame, text="⭐", font=("Arial", 64),
|
||
bg="white", fg="#CCCCCC").pack(pady=(50, 20))
|
||
tk.Label(empty_frame, text="Belum ada menu favorit",
|
||
font=("Arial", 16), bg="white", fg="#999999").pack()
|
||
tk.Label(empty_frame, text="Tandai menu favorit Anda dari tab Menu",
|
||
font=("Arial", 11), bg="white", fg="#CCCCCC").pack(pady=10)
|
||
return
|
||
|
||
container = tk.Frame(parent, bg="white")
|
||
container.pack(fill="both", expand=True, padx=15, pady=5)
|
||
|
||
canvas = tk.Canvas(container, bg="white", highlightthickness=0)
|
||
scrollbar = ttk.Scrollbar(container, orient="vertical", command=canvas.yview)
|
||
fav_frame = tk.Frame(canvas, bg="white")
|
||
|
||
canvas.configure(yscrollcommand=scrollbar.set)
|
||
scrollbar.pack(side="right", fill="y")
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
canvas_window = canvas.create_window((0, 0), window=fav_frame, anchor="nw")
|
||
|
||
fav_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.bind("<Configure>", lambda e: canvas.itemconfig(canvas_window, width=e.width))
|
||
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(-1 * int(e.delta / 120), "units"))
|
||
|
||
row = 0
|
||
col = 0
|
||
|
||
for fav_id in user_favs:
|
||
m = find_menu_by_id(fav_id)
|
||
if not m:
|
||
continue
|
||
|
||
card = tk.Frame(fav_frame, bg="white", relief="solid",
|
||
bd=1, highlightthickness=2, highlightbackground="#FFD700")
|
||
card.grid(row=row, column=col, padx=15, pady=15, sticky="nsew")
|
||
|
||
img_frame = tk.Frame(card, bg="#F5F5F5", width=220, height=180)
|
||
img_frame.pack(fill="x", padx=10, pady=(10, 5))
|
||
img_frame.pack_propagate(False)
|
||
|
||
foto = m.get('foto')
|
||
if foto and os.path.exists(foto):
|
||
img = ensure_image(foto, maxsize=(200, 160))
|
||
if img:
|
||
img_label = tk.Label(img_frame, image=img, bg="#F5F5F5")
|
||
img_label.image = img
|
||
img_label.pack(expand=True)
|
||
else:
|
||
tk.Label(img_frame, text="⭐\nFavorite", font=("Arial", 16),
|
||
bg="#F5F5F5", fg="#FFD700").pack(expand=True)
|
||
else:
|
||
tk.Label(img_frame, text="⭐\nFavorite", font=("Arial", 16),
|
||
bg="#F5F5F5", fg="#FFD700").pack(expand=True)
|
||
|
||
info_frame = tk.Frame(card, bg="white")
|
||
info_frame.pack(fill="x", padx=15, pady=10)
|
||
|
||
tk.Label(info_frame, text=m['nama'], font=("Arial", 13, "bold"),
|
||
bg="white", fg="#5C4033", anchor="w").pack(fill="x")
|
||
|
||
tk.Label(info_frame, text=f"📂 {m['kategori']}",
|
||
font=("Arial", 9), bg="white", fg="#999999",
|
||
anchor="w").pack(fill="x", pady=(3, 5))
|
||
|
||
tk.Label(info_frame, text=format_currency(m['harga']),
|
||
font=("Arial", 13, "bold"), bg="white", fg="#8B7355",
|
||
anchor="w").pack(fill="x")
|
||
|
||
btn_frame = tk.Frame(info_frame, bg="white")
|
||
btn_frame.pack(fill="x", pady=(10, 0))
|
||
|
||
tk.Button(btn_frame, text="💔 Hapus dari Favorit",
|
||
command=lambda mid=m['id']: self.remove_from_favorites(mid, parent),
|
||
font=("Arial", 9), bg="#E74C3C", fg="white",
|
||
relief="flat", padx=10, pady=5).pack()
|
||
|
||
col += 1
|
||
if col >= 3:
|
||
col = 0
|
||
row += 1
|
||
|
||
def remove_from_favorites(self, menu_id, parent):
|
||
username = self.current_user['username']
|
||
|
||
if username in favorites and menu_id in favorites[username]:
|
||
favorites[username].remove(menu_id)
|
||
save_favorites()
|
||
messagebox.showinfo("Success", "Menu dihapus dari favorit")
|
||
self.build_favorite_tab(parent)
|
||
|
||
# ================================
|
||
# TAB: RIWAYAT
|
||
# ================================
|
||
|
||
def build_riwayat_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="📜 Riwayat Transaksi",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_riwayat_tab(parent)).pack(side='right', padx=5)
|
||
|
||
filter_frame = ttk.LabelFrame(parent, text="🔍 Filter Riwayat", padding=10)
|
||
filter_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
filter_inner = ttk.Frame(filter_frame)
|
||
filter_inner.pack()
|
||
|
||
ttk.Label(filter_inner, text="Status:").grid(row=0, column=0, padx=5)
|
||
self.riwayat_status_var = tk.StringVar(value="Semua")
|
||
status_cb = ttk.Combobox(filter_inner, textvariable=self.riwayat_status_var,
|
||
values=["Semua", "Pending", "Berhasil", "Batal"],
|
||
state="readonly", width=15)
|
||
status_cb.grid(row=0, column=1, padx=5)
|
||
|
||
ttk.Label(filter_inner, text="Tanggal:").grid(row=0, column=2, padx=5)
|
||
self.riwayat_date_var = tk.StringVar(value=str(datetime.date.today()))
|
||
ttk.Entry(filter_inner, textvariable=self.riwayat_date_var, width=15).grid(row=0, column=3, padx=5)
|
||
|
||
ttk.Button(filter_inner, text="🔍 Tampilkan",
|
||
command=lambda: self.refresh_riwayat_tree()).grid(row=0, column=4, padx=5)
|
||
ttk.Button(filter_inner, text="🔄 Reset",
|
||
command=self.reset_riwayat_filter).grid(row=0, column=5, padx=5)
|
||
|
||
tree_frame = ttk.Frame(parent)
|
||
tree_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("ID", "Tanggal", "User", "Total", "Metode", "Status", "Meja")
|
||
self.riwayat_tree = ttk.Treeview(
|
||
tree_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=12,
|
||
yscrollcommand=tree_scroll.set
|
||
)
|
||
|
||
tree_scroll.config(command=self.riwayat_tree.yview)
|
||
|
||
for c in cols:
|
||
self.riwayat_tree.heading(c, text=c)
|
||
if c == "ID":
|
||
self.riwayat_tree.column(c, width=50)
|
||
elif c == "Total":
|
||
self.riwayat_tree.column(c, width=100)
|
||
elif c == "Meja":
|
||
self.riwayat_tree.column(c, width=60)
|
||
else:
|
||
self.riwayat_tree.column(c, width=100)
|
||
|
||
self.riwayat_tree.pack(side='left', fill='both', expand=True)
|
||
self.riwayat_tree.bind("<Double-1>", self.show_riwayat_detail)
|
||
|
||
detail_frame = ttk.LabelFrame(parent, text="📄 Detail Transaksi", padding=10)
|
||
detail_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
self.riwayat_detail_text = tk.Text(detail_frame, height=8, font=("Courier New", 9),
|
||
wrap='word', state='disabled')
|
||
self.riwayat_detail_text.pack(fill='x')
|
||
|
||
self.refresh_riwayat_tree()
|
||
|
||
def refresh_riwayat_tree(self):
|
||
for r in self.riwayat_tree.get_children():
|
||
self.riwayat_tree.delete(r)
|
||
|
||
status_filter = self.riwayat_status_var.get()
|
||
date_filter = self.riwayat_date_var.get().strip()
|
||
|
||
for t in reversed(transaksi):
|
||
payment_status = t.get('payment_status', 'Pending')
|
||
|
||
if status_filter != "Semua" and payment_status != status_filter:
|
||
continue
|
||
|
||
if date_filter and t['tanggal'] != date_filter:
|
||
continue
|
||
|
||
self.riwayat_tree.insert("", tk.END, values=(
|
||
t['id'],
|
||
t['tanggal'],
|
||
t['user'],
|
||
format_currency(t['total']),
|
||
t.get('payment_method', '-'),
|
||
payment_status,
|
||
t.get('meja', '-')
|
||
))
|
||
|
||
def reset_riwayat_filter(self):
|
||
self.riwayat_status_var.set("Semua")
|
||
self.riwayat_date_var.set(str(datetime.date.today()))
|
||
self.refresh_riwayat_tree()
|
||
|
||
def show_riwayat_detail(self, event):
|
||
sel = self.riwayat_tree.selection()
|
||
if not sel:
|
||
return
|
||
|
||
item = self.riwayat_tree.item(sel)['values']
|
||
transaksi_id = item[0]
|
||
|
||
t = None
|
||
for trans in transaksi:
|
||
if trans['id'] == transaksi_id:
|
||
t = trans
|
||
break
|
||
|
||
if not t:
|
||
return
|
||
|
||
detail_text = f"═══════════════════════════════════════\n"
|
||
detail_text += f"DETAIL TRANSAKSI #{t['id']}\n"
|
||
detail_text += f"═══════════════════════════════════════\n\n"
|
||
detail_text += f"Tanggal : {t['tanggal']}\n"
|
||
detail_text += f"Waktu : {t['waktu'].strftime('%H:%M:%S')}\n"
|
||
detail_text += f"User : {t['user']}\n"
|
||
detail_text += f"Meja : {t.get('meja', '-')}\n"
|
||
detail_text += f"Status Pesanan : {t['status']}\n"
|
||
detail_text += f"Status Bayar : {t.get('payment_status', 'Pending')}\n"
|
||
detail_text += f"Metode Bayar : {t.get('payment_method', '-')}\n\n"
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
detail_text += f"ITEM:\n"
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
|
||
for item in t['items']:
|
||
detail_text += f"• {item['nama']}\n"
|
||
detail_text += f" {item['qty']} x {format_currency(item['harga_satuan'])} = {format_currency(item['subtotal'])}\n\n"
|
||
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
detail_text += f"Subtotal : {format_currency(t['subtotal'])}\n"
|
||
detail_text += f"Diskon : {format_currency(t['diskon'])}\n"
|
||
|
||
if t.get('promo_code'):
|
||
detail_text += f"Promo Code : {t['promo_code']}\n"
|
||
|
||
detail_text += f"───────────────────────────────────────\n"
|
||
detail_text += f"TOTAL : {format_currency(t['total'])}\n"
|
||
|
||
if t.get('payment_method') == 'Cash':
|
||
detail_text += f"Bayar : {format_currency(t.get('paid_amount', 0))}\n"
|
||
detail_text += f"Kembalian : {format_currency(t.get('change', 0))}\n"
|
||
|
||
detail_text += f"═══════════════════════════════════════\n"
|
||
|
||
self.riwayat_detail_text.config(state='normal')
|
||
self.riwayat_detail_text.delete('1.0', tk.END)
|
||
self.riwayat_detail_text.insert('1.0', detail_text)
|
||
self.riwayat_detail_text.config(state='disabled')
|
||
|
||
# ================================
|
||
# TAB: MEJA
|
||
# ================================
|
||
|
||
def build_meja_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="🪑 Manajemen Meja",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_meja_tab(parent)).pack(side='right', padx=5)
|
||
|
||
# Summary
|
||
summary_frame = ttk.LabelFrame(parent, text="📊 Status Meja", padding=10)
|
||
summary_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
summary_inner = ttk.Frame(summary_frame)
|
||
summary_inner.pack()
|
||
|
||
kosong = len([m for m in data_meja.values() if m == "Kosong"])
|
||
terisi = len([m for m in data_meja.values() if m == "Terisi"])
|
||
|
||
ttk.Label(summary_inner, text="✅ Meja Kosong:",
|
||
font=("Arial", 10)).grid(row=0, column=0, sticky='w', padx=10, pady=3)
|
||
ttk.Label(summary_inner, text=str(kosong),
|
||
font=("Arial", 10, "bold"), foreground='green').grid(row=0, column=1, sticky='w', padx=10, pady=3)
|
||
|
||
ttk.Label(summary_inner, text="🔴 Meja Terisi:",
|
||
font=("Arial", 10)).grid(row=1, column=0, sticky='w', padx=10, pady=3)
|
||
ttk.Label(summary_inner, text=str(terisi),
|
||
font=("Arial", 10, "bold"), foreground='red').grid(row=1, column=1, sticky='w', padx=10, pady=3)
|
||
|
||
# Grid Meja
|
||
meja_frame = ttk.LabelFrame(parent, text="🗺️ Layout Meja", padding=15)
|
||
meja_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
grid_frame = tk.Frame(meja_frame, bg='white')
|
||
grid_frame.pack(expand=True)
|
||
|
||
# Buat grid 2x5 untuk 10 meja
|
||
for i in range(1, 11):
|
||
status = data_meja.get(i, "Kosong")
|
||
|
||
if status == "Kosong":
|
||
bg_color = "#D4EDDA"
|
||
fg_color = "#155724"
|
||
icon = "✅"
|
||
else:
|
||
bg_color = "#F8D7DA"
|
||
fg_color = "#721C24"
|
||
icon = "🔴"
|
||
|
||
# Hitung posisi grid
|
||
row = (i - 1) // 5
|
||
col = (i - 1) % 5
|
||
|
||
# Frame untuk setiap meja
|
||
meja_card = tk.Frame(grid_frame, bg=bg_color, relief='solid', bd=2,
|
||
width=120, height=100)
|
||
meja_card.grid(row=row, column=col, padx=10, pady=10)
|
||
meja_card.pack_propagate(False)
|
||
|
||
# Konten meja
|
||
tk.Label(meja_card, text=icon, font=("Arial", 20),
|
||
bg=bg_color).pack(pady=(10, 5))
|
||
tk.Label(meja_card, text=f"Meja {i}", font=("Arial", 11, "bold"),
|
||
bg=bg_color, fg=fg_color).pack()
|
||
tk.Label(meja_card, text=status, font=("Arial", 9),
|
||
bg=bg_color, fg=fg_color).pack(pady=(2, 5))
|
||
|
||
# Button aksi
|
||
if status == "Terisi":
|
||
btn = tk.Button(meja_card, text="🧹 Kosongkan",
|
||
command=lambda m=i: self.kosongkan_meja(m, parent),
|
||
font=("Arial", 8), bg='#FFC107', fg='black',
|
||
relief='flat', padx=8, pady=3, cursor='hand2')
|
||
btn.pack(pady=3)
|
||
else:
|
||
btn = tk.Button(meja_card, text="🔒 Tandai Terisi",
|
||
command=lambda m=i: self.tandai_terisi_meja(m, parent),
|
||
font=("Arial", 8), bg='#007BFF', fg='white',
|
||
relief='flat', padx=8, pady=3, cursor='hand2')
|
||
btn.pack(pady=3)
|
||
|
||
# Info footer
|
||
info_frame = ttk.Frame(parent)
|
||
info_frame.pack(fill='x', padx=10, pady=(5, 10))
|
||
|
||
ttk.Label(info_frame,
|
||
text="💡 Tips: Meja otomatis terisi saat pembeli checkout, dan kosong saat pembayaran selesai",
|
||
font=("Arial", 9), foreground='gray').pack()
|
||
|
||
def kosongkan_meja(self, nomor_meja, parent):
|
||
confirm = messagebox.askyesno("Konfirmasi",
|
||
f"Kosongkan Meja {nomor_meja}?\n\n"
|
||
f"Pastikan tamu sudah selesai dan pembayaran telah lunas.")
|
||
|
||
if not confirm:
|
||
return
|
||
|
||
data_meja[nomor_meja] = "Kosong"
|
||
save_meja()
|
||
add_notification("info", f"Meja {nomor_meja} dikosongkan oleh {self.current_user['username']}")
|
||
messagebox.showinfo("Success", f"Meja {nomor_meja} berhasil dikosongkan")
|
||
self.build_meja_tab(parent)
|
||
|
||
def tandai_terisi_meja(self, nomor_meja, parent):
|
||
confirm = messagebox.askyesno("Konfirmasi",
|
||
f"Tandai Meja {nomor_meja} terisi?\n\n"
|
||
f"Gunakan fitur ini jika ada tamu tanpa order sistem.")
|
||
|
||
if not confirm:
|
||
return
|
||
|
||
data_meja[nomor_meja] = "Terisi"
|
||
save_meja()
|
||
add_notification("info", f"Meja {nomor_meja} ditandai terisi oleh {self.current_user['username']}")
|
||
messagebox.showinfo("Success", f"Meja {nomor_meja} berhasil ditandai terisi")
|
||
self.build_meja_tab(parent)
|
||
|
||
# ================================
|
||
# TAB: USER MANAGEMENT
|
||
# ================================
|
||
|
||
def build_user_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="👥 Kelola User",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="➕ Tambah User",
|
||
command=self.add_user_dialog).pack(side='right', padx=5)
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_user_tab(parent)).pack(side='right', padx=5)
|
||
|
||
tree_frame = ttk.Frame(parent)
|
||
tree_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("ID", "Username", "Role", "Status")
|
||
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)
|
||
|
||
for c in cols:
|
||
self.user_tree.heading(c, text=c)
|
||
if c == "ID":
|
||
self.user_tree.column(c, width=50)
|
||
else:
|
||
self.user_tree.column(c, width=150)
|
||
|
||
self.user_tree.pack(side='left', fill='both', expand=True)
|
||
|
||
for u in users:
|
||
self.user_tree.insert("", tk.END, values=(
|
||
u['id'],
|
||
u['username'],
|
||
u['role'],
|
||
"✅ Aktif"
|
||
))
|
||
|
||
action_frame = ttk.Frame(parent)
|
||
action_frame.pack(fill='x', padx=10, pady=10)
|
||
|
||
ttk.Button(action_frame, text="✏️ Edit",
|
||
command=self.edit_user_dialog,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="🔒 Reset Password",
|
||
command=self.reset_user_password,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="🗑️ Hapus",
|
||
command=self.delete_user,
|
||
width=20).pack(side='left', padx=5)
|
||
|
||
def add_user_dialog(self):
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title("➕ Tambah User Baru")
|
||
dialog.geometry("400x300")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text="Username:").grid(row=0, column=0, sticky='w', pady=10)
|
||
username_entry = ttk.Entry(frame, width=25)
|
||
username_entry.grid(row=0, column=1, pady=10)
|
||
|
||
ttk.Label(frame, text="Password:").grid(row=1, column=0, sticky='w', pady=10)
|
||
password_entry = ttk.Entry(frame, show="●", width=25)
|
||
password_entry.grid(row=1, column=1, pady=10)
|
||
|
||
ttk.Label(frame, text="Role:").grid(row=2, column=0, sticky='w', pady=10)
|
||
role_var = tk.StringVar(value="pembeli")
|
||
role_cb = ttk.Combobox(frame, textvariable=role_var,
|
||
values=["admin", "kasir", "waiter", "pembeli", "owner"],
|
||
state="readonly", width=23)
|
||
role_cb.grid(row=2, column=1, pady=10)
|
||
|
||
def save_user():
|
||
username = username_entry.get().strip()
|
||
password = password_entry.get().strip()
|
||
role = role_var.get()
|
||
|
||
if not username or not password:
|
||
messagebox.showwarning("Input Error", "Username dan password harus diisi")
|
||
return
|
||
|
||
for u in users:
|
||
if u['username'] == username:
|
||
messagebox.showwarning("Sudah Ada", f"Username '{username}' sudah digunakan")
|
||
return
|
||
|
||
new_id = max([int(u['id']) for u in users], default=0) + 1
|
||
|
||
users.append({
|
||
'id': str(new_id),
|
||
'username': username,
|
||
'password': password,
|
||
'role': role
|
||
})
|
||
|
||
save_users()
|
||
add_notification("success", f"User '{username}' berhasil ditambahkan")
|
||
messagebox.showinfo("Success", f"User '{username}' berhasil ditambahkan")
|
||
dialog.destroy()
|
||
self.build_user_tab(self.user_tree.master.master)
|
||
|
||
ttk.Button(frame, text="💾 Simpan", command=save_user).grid(row=3, column=0, columnspan=2, pady=20)
|
||
|
||
def edit_user_dialog(self):
|
||
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 = str(item[0])
|
||
|
||
user = None
|
||
for u in users:
|
||
if u['id'] == user_id:
|
||
user = u
|
||
break
|
||
|
||
if not user:
|
||
return
|
||
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title(f"✏️ Edit User: {user['username']}")
|
||
dialog.geometry("400x250")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text="Username:").grid(row=0, column=0, sticky='w', pady=10)
|
||
username_entry = ttk.Entry(frame, width=25)
|
||
username_entry.insert(0, user['username'])
|
||
username_entry.grid(row=0, column=1, pady=10)
|
||
|
||
ttk.Label(frame, text="Role:").grid(row=1, column=0, sticky='w', pady=10)
|
||
role_var = tk.StringVar(value=user['role'])
|
||
role_cb = ttk.Combobox(frame, textvariable=role_var,
|
||
values=["admin", "kasir", "waiter", "pembeli", "owner"],
|
||
state="readonly", width=23)
|
||
role_cb.grid(row=1, column=1, pady=10)
|
||
|
||
def update_user():
|
||
new_username = username_entry.get().strip()
|
||
|
||
if not new_username:
|
||
messagebox.showwarning("Input Error", "Username harus diisi")
|
||
return
|
||
|
||
for u in users:
|
||
if u['username'] == new_username and u['id'] != user_id:
|
||
messagebox.showwarning("Sudah Ada", f"Username '{new_username}' sudah digunakan")
|
||
return
|
||
|
||
user['username'] = new_username
|
||
user['role'] = role_var.get()
|
||
|
||
save_users()
|
||
add_notification("info", f"User '{new_username}' berhasil diupdate")
|
||
messagebox.showinfo("Success", "User berhasil diupdate")
|
||
dialog.destroy()
|
||
self.build_user_tab(self.user_tree.master.master)
|
||
|
||
ttk.Button(frame, text="💾 Update", command=update_user).grid(row=2, column=0, columnspan=2, pady=20)
|
||
|
||
def reset_user_password(self):
|
||
sel = self.user_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih User", "Pilih user untuk reset password")
|
||
return
|
||
|
||
item = self.user_tree.item(sel)['values']
|
||
user_id = str(item[0])
|
||
|
||
user = None
|
||
for u in users:
|
||
if u['id'] == user_id:
|
||
user = u
|
||
break
|
||
|
||
if not user:
|
||
return
|
||
|
||
new_password = simpledialog.askstring("Reset Password",
|
||
f"Password baru untuk '{user['username']}':",
|
||
show='●')
|
||
|
||
if new_password:
|
||
user['password'] = new_password
|
||
save_users()
|
||
add_notification("warning", f"Password user '{user['username']}' direset")
|
||
messagebox.showinfo("Success", f"Password '{user['username']}' berhasil direset")
|
||
|
||
def delete_user(self):
|
||
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 = str(item[0])
|
||
|
||
user = None
|
||
for u in users:
|
||
if u['id'] == user_id:
|
||
user = u
|
||
break
|
||
|
||
if not user:
|
||
return
|
||
|
||
if user['id'] == str(self.current_user['id']):
|
||
messagebox.showwarning("Error", "Tidak bisa menghapus user yang sedang login")
|
||
return
|
||
|
||
confirm = messagebox.askyesno("Konfirmasi", f"Hapus user '{user['username']}'?")
|
||
if not confirm:
|
||
return
|
||
|
||
users.remove(user)
|
||
save_users()
|
||
add_notification("warning", f"User '{user['username']}' dihapus")
|
||
messagebox.showinfo("Success", f"User '{user['username']}' berhasil dihapus")
|
||
self.build_user_tab(self.user_tree.master.master)
|
||
|
||
|
||
# ================================
|
||
# TAB: WAITER PESANAN (FIXED)
|
||
# ================================
|
||
|
||
def build_waiter_pesanan_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="🍽️ Daftar Pesanan",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_waiter_pesanan_tab(parent)).pack(side='right', padx=5)
|
||
|
||
info_frame = ttk.Frame(parent)
|
||
info_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
pending_count = len([t for t in transaksi if t['status'] == 'Baru'])
|
||
proses_count = len([t for t in transaksi if t['status'] == 'Diproses'])
|
||
|
||
ttk.Label(info_frame, text=f"📋 Pesanan Baru: {pending_count}",
|
||
font=("Arial", 10), foreground='red').pack(side='left', padx=20)
|
||
ttk.Label(info_frame, text=f"⏳ Sedang Diproses: {proses_count}",
|
||
font=("Arial", 10), foreground='orange').pack(side='left', padx=20)
|
||
|
||
tree_frame = ttk.Frame(parent)
|
||
tree_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("ID", "Tanggal", "User", "Meja", "Total", "Status")
|
||
self.pesanan_tree = ttk.Treeview(
|
||
tree_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=15,
|
||
yscrollcommand=tree_scroll.set
|
||
)
|
||
|
||
tree_scroll.config(command=self.pesanan_tree.yview)
|
||
|
||
for c in cols:
|
||
self.pesanan_tree.heading(c, text=c)
|
||
if c == "ID":
|
||
self.pesanan_tree.column(c, width=50)
|
||
elif c == "Meja":
|
||
self.pesanan_tree.column(c, width=60)
|
||
elif c == "Total":
|
||
self.pesanan_tree.column(c, width=100)
|
||
else:
|
||
self.pesanan_tree.column(c, width=100)
|
||
|
||
self.pesanan_tree.pack(side='left', fill='both', expand=True)
|
||
|
||
for t in transaksi:
|
||
if t.get('payment_status') == 'Berhasil':
|
||
continue
|
||
|
||
self.pesanan_tree.insert("", tk.END, values=(
|
||
t['id'],
|
||
t['tanggal'],
|
||
t['user'],
|
||
t.get('meja', '-'),
|
||
format_currency(t['total']),
|
||
t['status']
|
||
))
|
||
|
||
action_frame = ttk.Frame(parent)
|
||
action_frame.pack(fill='x', padx=10, pady=10)
|
||
|
||
ttk.Button(action_frame, text="✅ Proses Pesanan",
|
||
command=self.proses_pesanan,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="🍽️ Pesanan Siap",
|
||
command=self.siap_pesanan,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="📄 Lihat Detail",
|
||
command=self.detail_pesanan,
|
||
width=20).pack(side='left', padx=5)
|
||
|
||
def proses_pesanan(self):
|
||
sel = self.pesanan_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Pesanan", "Pilih pesanan yang akan diproses")
|
||
return
|
||
|
||
item = self.pesanan_tree.item(sel)['values']
|
||
transaksi_id = item[0]
|
||
|
||
for t in transaksi:
|
||
if t['id'] == transaksi_id:
|
||
if t['status'] != 'Baru':
|
||
messagebox.showinfo("Info", "Pesanan sudah diproses")
|
||
return
|
||
|
||
t['status'] = 'Diproses'
|
||
save_transaksi()
|
||
add_notification("info", f"Pesanan #{t['id']} sedang diproses")
|
||
messagebox.showinfo("Success", f"Pesanan #{transaksi_id} sedang diproses")
|
||
self.build_waiter_pesanan_tab(self.pesanan_tree.master.master)
|
||
return
|
||
|
||
def siap_pesanan(self):
|
||
sel = self.pesanan_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Pesanan", "Pilih pesanan yang sudah siap")
|
||
return
|
||
|
||
item = self.pesanan_tree.item(sel)['values']
|
||
transaksi_id = item[0]
|
||
|
||
for t in transaksi:
|
||
if t['id'] == transaksi_id:
|
||
if t['status'] == 'Baru':
|
||
messagebox.showwarning("Warning", "Proses pesanan terlebih dahulu")
|
||
return
|
||
|
||
if t['status'] == 'Siap':
|
||
messagebox.showinfo("Info", "Pesanan sudah ditandai siap")
|
||
return
|
||
|
||
t['status'] = 'Siap'
|
||
save_transaksi()
|
||
add_notification("success", f"Pesanan #{t['id']} siap disajikan")
|
||
messagebox.showinfo("Success", f"Pesanan #{transaksi_id} siap disajikan!")
|
||
self.build_waiter_pesanan_tab(self.pesanan_tree.master.master)
|
||
return
|
||
|
||
def detail_pesanan(self):
|
||
sel = self.pesanan_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Pesanan", "Pilih pesanan untuk melihat detail")
|
||
return
|
||
|
||
item = self.pesanan_tree.item(sel)['values']
|
||
transaksi_id = item[0]
|
||
|
||
for t in transaksi:
|
||
if t['id'] == transaksi_id:
|
||
detail = f"═══════════════════════════════\n"
|
||
detail += f"DETAIL PESANAN #{t['id']}\n"
|
||
detail += f"═══════════════════════════════\n\n"
|
||
detail += f"Meja: {t.get('meja', '-')}\n"
|
||
detail += f"User: {t['user']}\n"
|
||
detail += f"Status: {t['status']}\n\n"
|
||
detail += f"ITEM PESANAN:\n"
|
||
detail += f"───────────────────────────────\n"
|
||
|
||
for it in t['items']:
|
||
detail += f"• {it['nama']} x{it['qty']}\n"
|
||
|
||
detail += f"───────────────────────────────\n"
|
||
detail += f"Total: {format_currency(t['total'])}\n"
|
||
detail += f"═══════════════════════════════\n"
|
||
|
||
messagebox.showinfo(f"Detail Pesanan #{transaksi_id}", detail)
|
||
return
|
||
|
||
# ================================
|
||
# TAB: KELOLA MENU (Admin)
|
||
# ================================
|
||
|
||
def build_menu_manage_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="⚙️ Kelola Menu",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="➕ Tambah Menu",
|
||
command=self.add_menu_dialog).pack(side='right', padx=5)
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_menu_manage_tab(parent)).pack(side='right', padx=5)
|
||
|
||
search_frame = ttk.LabelFrame(parent, text="🔍 Filter Menu", padding=10)
|
||
search_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
search_inner = ttk.Frame(search_frame)
|
||
search_inner.pack()
|
||
|
||
ttk.Label(search_inner, text="Kategori:").grid(row=0, column=0, padx=5)
|
||
self.menu_manage_filter_var = tk.StringVar(value="Semua")
|
||
categories = ["Semua"] + sorted(set(m['kategori'] for m in menu))
|
||
ttk.Combobox(search_inner, textvariable=self.menu_manage_filter_var,
|
||
values=categories, state="readonly", width=15).grid(row=0, column=1, padx=5)
|
||
|
||
ttk.Button(search_inner, text="🔍 Filter",
|
||
command=lambda: self.refresh_menu_manage_tree()).grid(row=0, column=2, padx=5)
|
||
|
||
tree_frame = ttk.Frame(parent)
|
||
tree_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
tree_scroll = ttk.Scrollbar(tree_frame, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("ID", "Nama", "Harga", "Kategori", "Stok", "Promo", "Diskon%")
|
||
self.menu_manage_tree = ttk.Treeview(
|
||
tree_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=12,
|
||
yscrollcommand=tree_scroll.set
|
||
)
|
||
|
||
tree_scroll.config(command=self.menu_manage_tree.yview)
|
||
|
||
for c in cols:
|
||
self.menu_manage_tree.heading(c, text=c)
|
||
if c == "ID":
|
||
self.menu_manage_tree.column(c, width=40)
|
||
elif c == "Nama":
|
||
self.menu_manage_tree.column(c, width=150)
|
||
elif c == "Diskon%":
|
||
self.menu_manage_tree.column(c, width=60)
|
||
else:
|
||
self.menu_manage_tree.column(c, width=100)
|
||
|
||
self.menu_manage_tree.pack(side='left', fill='both', expand=True)
|
||
|
||
self.refresh_menu_manage_tree()
|
||
|
||
action_frame = ttk.Frame(parent)
|
||
action_frame.pack(fill='x', padx=10, pady=10)
|
||
|
||
ttk.Button(action_frame, text="✏️ Edit Menu",
|
||
command=self.edit_menu_dialog,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="📸 Upload Foto",
|
||
command=self.upload_menu_photo,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="🗑️ Hapus Menu",
|
||
command=self.delete_menu,
|
||
width=20).pack(side='left', padx=5)
|
||
|
||
def refresh_menu_manage_tree(self):
|
||
for r in self.menu_manage_tree.get_children():
|
||
self.menu_manage_tree.delete(r)
|
||
|
||
filter_cat = self.menu_manage_filter_var.get()
|
||
|
||
for m in menu:
|
||
if filter_cat != "Semua" and m['kategori'] != filter_cat:
|
||
continue
|
||
|
||
self.menu_manage_tree.insert("", tk.END, values=(
|
||
m['id'],
|
||
m['nama'],
|
||
format_currency(m['harga']),
|
||
m['kategori'],
|
||
m['stok'],
|
||
m.get('promo', '-'),
|
||
f"{m.get('item_discount_pct', 0)}%"
|
||
))
|
||
|
||
def add_menu_dialog(self):
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title("➕ Tambah Menu Baru")
|
||
dialog.geometry("450x500")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text="Nama Menu:").grid(row=0, column=0, sticky='w', pady=8)
|
||
nama_entry = ttk.Entry(frame, width=30)
|
||
nama_entry.grid(row=0, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Harga:").grid(row=1, column=0, sticky='w', pady=8)
|
||
harga_entry = ttk.Entry(frame, width=30)
|
||
harga_entry.grid(row=1, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Kategori:").grid(row=2, column=0, sticky='w', pady=8)
|
||
kategori_var = tk.StringVar(value="Minuman")
|
||
ttk.Combobox(frame, textvariable=kategori_var,
|
||
values=["Minuman", "Makanan", "Snack", "Dessert"],
|
||
width=28).grid(row=2, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Stok Awal:").grid(row=3, column=0, sticky='w', pady=8)
|
||
stok_entry = ttk.Entry(frame, width=30)
|
||
stok_entry.insert(0, "10")
|
||
stok_entry.grid(row=3, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Promo Text:").grid(row=4, column=0, sticky='w', pady=8)
|
||
promo_entry = ttk.Entry(frame, width=30)
|
||
promo_entry.grid(row=4, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Diskon %:").grid(row=5, column=0, sticky='w', pady=8)
|
||
discount_entry = ttk.Entry(frame, width=30)
|
||
discount_entry.insert(0, "0")
|
||
discount_entry.grid(row=5, column=1, pady=8)
|
||
|
||
def save_menu():
|
||
nama = nama_entry.get().strip()
|
||
|
||
try:
|
||
harga = int(harga_entry.get())
|
||
stok = int(stok_entry.get())
|
||
discount = float(discount_entry.get())
|
||
except:
|
||
messagebox.showerror("Input Error", "Harga, Stok, dan Diskon harus angka")
|
||
return
|
||
|
||
if not nama:
|
||
messagebox.showwarning("Input Error", "Nama menu harus diisi")
|
||
return
|
||
|
||
new_id = max([m['id'] for m in menu], default=0) + 1
|
||
|
||
menu.append({
|
||
'id': new_id,
|
||
'nama': nama,
|
||
'harga': harga,
|
||
'kategori': kategori_var.get(),
|
||
'stok': stok,
|
||
'foto': '',
|
||
'promo': promo_entry.get().strip(),
|
||
'item_discount_pct': discount
|
||
})
|
||
|
||
save_menu()
|
||
add_notification("success", f"Menu '{nama}' berhasil ditambahkan")
|
||
messagebox.showinfo("Success", f"Menu '{nama}' berhasil ditambahkan!")
|
||
dialog.destroy()
|
||
self.build_menu_manage_tab(self.menu_manage_tree.master.master)
|
||
|
||
ttk.Button(frame, text="💾 Simpan", command=save_menu).grid(row=6, column=0, columnspan=2, pady=20)
|
||
|
||
def edit_menu_dialog(self):
|
||
sel = self.menu_manage_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Menu", "Pilih menu yang akan diedit")
|
||
return
|
||
|
||
item = self.menu_manage_tree.item(sel)['values']
|
||
menu_id = item[0]
|
||
|
||
menu_item = find_menu_by_id(menu_id)
|
||
if not menu_item:
|
||
return
|
||
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title(f"✏️ Edit Menu: {menu_item['nama']}")
|
||
dialog.geometry("450x500")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text="Nama Menu:").grid(row=0, column=0, sticky='w', pady=8)
|
||
nama_entry = ttk.Entry(frame, width=30)
|
||
nama_entry.insert(0, menu_item['nama'])
|
||
nama_entry.grid(row=0, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Harga:").grid(row=1, column=0, sticky='w', pady=8)
|
||
harga_entry = ttk.Entry(frame, width=30)
|
||
harga_entry.insert(0, str(menu_item['harga']))
|
||
harga_entry.grid(row=1, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Kategori:").grid(row=2, column=0, sticky='w', pady=8)
|
||
kategori_var = tk.StringVar(value=menu_item['kategori'])
|
||
ttk.Combobox(frame, textvariable=kategori_var,
|
||
values=["Minuman", "Makanan", "Snack", "Dessert"],
|
||
width=28).grid(row=2, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Stok:").grid(row=3, column=0, sticky='w', pady=8)
|
||
stok_entry = ttk.Entry(frame, width=30)
|
||
stok_entry.insert(0, str(menu_item['stok']))
|
||
stok_entry.grid(row=3, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Promo Text:").grid(row=4, column=0, sticky='w', pady=8)
|
||
promo_entry = ttk.Entry(frame, width=30)
|
||
promo_entry.insert(0, menu_item.get('promo', ''))
|
||
promo_entry.grid(row=4, column=1, pady=8)
|
||
|
||
ttk.Label(frame, text="Diskon %:").grid(row=5, column=0, sticky='w', pady=8)
|
||
discount_entry = ttk.Entry(frame, width=30)
|
||
discount_entry.insert(0, str(menu_item.get('item_discount_pct', 0)))
|
||
discount_entry.grid(row=5, column=1, pady=8)
|
||
|
||
def update_menu():
|
||
nama = nama_entry.get().strip()
|
||
|
||
try:
|
||
harga = int(harga_entry.get())
|
||
stok = int(stok_entry.get())
|
||
discount = float(discount_entry.get())
|
||
except:
|
||
messagebox.showerror("Input Error", "Harga, Stok, dan Diskon harus angka")
|
||
return
|
||
|
||
if not nama:
|
||
messagebox.showwarning("Input Error", "Nama menu harus diisi")
|
||
return
|
||
|
||
menu_item['nama'] = nama
|
||
menu_item['harga'] = harga
|
||
menu_item['kategori'] = kategori_var.get()
|
||
menu_item['stok'] = stok
|
||
menu_item['promo'] = promo_entry.get().strip()
|
||
menu_item['item_discount_pct'] = discount
|
||
|
||
save_menu()
|
||
add_notification("info", f"Menu '{nama}' berhasil diupdate")
|
||
messagebox.showinfo("Success", "Menu berhasil diupdate!")
|
||
dialog.destroy()
|
||
self.build_menu_manage_tab(self.menu_manage_tree.master.master)
|
||
|
||
ttk.Button(frame, text="💾 Update", command=update_menu).grid(row=6, column=0, columnspan=2, pady=20)
|
||
|
||
def upload_menu_photo(self):
|
||
sel = self.menu_manage_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Menu", "Pilih menu untuk upload foto")
|
||
return
|
||
|
||
item = self.menu_manage_tree.item(sel)['values']
|
||
menu_id = item[0]
|
||
|
||
menu_item = find_menu_by_id(menu_id)
|
||
if not menu_item:
|
||
return
|
||
|
||
file_path = filedialog.askopenfilename(
|
||
title="Pilih Foto Menu",
|
||
filetypes=[("Image files", "*.jpg *.jpeg *.png *.gif *.bmp")]
|
||
)
|
||
|
||
if file_path:
|
||
new_path = copy_image_to_project(file_path)
|
||
if new_path:
|
||
menu_item['foto'] = new_path
|
||
save_menu()
|
||
messagebox.showinfo("Success", f"Foto berhasil diupload untuk '{menu_item['nama']}'")
|
||
self.build_menu_manage_tab(self.menu_manage_tree.master.master)
|
||
else:
|
||
messagebox.showerror("Error", "Gagal mengcopy foto")
|
||
|
||
def delete_menu(self):
|
||
sel = self.menu_manage_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Menu", "Pilih menu yang akan dihapus")
|
||
return
|
||
|
||
item = self.menu_manage_tree.item(sel)['values']
|
||
menu_id = item[0]
|
||
|
||
menu_item = find_menu_by_id(menu_id)
|
||
if not menu_item:
|
||
return
|
||
|
||
confirm = messagebox.askyesno("Konfirmasi", f"Hapus menu '{menu_item['nama']}'?")
|
||
if not confirm:
|
||
return
|
||
|
||
menu.remove(menu_item)
|
||
save_menu()
|
||
add_notification("warning", f"Menu '{menu_item['nama']}' dihapus")
|
||
messagebox.showinfo("Success", f"Menu '{menu_item['nama']}' berhasil dihapus")
|
||
self.build_menu_manage_tab(self.menu_manage_tree.master.master)
|
||
|
||
# ================================
|
||
# TAB: STOK BAHAN (Admin)
|
||
# ================================
|
||
|
||
def build_bahan_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="📦 Kelola Stok Bahan",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="➕ Tambah Bahan",
|
||
command=self.add_bahan_dialog).pack(side='right', padx=5)
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_bahan_tab(parent)).pack(side='right', padx=5)
|
||
|
||
# Warning frame untuk stok rendah
|
||
low_stock = [name for name, qty in bahan.items() if qty < 10]
|
||
if low_stock:
|
||
warning_frame = ttk.Frame(parent)
|
||
warning_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
tk.Label(warning_frame,
|
||
text=f"⚠️ PERINGATAN: {len(low_stock)} bahan dengan stok < 10!",
|
||
font=("Arial", 10, "bold"),
|
||
bg="#FFF3CD", fg="#856404",
|
||
padx=10, pady=8).pack(fill='x')
|
||
|
||
# Container dengan 2 kolom
|
||
container = ttk.Frame(parent)
|
||
container.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
# LEFT: Daftar Bahan
|
||
left_frame = ttk.LabelFrame(container, text="📋 Daftar Bahan Baku", padding=10)
|
||
left_frame.pack(side='left', fill='both', expand=True, padx=(0, 5))
|
||
|
||
tree_scroll = ttk.Scrollbar(left_frame, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("Nama Bahan", "Jumlah", "Status")
|
||
self.bahan_tree = ttk.Treeview(
|
||
left_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=15,
|
||
yscrollcommand=tree_scroll.set
|
||
)
|
||
|
||
tree_scroll.config(command=self.bahan_tree.yview)
|
||
|
||
self.bahan_tree.heading("Nama Bahan", text="Nama Bahan")
|
||
self.bahan_tree.heading("Jumlah", text="Jumlah")
|
||
self.bahan_tree.heading("Status", text="Status")
|
||
|
||
self.bahan_tree.column("Nama Bahan", width=150)
|
||
self.bahan_tree.column("Jumlah", width=80)
|
||
self.bahan_tree.column("Status", width=100)
|
||
|
||
self.bahan_tree.pack(side='left', fill='both', expand=True)
|
||
|
||
for nama, qty in sorted(bahan.items()):
|
||
if qty < 10:
|
||
status = "🔴 Rendah"
|
||
elif qty < 20:
|
||
status = "🟡 Sedang"
|
||
else:
|
||
status = "🟢 Aman"
|
||
|
||
self.bahan_tree.insert("", tk.END, values=(nama, qty, status))
|
||
|
||
action_frame = ttk.Frame(left_frame)
|
||
action_frame.pack(fill='x', pady=(10, 0))
|
||
|
||
ttk.Button(action_frame, text="➕ Tambah Stok",
|
||
command=self.add_stock_dialog,
|
||
width=18).pack(side='left', padx=3)
|
||
ttk.Button(action_frame, text="➖ Kurangi Stok",
|
||
command=self.reduce_stock_dialog,
|
||
width=18).pack(side='left', padx=3)
|
||
ttk.Button(action_frame, text="🗑️ Hapus Bahan",
|
||
command=self.delete_bahan,
|
||
width=18).pack(side='left', padx=3)
|
||
|
||
# RIGHT: Resep Menu
|
||
right_frame = ttk.LabelFrame(container, text="📝 Resep Menu", padding=10)
|
||
right_frame.pack(side='right', fill='both', expand=True, padx=(5, 0))
|
||
|
||
ttk.Label(right_frame, text="Pilih Menu:", font=("Arial", 9, "bold")).pack(anchor='w', pady=5)
|
||
|
||
menu_names = [f"{m['id']} - {m['nama']}" for m in menu]
|
||
self.resep_menu_var = tk.StringVar()
|
||
|
||
menu_combo = ttk.Combobox(right_frame, textvariable=self.resep_menu_var,
|
||
values=menu_names, state="readonly", width=30)
|
||
menu_combo.pack(fill='x', pady=5)
|
||
menu_combo.bind("<<ComboboxSelected>>", lambda e: self.show_resep())
|
||
|
||
self.resep_text = tk.Text(right_frame, height=12, font=("Arial", 10),
|
||
wrap='word', state='disabled')
|
||
self.resep_text.pack(fill='both', expand=True, pady=5)
|
||
|
||
ttk.Button(right_frame, text="✏️ Edit Resep",
|
||
command=self.edit_resep_dialog).pack(fill='x', pady=5)
|
||
|
||
def show_resep(self):
|
||
selected = self.resep_menu_var.get()
|
||
if not selected:
|
||
return
|
||
|
||
try:
|
||
menu_id = int(selected.split(' - ')[0])
|
||
except:
|
||
return
|
||
|
||
menu_item = find_menu_by_id(menu_id)
|
||
if not menu_item:
|
||
return
|
||
|
||
self.resep_text.config(state='normal')
|
||
self.resep_text.delete('1.0', tk.END)
|
||
|
||
text = f"═══════════════════════════════\n"
|
||
text += f"RESEP: {menu_item['nama']}\n"
|
||
text += f"═══════════════════════════════\n\n"
|
||
|
||
if menu_id in resep and resep[menu_id]:
|
||
text += "Bahan yang dibutuhkan:\n\n"
|
||
for bahan_nama, qty in resep[menu_id].items():
|
||
stok_tersedia = bahan.get(bahan_nama, 0)
|
||
text += f"• {bahan_nama}: {qty} unit\n"
|
||
text += f" (Stok tersedia: {stok_tersedia})\n\n"
|
||
else:
|
||
text += "❌ Belum ada resep untuk menu ini.\n\n"
|
||
text += "Klik 'Edit Resep' untuk menambahkan."
|
||
|
||
self.resep_text.insert('1.0', text)
|
||
self.resep_text.config(state='disabled')
|
||
|
||
def add_bahan_dialog(self):
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title("➕ Tambah Bahan Baru")
|
||
dialog.geometry("400x250")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text="Nama Bahan:").grid(row=0, column=0, sticky='w', pady=10)
|
||
nama_entry = ttk.Entry(frame, width=25)
|
||
nama_entry.grid(row=0, column=1, pady=10)
|
||
|
||
ttk.Label(frame, text="Jumlah Awal:").grid(row=1, column=0, sticky='w', pady=10)
|
||
jumlah_entry = ttk.Entry(frame, width=25)
|
||
jumlah_entry.insert(0, "50")
|
||
jumlah_entry.grid(row=1, column=1, pady=10)
|
||
|
||
def save_bahan():
|
||
nama = nama_entry.get().strip().lower()
|
||
|
||
try:
|
||
jumlah = int(jumlah_entry.get())
|
||
except:
|
||
messagebox.showerror("Input Error", "Jumlah harus angka")
|
||
return
|
||
|
||
if not nama:
|
||
messagebox.showwarning("Input Error", "Nama bahan harus diisi")
|
||
return
|
||
|
||
if nama in bahan:
|
||
messagebox.showwarning("Sudah Ada", f"Bahan '{nama}' sudah ada")
|
||
return
|
||
|
||
bahan[nama] = jumlah
|
||
save_bahan()
|
||
add_notification("success", f"Bahan '{nama}' berhasil ditambahkan")
|
||
messagebox.showinfo("Success", f"Bahan '{nama}' berhasil ditambahkan!")
|
||
dialog.destroy()
|
||
self.build_bahan_tab(self.bahan_tree.master.master.master)
|
||
|
||
ttk.Button(frame, text="💾 Simpan", command=save_bahan).grid(row=2, column=0, columnspan=2, pady=20)
|
||
|
||
def add_stock_dialog(self):
|
||
sel = self.bahan_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Bahan", "Pilih bahan untuk menambah stok")
|
||
return
|
||
|
||
item = self.bahan_tree.item(sel)['values']
|
||
nama_bahan = item[0]
|
||
|
||
qty = simpledialog.askinteger("Tambah Stok",
|
||
f"Tambah berapa unit untuk '{nama_bahan}'?",
|
||
minvalue=1)
|
||
|
||
if qty:
|
||
bahan[nama_bahan] += qty
|
||
save_bahan()
|
||
add_notification("info", f"Stok {nama_bahan} ditambah {qty} unit")
|
||
messagebox.showinfo("Success", f"Stok '{nama_bahan}' berhasil ditambahkan!")
|
||
self.build_bahan_tab(self.bahan_tree.master.master.master)
|
||
|
||
def reduce_stock_dialog(self):
|
||
sel = self.bahan_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Bahan", "Pilih bahan untuk mengurangi stok")
|
||
return
|
||
|
||
item = self.bahan_tree.item(sel)['values']
|
||
nama_bahan = item[0]
|
||
stok_sekarang = bahan.get(nama_bahan, 0)
|
||
|
||
qty = simpledialog.askinteger("Kurangi Stok",
|
||
f"Kurangi berapa unit dari '{nama_bahan}'?\n(Stok sekarang: {stok_sekarang})",
|
||
minvalue=1, maxvalue=stok_sekarang)
|
||
|
||
if qty:
|
||
bahan[nama_bahan] -= qty
|
||
if bahan[nama_bahan] < 0:
|
||
bahan[nama_bahan] = 0
|
||
|
||
save_bahan()
|
||
add_notification("warning", f"Stok {nama_bahan} dikurangi {qty} unit")
|
||
messagebox.showinfo("Success", f"Stok '{nama_bahan}' berhasil dikurangi!")
|
||
self.build_bahan_tab(self.bahan_tree.master.master.master)
|
||
|
||
def delete_bahan(self):
|
||
sel = self.bahan_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Bahan", "Pilih bahan yang akan dihapus")
|
||
return
|
||
|
||
item = self.bahan_tree.item(sel)['values']
|
||
nama_bahan = item[0]
|
||
|
||
# Check if used in recipe
|
||
used_in = []
|
||
for menu_id, ingredients in resep.items():
|
||
if nama_bahan in ingredients:
|
||
menu_item = find_menu_by_id(menu_id)
|
||
if menu_item:
|
||
used_in.append(menu_item['nama'])
|
||
|
||
if used_in:
|
||
messagebox.showwarning("Tidak Bisa Dihapus",
|
||
f"Bahan '{nama_bahan}' digunakan di resep:\n" + "\n".join(used_in))
|
||
return
|
||
|
||
confirm = messagebox.askyesno("Konfirmasi", f"Hapus bahan '{nama_bahan}'?")
|
||
if not confirm:
|
||
return
|
||
|
||
del bahan[nama_bahan]
|
||
save_bahan()
|
||
add_notification("warning", f"Bahan '{nama_bahan}' dihapus")
|
||
messagebox.showinfo("Success", f"Bahan '{nama_bahan}' berhasil dihapus")
|
||
self.build_bahan_tab(self.bahan_tree.master.master.master)
|
||
|
||
def edit_resep_dialog(self):
|
||
selected = self.resep_menu_var.get()
|
||
if not selected:
|
||
messagebox.showwarning("Pilih Menu", "Pilih menu terlebih dahulu")
|
||
return
|
||
|
||
try:
|
||
menu_id = int(selected.split(' - ')[0])
|
||
except:
|
||
return
|
||
|
||
menu_item = find_menu_by_id(menu_id)
|
||
if not menu_item:
|
||
return
|
||
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title(f"✏️ Edit Resep: {menu_item['nama']}")
|
||
dialog.geometry("500x500")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text=f"Resep untuk: {menu_item['nama']}",
|
||
font=("Arial", 12, "bold")).pack(pady=10)
|
||
|
||
# Frame untuk resep entries
|
||
resep_frame = ttk.Frame(frame)
|
||
resep_frame.pack(fill='both', expand=True, pady=10)
|
||
|
||
# Canvas + Scrollbar untuk banyak bahan
|
||
canvas = tk.Canvas(resep_frame, height=300)
|
||
scrollbar = ttk.Scrollbar(resep_frame, orient="vertical", command=canvas.yview)
|
||
scrollable_frame = ttk.Frame(canvas)
|
||
|
||
scrollable_frame.bind(
|
||
"<Configure>",
|
||
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||
)
|
||
|
||
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
||
canvas.configure(yscrollcommand=scrollbar.set)
|
||
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
scrollbar.pack(side="right", fill="y")
|
||
|
||
# Get current recipe
|
||
current_resep = resep.get(menu_id, {})
|
||
|
||
# Buat entry untuk setiap bahan yang ada
|
||
entries = {}
|
||
row = 0
|
||
|
||
for bahan_nama in sorted(bahan.keys()):
|
||
ttk.Label(scrollable_frame, text=f"{bahan_nama}:").grid(row=row, column=0, sticky='w', padx=5, pady=5)
|
||
|
||
qty_entry = ttk.Entry(scrollable_frame, width=10)
|
||
qty_entry.insert(0, str(current_resep.get(bahan_nama, 0)))
|
||
qty_entry.grid(row=row, column=1, padx=5, pady=5)
|
||
|
||
ttk.Label(scrollable_frame, text="unit").grid(row=row, column=2, sticky='w', padx=5, pady=5)
|
||
|
||
entries[bahan_nama] = qty_entry
|
||
row += 1
|
||
|
||
def save_resep():
|
||
new_resep = {}
|
||
|
||
for bahan_nama, entry in entries.items():
|
||
try:
|
||
qty = int(entry.get())
|
||
if qty > 0:
|
||
new_resep[bahan_nama] = qty
|
||
except:
|
||
pass
|
||
|
||
if not new_resep:
|
||
confirm = messagebox.askyesno("Konfirmasi", "Resep kosong. Hapus resep untuk menu ini?")
|
||
if confirm and menu_id in resep:
|
||
del resep[menu_id]
|
||
else:
|
||
resep[menu_id] = new_resep
|
||
|
||
save_resep()
|
||
add_notification("info", f"Resep '{menu_item['nama']}' berhasil diupdate")
|
||
messagebox.showinfo("Success", "Resep berhasil disimpan!")
|
||
dialog.destroy()
|
||
self.show_resep()
|
||
|
||
ttk.Button(frame, text="💾 Simpan Resep", command=save_resep).pack(pady=10)
|
||
|
||
# ================================
|
||
# TAB: PROMO (Admin)
|
||
# ================================
|
||
|
||
def build_promo_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="🎁 Kelola Kode Promo",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="➕ Tambah Promo",
|
||
command=self.add_promo_dialog).pack(side='right', padx=5)
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_promo_tab(parent)).pack(side='right', padx=5)
|
||
|
||
info_frame = ttk.Frame(parent)
|
||
info_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
tk.Label(info_frame,
|
||
text=f"📊 Total Kode Promo Aktif: {len(promo_codes)}",
|
||
font=("Arial", 10, "bold"),
|
||
bg="#E3F2FD", fg="#1565C0",
|
||
padx=15, pady=8).pack(fill='x')
|
||
|
||
# Promo Code List
|
||
list_frame = ttk.LabelFrame(parent, text="📋 Daftar Kode Promo", padding=10)
|
||
list_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
tree_scroll = ttk.Scrollbar(list_frame, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("Kode Promo", "Diskon %", "Status")
|
||
self.promo_tree = ttk.Treeview(
|
||
list_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=12,
|
||
yscrollcommand=tree_scroll.set
|
||
)
|
||
|
||
tree_scroll.config(command=self.promo_tree.yview)
|
||
|
||
self.promo_tree.heading("Kode Promo", text="Kode Promo")
|
||
self.promo_tree.heading("Diskon %", text="Diskon %")
|
||
self.promo_tree.heading("Status", text="Status")
|
||
|
||
self.promo_tree.column("Kode Promo", width=200)
|
||
self.promo_tree.column("Diskon %", width=100)
|
||
self.promo_tree.column("Status", width=150)
|
||
|
||
self.promo_tree.pack(side='left', fill='both', expand=True)
|
||
|
||
for code, discount in sorted(promo_codes.items()):
|
||
self.promo_tree.insert("", tk.END, values=(
|
||
code,
|
||
f"{discount}%",
|
||
"✅ Aktif"
|
||
))
|
||
|
||
# Action buttons
|
||
action_frame = ttk.Frame(list_frame)
|
||
action_frame.pack(fill='x', pady=(10, 0))
|
||
|
||
ttk.Button(action_frame, text="✏️ Edit Promo",
|
||
command=self.edit_promo_dialog,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="🗑️ Hapus Promo",
|
||
command=self.delete_promo,
|
||
width=20).pack(side='left', padx=5)
|
||
ttk.Button(action_frame, text="📋 Salin Kode",
|
||
command=self.copy_promo_code,
|
||
width=20).pack(side='left', padx=5)
|
||
|
||
# Info box
|
||
info_box = ttk.LabelFrame(parent, text="ℹ️ Informasi", padding=10)
|
||
info_box.pack(fill='x', padx=10, pady=5)
|
||
|
||
info_text = """
|
||
- Kode promo dapat digunakan oleh pembeli saat checkout
|
||
- Diskon promo diterapkan setelah diskon item
|
||
- Kode promo tidak case-sensitive (CAFE10 = cafe10)
|
||
- Gunakan kode yang mudah diingat untuk pelanggan
|
||
"""
|
||
|
||
ttk.Label(info_box, text=info_text, font=("Arial", 9),
|
||
foreground='#666666', justify='left').pack(anchor='w')
|
||
|
||
def add_promo_dialog(self):
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title("➕ Tambah Kode Promo Baru")
|
||
dialog.geometry("450x300")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text="Kode Promo:", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky='w', pady=10)
|
||
ttk.Label(frame, text="(Huruf kapital, tanpa spasi)").grid(row=1, column=0, sticky='w', pady=(0, 10))
|
||
code_entry = ttk.Entry(frame, width=30, font=("Arial", 11))
|
||
code_entry.grid(row=0, column=1, rowspan=2, pady=10, padx=10)
|
||
|
||
ttk.Label(frame, text="Diskon (%):", font=("Arial", 10, "bold")).grid(row=2, column=0, sticky='w', pady=10)
|
||
discount_entry = ttk.Entry(frame, width=30)
|
||
discount_entry.grid(row=2, column=1, pady=10, padx=10)
|
||
|
||
ttk.Label(frame, text="Contoh: CAFE10, DISKON20, WELCOME15",
|
||
font=("Arial", 9), foreground='gray').grid(row=3, column=0, columnspan=2, pady=5)
|
||
|
||
def save_promo():
|
||
code = code_entry.get().strip().upper()
|
||
|
||
try:
|
||
discount = int(discount_entry.get())
|
||
except:
|
||
messagebox.showerror("Input Error", "Diskon harus angka")
|
||
return
|
||
|
||
if not code:
|
||
messagebox.showwarning("Input Error", "Kode promo harus diisi")
|
||
return
|
||
|
||
if ' ' in code:
|
||
messagebox.showwarning("Input Error", "Kode promo tidak boleh mengandung spasi")
|
||
return
|
||
|
||
if discount < 1 or discount > 100:
|
||
messagebox.showwarning("Input Error", "Diskon harus antara 1-100%")
|
||
return
|
||
|
||
if code in promo_codes:
|
||
messagebox.showwarning("Sudah Ada", f"Kode promo '{code}' sudah ada")
|
||
return
|
||
|
||
promo_codes[code] = discount
|
||
save_promo_codes()
|
||
add_notification("success", f"Kode promo '{code}' berhasil ditambahkan")
|
||
messagebox.showinfo("Success", f"✅ Kode promo '{code}' berhasil dibuat!\n\nDiskon: {discount}%")
|
||
dialog.destroy()
|
||
self.build_promo_tab(self.promo_tree.master.master)
|
||
|
||
ttk.Button(frame, text="💾 Simpan Promo", command=save_promo).grid(row=4, column=0, columnspan=2, pady=20)
|
||
|
||
def edit_promo_dialog(self):
|
||
sel = self.promo_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Promo", "Pilih kode promo yang akan diedit")
|
||
return
|
||
|
||
item = self.promo_tree.item(sel)['values']
|
||
code = item[0]
|
||
current_discount = promo_codes.get(code, 0)
|
||
|
||
dialog = tk.Toplevel(self.root)
|
||
dialog.title(f"✏️ Edit Kode Promo: {code}")
|
||
dialog.geometry("450x250")
|
||
dialog.resizable(False, False)
|
||
|
||
frame = ttk.Frame(dialog, padding=20)
|
||
frame.pack(fill='both', expand=True)
|
||
|
||
ttk.Label(frame, text="Kode Promo:", font=("Arial", 10, "bold")).grid(row=0, column=0, sticky='w', pady=10)
|
||
code_label = ttk.Label(frame, text=code, font=("Arial", 12, "bold"), foreground='#1565C0')
|
||
code_label.grid(row=0, column=1, pady=10, padx=10, sticky='w')
|
||
|
||
ttk.Label(frame, text="Diskon Baru (%):", font=("Arial", 10, "bold")).grid(row=1, column=0, sticky='w', pady=10)
|
||
discount_entry = ttk.Entry(frame, width=30)
|
||
discount_entry.insert(0, str(current_discount))
|
||
discount_entry.grid(row=1, column=1, pady=10, padx=10)
|
||
|
||
def update_promo():
|
||
try:
|
||
discount = int(discount_entry.get())
|
||
except:
|
||
messagebox.showerror("Input Error", "Diskon harus angka")
|
||
return
|
||
|
||
if discount < 1 or discount > 100:
|
||
messagebox.showwarning("Input Error", "Diskon harus antara 1-100%")
|
||
return
|
||
|
||
promo_codes[code] = discount
|
||
save_promo_codes()
|
||
add_notification("info", f"Kode promo '{code}' diupdate menjadi {discount}%")
|
||
messagebox.showinfo("Success", f"✅ Kode promo '{code}' berhasil diupdate!")
|
||
dialog.destroy()
|
||
self.build_promo_tab(self.promo_tree.master.master)
|
||
|
||
ttk.Button(frame, text="💾 Update", command=update_promo).grid(row=2, column=0, columnspan=2, pady=20)
|
||
|
||
def delete_promo(self):
|
||
sel = self.promo_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Promo", "Pilih kode promo yang akan dihapus")
|
||
return
|
||
|
||
item = self.promo_tree.item(sel)['values']
|
||
code = item[0]
|
||
|
||
confirm = messagebox.askyesno("Konfirmasi", f"Hapus kode promo '{code}'?")
|
||
if not confirm:
|
||
return
|
||
|
||
del promo_codes[code]
|
||
save_promo_codes()
|
||
add_notification("warning", f"Kode promo '{code}' dihapus")
|
||
messagebox.showinfo("Success", f"Kode promo '{code}' berhasil dihapus")
|
||
self.build_promo_tab(self.promo_tree.master.master)
|
||
|
||
def copy_promo_code(self):
|
||
sel = self.promo_tree.selection()
|
||
if not sel:
|
||
messagebox.showwarning("Pilih Promo", "Pilih kode promo yang akan disalin")
|
||
return
|
||
|
||
item = self.promo_tree.item(sel)['values']
|
||
code = item[0]
|
||
|
||
self.root.clipboard_clear()
|
||
self.root.clipboard_append(code)
|
||
messagebox.showinfo("Tersalin", f"✅ Kode promo '{code}' berhasil disalin ke clipboard!")
|
||
|
||
# ================================
|
||
# TAB: LAPORAN (Owner/Admin)
|
||
# ================================
|
||
|
||
def build_laporan_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
header = ttk.Frame(parent)
|
||
header.pack(fill='x', padx=10, pady=8)
|
||
|
||
ttk.Label(header, text="📊 Laporan & Analisis",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="🔄 Refresh",
|
||
command=lambda: self.build_laporan_tab(parent)).pack(side='right', padx=5)
|
||
ttk.Button(header, text="📥 Export Excel",
|
||
command=self.export_laporan_excel).pack(side='right', padx=5)
|
||
|
||
# Period selector
|
||
period_frame = ttk.LabelFrame(parent, text="📅 Pilih Periode Laporan", padding=10)
|
||
period_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
period_inner = ttk.Frame(period_frame)
|
||
period_inner.pack()
|
||
|
||
ttk.Label(period_inner, text="Dari:").grid(row=0, column=0, padx=5)
|
||
self.laporan_start_date = tk.StringVar(value=str(datetime.date.today()))
|
||
ttk.Entry(period_inner, textvariable=self.laporan_start_date, width=15).grid(row=0, column=1, padx=5)
|
||
|
||
ttk.Label(period_inner, text="Sampai:").grid(row=0, column=2, padx=5)
|
||
self.laporan_end_date = tk.StringVar(value=str(datetime.date.today()))
|
||
ttk.Entry(period_inner, textvariable=self.laporan_end_date, width=15).grid(row=0, column=3, padx=5)
|
||
|
||
ttk.Button(period_inner, text="📊 Tampilkan",
|
||
command=self.refresh_laporan).grid(row=0, column=4, padx=10)
|
||
|
||
# Quick filters
|
||
quick_frame = ttk.Frame(period_frame)
|
||
quick_frame.pack(pady=5)
|
||
|
||
ttk.Button(quick_frame, text="📅 Hari Ini",
|
||
command=lambda: self.set_period_today()).pack(side='left', padx=3)
|
||
ttk.Button(quick_frame, text="📆 Minggu Ini",
|
||
command=lambda: self.set_period_week()).pack(side='left', padx=3)
|
||
ttk.Button(quick_frame, text="📊 Bulan Ini",
|
||
command=lambda: self.set_period_month()).pack(side='left', padx=3)
|
||
|
||
# Main container with tabs
|
||
notebook = ttk.Notebook(parent)
|
||
notebook.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
# Tab 1: Dashboard
|
||
tab_dashboard = ttk.Frame(notebook)
|
||
notebook.add(tab_dashboard, text="📊 Dashboard")
|
||
self.build_dashboard_tab(tab_dashboard)
|
||
|
||
# Tab 2: Detail Transaksi
|
||
tab_detail = ttk.Frame(notebook)
|
||
notebook.add(tab_detail, text="📋 Detail Transaksi")
|
||
self.build_detail_transaksi_tab(tab_detail)
|
||
|
||
# Tab 3: Menu Analytics
|
||
tab_menu = ttk.Frame(notebook)
|
||
notebook.add(tab_menu, text="🍽️ Analisis Menu")
|
||
self.build_menu_analytics_tab(tab_menu)
|
||
|
||
# Tab 4: Payment Analytics
|
||
tab_payment = ttk.Frame(notebook)
|
||
notebook.add(tab_payment, text="💳 Analisis Pembayaran")
|
||
self.build_payment_analytics_tab(tab_payment)
|
||
|
||
def set_period_today(self):
|
||
today = str(datetime.date.today())
|
||
self.laporan_start_date.set(today)
|
||
self.laporan_end_date.set(today)
|
||
self.refresh_laporan()
|
||
|
||
def set_period_week(self):
|
||
today = datetime.date.today()
|
||
start = today - datetime.timedelta(days=today.weekday())
|
||
self.laporan_start_date.set(str(start))
|
||
self.laporan_end_date.set(str(today))
|
||
self.refresh_laporan()
|
||
|
||
def set_period_month(self):
|
||
today = datetime.date.today()
|
||
start = today.replace(day=1)
|
||
self.laporan_start_date.set(str(start))
|
||
self.laporan_end_date.set(str(today))
|
||
self.refresh_laporan()
|
||
|
||
def refresh_laporan(self):
|
||
# Rebuild all tabs
|
||
notebook = None
|
||
for widget in self.root.winfo_children():
|
||
if isinstance(widget, ttk.Notebook):
|
||
for tab in widget.winfo_children():
|
||
if isinstance(tab, ttk.Notebook):
|
||
notebook = tab
|
||
break
|
||
|
||
if notebook:
|
||
for i, tab in enumerate(notebook.winfo_children()):
|
||
if i == 0:
|
||
self.build_dashboard_tab(tab)
|
||
elif i == 1:
|
||
self.build_detail_transaksi_tab(tab)
|
||
elif i == 2:
|
||
self.build_menu_analytics_tab(tab)
|
||
elif i == 3:
|
||
self.build_payment_analytics_tab(tab)
|
||
|
||
def get_filtered_transactions(self):
|
||
start_date = self.laporan_start_date.get()
|
||
end_date = self.laporan_end_date.get()
|
||
|
||
return [t for t in transaksi
|
||
if start_date <= t['tanggal'] <= end_date
|
||
and t.get('payment_status') == 'Berhasil']
|
||
|
||
def build_dashboard_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
trans = self.get_filtered_transactions()
|
||
|
||
# Summary Cards
|
||
summary_frame = ttk.Frame(parent)
|
||
summary_frame.pack(fill='x', padx=10, pady=10)
|
||
|
||
# Card 1: Total Pendapatan
|
||
card1 = tk.Frame(summary_frame, bg='#4CAF50', relief='solid', bd=1)
|
||
card1.pack(side='left', fill='both', expand=True, padx=5)
|
||
|
||
total_income = sum(t['total'] for t in trans)
|
||
|
||
tk.Label(card1, text="💰", font=("Arial", 32), bg='#4CAF50', fg='white').pack(pady=(15, 5))
|
||
tk.Label(card1, text="Total Pendapatan", font=("Arial", 11), bg='#4CAF50', fg='white').pack()
|
||
tk.Label(card1, text=format_currency(total_income),
|
||
font=("Arial", 18, "bold"), bg='#4CAF50', fg='white').pack(pady=(5, 15))
|
||
|
||
# Card 2: Total Transaksi
|
||
card2 = tk.Frame(summary_frame, bg='#2196F3', relief='solid', bd=1)
|
||
card2.pack(side='left', fill='both', expand=True, padx=5)
|
||
|
||
tk.Label(card2, text="📋", font=("Arial", 32), bg='#2196F3', fg='white').pack(pady=(15, 5))
|
||
tk.Label(card2, text="Total Transaksi", font=("Arial", 11), bg='#2196F3', fg='white').pack()
|
||
tk.Label(card2, text=str(len(trans)),
|
||
font=("Arial", 18, "bold"), bg='#2196F3', fg='white').pack(pady=(5, 15))
|
||
|
||
# Card 3: Rata-rata
|
||
card3 = tk.Frame(summary_frame, bg='#FF9800', relief='solid', bd=1)
|
||
card3.pack(side='left', fill='both', expand=True, padx=5)
|
||
|
||
avg_trans = total_income // len(trans) if trans else 0
|
||
|
||
tk.Label(card3, text="📊", font=("Arial", 32), bg='#FF9800', fg='white').pack(pady=(15, 5))
|
||
tk.Label(card3, text="Rata-rata/Transaksi", font=("Arial", 11), bg='#FF9800', fg='white').pack()
|
||
tk.Label(card3, text=format_currency(avg_trans),
|
||
font=("Arial", 18, "bold"), bg='#FF9800', fg='white').pack(pady=(5, 15))
|
||
|
||
# Card 4: Menu Terjual
|
||
card4 = tk.Frame(summary_frame, bg='#9C27B0', relief='solid', bd=1)
|
||
card4.pack(side='left', fill='both', expand=True, padx=5)
|
||
|
||
total_items = sum(sum(item['qty'] for item in t['items']) for t in trans)
|
||
|
||
tk.Label(card4, text="🍽️", font=("Arial", 32), bg='#9C27B0', fg='white').pack(pady=(15, 5))
|
||
tk.Label(card4, text="Total Item Terjual", font=("Arial", 11), bg='#9C27B0', fg='white').pack()
|
||
tk.Label(card4, text=str(total_items),
|
||
font=("Arial", 18, "bold"), bg='#9C27B0', fg='white').pack(pady=(5, 15))
|
||
|
||
# Chart Section
|
||
chart_frame = ttk.LabelFrame(parent, text="📈 Grafik Penjualan", padding=10)
|
||
chart_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
if MATPLOTLIB_AVAILABLE and trans:
|
||
self.create_sales_chart(chart_frame, trans)
|
||
else:
|
||
tk.Label(chart_frame,
|
||
text="📊 Chart membutuhkan matplotlib\nInstall: pip install matplotlib",
|
||
font=("Arial", 12), fg='gray').pack(pady=50)
|
||
|
||
# Top Menu Section
|
||
top_frame = ttk.LabelFrame(parent, text="🏆 Top 5 Menu Terlaris", padding=10)
|
||
top_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
menu_sales = defaultdict(int)
|
||
for t in trans:
|
||
for item in t['items']:
|
||
menu_sales[item['nama']] += item['qty']
|
||
|
||
top_menu = sorted(menu_sales.items(), key=lambda x: x[1], reverse=True)[:5]
|
||
|
||
if top_menu:
|
||
for i, (nama, qty) in enumerate(top_menu, 1):
|
||
rank_frame = tk.Frame(top_frame, bg='white', relief='solid', bd=1)
|
||
rank_frame.pack(fill='x', pady=3)
|
||
|
||
medal = ['🥇', '🥈', '🥉', '4️⃣', '5️⃣'][i-1]
|
||
|
||
tk.Label(rank_frame, text=f"{medal} {nama}",
|
||
font=("Arial", 11, "bold"), bg='white', anchor='w').pack(side='left', padx=10, pady=8)
|
||
tk.Label(rank_frame, text=f"{qty} terjual",
|
||
font=("Arial", 10), bg='white', fg='green', anchor='e').pack(side='right', padx=10, pady=8)
|
||
else:
|
||
tk.Label(top_frame, text="Belum ada data", fg='gray').pack(pady=10)
|
||
|
||
def create_sales_chart(self, parent, transactions):
|
||
"""Buat multiple charts untuk visualisasi data penjualan"""
|
||
if not MATPLOTLIB_AVAILABLE or not transactions:
|
||
tk.Label(parent, text="📊 Chart data tidak tersedia", fg='gray').pack(pady=20)
|
||
return
|
||
|
||
today = str(datetime.date.today())
|
||
|
||
fig = Figure(figsize=(16, 10), dpi=80)
|
||
|
||
# Chart 1: Daily Sales (Bar Chart)
|
||
ax1 = fig.add_subplot(3, 3, 1)
|
||
daily_sales = defaultdict(int)
|
||
for t in transactions:
|
||
daily_sales[t['tanggal']] += t['total']
|
||
|
||
dates = sorted(daily_sales.keys())
|
||
sales = [daily_sales[d] for d in dates]
|
||
ax1.bar(dates, sales, color='#4CAF50', alpha=0.8)
|
||
ax1.set_title('📊 Penjualan Harian', fontweight='bold', fontsize=10)
|
||
ax1.set_ylabel('Pendapatan (Rp)', fontsize=9)
|
||
ax1.tick_params(axis='x', rotation=45)
|
||
ax1.grid(True, alpha=0.3, axis='y')
|
||
|
||
# Chart 2: Payment Methods (Pie Chart)
|
||
ax2 = fig.add_subplot(3, 3, 2)
|
||
payment_methods = defaultdict(int)
|
||
for t in transactions:
|
||
method = t.get('payment_method', 'Unknown')
|
||
payment_methods[method] += t['total']
|
||
|
||
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA726']
|
||
ax2.pie(payment_methods.values(), labels=payment_methods.keys(), autopct='%1.1f%%',
|
||
colors=colors[:len(payment_methods)], startangle=90)
|
||
ax2.set_title('💳 Metode Pembayaran (Total)', fontweight='bold', fontsize=10)
|
||
|
||
# Chart 3: Top 5 Menu (Bar Chart Horizontal)
|
||
ax3 = fig.add_subplot(3, 3, 3)
|
||
menu_sales = defaultdict(int)
|
||
for t in transactions:
|
||
for item in t['items']:
|
||
menu_sales[item['nama']] += item['qty']
|
||
|
||
top_menus = dict(sorted(menu_sales.items(), key=lambda x: x[1], reverse=True)[:5])
|
||
if top_menus:
|
||
ax3.barh(list(top_menus.keys()), list(top_menus.values()), color='#FFA726', alpha=0.8)
|
||
ax3.set_title('🍽️ Menu Top 5', fontweight='bold', fontsize=10)
|
||
ax3.set_xlabel('Jumlah Terjual', fontsize=9)
|
||
|
||
# Chart 4: Cumulative Revenue
|
||
ax4 = fig.add_subplot(3, 3, 4)
|
||
cumulative = 0
|
||
cum_values = []
|
||
for d in dates:
|
||
cumulative += daily_sales[d]
|
||
cum_values.append(cumulative)
|
||
|
||
ax4.plot(dates, cum_values, marker='o', linewidth=2, markersize=6, color='#2196F3')
|
||
ax4.fill_between(range(len(dates)), cum_values, alpha=0.3, color='#2196F3')
|
||
ax4.set_title('📈 Kumulatif Pendapatan', fontweight='bold', fontsize=10)
|
||
ax4.set_ylabel('Total (Rp)', fontsize=9)
|
||
ax4.tick_params(axis='x', rotation=45)
|
||
ax4.grid(True, alpha=0.3)
|
||
|
||
# Chart 5: Transaction Count per Day
|
||
ax5 = fig.add_subplot(3, 3, 5)
|
||
daily_count = defaultdict(int)
|
||
for t in transactions:
|
||
daily_count[t['tanggal']] += 1
|
||
|
||
counts = [daily_count[d] for d in dates]
|
||
ax5.bar(dates, counts, color='#9C27B0', alpha=0.8)
|
||
ax5.set_title('📊 Jumlah Transaksi per Hari', fontweight='bold', fontsize=10)
|
||
ax5.set_ylabel('Jumlah Transaksi', fontsize=9)
|
||
ax5.tick_params(axis='x', rotation=45)
|
||
ax5.grid(True, alpha=0.3, axis='y')
|
||
|
||
# Chart 6: Average Transaction Value
|
||
ax6 = fig.add_subplot(3, 3, 6)
|
||
daily_avg = []
|
||
for d in dates:
|
||
trans_on_day = [t['total'] for t in transactions if t['tanggal'] == d]
|
||
avg = sum(trans_on_day) / len(trans_on_day) if trans_on_day else 0
|
||
daily_avg.append(avg)
|
||
|
||
ax6.plot(dates, daily_avg, marker='s', linewidth=2, markersize=5, color='#4CAF50')
|
||
ax6.set_title('💵 Rata-rata Transaksi', fontweight='bold', fontsize=10)
|
||
ax6.set_ylabel('Nilai Rata-rata (Rp)', fontsize=9)
|
||
ax6.tick_params(axis='x', rotation=45)
|
||
ax6.grid(True, alpha=0.3)
|
||
|
||
# Chart 7: Hari Ini - Pendapatan QRIS vs CASH (Pie Chart)
|
||
ax7 = fig.add_subplot(3, 3, 7)
|
||
today_trans = [t for t in transactions if t['tanggal'] == today]
|
||
qris_cash = defaultdict(int)
|
||
for t in today_trans:
|
||
method = t.get('payment_method', 'Unknown')
|
||
if method:
|
||
method_lower = method.lower()
|
||
if method_lower == 'qris':
|
||
qris_cash['QRIS'] += t['total']
|
||
elif method_lower == 'cash':
|
||
qris_cash['CASH'] += t['total']
|
||
else:
|
||
qris_cash[method] += t['total']
|
||
|
||
if qris_cash and sum(qris_cash.values()) > 0:
|
||
colors_qris = ['#FF6B6B', '#4CAF50', '#2196F3', '#FFA726']
|
||
labels_pie = [f"{k}: Rp {int(v):,}" for k, v in qris_cash.items()]
|
||
ax7.pie(qris_cash.values(), labels=labels_pie,
|
||
autopct='%1.1f%%', colors=colors_qris[:len(qris_cash)], startangle=90)
|
||
ax7.set_title(f'💳 Pendapatan Hari Ini ({today})', fontweight='bold', fontsize=10)
|
||
else:
|
||
ax7.text(0.5, 0.5, 'Tidak ada transaksi hari ini', ha='center', va='center', fontsize=10)
|
||
ax7.set_title(f'💳 Pendapatan Hari Ini ({today})', fontweight='bold', fontsize=10)
|
||
|
||
# Chart 8: Hari Ini - Jumlah Transaksi QRIS vs CASH (Bar Chart)
|
||
ax8 = fig.add_subplot(3, 3, 8)
|
||
qris_cash_count = defaultdict(int)
|
||
for t in today_trans:
|
||
method = t.get('payment_method', 'Unknown')
|
||
if method:
|
||
method_lower = method.lower()
|
||
if method_lower == 'qris':
|
||
qris_cash_count['QRIS'] += 1
|
||
elif method_lower == 'cash':
|
||
qris_cash_count['CASH'] += 1
|
||
else:
|
||
qris_cash_count[method] += 1
|
||
|
||
if qris_cash_count and len(today_trans) > 0:
|
||
methods = list(qris_cash_count.keys())
|
||
counts_qris = list(qris_cash_count.values())
|
||
colors_bar = ['#FF6B6B', '#4CAF50', '#2196F3', '#FFA726']
|
||
ax8.bar(methods, counts_qris, color=colors_bar[:len(methods)], alpha=0.8)
|
||
ax8.set_title('📊 Jumlah Transaksi Hari Ini', fontweight='bold', fontsize=10)
|
||
ax8.set_ylabel('Jumlah Transaksi', fontsize=9)
|
||
ax8.grid(True, alpha=0.3, axis='y')
|
||
else:
|
||
ax8.text(0.5, 0.5, 'Tidak ada transaksi hari ini', ha='center', va='center', fontsize=10)
|
||
ax8.set_title('📊 Jumlah Transaksi Hari Ini', fontweight='bold', fontsize=10)
|
||
|
||
# Chart 9: Hari Ini - Rata-rata per Metode (Bar Chart)
|
||
ax9 = fig.add_subplot(3, 3, 9)
|
||
qris_cash_avg = defaultdict(lambda: {'total': 0, 'count': 0})
|
||
for t in today_trans:
|
||
method = t.get('payment_method', 'Unknown')
|
||
if method:
|
||
method_lower = method.lower()
|
||
if method_lower == 'qris':
|
||
qris_cash_avg['QRIS']['total'] += t['total']
|
||
qris_cash_avg['QRIS']['count'] += 1
|
||
elif method_lower == 'cash':
|
||
qris_cash_avg['CASH']['total'] += t['total']
|
||
qris_cash_avg['CASH']['count'] += 1
|
||
else:
|
||
qris_cash_avg[method]['total'] += t['total']
|
||
qris_cash_avg[method]['count'] += 1
|
||
|
||
if qris_cash_avg and len(today_trans) > 0:
|
||
methods_avg = list(qris_cash_avg.keys())
|
||
avg_values = [qris_cash_avg[m]['total'] / qris_cash_avg[m]['count'] if qris_cash_avg[m]['count'] > 0 else 0 for m in methods_avg]
|
||
colors_bar = ['#FF6B6B', '#4CAF50', '#2196F3', '#FFA726']
|
||
ax9.bar(methods_avg, avg_values, color=colors_bar[:len(methods_avg)], alpha=0.8)
|
||
ax9.set_title('💵 Rata-rata per Metode Hari Ini', fontweight='bold', fontsize=10)
|
||
ax9.set_ylabel('Nilai Rata-rata (Rp)', fontsize=9)
|
||
ax9.grid(True, alpha=0.3, axis='y')
|
||
else:
|
||
ax9.text(0.5, 0.5, 'Tidak ada transaksi hari ini', ha='center', va='center', fontsize=10)
|
||
ax9.set_title('💵 Rata-rata per Metode Hari Ini', fontweight='bold', fontsize=10)
|
||
|
||
fig.tight_layout()
|
||
|
||
canvas = FigureCanvasTkAgg(fig, master=parent)
|
||
canvas.draw()
|
||
canvas.get_tk_widget().pack(fill='both', expand=True)
|
||
|
||
def build_detail_transaksi_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
trans = self.get_filtered_transactions()
|
||
|
||
info_frame = ttk.Frame(parent)
|
||
info_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
ttk.Label(info_frame, text=f"📋 Menampilkan {len(trans)} transaksi",
|
||
font=("Arial", 10, "bold")).pack(side='left')
|
||
|
||
tree_frame = ttk.Frame(parent)
|
||
tree_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
tree_scroll_y = ttk.Scrollbar(tree_frame, orient='vertical')
|
||
tree_scroll_y.pack(side='right', fill='y')
|
||
|
||
tree_scroll_x = ttk.Scrollbar(tree_frame, orient='horizontal')
|
||
tree_scroll_x.pack(side='bottom', fill='x')
|
||
|
||
cols = ("ID", "Tanggal", "Waktu", "User", "Meja", "Items", "Subtotal", "Diskon", "Total", "Metode", "Promo")
|
||
detail_tree = ttk.Treeview(
|
||
tree_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=15,
|
||
yscrollcommand=tree_scroll_y.set,
|
||
xscrollcommand=tree_scroll_x.set
|
||
)
|
||
|
||
tree_scroll_y.config(command=detail_tree.yview)
|
||
tree_scroll_x.config(command=detail_tree.xview)
|
||
|
||
for c in cols:
|
||
detail_tree.heading(c, text=c)
|
||
if c in ["ID", "Meja"]:
|
||
detail_tree.column(c, width=50)
|
||
elif c in ["Subtotal", "Diskon", "Total"]:
|
||
detail_tree.column(c, width=100)
|
||
elif c == "Items":
|
||
detail_tree.column(c, width=200)
|
||
else:
|
||
detail_tree.column(c, width=100)
|
||
|
||
detail_tree.pack(side='left', fill='both', expand=True)
|
||
|
||
for t in reversed(trans):
|
||
items_str = ", ".join([f"{it['nama']}({it['qty']})" for it in t['items']])
|
||
|
||
detail_tree.insert("", tk.END, values=(
|
||
t['id'],
|
||
t['tanggal'],
|
||
t['waktu'].strftime('%H:%M'),
|
||
t['user'],
|
||
t.get('meja', '-'),
|
||
items_str,
|
||
format_currency(t['subtotal']),
|
||
format_currency(t['diskon']),
|
||
format_currency(t['total']),
|
||
t.get('payment_method', '-'),
|
||
t.get('promo_code', '-')
|
||
))
|
||
|
||
def build_menu_analytics_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
trans = self.get_filtered_transactions()
|
||
|
||
# Calculate menu statistics
|
||
menu_stats = defaultdict(lambda: {'qty': 0, 'revenue': 0})
|
||
|
||
for t in trans:
|
||
for item in t['items']:
|
||
menu_stats[item['nama']]['qty'] += item['qty']
|
||
menu_stats[item['nama']]['revenue'] += item['subtotal']
|
||
|
||
# Sort by quantity
|
||
sorted_menu = sorted(menu_stats.items(), key=lambda x: x[1]['qty'], reverse=True)
|
||
|
||
# Summary
|
||
summary_frame = ttk.LabelFrame(parent, text="📊 Ringkasan Menu", padding=10)
|
||
summary_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
summary_inner = ttk.Frame(summary_frame)
|
||
summary_inner.pack()
|
||
|
||
total_menu_sold = sum(s['qty'] for s in menu_stats.values())
|
||
total_menu_revenue = sum(s['revenue'] for s in menu_stats.values())
|
||
|
||
ttk.Label(summary_inner, text="Total Menu Berbeda Terjual:").grid(row=0, column=0, sticky='w', padx=10, pady=3)
|
||
ttk.Label(summary_inner, text=str(len(menu_stats)), font=("Arial", 10, "bold")).grid(row=0, column=1, sticky='w', padx=10, pady=3)
|
||
|
||
ttk.Label(summary_inner, text="Total Item Terjual:").grid(row=1, column=0, sticky='w', padx=10, pady=3)
|
||
ttk.Label(summary_inner, text=str(total_menu_sold), font=("Arial", 10, "bold")).grid(row=1, column=1, sticky='w', padx=10, pady=3)
|
||
|
||
ttk.Label(summary_inner, text="Total Pendapatan dari Menu:").grid(row=2, column=0, sticky='w', padx=10, pady=3)
|
||
ttk.Label(summary_inner, text=format_currency(total_menu_revenue),
|
||
font=("Arial", 10, "bold"), foreground='green').grid(row=2, column=1, sticky='w', padx=10, pady=3)
|
||
|
||
# Chart
|
||
if MATPLOTLIB_AVAILABLE and sorted_menu:
|
||
chart_frame = ttk.LabelFrame(parent, text="📊 Top 10 Menu", padding=10)
|
||
chart_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
top_10 = sorted_menu[:10]
|
||
names = [m[0] for m in top_10]
|
||
qtys = [m[1]['qty'] for m in top_10]
|
||
|
||
fig = Figure(figsize=(8, 5), dpi=80)
|
||
ax = fig.add_subplot(111)
|
||
|
||
bars = ax.bar(names, qtys, color='#4CAF50')
|
||
ax.set_ylabel('Jumlah Terjual', fontsize=10)
|
||
ax.set_title('Top 10 Menu Terlaris', fontsize=12, fontweight='bold')
|
||
ax.grid(axis='y', alpha=0.3)
|
||
ax.tick_params(axis='x', rotation=45)
|
||
|
||
# Add value labels
|
||
for bar in bars:
|
||
height = bar.get_height()
|
||
ax.text(bar.get_x() + bar.get_width()/2., height,
|
||
f'{int(height)}', ha='center', va='bottom', fontsize=9)
|
||
|
||
fig.tight_layout()
|
||
|
||
canvas = FigureCanvasTkAgg(fig, chart_frame)
|
||
canvas.draw()
|
||
canvas.get_tk_widget().pack(fill='both', expand=True)
|
||
|
||
# Detail table
|
||
table_frame = ttk.LabelFrame(parent, text="📋 Detail Semua Menu", padding=10)
|
||
table_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
tree_scroll = ttk.Scrollbar(table_frame, orient='vertical')
|
||
tree_scroll.pack(side='right', fill='y')
|
||
|
||
cols = ("Rank", "Nama Menu", "Qty Terjual", "Pendapatan", "% dari Total")
|
||
menu_tree = ttk.Treeview(
|
||
table_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=10,
|
||
yscrollcommand=tree_scroll.set
|
||
)
|
||
|
||
tree_scroll.config(command=menu_tree.yview)
|
||
|
||
for c in cols:
|
||
menu_tree.heading(c, text=c)
|
||
if c == "Rank":
|
||
menu_tree.column(c, width=50)
|
||
elif c == "Nama Menu":
|
||
menu_tree.column(c, width=200)
|
||
else:
|
||
menu_tree.column(c, width=120)
|
||
|
||
menu_tree.pack(side='left', fill='both', expand=True)
|
||
|
||
for rank, (nama, stats) in enumerate(sorted_menu, 1):
|
||
pct = (stats['revenue'] / total_menu_revenue * 100) if total_menu_revenue > 0 else 0
|
||
|
||
menu_tree.insert("", tk.END, values=(
|
||
rank,
|
||
nama,
|
||
stats['qty'],
|
||
format_currency(stats['revenue']),
|
||
f"{pct:.1f}%"
|
||
))
|
||
|
||
def build_payment_analytics_tab(self, parent):
|
||
for w in parent.winfo_children():
|
||
w.destroy()
|
||
|
||
trans = self.get_filtered_transactions()
|
||
|
||
# Calculate payment statistics
|
||
payment_stats = defaultdict(lambda: {'count': 0, 'total': 0})
|
||
|
||
for t in trans:
|
||
method = t.get('payment_method', 'Unknown')
|
||
payment_stats[method]['count'] += 1
|
||
payment_stats[method]['total'] += t['total']
|
||
|
||
# Summary cards
|
||
summary_frame = ttk.Frame(parent)
|
||
summary_frame.pack(fill='x', padx=10, pady=10)
|
||
|
||
for method, stats in sorted(payment_stats.items()):
|
||
card = tk.Frame(summary_frame, bg='#2196F3', relief='solid', bd=1)
|
||
card.pack(side='left', fill='both', expand=True, padx=5)
|
||
|
||
icon_map = {'Cash': '💵', 'QRIS': '📱', 'GoPay': '💳', 'OVO': '💳', 'DANA': '💳', 'ShopeePay': '💳'}
|
||
icon = icon_map.get(method, '💳')
|
||
|
||
tk.Label(card, text=icon, font=("Arial", 28), bg='#2196F3', fg='white').pack(pady=(10, 5))
|
||
tk.Label(card, text=method, font=("Arial", 11, "bold"), bg='#2196F3', fg='white').pack()
|
||
tk.Label(card, text=f"{stats['count']} transaksi",
|
||
font=("Arial", 9), bg='#2196F3', fg='white').pack(pady=(2, 0))
|
||
tk.Label(card, text=format_currency(stats['total']),
|
||
font=("Arial", 14, "bold"), bg='#2196F3', fg='white').pack(pady=(5, 10))
|
||
|
||
# Pie chart
|
||
if MATPLOTLIB_AVAILABLE and payment_stats:
|
||
chart_frame = ttk.LabelFrame(parent, text="📊 Distribusi Metode Pembayaran", padding=10)
|
||
chart_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
methods = list(payment_stats.keys())
|
||
counts = [payment_stats[m]['count'] for m in methods]
|
||
|
||
fig = Figure(figsize=(10, 5), dpi=80)
|
||
|
||
# Pie chart for count
|
||
ax1 = fig.add_subplot(121)
|
||
colors = ['#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#F44336']
|
||
wedges, texts, autotexts = ax1.pie(counts, labels=methods, autopct='%1.1f%%',
|
||
colors=colors[:len(methods)], startangle=90)
|
||
for text in texts:
|
||
text.set_fontsize(10)
|
||
for autotext in autotexts:
|
||
autotext.set_color('white')
|
||
autotext.set_fontsize(9)
|
||
autotext.set_fontweight('bold')
|
||
ax1.set_title('Berdasarkan Jumlah Transaksi', fontsize=11, fontweight='bold')
|
||
|
||
# Bar chart for revenue
|
||
ax2 = fig.add_subplot(122)
|
||
revenues = [payment_stats[m]['total'] for m in methods]
|
||
bars = ax2.bar(methods, revenues, color=colors[:len(methods)])
|
||
ax2.set_ylabel('Pendapatan (Rp)', fontsize=10)
|
||
ax2.set_title('Berdasarkan Pendapatan', fontsize=11, fontweight='bold')
|
||
ax2.tick_params(axis='x', rotation=45)
|
||
ax2.yaxis.set_major_formatter(matplotlib.ticker.FuncFormatter(lambda x, p: f'{int(x/1000)}k'))
|
||
ax2.grid(axis='y', alpha=0.3)
|
||
|
||
# Add value labels on bars
|
||
for bar in bars:
|
||
height = bar.get_height()
|
||
ax2.text(bar.get_x() + bar.get_width()/2., height,
|
||
f'{int(height/1000)}k',
|
||
ha='center', va='bottom', fontsize=9)
|
||
|
||
fig.tight_layout()
|
||
|
||
canvas = FigureCanvasTkAgg(fig, chart_frame)
|
||
canvas.draw()
|
||
canvas.get_tk_widget().pack(fill='both', expand=True)
|
||
|
||
# Detail table
|
||
table_frame = ttk.LabelFrame(parent, text="📋 Detail Pembayaran", padding=10)
|
||
table_frame.pack(fill='x', padx=10, pady=5)
|
||
|
||
cols = ("Metode", "Jumlah Trans", "Total Pendapatan", "Rata-rata", "% dari Total")
|
||
payment_tree = ttk.Treeview(
|
||
table_frame,
|
||
columns=cols,
|
||
show='headings',
|
||
height=6
|
||
)
|
||
|
||
for c in cols:
|
||
payment_tree.heading(c, text=c)
|
||
payment_tree.column(c, width=150)
|
||
|
||
payment_tree.pack(fill='x')
|
||
|
||
total_revenue = sum(s['total'] for s in payment_stats.values())
|
||
|
||
for method, stats in sorted(payment_stats.items(), key=lambda x: x[1]['total'], reverse=True):
|
||
avg = stats['total'] // stats['count'] if stats['count'] > 0 else 0
|
||
pct = (stats['total'] / total_revenue * 100) if total_revenue > 0 else 0
|
||
|
||
payment_tree.insert("", tk.END, values=(
|
||
method,
|
||
stats['count'],
|
||
format_currency(stats['total']),
|
||
format_currency(avg),
|
||
f"{pct:.1f}%"
|
||
))
|
||
|
||
def export_laporan_excel(self):
|
||
try:
|
||
trans = self.get_filtered_transactions()
|
||
|
||
if not trans:
|
||
messagebox.showwarning("Tidak Ada Data", "Tidak ada transaksi untuk periode ini")
|
||
return
|
||
|
||
filename = filedialog.asksaveasfilename(
|
||
defaultextension=".csv",
|
||
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||
initialfile=f"laporan_{self.laporan_start_date.get()}_to_{self.laporan_end_date.get()}.csv"
|
||
)
|
||
|
||
if filename:
|
||
with open(filename, 'w', newline='', encoding='utf-8') as f:
|
||
fieldnames = ["ID", "Tanggal", "Waktu", "User", "Meja", "Items",
|
||
"Subtotal", "Diskon", "Total", "Metode", "Promo"]
|
||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||
writer.writeheader()
|
||
|
||
for t in trans:
|
||
items_str = "; ".join([f"{it['nama']} x{it['qty']}" for it in t['items']])
|
||
|
||
writer.writerow({
|
||
"ID": t['id'],
|
||
"Tanggal": t['tanggal'],
|
||
"Waktu": t['waktu'].strftime('%H:%M:%S'),
|
||
"User": t['user'],
|
||
"Meja": t.get('meja', '-'),
|
||
"Items": items_str,
|
||
"Subtotal": t['subtotal'],
|
||
"Diskon": t['diskon'],
|
||
"Total": t['total'],
|
||
"Metode": t.get('payment_method', '-'),
|
||
"Promo": t.get('promo_code', '-')
|
||
})
|
||
|
||
messagebox.showinfo("Success", f"✅ Laporan berhasil di-export ke:\n{filename}")
|
||
|
||
except Exception as e:
|
||
messagebox.showerror("Export Error", f"Gagal export laporan:\n{str(e)}")
|
||
|
||
# ================================
|
||
# NOTIFIKASI
|
||
# ================================
|
||
|
||
def open_notifikasi_window(self):
|
||
notif_win = tk.Toplevel(self.root)
|
||
notif_win.title("🔔 Notifikasi")
|
||
notif_win.geometry("600x500")
|
||
|
||
header = ttk.Frame(notif_win)
|
||
header.pack(fill='x', padx=10, pady=10)
|
||
|
||
ttk.Label(header, text="🔔 Notifikasi",
|
||
font=("Arial", 14, "bold")).pack(side='left')
|
||
|
||
ttk.Button(header, text="✅ Tandai Semua Dibaca",
|
||
command=lambda: self.mark_all_read(notif_win)).pack(side='right', padx=5)
|
||
ttk.Button(header, text="🗑️ Hapus Semua",
|
||
command=lambda: self.clear_all_notifications(notif_win)).pack(side='right', padx=5)
|
||
|
||
list_frame = ttk.Frame(notif_win)
|
||
list_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
||
|
||
canvas = tk.Canvas(list_frame, bg='white')
|
||
scrollbar = ttk.Scrollbar(list_frame, orient='vertical', command=canvas.yview)
|
||
notif_container = ttk.Frame(canvas)
|
||
|
||
canvas.configure(yscrollcommand=scrollbar.set)
|
||
scrollbar.pack(side='right', fill='y')
|
||
canvas.pack(side='left', fill='both', expand=True)
|
||
|
||
canvas.create_window((0, 0), window=notif_container, anchor='nw')
|
||
notif_container.bind('<Configure>', lambda e: canvas.configure(scrollregion=canvas.bbox('all')))
|
||
|
||
if not notifikasi:
|
||
ttk.Label(notif_container, text="📭 Tidak ada notifikasi",
|
||
font=("Arial", 12), foreground='gray').pack(pady=50)
|
||
else:
|
||
for n in reversed(notifikasi):
|
||
self.create_notif_card(notif_container, n, notif_win)
|
||
|
||
def create_notif_card(self, parent, notif, window):
|
||
bg_color = "#F8F9FA" if notif['read'] else "#E3F2FD"
|
||
|
||
card = tk.Frame(parent, bg=bg_color, relief='solid', bd=1)
|
||
card.pack(fill='x', padx=5, pady=5)
|
||
|
||
inner = tk.Frame(card, bg=bg_color)
|
||
inner.pack(fill='x', padx=10, pady=8)
|
||
|
||
icon_map = {
|
||
'success': '✅',
|
||
'info': 'ℹ️',
|
||
'warning': '⚠️',
|
||
'error': '❌'
|
||
}
|
||
icon = icon_map.get(notif['type'], '📌')
|
||
|
||
tk.Label(inner, text=icon, font=("Arial", 16), bg=bg_color).pack(side='left', padx=(0, 10))
|
||
|
||
text_frame = tk.Frame(inner, bg=bg_color)
|
||
text_frame.pack(side='left', fill='x', expand=True)
|
||
|
||
tk.Label(text_frame, text=notif['message'], font=("Arial", 10),
|
||
bg=bg_color, anchor='w', wraplength=450).pack(fill='x')
|
||
tk.Label(text_frame, text=notif['timestamp'], font=("Arial", 8),
|
||
bg=bg_color, fg='gray', anchor='w').pack(fill='x')
|
||
|
||
if not notif['read']:
|
||
tk.Button(inner, text="✓", font=("Arial", 10), bg='#4CAF50', fg='white',
|
||
relief='flat', padx=10, pady=2,
|
||
command=lambda: self.mark_as_read(notif['id'], window)).pack(side='right')
|
||
|
||
def mark_as_read(self, notif_id, window):
|
||
for n in notifikasi:
|
||
if n['id'] == notif_id:
|
||
n['read'] = True
|
||
break
|
||
save_notifikasi()
|
||
self.open_notifikasi_window()
|
||
window.destroy()
|
||
self.build_main_ui()
|
||
|
||
def mark_all_read(self, window):
|
||
for n in notifikasi:
|
||
n['read'] = True
|
||
save_notifikasi()
|
||
messagebox.showinfo("Success", "Semua notifikasi ditandai sudah dibaca")
|
||
self.open_notifikasi_window()
|
||
window.destroy()
|
||
self.build_main_ui()
|
||
|
||
def clear_all_notifications(self, window):
|
||
confirm = messagebox.askyesno("Konfirmasi", "Hapus semua notifikasi?")
|
||
if confirm:
|
||
notifikasi.clear()
|
||
save_notifikasi()
|
||
messagebox.showinfo("Success", "Semua notifikasi dihapus")
|
||
window.destroy()
|
||
self.build_main_ui()
|
||
|
||
# ================================
|
||
# MAIN
|
||
# ================================
|
||
|
||
if __name__ == "__main__":
|
||
root = tk.Tk()
|
||
app = CafeApp(root)
|
||
root.mainloop() |