478 lines
16 KiB
JavaScript
478 lines
16 KiB
JavaScript
document.addEventListener("DOMContentLoaded", () => {
|
|
const $ = (id) => document.getElementById(id);
|
|
const isDash = !!$("val_raw_banana");
|
|
const isHist = !!$("historyTableBody");
|
|
const isFoto = !!$("fotoGrid");
|
|
|
|
let last = null,
|
|
active = false,
|
|
timer = null,
|
|
startAt = null;
|
|
let hf = { mode: "session", sessionId: "", dateFrom: "", dateTo: "" };
|
|
let ff = { mode: "session", sessionId: "", dateFrom: "", dateTo: "" };
|
|
|
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
|
|
const e = {
|
|
rb: $("val_raw_banana"),
|
|
rpb: $("val_ripe_banana"),
|
|
ro: $("val_raw_orange"),
|
|
rpo: $("val_ripe_orange"),
|
|
tbody: $("historyTableBody"),
|
|
btn: $("btnSorting"),
|
|
ico: $("btnSortIcon"),
|
|
lbl: $("btnSortLabel"),
|
|
dot: $("indicatorDot"),
|
|
txt: $("indicatorText"),
|
|
clock: $("sessionTimer"),
|
|
refresh: $("btnRefresh"),
|
|
};
|
|
|
|
const fs = $("filterSessionId"),
|
|
fr = $("customDateRange"),
|
|
fd = $("filterDateFrom"),
|
|
ft = $("filterDateTo"),
|
|
fa = $("btnApplyFilter");
|
|
const ffs = $("filterFotoSessionId"),
|
|
ffr = $("customFotoDateRange"),
|
|
ffd = $("filterFotoDateFrom"),
|
|
fft = $("filterFotoDateTo"),
|
|
ffa = $("btnApplyFotoFilter"),
|
|
fl = $("filterFotoLabel"),
|
|
rf = $("btnRefreshFoto");
|
|
const grid = $("fotoGrid"),
|
|
modal = $("fotoModal"),
|
|
mimg = $("fotoModalImg"),
|
|
mlbl = $("fotoModalLabel"),
|
|
mtime = $("fotoModalTime"),
|
|
mclose = $("fotoModalClose");
|
|
const cm = $("confirmModal"),
|
|
mc = $("modalBtnConfirm"),
|
|
mx = $("modalBtnCancel"),
|
|
toast = $("toastContainer");
|
|
|
|
function ui(a, s = null) {
|
|
if (!e.btn) return;
|
|
active = a;
|
|
e.btn.disabled = false;
|
|
if (a) {
|
|
e.btn.className = "btn-sorting btn-stop";
|
|
e.ico.innerHTML = '<i class="fas fa-stop"></i>';
|
|
e.lbl.textContent = "Stop Sortir";
|
|
e.dot.className = "indicator-dot active";
|
|
e.txt.className = "indicator-text active";
|
|
e.txt.textContent = "Sortir Berjalan";
|
|
if (s) {
|
|
startAt = new Date(s);
|
|
runTimer();
|
|
}
|
|
} else {
|
|
e.btn.className = "btn-sorting btn-start";
|
|
e.ico.innerHTML = '<i class="fas fa-play"></i>';
|
|
e.lbl.textContent = "Mulai Sortir";
|
|
e.dot.className = "indicator-dot idle";
|
|
e.txt.className = "indicator-text";
|
|
e.txt.textContent = "Siap";
|
|
e.clock.textContent = "";
|
|
startAt = null;
|
|
clearInterval(timer);
|
|
}
|
|
}
|
|
|
|
function runTimer() {
|
|
clearInterval(timer);
|
|
timer = setInterval(() => {
|
|
if (!startAt || !e.clock) return;
|
|
let d = Math.floor((new Date() - startAt) / 1000),
|
|
h = String(Math.floor(d / 3600)).padStart(2, "0"),
|
|
m = String(Math.floor((d % 3600) / 60)).padStart(2, "0"),
|
|
s = String(d % 60).padStart(2, "0");
|
|
e.clock.textContent = "⏱ " + h + ":" + m + ":" + s;
|
|
}, 1000);
|
|
}
|
|
|
|
async function status() {
|
|
try {
|
|
let r = await fetch("/api/session/status"),
|
|
d = await r.json();
|
|
ui(d.active, d.session?.started_at);
|
|
} catch {
|
|
ui(false);
|
|
}
|
|
}
|
|
|
|
function note(t, m, type = "info") {
|
|
if (!toast) return;
|
|
let n = document.createElement("div");
|
|
n.className = "toast toast-" + type;
|
|
n.innerHTML =
|
|
'<div class="toast-icon"><i class="fas fa-info-circle"></i></div><div class="toast-body"><div class="toast-title">' +
|
|
t +
|
|
'</div><div class="toast-message">' +
|
|
m +
|
|
'</div></div><button class="toast-close"><i class="fas fa-times"></i></button>';
|
|
n.querySelector("button").onclick = () => n.remove();
|
|
toast.appendChild(n);
|
|
setTimeout(() => n.remove(), 4000);
|
|
}
|
|
|
|
function ask() {
|
|
return new Promise((ok) => {
|
|
if (!cm) return ok(true);
|
|
cm.classList.add("show");
|
|
mc.onclick = () => {
|
|
cm.classList.remove("show");
|
|
ok(true);
|
|
};
|
|
mx.onclick = () => {
|
|
cm.classList.remove("show");
|
|
ok(false);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function start() {
|
|
if (!e.btn) return;
|
|
e.btn.disabled = true;
|
|
try {
|
|
let r = await fetch("/api/session/start", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-CSRF-TOKEN": csrf,
|
|
},
|
|
}),
|
|
d = await r.json();
|
|
d.success
|
|
? (ui(true, d.session.started_at),
|
|
load(),
|
|
note("Sortir Dimulai", "Sesi dimulai", "success"))
|
|
: (note("Gagal", d.message || "Gagal", "error"), ui(false));
|
|
} catch {
|
|
note("Error", "Server gagal", "error");
|
|
ui(false);
|
|
}
|
|
}
|
|
|
|
async function stop() {
|
|
if (!(await ask())) return;
|
|
if (!e.btn) return;
|
|
e.btn.disabled = true;
|
|
try {
|
|
let r = await fetch("/api/session/stop", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-CSRF-TOKEN": csrf,
|
|
},
|
|
}),
|
|
d = await r.json();
|
|
d.success
|
|
? (ui(false),
|
|
load(),
|
|
note("Sortir Selesai", "Data dikalkulasi", "success"))
|
|
: (note("Gagal", d.message || "Gagal", "error"), status());
|
|
} catch {
|
|
note("Error", "Server gagal", "error");
|
|
status();
|
|
}
|
|
}
|
|
|
|
if (e.btn) e.btn.onclick = () => (active ? stop() : start());
|
|
|
|
function tabs(q, state, sel, custom, cb) {
|
|
document.querySelectorAll(q).forEach(
|
|
(b) =>
|
|
(b.onclick = () => {
|
|
document
|
|
.querySelectorAll(q)
|
|
.forEach((x) => x.classList.remove("active"));
|
|
b.classList.add("active");
|
|
state.mode = b.dataset.filter;
|
|
if (sel)
|
|
sel.style.display =
|
|
state.mode === "session" ? "" : "none";
|
|
if (custom)
|
|
custom.style.display =
|
|
state.mode === "custom" ? "flex" : "none";
|
|
cb();
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (isHist || isDash) {
|
|
tabs(
|
|
"#tabSession,#tabToday,#tabWeek,#tabMonth,#tabCustom",
|
|
hf,
|
|
fs,
|
|
fr,
|
|
load,
|
|
);
|
|
if (fs) {
|
|
fs.style.display = "";
|
|
fs.onchange = () => {
|
|
hf.sessionId = fs.value;
|
|
load();
|
|
};
|
|
}
|
|
if (fa) {
|
|
fa.onclick = () => {
|
|
hf.dateFrom = fd.value;
|
|
hf.dateTo = ft.value;
|
|
load();
|
|
};
|
|
}
|
|
}
|
|
|
|
if (isFoto || isDash) {
|
|
tabs(
|
|
"#tabFotoSession,#tabFotoToday,#tabFotoWeek,#tabFotoMonth,#tabFotoCustom",
|
|
ff,
|
|
ffs,
|
|
ffr,
|
|
loadFoto,
|
|
);
|
|
if (ffs) {
|
|
ffs.style.display = "";
|
|
ffs.onchange = () => {
|
|
ff.sessionId = ffs.value;
|
|
loadFoto();
|
|
};
|
|
}
|
|
if (ffa) {
|
|
ffa.onclick = () => {
|
|
ff.dateFrom = ffd.value;
|
|
ff.dateTo = fft.value;
|
|
loadFoto();
|
|
};
|
|
}
|
|
}
|
|
|
|
function qs(st) {
|
|
let p = new URLSearchParams({ filter: st.mode });
|
|
if (st.mode === "session" && st.sessionId)
|
|
p.set("session_id", st.sessionId);
|
|
if (st.mode === "custom") {
|
|
if (st.dateFrom) p.set("date_from", st.dateFrom);
|
|
if (st.dateTo) p.set("date_to", st.dateTo);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
function fillSessions(s = []) {
|
|
[fs, ffs].forEach((sel) => {
|
|
if (!sel) return;
|
|
let v = sel.value;
|
|
sel.innerHTML = '<option value="">Sesi Aktif / Terakhir</option>';
|
|
s.forEach((x) => {
|
|
let o = document.createElement("option");
|
|
o.value = x.id;
|
|
o.textContent =
|
|
new Date(x.started_at).toLocaleString("id-ID") +
|
|
" (" +
|
|
(x.total_records || 0) +
|
|
" buah)";
|
|
sel.appendChild(o);
|
|
});
|
|
sel.value = v;
|
|
});
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
let r = await fetch("/api/latest-data?" + qs(hf)),
|
|
d = await r.json();
|
|
if (d.sessions) fillSessions(d.sessions);
|
|
draw(d);
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
function anim(o, a, b) {
|
|
if (!o) return;
|
|
let st = null;
|
|
requestAnimationFrame(function f(t) {
|
|
if (!st) st = t;
|
|
let p = Math.min((t - st) / 800, 1);
|
|
o.textContent = (a + (b - a) * p).toFixed(2);
|
|
if (p < 1) requestAnimationFrame(f);
|
|
});
|
|
}
|
|
|
|
function draw(d) {
|
|
// Update Ringkasan Kalkulasi Sesi secara dinamis
|
|
if (d.totals) {
|
|
const getVal = (v) => (v !== undefined && v !== null) ? Number(v).toFixed(2) : "0.00";
|
|
const getCount = (v) => (v !== undefined && v !== null) ? Number(v) : "0";
|
|
|
|
const sumRawBanana = $("sum_raw_banana");
|
|
const sumRipeBanana = $("sum_ripe_banana");
|
|
const sumRawOrange = $("sum_raw_orange");
|
|
const sumRipeOrange = $("sum_ripe_orange");
|
|
const sumTotalRecords = $("sum_total_records");
|
|
const sumGrandTotal = $("sum_grand_total");
|
|
|
|
if (sumRawBanana) sumRawBanana.textContent = getVal(d.totals.total_raw_banana_weight);
|
|
if (sumRipeBanana) sumRipeBanana.textContent = getVal(d.totals.total_ripe_banana_weight);
|
|
if (sumRawOrange) sumRawOrange.textContent = getVal(d.totals.total_raw_orange_weight);
|
|
if (sumRipeOrange) sumRipeOrange.textContent = getVal(d.totals.total_ripe_orange_weight);
|
|
if (sumTotalRecords) sumTotalRecords.textContent = getCount(d.totals.total_records);
|
|
|
|
if (sumGrandTotal) {
|
|
const grandTotal = Number(d.totals.total_raw_banana_weight || 0) +
|
|
Number(d.totals.total_ripe_banana_weight || 0) +
|
|
Number(d.totals.total_raw_orange_weight || 0) +
|
|
Number(d.totals.total_ripe_orange_weight || 0);
|
|
sumGrandTotal.textContent = grandTotal.toFixed(2);
|
|
}
|
|
}
|
|
|
|
if (!d.latest) {
|
|
if (isHist || isDash)
|
|
e.tbody.innerHTML =
|
|
'<tr><td colspan="5" class="empty-state">Belum ada riwayat klasifikasi.</td></tr>';
|
|
if (isDash)
|
|
[e.rb, e.rpb, e.ro, e.rpo].forEach(
|
|
(x) => (x.textContent = "0.00"),
|
|
);
|
|
return;
|
|
}
|
|
let id = d.latest.id || d.latest._id;
|
|
if (id !== last) {
|
|
last = id;
|
|
if (isDash) {
|
|
anim(
|
|
e.rb,
|
|
+e.rb.textContent || 0,
|
|
+d.latest.raw_banana_weight || 0,
|
|
);
|
|
anim(
|
|
e.rpb,
|
|
+e.rpb.textContent || 0,
|
|
+d.latest.ripe_banana_weight || 0,
|
|
);
|
|
anim(
|
|
e.ro,
|
|
+e.ro.textContent || 0,
|
|
+d.latest.raw_orange_weight || 0,
|
|
);
|
|
anim(
|
|
e.rpo,
|
|
+e.rpo.textContent || 0,
|
|
+d.latest.ripe_orange_weight || 0,
|
|
);
|
|
setTimeout(
|
|
() =>
|
|
[e.rb, e.rpb, e.ro, e.rpo].forEach((x) =>
|
|
anim(x, +x.textContent || 0, 0),
|
|
),
|
|
4000,
|
|
);
|
|
}
|
|
}
|
|
if (isHist || isDash) {
|
|
e.tbody.innerHTML = "";
|
|
(d.history || []).forEach((i) => {
|
|
let tr = document.createElement("tr");
|
|
tr.innerHTML =
|
|
"<td>" +
|
|
new Date(i.classification_date).toLocaleString("id-ID") +
|
|
"</td><td>" +
|
|
i.raw_banana_weight +
|
|
"</td><td>" +
|
|
i.ripe_banana_weight +
|
|
"</td><td>" +
|
|
i.raw_orange_weight +
|
|
"</td><td>" +
|
|
i.ripe_orange_weight +
|
|
"</td>";
|
|
e.tbody.appendChild(tr);
|
|
});
|
|
if (!(d.history || []).length)
|
|
e.tbody.innerHTML =
|
|
'<tr><td colspan="5" class="empty-state">Belum ada riwayat klasifikasi.</td></tr>';
|
|
}
|
|
}
|
|
|
|
async function loadFoto() {
|
|
if (!grid) return;
|
|
try {
|
|
let p = qs(ff);
|
|
if (fl && fl.value) p.set("label", fl.value);
|
|
let r = await fetch("/api/foto?" + p),
|
|
d = await r.json();
|
|
if (!d.fotos?.length) {
|
|
grid.innerHTML =
|
|
'<div class="foto-empty">Belum ada foto tersimpan.</div>';
|
|
return;
|
|
}
|
|
grid.innerHTML = d.fotos
|
|
.map(
|
|
(f) =>
|
|
'<div class="foto-card" data-b="' +
|
|
f.image_base64 +
|
|
'" data-l="' +
|
|
f.label +
|
|
'" data-t="' +
|
|
f.captured_at +
|
|
'"><img src="data:image/jpeg;base64,' +
|
|
f.image_base64 +
|
|
'"><div class="foto-card-info"><span class="foto-card-label ' +
|
|
(f.label.includes("matang") ? "matang" : "mentah") +
|
|
'">' +
|
|
f.label +
|
|
'</span><span class="foto-card-time">' +
|
|
new Date(f.captured_at).toLocaleString("id-ID") +
|
|
"</span></div></div>",
|
|
)
|
|
.join("");
|
|
|
|
if (modal) {
|
|
grid.querySelectorAll(".foto-card").forEach(
|
|
(c) =>
|
|
(c.onclick = () => {
|
|
if (mimg)
|
|
mimg.src =
|
|
"data:image/jpeg;base64," + c.dataset.b;
|
|
if (mlbl) mlbl.textContent = c.dataset.l;
|
|
if (mtime)
|
|
mtime.textContent = new Date(
|
|
c.dataset.t,
|
|
).toLocaleString("id-ID");
|
|
modal.classList.add("show");
|
|
}),
|
|
);
|
|
}
|
|
} catch {
|
|
grid.innerHTML = '<div class="foto-empty">Gagal memuat foto.</div>';
|
|
}
|
|
}
|
|
|
|
if (e.refresh) e.refresh.onclick = load;
|
|
if (rf) rf.onclick = loadFoto;
|
|
if (fl) fl.onchange = loadFoto;
|
|
|
|
if (mclose) mclose.onclick = () => modal.classList.remove("show");
|
|
if (modal)
|
|
modal.onclick = (x) => {
|
|
if (x.target === modal) modal.classList.remove("show");
|
|
};
|
|
|
|
status();
|
|
|
|
if (isDash || isHist) {
|
|
(async function p() {
|
|
await load();
|
|
setTimeout(p, 1000);
|
|
})();
|
|
}
|
|
|
|
if (isDash || isFoto) {
|
|
(async function pf() {
|
|
await loadFoto();
|
|
setTimeout(pf, 5000);
|
|
})();
|
|
}
|
|
});
|