update: fixing ai foto

main
muamars 2026-04-20 12:56:37 +07:00
parent 9e2f7d3cab
commit b1b36240b6
5 changed files with 875 additions and 519 deletions

View File

@ -159,6 +159,114 @@ namespace eSPJ.Controllers.SpjDriverUpstController
Response.Headers["Expires"] = "0"; 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<string>();
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<RecordSaveRequest?> ResolveRecordSaveRequestAsync() private async Task<RecordSaveRequest?> ResolveRecordSaveRequestAsync()
{ {
Request.EnableBuffering(); Request.EnableBuffering();
@ -701,8 +809,6 @@ namespace eSPJ.Controllers.SpjDriverUpstController
return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." }); return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." });
} }
[HttpPost("ocr-timbangan")] [HttpPost("ocr-timbangan")]
[IgnoreAntiforgeryToken] [IgnoreAntiforgeryToken]
public async Task<IActionResult> OcrTimbangan(IFormFile? Foto) public async Task<IActionResult> OcrTimbangan(IFormFile? Foto)
@ -730,17 +836,14 @@ namespace eSPJ.Controllers.SpjDriverUpstController
fileBytes = ms.ToArray(); fileBytes = ms.ToArray();
} }
var mimeType = string.IsNullOrWhiteSpace(Foto.ContentType) ? "image/jpeg" : Foto.ContentType;
var mimeType = "image/jpeg";
var base64 = Convert.ToBase64String(fileBytes); var base64 = Convert.ToBase64String(fileBytes);
var dataUrl = $"data:{mimeType};base64,{base64}"; var dataUrl = $"data:{mimeType};base64,{base64}";
var payload = new var payload = new
{ {
// model = "nvidia/nemotron-nano-12b-v2-vl:free", model = "openai/gpt-5-image-mini",
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[] messages = new object[]
{ {
new new
@ -752,18 +855,14 @@ namespace eSPJ.Controllers.SpjDriverUpstController
{ {
type = "text", type = "text",
text = @" text = @"
Baca angka berat timbangan digital pada foto. Baca angka pada display timbangan digital dari gambar ini.
Fokus hanya pada digit display, abaikan refleksi, cahaya merah, dan teks lain.
Rules: Keluarkan tepat satu baris angka saja.
- Abaikan tulisan seperti ZERO, TARE, STABLE, AC, PACK, PCS, KG, ADD, HOLD. Format output wajib NNN,NN (gunakan koma sebagai desimal dua digit).
- Jawab hanya angka dengan format 2 digit desimal pakai titik (contoh: 54.45). Jika tidak yakin atau angka tidak terbaca, keluarkan 0,00.
- Jika tidak terbaca jawab: UNREADABLE Jangan tambahkan kata, kalimat, atau simbol lain.
- 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 new
{ {
type = "image_url", type = "image_url",
@ -798,13 +897,25 @@ namespace eSPJ.Controllers.SpjDriverUpstController
}); });
} }
string content;
try
{
using var doc = JsonDocument.Parse(responseText); using var doc = JsonDocument.Parse(responseText);
var messageContent = doc.RootElement
var content = doc.RootElement
.GetProperty("choices")[0] .GetProperty("choices")[0]
.GetProperty("message") .GetProperty("message")
.GetProperty("content") .GetProperty("content");
.GetString() ?? ""; content = ExtractOcrMessageContent(messageContent);
}
catch
{
return Ok(new
{
success = false,
message = "Gagal membaca respons OCR.",
raw = responseText
});
}
content = content.Trim(); content = content.Trim();
@ -818,10 +929,9 @@ namespace eSPJ.Controllers.SpjDriverUpstController
}); });
} }
// cari format angka 2 desimal var weight = TryParseWeightFromOcrContent(content);
var match = Regex.Match(content, @"-?\d{1,5}([.,]\d{2})");
if (!match.Success) if (weight == null)
{ {
return Ok(new return Ok(new
{ {
@ -831,25 +941,14 @@ namespace eSPJ.Controllers.SpjDriverUpstController
}); });
} }
var normalized = match.Value.Replace(',', '.'); var weightStr = weight.Value.ToString("0.00", CultureInfo.GetCultureInfo("id-ID"));
if (!decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var weight))
{
return Ok(new
{
success = false,
message = "Format angka AI tidak valid.",
raw = content
});
}
return Ok(new return Ok(new
{ {
success = true, success = true,
weight = weight.ToString("0.00", CultureInfo.InvariantCulture), weight = weightStr,
raw = content raw = content
}); });
} }
} }
} }

View File

