/* ==========================================================================
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 }) => (
{label}
{hint ? {hint} : null}
{children}
);
const Plate = ({ children }) => {children} ;
const Tag = ({ tone = "netral", children }) => {children} ;
const Empty = ({ title, action, onAction }) => (
{title}
{action ? (
{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 (
);
}
const kosong = data.units.length === 0;
return (
Buku servis armada
Monitoring LV & Bus
{user ? user.nama || user.username : ""}
{saving ? "Menyimpan…" : "Tersimpan"}
Keluar
{["Semua", "LV", "Bus"].map((t) => (
setFilterTipe(t)}
>
{t}
))}
{err ? {err}
: null}
{kosong ? (
persist(seed())}
/>
openSheet("unit", { form: { tipe: "LV" } })}>
Tambah unit sendiri
) : (
<>
{tab === "dash" && (
setTab("part")}
onGoUang={() => setTab("uang")}
onGoKru={() => { setSubMaster("karyawan"); setTab("master"); }}
onGoAsuransi={() => { setSubMaster("asuransi"); setTab("master"); }}
jumlahUnit={visibleUnits.length}
/>
)}
{tab === "unit" && (
openSheet("unit", { form: { tipe: filterTipe === "Bus" ? "Bus" : "LV" } })}
onEdit={(u) =>
openSheet("unit", {
form: { ...u, loanId: (data.loans.find((l) => l.unitId === u.id) || {}).id || "" },
})
}
onOdo={(u) => openSheet("odo", { form: { id: u.id, odometer: u.odometer, kode: u.kode } })}
onDelete={(u) => hapus("units", u.id)}
/>
)}
{tab === "servis" && (
openSheet("servis", {
form: { tanggal: todayISO(), jenis: "Servis rutin", unitId: visibleUnits[0] && visibleUnits[0].id },
})
}
onDelete={(s) => hapus("services", s.id)}
/>
)}
{tab === "part" && (
openSheet("part", {
form: {
tglPasang: todayISO(),
unitId: visibleUnits[0] && visibleUnits[0].id,
kmPasang: visibleUnits[0] ? visibleUnits[0].odometer : 0,
},
})
}
onGanti={gantiPart}
onEdit={(p) => openSheet("part", { form: { ...p } })}
onDelete={(p) => hapus("parts", p.id)}
/>
)}
{tab === "master" && (
openSheet("karyawan", { form: { jabatan: "Sopir", statusKerja: "Tetap", tglMasuk: todayISO() } })
}
onEditKaryawan={(k) => openSheet("karyawan", { form: { ...k } })}
onDelKaryawan={(k) => hapus("karyawan", k.id)}
onImportKaryawan={() => openSheet("impor", { form: { teks: "" } })}
onSeedKaryawan={() => persist({ ...data, karyawan: seed().karyawan })}
onAddUnit={() => openSheet("unit", { form: { tipe: "LV" } })}
onEditUnit={(u) =>
openSheet("unit", {
form: { ...u, loanId: (data.loans.find((l) => l.unitId === u.id) || {}).id || "" },
})
}
onDelUnit={(u) => hapus("units", u.id)}
onAddMerek={() => openSheet("merek", { form: { tipe: "Umum" } })}
onEditMerek={(m) => openSheet("merek", { form: { ...m } })}
onDelMerek={(m) => hapus("merek", m.id)}
onAddAsuransi={() => openSheet("asuransi", { form: { jenis: "All Risk", mulai: todayISO() } })}
onEditAsuransi={(a) => openSheet("asuransi", { form: { ...a } })}
onDelAsuransi={(a) => hapus("asuransi", a.id)}
onAddLoan={() =>
openSheet("loan", { form: { mulai: todayISO().slice(0, 7), tglJatuhTempo: 10, tenor: 48 } })
}
onEditLoan={(l) => openSheet("loan", { form: { ...l } })}
onDelLoan={(l) => hapus("loans", l.id)}
/>
)}
{tab === "uang" && (
openSheet("income", { form: { tanggal: todayISO(), sumber: "Sewa harian" } })
}
onAddLoan={() =>
openSheet("loan", { form: { mulai: todayISO().slice(0, 7), tglJatuhTempo: 10, tenor: 48 } })
}
onBayar={bayarAngsuran}
onBatal={batalBayar}
onDelIncome={(i) => hapus("incomes", i.id)}
onDelLoan={(l) => hapus("loans", l.id)}
/>
)}
>
)}
{[
["dash", "Ringkas", "ringkas"],
["unit", "Unit", "unit"],
["servis", "Servis", "servis"],
["part", "Part", "part"],
["uang", "Keuangan", "keuangan"],
["master", "Master", "master"],
]
.filter(([, , hak]) => boleh(user, hak))
.map(([k, label]) => (
setTab(k)}>
{label}
))}
{/* ---------------- SHEETS ---------------- */}
Batal
Simpan unit
>
}
>
LV
Bus
— Pilih merek —
{(data.merek || []).map((m) => (
{m.nama}
))}
{form.merek && !(data.merek || []).some((m) => m.nama === form.merek) ? (
{form.merek}
) : null}
{(data.merek || []).length === 0 ? (
Daftar merek masih kosong. Isi lewat Master › Merek agar bisa dipilih di sini.
) : null}
— Tanpa pembiayaan —
{data.loans.map((l) => (
{l.kode || "tanpa kode"} — {l.lembaga}
))}
— Belum diasuransikan —
{(data.asuransi || []).map((a) => (
{a.kode || a.nomorPolis} — {a.penanggung}
))}
Batal
Simpan merek
>
}
>
Umum
LV
Bus
Batal
Simpan polis
>
}
>
All Risk
TLO
All Risk + Rider
Kombinasi
Polis dipasangkan ke unit lewat formulir unit.
Batal
Simpan
>
}
>
Batal
Simpan catatan
>
}
>
{data.units.map((u) => (
{u.kode} — {u.model}
))}
Servis rutin
Perbaikan
Ban
Body & cat
KIR / perizinan
Lainnya
Batal
Simpan part
>
}
>
{data.units.map((u) => (
{u.kode} — {u.model}
))}
Batal
Simpan kru
>
}
>
Sopir
Kernet
Mekanik
Kepala bengkel
Admin
Lainnya
Tetap
Kontrak
Harian
Non-aktif
— Tanpa unit —
{data.units.map((u) => (
{u.kode} — {u.model}
))}
— Tidak ada —
SIM A
SIM A Umum
SIM B1
SIM B1 Umum
SIM B2
SIM B2 Umum
Batal
Masukkan {parseKaryawan(form.teks, data.units).hasil.length} orang
>
}
>
Satu baris per orang, kolom dipisah tanda | (boleh juga tab atau koma). Urutan kolom:
NRP | Nama | Jabatan | Kode unit | Status | HP | Jenis SIM | Berlaku sampai | Gaji pokok | Tunjangan
Kolom yang tidak ada boleh dikosongkan. Tanggal: 2027-03-14 atau 14/03/2027.
{form.teks ? (
Terbaca {parseKaryawan(form.teks, data.units).hasil.length} baris. Nama unit yang tidak cocok akan
disimpan tanpa unit — bisa dipasangkan lewat tombol Ubah.
) : null}
Batal
Simpan pemasukan
>
}
>
— Tanpa unit —
{data.units.map((u) => (
{u.kode} — {u.model}
))}
Sewa harian
Sewa bulanan
Kontrak
Trip / rit
Lainnya
Batal
Simpan angsuran
>
}
>
— Tanpa unit —
{data.units.map((u) => (
{u.kode} — {u.model}
))}
);
}
/* ============================== VIEWS ============================== */
function Dashboard({ ringkas, chartData, biayaPerUnit, jumlahUnit, onGoPart, onGoUang, onGoKru, onGoAsuransi }) {
const labaPositif = ringkas.laba >= 0;
return (
<>
Unit dipantau
{jumlahUnit}
Pemasukan bulan ini
{rp(ringkas.masukBulan)}
Biaya perawatan
{rp(ringkas.biayaBulan)}
Angsuran bulan ini
{rp(ringkas.angsuranBulan)}
Gaji kru ({ringkas.jumlahKru} orang)
{rp(ringkas.gajiBulan)}
Sisa kas bulan ini
{rp(ringkas.laba)}
Perlu tindakan
{ringkas.partAlert.length === 0 &&
ringkas.jatuhTempo.length === 0 &&
ringkas.simAlert.length === 0 &&
ringkas.asuransiAlert.length === 0 ? (
Semua part masih dalam umur pakai, SIM kru dan polis asuransi berlaku, dan tidak ada angsuran mendekati
jatuh tempo.
) : (
{ringkas.partAlert.slice(0, 4).map(({ p, st }) => (
{st.level === "lewat" ? "Lewat umur" : "Segera ganti"}
{p.nama}
{st.sisaKm !== null && st.basis === "jarak"
? st.sisaKm >= 0
? `sisa ${km(st.sisaKm)}`
: `lewat ${km(-st.sisaKm)}`
: st.sisaHari >= 0
? `sisa ${st.sisaHari} hari`
: `lewat ${-st.sisaHari} hari`}
))}
{ringkas.simAlert.slice(0, 3).map(({ k, st }) => (
{st.level === "lewat" ? "SIM habis" : "SIM segera habis"}
{k.nama} · {k.jenisSIM}
{st.sisaHari >= 0 ? `${st.sisaHari} hari` : `lewat ${-st.sisaHari} hari`}
))}
{ringkas.asuransiAlert.slice(0, 3).map(({ a, st, unit }) => (
{st.level === "lewat" ? "Polis habis" : "Polis segera habis"}
{unit ? `${unit.kode} · ` : ""}
{a.penanggung}
{st.sisaHari >= 0 ? `${st.sisaHari} hari` : `lewat ${-st.sisaHari} hari`}
))}
{ringkas.jatuhTempo.slice(0, 3).map(({ l, st }) => (
{st.level === "telat" ? "Telat bayar" : "Jatuh tempo"}
{l.lembaga || "Angsuran"}
{fmtDate(st.dueISO)}
))}
)}
Pemasukan vs biaya perawatan
Biaya perawatan kumulatif per unit
{biayaPerUnit.length === 0 ? (
Belum ada catatan perawatan.
) : (
{biayaPerUnit.map((r) => {
const max = biayaPerUnit[0].Biaya || 1;
return (
{r.unit}
{rpShort(r.Biaya)}
);
})}
)}
>
);
}
function UnitList({ units, data, onAdd, onEdit, onOdo, onDelete, unitById }) {
return (
<>
Daftar unit
+ Unit
{units.length === 0 ? (
) : (
units.map((u) => {
const svc = data.services.filter((s) => s.unitId === u.id);
const biaya = svc.reduce((a, s) => a + s.biaya, 0);
const masuk = data.incomes.filter((i) => i.unitId === u.id).reduce((a, i) => a + i.jumlah, 0);
const alerts = data.parts
.filter((p) => p.unitId === u.id)
.map((p) => partStatus(p, unitById[p.unitId]))
.filter((s) => s.level !== "aman").length;
const terakhir = svc.slice().sort((a, b) => (a.tanggal < b.tanggal ? 1 : -1))[0];
return (
{u.kode}
{u.tipe}
{u.nopol || "—"}
{[u.merek, u.model].filter(Boolean).join(" ") || "—"} · {u.tahun || "—"} · lambung{" "}
{u.noLambung || "—"}
{(() => {
const kru = (data.karyawan || []).filter((k) => k.unitId === u.id && k.aktif !== false);
if (kru.length) return kru.map((k) => `${k.nama} (${k.jabatan})`).join(", ");
return "belum ada kru";
})()}
onOdo(u)}>
Odometer
{(u.odometer || 0).toLocaleString("id-ID")}
km
ubah
Biaya perawatan
{rpShort(biaya)}
Pemasukan
{rpShort(masuk)}
Part perlu cek
{alerts}
Servis terakhir: {terakhir ? `${fmtDate(terakhir.tanggal)} — ${terakhir.deskripsi}` : "belum ada"}
onEdit(u)}>
Ubah
onDelete(u)}>
Hapus
);
})
)}
>
);
}
function ServiceList({ data, unitById, visibleUnits, onAdd, onDelete }) {
const ids = new Set(visibleUnits.map((u) => u.id));
const list = data.services
.filter((s) => ids.has(s.unitId))
.slice()
.sort((a, b) => (a.tanggal < b.tanggal ? 1 : -1));
const total = list.reduce((a, s) => a + s.biaya, 0);
return (
<>
Riwayat perawatan
+ Catatan
{list.length} catatan
{rp(total)}
{list.length === 0 ? (
) : (
list.map((s) => {
const u = unitById[s.unitId];
return (
{u ? u.kode : "—"}
{s.jenis}
{fmtDate(s.tanggal)}
{s.deskripsi || "—"}
{km(s.odometer)} · {s.vendor || "vendor tidak dicatat"}
{rp(s.biaya)}
onDelete(s)}>
hapus
);
})
)}
>
);
}
function PartList({ data, unitById, visibleUnits, onAdd, onGanti, onEdit, onDelete }) {
const ids = new Set(visibleUnits.map((u) => u.id));
const list = data.parts
.filter((p) => ids.has(p.unitId))
.map((p) => ({ p, st: partStatus(p, unitById[p.unitId]) }))
.sort((a, b) => b.st.pct - a.st.pct);
return (
<>
Umur pakai part
+ Part
{list.length === 0 ? (
) : (
list.map(({ p, st }) => {
const u = unitById[p.unitId];
return (
{p.nama}
{st.level === "lewat" ? "Lewat umur" : st.level === "segera" ? "Segera ganti" : "Aman"}
{u ? `${u.kode} · ${u.model}` : "unit terhapus"} · dipasang {fmtDate(p.tglPasang)} di {km(p.kmPasang)}
{Math.round(st.pct * 100)}% terpakai
{p.lifetimeKm > 0
? st.sisaKm >= 0
? `sisa ${km(st.sisaKm)}`
: `lewat ${km(-st.sisaKm)}`
: "—"}
{p.lifetimeBulan > 0
? st.sisaHari >= 0
? `sisa ${st.sisaHari} hari`
: `lewat ${-st.sisaHari} hari`
: "—"}
{p.harga ? rp(p.harga) : ""}
onGanti(p)}>
Catat penggantian
onEdit(p)}>
Ubah
onDelete(p)}>
Hapus
);
})
)}
>
);
}
function Money({ data, unitById, visibleUnits, onAddIncome, onAddLoan, onBayar, onBatal, onDelIncome, onDelLoan }) {
const [sub, setSub] = useState("masuk");
const ids = new Set(visibleUnits.map((u) => u.id));
const incomes = data.incomes
.filter((i) => !i.unitId || ids.has(i.unitId))
.slice()
.sort((a, b) => (a.tanggal < b.tanggal ? 1 : -1));
const totalMasuk = incomes.reduce((a, i) => a + i.jumlah, 0);
return (
<>
setSub("masuk")}>
Pemasukan
setSub("angsuran")}>
Angsuran
{sub === "masuk" ? (
<>
Pemasukan
+ Pemasukan
{incomes.length} transaksi
{rp(totalMasuk)}
{incomes.length === 0 ? (
) : (
incomes.map((i) => (
{i.unitId && unitById[i.unitId] ? unitById[i.unitId].kode : "UMUM"}
{i.sumber}
{fmtDate(i.tanggal)}
{i.keterangan || "—"}
{rp(i.jumlah)}
onDelIncome(i)}>
hapus
))
)}
>
) : (
<>
Angsuran kendaraan
+ Angsuran
{data.loans.length === 0 ? (
) : (
data.loans.map((l) => {
const st = loanStatus(l);
const u = l.unitId ? unitById[l.unitId] : null;
return (
{l.lembaga || "Pembiayaan"}
{st.lunas ? "Lunas" : st.level === "telat" ? `Telat ${-st.selisih} hari` : `Jatuh tempo ${fmtDate(st.dueISO)}`}
{u ? `${u.kode} · ${u.model}` : "tanpa unit"} · pokok {rpShort(l.pokok)}
{st.paid}/{l.tenor} angsuran
{rp(l.angsuran)}/bln
sisa {rpShort(st.sisaBayar)}
onBayar(l)} disabled={st.lunas}>
Catat bayar ke-{st.paid + 1}
onBatal(l)} disabled={st.paid === 0}>
Batalkan terakhir
onDelLoan(l)}>
Hapus
);
})
)}
>
)}
>
);
}
function KaryawanList({ data, unitById, onAdd, onEdit, onDelete, onSeed, onImport }) {
const list = (data.karyawan || []).slice().sort((a, b) => (a.nrp || "zzz").localeCompare(b.nrp || "zzz") || a.nama.localeCompare(b.nama));
const totalBulanan = list.filter((k) => k.aktif !== false).reduce((a, k) => a + totalGaji(k), 0);
return (
<>
Kru & karyawan
Tempel data
+ Kru
{list.length === 0 ? (
Tempel banyak data sekaligus
Muat contoh kru
) : (
<>
{list.length} orang · beban gaji per bulan
{rp(totalBulanan)}
{list.map((k) => {
const st = simStatus(k);
const u = k.unitId ? unitById[k.unitId] : null;
return (
{k.nama}
{k.jabatan}
{u ?
{u.kode} :
Tanpa unit }
{k.nrp || "NRP belum diisi"} · {k.statusKerja} · masuk{" "}
{fmtDate(k.tglMasuk)} · {k.hp || "kontak belum diisi"}
Gaji pokok
{rpShort(k.gajiPokok)}
Tunjangan
{rpShort(k.tunjangan)}
Total / bulan
{rpShort(totalGaji(k))}
{k.jenisSIM || "SIM tidak dicatat"}
{st ? (
{st.level === "lewat"
? `Habis ${-st.sisaHari} hari lalu`
: st.level === "segera"
? `${st.sisaHari} hari lagi`
: `Berlaku s/d ${fmtDate(k.simExp)}`}
) : (
—
)}
onEdit(k)}>
Ubah
onDelete(k)}>
Hapus
);
})}
>
)}
>
);
}
function MasterData(props) {
const { data, unitById, sub, setSub, user } = props;
const tabs = [
["unit", "Unit", "m_unit"],
["karyawan", "Karyawan", "m_karyawan"],
["merek", "Merek", "m_merek"],
["asuransi", "Asuransi", "m_asuransi"],
["pinjaman", "Pinjaman", "m_pinjaman"],
["aktivitas", "Aktivitas", "aktivitas"],
].filter(([, , hak]) => boleh(user, hak));
if (user && user.peran === "superadmin") {
tabs.push(["pengguna", "Pengguna"]);
tabs.push(["hak", "Hak akses"]);
}
const aktif = tabs.some(([k]) => k === sub) ? sub : (tabs[0] || ["unit"])[0];
return (
<>
{tabs.map(([k, label]) => (
setSub(k)}>
{label}
))}
{aktif === "unit" && (
)}
{aktif === "karyawan" && (
)}
{aktif === "merek" && (
)}
{aktif === "asuransi" && (
)}
{aktif === "pengguna" && user && user.peran === "superadmin" && }
{aktif === "hak" && user && user.peran === "superadmin" && }
{aktif === "aktivitas" && }
{aktif === "pinjaman" && (
)}
>
);
}
function MasterUnit({ data, onAdd, onEdit, onDelete }) {
const list = data.units.slice().sort((a, b) => a.kode.localeCompare(b.kode));
return (
<>
Daftar unit
+ Unit
{list.length === 0 ? (
) : (
list.map((u) => {
const pol = (data.asuransi || []).find((a) => a.id === u.asuransiId);
const st = asuransiStatus(pol);
const loan = data.loans.find((l) => l.unitId === u.id);
return (
{u.kode}
{u.tipe}
{u.nopol || "—"}
Nomor lambung
{u.noLambung || "—"}
Merek / tipe
{[u.merek, u.model].filter(Boolean).join(" ") || "—"}
Tahun
{u.tahun || "—"}
Kode pinjaman
{loan ? loan.kode || loan.lembaga : "—"}
Asuransi
{pol ? `${pol.kode || pol.nomorPolis} · ${pol.jenis}` : "—"}
{st && st.level !== "aman" ? (
{st.level === "lewat" ? "Habis" : `${st.sisaHari} hari`}
) : null}
onEdit(u)}>
Ubah
onDelete(u)}>
Hapus
);
})
)}
>
);
}
function MasterMerek({ data, onAdd, onEdit, onDelete }) {
const list = (data.merek || []).slice().sort((a, b) => a.nama.localeCompare(b.nama));
return (
<>
Merek kendaraan
+ Merek
{list.length === 0 ? (
) : (
list.map((m) => {
const dipakai = data.units.filter((u) => u.merek === m.nama).length;
return (
{m.nama}
{m.tipe}
{m.keterangan || "—"}
{m.agen || "agen belum diisi"} · dipakai {dipakai} unit
onEdit(m)}>
ubah
onDelete(m)}>
hapus
);
})
)}
>
);
}
function MasterAsuransi({ data, onAdd, onEdit, onDelete }) {
const list = (data.asuransi || []).slice().sort((a, b) => (a.berakhir || "").localeCompare(b.berakhir || ""));
const totalPremi = list.reduce((a, x) => a + (Number(x.premi) || 0), 0);
return (
<>
Polis asuransi
+ Polis
{list.length === 0 ? (
) : (
<>
{list.length} polis · premi per tahun
{rp(totalPremi)}
{list.map((a) => {
const st = asuransiStatus(a);
const unit = data.units.find((u) => u.asuransiId === a.id);
return (
{a.kode || a.nomorPolis}
{a.jenis}
{st ? (
{st.level === "lewat"
? `Habis ${-st.sisaHari} hari lalu`
: st.level === "segera"
? `${st.sisaHari} hari lagi`
: `s/d ${fmtDate(a.berakhir)}`}
) : (
Tanpa masa berlaku
)}
{a.penanggung || "penanggung belum diisi"} · polis {a.nomorPolis || "—"}
Unit
{unit ? unit.kode : "belum dipasangkan"}
Premi
{a.premi ? rp(a.premi) : "—"}
Periode
{fmtDate(a.mulai)} — {fmtDate(a.berakhir)}
{a.keterangan ? {a.keterangan}
: null}
onEdit(a)}>
Ubah
onDelete(a)}>
Hapus
);
})}
>
)}
>
);
}
function MasterPinjaman({ data, unitById, onAdd, onEdit, onDelete }) {
const list = data.loans.slice();
return (
<>
Kode pinjaman
+ Pinjaman
{list.length === 0 ? (
) : (
list.map((l) => {
const st = loanStatus(l);
const u = l.unitId ? unitById[l.unitId] : null;
return (
{l.kode || "Tanpa kode"}
{u ?
{u.kode} :
Tanpa unit }
{l.lembaga || "lembaga belum diisi"}
Pokok
{rp(l.pokok)}
Angsuran
{rp(l.angsuran)} / bulan
Tenor
{l.tenor} bulan · terbayar {st.paid}
Jatuh tempo
tiap tanggal {l.tglJatuhTempo}
onEdit(l)}>
Ubah
onDelete(l)}>
Hapus
);
})
)}
>
);
}
function PenggunaList({ user, data }) {
const PERAN = [
["superadmin", "Super Admin"],
["admin", "Admin"],
["staff", "Staff"],
];
const labelPeran = (p) => (PERAN.find((x) => x[0] === p) || [p, p])[1];
const [list, setList] = useState([]);
const [muat, setMuat] = useState(true);
const [pesan, setPesan] = useState("");
const [galat, setGalat] = useState("");
const [form, setForm] = useState(null);
const [sandiku, setSandiku] = useState(null);
const ambil = async () => {
try {
setList(await api("users"));
setGalat("");
} catch (e) {
setGalat(e.message);
}
setMuat(false);
};
useEffect(() => {
ambil();
}, []);
const simpan = async () => {
try {
await api("user_save", form);
setForm(null);
setPesan("Pengguna tersimpan.");
ambil();
} catch (e) {
setGalat(e.message);
}
};
const buang = async (u) => {
try {
await api("user_delete", { id: u.id });
setPesan(`Pengguna ${u.username} dihapus.`);
ambil();
} catch (e) {
setGalat(e.message);
}
};
const gantiSandi = async () => {
try {
await api("ganti_sandi", sandiku);
setSandiku(null);
setPesan("Kata sandi diperbarui.");
} catch (e) {
setGalat(e.message);
}
};
const lemah = form && form.password && form.password.length < 6;
return (
<>
Akun aplikasi
setSandiku({ lama: "", baru: "" })}>
Sandi saya
setForm({ peran: "admin", password: SANDI_BAWAAN.admin, wajib_ganti: true })}
>
+ Pengguna
{galat ? {galat}
: null}
{pesan ? {pesan}
: null}
Setiap orang masuk memakai NRP-nya. Pilih dari data kru agar NRP dan namanya sama persis dengan Master →
Karyawan.
{muat ? (
) : (
list.map((u) => (
{u.username}
{labelPeran(u.peran)}
{u.nama}
{(data.karyawan || []).some((k) => (k.nrp || "").toUpperCase() === (u.username || "").toUpperCase())
? ""
: " · NRP di luar data kru"}
dibuat {fmtDate((u.dibuat || "").slice(0, 10))}
{u.username === user.username ? " · akun yang sedang dipakai" : ""}
{Number(u.wajib_ganti) ? " · masih memakai sandi bawaan" : ""}
setForm({ id: u.id, username: u.username, nama: u.nama, peran: u.peran })}
>
ubah
{u.username === user.username ? null : (
buang(u)}>
hapus
)}
))
)}
Arti peran
Super Admin
Semua data + kelola pengguna
Admin
Petugas input: isi & ubah seluruh data
Staff
Pengawas: lihat semua + catatan aktivitas
setForm(null)}
footer={
<>
setForm(null)}>
Batal
Simpan pengguna
>
}
>
{form ? (
<>
{
const k = (data.karyawan || []).find((x) => x.id === e.target.value);
if (k) setForm({ ...form, username: k.nrp || "", nama: k.nama });
}}
>
— Isi manual —
{(data.karyawan || [])
.filter((k) => k.nrp)
.map((k) => (
{k.nrp} — {k.nama} ({k.jabatan})
))}
setForm({ ...form, username: e.target.value })}
autoComplete="off"
placeholder="NRP-0004"
/>
setForm({ ...form, nama: e.target.value })} />
{
const baru = e.target.value;
const isiLama = form.password || "";
const otomatis = isiLama === "" || Object.values(SANDI_BAWAAN).includes(isiLama);
setForm({
...form,
peran: baru,
password: form.id ? form.password : otomatis ? SANDI_BAWAAN[baru] || "" : isiLama,
});
}}
>
{PERAN.map(([v, l]) => (
{l}
))}
setForm({ ...form, password: e.target.value })}
autoComplete="new-password"
/>
setForm({ ...form, wajib_ganti: e.target.checked })}
/>
Minta ganti sandi saat pertama masuk
{lemah && !form.wajib_ganti ? (
Sandi sependek ini bisa ditebak dalam hitungan detik oleh robot yang memindai internet. Centang
pilihan di atas agar penggunanya wajib menggantinya begitu masuk.
) : null}
>
) : null}
setSandiku(null)}
footer={
<>
setSandiku(null)}>
Batal
Simpan sandi
>
}
>
{sandiku ? (
<>
setSandiku({ ...sandiku, lama: e.target.value })}
autoComplete="current-password"
/>
setSandiku({ ...sandiku, baru: e.target.value })}
autoComplete="new-password"
/>
>
) : null}
>
);
}
function HakAkses() {
const KELOMPOK = [
["Menu utama", ["ringkas", "unit", "servis", "part", "keuangan", "master"]],
["Isi menu Master", ["m_unit", "m_karyawan", "m_merek", "m_asuransi", "m_pinjaman", "aktivitas"]],
["Wewenang data", ["tambah", "hapus"]],
];
const PERAN = [
["admin", "Admin"],
["staff", "Staff"],
];
const [peran, setPeran] = useState("admin");
const [semua, setSemua] = useState({});
const [muat, setMuat] = useState(true);
const [galat, setGalat] = useState("");
const [pesan, setPesan] = useState("");
const ambil = async () => {
try {
const d = await api("hak");
setSemua(d.peran);
setGalat("");
} catch (e) {
setGalat(e.message);
}
setMuat(false);
};
useEffect(() => {
ambil();
}, []);
const aktif = semua[peran] || [];
const alih = (k) => {
const baru = aktif.includes(k) ? aktif.filter((x) => x !== k) : [...aktif, k];
setSemua({ ...semua, [peran]: baru });
setPesan("");
};
const simpan = async () => {
try {
await api("hak_save", { peran, hak: aktif });
setPesan(`Hak akses ${peran === "admin" ? "Admin" : "Staff"} tersimpan. Berlaku saat orangnya masuk lagi.`);
setGalat("");
} catch (e) {
setGalat(e.message);
}
};
return (
<>
Hak akses peran
Super Admin selalu memegang seluruh menu dan wewenang, termasuk halaman ini, dan tidak bisa dikurangi.
Atur peran lain di bawah.
{PERAN.map(([v, l]) => (
setPeran(v)}>
{l}
))}
{galat ? {galat}
: null}
{pesan ? {pesan}
: null}
{muat ? (
) : (
<>
{KELOMPOK.map(([judul, keys]) => (
{judul}
{keys.map((k) => (
alih(k)} />
{NAMA_HAK[k]}
))}
))}
Mencabut "Tambah & ubah" sekaligus "Hapus" membuat peran itu hanya bisa melihat —
server akan menolak setiap penyimpanan darinya.
Simpan hak akses
>
)}
>
);
}
function Aktivitas() {
const [list, setList] = useState([]);
const [muat, setMuat] = useState(true);
const [galat, setGalat] = useState("");
const ambil = async () => {
setMuat(true);
try {
setList(await api("log"));
setGalat("");
} catch (e) {
setGalat(e.message);
}
setMuat(false);
};
useEffect(() => {
ambil();
}, []);
const jam = (w) => {
if (!w) return "—";
const dt = new Date(w.replace(" ", "T"));
if (isNaN(dt)) return w;
return dt.toLocaleString("id-ID", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
};
return (
<>
Catatan aktivitas
Muat ulang
{galat ? {galat}
: null}
{muat ? (
) : list.length === 0 ? (
) : (
<>
{list.length} perubahan terakhir
{list.map((l, i) => (
{l.username}
{l.peran}
{jam(l.waktu)}
{l.ringkasan}
))}
>
)}
>
);
}
/* ============================== ICONS ============================== */
function TabIcon({ name }) {
const c = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" };
if (name === "dash")
return (
);
if (name === "unit")
return (
);
if (name === "servis")
return (
);
if (name === "master")
return (
);
if (name === "kru")
return (
);
if (name === "part")
return (
);
return (
);
}
/* ============================== STYLE ============================== */
function Style() {
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 (
);
}
function WajibGanti({ user, onSelesai, onLogout }) {
const [lama, setLama] = useState("");
const [baru, setBaru] = useState("");
const [ulang, setUlang] = useState("");
const [err, setErr] = useState("");
const [sibuk, setSibuk] = useState(false);
const simpan = async () => {
if (baru.length < 4) {
setErr("Kata sandi baru minimal 4 karakter.");
return;
}
if (baru !== ulang) {
setErr("Ulangi kata sandi belum sama.");
return;
}
setSibuk(true);
setErr("");
try {
await api("ganti_sandi", { lama, baru });
onSelesai();
} catch (e) {
setErr(e.message);
setSibuk(false);
}
};
return (
);
}
function App() {
const [user, setUser] = useState(null);
const [cek, setCek] = useState(true);
useEffect(() => {
(async () => {
try {
setUser(await api("me"));
} catch (e) {
/* belum masuk */
}
setCek(false);
})();
}, []);
const keluar = async () => {
try {
await api("logout", {});
} catch (e) {
/* abaikan */
}
setUser(null);
};
if (cek)
return (
);
if (!user) return ;
if (user.wajib_ganti)
return (
{
try {
setUser(await api("me"));
} catch (e) {
setUser({ ...user, wajib_ganti: 0 });
}
}}
/>
);
return ;
}
ReactDOM.createRoot(document.getElementById("root")).render( );