/* ========================================================================== Monitoring Perawatan LV & Bus — versi server (PHP + MySQL) Dijalankan langsung di browser: React + Babel dimuat dari CDN, data disimpan di MySQL lewat api/index.php ========================================================================== */ const { useState, useEffect, useMemo } = React; const API = "api/index.php"; async function api(action, payload) { const res = await fetch(API + "?a=" + action, { method: payload ? "POST" : "GET", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: payload ? JSON.stringify(payload) : undefined, }); if (res.status === 401) throw new Error("401"); let j; try { j = await res.json(); } catch (e) { throw new Error("Balasan server tidak terbaca. Periksa pengaturan PHP di hosting."); } if (!j.ok) throw new Error(j.error || "Server menolak permintaan"); return j.data; } /* ============================== STORAGE ============================== */ const KEY = "fleet-lv-bus:v1"; const emptyData = { units: [], services: [], parts: [], incomes: [], loans: [], karyawan: [], merek: [], asuransi: [], }; const seed = () => { const today = new Date(); const d = (minus) => { const x = new Date(today); x.setDate(x.getDate() - minus); return x.toISOString().slice(0, 10); }; const units = [ { id: "u1", kode: "LV-01", noLambung: "L-101", tipe: "LV", merek: "Toyota", model: "Hilux D-Cab", nopol: "B 9012 KHD", tahun: 2022, odometer: 86400, asuransiId: "a1", aktif: true }, { id: "u2", kode: "LV-02", noLambung: "L-102", tipe: "LV", merek: "Mitsubishi", model: "Triton", nopol: "B 1874 RFA", tahun: 2021, odometer: 124800, asuransiId: "a2", aktif: true }, { id: "u3", kode: "BUS-01", noLambung: "B-201", tipe: "Bus", merek: "Hino", model: "RK8 / 45 seat", nopol: "B 7233 TGA", tahun: 2020, odometer: 312500, asuransiId: "a3", aktif: true }, { id: "u4", kode: "BUS-02", noLambung: "B-202", tipe: "Bus", merek: "Mercedes-Benz", model: "OH 1526 / 40 seat", nopol: "B 7418 TGB", tahun: 2023, odometer: 98200, asuransiId: "a4", aktif: true }, ]; const services = [ { id: "s1", unitId: "u3", tanggal: d(6), jenis: "Servis rutin", odometer: 310000, deskripsi: "Ganti oli mesin + filter", biaya: 2850000, vendor: "Bengkel Sinar Jaya" }, { id: "s2", unitId: "u1", tanggal: d(12), jenis: "Ban", odometer: 85200, deskripsi: "Rotasi 4 ban + spooring", biaya: 750000, vendor: "Ban Mandiri" }, { id: "s3", unitId: "u2", tanggal: d(23), jenis: "Perbaikan", odometer: 122100, deskripsi: "Ganti kampas rem depan", biaya: 1650000, vendor: "Workshop internal" }, { id: "s4", unitId: "u4", tanggal: d(40), jenis: "Servis rutin", odometer: 92000, deskripsi: "Servis 90.000 km", biaya: 4200000, vendor: "Dealer resmi" }, { id: "s5", unitId: "u3", tanggal: d(58), jenis: "Perbaikan", odometer: 298400, deskripsi: "Overhaul kompresor AC", biaya: 7300000, vendor: "Bengkel Sinar Jaya" }, ]; const parts = [ { id: "p1", unitId: "u3", nama: "Oli mesin", kmPasang: 310000, lifetimeKm: 10000, tglPasang: d(6), lifetimeBulan: 3, harga: 1900000 }, { id: "p2", unitId: "u3", nama: "Ban depan (2 pcs)", kmPasang: 265000, lifetimeKm: 60000, tglPasang: d(210), lifetimeBulan: 24, harga: 8600000 }, { id: "p3", unitId: "u1", nama: "Kampas rem depan", kmPasang: 62000, lifetimeKm: 30000, tglPasang: d(320), lifetimeBulan: 24, harga: 1250000 }, { id: "p4", unitId: "u2", nama: "Oli mesin", kmPasang: 118000, lifetimeKm: 8000, tglPasang: d(75), lifetimeBulan: 4, harga: 620000 }, { id: "p5", unitId: "u4", nama: "Filter solar", kmPasang: 92000, lifetimeKm: 20000, tglPasang: d(40), lifetimeBulan: 12, harga: 480000 }, { id: "p6", unitId: "u4", nama: "Aki 2x 100Ah", kmPasang: 60000, lifetimeKm: 120000, tglPasang: d(430), lifetimeBulan: 18, harga: 3400000 }, ]; const incomes = [ { id: "i1", unitId: "u3", tanggal: d(5), sumber: "Sewa harian", jumlah: 18500000, keterangan: "Trip Jakarta-Bandung 3 hari" }, { id: "i2", unitId: "u4", tanggal: d(11), sumber: "Kontrak", jumlah: 42000000, keterangan: "Antar-jemput karyawan (bulanan)" }, { id: "i3", unitId: "u1", tanggal: d(19), sumber: "Sewa harian", jumlah: 6200000, keterangan: "Support survei site" }, { id: "i4", unitId: "u3", tanggal: d(38), sumber: "Sewa harian", jumlah: 21000000, keterangan: "Wisata rombongan" }, { id: "i5", unitId: "u4", tanggal: d(42), sumber: "Kontrak", jumlah: 42000000, keterangan: "Antar-jemput karyawan (bulanan)" }, { id: "i6", unitId: "u2", tanggal: d(50), sumber: "Sewa bulanan", jumlah: 9500000, keterangan: "Unit operasional proyek" }, ]; const startMonth = (mBack) => { const x = new Date(today); x.setMonth(x.getMonth() - mBack); return `${x.getFullYear()}-${String(x.getMonth() + 1).padStart(2, "0")}`; }; const loans = [ { id: "l1", kode: "KRD-2023-014", unitId: "u4", lembaga: "BCA Finance", pokok: 780000000, angsuran: 14900000, tenor: 60, mulai: startMonth(14), tglJatuhTempo: 10, pembayaran: Array.from({ length: 13 }, (_, i) => ({ ke: i + 1, tanggal: d(400 - i * 30), jumlah: 14900000 })), }, { id: "l2", kode: "KRD-2022-007", unitId: "u2", lembaga: "Mandiri Tunas Finance", pokok: 310000000, angsuran: 7250000, tenor: 48, mulai: startMonth(26), tglJatuhTempo: 25, pembayaran: Array.from({ length: 26 }, (_, i) => ({ ke: i + 1, tanggal: d(780 - i * 30), jumlah: 7250000 })), }, ]; const dp = (plus) => { const x = new Date(today); x.setDate(x.getDate() + plus); return x.toISOString().slice(0, 10); }; const karyawan = [ { id: "k1", nrp: "NRP-0012", nama: "Sanusi", jabatan: "Sopir", statusKerja: "Tetap", unitId: "u1", hp: "0812-3344-5566", jenisSIM: "SIM B1 Umum", simExp: dp(420), tglMasuk: d(1500), gajiPokok: 4800000, tunjangan: 1200000, aktif: true }, { id: "k2", nrp: "NRP-0027", nama: "Deni Prasetyo", jabatan: "Sopir", statusKerja: "Kontrak", unitId: "u2", hp: "0813-7788-1020", jenisSIM: "SIM B1 Umum", simExp: dp(18), tglMasuk: d(700), gajiPokok: 4500000, tunjangan: 1000000, aktif: true }, { id: "k3", nrp: "NRP-0004", nama: "Warsito", jabatan: "Sopir", statusKerja: "Tetap", unitId: "u3", hp: "0857-2211-9080", jenisSIM: "SIM B2 Umum", simExp: dp(-12), tglMasuk: d(2400), gajiPokok: 6200000, tunjangan: 1800000, aktif: true }, { id: "k4", nrp: "NRP-0019", nama: "Iwan Setiawan", jabatan: "Sopir", statusKerja: "Tetap", unitId: "u4", hp: "0878-4455-2311", jenisSIM: "SIM B2 Umum", simExp: dp(260), tglMasuk: d(980), gajiPokok: 6000000, tunjangan: 1800000, aktif: true }, { id: "k5", nrp: "NRP-0041", nama: "Rudi Hartono", jabatan: "Kernet", statusKerja: "Harian", unitId: "u3", hp: "0821-9090-3344", jenisSIM: "", simExp: "", tglMasuk: d(300), gajiPokok: 2600000, tunjangan: 600000, aktif: true }, { id: "k6", nrp: "NRP-0008", nama: "Bambang S.", jabatan: "Mekanik", statusKerja: "Tetap", unitId: "", hp: "0811-2323-7788", jenisSIM: "SIM A", simExp: dp(95), tglMasuk: d(1900), gajiPokok: 5400000, tunjangan: 900000, aktif: true }, { id: "k7", nrp: "NRP-0033", nama: "Lestari", jabatan: "Admin", statusKerja: "Tetap", unitId: "", hp: "0819-6767-1212", jenisSIM: "", simExp: "", tglMasuk: d(1100), gajiPokok: 4200000, tunjangan: 700000, aktif: true }, ]; const merek = [ { id: "m1", nama: "Toyota", tipe: "LV", agen: "Auto2000", keterangan: "Hilux, Innova" }, { id: "m2", nama: "Mitsubishi", tipe: "LV", agen: "Dipo Star Finance", keterangan: "Triton, Pajero" }, { id: "m3", nama: "Hino", tipe: "Bus", agen: "Hino Sales Indonesia", keterangan: "RK8, RN285" }, { id: "m4", nama: "Mercedes-Benz", tipe: "Bus", agen: "Daimler Commercial", keterangan: "OH 1526, OH 1626" }, { id: "m5", nama: "Isuzu", tipe: "Umum", agen: "Isuzu Astra", keterangan: "Elf, Traga" }, ]; const asuransi = [ { id: "a1", kode: "ASR-001", penanggung: "Garda Oto", jenis: "All Risk", nomorPolis: "GO-2405-88121", premi: 9800000, mulai: d(160), berakhir: dp(205), keterangan: "" }, { id: "a2", kode: "ASR-002", penanggung: "Asuransi Sinar Mas", jenis: "TLO", nomorPolis: "SM-2312-40917", premi: 4300000, mulai: d(250), berakhir: dp(115), keterangan: "" }, { id: "a3", kode: "ASR-003", penanggung: "Adira Insurance", jenis: "All Risk + Rider", nomorPolis: "AD-2308-77304", premi: 21500000, mulai: d(343), berakhir: dp(22), keterangan: "Termasuk tanggung jawab pihak ketiga" }, { id: "a4", kode: "ASR-004", penanggung: "Zurich Asuransi", jenis: "All Risk", nomorPolis: "ZR-2401-19286", premi: 24800000, mulai: d(120), berakhir: dp(245), keterangan: "" }, ]; return { units, services, parts, incomes, loans, karyawan, merek, asuransi }; }; /* ============================== HELPERS ============================== */ const uid = () => Math.random().toString(36).slice(2, 9); const rp = (n) => "Rp " + Math.round(Number(n) || 0).toLocaleString("id-ID"); const rpShort = (n) => { const v = Number(n) || 0; if (Math.abs(v) >= 1e9) return (v / 1e9).toFixed(1).replace(".", ",") + " M"; if (Math.abs(v) >= 1e6) return (v / 1e6).toFixed(0) + " jt"; if (Math.abs(v) >= 1e3) return (v / 1e3).toFixed(0) + " rb"; return String(v); }; const km = (n) => (Number(n) || 0).toLocaleString("id-ID") + " km"; const todayISO = () => new Date().toISOString().slice(0, 10); const fmtDate = (iso) => { if (!iso) return "—"; const dt = new Date(iso + "T00:00:00"); if (isNaN(dt)) return iso; return dt.toLocaleDateString("id-ID", { day: "2-digit", month: "short", year: "numeric" }); }; const monthKey = (iso) => (iso || "").slice(0, 7); const monthLabel = (key) => { const [y, m] = key.split("-"); const nm = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"]; return `${nm[Number(m) - 1]} ${String(y).slice(2)}`; }; const daysBetween = (a, b) => Math.round((new Date(b) - new Date(a)) / 86400000); const addMonths = (ym, n) => { const [y, m] = ym.split("-").map(Number); const dt = new Date(y, m - 1 + n, 1); return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}`; }; /* Status umur part: gabungan pemakaian km dan umur waktu, ambil yang paling mendesak */ function partStatus(part, unit) { const odo = unit ? unit.odometer : part.kmPasang; const pakaiKm = Math.max(0, odo - part.kmPasang); const pctKm = part.lifetimeKm > 0 ? pakaiKm / part.lifetimeKm : 0; const sisaKm = part.lifetimeKm > 0 ? part.kmPasang + part.lifetimeKm - odo : null; let pctWaktu = 0; let sisaHari = null; if (part.lifetimeBulan > 0 && part.tglPasang) { const umurHari = daysBetween(part.tglPasang, todayISO()); const totalHari = part.lifetimeBulan * 30.4; pctWaktu = umurHari / totalHari; sisaHari = Math.round(totalHari - umurHari); } const pct = Math.max(pctKm, pctWaktu); const level = pct >= 1 ? "lewat" : pct >= 0.85 ? "segera" : "aman"; const basis = pctWaktu > pctKm ? "waktu" : "jarak"; return { pct, pctKm, pctWaktu, sisaKm, sisaHari, level, basis, pakaiKm }; } function loanStatus(loan) { const paid = loan.pembayaran ? loan.pembayaran.length : 0; const sisaTenor = Math.max(0, loan.tenor - paid); const sisaBayar = sisaTenor * loan.angsuran; const totalDibayar = (loan.pembayaran || []).reduce((s, p) => s + (Number(p.jumlah) || 0), 0); const nextMonth = addMonths(loan.mulai, paid); const [y, m] = nextMonth.split("-").map(Number); const dueDate = new Date(y, m - 1, Math.min(loan.tglJatuhTempo || 1, 28)); const dueISO = dueDate.toISOString().slice(0, 10); const selisih = daysBetween(todayISO(), dueISO); const lunas = sisaTenor === 0; const level = lunas ? "lunas" : selisih < 0 ? "telat" : selisih <= 7 ? "segera" : "aman"; return { paid, sisaTenor, sisaBayar, totalDibayar, dueISO, selisih, lunas, level }; } /* Masa berlaku SIM: peringatan 30 hari sebelum habis */ function simStatus(k) { if (!k.simExp) return null; const sisaHari = daysBetween(todayISO(), k.simExp); const level = sisaHari < 0 ? "lewat" : sisaHari <= 30 ? "segera" : "aman"; return { sisaHari, level }; } const totalGaji = (k) => (Number(k.gajiPokok) || 0) + (Number(k.tunjangan) || 0); /* Masa berlaku polis asuransi: peringatan 30 hari sebelum berakhir */ function asuransiStatus(a) { if (!a || !a.berakhir) return null; const sisaHari = daysBetween(todayISO(), a.berakhir); const level = sisaHari < 0 ? "lewat" : sisaHari <= 30 ? "segera" : "aman"; return { sisaHari, level }; } /* Impor massal: satu baris per orang, dipisah | atau tab atau koma */ const toNum = (v) => Number(String(v || "").replace(/[^\d]/g, "")) || 0; function normDate(s) { if (!s) return ""; const t = s.trim(); const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})$/); if (m) return `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`; return /^\d{4}-\d{2}-\d{2}$/.test(t) ? t : ""; } function parseKaryawan(text, units) { const baris = String(text || "") .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); const hasil = []; const gagal = []; baris.forEach((line) => { if (/^(nrp|nama)\s*[|,\t]/i.test(line)) return; const parts = /[|\t]/.test(line) ? line.split(/[|\t]/) : line.split(","); const g = (i) => (parts[i] || "").trim(); const nama = g(1); if (!nama) { gagal.push(line); return; } const kode = g(3).toUpperCase(); const u = units.find((x) => (x.kode || "").toUpperCase() === kode); hasil.push({ id: uid(), nrp: g(0), nama, jabatan: g(2) || "Sopir", unitId: u ? u.id : "", statusKerja: g(4) || "Tetap", hp: g(5), jenisSIM: g(6), simExp: normDate(g(7)), tglMasuk: todayISO(), gajiPokok: toNum(g(8)), tunjangan: toNum(g(9)), aktif: true, }); }); return { hasil, gagal }; } /* ============================== UI ATOMS ============================== */ const Sheet = ({ open, title, onClose, children, footer }) => { if (!open) return null; return (
e.stopPropagation()}>