@ -62,7 +62,6 @@ namespace eSPJ.Controllers.SpjDriverUpstController
var payload = new var payload = new
{ {
model = "google/gemini-2.5-flash-image", model = "google/gemini-2.5-flash-image",
temperature = 0,
messages = new object[] messages = new object[]
{ {
new new

View File

@ -115,6 +115,7 @@
--text-2xl--line-height: calc(2 / 1.5); --text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem; --text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875); --text-3xl--line-height: calc(2.25 / 1.875);
--font-weight-normal: 400;
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
@ -820,6 +821,9 @@
.mb-auto { .mb-auto {
margin-bottom: auto; margin-bottom: auto;
} }
.ml-1 {
margin-left: calc(var(--spacing) * 1);
}
.ml-4 { .ml-4 {
margin-left: calc(var(--spacing) * 4); margin-left: calc(var(--spacing) * 4);
} }
@ -1538,6 +1542,9 @@
.border-blue-200 { .border-blue-200 {
border-color: var(--color-blue-200); border-color: var(--color-blue-200);
} }
.border-blue-500 {
border-color: var(--color-blue-500);
}
.border-cyan-300 { .border-cyan-300 {
border-color: var(--color-cyan-300); border-color: var(--color-cyan-300);
} }
@ -1577,6 +1584,9 @@
.border-green-400 { .border-green-400 {
border-color: var(--color-green-400); border-color: var(--color-green-400);
} }
.border-green-500 {
border-color: var(--color-green-500);
}
.border-lime-400 { .border-lime-400 {
border-color: var(--color-lime-400); border-color: var(--color-lime-400);
} }
@ -1607,6 +1617,9 @@
.border-red-400 { .border-red-400 {
border-color: var(--color-red-400); border-color: var(--color-red-400);
} }
.border-red-500 {
border-color: var(--color-red-500);
}
.border-slate-200 { .border-slate-200 {
border-color: var(--color-slate-200); border-color: var(--color-slate-200);
} }
@ -2459,6 +2472,10 @@
--tw-font-weight: var(--font-weight-medium); --tw-font-weight: var(--font-weight-medium);
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 { .font-semibold {
--tw-font-weight: var(--font-weight-semibold); --tw-font-weight: var(--font-weight-semibold);
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)); --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,); 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 { .invert {
--tw-invert: invert(100%); --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,); 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,);

View File

@ -81,7 +81,7 @@ document.addEventListener('DOMContentLoaded', async function() {
fotoKedatanganUploaded: tps.fotoKedatanganUploaded || false, fotoKedatanganUploaded: tps.fotoKedatanganUploaded || false,
timbangan: (tps.timbangan || []).map(t => ({ timbangan: (tps.timbangan || []).map(t => ({
berat: (t.berat && t.berat.length > 0) ? t.berat[0] : 0, 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 || '', fotoFileName: t.fotoFileName || '',
uploaded: t.uploaded || false, uploaded: t.uploaded || false,
ocrInfo: t.ocrInfo || '' ocrInfo: t.ocrInfo || ''
@ -214,6 +214,11 @@ document.addEventListener('DOMContentLoaded', async function() {
} else { } else {
tps.timbangan = []; 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() { async function loadRecordForCurrentSpj() {
@ -307,6 +312,7 @@ document.addEventListener('DOMContentLoaded', async function() {
fotoKedatanganFileNames: [], fotoKedatanganFileNames: [],
fotoKedatanganUploaded: false, fotoKedatanganUploaded: false,
timbangan: [], timbangan: [],
activeTimbanganTab: DEFAULT_JENIS,
totalOrganik: 0, totalOrganik: 0,
totalAnorganik: 0, totalAnorganik: 0,
totalResidu: 0, totalResidu: 0,
@ -376,6 +382,25 @@ document.addEventListener('DOMContentLoaded', async function() {
function renderTpsForm() { function renderTpsForm() {
const tps = tpsData[activeTpsIndex]; const tps = tpsData[activeTpsIndex];
const submitState = getSubmitState(tps); 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 `<button type="button" class="timbangan-tab-btn flex-1 py-2 px-1 rounded-xl text-xs font-bold border transition ${colorClass}" data-jenis="${tab.key}">${tab.key}<br/><span class="timbangan-tab-count text-[10px] opacity-80">${count > 0 ? count + ' foto' : ''}</span></button>`;
}).join("");
const actionMarkup = tps.submitted const actionMarkup = tps.submitted
? ` ? `
<div class="flex items-center justify-center gap-2 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm"> <div class="flex items-center justify-center gap-2 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm">
@ -449,11 +474,21 @@ document.addEventListener('DOMContentLoaded', async function() {
</div> </div>
</div> </div>
<div class="tps-timbangan-repeater space-y-3"></div> <div class="timbangan-tabs flex gap-2">
${tabsMarkup}
</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"> <div class="timbangan-tab-content space-y-3">
+ Tambah Foto Timbangan <div class="tps-timbangan-repeater space-y-2"></div>
</button> <label class="block">
<span class="text-xs font-semibold text-gray-600 mb-1 block">
+ Tambah Foto <span class="active-jenis-label font-black">${activeJenis}</span>
<span class="text-[11px] font-normal text-gray-400 ml-1">(bisa pilih banyak)</span>
</span>
<input type="file" accept="image/*" multiple class="input-foto-timbangan-multi 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" />
</label>
<div class="timbangan-multi-progress hidden text-xs text-gray-500 text-center py-1"></div>
</div>
</section> </section>
<section class="bg-white border border-gray-100 rounded-3xl p-5 space-y-4"> <section class="bg-white border border-gray-100 rounded-3xl p-5 space-y-4">
@ -577,7 +612,7 @@ document.addEventListener('DOMContentLoaded', async function() {
const fotoKedatanganInput = form.querySelector(".tps-foto-kedatangan"); const fotoKedatanganInput = form.querySelector(".tps-foto-kedatangan");
const fotoPetugasInput = form.querySelector(".tps-foto-petugas"); const fotoPetugasInput = form.querySelector(".tps-foto-petugas");
const namaPetugasInput = form.querySelector(".tps-nama-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() { fotoKedatanganInput.addEventListener('change', function() {
if (!this.files || !this.files[0]) return; if (!this.files || !this.files[0]) return;
@ -633,11 +668,118 @@ document.addEventListener('DOMContentLoaded', async function() {
scheduleAutoSave(); scheduleAutoSave();
}); });
btnAddTimbangan.addEventListener("click", function () { const INACTIVE_TAB_COLOR = 'bg-gray-100 text-gray-500 border-gray-200';
createTimbanganItem(form.querySelector(".tps-timbangan-repeater")); 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(); syncTimbanganToTpsData();
refreshSubmitButtonState(form); 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( const btnUploadKedatangan = form.querySelector(
".tps-btn-upload-kedatangan", ".tps-btn-upload-kedatangan",
@ -658,15 +800,7 @@ document.addEventListener('DOMContentLoaded', async function() {
} }
function restoreTpsTimbanganItems() { function restoreTpsTimbanganItems() {
const tps = tpsData[activeTpsIndex]; renderTimbanganRepeater();
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));
}
} }
function updateWaktuKedatangan() { function updateWaktuKedatangan() {
@ -1001,10 +1135,21 @@ document.addEventListener('DOMContentLoaded', async function() {
if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar..."; if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar...";
try { try {
const img = await readFileAsImage(file);
let bestRawText = ""; let bestRawText = "";
let isSuccess = false; let isSuccess = false;
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 (!isSuccess) {
const img = await readFileAsImage(file);
for (const area of OCR_AREAS) { for (const area of OCR_AREAS) {
const cropCanvas = createCropCanvas(img, area); const cropCanvas = createCropCanvas(img, area);
const cropFile = await canvasToJpegFile( const cropFile = await canvasToJpegFile(
@ -1022,6 +1167,7 @@ document.addEventListener('DOMContentLoaded', async function() {
if (aiResult && aiResult.raw) bestRawText = aiResult.raw; if (aiResult && aiResult.raw) bestRawText = aiResult.raw;
} }
}
if (ocrInfoEl) { if (ocrInfoEl) {
const cleaned = (bestRawText || "").replace(/\s+/g, " ").trim(); const cleaned = (bestRawText || "").replace(/\s+/g, " ").trim();
@ -1054,19 +1200,19 @@ document.addEventListener('DOMContentLoaded', async function() {
let totalOrganik = 0.0; let totalOrganik = 0.0;
let totalAnorganik = 0.0; let totalAnorganik = 0.0;
let totalResidu = 0.0; let totalResidu = 0.0;
const repeater = form.querySelector(".tps-timbangan-repeater");
const items = repeater.querySelectorAll(".timbangan-item");
items.forEach(function (item) { (tps.timbangan || []).forEach(function (item) {
const weightInput = item.querySelector(".input-berat-timbangan-value"); const value = item?.berat && item.berat.length > 0
const jenisSampahSelect = item.querySelector(".input-jenis-sampah"); ? Number(item.berat[0]) || 0
if (weightInput && jenisSampahSelect) { : 0;
const value = parseWeightInput(weightInput.value || "0"); const jenis = normalizeJenisSampahValue(
const jenis = jenisSampahSelect.value; item?.jenisSampah && item.jenisSampah.length > 0
? item.jenisSampah[0]
: DEFAULT_JENIS,
);
if (jenis === "Organik") totalOrganik += value; if (jenis === "Organik") totalOrganik += value;
else if (jenis === "Anorganik") totalAnorganik += value; else if (jenis === "Anorganik") totalAnorganik += value;
else totalResidu += value; else totalResidu += value;
}
}); });
tps.totalOrganik = totalOrganik; tps.totalOrganik = totalOrganik;
@ -1259,8 +1405,7 @@ document.addEventListener('DOMContentLoaded', async function() {
const stateContainer = item.querySelector(".timbangan-upload-state"); const stateContainer = item.querySelector(".timbangan-upload-state");
if (!stateContainer) return; if (!stateContainer) return;
const repeater = item.parentElement; const itemIndex = parseInt(item.dataset.timbanganIndex || '-1', 10);
const itemIndex = repeater ? Array.from(repeater.children).indexOf(item) : -1;
const tps = tpsData[activeTpsIndex]; const tps = tpsData[activeTpsIndex];
const currentData = itemIndex >= 0 ? tps.timbangan[itemIndex] : null; const currentData = itemIndex >= 0 ? tps.timbangan[itemIndex] : null;
const fileInput = item.querySelector('.input-foto-timbangan'); const fileInput = item.querySelector('.input-foto-timbangan');
@ -1281,9 +1426,7 @@ document.addEventListener('DOMContentLoaded', async function() {
const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan"); const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan");
if (uploadBtn) { if (uploadBtn) {
uploadBtn.addEventListener("click", function () { uploadBtn.addEventListener("click", function () {
const latestIndex = repeater const latestIndex = parseInt(item.dataset.timbanganIndex || '-1', 10);
? Array.from(repeater.children).indexOf(item)
: -1;
uploadSingleFotoTimbangan(latestIndex, item); uploadSingleFotoTimbangan(latestIndex, item);
}); });
} }
@ -1307,48 +1450,96 @@ document.addEventListener('DOMContentLoaded', async function() {
}); });
} }
function createTimbanganItem(repeater, existingData = null) { function createEmptyTimbanganData(jenis) {
const photoNumber = repeater.children.length + 1; 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"); const item = document.createElement("div");
item.className = item.className =
"timbangan-item rounded-2xl border border-gray-200 p-3 space-y-2 bg-gray-50"; "timbangan-item rounded-2xl border border-gray-200 p-3 space-y-2 bg-gray-50";
item.dataset.photoNumber = photoNumber; 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 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 jenisSampah = existingData
const hasFileBlob = Boolean(existingData?.file); ? normalizeJenisSampahValue(existingData.jenisSampah && existingData.jenisSampah.length > 0 ? existingData.jenisSampah[0] : activeJenis)
: activeJenis;
item.dataset.jenisSampah = jenisSampah;
const hasFile = Boolean(existingData?.file || existingData?.fotoFileName); const hasFile = Boolean(existingData?.file || existingData?.fotoFileName);
const isUploaded = Boolean(existingData?.uploaded); const isUploaded = Boolean(existingData?.uploaded);
const ocrInfoText = existingData && existingData.ocrInfo ? existingData.ocrInfo : (hasFile ? 'OCR: diproses.' : 'OCR: belum diproses.'); 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.innerHTML = `
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-xs font-bold text-gray-600">Item Timbangan #${photoNumber}</p> <div class="flex items-center gap-2">
<p class="text-xs font-bold text-gray-600">Foto #${photoNumber}</p>
<span class="px-2 py-0.5 rounded-full text-[10px] font-black ${badgeColor}">${jenisSampah}</span>
</div>
<button type="button" class="btn-remove-timbangan text-[11px] font-bold text-red-500">Hapus</button> <button type="button" class="btn-remove-timbangan text-[11px] font-bold text-red-500">Hapus</button>
</div> </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">
<div class="input-preview-wrap ${(hasFileBlob || (hasFile && existingData?.fotoFileName?.startsWith('/'))) ? '' : 'hidden'} 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" /> <img class="input-preview-image w-full h-44 object-contain" alt="Preview foto timbangan" />
<div class="input-crop-overlay absolute inset-0 pointer-events-none"></div>
</div> </div>
<p class="text-[11px] text-gray-500 input-ocr-info">${ocrInfoText}</p> <p class="text-[11px] text-gray-500 input-ocr-info">${ocrInfoText}</p>
<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">
${JENIS_SAMPAH.map((js) => `<option value="${js}" ${js === jenisSampah ? "selected" : ""}>${js}</option>`).join("")}
</select>
</div>
<div> <div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Berat (kg)</label> <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="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)}" /> <input type="hidden" class="input-berat-timbangan-value" value="${weight.toFixed(2)}" />
</div> </div>
</div>
<div class="timbangan-upload-state">${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}</div> <div class="timbangan-upload-state">${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}</div>
`; `;
const fileInput = item.querySelector(".input-foto-timbangan");
const previewWrap = item.querySelector(".input-preview-wrap"); const previewWrap = item.querySelector(".input-preview-wrap");
const previewImage = item.querySelector(".input-preview-image"); const previewImage = item.querySelector(".input-preview-image");
const ocrInfoEl = item.querySelector(".input-ocr-info"); const ocrInfoEl = item.querySelector(".input-ocr-info");
@ -1356,7 +1547,6 @@ document.addEventListener('DOMContentLoaded', async function() {
".input-berat-timbangan-display", ".input-berat-timbangan-display",
); );
const weightInputValue = item.querySelector(".input-berat-timbangan-value"); const weightInputValue = item.querySelector(".input-berat-timbangan-value");
const jenisSampahSelect = item.querySelector(".input-jenis-sampah");
const removeBtn = item.querySelector(".btn-remove-timbangan"); const removeBtn = item.querySelector(".btn-remove-timbangan");
if (existingData && existingData.file) { if (existingData && existingData.file) {
@ -1369,38 +1559,6 @@ document.addEventListener('DOMContentLoaded', async function() {
previewWrap.classList.remove('hidden'); 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 () { weightInputDisplay.addEventListener("input", function () {
const cleaned = this.value.replace(/[^0-9.,]/g, ""); const cleaned = this.value.replace(/[^0-9.,]/g, "");
this.value = cleaned; this.value = cleaned;
@ -1431,24 +1589,16 @@ document.addEventListener('DOMContentLoaded', async function() {
scheduleAutoSave(); scheduleAutoSave();
}); });
jenisSampahSelect.addEventListener('change', function() {
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
const form = tpsContentContainer.querySelector('form');
if (form) refreshSubmitButtonState(form);
});
removeBtn.addEventListener('click', function() { removeBtn.addEventListener('click', function() {
item.remove(); const tps = tpsData[activeTpsIndex];
const form = tpsContentContainer.querySelector('form'); const idx = parseInt(item.dataset.timbanganIndex || '-1', 10);
const rep = form ? form.querySelector('.tps-timbangan-repeater') : null; if (!Number.isNaN(idx) && idx >= 0) {
if (rep) { tps.timbangan.splice(idx, 1);
renumberTimbanganItems(rep);
if (rep.children.length === 0) createTimbanganItem(rep);
} }
updateTpsTotalTimbangan(); renderTimbanganRepeater();
syncTimbanganToTpsData(); const form = tpsContentContainer.querySelector('form');
if (form) refreshSubmitButtonState(form); if (form) refreshSubmitButtonState(form);
scheduleAutoSave();
}); });
repeater.appendChild(item); repeater.appendChild(item);
@ -1463,25 +1613,25 @@ document.addEventListener('DOMContentLoaded', async function() {
const repeater = form.querySelector(".tps-timbangan-repeater"); const repeater = form.querySelector(".tps-timbangan-repeater");
const items = repeater.querySelectorAll(".timbangan-item"); const items = repeater.querySelectorAll(".timbangan-item");
const previousTimbangan = [...tps.timbangan]; items.forEach((item) => {
const dataIndex = parseInt(item.dataset.timbanganIndex || '-1', 10);
tps.timbangan = []; if (Number.isNaN(dataIndex) || dataIndex < 0) return;
items.forEach((item, index) => {
const fileInput = item.querySelector('.input-foto-timbangan');
const weightValue = item.querySelector('.input-berat-timbangan-value'); const weightValue = item.querySelector('.input-berat-timbangan-value');
const weight = parseWeightInput(weightValue.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.'; const ocrInfo = item.querySelector('.input-ocr-info')?.textContent || 'OCR: belum diproses.';
tps.timbangan.push({ const existing = tps.timbangan[dataIndex] || createEmptyTimbanganData(jenisSampah);
file: fileInput.files[0] || previousTimbangan[index]?.file || null, tps.timbangan[dataIndex] = {
fotoFileName: previousTimbangan[index]?.fotoFileName || '', ...existing,
file: existing.file || null,
fotoFileName: existing.fotoFileName || '',
berat: [weight], berat: [weight],
jenisSampah: [jenisSampah], jenisSampah: [jenisSampah],
lokasiAngkut: [], lokasiAngkut: [],
uploaded: previousTimbangan[index]?.uploaded ?? false, uploaded: existing.uploaded ?? false,
ocrInfo ocrInfo
}); };
}); });
} }
@ -1548,13 +1698,9 @@ document.addEventListener('DOMContentLoaded', async function() {
if (!targetItem) { if (!targetItem) {
const form = tpsContentContainer.querySelector("form"); const form = tpsContentContainer.querySelector("form");
const repeater = form targetItem = form
? form.querySelector(".tps-timbangan-repeater") ? form.querySelector(`.timbangan-item[data-timbangan-index="${itemIndex}"]`)
: null; : null;
const items = repeater
? repeater.querySelectorAll(".timbangan-item")
: [];
targetItem = items[itemIndex] || null;
} }
const uploadBtn = targetItem ? targetItem.querySelector('.btn-upload-timbangan') : null; const uploadBtn = targetItem ? targetItem.querySelector('.btn-upload-timbangan') : null;

View File

@ -201,10 +201,12 @@ const DetailPenjemputan = (function () {
item.fotoFileName, item.fotoFileName,
); );
const weight = Number(item.weight ?? getFirstValue(item.berat) ?? 0) || 0; const weight = Number(item.weight ?? getFirstValue(item.berat) ?? 0) || 0;
const jenisSampah = const _rawJenis = getFirstValue(item.jenisSampah);
getFirstValue(item.jenisSampah) || const jenisSampah = (_rawJenis != null)
item.JenisSampah || ? (typeof _rawJenis === 'number'
CONFIG.DEFAULT_JENIS; ? (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( const uploaded = Boolean(
item.uploaded ?? item.uploaded ??
item.isUploaded ?? item.isUploaded ??
@ -269,6 +271,9 @@ const DetailPenjemputan = (function () {
? apiTimbangan.map(normalizeTimbanganItem) ? apiTimbangan.map(normalizeTimbanganItem)
: currentTps.timbangan; : 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 { return {
...currentTps, ...currentTps,
nomorSpj: apiTps.nomorSpj || apiTps.NomorSpj || currentTps.nomorSpj, nomorSpj: apiTps.nomorSpj || apiTps.NomorSpj || currentTps.nomorSpj,
@ -331,6 +336,7 @@ const DetailPenjemputan = (function () {
), ),
submittedAt: submittedAt:
apiTps.submittedAt || apiTps.SubmittedAt || currentTps.submittedAt, apiTps.submittedAt || apiTps.SubmittedAt || currentTps.submittedAt,
activeTimbanganTab: restoredActiveTab,
}; };
}); });
@ -627,6 +633,7 @@ const DetailPenjemputan = (function () {
fotoKedatanganFileNames: [], fotoKedatanganFileNames: [],
fotoKedatanganUploaded: false, fotoKedatanganUploaded: false,
timbangan: [], timbangan: [],
activeTimbanganTab: CONFIG.DEFAULT_JENIS,
totalOrganik: 0, totalOrganik: 0,
totalAnorganik: 0, totalAnorganik: 0,
totalResidu: 0, totalResidu: 0,
@ -829,21 +836,45 @@ const DetailPenjemputan = (function () {
} }
function renderSection2Timbangan(tps, showTpsName) { 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 `<button type="button" class="timbangan-tab-btn flex-1 py-2 px-1 rounded-xl text-xs font-bold border transition ${colorClass}" data-jenis="${tab.key}">
${tab.key}<br/><span class="timbangan-tab-count text-[10px] opacity-80">${count > 0 ? count + ' foto' : ''}</span>
</button>`;
}).join('');
return ` return `
<section class="bg-white border border-gray-100 rounded-3xl p-5 space-y-4"> <section class="bg-white border border-gray-100 rounded-3xl p-5 space-y-4" data-section="timbangan">
<div class="flex items-center gap-3"> <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 class="w-8 h-8 rounded-full bg-upst text-white font-black text-sm flex items-center justify-center">2</div>
<div> <div>
<h3 class="font-black text-gray-800">Foto Timbang Sampah</h3> <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> <p class="text-xs text-gray-500">Pilih tab, upload banyak foto sekaligus</p>
</div> </div>
</div> </div>
<div class="timbangan-tabs flex gap-2">
<div class="tps-timbangan-repeater space-y-3"></div> ${tabsHtml}
</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"> <div class="timbangan-tab-content space-y-3">
+ Tambah Foto Timbangan <div class="tps-timbangan-repeater space-y-2"></div>
</button> <label class="block">
<span class="text-xs font-semibold text-gray-600 mb-1 block">
+ Tambah Foto <span class="active-jenis-label font-black">${activeTab}</span>
<span class="text-[11px] font-normal text-gray-400 ml-1">(bisa pilih banyak)</span>
</span>
<input type="file" accept="image/*" multiple class="input-foto-timbangan-multi 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" />
</label>
<div class="timbangan-multi-progress hidden text-xs text-gray-500 text-center py-1"></div>
</div>
</section> </section>
`; `;
} }
@ -881,7 +912,6 @@ const DetailPenjemputan = (function () {
const fotoKedatanganInput = form.querySelector(".tps-foto-kedatangan"); const fotoKedatanganInput = form.querySelector(".tps-foto-kedatangan");
const fotoPetugasInput = form.querySelector(".tps-foto-petugas"); const fotoPetugasInput = form.querySelector(".tps-foto-petugas");
const namaPetugasInput = form.querySelector(".tps-nama-petugas"); const namaPetugasInput = form.querySelector(".tps-nama-petugas");
const btnAddTimbangan = form.querySelector(".tps-btn-add-timbangan");
fotoKedatanganInput.addEventListener('change', function() { fotoKedatanganInput.addEventListener('change', function() {
if (!this.files || !this.files[0]) return; if (!this.files || !this.files[0]) return;
@ -937,11 +967,123 @@ const DetailPenjemputan = (function () {
scheduleAutoSave(state.activeTpsIndex); scheduleAutoSave(state.activeTpsIndex);
}); });
btnAddTimbangan.addEventListener('click', function() { const INACTIVE_TAB_COLOR = 'bg-gray-100 text-gray-500 border-gray-200';
createTimbanganItem(form.querySelector('.tps-timbangan-repeater')); const TAB_COLORS = {
syncTimbanganToTpsData(); 'Residu': { active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB_COLOR },
refreshSubmitButtonState(form); '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( const btnUploadKedatangan = form.querySelector(
".tps-btn-upload-kedatangan", ".tps-btn-upload-kedatangan",
@ -962,17 +1104,7 @@ const DetailPenjemputan = (function () {
} }
function restoreTpsTimbanganItems() { function restoreTpsTimbanganItems() {
const tps = state.tpsData[state.activeTpsIndex]; renderTimbanganRepeater();
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() { function restorePhotoPreview() {
@ -1210,57 +1342,71 @@ const DetailPenjemputan = (function () {
}); });
} }
function createTimbanganItem(repeater, existingData = null) { function renderTimbanganRepeater() {
const photoNumber = repeater.children.length + 1; 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?.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';
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'timbangan-item rounded-2xl border border-gray-200 p-3 space-y-2 bg-gray-50'; 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 weight = existingData ? (existingData.weight || 0) : 0;
const jenisSampah = existingData ? (existingData.jenisSampah || CONFIG.DEFAULT_JENIS) : CONFIG.DEFAULT_JENIS;
const hasFileBlob = isBrowserFile(existingData?.file);
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.innerHTML = `
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-xs font-bold text-gray-600">Item Timbangan #${photoNumber}</p> <div class="flex items-center gap-2">
<p class="text-xs font-bold text-gray-600">Foto #${photoNumber}</p>
<span class="px-2 py-0.5 rounded-full text-[10px] font-black ${badgeColor}">${jenisSampah}</span>
</div>
<button type="button" class="btn-remove-timbangan text-[11px] font-bold text-red-500">Hapus</button> <button type="button" class="btn-remove-timbangan text-[11px] font-bold text-red-500">Hapus</button>
</div> </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">
<div class="${(hasFileBlob || (hasFile && getStoredPhotoUrl(existingData?.file || existingData?.fotoFileName))) ? '' : '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" /> <img class="input-preview-image w-full h-44 object-contain" alt="Preview foto timbangan" />
</div> </div>
<p class="text-[11px] text-gray-500 input-ocr-info">${ocrInfoText}</p> <p class="text-[11px] text-gray-500 input-ocr-info">${ocrInfoText}</p>
<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> <div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Berat (kg)</label> <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="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)}" /> <input type="hidden" class="input-berat-timbangan-value" value="${weight.toFixed(2)}" />
</div> </div>
<div class="timbangan-upload-state">${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}</div> <div class="timbangan-upload-state">${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}</div>
</div>
`; `;
const fileInput = item.querySelector(".input-foto-timbangan"); const previewWrap = item.querySelector('.input-preview-wrap');
const previewWrap = item.querySelector(".input-preview-wrap"); const previewImage = item.querySelector('.input-preview-image');
const previewImage = item.querySelector(".input-preview-image"); const weightInputDisplay = item.querySelector('.input-berat-timbangan-display');
const ocrInfoEl = item.querySelector(".input-ocr-info"); const weightInputValue = item.querySelector('.input-berat-timbangan-value');
const weightInputDisplay = item.querySelector( const removeBtn = item.querySelector('.btn-remove-timbangan');
".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)) { if (existingData && hasStoredPhoto(existingData.file)) {
const localUrl = getStoredPhotoUrl(existingData.file); const localUrl = getStoredPhotoUrl(existingData.file);
@ -1275,90 +1421,45 @@ const DetailPenjemputan = (function () {
previewWrap.classList.remove('hidden'); 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() { weightInputDisplay.addEventListener('input', function() {
const cleaned = this.value.replace(/[^0-9.,]/g, ''); const cleaned = this.value.replace(/[^0-9.,]/g, '');
this.value = cleaned; this.value = cleaned;
const parsed = parseWeightInput(cleaned); const parsed = parseWeightInput(cleaned);
weightInputValue.value = parsed.toFixed(2); weightInputValue.value = parsed.toFixed(2);
const idx = parseInt(item.dataset.timbanganIndex, 10);
if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed;
updateTpsTotalTimbangan(); updateTpsTotalTimbangan();
syncTimbanganToTpsData();
refreshTimbanganUploadState(item); refreshTimbanganUploadState(item);
const form = elements.tpsContentContainer.querySelector('form'); const form = elements.tpsContentContainer.querySelector('form');
if (form) refreshSubmitButtonState(form); if (form) refreshSubmitButtonState(form);
scheduleAutoSave(state.activeTpsIndex); scheduleAutoSave(state.activeTpsIndex);
}); });
weightInputDisplay.addEventListener("blur", function () { weightInputDisplay.addEventListener('blur', function() {
const parsed = parseWeightInput(this.value); const parsed = parseWeightInput(this.value);
if (parsed > 0) { if (parsed > 0) {
this.value = formatWeightDisplay(parsed); this.value = formatWeightDisplay(parsed);
weightInputValue.value = parsed.toFixed(2); weightInputValue.value = parsed.toFixed(2);
} else { } else {
this.value = ""; this.value = '';
weightInputValue.value = "0.00"; weightInputValue.value = '0.00';
} }
const idx = parseInt(item.dataset.timbanganIndex, 10);
if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed;
updateTpsTotalTimbangan(); updateTpsTotalTimbangan();
syncTimbanganToTpsData();
refreshTimbanganUploadState(item); refreshTimbanganUploadState(item);
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshSubmitButtonState(form);
});
jenisSampahSelect.addEventListener('change', function() {
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
const form = elements.tpsContentContainer.querySelector('form'); const form = elements.tpsContentContainer.querySelector('form');
if (form) refreshSubmitButtonState(form); if (form) refreshSubmitButtonState(form);
}); });
removeBtn.addEventListener('click', function() { removeBtn.addEventListener('click', function() {
item.remove(); const idx = parseInt(item.dataset.timbanganIndex, 10);
const form = elements.tpsContentContainer.querySelector('form'); tps.timbangan.splice(idx, 1);
const repeater = form ? form.querySelector('.tps-timbangan-repeater') : null; renderTimbanganRepeater();
if (repeater) {
renumberTimbanganItems(repeater);
if (repeater.children.length === 0) {
createTimbanganItem(repeater);
}
}
updateTpsTotalTimbangan(); updateTpsTotalTimbangan();
syncTimbanganToTpsData(); const form = elements.tpsContentContainer.querySelector('form');
if (form) refreshSubmitButtonState(form); if (form) refreshSubmitButtonState(form);
scheduleAutoSave(state.activeTpsIndex);
}); });
repeater.appendChild(item); repeater.appendChild(item);
@ -1368,7 +1469,7 @@ const DetailPenjemputan = (function () {
function getTimbanganUploadStateMarkup(hasFile, isUploaded, hasValidWeight) { function getTimbanganUploadStateMarkup(hasFile, isUploaded, hasValidWeight) {
if (!hasFile) { if (!hasFile) {
return '<p class="text-[11px] text-gray-400">Pilih foto timbangan terlebih dahulu</p>'; return '<p class="text-[11px] text-gray-400">Foto sedang diproses AI (processing...)</p>';
} }
if (isUploaded) { if (isUploaded) {
@ -1535,12 +1636,10 @@ const DetailPenjemputan = (function () {
const stateContainer = item.querySelector(".timbangan-upload-state"); const stateContainer = item.querySelector(".timbangan-upload-state");
if (!stateContainer) return; if (!stateContainer) return;
const repeater = item.parentElement;
const itemIndex = repeater ? Array.from(repeater.children).indexOf(item) : -1;
const tps = state.tpsData[state.activeTpsIndex]; const tps = state.tpsData[state.activeTpsIndex];
const currentData = itemIndex >= 0 ? tps.timbangan[itemIndex] : null; const itemIndex = parseInt(item.dataset.timbanganIndex, 10);
const fileInput = item.querySelector('.input-foto-timbangan'); const currentData = !isNaN(itemIndex) ? tps.timbangan[itemIndex] : null;
const hasFile = Boolean(currentData?.file || fileInput?.files?.[0] || currentData?.fotoFileName); const hasFile = Boolean(currentData?.file || currentData?.fotoFileName);
const isUploaded = Boolean(currentData?.uploaded); const isUploaded = Boolean(currentData?.uploaded);
const weightInputValue = item.querySelector('.input-berat-timbangan-value'); const weightInputValue = item.querySelector('.input-berat-timbangan-value');
const currentWeight = currentData?.weight ?? parseWeightInput(weightInputValue?.value || '0'); const currentWeight = currentData?.weight ?? parseWeightInput(weightInputValue?.value || '0');
@ -1555,9 +1654,7 @@ const DetailPenjemputan = (function () {
const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan"); const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan");
if (uploadBtn) { if (uploadBtn) {
uploadBtn.addEventListener("click", function () { uploadBtn.addEventListener("click", function () {
const latestIndex = repeater const latestIndex = parseInt(item.dataset.timbanganIndex, 10);
? Array.from(repeater.children).indexOf(item)
: -1;
uploadSingleFotoTimbangan(latestIndex, item); uploadSingleFotoTimbangan(latestIndex, item);
}); });
} }
@ -1755,10 +1852,21 @@ const DetailPenjemputan = (function () {
if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar..."; if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar...";
try { try {
const img = await readFileAsImage(file);
let bestRawText = ""; let bestRawText = "";
let isSuccess = false; let isSuccess = false;
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 (!isSuccess) {
const img = await readFileAsImage(file);
for (const area of CONFIG.OCR_AREAS) { for (const area of CONFIG.OCR_AREAS) {
const cropCanvas = createCropCanvas(img, area); const cropCanvas = createCropCanvas(img, area);
const cropFile = await canvasToJpegFile( const cropFile = await canvasToJpegFile(
@ -1778,6 +1886,7 @@ const DetailPenjemputan = (function () {
bestRawText = aiResult.raw; bestRawText = aiResult.raw;
} }
} }
}
if (ocrInfoEl) { if (ocrInfoEl) {
const cleaned = (bestRawText || "").replace(/\s+/g, " ").trim(); const cleaned = (bestRawText || "").replace(/\s+/g, " ").trim();
@ -1868,32 +1977,35 @@ const DetailPenjemputan = (function () {
function updateTpsTotalTimbangan() { function updateTpsTotalTimbangan() {
const tps = state.tpsData[state.activeTpsIndex]; const tps = state.tpsData[state.activeTpsIndex];
const form = elements.tpsContentContainer.querySelector("form");
if (!form) return;
let totalOrganik = 0.0; let totalOrganik = 0.0;
let totalAnorganik = 0.0; let totalAnorganik = 0.0;
let totalResidu = 0.0; let totalResidu = 0.0;
const form = elements.tpsContentContainer.querySelector("form");
if (form) {
const repeater = form.querySelector(".tps-timbangan-repeater"); const repeater = form.querySelector(".tps-timbangan-repeater");
const items = repeater.querySelectorAll(".timbangan-item"); if (repeater) {
repeater.querySelectorAll(".timbangan-item").forEach(function (item) {
items.forEach(function (item) { const idx = parseInt(item.dataset.timbanganIndex, 10);
const weightInput = item.querySelector(".input-berat-timbangan-value"); const weightInput = item.querySelector(".input-berat-timbangan-value");
const jenisSampahSelect = item.querySelector(".input-jenis-sampah"); if (!isNaN(idx) && tps.timbangan[idx] && weightInput) {
tps.timbangan[idx].weight = parseWeightInput(weightInput.value || "0");
if (weightInput && jenisSampahSelect) { }
const value = parseWeightInput(weightInput.value || "0"); });
const jenis = jenisSampahSelect.value; }
}
tps.timbangan.forEach(function (timb) {
const value = timb.weight || 0;
const jenis = timb.jenisSampah || CONFIG.DEFAULT_JENIS;
if (jenis === "Organik") { if (jenis === "Organik") {
totalOrganik += value; totalOrganik += value;
} else if (jenis === "Anorganik") { } else if (jenis === "Anorganik") {
totalAnorganik += value; totalAnorganik += value;
} else if (jenis === "Residu") { } else {
totalResidu += value; totalResidu += value;
} }
}
}); });
tps.totalOrganik = totalOrganik; tps.totalOrganik = totalOrganik;
@ -1901,23 +2013,16 @@ const DetailPenjemputan = (function () {
tps.totalResidu = totalResidu; tps.totalResidu = totalResidu;
tps.totalTimbangan = totalOrganik + totalAnorganik + totalResidu; tps.totalTimbangan = totalOrganik + totalAnorganik + totalResidu;
const displayTotalOrganik = form.querySelector( if (form) {
".tps-display-total-organik", const displayTotalOrganik = form.querySelector(".tps-display-total-organik");
); const displayTotalAnorganik = form.querySelector(".tps-display-total-anorganik");
const displayTotalAnorganik = form.querySelector(
".tps-display-total-anorganik",
);
const displayTotalResidu = form.querySelector(".tps-display-total-residu"); const displayTotalResidu = form.querySelector(".tps-display-total-residu");
const displayTotal = form.querySelector(".tps-display-total"); const displayTotal = form.querySelector(".tps-display-total");
if (displayTotalOrganik) displayTotalOrganik.textContent = formatWeightDisplay(totalOrganik);
if (displayTotalOrganik) if (displayTotalAnorganik) displayTotalAnorganik.textContent = formatWeightDisplay(totalAnorganik);
displayTotalOrganik.textContent = formatWeightDisplay(totalOrganik); if (displayTotalResidu) displayTotalResidu.textContent = formatWeightDisplay(totalResidu);
if (displayTotalAnorganik) if (displayTotal) displayTotal.textContent = formatWeightDisplay(tps.totalTimbangan);
displayTotalAnorganik.textContent = formatWeightDisplay(totalAnorganik); }
if (displayTotalResidu)
displayTotalResidu.textContent = formatWeightDisplay(totalResidu);
if (displayTotal)
displayTotal.textContent = formatWeightDisplay(tps.totalTimbangan);
updateAllTotals(); updateAllTotals();
saveState(); saveState();
@ -1956,29 +2061,17 @@ const DetailPenjemputan = (function () {
function syncTimbanganToTpsData() { function syncTimbanganToTpsData() {
const tps = state.tpsData[state.activeTpsIndex]; const tps = state.tpsData[state.activeTpsIndex];
const form = elements.tpsContentContainer.querySelector("form"); const form = elements.tpsContentContainer.querySelector('form');
if (!form) return; if (!form) return;
const repeater = form.querySelector('.tps-timbangan-repeater'); const repeater = form.querySelector('.tps-timbangan-repeater');
const items = repeater.querySelectorAll('.timbangan-item'); if (!repeater) return;
const previousTimbangan = [...tps.timbangan]; repeater.querySelectorAll('.timbangan-item').forEach(item => {
const idx = parseInt(item.dataset.timbanganIndex, 10);
tps.timbangan = []; if (isNaN(idx) || !tps.timbangan[idx]) return;
items.forEach((item, index) => {
const fileInput = item.querySelector('.input-foto-timbangan');
const weightValue = item.querySelector('.input-berat-timbangan-value'); const weightValue = item.querySelector('.input-berat-timbangan-value');
const jenisSampahSelect = item.querySelector('.input-jenis-sampah'); const ocrInfo = item.querySelector('.input-ocr-info');
const ocrInfo = item.querySelector('.input-ocr-info')?.textContent || 'OCR: belum diproses.'; if (weightValue) tps.timbangan[idx].weight = parseWeightInput(weightValue.value);
const existingData = previousTimbangan[index]; if (ocrInfo) tps.timbangan[idx].ocrInfo = ocrInfo.textContent;
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
});
}); });
} }
@ -2025,8 +2118,6 @@ const DetailPenjemputan = (function () {
if (res.ok && data.success) { if (res.ok && data.success) {
timbanganItem.uploaded = true; timbanganItem.uploaded = true;
timbanganItem.fotoFileName = data.fileUrl || data.fileName || ''; 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'); showToast(data.message || `Foto timbangan #${itemIndex + 1} berhasil diupload.`, 'success');
} else { } else {
showToast(data.message || 'Gagal upload foto timbangan.', 'error'); showToast(data.message || 'Gagal upload foto timbangan.', 'error');