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";
}
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()
{
Request.EnableBuffering();
@ -701,8 +809,6 @@ 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<IActionResult> OcrTimbangan(IFormFile? Foto)
@ -730,17 +836,14 @@ namespace eSPJ.Controllers.SpjDriverUpstController
fileBytes = ms.ToArray();
}
var mimeType = string.IsNullOrWhiteSpace(Foto.ContentType) ? "image/jpeg" : Foto.ContentType;
var mimeType = "image/jpeg";
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,
model = "openai/gpt-5-image-mini",
messages = new object[]
{
new
@ -752,18 +855,14 @@ namespace eSPJ.Controllers.SpjDriverUpstController
{
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.
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",
@ -798,13 +897,25 @@ namespace eSPJ.Controllers.SpjDriverUpstController
});
}
string content;
try
{
using var doc = JsonDocument.Parse(responseText);
var content = doc.RootElement
var messageContent = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? "";
.GetProperty("content");
content = ExtractOcrMessageContent(messageContent);
}
catch
{
return Ok(new
{
success = false,
message = "Gagal membaca respons OCR.",
raw = responseText
});
}
content = content.Trim();
@ -818,10 +929,9 @@ namespace eSPJ.Controllers.SpjDriverUpstController
});
}
// cari format angka 2 desimal
var match = Regex.Match(content, @"-?\d{1,5}([.,]\d{2})");
var weight = TryParseWeightFromOcrContent(content);
if (!match.Success)
if (weight == null)
{
return Ok(new
{
@ -831,25 +941,14 @@ namespace eSPJ.Controllers.SpjDriverUpstController
});
}
var normalized = match.Value.Replace(',', '.');
if (!decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var weight))
{
return Ok(new
{
success = false,
message = "Format angka AI tidak 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
});
}
}
}

View File

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

View File

@ -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,);

View File

