eSPJ/wwwroot/driver/js/detail-penjemputan-tps.js

1958 lines
64 KiB
JavaScript

const DetailPenjemputan = (function () {
"use strict";
const CONFIG = {
OCR_AREAS: [
{
id: "A",
x: 0.34,
y: 0.35,
w: 0.4,
h: 0.11,
color: "border-lime-400 bg-lime-500/15",
},
{
id: "B",
x: 0.31,
y: 0.33,
w: 0.45,
h: 0.14,
color: "border-amber-300 bg-amber-400/10",
},
{
id: "C",
x: 0.29,
y: 0.31,
w: 0.49,
h: 0.17,
color: "border-cyan-300 bg-cyan-400/10",
},
],
JENIS_SAMPAH: ["Organik", "Anorganik", "Residu"],
DEFAULT_JENIS: "Residu",
};
let state = {
activeTpsIndex: 0,
tpsData: [],
availableTpsList: [],
selectedTpsList: [],
hasRequestedLocation: [],
nomorSpj: "SPJ/07-2025/PKM/000476",
};
const STORAGE_KEY = "detailPenjemputanTpsState";
function saveState() {
try {
const stateCopy = JSON.parse(
JSON.stringify(state, (key, value) => {
if (value instanceof File) {
return { name: value.name, size: value.size, type: value.type };
}
return value;
}),
);
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateCopy));
} catch (e) {
console.warn("Failed to save state to localStorage:", e);
}
}
function loadState() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
state = { ...state, ...parsed };
}
} catch (e) {
console.warn("Failed to load state from localStorage:", e);
}
}
function clearState() {
localStorage.removeItem(STORAGE_KEY);
}
function isBrowserFile(value) {
return typeof File !== "undefined" && value instanceof File;
}
function getFirstValue(value) {
return Array.isArray(value) ? value[0] : value;
}
function resolveStoredPhoto(value) {
if (!value) {
return null;
}
if (isBrowserFile(value)) {
return value;
}
if (typeof value === "string") {
return {
url: value,
name: value.split("/").pop() || "Foto",
size: 0,
source: "api",
};
}
if (typeof value !== "object") {
return null;
}
const url =
value.url ||
value.fileUrl ||
value.path ||
value.filePath ||
value.src ||
value.fotoUrl ||
value.photoUrl ||
value.fotoFileName ||
value.fileName ||
"";
return {
...value,
url,
name:
value.name ||
value.fileName ||
value.file_name ||
(url ? url.split("/").pop() : "Foto"),
size: Number(value.size || value.fileSize || 0) || 0,
source: value.source || "api",
};
}
function getStoredPhotoUrl(value) {
const photo = resolveStoredPhoto(value);
if (!photo) {
return "";
}
if (isBrowserFile(photo)) {
return URL.createObjectURL(photo);
}
return photo.url || "";
}
function getStoredPhotoName(value, fallbackName = "Foto") {
const photo = resolveStoredPhoto(value);
if (!photo) {
return fallbackName;
}
return isBrowserFile(photo)
? photo.name || fallbackName
: photo.name || fallbackName;
}
function getStoredPhotoSize(value) {
const photo = resolveStoredPhoto(value);
if (!photo) {
return 0;
}
return isBrowserFile(photo)
? photo.size || 0
: Number(photo.size || 0) || 0;
}
function hasStoredPhoto(value) {
const photo = resolveStoredPhoto(value);
if (!photo) {
return false;
}
return isBrowserFile(photo) || Boolean(photo.url || photo.name);
}
function normalizePhotoList(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map(resolveStoredPhoto).filter(Boolean);
}
function normalizeTimbanganItem(item) {
if (!item) {
return {
file: null,
weight: 0,
jenisSampah: CONFIG.DEFAULT_JENIS,
uploaded: false,
ocrInfo: "OCR: belum diproses.",
};
}
const file = resolveStoredPhoto(
item.file ||
item.foto ||
item.photo ||
item.fotoUrl ||
item.photoUrl ||
item.fotoFileName,
);
const weight = Number(item.weight ?? getFirstValue(item.berat) ?? 0) || 0;
const jenisSampah =
getFirstValue(item.jenisSampah) ||
item.JenisSampah ||
CONFIG.DEFAULT_JENIS;
const uploaded = Boolean(
item.uploaded ??
item.isUploaded ??
item.IsUploaded ??
hasStoredPhoto(file),
);
return {
...item,
file,
weight,
jenisSampah,
uploaded,
ocrInfo:
item.ocrInfo ||
item.OcrInfo ||
(uploaded ? "Foto dari server." : "OCR: belum diproses."),
};
}
function findMatchingApiTps(apiList, currentTps, index) {
return (
apiList.find(
(item) =>
(currentTps.spjDetailId &&
(item.spjDetailId === currentTps.spjDetailId ||
item.SpjDetailID === currentTps.spjDetailId)) ||
(currentTps.lokasiAngkutId &&
(item.lokasiAngkutId === currentTps.lokasiAngkutId ||
item.LokasiAngkutID === currentTps.lokasiAngkutId)) ||
(item.name || item.Name) === currentTps.name,
) || apiList[index]
);
}
function applyApiDraftData(draftData) {
const apiList = Array.isArray(draftData)
? draftData
: draftData?.tpsData || draftData?.draftPenjemputan || [];
if (!Array.isArray(apiList) || apiList.length === 0) {
return;
}
if (!state.tpsData.length) {
initializeTpsData(apiList);
}
state.tpsData = state.tpsData.map((currentTps, index) => {
const apiTps = findMatchingApiTps(apiList, currentTps, index);
if (!apiTps) {
return currentTps;
}
const fotoKedatangan = normalizePhotoList(
apiTps.fotoKedatangan || apiTps.FotoKedatangan,
);
const fotoPetugas = normalizePhotoList(
apiTps.fotoPetugas || apiTps.FotoPetugas,
);
const apiTimbangan = apiTps.timbangan || apiTps.Timbangan;
const timbangan = Array.isArray(apiTimbangan)
? apiTimbangan.map(normalizeTimbanganItem)
: currentTps.timbangan;
return {
...currentTps,
name: apiTps.name || apiTps.Name || currentTps.name,
lokasiAngkutId:
apiTps.lokasiAngkutId ||
apiTps.LokasiAngkutID ||
currentTps.lokasiAngkutId,
spjDetailId:
apiTps.spjDetailId || apiTps.SpjDetailID || currentTps.spjDetailId,
latitude: apiTps.latitude || apiTps.Latitude || currentTps.latitude,
longitude: apiTps.longitude || apiTps.Longitude || currentTps.longitude,
alamatJalan:
apiTps.alamatJalan || apiTps.AlamatJalan || currentTps.alamatJalan,
waktuKedatangan:
apiTps.waktuKedatangan ||
apiTps.WaktuKedatangan ||
currentTps.waktuKedatangan,
fotoKedatangan: fotoKedatangan.length
? fotoKedatangan
: currentTps.fotoKedatangan,
fotoKedatanganUploaded:
Boolean(
apiTps.fotoKedatanganUploaded ?? apiTps.FotoKedatanganUploaded,
) ||
fotoKedatangan.length > 0 ||
currentTps.fotoKedatanganUploaded,
timbangan,
totalOrganik:
Number(
apiTps.totalOrganik ??
apiTps.TotalOrganik ??
currentTps.totalOrganik,
) || 0,
totalAnorganik:
Number(
apiTps.totalAnorganik ??
apiTps.TotalAnorganik ??
currentTps.totalAnorganik,
) || 0,
totalResidu:
Number(
apiTps.totalResidu ?? apiTps.TotalResidu ?? currentTps.totalResidu,
) || 0,
totalTimbangan:
Number(
apiTps.totalTimbangan ??
apiTps.TotalTimbangan ??
currentTps.totalTimbangan,
) || 0,
fotoPetugas: fotoPetugas.length ? fotoPetugas : currentTps.fotoPetugas,
fotoPetugasUploaded:
Boolean(apiTps.fotoPetugasUploaded ?? apiTps.FotoPetugasUploaded) ||
fotoPetugas.length > 0 ||
currentTps.fotoPetugasUploaded,
namaPetugas:
apiTps.namaPetugas || apiTps.NamaPetugas || currentTps.namaPetugas,
submitted: Boolean(
apiTps.submitted ?? apiTps.Submitted ?? currentTps.submitted,
),
};
});
state.selectedTpsList = state.tpsData.map((item) => item.name);
state.activeTpsIndex = Math.min(
state.activeTpsIndex,
Math.max(state.tpsData.length - 1, 0),
);
elements.tpsTabsContainer.style.display = "block";
if (state.tpsData.length === 1) {
renderSingleForm();
} else {
renderTabs();
renderTpsForm();
}
updateAllTotals();
saveState();
}
const elements = {
grandTotalDisplay: null,
tpsSelectionContainer: null,
tpsTabsContainer: null,
tpsCheckboxesContainer: null,
btnConfirmTps: null,
tpsTabsEl: null,
tpsContentContainer: null,
totalOrganikDisplay: null,
totalAnorganikDisplay: null,
totalResiduDisplay: null,
};
function init(tpsList) {
loadState();
initElements();
initializeLocation(tpsList);
}
function initElements() {
elements.grandTotalDisplay = document.getElementById(
"grand-total-timbangan",
);
elements.tpsSelectionContainer = document.getElementById(
"tps-selection-container",
);
elements.tpsTabsContainer = document.getElementById("tps-tabs-container");
elements.tpsCheckboxesContainer = document.getElementById("tps-checkboxes");
elements.btnConfirmTps = document.getElementById("btn-confirm-tps");
elements.tpsTabsEl = document.getElementById("tps-tabs");
elements.tpsContentContainer = document.getElementById("tps-content");
elements.totalOrganikDisplay = document.getElementById(
"grand-total-organik",
);
elements.totalAnorganikDisplay = document.getElementById(
"grand-total-anorganik",
);
elements.totalResiduDisplay = document.getElementById("grand-total-residu");
if (elements.btnConfirmTps) {
elements.btnConfirmTps.addEventListener("click", handleConfirmTps);
}
}
function initializeLocation(tpsList) {
state.availableTpsList = tpsList || [];
if (elements.tpsSelectionContainer) {
elements.tpsSelectionContainer.style.display = "none";
}
if (state.tpsData.length > 0) {
state.selectedTpsList = state.tpsData.map((item) => item.name);
state.activeTpsIndex = Math.min(
state.activeTpsIndex,
Math.max(state.tpsData.length - 1, 0),
);
elements.tpsTabsContainer.style.display = "block";
if (state.tpsData.length === 1) {
renderSingleForm();
} else {
renderTabs();
renderTpsForm();
}
updateAllTotals();
return;
}
if (state.availableTpsList.length === 0) {
state.selectedTpsList = ["1 Lokasi TPS"];
initializeTpsData(state.selectedTpsList);
elements.tpsTabsContainer.style.display = "block";
renderSingleForm();
return;
}
state.selectedTpsList = [...state.availableTpsList];
initializeTpsData(state.selectedTpsList);
elements.tpsTabsContainer.style.display = "block";
if (state.selectedTpsList.length === 1) {
renderSingleForm();
} else {
renderTabs();
renderTpsForm();
}
}
function initializeTpsData(tpsNames) {
state.tpsData = tpsNames.map((tpsItem, index) => ({
name:
typeof tpsItem === "string"
? tpsItem
: tpsItem?.name || tpsItem?.Name || `TPS ${index + 1}`,
index: index,
lokasiAngkutId:
typeof tpsItem === "string"
? ""
: tpsItem?.lokasiAngkutId || tpsItem?.LokasiAngkutID || "",
spjDetailId:
typeof tpsItem === "string"
? ""
: tpsItem?.spjDetailId || tpsItem?.SpjDetailID || "",
latitude: "",
longitude: "",
alamatJalan: "",
waktuKedatangan: "",
fotoKedatangan: [],
fotoKedatanganUploaded: false,
timbangan: [],
totalOrganik: 0,
totalAnorganik: 0,
totalResidu: 0,
totalTimbangan: 0,
fotoPetugas: [],
fotoPetugasUploaded: false,
namaPetugas: "",
submitted: false,
}));
state.hasRequestedLocation = new Array(tpsNames.length).fill(false);
}
function renderTpsSelection() {
elements.tpsCheckboxesContainer.innerHTML = "";
state.availableTpsList.forEach((tpsName) => {
const wrapper = document.createElement("label");
wrapper.className =
"flex items-center gap-3 p-3 rounded-xl border border-gray-200 hover:bg-gray-50 cursor-pointer transition";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = tpsName;
checkbox.className =
"w-5 h-5 rounded border-gray-300 text-upst focus:ring-upst";
checkbox.checked = true;
const label = document.createElement("span");
label.className = "text-sm font-bold text-gray-700";
label.textContent = tpsName;
wrapper.appendChild(checkbox);
wrapper.appendChild(label);
elements.tpsCheckboxesContainer.appendChild(wrapper);
});
}
function handleConfirmTps() {
const checkboxes = elements.tpsCheckboxesContainer.querySelectorAll(
'input[type="checkbox"]:checked',
);
state.selectedTpsList = Array.from(checkboxes).map((cb) => cb.value);
if (state.selectedTpsList.length === 0) {
alert("Pilih minimal 1 TPS untuk diangkut!");
return;
}
initializeTpsData(state.selectedTpsList);
elements.tpsSelectionContainer.style.display = "none";
elements.tpsTabsContainer.style.display = "block";
if (state.selectedTpsList.length === 1) {
renderSingleForm();
} else {
renderTabs();
renderTpsForm();
}
}
function renderSingleForm() {
elements.tpsTabsEl.style.display = "none";
state.activeTpsIndex = 0;
renderTpsForm();
}
function renderTabs() {
elements.tpsTabsEl.style.display = "block";
elements.tpsTabsEl.className = "mb-4";
const activeTps = state.tpsData[state.activeTpsIndex];
const isActiveSubmitted = !!(activeTps && activeTps.submitted);
const selectClass = isActiveSubmitted
? "w-full rounded-xl border border-green-300 bg-green-50 text-green-700 px-3 py-2 text-sm font-bold"
: "w-full rounded-xl border border-gray-200 bg-white text-gray-700 px-3 py-2 text-sm font-bold";
const optionsHtml = state.tpsData
.map((tps, index) => {
const marker = tps.submitted ? "✅ " : "";
return `<option value="${index}" ${index === state.activeTpsIndex ? "selected" : ""}>${marker}${tps.name}</option>`;
})
.join("");
elements.tpsTabsEl.innerHTML = `
<label class="block text-xs font-semibold text-gray-500 mb-1">Pilih TPS</label>
<select id="tps-dropdown" class="${selectClass}">
${optionsHtml}
</select>
`;
const dropdown = elements.tpsTabsEl.querySelector("#tps-dropdown");
if (dropdown) {
dropdown.addEventListener("change", (e) => {
const nextIndex = parseInt(e.target.value, 10);
if (!Number.isNaN(nextIndex)) {
switchToTps(nextIndex);
}
});
}
}
function switchToTps(index) {
state.activeTpsIndex = index;
renderTabs();
renderTpsForm();
if (!state.hasRequestedLocation[index]) {
state.hasRequestedLocation[index] = true;
getLocationUpdate();
}
updateAllTotals();
}
function renderTpsForm() {
const tps = state.tpsData[state.activeTpsIndex];
const showTpsName =
state.selectedTpsList.length > 1 || state.availableTpsList.length > 0;
const submitState = getSubmitState(tps);
elements.tpsContentContainer.innerHTML = `
<form class="space-y-5 pb-8" data-tps-index="${tps.index}">
<input type="hidden" class="tps-lokasi-angkut-id" value="${tps.lokasiAngkutId || ""}" />
<input type="hidden" class="tps-spj-detail-id" value="${tps.spjDetailId || ""}" />
<input type="hidden" class="tps-latitude" value="${tps.latitude}" />
<input type="hidden" class="tps-longitude" value="${tps.longitude}" />
<input type="hidden" class="tps-alamat-jalan" value="${tps.alamatJalan}" />
<input type="hidden" class="tps-total-timbangan" value="${tps.totalTimbangan}" />
<input type="hidden" class="tps-total-organik" value="${tps.totalOrganik}" />
<input type="hidden" class="tps-total-anorganik" value="${tps.totalAnorganik}" />
<input type="hidden" class="tps-total-residu" value="${tps.totalResidu}" />
${renderSection1Kedatangan(tps, showTpsName)}
${renderSection2Timbangan(tps, showTpsName)}
${renderSection3Petugas(tps)}
<div class="flex gap-3">
<a href="/upst/detail-penjemputan/batal" class="w-1/3 text-center bg-red-500 text-white py-3 rounded-xl font-bold text-sm">Batal</a>
<button type="submit" ${submitState.canSubmit ? "" : "disabled"} class="w-2/3 py-3 rounded-xl font-bold text-sm transition ${submitState.canSubmit ? "bg-upst text-white hover:brightness-110" : "bg-gray-300 text-gray-500 cursor-not-allowed"}">Submit${showTpsName ? " " + tps.name : ""}</button>
</div>
${submitState.canSubmit ? "" : `<p class="submit-state-message text-[11px] text-center text-red-500 font-medium">${submitState.message}</p>`}
</form>
`;
attachTpsFormListeners();
restoreTpsTimbanganItems();
restorePhotoPreview();
}
function renderSection1Kedatangan(tps, showTpsName) {
return `
<section class="bg-white border border-gray-100 rounded-3xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-upst text-white font-black text-sm flex items-center justify-center">1</div>
<div>
<h3 class="font-black text-gray-800">Foto Kedatangan${showTpsName ? " - " + tps.name : ""}</h3>
<p class="text-xs text-gray-500">Upload foto kedatangan</p>
</div>
</div>
<label class="block text-xs font-semibold text-gray-600">Upload Foto Kedatangan</label>
<input type="file" class="tps-foto-kedatangan block w-full text-sm text-gray-700 border border-gray-200 rounded-xl p-2 file:mr-3 file:rounded-lg file:border-0 file:bg-upst file:px-3 file:py-2 file:text-xs file:font-bold file:text-white" accept="image/*" multiple />
<div class="tps-preview-kedatangan space-y-2"></div>
<div class="kedatangan-upload-state">${getKedatanganUploadStateMarkup(tps)}</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Latitude</label>
<input type="text" class="tps-display-latitude w-full rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs" readonly value="${tps.latitude}" />
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Longitude</label>
<input type="text" class="tps-display-longitude w-full rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs" readonly value="${tps.longitude}" />
</div>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Waktu Kedatangan</label>
<input type="text" class="tps-waktu-kedatangan w-full rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs" readonly value="${tps.waktuKedatangan}" />
</div>
</section>
`;
}
function renderSection2Timbangan(tps, showTpsName) {
return `
<section class="bg-white border border-gray-100 rounded-3xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-upst text-white font-black text-sm flex items-center justify-center">2</div>
<div>
<h3 class="font-black text-gray-800">Foto Timbang Sampah</h3>
<p class="text-xs text-gray-500">Upload foto timbangan, berat auto terisi</p>
</div>
</div>
<div class="tps-timbangan-repeater space-y-3"></div>
<button type="button" class="tps-btn-add-timbangan w-full border border-dashed border-upst text-upst rounded-xl py-2 text-xs font-bold transition">
+ Tambah Foto Timbangan
</button>
</section>
`;
}
function renderSection3Petugas(tps) {
return `
<section class="bg-white border border-gray-100 rounded-3xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-upst text-white font-black text-sm flex items-center justify-center">3</div>
<div>
<h3 class="font-black text-gray-800">Foto Petugas</h3>
<p class="text-xs text-gray-500">Upload dokumentasi petugas</p>
</div>
</div>
<label class="block text-xs font-semibold text-gray-600">Upload Foto Petugas</label>
<input type="file" class="tps-foto-petugas block w-full text-sm text-gray-700 border border-gray-200 rounded-xl p-2 file:mr-3 file:rounded-lg file:border-0 file:bg-upst file:px-3 file:py-2 file:text-xs file:font-bold file:text-white" accept="image/*" multiple />
<div class="tps-preview-petugas space-y-2"></div>
<div class="petugas-upload-state">${getPetugasUploadStateMarkup(tps)}</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Nama Petugas</label>
<input type="text" class="tps-nama-petugas w-full rounded-xl border border-gray-200 px-3 py-2 text-sm" placeholder="Masukkan nama petugas" value="${tps.namaPetugas}" />
</div>
</section>
`;
}
function attachTpsFormListeners() {
const form = elements.tpsContentContainer.querySelector("form");
const tps = state.tpsData[state.activeTpsIndex];
const fotoKedatanganInput = form.querySelector(".tps-foto-kedatangan");
const fotoPetugasInput = form.querySelector(".tps-foto-petugas");
const namaPetugasInput = form.querySelector(".tps-nama-petugas");
const btnAddTimbangan = form.querySelector(".tps-btn-add-timbangan");
fotoKedatanganInput.addEventListener("change", function () {
tps.fotoKedatangan = Array.from(this.files);
tps.fotoKedatanganUploaded = false;
updateWaktuKedatangan();
updateMultiPreview(this, form.querySelector(".tps-preview-kedatangan"));
refreshKedatanganUploadState(form);
saveState();
});
fotoPetugasInput.addEventListener("change", function () {
tps.fotoPetugas = Array.from(this.files);
tps.fotoPetugasUploaded = false;
updateMultiPreview(this, form.querySelector(".tps-preview-petugas"));
refreshPetugasUploadState(form);
saveState();
});
namaPetugasInput.addEventListener("input", function () {
tps.namaPetugas = this.value;
refreshPetugasUploadState(form);
saveState();
});
btnAddTimbangan.addEventListener("click", function () {
createTimbanganItem(form.querySelector(".tps-timbangan-repeater"));
syncTimbanganToTpsData();
refreshSubmitButtonState(form);
});
const btnUploadKedatangan = form.querySelector(
".tps-btn-upload-kedatangan",
);
if (btnUploadKedatangan) {
btnUploadKedatangan.addEventListener("click", uploadFotoKedatangan);
}
const btnUploadPetugas = form.querySelector(".tps-btn-upload-petugas");
if (btnUploadPetugas) {
btnUploadPetugas.addEventListener("click", uploadFotoPetugas);
}
form.addEventListener("submit", function (e) {
e.preventDefault();
submitTpsData();
});
}
function restoreTpsTimbanganItems() {
const tps = state.tpsData[state.activeTpsIndex];
const form = elements.tpsContentContainer.querySelector("form");
const repeater = form.querySelector(".tps-timbangan-repeater");
if (tps.timbangan.length === 0) {
createTimbanganItem(repeater);
} else {
tps.timbangan.forEach((timb) => {
createTimbanganItem(repeater, timb);
});
}
}
function restorePhotoPreview() {
const tps = state.tpsData[state.activeTpsIndex];
const form = elements.tpsContentContainer.querySelector("form");
if (!form) return;
const previewKedatangan = form.querySelector(".tps-preview-kedatangan");
if (previewKedatangan && tps.fotoKedatangan.length > 0) {
renderStoredPhotos(tps.fotoKedatangan, previewKedatangan);
}
const previewPetugas = form.querySelector(".tps-preview-petugas");
if (previewPetugas && tps.fotoPetugas.length > 0) {
renderStoredPhotos(tps.fotoPetugas, previewPetugas);
}
}
function renderStoredPhotos(files, container) {
container.innerHTML = "";
container.className = "space-y-2";
files.forEach((file, index) => {
const item = document.createElement("div");
item.className =
"rounded-xl border border-gray-200 overflow-hidden bg-black";
const imageUrl = getStoredPhotoUrl(file);
const safeName = getStoredPhotoName(file, `Foto ${index + 1}`).replace(
/"/g,
"&quot;",
);
const fileSize = getStoredPhotoSize(file);
item.innerHTML = `
<div class="h-44 bg-black/80">
<img src="${imageUrl}" alt="Preview ${index + 1}" class="w-full h-full object-contain preview-multi-image" />
</div>
<div class="px-2 py-1 bg-white">
<p class="text-[11px] font-semibold text-gray-700 truncate">${index + 1}. ${safeName}</p>
<p class="text-[10px] text-gray-500">${fileSize > 0 ? formatFileSize(fileSize) : "Tersimpan di server"}</p>
</div>
`;
const img = item.querySelector(".preview-multi-image");
if (img && isBrowserFile(resolveStoredPhoto(file))) {
img.onload = function () {
URL.revokeObjectURL(imageUrl);
};
}
container.appendChild(item);
});
}
function updateWaktuKedatangan() {
const tps = state.tpsData[state.activeTpsIndex];
const now = new Date();
const formatted = now.toLocaleString("id-ID", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
tps.waktuKedatangan = formatted;
const form = elements.tpsContentContainer.querySelector("form");
const displayWaktu = form.querySelector(".tps-waktu-kedatangan");
if (displayWaktu) displayWaktu.value = formatted;
getLocationUpdate();
saveState();
}
function getLocationUpdate() {
if (!("geolocation" in navigator)) return;
navigator.geolocation.getCurrentPosition(
function (position) {
const lat = position.coords.latitude.toFixed(6);
const lng = position.coords.longitude.toFixed(6);
reverseGeocode(lat, lng);
},
function () {
console.log("Lokasi tidak diizinkan");
},
);
}
function reverseGeocode(lat, lng) {
const tps = state.tpsData[state.activeTpsIndex];
fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`,
)
.then((res) => res.json())
.then((data) => {
const address = data.display_name || `${lat}, ${lng}`;
updateTpsLocation(lat, lng, address);
})
.catch(() => {
updateTpsLocation(lat, lng, `${lat}, ${lng}`);
});
}
function updateTpsLocation(lat, lng, address) {
const tps = state.tpsData[state.activeTpsIndex];
tps.latitude = lat;
tps.longitude = lng;
tps.alamatJalan = address;
const form = elements.tpsContentContainer.querySelector("form");
if (form) {
const latInput = form.querySelector(".tps-display-latitude");
const lngInput = form.querySelector(".tps-display-longitude");
if (latInput) latInput.value = lat;
if (lngInput) lngInput.value = lng;
}
saveState();
}
function updateMultiPreview(input, previewContainer) {
if (!input || !previewContainer) return;
previewContainer.innerHTML = "";
previewContainer.className = "space-y-2";
if (!input.files || input.files.length === 0) return;
Array.from(input.files).forEach((file, index) => {
const item = document.createElement("div");
item.className =
"rounded-xl border border-gray-200 overflow-hidden bg-black";
const imageUrl = URL.createObjectURL(file);
const safeName = file.name.replace(/"/g, "&quot;");
item.innerHTML = `
<div class="h-44 bg-black/80">
<img src="${imageUrl}" alt="Preview ${index + 1}" class="w-full h-full object-contain preview-multi-image" />
</div>
<div class="px-2 py-1 bg-white">
<p class="text-[11px] font-semibold text-gray-700 truncate">${index + 1}. ${safeName}</p>
<p class="text-[10px] text-gray-500">${formatFileSize(file.size)}</p>
</div>
`;
const img = item.querySelector(".preview-multi-image");
if (img) {
img.onload = function () {
URL.revokeObjectURL(imageUrl);
};
}
previewContainer.appendChild(item);
});
}
function createTimbanganItem(repeater, existingData = null) {
const photoNumber = repeater.children.length + 1;
const item = document.createElement("div");
item.className =
"timbangan-item rounded-2xl border border-gray-200 p-3 space-y-2 bg-gray-50";
item.dataset.photoNumber = photoNumber;
const weight = existingData ? existingData.weight : 0;
const jenisSampah = existingData
? existingData.jenisSampah
: CONFIG.DEFAULT_JENIS;
const hasFile = hasStoredPhoto(existingData && existingData.file);
const isUploaded = Boolean(existingData && existingData.uploaded);
const ocrInfoText =
existingData && existingData.ocrInfo
? existingData.ocrInfo
: hasFile
? "OCR: diproses."
: "OCR: belum diproses.";
item.innerHTML = `
<div class="flex items-center justify-between">
<p class="text-xs font-bold text-gray-600">Item Timbangan #${photoNumber}</p>
<button type="button" class="btn-remove-timbangan text-[11px] font-bold text-red-500">Hapus</button>
</div>
<input type="file" name="FotoTimbangan" accept="image/*" class="input-foto-timbangan block w-full text-sm text-gray-700 border border-gray-200 rounded-xl p-2 file:mr-3 file:rounded-lg file:border-0 file:bg-upst file:px-3 file:py-2 file:text-xs file:font-bold file:text-white" />
<div class="${hasFile ? "" : "hidden"} input-preview-wrap relative rounded-xl overflow-hidden border border-gray-200 bg-black">
<img class="input-preview-image w-full h-44 object-contain" alt="Preview foto timbangan" />
</div>
<p class="text-[11px] text-gray-500 input-ocr-info">${ocrInfoText}</p>
<div class="timbangan-upload-state">${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}</div>
<div class="grid grid-cols-1 gap-2">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Jenis Sampah</label>
<select class="input-jenis-sampah w-full rounded-xl border border-gray-200 px-3 py-2 text-sm">
${CONFIG.JENIS_SAMPAH.map((js) => `<option value="${js}" ${js === jenisSampah ? "selected" : ""}>${js}</option>`).join("")}
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Berat (kg)</label>
<input type="text" inputmode="decimal" class="input-berat-timbangan-display w-full rounded-xl border border-gray-200 px-3 py-2 text-sm" placeholder="Contoh: 54,45" value="${weight > 0 ? formatWeightDisplay(weight) : ""}" />
<input type="hidden" class="input-berat-timbangan-value" value="${weight.toFixed(2)}" />
</div>
</div>
`;
const fileInput = item.querySelector(".input-foto-timbangan");
const previewWrap = item.querySelector(".input-preview-wrap");
const previewImage = item.querySelector(".input-preview-image");
const ocrInfoEl = item.querySelector(".input-ocr-info");
const weightInputDisplay = item.querySelector(
".input-berat-timbangan-display",
);
const weightInputValue = item.querySelector(".input-berat-timbangan-value");
const jenisSampahSelect = item.querySelector(".input-jenis-sampah");
const removeBtn = item.querySelector(".btn-remove-timbangan");
if (existingData && hasStoredPhoto(existingData.file)) {
const existingPhoto = resolveStoredPhoto(existingData.file);
const photoUrl = getStoredPhotoUrl(existingPhoto);
previewImage.src = photoUrl;
previewWrap.classList.remove("hidden");
if (isBrowserFile(existingPhoto)) {
previewImage.onload = function () {
URL.revokeObjectURL(photoUrl);
};
}
}
fileInput.addEventListener("change", async function () {
if (fileInput.files && fileInput.files[0]) {
const originalFile = fileInput.files[0];
const watermarkedFile = await applyWatermark(originalFile, photoNumber);
const dataTransfer = new DataTransfer();
dataTransfer.items.add(watermarkedFile);
fileInput.files = dataTransfer.files;
const localUrl = URL.createObjectURL(watermarkedFile);
previewImage.src = localUrl;
previewWrap.classList.remove("hidden");
previewImage.onload = function () {
URL.revokeObjectURL(localUrl);
};
await autoFillWeight(watermarkedFile, weightInputDisplay, ocrInfoEl);
const parsed = parseWeightInput(weightInputDisplay.value);
weightInputValue.value = parsed.toFixed(2);
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
const tps = state.tpsData[state.activeTpsIndex];
const itemIndex = Array.from(repeater.children).indexOf(item);
if (itemIndex >= 0 && tps.timbangan[itemIndex]) {
tps.timbangan[itemIndex].uploaded = false;
refreshTimbanganUploadState(item);
saveState();
}
}
});
weightInputDisplay.addEventListener("input", function () {
const cleaned = this.value.replace(/[^0-9.,]/g, "");
this.value = cleaned;
const parsed = parseWeightInput(cleaned);
weightInputValue.value = parsed.toFixed(2);
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
refreshTimbanganUploadState(item);
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshSubmitButtonState(form);
});
weightInputDisplay.addEventListener("blur", function () {
const parsed = parseWeightInput(this.value);
if (parsed > 0) {
this.value = formatWeightDisplay(parsed);
weightInputValue.value = parsed.toFixed(2);
} else {
this.value = "";
weightInputValue.value = "0.00";
}
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
refreshTimbanganUploadState(item);
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshSubmitButtonState(form);
});
jenisSampahSelect.addEventListener("change", function () {
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshSubmitButtonState(form);
});
removeBtn.addEventListener("click", function () {
item.remove();
const form = elements.tpsContentContainer.querySelector("form");
const repeater = form
? form.querySelector(".tps-timbangan-repeater")
: null;
if (repeater) {
renumberTimbanganItems(repeater);
if (repeater.children.length === 0) {
createTimbanganItem(repeater);
}
}
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
if (form) refreshSubmitButtonState(form);
});
repeater.appendChild(item);
refreshTimbanganUploadState(item);
return item;
}
function getTimbanganUploadStateMarkup(hasFile, isUploaded, hasValidWeight) {
if (!hasFile) {
return '<p class="text-[11px] text-gray-400">Pilih foto timbangan terlebih dahulu</p>';
}
if (isUploaded) {
return `
<div class="text-center text-xs text-green-600 font-bold py-2 upload-success-message">✓ Foto timbangan sudah diupload</div>
<p class="text-[11px] text-gray-500 text-center">Jika ingin revisi, pilih file baru diatas. Status upload akan tereset otomatis.</p>
`;
}
if (!hasValidWeight) {
return `
<button type="button" disabled class="btn-upload-timbangan w-full bg-gray-300 text-gray-500 py-2 rounded-xl font-bold text-xs cursor-not-allowed">Upload Foto Timbangan Ini</button>
<p class="text-[11px] text-red-500 text-center">Isi berat manual dulu sebelum upload jika berat tidak terbaca.</p>
`;
}
return `
<button type="button" class="btn-upload-timbangan w-full bg-blue-500 text-white py-2 rounded-xl font-bold text-xs hover:brightness-110">Upload Foto Timbangan Ini</button>
<p class="text-[11px] text-amber-600 text-center">Foto siap diupload.</p>
`;
}
function getKedatanganUploadStateMarkup(tps) {
if (tps.fotoKedatanganUploaded) {
return '<div class="text-center text-xs text-green-600 font-bold py-2">✓ Foto kedatangan sudah diupload</div>';
}
if (!tps.fotoKedatangan.length) {
return "";
}
return `<button type="button" class="tps-btn-upload-kedatangan w-full bg-blue-500 text-white py-2 rounded-xl font-bold text-xs hover:brightness-110">Upload ${tps.fotoKedatangan.length} Foto Kedatangan</button>`;
}
function refreshKedatanganUploadState(form) {
const stateContainer = form.querySelector(".kedatangan-upload-state");
if (!stateContainer) return;
const tps = state.tpsData[state.activeTpsIndex];
stateContainer.innerHTML = getKedatanganUploadStateMarkup(tps);
const btnUploadKedatangan = stateContainer.querySelector(
".tps-btn-upload-kedatangan",
);
if (btnUploadKedatangan) {
btnUploadKedatangan.addEventListener("click", uploadFotoKedatangan);
}
refreshSubmitButtonState(form);
}
function getPetugasUploadStateMarkup(tps) {
if (tps.fotoPetugasUploaded) {
return '<div class="text-center text-xs text-green-600 font-bold py-2">✓ Foto petugas sudah diupload</div>';
}
if (!tps.fotoPetugas.length) {
return "";
}
if (!tps.namaPetugas.trim()) {
return `
<button type="button" disabled class="tps-btn-upload-petugas w-full bg-gray-300 text-gray-500 py-2 rounded-xl font-bold text-xs cursor-not-allowed">Upload ${tps.fotoPetugas.length} Foto Petugas</button>
<p class="text-[11px] text-red-500 text-center">Isi nama petugas terlebih dahulu</p>
`;
}
return `<button type="button" class="tps-btn-upload-petugas w-full bg-blue-500 text-white py-2 rounded-xl font-bold text-xs hover:brightness-110">Upload ${tps.fotoPetugas.length} Foto Petugas</button>`;
}
function refreshPetugasUploadState(form) {
const stateContainer = form.querySelector(".petugas-upload-state");
if (!stateContainer) return;
const tps = state.tpsData[state.activeTpsIndex];
stateContainer.innerHTML = getPetugasUploadStateMarkup(tps);
const btnUploadPetugas = stateContainer.querySelector(
".tps-btn-upload-petugas:not([disabled])",
);
if (btnUploadPetugas) {
btnUploadPetugas.addEventListener("click", uploadFotoPetugas);
}
refreshSubmitButtonState(form);
}
function isTimbanganItemReady(timbanganItem) {
return (
hasStoredPhoto(timbanganItem?.file) &&
Boolean(timbanganItem?.uploaded) &&
(timbanganItem?.weight || 0) > 0
);
}
function getSubmitState(tps) {
if (!tps.fotoKedatangan.length || !tps.fotoKedatanganUploaded) {
return {
canSubmit: false,
message: "Silakan upload foto kedatangan terlebih dahulu.",
};
}
if (!tps.timbangan.length) {
return {
canSubmit: false,
message: "Tambahkan minimal 1 data timbangan sebelum submit.",
};
}
if (tps.timbangan.some((item) => !isTimbanganItemReady(item))) {
return {
canSubmit: false,
message:
"Pastikan semua foto timbangan sudah diupload dan beratnya valid.",
};
}
if (!tps.fotoPetugas.length || !tps.fotoPetugasUploaded) {
return {
canSubmit: false,
message: "Silakan upload foto petugas terlebih dahulu",
};
}
if (!tps.namaPetugas.trim()) {
return {
canSubmit: false,
message: "Isi nama petugas dulu sebelum submit.",
};
}
return { canSubmit: true, message: "" };
}
function refreshSubmitButtonState(form) {
const submitButton = form.querySelector('button[type="submit"]');
if (!submitButton) return;
let messageEl = form.querySelector(".submit-state-message");
const tps = state.tpsData[state.activeTpsIndex];
const submitState = getSubmitState(tps);
submitButton.disabled = !submitState.canSubmit;
submitButton.className = `w-2/3 py-3 rounded-xl font-bold text-sm transition ${submitState.canSubmit ? "bg-upst text-white hover:brightness-110" : "bg-gray-300 text-gray-500 cursor-not-allowed"}`;
if (!messageEl) {
messageEl = document.createElement("p");
messageEl.className =
"submit-state-message text-[11px] text-center text-red-500 font-medium";
submitButton
.closest(".flex.gap-3")
?.insertAdjacentElement("afterend", messageEl);
}
if (submitState.canSubmit) {
messageEl.textContent = "";
messageEl.classList.add("hidden");
} else {
messageEl.textContent = submitState.message;
messageEl.classList.remove("hidden");
}
}
function refreshTimbanganUploadState(item) {
const stateContainer = item.querySelector(".timbangan-upload-state");
if (!stateContainer) return;
const repeater = item.parentElement;
const itemIndex = repeater
? Array.from(repeater.children).indexOf(item)
: -1;
const tps = state.tpsData[state.activeTpsIndex];
const currentData = itemIndex >= 0 ? tps.timbangan[itemIndex] : null;
const fileInput = item.querySelector(".input-foto-timbangan");
const hasFile = hasStoredPhoto(currentData?.file || fileInput?.files?.[0]);
const isUploaded = Boolean(currentData?.uploaded);
const weightInputValue = item.querySelector(".input-berat-timbangan-value");
const currentWeight =
currentData?.weight ?? parseWeightInput(weightInputValue?.value || "0");
const hasValidWeight = currentWeight > 0;
stateContainer.innerHTML = getTimbanganUploadStateMarkup(
hasFile,
isUploaded,
hasValidWeight,
);
const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan");
if (uploadBtn) {
uploadBtn.addEventListener("click", function () {
const latestIndex = repeater
? Array.from(repeater.children).indexOf(item)
: -1;
uploadSingleFotoTimbangan(latestIndex, item);
});
}
const form = elements.tpsContentContainer.querySelector("form");
if (form) {
refreshSubmitButtonState(form);
}
}
function renumberTimbanganItems(repeater) {
const items = repeater.querySelectorAll(".timbangan-item");
items.forEach((item, index) => {
const newNumber = index + 1;
item.dataset.photoNumber = newNumber;
const label = item.querySelector(".text-xs.font-bold.text-gray-600");
if (label) {
label.textContent = `Item Timbangan #${newNumber}`;
}
});
}
async function applyWatermark(file, photoNumber) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const now = new Date();
const days = [
"Minggu",
"Senin",
"Selasa",
"Rabu",
"Kamis",
"Jumat",
"Sabtu",
];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"Mei",
"Jun",
"Jul",
"Agu",
"Sep",
"Okt",
"Nov",
"Des",
];
const dayName = days[now.getDay()];
const date = now.getDate().toString().padStart(2, "0");
const month = months[now.getMonth()];
const year = now.getFullYear();
const hours = now.getHours().toString().padStart(2, "0");
const minutes = now.getMinutes().toString().padStart(2, "0");
const seconds = now.getSeconds().toString().padStart(2, "0");
const timestamp = `${dayName}, ${date} ${month} ${year}${hours}:${minutes}:${seconds}`;
const baseFontSize = Math.max(
16,
Math.min(img.width, img.height) * 0.025,
);
const fontFamily = "'Montserrat', 'Segoe UI', 'Roboto', sans-serif";
const lines = [
{
text: `FOTO TIMBANG #${photoNumber}`,
size: baseFontSize * 1.1,
weight: "900",
color: "#FFD700",
},
{
text: `${state.nomorSpj}`,
size: baseFontSize * 0.9,
weight: "700",
color: "#FFFFFF",
},
{
text: timestamp,
size: baseFontSize * 0.75,
weight: "500",
color: "#E2E8F0",
},
];
const paddingX = baseFontSize * 1.2;
const paddingY = baseFontSize * 1.0;
const lineGap = baseFontSize * 0.4;
let maxWidth = 0;
let totalHeight = 0;
lines.forEach((line) => {
ctx.font = `${line.weight} ${line.size}px ${fontFamily}`;
const metrics = ctx.measureText(line.text);
maxWidth = Math.max(maxWidth, metrics.width);
totalHeight += line.size + lineGap;
});
totalHeight -= lineGap;
const margin = baseFontSize * 1.5;
const boxWidth = maxWidth + paddingX * 2;
const boxHeight = totalHeight + paddingY * 2;
const boxX = img.width - boxWidth - margin;
const boxY = img.height - boxHeight - margin;
ctx.save();
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, baseFontSize * 0.8);
} else {
ctx.rect(boxX, boxY, boxWidth, boxHeight);
}
ctx.fillStyle = "rgba(15, 23, 42, 0.85)";
ctx.fill();
ctx.beginPath();
const accentWidth = baseFontSize * 0.3;
if (ctx.roundRect) {
ctx.roundRect(
boxX + boxWidth - accentWidth,
boxY,
accentWidth,
boxHeight,
[0, baseFontSize * 0.8, baseFontSize * 0.8, 0],
);
} else {
ctx.rect(
boxX + boxWidth - accentWidth,
boxY,
accentWidth,
boxHeight,
);
}
ctx.fillStyle = "#FFD700";
ctx.fill();
ctx.restore();
ctx.shadowColor = "rgba(0, 0, 0, 0.6)";
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.textAlign = "right";
ctx.textBaseline = "top";
let currentY = boxY + paddingY;
const textRightLimit = boxX + boxWidth - paddingX - accentWidth;
lines.forEach((line) => {
ctx.font = `${line.weight} ${line.size}px ${fontFamily}`;
ctx.fillStyle = line.color;
ctx.fillText(line.text, textRightLimit, currentY);
currentY += line.size + lineGap;
});
ctx.shadowColor = "transparent";
canvas.toBlob(
function (blob) {
const watermarkedFile = new File([blob], file.name, {
type: "image/jpeg",
lastModified: Date.now(),
});
resolve(watermarkedFile);
},
"image/jpeg",
0.95,
);
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function autoFillWeight(file, weightInput, ocrInfoEl) {
let guessedWeight = 0;
weightInput.placeholder = "Membaca angka dari foto...";
if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar...";
try {
const img = await readFileAsImage(file);
let bestRawText = "";
let isSuccess = false;
for (const area of CONFIG.OCR_AREAS) {
const cropCanvas = createCropCanvas(img, area);
const cropFile = await canvasToJpegFile(
cropCanvas,
`crop-${area.id}.jpg`,
);
const aiResult = await requestOpenRouterWeight(cropFile);
if (aiResult && aiResult.success && aiResult.weight) {
guessedWeight = parseWeightInput(aiResult.weight);
bestRawText = aiResult.raw || aiResult.weight;
isSuccess = guessedWeight > 0;
if (isSuccess) break;
}
if (aiResult && aiResult.raw) {
bestRawText = aiResult.raw;
}
}
if (ocrInfoEl) {
const cleaned = (bestRawText || "").replace(/\s+/g, " ").trim();
ocrInfoEl.textContent = isSuccess
? `AI terbaca: ${cleaned}`
: cleaned
? `AI tidak valid: ${cleaned}`
: "AI tidak menemukan angka valid.";
}
} catch (_) {
guessedWeight = 0;
if (ocrInfoEl) ocrInfoEl.textContent = "AI gagal diproses.";
}
if (guessedWeight > 0) {
weightInput.value = formatWeightDisplay(guessedWeight);
weightInput.placeholder = "Berat terdeteksi otomatis";
} else {
weightInput.placeholder = "Tidak terbaca otomatis, isi manual";
}
updateTpsTotalTimbangan();
}
function readFileAsImage(file) {
return new Promise(function (resolve, reject) {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = function () {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = function () {
URL.revokeObjectURL(objectUrl);
reject(new Error("Gagal membaca gambar."));
};
img.src = objectUrl;
});
}
function createCropCanvas(img, area) {
const sx = Math.max(0, Math.floor(img.width * area.x));
const sy = Math.max(0, Math.floor(img.height * area.y));
const sw = Math.max(1, Math.floor(img.width * area.w));
const sh = Math.max(1, Math.floor(img.height * area.h));
const canvas = document.createElement("canvas");
const scale = 2.2;
canvas.width = Math.max(1, Math.floor(sw * scale));
canvas.height = Math.max(1, Math.floor(sh * scale));
const ctx = canvas.getContext("2d");
if (!ctx) return canvas;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
return canvas;
}
function canvasToJpegFile(canvas, fileName) {
return new Promise(function (resolve, reject) {
canvas.toBlob(
function (blob) {
if (!blob) {
reject(new Error("Gagal membuat crop image."));
return;
}
resolve(new File([blob], fileName, { type: "image/jpeg" }));
},
"image/jpeg",
0.92,
);
});
}
async function requestOpenRouterWeight(imageFile) {
const formData = new FormData();
formData.append("Foto", imageFile);
const response = await fetch("/upst/detail-penjemputan/ocr-timbangan", {
method: "POST",
body: formData,
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || "Request OCR gagal.");
}
return result;
}
function updateTpsTotalTimbangan() {
const tps = state.tpsData[state.activeTpsIndex];
const form = elements.tpsContentContainer.querySelector("form");
if (!form) return;
let totalOrganik = 0.0;
let totalAnorganik = 0.0;
let totalResidu = 0.0;
const repeater = form.querySelector(".tps-timbangan-repeater");
const items = repeater.querySelectorAll(".timbangan-item");
items.forEach(function (item) {
const weightInput = item.querySelector(".input-berat-timbangan-value");
const jenisSampahSelect = item.querySelector(".input-jenis-sampah");
if (weightInput && jenisSampahSelect) {
const value = parseWeightInput(weightInput.value || "0");
const jenis = jenisSampahSelect.value;
if (jenis === "Organik") {
totalOrganik += value;
} else if (jenis === "Anorganik") {
totalAnorganik += value;
} else if (jenis === "Residu") {
totalResidu += value;
}
}
});
tps.totalOrganik = totalOrganik;
tps.totalAnorganik = totalAnorganik;
tps.totalResidu = totalResidu;
tps.totalTimbangan = totalOrganik + totalAnorganik + totalResidu;
const displayTotalOrganik = form.querySelector(
".tps-display-total-organik",
);
const displayTotalAnorganik = form.querySelector(
".tps-display-total-anorganik",
);
const displayTotalResidu = form.querySelector(".tps-display-total-residu");
const displayTotal = form.querySelector(".tps-display-total");
if (displayTotalOrganik)
displayTotalOrganik.textContent = formatWeightDisplay(totalOrganik);
if (displayTotalAnorganik)
displayTotalAnorganik.textContent = formatWeightDisplay(totalAnorganik);
if (displayTotalResidu)
displayTotalResidu.textContent = formatWeightDisplay(totalResidu);
if (displayTotal)
displayTotal.textContent = formatWeightDisplay(tps.totalTimbangan);
updateAllTotals();
saveState();
}
function updateAllTotals() {
let grandTotal = 0;
let grandOrganik = 0;
let grandAnorganik = 0;
let grandResidu = 0;
state.tpsData.forEach((tps) => {
grandTotal += tps.totalTimbangan;
grandOrganik += tps.totalOrganik;
grandAnorganik += tps.totalAnorganik;
grandResidu += tps.totalResidu;
});
if (elements.grandTotalDisplay) {
elements.grandTotalDisplay.textContent = formatWeightDisplay(grandTotal);
}
if (elements.totalOrganikDisplay) {
elements.totalOrganikDisplay.textContent =
formatWeightDisplay(grandOrganik);
}
if (elements.totalAnorganikDisplay) {
elements.totalAnorganikDisplay.textContent =
formatWeightDisplay(grandAnorganik);
}
if (elements.totalResiduDisplay) {
elements.totalResiduDisplay.textContent =
formatWeightDisplay(grandResidu);
}
saveState();
}
function syncTimbanganToTpsData() {
const tps = state.tpsData[state.activeTpsIndex];
const form = elements.tpsContentContainer.querySelector("form");
if (!form) return;
const repeater = form.querySelector(".tps-timbangan-repeater");
const items = repeater.querySelectorAll(".timbangan-item");
const previousTimbangan = [...tps.timbangan];
tps.timbangan = [];
items.forEach((item, index) => {
const fileInput = item.querySelector(".input-foto-timbangan");
const weightValue = item.querySelector(".input-berat-timbangan-value");
const jenisSampahSelect = item.querySelector(".input-jenis-sampah");
const ocrInfo =
item.querySelector(".input-ocr-info")?.textContent ||
"OCR: belum diproses.";
const existingData = previousTimbangan[index];
tps.timbangan.push({
file: fileInput.files[0] || (existingData ? existingData.file : null),
weight: parseWeightInput(weightValue.value),
jenisSampah: jenisSampahSelect.value,
uploaded: existingData ? existingData.uploaded : false,
ocrInfo,
});
});
saveState();
}
function uploadSingleFotoTimbangan(itemIndex, targetItem = null) {
const tps = state.tpsData[state.activeTpsIndex];
if (!tps.timbangan[itemIndex] || !tps.timbangan[itemIndex].file) {
alert("Belum ada foto timbangan yang dipilih!");
return;
}
const timbanganItem = tps.timbangan[itemIndex];
if (timbanganItem.weight <= 0) {
alert(
"Berat belum valid. Isi manual dulu sebelum upload foto timbangan.",
);
return;
}
const _ext = (
timbanganItem.file.name.split(".").pop() || "jpg"
).toLowerCase();
const _jenis = timbanganItem.jenisSampah.toLowerCase();
const _beratStr = parseFloat(timbanganItem.weight.toFixed(2))
.toString()
.replace(".", "_");
const _newName = `timbangan${itemIndex + 1}-${_jenis}-${_beratStr}.${_ext}`;
timbanganItem.file = new File([timbanganItem.file], _newName, {
type: timbanganItem.file.type,
lastModified: timbanganItem.file.lastModified,
});
alert(
`Upload foto timbangan #${itemIndex + 1} untuk ${tps.name}\nJenis: ${timbanganItem.jenisSampah}\nBerat: ${timbanganItem.weight} kg\n(Implementasi upload ke server)`,
);
timbanganItem.uploaded = true;
if (!targetItem) {
const form = elements.tpsContentContainer.querySelector("form");
const repeater = form
? form.querySelector(".tps-timbangan-repeater")
: null;
const items = repeater
? repeater.querySelectorAll(".timbangan-item")
: [];
targetItem = items[itemIndex] || null;
}
if (targetItem) {
refreshTimbanganUploadState(targetItem);
}
syncTimbanganToTpsData();
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshSubmitButtonState(form);
saveState();
}
function uploadFotoKedatangan() {
const tps = state.tpsData[state.activeTpsIndex];
if (tps.fotoKedatangan.length === 0) {
alert("Belum ada foto kedatangan yang dipilih!");
return;
}
alert(
`Upload ${tps.fotoKedatangan.length} foto kedatangan untuk ${tps.name}\n(Implementasi upload ke server)`,
);
tps.fotoKedatanganUploaded = true;
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshKedatanganUploadState(form);
saveState();
}
function uploadFotoPetugas() {
const tps = state.tpsData[state.activeTpsIndex];
if (tps.fotoPetugas.length === 0) {
alert("Belum ada foto petugas yang dipilih!");
return;
}
if (!tps.namaPetugas.trim()) {
alert("Nama petugas wajib diisi sebelum upload foto petugas!");
return;
}
alert(
`Upload ${tps.fotoPetugas.length} foto petugas untuk ${tps.name}\n(Implementasi upload ke server)`,
);
tps.fotoPetugasUploaded = true;
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshPetugasUploadState(form);
saveState();
}
function buildSubmitFormData(tps) {
const formData = new FormData();
formData.append("LokasiAngkutID", tps.lokasiAngkutId || "");
formData.append("SpjDetailID", tps.spjDetailId || "");
formData.append("TpsName", tps.name);
formData.append("Latitude", tps.latitude);
formData.append("Longitude", tps.longitude);
formData.append("AlamatJalan", tps.alamatJalan);
formData.append("WaktuKedatangan", tps.waktuKedatangan);
formData.append("TotalTimbangan", tps.totalTimbangan);
formData.append("TotalOrganik", tps.totalOrganik);
formData.append("TotalAnorganik", tps.totalAnorganik);
formData.append("TotalResidu", tps.totalResidu);
formData.append("NamaPetugas", tps.namaPetugas);
tps.fotoKedatangan.forEach((file) => {
if (isBrowserFile(file)) {
formData.append("FotoKedatangan", file);
}
});
tps.timbangan.forEach((timb) => {
if (isBrowserFile(timb.file)) formData.append("FotoTimbangan", timb.file);
formData.append("BeratTimbangan", timb.weight);
formData.append("JenisSampahList", timb.jenisSampah);
});
tps.fotoPetugas.forEach((file) => {
if (isBrowserFile(file)) {
formData.append("FotoPetugas", file);
}
});
const antiForgeryTokenEl = document.querySelector(
'#upst-antiforgery input[name="__RequestVerificationToken"]',
);
if (antiForgeryTokenEl && antiForgeryTokenEl.value) {
formData.append("__RequestVerificationToken", antiForgeryTokenEl.value);
}
return formData;
}
function markTpsSubmitted(tps) {
tps.submitted = true;
if (state.selectedTpsList.length > 1) {
renderTabs();
}
saveState();
}
function clearStateIfAllSubmitted() {
if (
state.tpsData.length > 0 &&
state.tpsData.every((item) => item.submitted)
) {
clearState();
}
}
function submitTpsData() {
const tps = state.tpsData[state.activeTpsIndex];
const submitState = getSubmitState(tps);
if (!submitState.canSubmit) {
alert(submitState.message);
return;
}
// MODE STATIK (aktif sekarang)
// Cuma validasi + tandai TPS selesai, ga kirim ke backend.
markTpsSubmitted(tps);
clearStateIfAllSubmitted();
alert(
`Validasi ${tps.name} OK. Data belum dikirim ke server (mode statik).`,
);
// MODE PRODUCTION (aktifkan kalau backend udah ready mas ebik)
/*
const formData = buildSubmitFormData(tps);
fetch('/upst/detail-penjemputan', {
method: 'POST',
body: formData
})
.then(async response => {
if (response.ok) {
markTpsSubmitted(tps);
clearStateIfAllSubmitted();
alert(`Data ${tps.name} berhasil disimpan!`);
window.location.reload();
} else {
const errorText = await response.text();
if (response.status === 400) {
alert('Sesi submit tidak valid. Silakan refresh halaman lalu coba lagi.');
} else {
alert(errorText || 'Gagal menyimpan data. Silakan coba lagi.');
}
}
})
.catch(error => {
console.error('Error:', error);
alert('Terjadi kesalahan saat menyimpan data.');
});
*/
}
function formatWeightDisplay(value) {
if (isNaN(value)) return "0,00";
return value.toFixed(2).replace(".", ",");
}
function parseWeightInput(value) {
if (!value) return 0;
const cleaned = value
.toString()
.trim()
.replace(/\s/g, "")
.replace(",", ".");
const parsed = parseFloat(cleaned);
return isNaN(parsed) ? 0 : parsed;
}
function formatFileSize(bytes) {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(2)} MB`;
}
return {
init: init,
hydrateFromApi: applyApiDraftData,
setNomorSpj: function (nomorSpj) {
state.nomorSpj = nomorSpj;
saveState();
},
};
})();
document.addEventListener("DOMContentLoaded", async function () {
try {
const response = await fetch("/driver/json/tps-list.json");
const data = await response.json();
const tpsList = data.tpsList.map((tps) => ({
name: tps.name,
lokasiAngkutId: tps.lokasiAngkutId,
spjDetailId: tps.spjDetailId,
id: tps.id,
}));
DetailPenjemputan.init(tpsList);
if (data.draftPenjemputan || data.tpsData) {
DetailPenjemputan.hydrateFromApi(data.draftPenjemputan || data.tpsData);
}
} catch (error) {
console.error("Error loading TPS list:", error);
}
const platNomorEl = document.getElementById("plat-nomor");
if (platNomorEl) {
const nomorSpjEl = document.querySelector(".text-gray-600.font-mono");
if (nomorSpjEl) {
DetailPenjemputan.setNomorSpj(nomorSpjEl.textContent.trim());
}
}
});