{title}

{children}
{footer ?
{footer}
: null}
); }; const Field = ({ label, hint, children }) => ( ); const Plate = ({ children }) => {children}; const Tag = ({ tone = "netral", children }) => {children}; const Empty = ({ title, action, onAction }) => (

{title}

{action ? ( ) : null}
); const Wear = ({ pct, level }) => (
); /* ============================== APP ============================== */ const NAMA_HAK = { ringkas: "Ringkas (dasbor)", unit: "Unit", servis: "Servis", part: "Part", keuangan: "Keuangan", master: "Menu Master", m_unit: "Master · Unit", m_karyawan: "Master · Karyawan", m_merek: "Master · Merek", m_asuransi: "Master · Asuransi", m_pinjaman: "Master · Pinjaman", aktivitas: "Master · Catatan aktivitas", tambah: "Tambah & ubah data", hapus: "Hapus data", }; const SANDI_BAWAAN = { admin: "3333", staff: "2222", superadmin: "" }; const boleh = (user, k) => !!user && (user.peran === "superadmin" || (user.hak || []).includes(k)); function FleetMonitor({ user, onLogout }) { const [data, setData] = useState(emptyData); const [loading, setLoading] = useState(true); const [tab, setTab] = useState("dash"); const [sheet, setSheet] = useState(null); // {kind, payload} const [form, setForm] = useState({}); const [filterTipe, setFilterTipe] = useState("Semua"); const [subMaster, setSubMaster] = useState("unit"); const [saving, setSaving] = useState(false); const [err, setErr] = useState(""); useEffect(() => { (async () => { try { const d = await api("load"); setData({ ...emptyData, ...d }); } catch (e) { if (e.message === "401") { onLogout(); return; } setErr(e.message); } setLoading(false); })(); }, []); const persist = async (next) => { const sebelum = data; setData(next); setSaving(true); try { await api("sync", next); setErr(""); } catch (e) { setData(sebelum); setErr(e.message === "401" ? "Sesi berakhir. Masuk lagi untuk menyimpan." : "Perubahan gagal disimpan: " + e.message); } setSaving(false); }; const unitById = useMemo(() => { const m = {}; data.units.forEach((u) => (m[u.id] = u)); return m; }, [data.units]); const visibleUnits = useMemo( () => data.units.filter((u) => filterTipe === "Semua" || u.tipe === filterTipe), [data.units, filterTipe] ); const openSheet = (kind, payload = {}) => { setForm(payload.form || {}); setSheet({ kind, payload }); }; const closeSheet = () => setSheet(null); const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value })); const num = (v) => Number(String(v ?? "").replace(/[^\d-]/g, "")) || 0; /* ---------- aksi data ---------- */ const saveUnit = () => { if (!form.kode) return; const rec = { id: form.id || uid(), kode: form.kode.trim(), noLambung: (form.noLambung || "").trim(), tipe: form.tipe || "LV", merek: form.merek || "", model: form.model || "", nopol: (form.nopol || "").toUpperCase(), tahun: num(form.tahun), odometer: num(form.odometer), asuransiId: form.asuransiId || "", aktif: true, }; const units = form.id ? data.units.map((u) => (u.id === form.id ? rec : u)) : [...data.units, rec]; // kode pinjaman: satu unit satu kontrak, lepaskan dari unit lain const loans = data.loans.map((l) => { if (l.id === form.loanId) return { ...l, unitId: rec.id }; if (l.unitId === rec.id && l.id !== form.loanId) return { ...l, unitId: "" }; return l; }); persist({ ...data, units, loans }); closeSheet(); }; const saveMerek = () => { if (!form.nama) return; const rec = { id: form.id || uid(), nama: form.nama.trim(), tipe: form.tipe || "Umum", agen: form.agen || "", keterangan: form.keterangan || "", }; const list = data.merek || []; const merek = form.id ? list.map((m) => (m.id === rec.id ? rec : m)) : [...list, rec]; persist({ ...data, merek }); closeSheet(); }; const saveAsuransi = () => { if (!form.penanggung && !form.kode) return; const rec = { id: form.id || uid(), kode: (form.kode || "").trim(), penanggung: form.penanggung || "", jenis: form.jenis || "All Risk", nomorPolis: form.nomorPolis || "", premi: num(form.premi), mulai: form.mulai || todayISO(), berakhir: form.berakhir || "", keterangan: form.keterangan || "", }; const list = data.asuransi || []; const asuransi = form.id ? list.map((a) => (a.id === rec.id ? rec : a)) : [...list, rec]; persist({ ...data, asuransi }); closeSheet(); }; const saveService = () => { if (!form.unitId) return; const rec = { id: form.id || uid(), unitId: form.unitId, tanggal: form.tanggal || todayISO(), jenis: form.jenis || "Servis rutin", odometer: num(form.odometer), deskripsi: form.deskripsi || "", biaya: num(form.biaya), vendor: form.vendor || "", }; const services = form.id ? data.services.map((s) => (s.id === form.id ? rec : s)) : [rec, ...data.services]; // odometer unit ikut naik bila catatan lebih tinggi const units = data.units.map((u) => u.id === rec.unitId && rec.odometer > u.odometer ? { ...u, odometer: rec.odometer } : u ); persist({ ...data, services, units }); closeSheet(); }; const savePart = () => { if (!form.unitId || !form.nama) return; const rec = { id: form.id || uid(), unitId: form.unitId, nama: form.nama, kmPasang: num(form.kmPasang), lifetimeKm: num(form.lifetimeKm), lifetimeBulan: num(form.lifetimeBulan), tglPasang: form.tglPasang || todayISO(), harga: num(form.harga), }; const parts = form.id ? data.parts.map((p) => (p.id === rec.id ? rec : p)) : [...data.parts, rec]; persist({ ...data, parts }); closeSheet(); }; const gantiPart = (part) => { const u = unitById[part.unitId]; openSheet("part", { form: { ...part, id: undefined, kmPasang: u ? u.odometer : part.kmPasang, tglPasang: todayISO(), _ganti: part.id, }, }); }; const saveIncome = () => { const rec = { id: form.id || uid(), unitId: form.unitId || "", tanggal: form.tanggal || todayISO(), sumber: form.sumber || "Sewa harian", jumlah: num(form.jumlah), keterangan: form.keterangan || "", }; const incomes = form.id ? data.incomes.map((i) => (i.id === rec.id ? rec : i)) : [rec, ...data.incomes]; persist({ ...data, incomes }); closeSheet(); }; const saveLoan = () => { const rec = { id: form.id || uid(), kode: (form.kode || "").trim(), unitId: form.unitId || "", lembaga: form.lembaga || "", pokok: num(form.pokok), angsuran: num(form.angsuran), tenor: num(form.tenor), mulai: form.mulai || todayISO().slice(0, 7), tglJatuhTempo: num(form.tglJatuhTempo) || 1, pembayaran: form.pembayaran || [], }; const loans = form.id ? data.loans.map((l) => (l.id === rec.id ? rec : l)) : [...data.loans, rec]; persist({ ...data, loans }); closeSheet(); }; const bayarAngsuran = (loan) => { const st = loanStatus(loan); if (st.lunas) return; const loans = data.loans.map((l) => l.id === loan.id ? { ...l, pembayaran: [...(l.pembayaran || []), { ke: st.paid + 1, tanggal: todayISO(), jumlah: l.angsuran }] } : l ); persist({ ...data, loans }); }; const batalBayar = (loan) => { const loans = data.loans.map((l) => l.id === loan.id ? { ...l, pembayaran: (l.pembayaran || []).slice(0, -1) } : l ); persist({ ...data, loans }); }; const saveKaryawan = () => { if (!form.nama) return; const rec = { id: form.id || uid(), nrp: (form.nrp || "").trim(), nama: form.nama.trim(), jabatan: form.jabatan || "Sopir", statusKerja: form.statusKerja || "Tetap", unitId: form.unitId || "", hp: form.hp || "", jenisSIM: form.jenisSIM || "", simExp: form.simExp || "", tglMasuk: form.tglMasuk || todayISO(), gajiPokok: num(form.gajiPokok), tunjangan: num(form.tunjangan), aktif: form.aktif === false ? false : true, }; const list = data.karyawan || []; const karyawan = form.id ? list.map((k) => (k.id === rec.id ? rec : k)) : [...list, rec]; persist({ ...data, karyawan }); closeSheet(); }; const imporKaryawan = () => { const { hasil } = parseKaryawan(form.teks, data.units); const lama = data.karyawan || []; const adaNrp = new Set(lama.map((k) => (k.nrp || "").toUpperCase()).filter(Boolean)); const baru = hasil.filter((k) => !k.nrp || !adaNrp.has(k.nrp.toUpperCase())); if (!baru.length) return; persist({ ...data, karyawan: [...lama, ...baru] }); closeSheet(); }; const hapus = (koleksi, id) => { persist({ ...data, [koleksi]: data[koleksi].filter((x) => x.id !== id) }); }; const saveOdo = () => { const units = data.units.map((u) => (u.id === form.id ? { ...u, odometer: num(form.odometer) } : u)); persist({ ...data, units }); closeSheet(); }; /* ---------- ringkasan ---------- */ const bulanIni = monthKey(todayISO()); const ringkas = useMemo(() => { const ids = new Set(visibleUnits.map((u) => u.id)); const svc = data.services.filter((s) => ids.has(s.unitId)); const inc = data.incomes.filter((i) => !i.unitId || ids.has(i.unitId)); const biayaBulan = svc.filter((s) => monthKey(s.tanggal) === bulanIni).reduce((a, s) => a + s.biaya, 0); const masukBulan = inc.filter((i) => monthKey(i.tanggal) === bulanIni).reduce((a, i) => a + i.jumlah, 0); const angsuranBulan = data.loans .filter((l) => !l.unitId || ids.has(l.unitId)) .filter((l) => !loanStatus(l).lunas) .reduce((a, l) => a + l.angsuran, 0); const partAlert = data.parts .filter((p) => ids.has(p.unitId)) .map((p) => ({ p, st: partStatus(p, unitById[p.unitId]) })) .filter((x) => x.st.level !== "aman") .sort((a, b) => b.st.pct - a.st.pct); const jatuhTempo = data.loans .map((l) => ({ l, st: loanStatus(l) })) .filter((x) => !x.st.lunas && (x.st.level === "telat" || x.st.level === "segera")) .sort((a, b) => a.st.selisih - b.st.selisih); const kru = (data.karyawan || []).filter((k) => k.aktif !== false); const gajiBulan = kru.reduce((a, k) => a + totalGaji(k), 0); const simAlert = kru .map((k) => ({ k, st: simStatus(k) })) .filter((x) => x.st && x.st.level !== "aman") .sort((a, b) => a.st.sisaHari - b.st.sisaHari); const asuransiAlert = (data.asuransi || []) .map((a) => ({ a, st: asuransiStatus(a), unit: data.units.find((u) => u.asuransiId === a.id) })) .filter((x) => x.st && x.st.level !== "aman") .sort((a, b) => a.st.sisaHari - b.st.sisaHari); return { biayaBulan, masukBulan, angsuranBulan, gajiBulan, jumlahKru: kru.length, simAlert, asuransiAlert, laba: masukBulan - biayaBulan - angsuranBulan - gajiBulan, partAlert, jatuhTempo, }; }, [data, visibleUnits, unitById, bulanIni]); const chartData = useMemo(() => { const keys = []; for (let i = 5; i >= 0; i--) keys.push(addMonths(bulanIni, -i)); const ids = new Set(visibleUnits.map((u) => u.id)); return keys.map((k) => ({ bulan: monthLabel(k), Pemasukan: data.incomes .filter((i) => monthKey(i.tanggal) === k && (!i.unitId || ids.has(i.unitId))) .reduce((a, i) => a + i.jumlah, 0), Biaya: data.services .filter((s) => monthKey(s.tanggal) === k && ids.has(s.unitId)) .reduce((a, s) => a + s.biaya, 0), })); }, [data, visibleUnits, bulanIni]); const biayaPerUnit = useMemo(() => { return visibleUnits .map((u) => ({ unit: u.kode, Biaya: data.services.filter((s) => s.unitId === u.id).reduce((a, s) => a + s.biaya, 0), })) .sort((a, b) => b.Biaya - a.Biaya); }, [visibleUnits, data.services]); /* ---------- render ---------- */ if (loading) { return (
); } /* ============================== CHART, LOGIN, ROOT ============================== */ function MiniChart({ rows }) { const W = 320, H = 148, padL = 30, padB = 18, padT = 8; const max = Math.max(1, ...rows.map((r) => Math.max(r.Pemasukan, r.Biaya))); const plotH = H - padB - padT; const slot = (W - padL) / Math.max(1, rows.length); const bw = Math.min(13, slot / 3); const y = (v) => padT + plotH - (v / max) * plotH; const garis = [0, 0.5, 1]; return (
{garis.map((g) => ( {g === 0 ? "0" : rpShort(max * g)} ))} {rows.map((r, i) => { const x0 = padL + i * slot + slot / 2; return ( {r.bulan} ); })}
Pemasukan Biaya perawatan
); } function Login({ onMasuk }) { const [u, setU] = useState(""); const [p, setP] = useState(""); const [err, setErr] = useState(""); const [sibuk, setSibuk] = useState(false); const masuk = async () => { if (!u || !p) { setErr("NRP dan kata sandi harus diisi."); return; } setSibuk(true); setErr(""); try { const user = await api("login", { username: u, password: p }); onMasuk(user); } catch (e) { setErr(e.message === "401" ? "NRP atau kata sandi salah." : e.message); setSibuk(false); } }; return (