@ -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 `<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
? `
<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 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">
+ Tambah Foto Timbangan
</button>
<div class="timbangan-tab-content space-y-3">
<div class="tps-timbangan-repeater space-y-2"></div>
<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 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 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,11 +668,118 @@ document.addEventListener('DOMContentLoaded', async function() {
scheduleAutoSave();
});
btnAddTimbangan.addEventListener("click", function () {
createTimbanganItem(form.querySelector(".tps-timbangan-repeater"));
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();
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(
".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,10 +1135,21 @@ document.addEventListener('DOMContentLoaded', async function() {
if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar...";
try {
const img = await readFileAsImage(file);
let bestRawText = "";
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) {
const cropCanvas = createCropCanvas(img, area);
const cropFile = await canvasToJpegFile(
@ -1022,6 +1167,7 @@ document.addEventListener('DOMContentLoaded', async function() {
if (aiResult && aiResult.raw) bestRawText = aiResult.raw;
}
}
if (ocrInfoEl) {
const cleaned = (bestRawText || "").replace(/\s+/g, " ").trim();
@ -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;
(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 = `
<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>
</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="input-preview-wrap ${(hasFileBlob || (hasFile && existingData?.fotoFileName?.startsWith('/'))) ? '' : 'hidden'} relative rounded-xl overflow-hidden border border-gray-200 bg-black">
<div class="${hasFile ? '' : 'hidden'} input-preview-wrap relative rounded-xl overflow-hidden border border-gray-200 bg-black">
<img class="input-preview-image w-full h-44 object-contain" alt="Preview foto timbangan" />
<div class="input-crop-overlay absolute inset-0 pointer-events-none"></div>
</div>
<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>
<label class="block text-xs font-semibold text-gray-600 mb-1">Berat (kg)</label>
<input type="text" inputmode="decimal" class="input-berat-timbangan-display w-full rounded-xl border border-gray-200 px-3 py-2 text-sm" placeholder="Contoh: 54,45" value="${weight > 0 ? formatWeightDisplay(weight) : ""}" />
<input type="hidden" class="input-berat-timbangan-value" value="${weight.toFixed(2)}" />
</div>
</div>
<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 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 form = tpsContentContainer.querySelector('form');
const rep = form ? form.querySelector('.tps-timbangan-repeater') : null;
if (rep) {
renumberTimbanganItems(rep);
if (rep.children.length === 0) createTimbanganItem(rep);
const tps = tpsData[activeTpsIndex];
const idx = parseInt(item.dataset.timbanganIndex || '-1', 10);
if (!Number.isNaN(idx) && idx >= 0) {
tps.timbangan.splice(idx, 1);
}
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
renderTimbanganRepeater();
const form = tpsContentContainer.querySelector('form');
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;

View File

@ -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,21 +836,45 @@ 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 `<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 `
<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="w-8 h-8 rounded-full bg-upst text-white font-black text-sm flex items-center justify-center">2</div>
<div>
<h3 class="font-black text-gray-800">Foto Timbang Sampah</h3>
<p class="text-xs text-gray-500">Upload foto timbangan, berat auto terisi</p>
<p class="text-xs text-gray-500">Pilih tab, upload banyak foto sekaligus</p>
</div>
</div>
<div class="tps-timbangan-repeater space-y-3"></div>
<button type="button" class="tps-btn-add-timbangan w-full border border-dashed border-upst text-upst rounded-xl py-2 text-xs font-bold transition">
+ Tambah Foto Timbangan
</button>
<div class="timbangan-tabs flex gap-2">
${tabsHtml}
</div>
<div class="timbangan-tab-content space-y-3">
<div class="tps-timbangan-repeater space-y-2"></div>
<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>
`;
}
@ -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,57 +1342,71 @@ const DetailPenjemputan = (function () {
});
}
function createTimbanganItem(repeater, existingData = null) {
const photoNumber = repeater.children.length + 1;
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?.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');
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);
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.dataset.timbanganIndex = String(timbanganIndex);
item.innerHTML = `
<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>
</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="${(hasFileBlob || (hasFile && getStoredPhotoUrl(existingData?.file || existingData?.fotoFileName))) ? '' : 'hidden'} input-preview-wrap relative rounded-xl overflow-hidden border border-gray-200 bg-black">
<div class="${hasFile ? '' : 'hidden'} input-preview-wrap relative rounded-xl overflow-hidden border border-gray-200 bg-black">
<img class="input-preview-image w-full h-44 object-contain" alt="Preview foto timbangan" />
</div>
<p class="text-[11px] text-gray-500 input-ocr-info">${ocrInfoText}</p>
<div class="grid grid-cols-1 gap-2">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Jenis Sampah</label>
<select class="input-jenis-sampah w-full rounded-xl border border-gray-200 px-3 py-2 text-sm">
${CONFIG.JENIS_SAMPAH.map((js) => `<option value="${js}" ${js === jenisSampah ? "selected" : ""}>${js}</option>`).join("")}
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Berat (kg)</label>
<input type="text" inputmode="decimal" class="input-berat-timbangan-display w-full rounded-xl border border-gray-200 px-3 py-2 text-sm" placeholder="Contoh: 54,45" value="${weight > 0 ? formatWeightDisplay(weight) : ""}" />
<input type="text" inputmode="decimal" class="input-berat-timbangan-display w-full rounded-xl border border-gray-200 px-3 py-2 text-sm" placeholder="Contoh: 54,45" value="${weight > 0 ? formatWeightDisplay(weight) : ''}" />
<input type="hidden" class="input-berat-timbangan-value" value="${weight.toFixed(2)}" />
</div>
<div 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 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);
@ -1275,90 +1421,45 @@ const DetailPenjemputan = (function () {
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);
const idx = parseInt(item.dataset.timbanganIndex, 10);
if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed;
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
refreshTimbanganUploadState(item);
const form = elements.tpsContentContainer.querySelector('form');
if (form) refreshSubmitButtonState(form);
scheduleAutoSave(state.activeTpsIndex);
});
weightInputDisplay.addEventListener("blur", function () {
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";
this.value = '';
weightInputValue.value = '0.00';
}
const idx = parseInt(item.dataset.timbanganIndex, 10);
if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed;
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
refreshTimbanganUploadState(item);
const form = elements.tpsContentContainer.querySelector("form");
if (form) refreshSubmitButtonState(form);
});
jenisSampahSelect.addEventListener('change', function() {
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
const form = elements.tpsContentContainer.querySelector('form');
if (form) refreshSubmitButtonState(form);
});
removeBtn.addEventListener('click', function() {
item.remove();
const form = elements.tpsContentContainer.querySelector('form');
const repeater = form ? form.querySelector('.tps-timbangan-repeater') : null;
if (repeater) {
renumberTimbanganItems(repeater);
if (repeater.children.length === 0) {
createTimbanganItem(repeater);
}
}
const idx = parseInt(item.dataset.timbanganIndex, 10);
tps.timbangan.splice(idx, 1);
renderTimbanganRepeater();
updateTpsTotalTimbangan();
syncTimbanganToTpsData();
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 '<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) {
@ -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,10 +1852,21 @@ const DetailPenjemputan = (function () {
if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar...";
try {
const img = await readFileAsImage(file);
let bestRawText = "";
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) {
const cropCanvas = createCropCanvas(img, area);
const cropFile = await canvasToJpegFile(
@ -1778,6 +1886,7 @@ const DetailPenjemputan = (function () {
bestRawText = aiResult.raw;
}
}
}
if (ocrInfoEl) {
const cleaned = (bestRawText || "").replace(/\s+/g, " ").trim();
@ -1868,32 +1977,35 @@ 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 form = elements.tpsContentContainer.querySelector("form");
if (form) {
const repeater = form.querySelector(".tps-timbangan-repeater");
const items = repeater.querySelectorAll(".timbangan-item");
items.forEach(function (item) {
if (repeater) {
repeater.querySelectorAll(".timbangan-item").forEach(function (item) {
const idx = parseInt(item.dataset.timbanganIndex, 10);
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 (!isNaN(idx) && tps.timbangan[idx] && weightInput) {
tps.timbangan[idx].weight = parseWeightInput(weightInput.value || "0");
}
});
}
}
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 if (jenis === "Residu") {
} else {
totalResidu += value;
}
}
});
tps.totalOrganik = totalOrganik;
@ -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",
);
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);
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,29 +2061,17 @@ 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');
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 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 ocrInfo = item.querySelector('.input-ocr-info');
if (weightValue) tps.timbangan[idx].weight = parseWeightInput(weightValue.value);
if (ocrInfo) tps.timbangan[idx].ocrInfo = ocrInfo.textContent;
});
}
@ -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');