diff --git a/Controllers/SpjDriverUpstController/DetailController.cs b/Controllers/SpjDriverUpstController/DetailController.cs index b9a0e2a..d6176bb 100644 --- a/Controllers/SpjDriverUpstController/DetailController.cs +++ b/Controllers/SpjDriverUpstController/DetailController.cs @@ -159,6 +159,114 @@ namespace eSPJ.Controllers.SpjDriverUpstController Response.Headers["Expires"] = "0"; } + private static string ExtractOcrMessageContent(JsonElement messageContent) + { + if (messageContent.ValueKind == JsonValueKind.String) + { + return messageContent.GetString() ?? string.Empty; + } + + if (messageContent.ValueKind == JsonValueKind.Array) + { + var parts = new List(); + foreach (var item in messageContent.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + var text = item.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + { + parts.Add(text); + } + continue; + } + + if (item.ValueKind == JsonValueKind.Object + && item.TryGetProperty("text", out var textProp) + && textProp.ValueKind == JsonValueKind.String) + { + var text = textProp.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + { + parts.Add(text); + } + } + } + + return string.Join(" ", parts); + } + + return string.Empty; + } + + private static decimal? TryParseWeightFromOcrContent(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return null; + } + + foreach (var rawLine in content.Split('\n')) + { + var line = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + line = line.Trim('"', '\'', '`', '.', ';', ':'); + + if (Regex.IsMatch(line, @"^-?\d{1,6}[.,]\d{2}$")) + { + var normalized = line.Replace(',', '.'); + if (decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var value)) + { + return value; + } + } + + if (Regex.IsMatch(line, @"^\d{4}$") + && int.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out var int4Value)) + { + return int4Value / 100m; + } + } + + var looksExplanatory = Regex.IsMatch(content, @"[A-Za-z]") + || content.Contains("contoh", StringComparison.OrdinalIgnoreCase) + || content.Contains("misal", StringComparison.OrdinalIgnoreCase); + if (looksExplanatory) + { + return null; + } + + var decMatch = Regex.Match(content, @"-?\d{1,6}[.,]\d{2}"); + if (decMatch.Success) + { + var normalized = decMatch.Value.Replace(',', '.'); + if (decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var decValue)) + { + return decValue; + } + } + + var int4Match = Regex.Match(content, @"\b\d{4}\b"); + if (int4Match.Success + && int.TryParse(int4Match.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fallbackInt4)) + { + return fallbackInt4 / 100m; + } + + return null; + } + + + + private void TryDeleteFile(string path) + { + try { System.IO.File.Delete(path); } catch { /* best-effort cleanup */ } + } + private async Task ResolveRecordSaveRequestAsync() { Request.EnableBuffering(); @@ -701,155 +809,146 @@ namespace eSPJ.Controllers.SpjDriverUpstController return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." }); } + [HttpPost("ocr-timbangan")] + [IgnoreAntiforgeryToken] + public async Task OcrTimbangan(IFormFile? Foto) + { + if (Foto == null || Foto.Length == 0) + { + return BadRequest(new { success = false, message = "Foto tidak ditemukan." }); + } + + if (Foto.Length > 5 * 1024 * 1024) + { + return BadRequest(new { success = false, message = "Ukuran foto terlalu besar. Maksimal 5MB." }); + } + + var apiKey = _configuration["OpenRouter:OCRkey"]; + if (string.IsNullOrWhiteSpace(apiKey)) + { + return StatusCode(500, new { success = false, message = "OpenRouter API key belum diset." }); + } + + byte[] fileBytes; + await using (var ms = new MemoryStream()) + { + await Foto.CopyToAsync(ms); + fileBytes = ms.ToArray(); + } - [HttpPost("ocr-timbangan")] - [IgnoreAntiforgeryToken] - public async Task OcrTimbangan(IFormFile? Foto) + var mimeType = "image/jpeg"; + var base64 = Convert.ToBase64String(fileBytes); + var dataUrl = $"data:{mimeType};base64,{base64}"; + + var payload = new + { + model = "openai/gpt-5-image-mini", + messages = new object[] { - if (Foto == null || Foto.Length == 0) + new { - return BadRequest(new { success = false, message = "Foto tidak ditemukan." }); - } - - if (Foto.Length > 5 * 1024 * 1024) - { - return BadRequest(new { success = false, message = "Ukuran foto terlalu besar. Maksimal 5MB." }); - } - - var apiKey = _configuration["OpenRouter:OCRkey"]; - if (string.IsNullOrWhiteSpace(apiKey)) - { - return StatusCode(500, new { success = false, message = "OpenRouter API key belum diset." }); - } - - byte[] fileBytes; - await using (var ms = new MemoryStream()) - { - await Foto.CopyToAsync(ms); - fileBytes = ms.ToArray(); - } - - var mimeType = string.IsNullOrWhiteSpace(Foto.ContentType) ? "image/jpeg" : Foto.ContentType; - var base64 = Convert.ToBase64String(fileBytes); - var dataUrl = $"data:{mimeType};base64,{base64}"; - - var payload = new - { - // model = "nvidia/nemotron-nano-12b-v2-vl:free", - model = "google/gemini-2.5-flash-image", - // model = "google/gemini-2.5-flash-lite", - // model = "google/gemini-2.5-flash-lite-preview-09-2025", - temperature = 0, - messages = new object[] + role = "user", + content = new object[] { new { - role = "user", - content = new object[] - { - new - { - type = "text", - text = @" - Baca angka berat timbangan digital pada foto. - - Rules: - - Abaikan tulisan seperti ZERO, TARE, STABLE, AC, PACK, PCS, KG, ADD, HOLD. - - Jawab hanya angka dengan format 2 digit desimal pakai titik (contoh: 54.45). - - Jika tidak terbaca jawab: UNREADABLE - - Fokus pada angka layar LED merah yang menyala. - - Abaikan refleksi atau pantulan cahaya yang mungkin muncul di layar. - - Abaikan timestamp seperti tanggal, jam, atau informasi lain yang biasanya muncul di layar timbangan. - " - }, - - new - { - type = "image_url", - image_url = new { url = dataUrl } - } - } + type = "text", + text = @" + Baca angka pada display timbangan digital dari gambar ini. + Fokus hanya pada digit display, abaikan refleksi, cahaya merah, dan teks lain. + Keluarkan tepat satu baris angka saja. + Format output wajib NNN,NN (gunakan koma sebagai desimal dua digit). + Jika tidak yakin atau angka tidak terbaca, keluarkan 0,00. + Jangan tambahkan kata, kalimat, atau simbol lain. + " + }, + new + { + type = "image_url", + image_url = new { url = dataUrl } } } - }; - - var json = JsonSerializer.Serialize(payload); - - var request = new HttpRequestMessage(HttpMethod.Post, "https://openrouter.ai/api/v1/chat/completions"); - request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); - request.Headers.TryAddWithoutValidation("Accept", "application/json"); - request.Headers.TryAddWithoutValidation("HTTP-Referer", "https://pesapakawan.dinaslhdki.id"); - request.Headers.TryAddWithoutValidation("X-Title", "eSPJ OCR Timbangan"); - - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - - var client = _httpClientFactory.CreateClient(); - using var response = await client.SendAsync(request); - var responseText = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - return StatusCode((int)response.StatusCode, new - { - success = false, - message = "OpenRouter request gagal.", - detail = responseText - }); } + } + }; - using var doc = JsonDocument.Parse(responseText); + var json = JsonSerializer.Serialize(payload); - var content = doc.RootElement - .GetProperty("choices")[0] - .GetProperty("message") - .GetProperty("content") - .GetString() ?? ""; + var request = new HttpRequestMessage(HttpMethod.Post, "https://openrouter.ai/api/v1/chat/completions"); + request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); + request.Headers.TryAddWithoutValidation("Accept", "application/json"); + request.Headers.TryAddWithoutValidation("HTTP-Referer", "https://pesapakawan.dinaslhdki.id"); + request.Headers.TryAddWithoutValidation("X-Title", "eSPJ OCR Timbangan"); - content = content.Trim(); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - if (content.Contains("UNREADABLE", StringComparison.OrdinalIgnoreCase)) - { - return Ok(new - { - success = false, - message = "Angka tidak terbaca.", - raw = content - }); - } + var client = _httpClientFactory.CreateClient(); + using var response = await client.SendAsync(request); + var responseText = await response.Content.ReadAsStringAsync(); - // cari format angka 2 desimal - var match = Regex.Match(content, @"-?\d{1,5}([.,]\d{2})"); + if (!response.IsSuccessStatusCode) + { + return StatusCode((int)response.StatusCode, new + { + success = false, + message = "OpenRouter request gagal.", + detail = responseText + }); + } - if (!match.Success) - { - return Ok(new - { - success = false, - message = "AI tidak menemukan angka valid.", - raw = content - }); - } + string content; + try + { + using var doc = JsonDocument.Parse(responseText); + var messageContent = doc.RootElement + .GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content"); + content = ExtractOcrMessageContent(messageContent); + } + catch + { + return Ok(new + { + success = false, + message = "Gagal membaca respons OCR.", + raw = responseText + }); + } - var normalized = match.Value.Replace(',', '.'); + content = content.Trim(); - if (!decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var weight)) - { - return Ok(new - { - success = false, - message = "Format angka AI tidak valid.", - raw = content - }); - } + if (content.Contains("UNREADABLE", StringComparison.OrdinalIgnoreCase)) + { + return Ok(new + { + success = false, + message = "Angka tidak terbaca.", + raw = content + }); + } - return Ok(new + var weight = TryParseWeightFromOcrContent(content); + + if (weight == null) + { + return Ok(new + { + success = false, + message = "AI tidak menemukan angka valid.", + raw = content + }); + } + + var weightStr = weight.Value.ToString("0.00", CultureInfo.GetCultureInfo("id-ID")); + + return Ok(new { success = true, - weight = weight.ToString("0.00", CultureInfo.InvariantCulture), + weight = weightStr, raw = content }); } - } } diff --git a/Controllers/SpjDriverUpstController/SubmitController.cs b/Controllers/SpjDriverUpstController/SubmitController.cs index a8f1f0b..ecd4665 100644 --- a/Controllers/SpjDriverUpstController/SubmitController.cs +++ b/Controllers/SpjDriverUpstController/SubmitController.cs @@ -62,7 +62,6 @@ namespace eSPJ.Controllers.SpjDriverUpstController var payload = new { model = "google/gemini-2.5-flash-image", - temperature = 0, messages = new object[] { new diff --git a/wwwroot/driver/css/watch.css b/wwwroot/driver/css/watch.css index 8109ef7..cf95114 100644 --- a/wwwroot/driver/css/watch.css +++ b/wwwroot/driver/css/watch.css @@ -115,6 +115,7 @@ --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25 / 1.875); + --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; @@ -820,6 +821,9 @@ .mb-auto { margin-bottom: auto; } + .ml-1 { + margin-left: calc(var(--spacing) * 1); + } .ml-4 { margin-left: calc(var(--spacing) * 4); } @@ -1538,6 +1542,9 @@ .border-blue-200 { border-color: var(--color-blue-200); } + .border-blue-500 { + border-color: var(--color-blue-500); + } .border-cyan-300 { border-color: var(--color-cyan-300); } @@ -1577,6 +1584,9 @@ .border-green-400 { border-color: var(--color-green-400); } + .border-green-500 { + border-color: var(--color-green-500); + } .border-lime-400 { border-color: var(--color-lime-400); } @@ -1607,6 +1617,9 @@ .border-red-400 { border-color: var(--color-red-400); } + .border-red-500 { + border-color: var(--color-red-500); + } .border-slate-200 { border-color: var(--color-slate-200); } @@ -2459,6 +2472,10 @@ --tw-font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium); } + .font-normal { + --tw-font-weight: var(--font-weight-normal); + font-weight: var(--font-weight-normal); + } .font-semibold { --tw-font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold); @@ -2844,6 +2861,10 @@ --tw-drop-shadow: drop-shadow(var(--drop-shadow-lg)); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .grayscale { + --tw-grayscale: grayscale(100%); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); diff --git a/wwwroot/driver/js/detail-penjemputan-non-tps.js b/wwwroot/driver/js/detail-penjemputan-non-tps.js index 095487d..2575526 100644 --- a/wwwroot/driver/js/detail-penjemputan-non-tps.js +++ b/wwwroot/driver/js/detail-penjemputan-non-tps.js @@ -81,7 +81,7 @@ document.addEventListener('DOMContentLoaded', async function() { fotoKedatanganUploaded: tps.fotoKedatanganUploaded || false, timbangan: (tps.timbangan || []).map(t => ({ berat: (t.berat && t.berat.length > 0) ? t.berat[0] : 0, - jenisSampah: (t.jenisSampah && t.jenisSampah.length > 0) ? t.jenisSampah[0] : DEFAULT_JENIS, + jenisSampah: normalizeJenisSampahValue((t.jenisSampah && t.jenisSampah.length > 0) ? t.jenisSampah[0] : DEFAULT_JENIS), fotoFileName: t.fotoFileName || '', uploaded: t.uploaded || false, ocrInfo: t.ocrInfo || '' @@ -214,6 +214,11 @@ document.addEventListener('DOMContentLoaded', async function() { } else { tps.timbangan = []; } + + const TAB_VISUAL_ORDER = ['Residu', 'Organik', 'Anorganik']; + tps.activeTimbanganTab = TAB_VISUAL_ORDER.find(j => + tps.timbangan.some(t => normalizeJenisSampahValue(t.jenisSampah && t.jenisSampah.length > 0 ? t.jenisSampah[0] : '') === j) + ) || tps.activeTimbanganTab || DEFAULT_JENIS; } async function loadRecordForCurrentSpj() { @@ -307,6 +312,7 @@ document.addEventListener('DOMContentLoaded', async function() { fotoKedatanganFileNames: [], fotoKedatanganUploaded: false, timbangan: [], + activeTimbanganTab: DEFAULT_JENIS, totalOrganik: 0, totalAnorganik: 0, totalResidu: 0, @@ -376,6 +382,25 @@ document.addEventListener('DOMContentLoaded', async function() { function renderTpsForm() { const tps = tpsData[activeTpsIndex]; const submitState = getSubmitState(tps); + const activeJenis = normalizeJenisSampahValue( + tps.activeTimbanganTab || DEFAULT_JENIS, + ); + const INACTIVE_TAB = 'bg-gray-100 text-gray-500 border-gray-200'; + const TAB_CONFIG = [ + { key: 'Residu', active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB }, + { key: 'Organik', active: 'bg-green-500 text-white border-green-500', inactive: INACTIVE_TAB }, + { key: 'Anorganik', active: 'bg-blue-500 text-white border-blue-500', inactive: INACTIVE_TAB }, + ]; + const tabsMarkup = TAB_CONFIG.map((tab) => { + const count = (tps.timbangan || []).filter((item) => { + const v = item?.jenisSampah && item.jenisSampah.length > 0 ? item.jenisSampah[0] : DEFAULT_JENIS; + return normalizeJenisSampahValue(v) === tab.key; + }).length; + const isActive = activeJenis === tab.key; + const colorClass = isActive ? tab.active : tab.inactive; + return ``; + }).join(""); + const actionMarkup = tps.submitted ? `
@@ -449,11 +474,21 @@ document.addEventListener('DOMContentLoaded', async function() {
-
+
+ ${tabsMarkup} +
- +
+
+ + +
@@ -577,7 +612,7 @@ document.addEventListener('DOMContentLoaded', async function() { 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"); + const tabButtons = form.querySelectorAll('.timbangan-tab-btn'); fotoKedatanganInput.addEventListener('change', function() { if (!this.files || !this.files[0]) return; @@ -633,12 +668,119 @@ document.addEventListener('DOMContentLoaded', async function() { scheduleAutoSave(); }); - btnAddTimbangan.addEventListener("click", function () { - createTimbanganItem(form.querySelector(".tps-timbangan-repeater")); - syncTimbanganToTpsData(); - refreshSubmitButtonState(form); + const INACTIVE_TAB_COLOR = 'bg-gray-100 text-gray-500 border-gray-200'; + const TAB_COLORS = { + 'Residu': { active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB_COLOR }, + 'Organik': { active: 'bg-green-500 text-white border-green-500', inactive: INACTIVE_TAB_COLOR }, + 'Anorganik': { active: 'bg-blue-500 text-white border-blue-500', inactive: INACTIVE_TAB_COLOR }, + }; + tabButtons.forEach((btn) => { + btn.addEventListener('click', function () { + const nextJenis = this.dataset.jenis; + const current = tpsData[activeTpsIndex]; + if (!current) return; + syncTimbanganToTpsData(); + current.activeTimbanganTab = nextJenis; + tabButtons.forEach(b => { + const cfg = TAB_COLORS[b.dataset.jenis] || { active: 'bg-upst text-white border-upst', inactive: INACTIVE_TAB_COLOR }; + b.className = `timbangan-tab-btn flex-1 py-2 px-1 rounded-xl text-xs font-bold border transition ${b.dataset.jenis === nextJenis ? cfg.active : cfg.inactive}`; + }); + const jenisLabel = form.querySelector('.active-jenis-label'); + if (jenisLabel) jenisLabel.textContent = nextJenis; + renderTimbanganRepeater(); + }); }); + const multiFileInput = form.querySelector('.input-foto-timbangan-multi'); + if (multiFileInput) { + multiFileInput.addEventListener('change', async function() { + if (!this.files || !this.files.length) return; + const currentTps = tpsData[activeTpsIndex]; + const activeJenis = normalizeJenisSampahValue(currentTps.activeTimbanganTab || DEFAULT_JENIS); + const files = Array.from(this.files); + const progressEl = form.querySelector('.timbangan-multi-progress'); + if (progressEl) { + progressEl.textContent = `Memproses ${files.length} foto...`; + progressEl.classList.remove('hidden'); + } + + const startIndex = currentTps.timbangan.length; + files.forEach(() => { + currentTps.timbangan.push(createEmptyTimbanganData(activeJenis)); + currentTps.timbangan[currentTps.timbangan.length - 1].ocrInfo = 'AI: memproses...'; + }); + renderTimbanganRepeater(); + + const processFile = async (file, timbanganIndex) => { + try { + const photoNumber = timbanganIndex + 1; + const watermarkedFile = await applyWatermark(file, photoNumber); + currentTps.timbangan[timbanganIndex].file = watermarkedFile; + + const currentForm = tpsContentContainer.querySelector('form'); + const itemEl = currentForm ? currentForm.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null; + if (itemEl) { + const previewWrap = itemEl.querySelector('.input-preview-wrap'); + const previewImg = itemEl.querySelector('.input-preview-image'); + if (previewWrap && previewImg) { + const localUrl = URL.createObjectURL(watermarkedFile); + previewImg.src = localUrl; + previewWrap.classList.remove('hidden'); + previewImg.onload = () => URL.revokeObjectURL(localUrl); + } + } + + const weightInputProxy = { value: '', placeholder: '' }; + const ocrInfoProxy = { textContent: 'AI: memproses...' }; + await autoFillWeight(file, weightInputProxy, ocrInfoProxy); + + const detectedWeight = parseWeightInput(weightInputProxy.value); + currentTps.timbangan[timbanganIndex].berat = [detectedWeight]; + currentTps.timbangan[timbanganIndex].ocrInfo = ocrInfoProxy.textContent; + + const currentForm2 = tpsContentContainer.querySelector('form'); + const itemEl2 = currentForm2 ? currentForm2.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null; + if (itemEl2) { + const ocrInfoEl = itemEl2.querySelector('.input-ocr-info'); + const weightDisplay = itemEl2.querySelector('.input-berat-timbangan-display'); + const weightValue = itemEl2.querySelector('.input-berat-timbangan-value'); + if (ocrInfoEl) ocrInfoEl.textContent = ocrInfoProxy.textContent; + if (weightDisplay) { + if (detectedWeight > 0) { + weightDisplay.value = formatWeightDisplay(detectedWeight); + weightDisplay.placeholder = 'Berat terdeteksi otomatis'; + } else { + weightDisplay.value = ''; + weightDisplay.placeholder = 'Tidak terbaca, isi manual'; + } + } + if (weightValue) weightValue.value = detectedWeight.toFixed(2); + refreshTimbanganUploadState(itemEl2); + } + } catch (_) { + if (currentTps.timbangan[timbanganIndex]) { + currentTps.timbangan[timbanganIndex].ocrInfo = 'AI gagal diproses.'; + } + const currentForm3 = tpsContentContainer.querySelector('form'); + const itemEl3 = currentForm3 ? currentForm3.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null; + if (itemEl3) { + const ocrInfoEl = itemEl3.querySelector('.input-ocr-info'); + if (ocrInfoEl) ocrInfoEl.textContent = 'AI gagal diproses.'; + } + } + updateTpsTotalTimbangan(); + const currentForm4 = tpsContentContainer.querySelector('form'); + if (currentForm4) refreshSubmitButtonState(currentForm4); + }; + + await Promise.all(files.map((file, i) => processFile(file, startIndex + i))); + + if (progressEl) progressEl.classList.add('hidden'); + this.value = ''; + scheduleAutoSave(); + }); + } + const btnUploadKedatangan = form.querySelector( ".tps-btn-upload-kedatangan", ); @@ -658,15 +800,7 @@ document.addEventListener('DOMContentLoaded', async function() { } function restoreTpsTimbanganItems() { - const tps = tpsData[activeTpsIndex]; - const form = 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)); - } + renderTimbanganRepeater(); } function updateWaktuKedatangan() { @@ -1001,26 +1135,38 @@ document.addEventListener('DOMContentLoaded', async function() { if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar..."; try { - const img = await readFileAsImage(file); let bestRawText = ""; let isSuccess = false; - for (const area of OCR_AREAS) { - const cropCanvas = createCropCanvas(img, area); - const cropFile = await canvasToJpegFile( - cropCanvas, - `crop-${area.id}.jpg`, - ); - const aiResult = await requestOpenRouterWeight(cropFile); + const fullResult = await requestOpenRouterWeight(file); + if (fullResult && fullResult.success && fullResult.weight) { + guessedWeight = parseWeightInput(fullResult.weight); + bestRawText = fullResult.raw || fullResult.weight; + isSuccess = guessedWeight > 0; + } else if (fullResult && fullResult.raw) { + bestRawText = fullResult.raw; + } - if (aiResult && aiResult.success && aiResult.weight) { - guessedWeight = parseWeightInput(aiResult.weight); - bestRawText = aiResult.raw || aiResult.weight; - isSuccess = guessedWeight > 0; - if (isSuccess) break; + if (!isSuccess) { + const img = await readFileAsImage(file); + + for (const area of 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 (aiResult && aiResult.raw) bestRawText = aiResult.raw; } if (ocrInfoEl) { @@ -1054,19 +1200,19 @@ document.addEventListener('DOMContentLoaded', async function() { 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 totalResidu += value; - } + (tps.timbangan || []).forEach(function (item) { + const value = item?.berat && item.berat.length > 0 + ? Number(item.berat[0]) || 0 + : 0; + const jenis = normalizeJenisSampahValue( + item?.jenisSampah && item.jenisSampah.length > 0 + ? item.jenisSampah[0] + : DEFAULT_JENIS, + ); + if (jenis === "Organik") totalOrganik += value; + else if (jenis === "Anorganik") totalAnorganik += value; + else totalResidu += value; }); tps.totalOrganik = totalOrganik; @@ -1259,8 +1405,7 @@ document.addEventListener('DOMContentLoaded', async function() { 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 itemIndex = parseInt(item.dataset.timbanganIndex || '-1', 10); const tps = tpsData[activeTpsIndex]; const currentData = itemIndex >= 0 ? tps.timbangan[itemIndex] : null; const fileInput = item.querySelector('.input-foto-timbangan'); @@ -1281,9 +1426,7 @@ document.addEventListener('DOMContentLoaded', async function() { const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan"); if (uploadBtn) { uploadBtn.addEventListener("click", function () { - const latestIndex = repeater - ? Array.from(repeater.children).indexOf(item) - : -1; + const latestIndex = parseInt(item.dataset.timbanganIndex || '-1', 10); uploadSingleFotoTimbangan(latestIndex, item); }); } @@ -1307,48 +1450,96 @@ document.addEventListener('DOMContentLoaded', async function() { }); } - function createTimbanganItem(repeater, existingData = null) { - const photoNumber = repeater.children.length + 1; + function createEmptyTimbanganData(jenis) { + const normalizedJenis = normalizeJenisSampahValue(jenis || DEFAULT_JENIS); + return { + file: null, + fotoFileName: '', + berat: [0], + jenisSampah: [normalizedJenis], + lokasiAngkut: [], + uploaded: false, + ocrInfo: 'OCR: belum diproses.' + }; + } + + function renderTimbanganRepeater() { + const tps = tpsData[activeTpsIndex]; + const form = tpsContentContainer.querySelector('form'); + if (!form) return; + const repeater = form.querySelector('.tps-timbangan-repeater'); + if (!repeater) return; + + const activeTab = normalizeJenisSampahValue(tps.activeTimbanganTab || DEFAULT_JENIS); + repeater.innerHTML = ''; + + (tps.timbangan || []).forEach((timb, index) => { + const jenis = normalizeJenisSampahValue( + timb?.jenisSampah && timb.jenisSampah.length > 0 ? timb.jenisSampah[0] : DEFAULT_JENIS, + ); + if (jenis === activeTab) { + createTimbanganItem(repeater, index, timb); + } + }); + + form.querySelectorAll('.timbangan-tab-btn').forEach((btn) => { + const jenis = btn.dataset.jenis; + const count = (tps.timbangan || []).filter((timb) => { + const v = timb?.jenisSampah && timb.jenisSampah.length > 0 ? timb.jenisSampah[0] : DEFAULT_JENIS; + return normalizeJenisSampahValue(v) === jenis; + }).length; + const span = btn.querySelector('.timbangan-tab-count'); + if (span) span.textContent = count > 0 ? count + ' foto' : ''; + }); + + updateTpsTotalTimbangan(); + } + + function createTimbanganItem(repeater, timbanganIndex, existingData = null) { + const photoNumber = timbanganIndex + 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; + item.dataset.timbanganIndex = String(timbanganIndex); + const activeJenis = normalizeJenisSampahValue((tpsData[activeTpsIndex] && tpsData[activeTpsIndex].activeTimbanganTab) || DEFAULT_JENIS); const weight = existingData ? (existingData.berat && existingData.berat.length > 0 ? existingData.berat[0] : 0) : 0; - const jenisSampah = existingData ? (existingData.jenisSampah && existingData.jenisSampah.length > 0 ? existingData.jenisSampah[0] : DEFAULT_JENIS) : DEFAULT_JENIS; - const hasFileBlob = Boolean(existingData?.file); + const jenisSampah = existingData + ? normalizeJenisSampahValue(existingData.jenisSampah && existingData.jenisSampah.length > 0 ? existingData.jenisSampah[0] : activeJenis) + : activeJenis; + item.dataset.jenisSampah = jenisSampah; const hasFile = Boolean(existingData?.file || existingData?.fotoFileName); const isUploaded = Boolean(existingData?.uploaded); const ocrInfoText = existingData && existingData.ocrInfo ? existingData.ocrInfo : (hasFile ? 'OCR: diproses.' : 'OCR: belum diproses.'); + const BADGE_COLORS = { + 'Organik': 'bg-green-100 text-green-700', + 'Anorganik': 'bg-blue-100 text-blue-700', + 'Residu': 'bg-red-100 text-red-700', + }; + const badgeColor = BADGE_COLORS[jenisSampah] || 'bg-gray-100 text-gray-600'; + item.innerHTML = `
-

Item Timbangan #${photoNumber}

- +
+

Foto #${photoNumber}

+ ${jenisSampah} +
+
- -
+
Preview foto timbangan -

${ocrInfoText}

-
-
- - -
-
- - - -
+
+ + +
${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}
`; - 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"); @@ -1356,7 +1547,6 @@ document.addEventListener('DOMContentLoaded', async function() { ".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 && existingData.file) { @@ -1369,38 +1559,6 @@ document.addEventListener('DOMContentLoaded', async function() { previewWrap.classList.remove('hidden'); } - fileInput.addEventListener('change', async function() { - if (fileInput.files && fileInput.files[0]) { - const originalFile = fileInput.files[0]; - const photoNumber = Number(item.dataset.photoNumber || (Array.from(repeater.children).indexOf(item) + 1)); - 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 = tpsData[activeTpsIndex]; - const itemIndex = Array.from(repeater.children).indexOf(item); - if (itemIndex >= 0 && tps.timbangan[itemIndex]) { - tps.timbangan[itemIndex].uploaded = false; - refreshTimbanganUploadState(item); - } - } - }); - weightInputDisplay.addEventListener("input", function () { const cleaned = this.value.replace(/[^0-9.,]/g, ""); this.value = cleaned; @@ -1431,24 +1589,16 @@ document.addEventListener('DOMContentLoaded', async function() { scheduleAutoSave(); }); - jenisSampahSelect.addEventListener('change', function() { - updateTpsTotalTimbangan(); - syncTimbanganToTpsData(); - const form = tpsContentContainer.querySelector('form'); - if (form) refreshSubmitButtonState(form); - }); - removeBtn.addEventListener('click', function() { - item.remove(); + const tps = tpsData[activeTpsIndex]; + const idx = parseInt(item.dataset.timbanganIndex || '-1', 10); + if (!Number.isNaN(idx) && idx >= 0) { + tps.timbangan.splice(idx, 1); + } + renderTimbanganRepeater(); const form = tpsContentContainer.querySelector('form'); - const rep = form ? form.querySelector('.tps-timbangan-repeater') : null; - if (rep) { - renumberTimbanganItems(rep); - if (rep.children.length === 0) createTimbanganItem(rep); - } - updateTpsTotalTimbangan(); - syncTimbanganToTpsData(); if (form) refreshSubmitButtonState(form); + scheduleAutoSave(); }); repeater.appendChild(item); @@ -1463,25 +1613,25 @@ document.addEventListener('DOMContentLoaded', async function() { 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'); + items.forEach((item) => { + const dataIndex = parseInt(item.dataset.timbanganIndex || '-1', 10); + if (Number.isNaN(dataIndex) || dataIndex < 0) return; const weightValue = item.querySelector('.input-berat-timbangan-value'); const weight = parseWeightInput(weightValue.value); - const jenisSampah = item.querySelector('.input-jenis-sampah').value; + const jenisSampah = normalizeJenisSampahValue(item.dataset.jenisSampah || DEFAULT_JENIS); const ocrInfo = item.querySelector('.input-ocr-info')?.textContent || 'OCR: belum diproses.'; - - tps.timbangan.push({ - file: fileInput.files[0] || previousTimbangan[index]?.file || null, - fotoFileName: previousTimbangan[index]?.fotoFileName || '', + + const existing = tps.timbangan[dataIndex] || createEmptyTimbanganData(jenisSampah); + tps.timbangan[dataIndex] = { + ...existing, + file: existing.file || null, + fotoFileName: existing.fotoFileName || '', berat: [weight], jenisSampah: [jenisSampah], lokasiAngkut: [], - uploaded: previousTimbangan[index]?.uploaded ?? false, + uploaded: existing.uploaded ?? false, ocrInfo - }); + }; }); } @@ -1548,13 +1698,9 @@ document.addEventListener('DOMContentLoaded', async function() { if (!targetItem) { const form = tpsContentContainer.querySelector("form"); - const repeater = form - ? form.querySelector(".tps-timbangan-repeater") + targetItem = form + ? form.querySelector(`.timbangan-item[data-timbangan-index="${itemIndex}"]`) : null; - const items = repeater - ? repeater.querySelectorAll(".timbangan-item") - : []; - targetItem = items[itemIndex] || null; } const uploadBtn = targetItem ? targetItem.querySelector('.btn-upload-timbangan') : null; diff --git a/wwwroot/driver/js/detail-penjemputan-tps.js b/wwwroot/driver/js/detail-penjemputan-tps.js index 10d7680..b7f4d26 100644 --- a/wwwroot/driver/js/detail-penjemputan-tps.js +++ b/wwwroot/driver/js/detail-penjemputan-tps.js @@ -201,10 +201,12 @@ const DetailPenjemputan = (function () { item.fotoFileName, ); const weight = Number(item.weight ?? getFirstValue(item.berat) ?? 0) || 0; - const jenisSampah = - getFirstValue(item.jenisSampah) || - item.JenisSampah || - CONFIG.DEFAULT_JENIS; + const _rawJenis = getFirstValue(item.jenisSampah); + const jenisSampah = (_rawJenis != null) + ? (typeof _rawJenis === 'number' + ? (CONFIG.JENIS_SAMPAH[_rawJenis] || CONFIG.DEFAULT_JENIS) + : CONFIG.JENIS_SAMPAH.find(j => j.toLowerCase() === String(_rawJenis).trim().toLowerCase()) || CONFIG.DEFAULT_JENIS) + : (item.JenisSampah && CONFIG.JENIS_SAMPAH.find(j => j.toLowerCase() === String(item.JenisSampah).trim().toLowerCase()) || CONFIG.DEFAULT_JENIS); const uploaded = Boolean( item.uploaded ?? item.isUploaded ?? @@ -269,6 +271,9 @@ const DetailPenjemputan = (function () { ? apiTimbangan.map(normalizeTimbanganItem) : currentTps.timbangan; + const TAB_VISUAL_ORDER = ['Residu', 'Organik', 'Anorganik']; + const restoredActiveTab = TAB_VISUAL_ORDER.find(j => timbangan.some(t => t.jenisSampah === j)) || currentTps.activeTimbanganTab || CONFIG.DEFAULT_JENIS; + return { ...currentTps, nomorSpj: apiTps.nomorSpj || apiTps.NomorSpj || currentTps.nomorSpj, @@ -331,6 +336,7 @@ const DetailPenjemputan = (function () { ), submittedAt: apiTps.submittedAt || apiTps.SubmittedAt || currentTps.submittedAt, + activeTimbanganTab: restoredActiveTab, }; }); @@ -627,6 +633,7 @@ const DetailPenjemputan = (function () { fotoKedatanganFileNames: [], fotoKedatanganUploaded: false, timbangan: [], + activeTimbanganTab: CONFIG.DEFAULT_JENIS, totalOrganik: 0, totalAnorganik: 0, totalResidu: 0, @@ -829,23 +836,47 @@ const DetailPenjemputan = (function () { } function renderSection2Timbangan(tps, showTpsName) { + const activeTab = tps.activeTimbanganTab || CONFIG.DEFAULT_JENIS; + const INACTIVE_TAB = 'bg-gray-100 text-gray-500 border-gray-200'; + const TAB_CONFIG = [ + { key: 'Residu', active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB }, + { key: 'Organik', active: 'bg-green-500 text-white border-green-500', inactive: INACTIVE_TAB }, + { key: 'Anorganik', active: 'bg-blue-500 text-white border-blue-500', inactive: INACTIVE_TAB }, + ]; + const tabsHtml = TAB_CONFIG.map(tab => { + const count = (tps.timbangan || []).filter(t => (t.jenisSampah || CONFIG.DEFAULT_JENIS) === tab.key).length; + const isActive = tab.key === activeTab; + const colorClass = isActive ? tab.active : tab.inactive; + return ``; + }).join(''); + return ` -
-
-
2
-
-

Foto Timbang Sampah

-

Upload foto timbangan, berat auto terisi

-
-
- -
- - -
- `; +
+
+
2
+
+

Foto Timbang Sampah

+

Pilih tab, upload banyak foto sekaligus

+
+
+
+ ${tabsHtml} +
+
+
+ + +
+
+ `; } function renderSection3Petugas(tps) { @@ -881,7 +912,6 @@ const DetailPenjemputan = (function () { 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() { if (!this.files || !this.files[0]) return; @@ -937,11 +967,123 @@ const DetailPenjemputan = (function () { scheduleAutoSave(state.activeTpsIndex); }); - btnAddTimbangan.addEventListener('click', function() { - createTimbanganItem(form.querySelector('.tps-timbangan-repeater')); - syncTimbanganToTpsData(); - refreshSubmitButtonState(form); + const INACTIVE_TAB_COLOR = 'bg-gray-100 text-gray-500 border-gray-200'; + const TAB_COLORS = { + 'Residu': { active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB_COLOR }, + 'Organik': { active: 'bg-green-500 text-white border-green-500', inactive: INACTIVE_TAB_COLOR }, + 'Anorganik': { active: 'bg-blue-500 text-white border-blue-500', inactive: INACTIVE_TAB_COLOR }, + }; + const tabBtns = form.querySelectorAll('.timbangan-tab-btn'); + tabBtns.forEach(btn => { + btn.addEventListener('click', function() { + const jenis = this.dataset.jenis; + const currentTps = state.tpsData[state.activeTpsIndex]; + currentTps.activeTimbanganTab = jenis; + tabBtns.forEach(b => { + const cfg = TAB_COLORS[b.dataset.jenis] || { active: 'bg-upst text-white border-upst', inactive: 'bg-gray-50 text-gray-600 border-gray-200' }; + b.className = `timbangan-tab-btn flex-1 py-2 px-1 rounded-xl text-xs font-bold border transition ${b.dataset.jenis === jenis ? cfg.active : cfg.inactive}`; }); + const jenisLabel = form.querySelector('.active-jenis-label'); + if (jenisLabel) jenisLabel.textContent = jenis; + renderTimbanganRepeater(); + }); + }); + + const multiFileInput = form.querySelector('.input-foto-timbangan-multi'); + if (multiFileInput) { + multiFileInput.addEventListener('change', async function() { + if (!this.files || !this.files.length) return; + const currentTps = state.tpsData[state.activeTpsIndex]; + const activeJenis = currentTps.activeTimbanganTab || CONFIG.DEFAULT_JENIS; + const files = Array.from(this.files); + const progressEl = form.querySelector('.timbangan-multi-progress'); + if (progressEl) { + progressEl.textContent = `Memproses ${files.length} foto...`; + progressEl.classList.remove('hidden'); + } + + const startIndex = currentTps.timbangan.length; + files.forEach(() => { + currentTps.timbangan.push({ + file: null, + fotoFileName: '', + weight: 0, + jenisSampah: activeJenis, + uploaded: false, + ocrInfo: 'AI: memproses...', + }); + }); + renderTimbanganRepeater(); + + const processFile = async (file, timbanganIndex) => { + try { + const photoNumber = timbanganIndex + 1; + const watermarkedFile = await applyWatermark(file, photoNumber); + currentTps.timbangan[timbanganIndex].file = watermarkedFile; + + const currentForm = elements.tpsContentContainer.querySelector('form'); + const itemEl = currentForm ? currentForm.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null; + if (itemEl) { + const previewWrap = itemEl.querySelector('.input-preview-wrap'); + const previewImg = itemEl.querySelector('.input-preview-image'); + if (previewWrap && previewImg) { + const localUrl = URL.createObjectURL(watermarkedFile); + previewImg.src = localUrl; + previewWrap.classList.remove('hidden'); + previewImg.onload = () => URL.revokeObjectURL(localUrl); + } + } + + const weightInputProxy = { value: '', placeholder: '' }; + const ocrInfoProxy = { textContent: 'AI: memproses...' }; + await autoFillWeight(file, weightInputProxy, ocrInfoProxy); + + const detectedWeight = parseWeightInput(weightInputProxy.value); + currentTps.timbangan[timbanganIndex].weight = detectedWeight; + currentTps.timbangan[timbanganIndex].ocrInfo = ocrInfoProxy.textContent; + + const currentForm2 = elements.tpsContentContainer.querySelector('form'); + const itemEl2 = currentForm2 ? currentForm2.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null; + if (itemEl2) { + const ocrInfoEl = itemEl2.querySelector('.input-ocr-info'); + const weightDisplay = itemEl2.querySelector('.input-berat-timbangan-display'); + const weightValue = itemEl2.querySelector('.input-berat-timbangan-value'); + if (ocrInfoEl) ocrInfoEl.textContent = ocrInfoProxy.textContent; + if (weightDisplay) { + if (detectedWeight > 0) { + weightDisplay.value = formatWeightDisplay(detectedWeight); + weightDisplay.placeholder = 'Berat terdeteksi otomatis'; + } else { + weightDisplay.value = ''; + weightDisplay.placeholder = 'Tidak terbaca, isi manual'; + } + } + if (weightValue) weightValue.value = detectedWeight.toFixed(2); + refreshTimbanganUploadState(itemEl2); + } + } catch (_) { + if (currentTps.timbangan[timbanganIndex]) { + currentTps.timbangan[timbanganIndex].ocrInfo = 'AI gagal diproses.'; + } + const currentForm3 = elements.tpsContentContainer.querySelector('form'); + const itemEl3 = currentForm3 ? currentForm3.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null; + if (itemEl3) { + const ocrInfoEl = itemEl3.querySelector('.input-ocr-info'); + if (ocrInfoEl) ocrInfoEl.textContent = 'AI gagal diproses.'; + } + } + updateTpsTotalTimbangan(); + const currentForm4 = elements.tpsContentContainer.querySelector('form'); + if (currentForm4) refreshSubmitButtonState(currentForm4); + }; + + await Promise.all(files.map((file, i) => processFile(file, startIndex + i))); + + if (progressEl) progressEl.classList.add('hidden'); + this.value = ''; + scheduleAutoSave(state.activeTpsIndex); + }); + } const btnUploadKedatangan = form.querySelector( ".tps-btn-upload-kedatangan", @@ -962,17 +1104,7 @@ const DetailPenjemputan = (function () { } 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); - }); - } + renderTimbanganRepeater(); } function restorePhotoPreview() { @@ -1210,155 +1342,124 @@ const DetailPenjemputan = (function () { }); } - 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) : 0; - const jenisSampah = existingData ? (existingData.jenisSampah || CONFIG.DEFAULT_JENIS) : CONFIG.DEFAULT_JENIS; - const hasFileBlob = isBrowserFile(existingData?.file); + function renderTimbanganRepeater() { + const tps = state.tpsData[state.activeTpsIndex]; + const form = elements.tpsContentContainer.querySelector('form'); + if (!form) return; + const repeater = form.querySelector('.tps-timbangan-repeater'); + if (!repeater) return; + const activeTab = tps.activeTimbanganTab || CONFIG.DEFAULT_JENIS; + repeater.innerHTML = ''; + tps.timbangan.forEach((timb, index) => { + if ((timb.jenisSampah || CONFIG.DEFAULT_JENIS) === activeTab) { + createTimbanganItem(repeater, index, timb); + } + }); + form.querySelectorAll('.timbangan-tab-btn').forEach(btn => { + const jenis = btn.dataset.jenis; + const count = tps.timbangan.filter(t => (t.jenisSampah || CONFIG.DEFAULT_JENIS) === jenis).length; + const countSpan = btn.querySelector('.timbangan-tab-count'); + if (countSpan) countSpan.textContent = count > 0 ? count + ' foto' : ''; + }); + } + + function createTimbanganItem(repeater, timbanganIndex, existingData) { + const tps = state.tpsData[state.activeTpsIndex]; + const weight = existingData?.weight || 0; + const jenisSampah = existingData?.jenisSampah || CONFIG.DEFAULT_JENIS; const hasFile = Boolean(hasStoredPhoto(existingData?.file) || existingData?.fotoFileName); const isUploaded = Boolean(existingData?.uploaded); - const ocrInfoText = existingData && existingData.ocrInfo ? existingData.ocrInfo : (hasFile ? 'OCR: diproses.' : 'OCR: belum diproses.'); - - item.innerHTML = ` -
-

Item Timbangan #${photoNumber}

- -
- -
- Preview foto timbangan -
-

${ocrInfoText}

-
-
- - -
-
- - - -
+ const ocrInfoText = existingData?.ocrInfo || (hasFile ? 'OCR: diproses.' : 'OCR: belum diproses.'); + const photoNumber = timbanganIndex + 1; + const BADGE_COLORS = { + 'Organik': 'bg-green-100 text-green-700', + 'Anorganik': 'bg-blue-100 text-blue-700', + 'Residu': 'bg-red-100 text-red-700', + }; + const badgeColor = BADGE_COLORS[jenisSampah] || 'bg-gray-100 text-gray-600'; -
${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}
+ 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.timbanganIndex = String(timbanganIndex); + + item.innerHTML = ` +
+
+

Foto #${photoNumber}

+ ${jenisSampah}
+ +
+
+ Preview foto timbangan +
+

${ocrInfoText}

+
+ + + +
+
${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}
`; - 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"); + const previewWrap = item.querySelector('.input-preview-wrap'); + const previewImage = item.querySelector('.input-preview-image'); + const weightInputDisplay = item.querySelector('.input-berat-timbangan-display'); + const weightInputValue = item.querySelector('.input-berat-timbangan-value'); + const removeBtn = item.querySelector('.btn-remove-timbangan'); if (existingData && hasStoredPhoto(existingData.file)) { const localUrl = getStoredPhotoUrl(existingData.file); - previewImage.src = localUrl; - previewWrap.classList.remove('hidden'); - previewImage.onload = function() { + previewImage.src = localUrl; + previewWrap.classList.remove('hidden'); + previewImage.onload = function() { if (!isBrowserFile(resolveStoredPhoto(existingData.file))) return; - URL.revokeObjectURL(localUrl); - }; + URL.revokeObjectURL(localUrl); + }; } else if (existingData && existingData.fotoFileName && existingData.fotoFileName.startsWith('/')) { - previewImage.src = existingData.fotoFileName; - previewWrap.classList.remove('hidden'); + previewImage.src = existingData.fotoFileName; + previewWrap.classList.remove('hidden'); } - 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; - tps.timbangan[itemIndex].fotoFileName = ''; - refreshTimbanganUploadState(item); - } - } - }); - 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); - scheduleAutoSave(state.activeTpsIndex); + const cleaned = this.value.replace(/[^0-9.,]/g, ''); + this.value = cleaned; + const parsed = parseWeightInput(cleaned); + weightInputValue.value = parsed.toFixed(2); + const idx = parseInt(item.dataset.timbanganIndex, 10); + if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed; + updateTpsTotalTimbangan(); + refreshTimbanganUploadState(item); + const form = elements.tpsContentContainer.querySelector('form'); + if (form) refreshSubmitButtonState(form); + scheduleAutoSave(state.activeTpsIndex); }); - 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); + 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'; + } + const idx = parseInt(item.dataset.timbanganIndex, 10); + if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed; + updateTpsTotalTimbangan(); + refreshTimbanganUploadState(item); + 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); + const idx = parseInt(item.dataset.timbanganIndex, 10); + tps.timbangan.splice(idx, 1); + renderTimbanganRepeater(); + updateTpsTotalTimbangan(); + const form = elements.tpsContentContainer.querySelector('form'); + if (form) refreshSubmitButtonState(form); + scheduleAutoSave(state.activeTpsIndex); }); repeater.appendChild(item); @@ -1368,7 +1469,7 @@ const DetailPenjemputan = (function () { function getTimbanganUploadStateMarkup(hasFile, isUploaded, hasValidWeight) { if (!hasFile) { - return '

Pilih foto timbangan terlebih dahulu

'; + return '

Foto sedang diproses AI (processing...)

'; } if (isUploaded) { @@ -1535,12 +1636,10 @@ const DetailPenjemputan = (function () { 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 = Boolean(currentData?.file || fileInput?.files?.[0] || currentData?.fotoFileName); + const itemIndex = parseInt(item.dataset.timbanganIndex, 10); + const currentData = !isNaN(itemIndex) ? tps.timbangan[itemIndex] : null; + const hasFile = Boolean(currentData?.file || currentData?.fotoFileName); const isUploaded = Boolean(currentData?.uploaded); const weightInputValue = item.querySelector('.input-berat-timbangan-value'); const currentWeight = currentData?.weight ?? parseWeightInput(weightInputValue?.value || '0'); @@ -1555,9 +1654,7 @@ const DetailPenjemputan = (function () { const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan"); if (uploadBtn) { uploadBtn.addEventListener("click", function () { - const latestIndex = repeater - ? Array.from(repeater.children).indexOf(item) - : -1; + const latestIndex = parseInt(item.dataset.timbanganIndex, 10); uploadSingleFotoTimbangan(latestIndex, item); }); } @@ -1755,27 +1852,39 @@ const DetailPenjemputan = (function () { 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); + const fullResult = await requestOpenRouterWeight(file); + if (fullResult && fullResult.success && fullResult.weight) { + guessedWeight = parseWeightInput(fullResult.weight); + bestRawText = fullResult.raw || fullResult.weight; + isSuccess = guessedWeight > 0; + } else if (fullResult && fullResult.raw) { + bestRawText = fullResult.raw; + } - if (aiResult && aiResult.success && aiResult.weight) { - guessedWeight = parseWeightInput(aiResult.weight); - bestRawText = aiResult.raw || aiResult.weight; - isSuccess = guessedWeight > 0; - if (isSuccess) break; - } + if (!isSuccess) { + const img = await readFileAsImage(file); - if (aiResult && aiResult.raw) { - bestRawText = aiResult.raw; + 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; + } } } @@ -1868,31 +1977,34 @@ const DetailPenjemputan = (function () { 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"); + const form = elements.tpsContentContainer.querySelector("form"); + if (form) { + const repeater = form.querySelector(".tps-timbangan-repeater"); + if (repeater) { + repeater.querySelectorAll(".timbangan-item").forEach(function (item) { + const idx = parseInt(item.dataset.timbanganIndex, 10); + const weightInput = item.querySelector(".input-berat-timbangan-value"); + if (!isNaN(idx) && tps.timbangan[idx] && weightInput) { + tps.timbangan[idx].weight = parseWeightInput(weightInput.value || "0"); + } + }); + } + } - 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.timbangan.forEach(function (timb) { + const value = timb.weight || 0; + const jenis = timb.jenisSampah || CONFIG.DEFAULT_JENIS; + if (jenis === "Organik") { + totalOrganik += value; + } else if (jenis === "Anorganik") { + totalAnorganik += value; + } else { + totalResidu += value; } }); @@ -1901,23 +2013,16 @@ const DetailPenjemputan = (function () { 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); + if (form) { + 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(); @@ -1956,31 +2061,19 @@ const DetailPenjemputan = (function () { function syncTimbanganToTpsData() { const tps = state.tpsData[state.activeTpsIndex]; - const form = elements.tpsContentContainer.querySelector("form"); + 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), - fotoFileName: existingData ? existingData.fotoFileName || '' : '', - weight: parseWeightInput(weightValue.value), - jenisSampah: jenisSampahSelect.value, - uploaded: existingData ? existingData.uploaded : false, - ocrInfo - }); - }); - } + const repeater = form.querySelector('.tps-timbangan-repeater'); + if (!repeater) return; + repeater.querySelectorAll('.timbangan-item').forEach(item => { + const idx = parseInt(item.dataset.timbanganIndex, 10); + if (isNaN(idx) || !tps.timbangan[idx]) return; + const weightValue = item.querySelector('.input-berat-timbangan-value'); + const ocrInfo = item.querySelector('.input-ocr-info'); + if (weightValue) tps.timbangan[idx].weight = parseWeightInput(weightValue.value); + if (ocrInfo) tps.timbangan[idx].ocrInfo = ocrInfo.textContent; + }); + } async function uploadSingleFotoTimbangan(itemIndex, targetItem = null) { const tps = state.tpsData[state.activeTpsIndex]; @@ -2025,8 +2118,6 @@ const DetailPenjemputan = (function () { if (res.ok && data.success) { timbanganItem.uploaded = true; timbanganItem.fotoFileName = data.fileUrl || data.fileName || ''; - const fotoInputTimb = targetItem?.querySelector('.input-foto-timbangan'); - if (fotoInputTimb) fotoInputTimb.value = ''; showToast(data.message || `Foto timbangan #${itemIndex + 1} berhasil diupload.`, 'success'); } else { showToast(data.message || 'Gagal upload foto timbangan.', 'error');