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