update: fixing ai foto
parent
9e2f7d3cab
commit
b1b36240b6
|
|
@ -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,155 +809,146 @@ namespace eSPJ.Controllers.SpjDriverUpstController
|
|||
|
||||
return Ok(new { success = true, fileNames, fileUrls, message = $"{fileNames.Count} foto petugas berhasil diupload." });
|
||||
}
|
||||
[HttpPost("ocr-timbangan")]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public async Task<IActionResult> OcrTimbangan(IFormFile? Foto)
|
||||
{
|
||||
if (Foto == null || Foto.Length == 0)
|
||||
{
|
||||
return BadRequest(new { success = false, message = "Foto tidak ditemukan." });
|
||||
}
|
||||
|
||||
if (Foto.Length > 5 * 1024 * 1024)
|
||||
{
|
||||
return BadRequest(new { success = false, message = "Ukuran foto terlalu besar. Maksimal 5MB." });
|
||||
}
|
||||
|
||||
var apiKey = _configuration["OpenRouter:OCRkey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return StatusCode(500, new { success = false, message = "OpenRouter API key belum diset." });
|
||||
}
|
||||
|
||||
byte[] fileBytes;
|
||||
await using (var ms = new MemoryStream())
|
||||
{
|
||||
await Foto.CopyToAsync(ms);
|
||||
fileBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("ocr-timbangan")]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public async Task<IActionResult> OcrTimbangan(IFormFile? Foto)
|
||||
var mimeType = "image/jpeg";
|
||||
var base64 = Convert.ToBase64String(fileBytes);
|
||||
var dataUrl = $"data:{mimeType};base64,{base64}";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
model = "openai/gpt-5-image-mini",
|
||||
messages = new object[]
|
||||
{
|
||||
if (Foto == null || Foto.Length == 0)
|
||||
new
|
||||
{
|
||||
return BadRequest(new { success = false, message = "Foto tidak ditemukan." });
|
||||
}
|
||||
|
||||
if (Foto.Length > 5 * 1024 * 1024)
|
||||
{
|
||||
return BadRequest(new { success = false, message = "Ukuran foto terlalu besar. Maksimal 5MB." });
|
||||
}
|
||||
|
||||
var apiKey = _configuration["OpenRouter:OCRkey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return StatusCode(500, new { success = false, message = "OpenRouter API key belum diset." });
|
||||
}
|
||||
|
||||
byte[] fileBytes;
|
||||
await using (var ms = new MemoryStream())
|
||||
{
|
||||
await Foto.CopyToAsync(ms);
|
||||
fileBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var mimeType = string.IsNullOrWhiteSpace(Foto.ContentType) ? "image/jpeg" : Foto.ContentType;
|
||||
var base64 = Convert.ToBase64String(fileBytes);
|
||||
var dataUrl = $"data:{mimeType};base64,{base64}";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
// model = "nvidia/nemotron-nano-12b-v2-vl:free",
|
||||
model = "google/gemini-2.5-flash-image",
|
||||
// model = "google/gemini-2.5-flash-lite",
|
||||
// model = "google/gemini-2.5-flash-lite-preview-09-2025",
|
||||
temperature = 0,
|
||||
messages = new object[]
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
role = "user",
|
||||
content = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "text",
|
||||
text = @"
|
||||
Baca angka berat timbangan digital pada foto.
|
||||
|
||||
Rules:
|
||||
- Abaikan tulisan seperti ZERO, TARE, STABLE, AC, PACK, PCS, KG, ADD, HOLD.
|
||||
- Jawab hanya angka dengan format 2 digit desimal pakai titik (contoh: 54.45).
|
||||
- Jika tidak terbaca jawab: UNREADABLE
|
||||
- Fokus pada angka layar LED merah yang menyala.
|
||||
- Abaikan refleksi atau pantulan cahaya yang mungkin muncul di layar.
|
||||
- Abaikan timestamp seperti tanggal, jam, atau informasi lain yang biasanya muncul di layar timbangan.
|
||||
"
|
||||
},
|
||||
|
||||
new
|
||||
{
|
||||
type = "image_url",
|
||||
image_url = new { url = dataUrl }
|
||||
}
|
||||
}
|
||||
type = "text",
|
||||
text = @"
|
||||
Baca angka pada display timbangan digital dari gambar ini.
|
||||
Fokus hanya pada digit display, abaikan refleksi, cahaya merah, dan teks lain.
|
||||
Keluarkan tepat satu baris angka saja.
|
||||
Format output wajib NNN,NN (gunakan koma sebagai desimal dua digit).
|
||||
Jika tidak yakin atau angka tidak terbaca, keluarkan 0,00.
|
||||
Jangan tambahkan kata, kalimat, atau simbol lain.
|
||||
"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "image_url",
|
||||
image_url = new { url = dataUrl }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://openrouter.ai/api/v1/chat/completions");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}");
|
||||
request.Headers.TryAddWithoutValidation("Accept", "application/json");
|
||||
request.Headers.TryAddWithoutValidation("HTTP-Referer", "https://pesapakawan.dinaslhdki.id");
|
||||
request.Headers.TryAddWithoutValidation("X-Title", "eSPJ OCR Timbangan");
|
||||
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.SendAsync(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode((int)response.StatusCode, new
|
||||
{
|
||||
success = false,
|
||||
message = "OpenRouter request gagal.",
|
||||
detail = responseText
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
using var doc = JsonDocument.Parse(responseText);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
|
||||
var content = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString() ?? "";
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://openrouter.ai/api/v1/chat/completions");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}");
|
||||
request.Headers.TryAddWithoutValidation("Accept", "application/json");
|
||||
request.Headers.TryAddWithoutValidation("HTTP-Referer", "https://pesapakawan.dinaslhdki.id");
|
||||
request.Headers.TryAddWithoutValidation("X-Title", "eSPJ OCR Timbangan");
|
||||
|
||||
content = content.Trim();
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
if (content.Contains("UNREADABLE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
message = "Angka tidak terbaca.",
|
||||
raw = content
|
||||
});
|
||||
}
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
using var response = await client.SendAsync(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// cari format angka 2 desimal
|
||||
var match = Regex.Match(content, @"-?\d{1,5}([.,]\d{2})");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return StatusCode((int)response.StatusCode, new
|
||||
{
|
||||
success = false,
|
||||
message = "OpenRouter request gagal.",
|
||||
detail = responseText
|
||||
});
|
||||
}
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
message = "AI tidak menemukan angka valid.",
|
||||
raw = content
|
||||
});
|
||||
}
|
||||
string content;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseText);
|
||||
var messageContent = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content");
|
||||
content = ExtractOcrMessageContent(messageContent);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
message = "Gagal membaca respons OCR.",
|
||||
raw = responseText
|
||||
});
|
||||
}
|
||||
|
||||
var normalized = match.Value.Replace(',', '.');
|
||||
content = content.Trim();
|
||||
|
||||
if (!decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out var weight))
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
message = "Format angka AI tidak valid.",
|
||||
raw = content
|
||||
});
|
||||
}
|
||||
if (content.Contains("UNREADABLE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
message = "Angka tidak terbaca.",
|
||||
raw = content
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
var weight = TryParseWeightFromOcrContent(content);
|
||||
|
||||
if (weight == null)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
success = false,
|
||||
message = "AI tidak menemukan angka valid.",
|
||||
raw = content
|
||||
});
|
||||
}
|
||||
|
||||
var weightStr = weight.Value.ToString("0.00", CultureInfo.GetCultureInfo("id-ID"));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
weight = weight.ToString("0.00", CultureInfo.InvariantCulture),
|
||||
weight = weightStr,
|
||||
raw = content
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ namespace eSPJ.Controllers.SpjDriverUpstController
|
|||
var payload = new
|
||||
{
|
||||
model = "google/gemini-2.5-flash-image",
|
||||
temperature = 0,
|
||||
messages = new object[]
|
||||
{
|
||||
new
|
||||
|
|
|
|||
|
|
@ -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,);
|
||||
|
|
|
|||
|
|
@ -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,12 +668,119 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
scheduleAutoSave();
|
||||
});
|
||||
|
||||
btnAddTimbangan.addEventListener("click", function () {
|
||||
createTimbanganItem(form.querySelector(".tps-timbangan-repeater"));
|
||||
syncTimbanganToTpsData();
|
||||
refreshSubmitButtonState(form);
|
||||
const INACTIVE_TAB_COLOR = 'bg-gray-100 text-gray-500 border-gray-200';
|
||||
const TAB_COLORS = {
|
||||
'Residu': { active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB_COLOR },
|
||||
'Organik': { active: 'bg-green-500 text-white border-green-500', inactive: INACTIVE_TAB_COLOR },
|
||||
'Anorganik': { active: 'bg-blue-500 text-white border-blue-500', inactive: INACTIVE_TAB_COLOR },
|
||||
};
|
||||
tabButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', function () {
|
||||
const nextJenis = this.dataset.jenis;
|
||||
const current = tpsData[activeTpsIndex];
|
||||
if (!current) return;
|
||||
syncTimbanganToTpsData();
|
||||
current.activeTimbanganTab = nextJenis;
|
||||
tabButtons.forEach(b => {
|
||||
const cfg = TAB_COLORS[b.dataset.jenis] || { active: 'bg-upst text-white border-upst', inactive: INACTIVE_TAB_COLOR };
|
||||
b.className = `timbangan-tab-btn flex-1 py-2 px-1 rounded-xl text-xs font-bold border transition ${b.dataset.jenis === nextJenis ? cfg.active : cfg.inactive}`;
|
||||
});
|
||||
const jenisLabel = form.querySelector('.active-jenis-label');
|
||||
if (jenisLabel) jenisLabel.textContent = nextJenis;
|
||||
renderTimbanganRepeater();
|
||||
});
|
||||
});
|
||||
|
||||
const multiFileInput = form.querySelector('.input-foto-timbangan-multi');
|
||||
if (multiFileInput) {
|
||||
multiFileInput.addEventListener('change', async function() {
|
||||
if (!this.files || !this.files.length) return;
|
||||
const currentTps = tpsData[activeTpsIndex];
|
||||
const activeJenis = normalizeJenisSampahValue(currentTps.activeTimbanganTab || DEFAULT_JENIS);
|
||||
const files = Array.from(this.files);
|
||||
const progressEl = form.querySelector('.timbangan-multi-progress');
|
||||
if (progressEl) {
|
||||
progressEl.textContent = `Memproses ${files.length} foto...`;
|
||||
progressEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const startIndex = currentTps.timbangan.length;
|
||||
files.forEach(() => {
|
||||
currentTps.timbangan.push(createEmptyTimbanganData(activeJenis));
|
||||
currentTps.timbangan[currentTps.timbangan.length - 1].ocrInfo = 'AI: memproses...';
|
||||
});
|
||||
renderTimbanganRepeater();
|
||||
|
||||
const processFile = async (file, timbanganIndex) => {
|
||||
try {
|
||||
const photoNumber = timbanganIndex + 1;
|
||||
const watermarkedFile = await applyWatermark(file, photoNumber);
|
||||
currentTps.timbangan[timbanganIndex].file = watermarkedFile;
|
||||
|
||||
const currentForm = tpsContentContainer.querySelector('form');
|
||||
const itemEl = currentForm ? currentForm.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null;
|
||||
if (itemEl) {
|
||||
const previewWrap = itemEl.querySelector('.input-preview-wrap');
|
||||
const previewImg = itemEl.querySelector('.input-preview-image');
|
||||
if (previewWrap && previewImg) {
|
||||
const localUrl = URL.createObjectURL(watermarkedFile);
|
||||
previewImg.src = localUrl;
|
||||
previewWrap.classList.remove('hidden');
|
||||
previewImg.onload = () => URL.revokeObjectURL(localUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const weightInputProxy = { value: '', placeholder: '' };
|
||||
const ocrInfoProxy = { textContent: 'AI: memproses...' };
|
||||
await autoFillWeight(file, weightInputProxy, ocrInfoProxy);
|
||||
|
||||
const detectedWeight = parseWeightInput(weightInputProxy.value);
|
||||
currentTps.timbangan[timbanganIndex].berat = [detectedWeight];
|
||||
currentTps.timbangan[timbanganIndex].ocrInfo = ocrInfoProxy.textContent;
|
||||
|
||||
const currentForm2 = tpsContentContainer.querySelector('form');
|
||||
const itemEl2 = currentForm2 ? currentForm2.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null;
|
||||
if (itemEl2) {
|
||||
const ocrInfoEl = itemEl2.querySelector('.input-ocr-info');
|
||||
const weightDisplay = itemEl2.querySelector('.input-berat-timbangan-display');
|
||||
const weightValue = itemEl2.querySelector('.input-berat-timbangan-value');
|
||||
if (ocrInfoEl) ocrInfoEl.textContent = ocrInfoProxy.textContent;
|
||||
if (weightDisplay) {
|
||||
if (detectedWeight > 0) {
|
||||
weightDisplay.value = formatWeightDisplay(detectedWeight);
|
||||
weightDisplay.placeholder = 'Berat terdeteksi otomatis';
|
||||
} else {
|
||||
weightDisplay.value = '';
|
||||
weightDisplay.placeholder = 'Tidak terbaca, isi manual';
|
||||
}
|
||||
}
|
||||
if (weightValue) weightValue.value = detectedWeight.toFixed(2);
|
||||
refreshTimbanganUploadState(itemEl2);
|
||||
}
|
||||
} catch (_) {
|
||||
if (currentTps.timbangan[timbanganIndex]) {
|
||||
currentTps.timbangan[timbanganIndex].ocrInfo = 'AI gagal diproses.';
|
||||
}
|
||||
const currentForm3 = tpsContentContainer.querySelector('form');
|
||||
const itemEl3 = currentForm3 ? currentForm3.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null;
|
||||
if (itemEl3) {
|
||||
const ocrInfoEl = itemEl3.querySelector('.input-ocr-info');
|
||||
if (ocrInfoEl) ocrInfoEl.textContent = 'AI gagal diproses.';
|
||||
}
|
||||
}
|
||||
updateTpsTotalTimbangan();
|
||||
const currentForm4 = tpsContentContainer.querySelector('form');
|
||||
if (currentForm4) refreshSubmitButtonState(currentForm4);
|
||||
};
|
||||
|
||||
await Promise.all(files.map((file, i) => processFile(file, startIndex + i)));
|
||||
|
||||
if (progressEl) progressEl.classList.add('hidden');
|
||||
this.value = '';
|
||||
scheduleAutoSave();
|
||||
});
|
||||
}
|
||||
|
||||
const btnUploadKedatangan = form.querySelector(
|
||||
".tps-btn-upload-kedatangan",
|
||||
);
|
||||
|
|
@ -658,15 +800,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
}
|
||||
|
||||
function restoreTpsTimbanganItems() {
|
||||
const tps = tpsData[activeTpsIndex];
|
||||
const form = tpsContentContainer.querySelector("form");
|
||||
const repeater = form.querySelector(".tps-timbangan-repeater");
|
||||
|
||||
if (tps.timbangan.length === 0) {
|
||||
createTimbanganItem(repeater);
|
||||
} else {
|
||||
tps.timbangan.forEach((timb) => createTimbanganItem(repeater, timb));
|
||||
}
|
||||
renderTimbanganRepeater();
|
||||
}
|
||||
|
||||
function updateWaktuKedatangan() {
|
||||
|
|
@ -1001,26 +1135,38 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar...";
|
||||
|
||||
try {
|
||||
const img = await readFileAsImage(file);
|
||||
let bestRawText = "";
|
||||
let isSuccess = false;
|
||||
|
||||
for (const area of OCR_AREAS) {
|
||||
const cropCanvas = createCropCanvas(img, area);
|
||||
const cropFile = await canvasToJpegFile(
|
||||
cropCanvas,
|
||||
`crop-${area.id}.jpg`,
|
||||
);
|
||||
const aiResult = await requestOpenRouterWeight(cropFile);
|
||||
const fullResult = await requestOpenRouterWeight(file);
|
||||
if (fullResult && fullResult.success && fullResult.weight) {
|
||||
guessedWeight = parseWeightInput(fullResult.weight);
|
||||
bestRawText = fullResult.raw || fullResult.weight;
|
||||
isSuccess = guessedWeight > 0;
|
||||
} else if (fullResult && fullResult.raw) {
|
||||
bestRawText = fullResult.raw;
|
||||
}
|
||||
|
||||
if (aiResult && aiResult.success && aiResult.weight) {
|
||||
guessedWeight = parseWeightInput(aiResult.weight);
|
||||
bestRawText = aiResult.raw || aiResult.weight;
|
||||
isSuccess = guessedWeight > 0;
|
||||
if (isSuccess) break;
|
||||
if (!isSuccess) {
|
||||
const img = await readFileAsImage(file);
|
||||
|
||||
for (const area of OCR_AREAS) {
|
||||
const cropCanvas = createCropCanvas(img, area);
|
||||
const cropFile = await canvasToJpegFile(
|
||||
cropCanvas,
|
||||
`crop-${area.id}.jpg`,
|
||||
);
|
||||
const aiResult = await requestOpenRouterWeight(cropFile);
|
||||
|
||||
if (aiResult && aiResult.success && aiResult.weight) {
|
||||
guessedWeight = parseWeightInput(aiResult.weight);
|
||||
bestRawText = aiResult.raw || aiResult.weight;
|
||||
isSuccess = guessedWeight > 0;
|
||||
if (isSuccess) break;
|
||||
}
|
||||
|
||||
if (aiResult && aiResult.raw) bestRawText = aiResult.raw;
|
||||
}
|
||||
|
||||
if (aiResult && aiResult.raw) bestRawText = aiResult.raw;
|
||||
}
|
||||
|
||||
if (ocrInfoEl) {
|
||||
|
|
@ -1054,19 +1200,19 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
let totalOrganik = 0.0;
|
||||
let totalAnorganik = 0.0;
|
||||
let totalResidu = 0.0;
|
||||
const repeater = form.querySelector(".tps-timbangan-repeater");
|
||||
const items = repeater.querySelectorAll(".timbangan-item");
|
||||
|
||||
items.forEach(function (item) {
|
||||
const weightInput = item.querySelector(".input-berat-timbangan-value");
|
||||
const jenisSampahSelect = item.querySelector(".input-jenis-sampah");
|
||||
if (weightInput && jenisSampahSelect) {
|
||||
const value = parseWeightInput(weightInput.value || "0");
|
||||
const jenis = jenisSampahSelect.value;
|
||||
if (jenis === "Organik") totalOrganik += value;
|
||||
else if (jenis === "Anorganik") totalAnorganik += value;
|
||||
else totalResidu += value;
|
||||
}
|
||||
(tps.timbangan || []).forEach(function (item) {
|
||||
const value = item?.berat && item.berat.length > 0
|
||||
? Number(item.berat[0]) || 0
|
||||
: 0;
|
||||
const jenis = normalizeJenisSampahValue(
|
||||
item?.jenisSampah && item.jenisSampah.length > 0
|
||||
? item.jenisSampah[0]
|
||||
: DEFAULT_JENIS,
|
||||
);
|
||||
if (jenis === "Organik") totalOrganik += value;
|
||||
else if (jenis === "Anorganik") totalAnorganik += value;
|
||||
else totalResidu += value;
|
||||
});
|
||||
|
||||
tps.totalOrganik = totalOrganik;
|
||||
|
|
@ -1259,8 +1405,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
const stateContainer = item.querySelector(".timbangan-upload-state");
|
||||
if (!stateContainer) return;
|
||||
|
||||
const repeater = item.parentElement;
|
||||
const itemIndex = repeater ? Array.from(repeater.children).indexOf(item) : -1;
|
||||
const itemIndex = parseInt(item.dataset.timbanganIndex || '-1', 10);
|
||||
const tps = tpsData[activeTpsIndex];
|
||||
const currentData = itemIndex >= 0 ? tps.timbangan[itemIndex] : null;
|
||||
const fileInput = item.querySelector('.input-foto-timbangan');
|
||||
|
|
@ -1281,9 +1426,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
const uploadBtn = stateContainer.querySelector(".btn-upload-timbangan");
|
||||
if (uploadBtn) {
|
||||
uploadBtn.addEventListener("click", function () {
|
||||
const latestIndex = repeater
|
||||
? Array.from(repeater.children).indexOf(item)
|
||||
: -1;
|
||||
const latestIndex = parseInt(item.dataset.timbanganIndex || '-1', 10);
|
||||
uploadSingleFotoTimbangan(latestIndex, item);
|
||||
});
|
||||
}
|
||||
|
|
@ -1307,48 +1450,96 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
});
|
||||
}
|
||||
|
||||
function createTimbanganItem(repeater, existingData = null) {
|
||||
const photoNumber = repeater.children.length + 1;
|
||||
function createEmptyTimbanganData(jenis) {
|
||||
const normalizedJenis = normalizeJenisSampahValue(jenis || DEFAULT_JENIS);
|
||||
return {
|
||||
file: null,
|
||||
fotoFileName: '',
|
||||
berat: [0],
|
||||
jenisSampah: [normalizedJenis],
|
||||
lokasiAngkut: [],
|
||||
uploaded: false,
|
||||
ocrInfo: 'OCR: belum diproses.'
|
||||
};
|
||||
}
|
||||
|
||||
function renderTimbanganRepeater() {
|
||||
const tps = tpsData[activeTpsIndex];
|
||||
const form = tpsContentContainer.querySelector('form');
|
||||
if (!form) return;
|
||||
const repeater = form.querySelector('.tps-timbangan-repeater');
|
||||
if (!repeater) return;
|
||||
|
||||
const activeTab = normalizeJenisSampahValue(tps.activeTimbanganTab || DEFAULT_JENIS);
|
||||
repeater.innerHTML = '';
|
||||
|
||||
(tps.timbangan || []).forEach((timb, index) => {
|
||||
const jenis = normalizeJenisSampahValue(
|
||||
timb?.jenisSampah && timb.jenisSampah.length > 0 ? timb.jenisSampah[0] : DEFAULT_JENIS,
|
||||
);
|
||||
if (jenis === activeTab) {
|
||||
createTimbanganItem(repeater, index, timb);
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelectorAll('.timbangan-tab-btn').forEach((btn) => {
|
||||
const jenis = btn.dataset.jenis;
|
||||
const count = (tps.timbangan || []).filter((timb) => {
|
||||
const v = timb?.jenisSampah && timb.jenisSampah.length > 0 ? timb.jenisSampah[0] : DEFAULT_JENIS;
|
||||
return normalizeJenisSampahValue(v) === jenis;
|
||||
}).length;
|
||||
const span = btn.querySelector('.timbangan-tab-count');
|
||||
if (span) span.textContent = count > 0 ? count + ' foto' : '';
|
||||
});
|
||||
|
||||
updateTpsTotalTimbangan();
|
||||
}
|
||||
|
||||
function createTimbanganItem(repeater, timbanganIndex, existingData = null) {
|
||||
const photoNumber = timbanganIndex + 1;
|
||||
const item = document.createElement("div");
|
||||
item.className =
|
||||
"timbangan-item rounded-2xl border border-gray-200 p-3 space-y-2 bg-gray-50";
|
||||
item.dataset.photoNumber = photoNumber;
|
||||
item.dataset.timbanganIndex = String(timbanganIndex);
|
||||
|
||||
const activeJenis = normalizeJenisSampahValue((tpsData[activeTpsIndex] && tpsData[activeTpsIndex].activeTimbanganTab) || DEFAULT_JENIS);
|
||||
const weight = existingData ? (existingData.berat && existingData.berat.length > 0 ? existingData.berat[0] : 0) : 0;
|
||||
const jenisSampah = existingData ? (existingData.jenisSampah && existingData.jenisSampah.length > 0 ? existingData.jenisSampah[0] : DEFAULT_JENIS) : DEFAULT_JENIS;
|
||||
const hasFileBlob = Boolean(existingData?.file);
|
||||
const jenisSampah = existingData
|
||||
? normalizeJenisSampahValue(existingData.jenisSampah && existingData.jenisSampah.length > 0 ? existingData.jenisSampah[0] : activeJenis)
|
||||
: activeJenis;
|
||||
item.dataset.jenisSampah = jenisSampah;
|
||||
const hasFile = Boolean(existingData?.file || existingData?.fotoFileName);
|
||||
const isUploaded = Boolean(existingData?.uploaded);
|
||||
const ocrInfoText = existingData && existingData.ocrInfo ? existingData.ocrInfo : (hasFile ? 'OCR: diproses.' : 'OCR: belum diproses.');
|
||||
|
||||
const BADGE_COLORS = {
|
||||
'Organik': 'bg-green-100 text-green-700',
|
||||
'Anorganik': 'bg-blue-100 text-blue-700',
|
||||
'Residu': 'bg-red-100 text-red-700',
|
||||
};
|
||||
const badgeColor = BADGE_COLORS[jenisSampah] || 'bg-gray-100 text-gray-600';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-bold text-gray-600">Item Timbangan #${photoNumber}</p>
|
||||
<button type="button" class="btn-remove-timbangan text-[11px] font-bold text-red-500">Hapus</button>
|
||||
<div 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>
|
||||
<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 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 tps = tpsData[activeTpsIndex];
|
||||
const idx = parseInt(item.dataset.timbanganIndex || '-1', 10);
|
||||
if (!Number.isNaN(idx) && idx >= 0) {
|
||||
tps.timbangan.splice(idx, 1);
|
||||
}
|
||||
renderTimbanganRepeater();
|
||||
const form = tpsContentContainer.querySelector('form');
|
||||
const rep = form ? form.querySelector('.tps-timbangan-repeater') : null;
|
||||
if (rep) {
|
||||
renumberTimbanganItems(rep);
|
||||
if (rep.children.length === 0) createTimbanganItem(rep);
|
||||
}
|
||||
updateTpsTotalTimbangan();
|
||||
syncTimbanganToTpsData();
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
scheduleAutoSave();
|
||||
});
|
||||
|
||||
repeater.appendChild(item);
|
||||
|
|
@ -1463,25 +1613,25 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
|
||||
const repeater = form.querySelector(".tps-timbangan-repeater");
|
||||
const items = repeater.querySelectorAll(".timbangan-item");
|
||||
const previousTimbangan = [...tps.timbangan];
|
||||
|
||||
tps.timbangan = [];
|
||||
items.forEach((item, index) => {
|
||||
const fileInput = item.querySelector('.input-foto-timbangan');
|
||||
items.forEach((item) => {
|
||||
const dataIndex = parseInt(item.dataset.timbanganIndex || '-1', 10);
|
||||
if (Number.isNaN(dataIndex) || dataIndex < 0) return;
|
||||
const weightValue = item.querySelector('.input-berat-timbangan-value');
|
||||
const weight = parseWeightInput(weightValue.value);
|
||||
const jenisSampah = item.querySelector('.input-jenis-sampah').value;
|
||||
const jenisSampah = normalizeJenisSampahValue(item.dataset.jenisSampah || DEFAULT_JENIS);
|
||||
const ocrInfo = item.querySelector('.input-ocr-info')?.textContent || 'OCR: belum diproses.';
|
||||
|
||||
tps.timbangan.push({
|
||||
file: fileInput.files[0] || previousTimbangan[index]?.file || null,
|
||||
fotoFileName: previousTimbangan[index]?.fotoFileName || '',
|
||||
const existing = tps.timbangan[dataIndex] || createEmptyTimbanganData(jenisSampah);
|
||||
tps.timbangan[dataIndex] = {
|
||||
...existing,
|
||||
file: existing.file || null,
|
||||
fotoFileName: existing.fotoFileName || '',
|
||||
berat: [weight],
|
||||
jenisSampah: [jenisSampah],
|
||||
lokasiAngkut: [],
|
||||
uploaded: previousTimbangan[index]?.uploaded ?? false,
|
||||
uploaded: existing.uploaded ?? false,
|
||||
ocrInfo
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1548,13 +1698,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||
|
||||
if (!targetItem) {
|
||||
const form = tpsContentContainer.querySelector("form");
|
||||
const repeater = form
|
||||
? form.querySelector(".tps-timbangan-repeater")
|
||||
targetItem = form
|
||||
? form.querySelector(`.timbangan-item[data-timbangan-index="${itemIndex}"]`)
|
||||
: null;
|
||||
const items = repeater
|
||||
? repeater.querySelectorAll(".timbangan-item")
|
||||
: [];
|
||||
targetItem = items[itemIndex] || null;
|
||||
}
|
||||
|
||||
const uploadBtn = targetItem ? targetItem.querySelector('.btn-upload-timbangan') : null;
|
||||
|
|
|
|||
|
|
@ -201,10 +201,12 @@ const DetailPenjemputan = (function () {
|
|||
item.fotoFileName,
|
||||
);
|
||||
const weight = Number(item.weight ?? getFirstValue(item.berat) ?? 0) || 0;
|
||||
const jenisSampah =
|
||||
getFirstValue(item.jenisSampah) ||
|
||||
item.JenisSampah ||
|
||||
CONFIG.DEFAULT_JENIS;
|
||||
const _rawJenis = getFirstValue(item.jenisSampah);
|
||||
const jenisSampah = (_rawJenis != null)
|
||||
? (typeof _rawJenis === 'number'
|
||||
? (CONFIG.JENIS_SAMPAH[_rawJenis] || CONFIG.DEFAULT_JENIS)
|
||||
: CONFIG.JENIS_SAMPAH.find(j => j.toLowerCase() === String(_rawJenis).trim().toLowerCase()) || CONFIG.DEFAULT_JENIS)
|
||||
: (item.JenisSampah && CONFIG.JENIS_SAMPAH.find(j => j.toLowerCase() === String(item.JenisSampah).trim().toLowerCase()) || CONFIG.DEFAULT_JENIS);
|
||||
const uploaded = Boolean(
|
||||
item.uploaded ??
|
||||
item.isUploaded ??
|
||||
|
|
@ -269,6 +271,9 @@ const DetailPenjemputan = (function () {
|
|||
? apiTimbangan.map(normalizeTimbanganItem)
|
||||
: currentTps.timbangan;
|
||||
|
||||
const TAB_VISUAL_ORDER = ['Residu', 'Organik', 'Anorganik'];
|
||||
const restoredActiveTab = TAB_VISUAL_ORDER.find(j => timbangan.some(t => t.jenisSampah === j)) || currentTps.activeTimbanganTab || CONFIG.DEFAULT_JENIS;
|
||||
|
||||
return {
|
||||
...currentTps,
|
||||
nomorSpj: apiTps.nomorSpj || apiTps.NomorSpj || currentTps.nomorSpj,
|
||||
|
|
@ -331,6 +336,7 @@ const DetailPenjemputan = (function () {
|
|||
),
|
||||
submittedAt:
|
||||
apiTps.submittedAt || apiTps.SubmittedAt || currentTps.submittedAt,
|
||||
activeTimbanganTab: restoredActiveTab,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -627,6 +633,7 @@ const DetailPenjemputan = (function () {
|
|||
fotoKedatanganFileNames: [],
|
||||
fotoKedatanganUploaded: false,
|
||||
timbangan: [],
|
||||
activeTimbanganTab: CONFIG.DEFAULT_JENIS,
|
||||
totalOrganik: 0,
|
||||
totalAnorganik: 0,
|
||||
totalResidu: 0,
|
||||
|
|
@ -829,23 +836,47 @@ const DetailPenjemputan = (function () {
|
|||
}
|
||||
|
||||
function renderSection2Timbangan(tps, showTpsName) {
|
||||
const activeTab = tps.activeTimbanganTab || CONFIG.DEFAULT_JENIS;
|
||||
const INACTIVE_TAB = 'bg-gray-100 text-gray-500 border-gray-200';
|
||||
const TAB_CONFIG = [
|
||||
{ key: 'Residu', active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB },
|
||||
{ key: 'Organik', active: 'bg-green-500 text-white border-green-500', inactive: INACTIVE_TAB },
|
||||
{ key: 'Anorganik', active: 'bg-blue-500 text-white border-blue-500', inactive: INACTIVE_TAB },
|
||||
];
|
||||
const tabsHtml = TAB_CONFIG.map(tab => {
|
||||
const count = (tps.timbangan || []).filter(t => (t.jenisSampah || CONFIG.DEFAULT_JENIS) === tab.key).length;
|
||||
const isActive = tab.key === activeTab;
|
||||
const colorClass = isActive ? tab.active : tab.inactive;
|
||||
return `<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-upst text-white font-black text-sm flex items-center justify-center">2</div>
|
||||
<div>
|
||||
<h3 class="font-black text-gray-800">Foto Timbang Sampah</h3>
|
||||
<p class="text-xs text-gray-500">Upload foto timbangan, berat auto terisi</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tps-timbangan-repeater space-y-3"></div>
|
||||
|
||||
<button type="button" class="tps-btn-add-timbangan w-full border border-dashed border-upst text-upst rounded-xl py-2 text-xs font-bold transition">
|
||||
+ Tambah Foto Timbangan
|
||||
</button>
|
||||
</section>
|
||||
`;
|
||||
<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">Pilih tab, upload banyak foto sekaligus</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSection3Petugas(tps) {
|
||||
|
|
@ -881,7 +912,6 @@ const DetailPenjemputan = (function () {
|
|||
const fotoKedatanganInput = form.querySelector(".tps-foto-kedatangan");
|
||||
const fotoPetugasInput = form.querySelector(".tps-foto-petugas");
|
||||
const namaPetugasInput = form.querySelector(".tps-nama-petugas");
|
||||
const btnAddTimbangan = form.querySelector(".tps-btn-add-timbangan");
|
||||
|
||||
fotoKedatanganInput.addEventListener('change', function() {
|
||||
if (!this.files || !this.files[0]) return;
|
||||
|
|
@ -937,11 +967,123 @@ const DetailPenjemputan = (function () {
|
|||
scheduleAutoSave(state.activeTpsIndex);
|
||||
});
|
||||
|
||||
btnAddTimbangan.addEventListener('click', function() {
|
||||
createTimbanganItem(form.querySelector('.tps-timbangan-repeater'));
|
||||
syncTimbanganToTpsData();
|
||||
refreshSubmitButtonState(form);
|
||||
const INACTIVE_TAB_COLOR = 'bg-gray-100 text-gray-500 border-gray-200';
|
||||
const TAB_COLORS = {
|
||||
'Residu': { active: 'bg-red-500 text-white border-red-500', inactive: INACTIVE_TAB_COLOR },
|
||||
'Organik': { active: 'bg-green-500 text-white border-green-500', inactive: INACTIVE_TAB_COLOR },
|
||||
'Anorganik': { active: 'bg-blue-500 text-white border-blue-500', inactive: INACTIVE_TAB_COLOR },
|
||||
};
|
||||
const tabBtns = form.querySelectorAll('.timbangan-tab-btn');
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const jenis = this.dataset.jenis;
|
||||
const currentTps = state.tpsData[state.activeTpsIndex];
|
||||
currentTps.activeTimbanganTab = jenis;
|
||||
tabBtns.forEach(b => {
|
||||
const cfg = TAB_COLORS[b.dataset.jenis] || { active: 'bg-upst text-white border-upst', inactive: 'bg-gray-50 text-gray-600 border-gray-200' };
|
||||
b.className = `timbangan-tab-btn flex-1 py-2 px-1 rounded-xl text-xs font-bold border transition ${b.dataset.jenis === jenis ? cfg.active : cfg.inactive}`;
|
||||
});
|
||||
const jenisLabel = form.querySelector('.active-jenis-label');
|
||||
if (jenisLabel) jenisLabel.textContent = jenis;
|
||||
renderTimbanganRepeater();
|
||||
});
|
||||
});
|
||||
|
||||
const multiFileInput = form.querySelector('.input-foto-timbangan-multi');
|
||||
if (multiFileInput) {
|
||||
multiFileInput.addEventListener('change', async function() {
|
||||
if (!this.files || !this.files.length) return;
|
||||
const currentTps = state.tpsData[state.activeTpsIndex];
|
||||
const activeJenis = currentTps.activeTimbanganTab || CONFIG.DEFAULT_JENIS;
|
||||
const files = Array.from(this.files);
|
||||
const progressEl = form.querySelector('.timbangan-multi-progress');
|
||||
if (progressEl) {
|
||||
progressEl.textContent = `Memproses ${files.length} foto...`;
|
||||
progressEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const startIndex = currentTps.timbangan.length;
|
||||
files.forEach(() => {
|
||||
currentTps.timbangan.push({
|
||||
file: null,
|
||||
fotoFileName: '',
|
||||
weight: 0,
|
||||
jenisSampah: activeJenis,
|
||||
uploaded: false,
|
||||
ocrInfo: 'AI: memproses...',
|
||||
});
|
||||
});
|
||||
renderTimbanganRepeater();
|
||||
|
||||
const processFile = async (file, timbanganIndex) => {
|
||||
try {
|
||||
const photoNumber = timbanganIndex + 1;
|
||||
const watermarkedFile = await applyWatermark(file, photoNumber);
|
||||
currentTps.timbangan[timbanganIndex].file = watermarkedFile;
|
||||
|
||||
const currentForm = elements.tpsContentContainer.querySelector('form');
|
||||
const itemEl = currentForm ? currentForm.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null;
|
||||
if (itemEl) {
|
||||
const previewWrap = itemEl.querySelector('.input-preview-wrap');
|
||||
const previewImg = itemEl.querySelector('.input-preview-image');
|
||||
if (previewWrap && previewImg) {
|
||||
const localUrl = URL.createObjectURL(watermarkedFile);
|
||||
previewImg.src = localUrl;
|
||||
previewWrap.classList.remove('hidden');
|
||||
previewImg.onload = () => URL.revokeObjectURL(localUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const weightInputProxy = { value: '', placeholder: '' };
|
||||
const ocrInfoProxy = { textContent: 'AI: memproses...' };
|
||||
await autoFillWeight(file, weightInputProxy, ocrInfoProxy);
|
||||
|
||||
const detectedWeight = parseWeightInput(weightInputProxy.value);
|
||||
currentTps.timbangan[timbanganIndex].weight = detectedWeight;
|
||||
currentTps.timbangan[timbanganIndex].ocrInfo = ocrInfoProxy.textContent;
|
||||
|
||||
const currentForm2 = elements.tpsContentContainer.querySelector('form');
|
||||
const itemEl2 = currentForm2 ? currentForm2.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null;
|
||||
if (itemEl2) {
|
||||
const ocrInfoEl = itemEl2.querySelector('.input-ocr-info');
|
||||
const weightDisplay = itemEl2.querySelector('.input-berat-timbangan-display');
|
||||
const weightValue = itemEl2.querySelector('.input-berat-timbangan-value');
|
||||
if (ocrInfoEl) ocrInfoEl.textContent = ocrInfoProxy.textContent;
|
||||
if (weightDisplay) {
|
||||
if (detectedWeight > 0) {
|
||||
weightDisplay.value = formatWeightDisplay(detectedWeight);
|
||||
weightDisplay.placeholder = 'Berat terdeteksi otomatis';
|
||||
} else {
|
||||
weightDisplay.value = '';
|
||||
weightDisplay.placeholder = 'Tidak terbaca, isi manual';
|
||||
}
|
||||
}
|
||||
if (weightValue) weightValue.value = detectedWeight.toFixed(2);
|
||||
refreshTimbanganUploadState(itemEl2);
|
||||
}
|
||||
} catch (_) {
|
||||
if (currentTps.timbangan[timbanganIndex]) {
|
||||
currentTps.timbangan[timbanganIndex].ocrInfo = 'AI gagal diproses.';
|
||||
}
|
||||
const currentForm3 = elements.tpsContentContainer.querySelector('form');
|
||||
const itemEl3 = currentForm3 ? currentForm3.querySelector(`.timbangan-item[data-timbangan-index="${timbanganIndex}"]`) : null;
|
||||
if (itemEl3) {
|
||||
const ocrInfoEl = itemEl3.querySelector('.input-ocr-info');
|
||||
if (ocrInfoEl) ocrInfoEl.textContent = 'AI gagal diproses.';
|
||||
}
|
||||
}
|
||||
updateTpsTotalTimbangan();
|
||||
const currentForm4 = elements.tpsContentContainer.querySelector('form');
|
||||
if (currentForm4) refreshSubmitButtonState(currentForm4);
|
||||
};
|
||||
|
||||
await Promise.all(files.map((file, i) => processFile(file, startIndex + i)));
|
||||
|
||||
if (progressEl) progressEl.classList.add('hidden');
|
||||
this.value = '';
|
||||
scheduleAutoSave(state.activeTpsIndex);
|
||||
});
|
||||
}
|
||||
|
||||
const btnUploadKedatangan = form.querySelector(
|
||||
".tps-btn-upload-kedatangan",
|
||||
|
|
@ -962,17 +1104,7 @@ const DetailPenjemputan = (function () {
|
|||
}
|
||||
|
||||
function restoreTpsTimbanganItems() {
|
||||
const tps = state.tpsData[state.activeTpsIndex];
|
||||
const form = elements.tpsContentContainer.querySelector("form");
|
||||
const repeater = form.querySelector(".tps-timbangan-repeater");
|
||||
|
||||
if (tps.timbangan.length === 0) {
|
||||
createTimbanganItem(repeater);
|
||||
} else {
|
||||
tps.timbangan.forEach((timb) => {
|
||||
createTimbanganItem(repeater, timb);
|
||||
});
|
||||
}
|
||||
renderTimbanganRepeater();
|
||||
}
|
||||
|
||||
function restorePhotoPreview() {
|
||||
|
|
@ -1210,155 +1342,124 @@ const DetailPenjemputan = (function () {
|
|||
});
|
||||
}
|
||||
|
||||
function createTimbanganItem(repeater, existingData = null) {
|
||||
const photoNumber = repeater.children.length + 1;
|
||||
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>
|
||||
<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">
|
||||
<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="hidden" class="input-berat-timbangan-value" value="${weight.toFixed(2)}" />
|
||||
</div>
|
||||
|
||||
<div class="timbangan-upload-state">${getTimbanganUploadStateMarkup(hasFile, isUploaded, weight > 0)}</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<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>
|
||||
<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 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");
|
||||
const weightInputDisplay = item.querySelector(
|
||||
".input-berat-timbangan-display",
|
||||
);
|
||||
const weightInputValue = item.querySelector(".input-berat-timbangan-value");
|
||||
const jenisSampahSelect = item.querySelector(".input-jenis-sampah");
|
||||
const removeBtn = item.querySelector(".btn-remove-timbangan");
|
||||
const previewWrap = item.querySelector('.input-preview-wrap');
|
||||
const previewImage = item.querySelector('.input-preview-image');
|
||||
const weightInputDisplay = item.querySelector('.input-berat-timbangan-display');
|
||||
const weightInputValue = item.querySelector('.input-berat-timbangan-value');
|
||||
const removeBtn = item.querySelector('.btn-remove-timbangan');
|
||||
|
||||
if (existingData && hasStoredPhoto(existingData.file)) {
|
||||
const localUrl = getStoredPhotoUrl(existingData.file);
|
||||
previewImage.src = localUrl;
|
||||
previewWrap.classList.remove('hidden');
|
||||
previewImage.onload = function() {
|
||||
previewImage.src = localUrl;
|
||||
previewWrap.classList.remove('hidden');
|
||||
previewImage.onload = function() {
|
||||
if (!isBrowserFile(resolveStoredPhoto(existingData.file))) return;
|
||||
URL.revokeObjectURL(localUrl);
|
||||
};
|
||||
URL.revokeObjectURL(localUrl);
|
||||
};
|
||||
} else if (existingData && existingData.fotoFileName && existingData.fotoFileName.startsWith('/')) {
|
||||
previewImage.src = existingData.fotoFileName;
|
||||
previewWrap.classList.remove('hidden');
|
||||
previewImage.src = existingData.fotoFileName;
|
||||
previewWrap.classList.remove('hidden');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', async function() {
|
||||
if (fileInput.files && fileInput.files[0]) {
|
||||
const originalFile = fileInput.files[0];
|
||||
|
||||
const watermarkedFile = await applyWatermark(originalFile, photoNumber);
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(watermarkedFile);
|
||||
fileInput.files = dataTransfer.files;
|
||||
|
||||
const localUrl = URL.createObjectURL(watermarkedFile);
|
||||
previewImage.src = localUrl;
|
||||
previewWrap.classList.remove('hidden');
|
||||
previewImage.onload = function() {
|
||||
URL.revokeObjectURL(localUrl);
|
||||
};
|
||||
|
||||
await autoFillWeight(watermarkedFile, weightInputDisplay, ocrInfoEl);
|
||||
const parsed = parseWeightInput(weightInputDisplay.value);
|
||||
weightInputValue.value = parsed.toFixed(2);
|
||||
updateTpsTotalTimbangan();
|
||||
syncTimbanganToTpsData();
|
||||
|
||||
const tps = state.tpsData[state.activeTpsIndex];
|
||||
const itemIndex = Array.from(repeater.children).indexOf(item);
|
||||
if (itemIndex >= 0 && tps.timbangan[itemIndex]) {
|
||||
tps.timbangan[itemIndex].uploaded = false;
|
||||
tps.timbangan[itemIndex].fotoFileName = '';
|
||||
refreshTimbanganUploadState(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
weightInputDisplay.addEventListener('input', function() {
|
||||
const cleaned = this.value.replace(/[^0-9.,]/g, '');
|
||||
this.value = cleaned;
|
||||
const parsed = parseWeightInput(cleaned);
|
||||
weightInputValue.value = parsed.toFixed(2);
|
||||
updateTpsTotalTimbangan();
|
||||
syncTimbanganToTpsData();
|
||||
refreshTimbanganUploadState(item);
|
||||
const form = elements.tpsContentContainer.querySelector('form');
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
scheduleAutoSave(state.activeTpsIndex);
|
||||
const cleaned = this.value.replace(/[^0-9.,]/g, '');
|
||||
this.value = cleaned;
|
||||
const parsed = parseWeightInput(cleaned);
|
||||
weightInputValue.value = parsed.toFixed(2);
|
||||
const idx = parseInt(item.dataset.timbanganIndex, 10);
|
||||
if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed;
|
||||
updateTpsTotalTimbangan();
|
||||
refreshTimbanganUploadState(item);
|
||||
const form = elements.tpsContentContainer.querySelector('form');
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
scheduleAutoSave(state.activeTpsIndex);
|
||||
});
|
||||
|
||||
weightInputDisplay.addEventListener("blur", function () {
|
||||
const parsed = parseWeightInput(this.value);
|
||||
if (parsed > 0) {
|
||||
this.value = formatWeightDisplay(parsed);
|
||||
weightInputValue.value = parsed.toFixed(2);
|
||||
} else {
|
||||
this.value = "";
|
||||
weightInputValue.value = "0.00";
|
||||
}
|
||||
updateTpsTotalTimbangan();
|
||||
syncTimbanganToTpsData();
|
||||
refreshTimbanganUploadState(item);
|
||||
const form = elements.tpsContentContainer.querySelector("form");
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
});
|
||||
|
||||
jenisSampahSelect.addEventListener('change', function() {
|
||||
updateTpsTotalTimbangan();
|
||||
syncTimbanganToTpsData();
|
||||
const form = elements.tpsContentContainer.querySelector('form');
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
weightInputDisplay.addEventListener('blur', function() {
|
||||
const parsed = parseWeightInput(this.value);
|
||||
if (parsed > 0) {
|
||||
this.value = formatWeightDisplay(parsed);
|
||||
weightInputValue.value = parsed.toFixed(2);
|
||||
} else {
|
||||
this.value = '';
|
||||
weightInputValue.value = '0.00';
|
||||
}
|
||||
const idx = parseInt(item.dataset.timbanganIndex, 10);
|
||||
if (tps.timbangan[idx]) tps.timbangan[idx].weight = parsed;
|
||||
updateTpsTotalTimbangan();
|
||||
refreshTimbanganUploadState(item);
|
||||
const form = elements.tpsContentContainer.querySelector('form');
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function() {
|
||||
item.remove();
|
||||
const form = elements.tpsContentContainer.querySelector('form');
|
||||
const repeater = form ? form.querySelector('.tps-timbangan-repeater') : null;
|
||||
|
||||
if (repeater) {
|
||||
renumberTimbanganItems(repeater);
|
||||
if (repeater.children.length === 0) {
|
||||
createTimbanganItem(repeater);
|
||||
}
|
||||
}
|
||||
|
||||
updateTpsTotalTimbangan();
|
||||
syncTimbanganToTpsData();
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
const idx = parseInt(item.dataset.timbanganIndex, 10);
|
||||
tps.timbangan.splice(idx, 1);
|
||||
renderTimbanganRepeater();
|
||||
updateTpsTotalTimbangan();
|
||||
const form = elements.tpsContentContainer.querySelector('form');
|
||||
if (form) refreshSubmitButtonState(form);
|
||||
scheduleAutoSave(state.activeTpsIndex);
|
||||
});
|
||||
|
||||
repeater.appendChild(item);
|
||||
|
|
@ -1368,7 +1469,7 @@ const DetailPenjemputan = (function () {
|
|||
|
||||
function getTimbanganUploadStateMarkup(hasFile, isUploaded, hasValidWeight) {
|
||||
if (!hasFile) {
|
||||
return '<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,27 +1852,39 @@ const DetailPenjemputan = (function () {
|
|||
if (ocrInfoEl) ocrInfoEl.textContent = "AI: memproses gambar...";
|
||||
|
||||
try {
|
||||
const img = await readFileAsImage(file);
|
||||
let bestRawText = "";
|
||||
let isSuccess = false;
|
||||
|
||||
for (const area of CONFIG.OCR_AREAS) {
|
||||
const cropCanvas = createCropCanvas(img, area);
|
||||
const cropFile = await canvasToJpegFile(
|
||||
cropCanvas,
|
||||
`crop-${area.id}.jpg`,
|
||||
);
|
||||
const aiResult = await requestOpenRouterWeight(cropFile);
|
||||
const fullResult = await requestOpenRouterWeight(file);
|
||||
if (fullResult && fullResult.success && fullResult.weight) {
|
||||
guessedWeight = parseWeightInput(fullResult.weight);
|
||||
bestRawText = fullResult.raw || fullResult.weight;
|
||||
isSuccess = guessedWeight > 0;
|
||||
} else if (fullResult && fullResult.raw) {
|
||||
bestRawText = fullResult.raw;
|
||||
}
|
||||
|
||||
if (aiResult && aiResult.success && aiResult.weight) {
|
||||
guessedWeight = parseWeightInput(aiResult.weight);
|
||||
bestRawText = aiResult.raw || aiResult.weight;
|
||||
isSuccess = guessedWeight > 0;
|
||||
if (isSuccess) break;
|
||||
}
|
||||
if (!isSuccess) {
|
||||
const img = await readFileAsImage(file);
|
||||
|
||||
if (aiResult && aiResult.raw) {
|
||||
bestRawText = aiResult.raw;
|
||||
for (const area of CONFIG.OCR_AREAS) {
|
||||
const cropCanvas = createCropCanvas(img, area);
|
||||
const cropFile = await canvasToJpegFile(
|
||||
cropCanvas,
|
||||
`crop-${area.id}.jpg`,
|
||||
);
|
||||
const aiResult = await requestOpenRouterWeight(cropFile);
|
||||
|
||||
if (aiResult && aiResult.success && aiResult.weight) {
|
||||
guessedWeight = parseWeightInput(aiResult.weight);
|
||||
bestRawText = aiResult.raw || aiResult.weight;
|
||||
isSuccess = guessedWeight > 0;
|
||||
if (isSuccess) break;
|
||||
}
|
||||
|
||||
if (aiResult && aiResult.raw) {
|
||||
bestRawText = aiResult.raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1868,31 +1977,34 @@ const DetailPenjemputan = (function () {
|
|||
|
||||
function updateTpsTotalTimbangan() {
|
||||
const tps = state.tpsData[state.activeTpsIndex];
|
||||
const form = elements.tpsContentContainer.querySelector("form");
|
||||
if (!form) return;
|
||||
|
||||
let totalOrganik = 0.0;
|
||||
let totalAnorganik = 0.0;
|
||||
let totalResidu = 0.0;
|
||||
|
||||
const repeater = form.querySelector(".tps-timbangan-repeater");
|
||||
const items = repeater.querySelectorAll(".timbangan-item");
|
||||
const form = elements.tpsContentContainer.querySelector("form");
|
||||
if (form) {
|
||||
const repeater = form.querySelector(".tps-timbangan-repeater");
|
||||
if (repeater) {
|
||||
repeater.querySelectorAll(".timbangan-item").forEach(function (item) {
|
||||
const idx = parseInt(item.dataset.timbanganIndex, 10);
|
||||
const weightInput = item.querySelector(".input-berat-timbangan-value");
|
||||
if (!isNaN(idx) && tps.timbangan[idx] && weightInput) {
|
||||
tps.timbangan[idx].weight = parseWeightInput(weightInput.value || "0");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(function (item) {
|
||||
const weightInput = item.querySelector(".input-berat-timbangan-value");
|
||||
const jenisSampahSelect = item.querySelector(".input-jenis-sampah");
|
||||
|
||||
if (weightInput && jenisSampahSelect) {
|
||||
const value = parseWeightInput(weightInput.value || "0");
|
||||
const jenis = jenisSampahSelect.value;
|
||||
|
||||
if (jenis === "Organik") {
|
||||
totalOrganik += value;
|
||||
} else if (jenis === "Anorganik") {
|
||||
totalAnorganik += value;
|
||||
} else if (jenis === "Residu") {
|
||||
totalResidu += value;
|
||||
}
|
||||
tps.timbangan.forEach(function (timb) {
|
||||
const value = timb.weight || 0;
|
||||
const jenis = timb.jenisSampah || CONFIG.DEFAULT_JENIS;
|
||||
if (jenis === "Organik") {
|
||||
totalOrganik += value;
|
||||
} else if (jenis === "Anorganik") {
|
||||
totalAnorganik += value;
|
||||
} else {
|
||||
totalResidu += value;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1901,23 +2013,16 @@ const DetailPenjemputan = (function () {
|
|||
tps.totalResidu = totalResidu;
|
||||
tps.totalTimbangan = totalOrganik + totalAnorganik + totalResidu;
|
||||
|
||||
const displayTotalOrganik = form.querySelector(
|
||||
".tps-display-total-organik",
|
||||
);
|
||||
const displayTotalAnorganik = form.querySelector(
|
||||
".tps-display-total-anorganik",
|
||||
);
|
||||
const displayTotalResidu = form.querySelector(".tps-display-total-residu");
|
||||
const displayTotal = form.querySelector(".tps-display-total");
|
||||
|
||||
if (displayTotalOrganik)
|
||||
displayTotalOrganik.textContent = formatWeightDisplay(totalOrganik);
|
||||
if (displayTotalAnorganik)
|
||||
displayTotalAnorganik.textContent = formatWeightDisplay(totalAnorganik);
|
||||
if (displayTotalResidu)
|
||||
displayTotalResidu.textContent = formatWeightDisplay(totalResidu);
|
||||
if (displayTotal)
|
||||
displayTotal.textContent = formatWeightDisplay(tps.totalTimbangan);
|
||||
if (form) {
|
||||
const displayTotalOrganik = form.querySelector(".tps-display-total-organik");
|
||||
const displayTotalAnorganik = form.querySelector(".tps-display-total-anorganik");
|
||||
const displayTotalResidu = form.querySelector(".tps-display-total-residu");
|
||||
const displayTotal = form.querySelector(".tps-display-total");
|
||||
if (displayTotalOrganik) displayTotalOrganik.textContent = formatWeightDisplay(totalOrganik);
|
||||
if (displayTotalAnorganik) displayTotalAnorganik.textContent = formatWeightDisplay(totalAnorganik);
|
||||
if (displayTotalResidu) displayTotalResidu.textContent = formatWeightDisplay(totalResidu);
|
||||
if (displayTotal) displayTotal.textContent = formatWeightDisplay(tps.totalTimbangan);
|
||||
}
|
||||
|
||||
updateAllTotals();
|
||||
saveState();
|
||||
|
|
@ -1956,31 +2061,19 @@ const DetailPenjemputan = (function () {
|
|||
|
||||
function syncTimbanganToTpsData() {
|
||||
const tps = state.tpsData[state.activeTpsIndex];
|
||||
const form = elements.tpsContentContainer.querySelector("form");
|
||||
const form = elements.tpsContentContainer.querySelector('form');
|
||||
if (!form) return;
|
||||
|
||||
const repeater = form.querySelector('.tps-timbangan-repeater');
|
||||
const items = repeater.querySelectorAll('.timbangan-item');
|
||||
const previousTimbangan = [...tps.timbangan];
|
||||
|
||||
tps.timbangan = [];
|
||||
items.forEach((item, index) => {
|
||||
const fileInput = item.querySelector('.input-foto-timbangan');
|
||||
const weightValue = item.querySelector('.input-berat-timbangan-value');
|
||||
const jenisSampahSelect = item.querySelector('.input-jenis-sampah');
|
||||
const ocrInfo = item.querySelector('.input-ocr-info')?.textContent || 'OCR: belum diproses.';
|
||||
const existingData = previousTimbangan[index];
|
||||
|
||||
tps.timbangan.push({
|
||||
file: fileInput.files[0] || (existingData ? existingData.file : null),
|
||||
fotoFileName: existingData ? existingData.fotoFileName || '' : '',
|
||||
weight: parseWeightInput(weightValue.value),
|
||||
jenisSampah: jenisSampahSelect.value,
|
||||
uploaded: existingData ? existingData.uploaded : false,
|
||||
ocrInfo
|
||||
});
|
||||
});
|
||||
}
|
||||
const repeater = form.querySelector('.tps-timbangan-repeater');
|
||||
if (!repeater) return;
|
||||
repeater.querySelectorAll('.timbangan-item').forEach(item => {
|
||||
const idx = parseInt(item.dataset.timbanganIndex, 10);
|
||||
if (isNaN(idx) || !tps.timbangan[idx]) return;
|
||||
const weightValue = item.querySelector('.input-berat-timbangan-value');
|
||||
const ocrInfo = item.querySelector('.input-ocr-info');
|
||||
if (weightValue) tps.timbangan[idx].weight = parseWeightInput(weightValue.value);
|
||||
if (ocrInfo) tps.timbangan[idx].ocrInfo = ocrInfo.textContent;
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadSingleFotoTimbangan(itemIndex, targetItem = null) {
|
||||
const tps = state.tpsData[state.activeTpsIndex];
|
||||
|
|
@ -2025,8 +2118,6 @@ const DetailPenjemputan = (function () {
|
|||
if (res.ok && data.success) {
|
||||
timbanganItem.uploaded = true;
|
||||
timbanganItem.fotoFileName = data.fileUrl || data.fileName || '';
|
||||
const fotoInputTimb = targetItem?.querySelector('.input-foto-timbangan');
|
||||
if (fotoInputTimb) fotoInputTimb.value = '';
|
||||
showToast(data.message || `Foto timbangan #${itemIndex + 1} berhasil diupload.`, 'success');
|
||||
} else {
|
||||
showToast(data.message || 'Gagal upload foto timbangan.', 'error');
|
||||
|
|
|
|||
Loading…
Reference in New Issue