/* Admin Panel Views: Clients, Machines, Billing, Invoices */ const { useState: useStateAdmin, useMemo: useMemoAdmin } = React; /* ═══════════════════════════════════════════════ ADMIN: DAFTAR CLIENT ═══════════════════════════════════════════════ */ function AdminClientsView() { const { users, setUsers, allMachines, invoices, navigate, toast } = useApp(); const clients = users.filter(u => u.role === "client"); const [search, setSearch] = useStateAdmin(""); const [showAdd, setShowAdd] = useStateAdmin(false); const [showEdit, setShowEdit] = useStateAdmin(null); // client object const [newClient, setNewClient] = useStateAdmin({ name:"", email:"", password:"", phone:"", address:"" }); const filtered = clients.filter(c => c.name.toLowerCase().includes(search.toLowerCase()) || c.email.toLowerCase().includes(search.toLowerCase()) ); function addClient(e) { e.preventDefault(); if (!newClient.name || !newClient.email || !newClient.password) { toast("Lengkapi semua field wajib.", "danger"); return; } if (users.find(u => u.email === newClient.email)) { toast("Email sudah terdaftar.", "danger"); return; } const id = "client-" + Date.now(); const created = { ...newClient, id, role: "client", joined: new Date().toLocaleDateString("id-ID", { day:"numeric", month:"long", year:"numeric" }), }; setUsers(prev => [...prev, created]); setNewClient({ name:"", email:"", password:"", phone:"", address:"" }); setShowAdd(false); toast(`Client ${newClient.name} berhasil ditambahkan.`, "success"); db.upsertUser(created).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } function updateClient(e) { e.preventDefault(); const updated = showEdit; setUsers(prev => prev.map(u => u.id === updated.id ? updated : u)); setShowEdit(null); toast("Data client berhasil diupdate.", "success"); db.upsertUser(updated).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } function deleteClient(id) { if (!window.confirm("Hapus client ini? Data mesin terkait tidak akan terhapus.")) return; setUsers(prev => prev.filter(u => u.id !== id)); toast("Client dihapus.", "info"); db.deleteUser(id).catch(err => toast("Gagal hapus di Supabase: " + err.message, "danger")); } function clientMachineCount(clientId) { return allMachines.filter(m => m.clientId === clientId).length; } return (
setShowAdd(true)} variant="primary"> Tambah Client } /> {/* Search */}
setSearch(e.target.value)} placeholder="Cari nama atau email..." className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-stone-300 bg-white text-sm focus:border-[#FF2D55] focus:ring-2 focus:ring-pink-100 outline-none" />
{/* Table */}
{filtered.map(c => ( ))} {filtered.length === 0 && ( )}
Client Kontak Mesin Bergabung Aksi
{c.name.charAt(0)}
{c.name}
{c.email}
{c.phone || "—"} {clientMachineCount(c.id)} {c.joined}
Tidak ada client ditemukan.
{/* Add Client Modal */} setShowAdd(false)} title="Tambah Client Baru">
setNewClient({...newClient, name:v})} required/> setNewClient({...newClient, email:v})} required/> setNewClient({...newClient, password:v})} required/> setNewClient({...newClient, phone:v})}/> setNewClient({...newClient, address:v})} full/>
{/* Edit Client Modal */} {showEdit && ( setShowEdit(null)} title="Edit Client">
setShowEdit({...showEdit, name:v})}/> setShowEdit({...showEdit, email:v})}/> setShowEdit({...showEdit, password:v})} placeholder="Kosongkan jika tidak diubah"/> setShowEdit({...showEdit, phone:v})}/> setShowEdit({...showEdit, address:v})} full/>
)}
); } /* ═══════════════════════════════════════════════ ADMIN: STATUS MESIN ═══════════════════════════════════════════════ */ function AdminMachinesView() { const { allMachines, setMachines, users, navigate, toast } = useApp(); const [filter, setFilter] = useStateAdmin("all"); const [search, setSearch] = useStateAdmin(""); const [showEdit, setShowEdit] = useStateAdmin(null); const [showAssign, setShowAssign] = useStateAdmin(null); // machine to assign const [assignTo, setAssignTo] = useStateAdmin(""); const [showAdd, setShowAdd] = useStateAdmin(false); const [newMachine, setNewMachine] = useStateAdmin({ id: "", model: "", type: "", category: "combo", location: "", status: "Aktif", scheme: "Sewa", contractStart: new Date().toISOString().split("T")[0], contractEnd: "", minContract: 6, rates: { sewa: 2500000, cloud: 350000, refill: 0, cloudApi: 0 }, addons: { cloud: true, refill: false, cloudApi: false }, color: "#FFD9E0", clientId: "", }); const clients = users.filter(u => u.role === "client"); const COLOR_OPTIONS = [ { hex: "#FFD9E0", name: "Pink" }, { hex: "#FFE9A8", name: "Kuning" }, { hex: "#D0F0FF", name: "Biru" }, { hex: "#D4F4DD", name: "Hijau" }, { hex: "#F0E0FF", name: "Ungu" }, { hex: "#FFD9C2", name: "Oranye" }, ]; const filtered = useMemoAdmin(() => { let list = allMachines; if (filter !== "all") { list = list.filter(m => { const tone = statusForMachine(m).tone; return (filter === "active" && tone === "success") || (filter === "expiring" && tone === "warning") || (filter === "expired" && tone === "danger"); }); } if (search) { list = list.filter(m => m.id.toLowerCase().includes(search.toLowerCase()) || m.model.toLowerCase().includes(search.toLowerCase()) || m.location.toLowerCase().includes(search.toLowerCase()) ); } return list; }, [allMachines, filter, search]); function saveEdit(e) { e.preventDefault(); const updated = showEdit; setMachines(prev => prev.map(m => m.id === updated.id ? updated : m)); setShowEdit(null); toast("Data mesin berhasil diupdate.", "success"); db.upsertMachine(updated).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } function openAssign(machine) { setAssignTo(machine.clientId || ""); setShowAssign(machine); } function saveAssign(e) { e.preventDefault(); if (!assignTo) { toast("Pilih client terlebih dahulu.", "danger"); return; } const target = clients.find(c => c.id === assignTo); const updated = { ...showAssign, clientId: assignTo }; setMachines(prev => prev.map(m => m.id === updated.id ? updated : m)); toast(`${updated.id} di-assign ke ${target?.name || assignTo}.`, "success"); setShowAssign(null); setAssignTo(""); db.upsertMachine(updated).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } function addMachine(e) { e.preventDefault(); if (!newMachine.id || !newMachine.model || !newMachine.location || !newMachine.clientId || !newMachine.contractEnd) { toast("Lengkapi: ID, model, lokasi, client, dan tanggal berakhir.", "danger"); return; } if (allMachines.find(m => m.id === newMachine.id)) { toast("ID mesin sudah dipakai.", "danger"); return; } const monthly = Object.entries(newMachine.rates).reduce((sum, [k, v]) => sum + ((k === "sewa" || newMachine.addons[k]) ? Number(v) : 0), 0); const created = { ...newMachine, monthlyRevenue: monthly, salesThisMonth: 0, transactionsThisMonth: 0, payments: [], }; setMachines(prev => [...prev, created]); toast(`Mesin ${newMachine.id} berhasil ditambahkan.`, "success"); setShowAdd(false); db.upsertMachine(created).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); setNewMachine({ id: "", model: "", type: "", category: "combo", location: "", status: "Aktif", scheme: "Sewa", contractStart: new Date().toISOString().split("T")[0], contractEnd: "", minContract: 6, rates: { sewa: 2500000, cloud: 350000, refill: 0, cloudApi: 0 }, addons: { cloud: true, refill: false, cloudApi: false }, color: "#FFD9E0", clientId: "", }); } const getClient = (cid) => users.find(u => u.id === cid); return (
statusForMachine(m).tone === "warning").length} mendekati expired`} actions={ } /> {/* Filters */}
setSearch(e.target.value)} placeholder="Cari mesin..." className="pl-8 pr-4 py-2 rounded-xl border border-stone-300 bg-white text-sm focus:border-[#FF2D55] outline-none"/>
{["all","active","expiring","expired"].map(f => ( ))}
{/* Table */}
{filtered.map(m => { const s = statusForMachine(m); const client = getClient(m.clientId); return ( ); })} {filtered.length === 0 && ( )}
Mesin Client Lokasi Status Kontrak Billing Aksi
{m.id}
{m.model}
{client?.name || "—"}
{client?.email}
{m.location} {s.tone === "success" ? "Aktif" : s.tone === "warning" ? "Akan Berakhir" : "Expired"}
{s.label}
Berakhir
{formatDateID(m.contractEnd)}
{formatRp(m.monthlyRevenue)}
/bulan
Tidak ada mesin ditemukan.
{/* Edit Machine Modal */} {showEdit && ( setShowEdit(null)} title={`Edit Mesin ${showEdit.id}`} width="max-w-2xl">
setShowEdit({...showEdit, location:v})} full/>
setShowEdit({...showEdit, contractEnd:v})}/> setShowEdit({...showEdit, scheme:v})}/>
Harga Bulanan (Rp)
{["sewa","cloud","refill","cloudApi"].map(key => (
setShowEdit({...showEdit, rates:{...showEdit.rates, [key]: Number(e.target.value)}})} className="mt-1 w-full px-3 py-2 rounded-xl border border-stone-300 bg-white text-sm focus:border-[#FF2D55] outline-none"/>
))}
)} {/* Assign Client Modal */} {showAssign && ( { setShowAssign(null); setAssignTo(""); }} title={`Assign Client — ${showAssign.id}`}>
{showAssign.model}
{showAssign.location}
Saat ini: {getClient(showAssign.clientId)?.name || Belum di-assign}
{clients.length === 0 && (
Belum ada client. Buat client dulu di menu Daftar Client.
)}
)} {/* Add New Machine Modal */} setShowAdd(false)} title="Tambah Mesin Baru" width="max-w-3xl">
Identitas Mesin
setNewMachine({...newMachine, id: v.toUpperCase()})} required/> setNewMachine({...newMachine, model: v})} required/> setNewMachine({...newMachine, type: v})}/>
setNewMachine({...newMachine, location: v})} required full/>
{COLOR_OPTIONS.map(c => (
Kontrak
setNewMachine({...newMachine, contractStart: v})}/> setNewMachine({...newMachine, contractEnd: v})} required/>
Harga Bulanan (Rp)
{[ { key: "sewa", label: "Sewa Pokok", always: true }, { key: "cloud", label: "Cloud / Dashboard" }, { key: "refill", label: "Refill" }, { key: "cloudApi", label: "Cloud API" }, ].map(({key, label, always}) => (
setNewMachine({...newMachine, rates:{...newMachine.rates, [key]: Number(e.target.value)}})} className="mt-1 w-full px-3 py-2 rounded-xl border border-stone-300 bg-white text-sm focus:border-[#FF2D55] outline-none"/> {!always && ( )}
))}
Total / bulan: {formatRp(Object.entries(newMachine.rates).reduce((s,[k,v]) => s + ((k === "sewa" || newMachine.addons[k]) ? Number(v) : 0), 0))}
); } /* ═══════════════════════════════════════════════ ADMIN: BILLING ═══════════════════════════════════════════════ */ function AdminBillingView() { const { allMachines, users, invoices, setInvoices, toast } = useApp(); const clients = users.filter(u => u.role === "client"); const totalMonthly = allMachines.reduce((s, m) => s + m.monthlyRevenue, 0); const totalCollected = invoices.reduce((s, i) => s + (i.status === "Lunas" ? i.amount : 0), 0); const outstanding = invoices.reduce((s, i) => s + (i.status !== "Lunas" ? i.amount : 0), 0); function markPaid(invId) { const target = invoices.find(i => i.id === invId); if (!target) return; const updated = { ...target, status: "Lunas" }; setInvoices(prev => prev.map(i => i.id === invId ? updated : i)); toast("Invoice ditandai Lunas.", "success"); db.upsertInvoice(updated).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } return (
{/* Summary */}
i.status === "Lunas").length} invoice lunas`} icon="wallet" accent="bg-sky-50 text-sky-700"/> 0 ? "Rp" + (outstanding/1000000).toFixed(1) + "Jt" : "Nihil"} sub={`${invoices.filter(i => i.status !== "Lunas").length} invoice pending`} icon="alertCircle" accent={outstanding > 0 ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}/>
{/* Per-client billing breakdown */}
{clients.map(c => { const cMachines = allMachines.filter(m => m.clientId === c.id); const cInvoices = invoices.filter(i => i.clientId === c.id); const cTotal = cMachines.reduce((s, m) => s + m.monthlyRevenue, 0); if (cMachines.length === 0) return null; return (
{c.name.charAt(0)}
{c.name}
{c.email} · {cMachines.length} mesin
Total / bulan
{formatRp(cTotal)}
{/* Machine breakdown */}
{cMachines.map(m => { const s = statusForMachine(m); return (
{m.id}
{m.location}
{s.tone === "success" ? "Aktif" : s.tone === "warning" ? "Expiring" : "Expired"}
{formatRp(m.monthlyRevenue)}/bln
); })}
{/* Recent invoices for this client */} {cInvoices.length > 0 && (
Invoice Terbaru
{cInvoices.slice(0, 3).map(inv => (
{inv.id} {inv.period}
{formatRp(inv.amount)} {inv.status === "Lunas" ? Lunas : }
))}
)}
); })}
); } /* ═══════════════════════════════════════════════ INVOICE PDF EXPORT (shared by Admin + Client Overview) Usage: window.exportInvoiceDoc(inv, clientName) Throws on jsPDF missing — caller handles toast. ═══════════════════════════════════════════════ */ function exportInvoiceDoc(inv, clientName = "—") { const jspdfNs = window.jspdf; if (!jspdfNs || !jspdfNs.jsPDF) throw new Error("jsPDF belum termuat (cek koneksi CDN)."); const doc = new jspdfNs.jsPDF({ unit: "mm", format: "a4" }); const co = window.COMPANY_SETTINGS || {}; const sep = " · "; const sepIdx = (inv.period || "").indexOf(sep); const itemName = sepIdx > 0 ? inv.period.slice(0, sepIdx) : (inv.period || "—"); const periodSub = sepIdx > 0 ? inv.period.slice(sepIdx + sep.length) : ""; const qty = Number(inv.qty) || 1; const unitPrice = Number(inv.unitPrice) || Number(inv.amount) || 0; doc.setFillColor(255, 45, 85); doc.rect(0, 0, 210, 4, "F"); doc.setTextColor(255, 45, 85); doc.setFont("helvetica", "bold"); doc.setFontSize(26); doc.text("INVOICE", 14, 20); let logoDrawn = false; if (co.logo_url) { const tryFmt = (fmt) => { try { doc.addImage(co.logo_url, fmt, 150, 10, 40, 40); return true; } catch { return false; } }; logoDrawn = tryFmt("PNG") || tryFmt("JPEG") || tryFmt("WEBP"); } let y = 30; doc.setTextColor(20); doc.setFont("helvetica", "bold"); doc.setFontSize(12); if (co.name) { doc.text(co.name, 14, y, { maxWidth: 130 }); y += 5; } doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(110); if (co.tagline) { doc.text(co.tagline, 14, y, { maxWidth: 130 }); y += 4; } if (co.legal_name) { doc.text(co.legal_name, 14, y, { maxWidth: 130 }); y += 4; } y += 1; if (co.address) { doc.splitTextToSize(co.address, 130).forEach(line => { doc.text(line, 14, y); y += 4; }); } const contactParts = [co.phone && `Telp: ${co.phone}`, co.email, co.website].filter(Boolean); if (contactParts.length) { doc.splitTextToSize(contactParts.join(" · "), 130).forEach(line => { doc.text(line, 14, y); y += 4; }); } if (co.npwp) { doc.text(`NPWP: ${co.npwp}`, 14, y, { maxWidth: 130 }); y += 4; } const headerEndY = Math.max(y + 4, logoDrawn ? 56 : y + 4); doc.setDrawColor(220); doc.line(14, headerEndY, 196, headerEndY); const billY = headerEndY + 8; doc.setTextColor(20); doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.text("Ditagihkan kepada:", 14, billY); doc.setFont("helvetica", "normal"); doc.text(clientName, 14, billY + 6, { maxWidth: 110 }); if (inv.machineId) doc.text(`Mesin: ${inv.machineId}`, 14, billY + 12); doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.text(`No: ${inv.id}`, 196, billY, { align: "right" }); doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.text(`Tanggal: ${formatDateID(inv.date)}`, 196, billY + 6, { align: "right" }); doc.setFont("helvetica", "bold"); doc.text(`Status: ${inv.status}`, 196, billY + 12, { align: "right" }); const tableTop = billY + 22; doc.setFillColor(245, 245, 244); doc.rect(14, tableTop, 182, 9, "F"); doc.setFont("helvetica", "bold"); doc.setFontSize(10); doc.setTextColor(20); const X_DESC = 18, X_QTY = 120, X_UNIT = 160, X_TOTAL = 192; doc.text("Deskripsi", X_DESC, tableTop + 6); doc.text("Qty", X_QTY, tableTop + 6, { align: "right" }); doc.text("Harga Satuan", X_UNIT, tableTop + 6, { align: "right" }); doc.text("Jumlah", X_TOTAL, tableTop + 6, { align: "right" }); let rowY = tableTop + 16; doc.setFont("helvetica", "normal"); doc.setFontSize(10); doc.text(itemName, X_DESC, rowY, { maxWidth: 95 }); doc.text(`${qty} bulan`, X_QTY, rowY, { align: "right" }); doc.text(formatRp(unitPrice), X_UNIT, rowY, { align: "right" }); doc.text(formatRp(inv.amount), X_TOTAL, rowY, { align: "right" }); if (periodSub) { doc.setFont("helvetica", "italic"); doc.setFontSize(8); doc.setTextColor(120); doc.text(periodSub, X_DESC, rowY + 5, { maxWidth: 95 }); rowY += 5; } const totalY = rowY + 12; doc.setDrawColor(60); doc.line(14, totalY - 5, 196, totalY - 5); doc.setFont("helvetica", "bold"); doc.setFontSize(13); doc.setTextColor(20); doc.text("TOTAL", X_DESC, totalY); doc.setTextColor(255, 45, 85); doc.text(formatRp(inv.amount), X_TOTAL, totalY, { align: "right" }); let footY = totalY + 14; doc.setDrawColor(220); doc.line(14, footY - 5, 196, footY - 5); if (co.bank_name || co.bank_account_number) { doc.setFont("helvetica", "bold"); doc.setFontSize(9); doc.setTextColor(20); doc.text("Pembayaran via transfer:", 14, footY); footY += 5; doc.setFont("helvetica", "normal"); doc.setTextColor(60); const bankParts = [ co.bank_name && (co.bank_branch ? `${co.bank_name} · ${co.bank_branch}` : co.bank_name), co.bank_account_number && `Rek: ${co.bank_account_number}`, co.bank_account_name && `a.n. ${co.bank_account_name}`, ].filter(Boolean); if (bankParts.length) { doc.splitTextToSize(bankParts.join(" · "), 180).forEach(line => { doc.text(line, 14, footY); footY += 4; }); } footY += 2; } if (co.invoice_notes) { doc.setFont("helvetica", "italic"); doc.setFontSize(8); doc.setTextColor(100); doc.splitTextToSize(co.invoice_notes, 180).forEach(line => { doc.text(line, 14, footY); footY += 4; }); } doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(120); doc.text(`Metode pembayaran: ${inv.method || "—"}`, 14, 275); if (co.invoice_footer) { doc.setFont("helvetica", "italic"); doc.text(co.invoice_footer, 14, 285, { maxWidth: 180 }); } doc.save(`${inv.id}.pdf`); } /* ═══════════════════════════════════════════════ ADMIN: INVOICE ═══════════════════════════════════════════════ */ const INVOICE_STATUSES = ["Lunas", "Outstanding", "Pending"]; const INVOICE_METHODS = ["Transfer","QRIS","BCA VA","Mandiri VA","BNI VA","BRI VA","GoPay","OVO","DANA","Tunai"]; function statusTone(s) { if (s === "Lunas") return "success"; if (s === "Outstanding") return "danger"; return "warning"; } function monthsBetween(start, end) { if (!start || !end) return 0; const s = new Date(start), e = new Date(end); if (isNaN(s) || isNaN(e) || e < s) return 0; return (e.getFullYear() - s.getFullYear()) * 12 + (e.getMonth() - s.getMonth()) + 1; } const EMPTY_INV_FORM = { clientId: "", machineId: "", item: "Sewa Cloud", periodStart: "", periodEnd: "", qty: "1", unitPrice: "", manualAmount: false, amount: "", status: "Outstanding", method: "Transfer", }; function AdminInvoicesView() { const { invoices, setInvoices, allMachines, users, toast } = useApp(); const [filter, setFilter] = useStateAdmin("all"); const [search, setSearch] = useStateAdmin(""); const [clientFilter, setClientFilter] = useStateAdmin("all"); const [showNew, setShowNew] = useStateAdmin(false); const [newInv, setNewInv] = useStateAdmin(EMPTY_INV_FORM); const clients = users.filter(u => u.role === "client"); const getClientName = (cid) => users.find(u => u.id === cid)?.name || "—"; // Auto-derive bulan dari periode (hint untuk auto-fill qty), dan amount = qty × unitPrice const computedMonths = useMemoAdmin( () => monthsBetween(newInv.periodStart, newInv.periodEnd), [newInv.periodStart, newInv.periodEnd] ); const computedAmount = useMemoAdmin( () => (Number(newInv.qty || 0) * Number(newInv.unitPrice || 0)), [newInv.qty, newInv.unitPrice] ); const effectiveAmount = newInv.manualAmount ? Number(newInv.amount || 0) : computedAmount; const filtered = useMemoAdmin(() => { let list = invoices; if (filter !== "all") { const want = filter[0].toUpperCase() + filter.slice(1); list = list.filter(i => i.status === want); } if (clientFilter !== "all") list = list.filter(i => i.clientId === clientFilter); if (search) { const q = search.toLowerCase(); list = list.filter(i => i.id.toLowerCase().includes(q) || (i.machineId || "").toLowerCase().includes(q) || (i.period || "").toLowerCase().includes(q) || getClientName(i.clientId).toLowerCase().includes(q) ); } return [...list].sort((a, b) => new Date(b.date) - new Date(a.date)); }, [invoices, filter, clientFilter, search, users]); const totalFiltered = filtered.reduce((s, i) => s + i.amount, 0); function markPaid(id) { const target = invoices.find(i => i.id === id); if (!target) return; const updated = { ...target, status: "Lunas" }; setInvoices(prev => prev.map(i => i.id === id ? updated : i)); toast("Invoice ditandai Lunas.", "success"); db.upsertInvoice(updated).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } function addInvoice(e) { e.preventDefault(); if (!newInv.clientId) { toast("Pilih client.", "danger"); return; } if (!newInv.item.trim()){ toast("Isi item / deskripsi.", "danger"); return; } if (!newInv.periodStart || !newInv.periodEnd) { toast("Pilih periode (dari–sampai).", "danger"); return; } if (new Date(newInv.periodEnd) < new Date(newInv.periodStart)) { toast("Tanggal akhir periode lebih awal dari tanggal mulai.", "danger"); return; } if (effectiveAmount <= 0) { toast("Nominal invalid (cek harga/bulan atau input manual).", "danger"); return; } const periodText = `${newInv.item.trim()} · ${formatDateID(newInv.periodStart)} – ${formatDateID(newInv.periodEnd)}`; const id = "INV-" + Date.now(); const qty = Number(newInv.qty) || 1; const unitPrice = newInv.manualAmount ? Math.round(Number(effectiveAmount) / qty) // derive unit price from manual total : Number(newInv.unitPrice) || 0; const created = { id, clientId: newInv.clientId, machineId: newInv.machineId || null, amount: Number(effectiveAmount), date: newInv.periodStart, // invoice date = awal periode period: periodText, method: newInv.method, status: newInv.status, qty, unitPrice, }; setInvoices(prev => [...prev, created]); setNewInv(EMPTY_INV_FORM); setShowNew(false); toast(`Invoice ${id} dibuat (${formatRp(created.amount)}).`, "success"); db.upsertInvoice(created).catch(err => toast("Gagal simpan ke Supabase: " + err.message, "danger")); } function exportInvoicePDF(inv) { try { exportInvoiceDoc(inv, getClientName(inv.clientId)); } catch (e) { toast(e.message || "Gagal membuat PDF.", "danger"); } } return (
setShowNew(true)} variant="primary"> Buat Invoice } /> {/* Filters */}
setSearch(e.target.value)} placeholder="Cari invoice / client / mesin..." className="pl-8 pr-4 py-2 rounded-xl border border-stone-300 bg-white text-sm focus:border-[#FF2D55] outline-none"/>
{[ { id:"all", label:"Semua" }, { id:"lunas", label:"Lunas" }, { id:"outstanding", label:"Outstanding" }, { id:"pending", label:"Pending" }, ].map(f => ( ))}
{/* Table */}
{filtered.map(inv => ( ))} {filtered.length === 0 && ( )}
Invoice ID Client Mesin Periode Metode Nominal Status Aksi
{inv.id}
{formatDateID(inv.date)}
{getClientName(inv.clientId)} {inv.machineId ? {inv.machineId} : } {inv.period} {inv.method} {formatRp(inv.amount)} {inv.status}
{inv.status !== "Lunas" && ( )}
Tidak ada invoice ditemukan.
{/* Footer total */}
{filtered.length} invoice
{formatRp(totalFiltered)}
{/* New Invoice Modal */} setShowNew(false)} title="Buat Invoice Baru" width="max-w-2xl">
{/* Client */}
{/* Mesin (opsional) */}
{/* Item / deskripsi */} setNewInv({...newInv, item: v})} placeholder="cth: Sewa Cloud, Sewa Mesin, Refill, …" full required/> {/* Periode */} setNewInv({...newInv, periodStart: v})} required/> setNewInv({...newInv, periodEnd: v})} required/> {/* Qty (bulan) */}
setNewInv({...newInv, qty: e.target.value})} className="mt-1.5 w-full px-4 py-2.5 rounded-xl border border-stone-300 bg-white text-[#1A1A1A] focus:border-[#FF2D55] outline-none text-sm"/> {computedMonths > 0 && Number(newInv.qty) !== computedMonths && ( )}
{/* Harga Satuan */} setNewInv({...newInv, unitPrice: v})} placeholder="cth: 350000"/> {/* Nominal total */}
setNewInv({...newInv, amount: e.target.value})} disabled={!newInv.manualAmount} className={`mt-1.5 w-full px-4 py-2.5 rounded-xl border border-stone-300 text-[#1A1A1A] focus:border-[#FF2D55] outline-none text-sm ${newInv.manualAmount ? "bg-white" : "bg-stone-50 text-stone-500"}`}/> {!newInv.manualAmount && Number(newInv.qty) > 0 && Number(newInv.unitPrice) > 0 && (
{newInv.qty} bulan × {formatRp(Number(newInv.unitPrice))} = {formatRp(computedAmount)}
)}
{/* Status */}
{/* Metode */}
); } /* ═══════════════════════════════════════════════ ADMIN: PENGATURAN PERUSAHAAN ═══════════════════════════════════════════════ */ const SETTINGS_TABS = [ { id: "company", label: "Perusahaan", icon: "shield" }, { id: "contact", label: "Kontak", icon: "user" }, { id: "bank", label: "Rekening", icon: "wallet" }, { id: "invoice", label: "Invoice", icon: "receipt" }, ]; function SettingsField({ label, type = "text", value, onChange, placeholder, hint, full }) { return (
{type === "textarea" ? (