Files
local-ocr/templates/index.html

1480 lines
61 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OCR KTP/KK - Pembaca Dokumen Indonesia</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>📄 OCR KTP/KK</h1>
<p class="subtitle">Pembaca Dokumen Indonesia Offline</p>
<div class="header-actions">
<button id="reloadBtn" class="archive-header-btn secondary" title="Reload halaman">🔄 Reset /
Baru</button>
<button id="archiveBtn" class="archive-header-btn">📂 Arsip KTP</button>
<button id="archiveKKBtn" class="archive-header-btn">📂 Arsip KK</button>
</div>
</header>
<main>
<!-- Upload Section -->
<section class="upload-section">
<div class="doc-type-selector">
<button class="doc-btn active" data-type="ktp">
<span class="icon">🪪</span>
KTP
</button>
<button class="doc-btn" data-type="kk">
<span class="icon">👨‍👩‍👧‍👦</span>
Kartu Keluarga
</button>
</div>
<div class="dropzone" id="dropzone">
<div class="dropzone-content">
<div class="upload-icon">📷</div>
<p>Drag & drop gambar di sini</p>
<p class="hint">atau</p>
<label class="file-btn">
Pilih File
<input type="file" id="fileInput" accept="image/*" hidden>
</label>
<p class="file-types">PNG, JPG, JPEG, BMP, WEBP (max 16MB)</p>
</div>
<!-- Crop Container -->
<div id="cropContainer" class="crop-container" style="display: none;">
<!-- Canvas for editing (rotation & crop) -->
<canvas id="cropCanvas" class="preview-image"></canvas>
<!-- Keep img for simple viewing if needed, or just use canvas. Let's rely on canvas for editor -->
<img id="preview" class="preview-image" style="display: none;">
<div id="cropArea" class="crop-area">
<svg width="100%" height="100%"
style="position: absolute; top:0; left:0; overflow:visible;">
<polygon id="cropPolygon" points=""
style="fill: rgba(255, 255, 255, 0.1); stroke: var(--accent-primary); stroke-width: 2; vector-effect: non-scaling-stroke;">
</polygon>
</svg>
<!-- Handles TL, TR, BR, BL -->
<div class="crop-handle" data-index="0"></div>
<div class="crop-handle" data-index="1"></div>
<div class="crop-handle" data-index="2"></div>
<div class="crop-handle" data-index="3"></div>
</div>
</div>
</div>
<!-- Crop Actions -->
<div id="cropActions" class="crop-actions-container" style="display: none;">
<div class="rotation-control">
<label for="rotationSlider">Rotasi: <span id="rotationValue"></span></label>
<input type="range" id="rotationSlider" min="-45" max="45" value="0" step="1">
</div>
<div class="crop-buttons">
<button type="button" id="resetCropBtn" class="crop-action-btn secondary">🔄 Reset</button>
<button type="button" id="applyCropBtn" class="crop-action-btn primary">✂️ Terapkan
Crop</button>
</div>
</div>
<button id="processBtn" class="process-btn" disabled>
<span class="btn-text">🔍 Proses OCR</span>
<span class="btn-loading" style="display: none;">⏳ Memproses...</span>
</button>
</section>
<!-- Results Section -->
<section class="results-section" id="resultsSection" style="display: none;">
<div class="results-header">
<h2>📋 Hasil Ekstraksi</h2>
<div class="results-actions">
<button class="action-btn secondary" id="printBtn">🖨️ Cetak</button>
<button class="action-btn secondary" id="downloadBtn">⬇️ Unduh</button>
<button class="action-btn primary" id="saveBtn" title="Simpan KTP">💾 Simpan</button>
<button class="action-btn" id="copyBtn" title="Copy Text (Word)">📋 Copy</button>
<button class="action-btn" id="exportBtn" title="Download Excel (.xlsx)">📤 Excel</button>
<button class="action-btn secondary" id="toggleRaw">📝 Raw Text</button>
</div>
</div>
<div class="results-content">
<table class="results-table" id="resultsTable">
<thead>
<tr>
<th>Field</th>
<th>Nilai</th>
</tr>
</thead>
<tbody id="resultsBody">
</tbody>
</table>
<div class="raw-text-section" id="rawTextSection" style="display: none;">
<h3>Raw OCR Text</h3>
<pre id="rawText"></pre>
</div>
</div>
</section>
<!-- Error Section -->
<section class="error-section" id="errorSection" style="display: none;">
<div class="error-content">
<span class="error-icon">⚠️</span>
<p id="errorMessage"></p>
</div>
</section>
</main>
<!-- Login Modal -->
<div id="loginModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>🔐 Login Arsip</h2>
<span class="close-btn" id="closeLoginBtn">&times;</span>
</div>
<div class="modal-body">
<p style="margin-bottom:1rem; color:var(--text-secondary);">Masukkan password untuk mengakses arsip
(Default: admin / 123).</p>
<div class="form-group">
<label>Username</label>
<input type="text" id="loginUser" class="form-control" value="admin">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="loginPass" class="form-control" placeholder="Password">
</div>
<div id="loginError"
style="color:var(--text-error); display:none; margin-bottom:1rem; font-size:0.9rem;"></div>
<button id="submitLoginBtn" class="action-btn primary" style="width:100%;">Masuk</button>
</div>
</div>
</div>
<!-- Archive Modal -->
<div id="archiveModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2>📂 Arsip KTP</h2>
<button id="closeModalBtn" class="close-btn">&times;</button>
</div>
<div class="modal-body">
<div id="archiveList" class="archive-list">
<!-- Cards will be loaded here -->
</div>
<div id="archiveLoading" class="archive-loading" style="display: none;">
⏳ Memuat...
</div>
<div id="archiveEmpty" class="archive-empty" style="display: none;">
Belum ada KTP yang disimpan
</div>
</div>
</div>
</div>
<!-- Print Settings Modal -->
<div id="printSettingsModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2>🖨️ Pengaturan Cetak</h2>
<span class="close-btn" id="closePrintBtn">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label>Jumlah Salinan (Copies)</label>
<input type="number" id="printCopies" class="form-control" value="1" min="1" max="50">
<p class="suggestion-text">Masukkan jumlah KTP yang ingin dicetak dalam satu halaman.</p>
</div>
<div class="form-group" style="margin-top: 1rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox" id="printAutoArrange" checked>
<span>Atur Otomatis (Hemat Kertas)</span>
</label>
<p class="suggestion-text">Otomatis menata gambar berderet untuk memaksimalkan ruang kertas A4.
</p>
</div>
<div class="print-preview-info"
style="margin-top: 1.5rem; padding: 1rem; background: var(--bg-primary); border-radius: var(--radius);">
<p> <strong>Tips:</strong> Saat dialog print muncul:</p>
<ul style="margin-left: 1.5rem; margin-top: 0.5rem; color: var(--text-secondary);">
<li>Pilih Paper Size: <strong>A4</strong></li>
<li>Margins: <strong>Minimum / None</strong></li>
<li>Scale: <strong>100%</strong></li>
</ul>
</div>
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
<button id="cancelPrintBtn" class="action-btn secondary" style="flex: 1;">Batal</button>
<button id="confirmPrintBtn" class="action-btn primary" style="flex: 1;">🖨️ Cetak
Sekarang</button>
</div>
</div>
</div>
</div>
<footer>
<p>OCR menggunakan <a href="https://github.com/PaddlePaddle/PaddleOCR" target="_blank">PaddleOCR</a> • Data
diproses secara lokal</p>
<p style="margin-top: 0.5rem; font-size: 0.9em; opacity: 0.8;">&copy; Copyright by Wartana</p>
</footer>
</div>
<script>
// State
let selectedFile = null;
let originalImageObject = null; // For cropping
let extractedData = null;
let currentDocType = 'ktp'; // Default
let currentArchiveType = 'ktp'; // Default for archive view
// Elements
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const processBtn = document.getElementById('processBtn');
const resultsSection = document.getElementById('resultsSection');
const resultsBody = document.getElementById('resultsBody');
const rawText = document.getElementById('rawText');
const rawTextSection = document.getElementById('rawTextSection');
const errorSection = document.getElementById('errorSection');
const errorMessage = document.getElementById('errorMessage');
const docBtns = document.querySelectorAll('.doc-btn');
// Field labels untuk display
const fieldLabels = {
// KTP
'nik': 'NIK',
'nama': 'Nama',
'tempat_lahir': 'Tempat Lahir',
'tanggal_lahir': 'Tanggal Lahir',
'jenis_kelamin': 'Jenis Kelamin',
'gol_darah': 'Gol. Darah',
'alamat': 'Alamat',
'rt_rw': 'RT/RW',
'kel_desa': 'Kel/Desa',
'kecamatan': 'Kecamatan',
'agama': 'Agama',
'status_perkawinan': 'Status Perkawinan',
'pekerjaan': 'Pekerjaan',
'kewarganegaraan': 'Kewarganegaraan',
'berlaku_hingga': 'Berlaku Hingga',
'provinsi': 'Provinsi',
'kabupaten_kota': 'Kabupaten/Kota',
'tanggal_penerbitan': 'Tanggal Penerbitan',
// KK
'no_kk': 'No. KK',
'nama_kepala_keluarga': 'Kepala Keluarga',
'kode_pos': 'Kode Pos',
'anggota_keluarga': 'Jumlah Anggota'
};
// Doc type selection
docBtns.forEach(btn => {
btn.addEventListener('click', () => {
docBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentDocType = btn.dataset.type;
});
});
// Drag & drop
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => {
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dropzone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
});
// File input
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
// Click on dropzone
// Click on dropzone - DISABLED (User request: Only 'Pilih File' button should work)
dropzone.addEventListener('click', (e) => {
// Do nothing. Label 'file-btn' handles clicks on itself automatically.
// preventing accidental uploads when clicking background/crop area.
});
// Canvas & Rotation Variables
const cropCanvas = document.getElementById('cropCanvas');
const rotationSlider = document.getElementById('rotationSlider');
const rotationValue = document.getElementById('rotationValue');
let currentRotation = 0;
// let originalImageObject = null; // Store Image object for redraws - moved to global state
function handleFile(file) {
if (!file.type.startsWith('image/')) {
showError('File harus berupa gambar');
return;
}
if (file.size > 16 * 1024 * 1024) {
showError('Ukuran file maksimal 16MB');
return;
}
originalFile = file;
selectedFile = file;
currentRotation = 0;
updateRotationUI();
// Load image
const reader = new FileReader();
reader.onload = (e) => {
originalImageData = e.target.result;
// Create Image object
const img = new Image();
img.onload = () => {
originalImageObject = img;
preview.src = e.target.result; // Keep this for backup/debugging
// Render to canvas
renderEditor();
cropCanvas.style.display = 'block';
preview.style.display = 'none';
cropContainer.style.display = 'block';
dropzone.querySelector('.dropzone-content').style.display = 'none';
cropActions.style.display = 'flex';
// Init crop area after first render
// Small timeout to ensure layout is done
setTimeout(initCropArea, 50);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
processBtn.disabled = false;
hideError();
resultsSection.style.display = 'none';
}
// Handle Rotation
rotationSlider.addEventListener('input', (e) => {
currentRotation = parseInt(e.target.value);
updateRotationUI();
renderEditor();
});
function updateRotationUI() {
rotationSlider.value = currentRotation;
rotationValue.textContent = currentRotation + '°';
}
function renderEditor() {
if (!originalImageObject) return;
const angleRad = currentRotation * Math.PI / 180;
const sin = Math.abs(Math.sin(angleRad));
const cos = Math.abs(Math.cos(angleRad));
// Calculate new bounding box size
const width = originalImageObject.naturalWidth;
const height = originalImageObject.naturalHeight;
const newWidth = width * cos + height * sin;
const newHeight = width * sin + height * cos;
// Set canvas internal size
cropCanvas.width = newWidth;
cropCanvas.height = newHeight;
const ctx = cropCanvas.getContext('2d');
// Clear & Draw
ctx.clearRect(0, 0, newWidth, newHeight);
ctx.save();
ctx.translate(newWidth / 2, newHeight / 2);
ctx.rotate(angleRad);
ctx.drawImage(originalImageObject, -width / 2, -height / 2);
ctx.restore();
}
// Crop functionality (Perspective / 4-Point)
const cropContainer = document.getElementById('cropContainer');
// Prevent clicks in crop area from triggering file upload
cropContainer.addEventListener('click', (e) => {
e.stopPropagation();
});
cropContainer.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
const cropArea = document.getElementById('cropArea');
const cropPolygon = document.getElementById('cropPolygon');
const cropActions = document.getElementById('cropActions');
const resetCropBtn = document.getElementById('resetCropBtn');
const applyCropBtn = document.getElementById('applyCropBtn');
let originalFile = null;
let originalImageData = null;
let cropPoints = []; // [{x,y}, {x,y}, {x,y}, {x,y}]
let isDragging = false;
let activeHandleIndex = null;
// KTP aspect ratio: 85.6mm x 53.98mm = ~1.586
const KTP_ASPECT_RATIO = 85.6 / 53.98;
// KK aspect ratio (A4 Landscape): 297mm x 210mm = ~1.414
const KK_ASPECT_RATIO = 297 / 210;
function initCropArea() {
// Match cropArea size/pos to canvas size/pos
cropArea.style.left = cropCanvas.offsetLeft + 'px';
cropArea.style.top = cropCanvas.offsetTop + 'px';
cropArea.style.width = cropCanvas.offsetWidth + 'px';
cropArea.style.height = cropCanvas.offsetHeight + 'px';
const w = cropCanvas.offsetWidth;
const h = cropCanvas.offsetHeight;
// Initialize default box (Centered Rectangle with appropriate ratio)
const targetRatio = currentDocType === 'kk' ? KK_ASPECT_RATIO : KTP_ASPECT_RATIO;
let boxW, boxH;
if (w / h > targetRatio) {
boxH = h * 0.7;
boxW = boxH * targetRatio;
} else {
boxW = w * 0.7;
boxH = boxW / targetRatio;
}
const cx = w / 2;
const cy = h / 2;
// 0: TL, 1: TR, 2: BR, 3: BL
cropPoints = [
{ x: cx - boxW / 2, y: cy - boxH / 2 },
{ x: cx + boxW / 2, y: cy - boxH / 2 },
{ x: cx + boxW / 2, y: cy + boxH / 2 },
{ x: cx - boxW / 2, y: cy + boxH / 2 }
];
updateCropVisuals();
cropArea.style.display = 'block';
}
function updateCropVisuals() {
// Update Handles
const handles = cropArea.querySelectorAll('.crop-handle');
cropPoints.forEach((p, i) => {
if (handles[i]) {
handles[i].style.left = p.x + 'px';
handles[i].style.top = p.y + 'px';
}
});
// Update Polygon
// SVG points format: x1,y1 x2,y2 ...
const pointsStr = cropPoints.map(p => `${p.x},${p.y}`).join(' ');
cropPolygon.setAttribute('points', pointsStr);
}
// Handle Dragging
const handles = cropArea.querySelectorAll('.crop-handle');
handles.forEach(handle => {
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
activeHandleIndex = parseInt(handle.dataset.index);
isDragging = true;
});
handle.addEventListener('touchstart', (e) => {
e.stopPropagation();
activeHandleIndex = parseInt(handle.dataset.index);
isDragging = true;
}, { passive: false });
});
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('touchmove', handleDragMove, { passive: false });
document.addEventListener('mouseup', handleDragEnd);
document.addEventListener('touchend', handleDragEnd);
// Also allow updating cropArea size on window resize (since canvas might resize)
window.addEventListener('resize', () => {
if (cropCanvas.offsetParent) {
cropArea.style.left = cropCanvas.offsetLeft + 'px';
cropArea.style.top = cropCanvas.offsetTop + 'px';
cropArea.style.width = cropCanvas.offsetWidth + 'px';
cropArea.style.height = cropCanvas.offsetHeight + 'px';
}
});
function getEventPos(e) {
const rect = cropArea.getBoundingClientRect();
let clientX, clientY;
if (e.touches && e.touches.length > 0) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
return {
x: clientX - rect.left,
y: clientY - rect.top
};
}
function handleDragMove(e) {
if (!isDragging || activeHandleIndex === null) return;
e.preventDefault();
const pos = getEventPos(e);
// Constrain to bounds
// Allow slightly outside? No, keep inside for UI sanity
const x = Math.max(0, Math.min(pos.x, cropArea.offsetWidth));
const y = Math.max(0, Math.min(pos.y, cropArea.offsetHeight));
cropPoints[activeHandleIndex] = { x, y };
updateCropVisuals();
}
function handleDragEnd() {
isDragging = false;
activeHandleIndex = null;
}
// Hook rotation to reset crop
rotationSlider.addEventListener('input', () => {
// We need to re-init crop area because canvas content changed
initCropArea();
});
// Reset crop button
resetCropBtn.addEventListener('click', () => {
currentRotation = 0;
updateRotationUI();
if (originalImageObject) {
renderEditor();
cropCanvas.style.display = 'block';
preview.style.display = 'none';
setTimeout(initCropArea, 50);
selectedFile = originalFile;
}
});
// Apply crop button (Perspective Transform API)
applyCropBtn.addEventListener('click', async () => {
if (!originalImageObject) return;
applyCropBtn.disabled = true;
applyCropBtn.textContent = '⏳ Memproses...';
try {
// 1. Get current Canvas blob (Rotated image)
const canvasBlob = await new Promise(resolve => cropCanvas.toBlob(resolve, 'image/jpeg', 0.95));
// 2. Calculate points relative to internal canvas resolution
const scaleX = cropCanvas.width / cropCanvas.offsetWidth;
const scaleY = cropCanvas.height / cropCanvas.offsetHeight;
// Map points to actual image coordinates
// Note: The backend expects [TL, TR, BR, BL] which is how cropPoints is ordered [0,1,2,3]
const realPoints = cropPoints.map(p => [p.x * scaleX, p.y * scaleY]);
// 3. Send to server
const formData = new FormData();
formData.append('image', canvasBlob, 'rotated_temp.jpg');
formData.append('image', canvasBlob, 'rotated_temp.jpg');
formData.append('points', JSON.stringify(realPoints));
formData.append('doc_type', currentDocType);
const response = await fetch('/api/transform-perspective', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// Update preview with transformed image
preview.src = result.image_url;
preview.style.display = 'block';
cropCanvas.style.display = 'none';
cropArea.style.display = 'none';
// Fetch blob to update selectedFile
const resBlob = await fetch(result.image_url).then(r => r.blob());
selectedFile = new File([resBlob], result.filename || 'perspective_cropped.jpg', { type: 'image/jpeg' });
} else {
showError('Gagal transformasi: ' + result.error);
}
} catch (error) {
console.error('Crop error:', error);
showError('Gagal memproses crop: ' + error.message);
} finally {
applyCropBtn.disabled = false;
applyCropBtn.textContent = '✂️ Terapkan Crop';
}
});
// Process button
processBtn.addEventListener('click', async () => {
if (!selectedFile) return;
const btnText = processBtn.querySelector('.btn-text');
const btnLoading = processBtn.querySelector('.btn-loading');
processBtn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'inline';
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('doc_type', currentDocType);
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
extractedData = result.data;
currentDocType = result.doc_type || 'ktp';
if (result.validation) {
validationResult = result.validation;
}
displayResults(result);
hideError();
} else {
showError(result.error);
resultsSection.style.display = 'none';
}
} catch (error) {
showError('Terjadi kesalahan: ' + error.message);
} finally {
processBtn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
});
// Region fields that use dropdowns - in hierarchical order
const regionFields = ['provinsi', 'kabupaten_kota', 'kecamatan', 'kel_desa'];
let regionData = {
provinces: [],
regencies: {},
districts: {},
villages: {}
};
let validationResult = null;
// Define field display order
const fieldOrder = [
// Location hierarchy first
'provinsi', 'kabupaten_kota', 'kecamatan', 'kel_desa',
// Identity
'nik', 'nama', 'tempat_lahir', 'tanggal_lahir', 'jenis_kelamin', 'gol_darah',
// Address
'alamat', 'rt_rw',
// Other info
'agama', 'status_perkawinan', 'pekerjaan', 'kewarganegaraan', 'berlaku_hingga',
// Issue date
'tanggal_penerbitan',
// KK specific
'no_kk', 'nama_kepala_keluarga', 'kode_pos', 'anggota_keluarga'
];
async function displayResults(result) {
resultsBody.innerHTML = '';
const data = result.data;
extractedData = data;
// Validate region data first
await validateRegionData(data);
// Sort keys by fieldOrder
const sortedKeys = Object.keys(data).sort((a, b) => {
const indexA = fieldOrder.indexOf(a);
const indexB = fieldOrder.indexOf(b);
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
// Disable Save button initially (since it's auto-saved)
const saveBtn = document.getElementById('saveBtn');
if (result.saved_to_db) {
saveBtn.disabled = true;
saveBtn.textContent = '💾 Tersimpan';
saveBtn.classList.add('secondary');
saveBtn.classList.remove('primary');
} else {
saveBtn.disabled = false;
saveBtn.textContent = '💾 Simpan';
saveBtn.classList.remove('secondary');
saveBtn.classList.add('primary');
}
for (const key of sortedKeys) {
const value = data[key];
if (key === 'anggota_keluarga') {
const count = Array.isArray(value) ? value.length : 0;
addResultRow('Jumlah Anggota', count + ' orang', null, false);
} else if (regionFields.includes(key)) {
// Region field with dropdown
const label = fieldLabels[key] || key;
await addRegionRow(label, value || '', key);
} else {
const label = fieldLabels[key] || key;
addResultRow(label, value || '', key, true);
}
}
// Add change listener to all inputs to re-enable save button
document.querySelectorAll('.editable-field').forEach(input => {
input.addEventListener('input', enableSaveButton);
});
rawText.textContent = result.raw_text;
resultsSection.style.display = 'block';
resultsSection.scrollIntoView({ behavior: 'smooth' });
}
function enableSaveButton() {
const saveBtn = document.getElementById('saveBtn');
saveBtn.disabled = false;
saveBtn.textContent = '💾 Simpan Perubahan';
saveBtn.classList.remove('secondary');
saveBtn.classList.add('primary');
}
async function validateRegionData(data) {
try {
const response = await fetch('/api/validate-region', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
validationResult = result.validation;
}
} catch (e) {
console.error('Validation error:', e);
}
}
async function addRegionRow(label, value, key) {
const row = document.createElement('tr');
const validation = validationResult?.[key];
const isValid = validation?.valid;
const suggestion = validation?.suggestion;
// Status indicator
const statusIcon = isValid ? '✓' : (value ? '⚠' : '');
const statusClass = isValid ? 'valid-field' : (value ? 'invalid-field' : '');
row.innerHTML = `
<td class="field-label">
${label}
<span class="validation-status ${statusClass}">${statusIcon}</span>
</td>
<td class="field-value">
<div class="region-field-wrapper">
<input type="text" class="editable-field ${statusClass}" data-key="${key}"
value="${suggestion || value || ''}" placeholder="Ketik atau pilih...">
<select class="region-dropdown" data-key="${key}" style="display: none;">
<option value="">-- Pilih --</option>
</select>
<button type="button" class="dropdown-toggle" data-key="${key}" title="Pilih dari daftar">▼</button>
</div>
${suggestion && suggestion !== value ? `<div class="suggestion-text">Saran: ${suggestion}</div>` : ''}
</td>
`;
const input = row.querySelector('input');
const select = row.querySelector('select');
const toggleBtn = row.querySelector('.dropdown-toggle');
// Input change
input.addEventListener('input', (e) => {
if (extractedData) {
extractedData[key] = e.target.value;
}
});
// Toggle dropdown
toggleBtn.addEventListener('click', async () => {
if (select.style.display === 'none') {
await loadDropdownOptions(key, select);
select.style.display = 'block';
input.style.display = 'none';
} else {
select.style.display = 'none';
input.style.display = 'block';
}
});
// Select change
select.addEventListener('change', (e) => {
const selectedOption = e.target.options[e.target.selectedIndex];
const selectedCode = selectedOption.value;
const selectedName = selectedOption.text !== '-- Pilih --' ? selectedOption.text : '';
input.value = selectedName;
if (extractedData) {
extractedData[key] = selectedName;
}
// Update validation result with selected code for cascading
if (!validationResult) validationResult = {};
validationResult[key] = {
valid: !!selectedCode,
code: selectedCode,
suggestion: selectedName
};
select.style.display = 'none';
input.style.display = 'block';
// Change toggle button to checkmark if valid selection
if (selectedCode) {
toggleBtn.textContent = '✓';
toggleBtn.classList.add('confirmed');
input.classList.remove('invalid-field');
input.classList.add('valid-field');
} else {
toggleBtn.textContent = '▼';
toggleBtn.classList.remove('confirmed');
}
// Clear dependent fields and their codes
clearDependentFields(key);
});
resultsBody.appendChild(row);
}
async function loadDropdownOptions(key, select) {
select.innerHTML = '<option value="">Loading...</option>';
try {
let data = [];
if (key === 'provinsi') {
if (!regionData.provinces.length) {
const res = await fetch('/api/provinces');
const json = await res.json();
regionData.provinces = json.data || [];
}
data = regionData.provinces;
} else if (key === 'kabupaten_kota') {
const provCode = validationResult?.provinsi?.code;
if (provCode) {
if (!regionData.regencies[provCode]) {
const res = await fetch(`/api/regencies/${provCode}`);
const json = await res.json();
regionData.regencies[provCode] = json.data || [];
}
data = regionData.regencies[provCode];
}
} else if (key === 'kecamatan') {
const regCode = validationResult?.kabupaten_kota?.code;
if (regCode) {
if (!regionData.districts[regCode]) {
const res = await fetch(`/api/districts/${regCode}`);
const json = await res.json();
regionData.districts[regCode] = json.data || [];
}
data = regionData.districts[regCode];
}
} else if (key === 'kel_desa') {
const distCode = validationResult?.kecamatan?.code;
if (distCode) {
if (!regionData.villages[distCode]) {
const res = await fetch(`/api/villages/${distCode}`);
const json = await res.json();
regionData.villages[distCode] = json.data || [];
}
data = regionData.villages[distCode];
}
}
select.innerHTML = '<option value="">-- Pilih --</option>';
data.forEach(item => {
const option = document.createElement('option');
option.value = item.code;
option.textContent = item.name;
select.appendChild(option);
});
} catch (e) {
select.innerHTML = '<option value="">Error loading data</option>';
}
}
function clearDependentFields(key) {
const dependents = {
'provinsi': ['kabupaten_kota', 'kecamatan', 'kel_desa'],
'kabupaten_kota': ['kecamatan', 'kel_desa'],
'kecamatan': ['kel_desa']
};
(dependents[key] || []).forEach(depKey => {
const input = document.querySelector(`input[data-key="${depKey}"]`);
if (input) input.value = '';
if (extractedData) extractedData[depKey] = '';
// Clear validation code for cascading
if (validationResult && validationResult[depKey]) {
validationResult[depKey] = { valid: false, code: null, suggestion: null };
}
});
}
function addResultRow(label, value, key, editable = true) {
const row = document.createElement('tr');
if (editable && key) {
row.innerHTML = `
<td class="field-label">${label}</td>
<td class="field-value">
<input type="text" class="editable-field" data-key="${key}" value="${value || ''}" placeholder="Klik untuk edit...">
</td>
`;
const input = row.querySelector('input');
input.addEventListener('input', (e) => {
if (extractedData && key) {
extractedData[key] = e.target.value;
}
});
} else {
row.innerHTML = `
<td class="field-label">${label}</td>
<td class="field-value">${value || '-'}</td>
`;
}
resultsBody.appendChild(row);
}
// Toggle raw text
document.getElementById('toggleRaw').addEventListener('click', () => {
const isVisible = rawTextSection.style.display !== 'none';
rawTextSection.style.display = isVisible ? 'none' : 'block';
});
// Copy to clipboard (Formatted Text for Word)
document.getElementById('copyBtn').addEventListener('click', () => {
if (extractedData) {
// Format as Key: Value text
const excludeKeys = ['raw_text', 'image_path', 'id', 'created_at', 'updated_at'];
const text = Object.entries(extractedData)
.filter(([k, v]) => !excludeKeys.includes(k) && v)
.map(([k, v]) => {
const label = k.replace(/_/g, ' ').toUpperCase();
return `${label}: ${v}`;
})
.join('\n');
navigator.clipboard.writeText(text)
.then(() => alert('Data berhasil disalin (Format Teks)! Bisa dipaste di Word/Notepad.'));
}
});
// Export Excel (Real .xlsx via Backend)
document.getElementById('exportBtn').addEventListener('click', async () => {
if (!extractedData) return;
const btn = document.getElementById('exportBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '⏳...';
btn.disabled = true;
try {
const res = await fetch('/api/export-excel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(extractedData)
});
if (res.ok) {
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const filename = extractedData.nik ? `Data_KTP_${extractedData.nik}.xlsx` : 'Data_KTP.xlsx';
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} else {
const err = await res.json();
alert('Gagal export excel: ' + (err.error || 'Unknown error'));
}
} catch (e) {
console.error(e);
alert('Error export excel.');
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
});
function showError(message) {
errorMessage.textContent = message;
errorSection.style.display = 'block';
}
function hideError() {
errorSection.style.display = 'none';
}
// Save KTP Button
const saveBtn = document.getElementById('saveBtn');
saveBtn.addEventListener('click', async () => {
if (!extractedData || !selectedFile) return;
saveBtn.disabled = true;
saveBtn.innerHTML = '⏳ Menyimpan...';
// Determine endpoint based on currentDocType
const endpoint = currentDocType === 'kk' ? '/api/save-kk' : '/api/save-ktp';
try {
// Use selectedFile directly (it is already cropped/rotated by Apply Crop)
const formData = new FormData();
// Rename file based on type just for neatness
const filename = currentDocType === 'kk' ? 'kk_saved.jpg' : 'ktp_saved.jpg';
formData.append('image', selectedFile, filename);
formData.append('data', JSON.stringify(extractedData));
// Send to server
const response = await fetch(endpoint, {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
const docName = currentDocType === 'kk' ? 'Kartu Keluarga' : 'KTP';
alert(`Data ${docName} berhasil disimpan!`);
saveBtn.innerHTML = '✅ Tersimpan';
} else {
alert('Gagal menyimpan: ' + result.error);
saveBtn.innerHTML = '💾 Simpan';
saveBtn.disabled = false;
}
} catch (error) {
alert('Terjadi kesalahan: ' + error.message);
saveBtn.innerHTML = '💾 Simpan';
saveBtn.disabled = false;
}
});
// Print functionality
const printBtn = document.getElementById('printBtn');
const printSettingsModal = document.getElementById('printSettingsModal');
const closePrintBtn = document.getElementById('closePrintBtn');
const cancelPrintBtn = document.getElementById('cancelPrintBtn');
const confirmPrintBtn = document.getElementById('confirmPrintBtn');
const printCopiesInput = document.getElementById('printCopies');
const printAutoArrange = document.getElementById('printAutoArrange');
// Open Print Settings
printBtn.addEventListener('click', () => {
// Validate if there is something to print
const isPreviewVisible = preview.style.display !== 'none' && preview.getAttribute('src') !== '#' && preview.src;
const isCanvasVisible = cropCanvas.style.display !== 'none';
if (!isPreviewVisible && !isCanvasVisible) {
alert('Tidak ada gambar untuk dicetak!');
return;
}
// Defaults
printCopiesInput.value = 1;
printAutoArrange.checked = currentDocType === 'ktp'; // Default on for KTP
printSettingsModal.style.display = 'block';
});
// Close Print Settings
function closePrintModal() {
printSettingsModal.style.display = 'none';
}
closePrintBtn.addEventListener('click', closePrintModal);
cancelPrintBtn.addEventListener('click', closePrintModal);
// Confirm Print
confirmPrintBtn.addEventListener('click', () => {
const printArea = document.getElementById('printArea');
printArea.innerHTML = ''; // Clear previous
// Determine Source Image
let imgSrc = '';
let isCropCanvas = false;
// Prioritize Preview (Result/Archive), then Crop Canvas (Editing)
if (preview.style.display !== 'none' && preview.getAttribute('src') !== '#' && preview.src) {
imgSrc = preview.src;
} else if (cropCanvas.style.display !== 'none') {
// Use Canvas Data
imgSrc = cropCanvas.toDataURL('image/jpeg', 0.95);
isCropCanvas = true;
}
if (!imgSrc) {
alert('Gagal mengambil gambar source.');
closePrintModal();
return;
}
const copies = parseInt(printCopiesInput.value) || 1;
// KTP Logic: Duplicate N times
if (currentDocType === 'ktp') {
for (let i = 0; i < copies; i++) {
const img = document.createElement('img');
img.src = imgSrc;
img.className = 'ktp-print-item';
printArea.appendChild(img);
}
// Apply Grid? CSS handles #printArea display: grid by default in @media print
// We can force toggle if needed, but CSS is cleaner.
// If User unchecks "Auto Arrange", we could change class to block?
if (!printAutoArrange.checked) {
printArea.style.display = 'block'; // Override grid
// Add page breaks or margins?
// For non-arranged, maybe just list them?
// Let's stick to Grid as default default. If unchecked, maybe just normal flow?
// Actually, user wants "Save Paper" (Grid) vs "One per page"?
// Let's assume unchecked means "Standard Flow" which might just be grid anyway but maybe less aggressive?
// For now, let's keep it simple: Grid is always active for KTP if multiple copies.
// A simple way to respect "No Auto Arrange" is to force page break?
// User request: "Jangan ditaruh di tengah... hemat kertas" -> Default Grid is the solution.
} else {
printArea.style.removeProperty('display'); // Use CSS default (grid)
}
} else {
// KK / A4 Logic
// Usually 1 copy per page, or just N copies
for (let i = 0; i < copies; i++) {
const img = document.createElement('img');
img.src = imgSrc;
img.className = 'a4-print-size';
printArea.appendChild(img);
}
}
closePrintModal();
// Wait a bit for images to render in hidden DOM
setTimeout(() => {
window.print();
}, 300);
});
// Download functionality
const downloadBtn = document.getElementById('downloadBtn');
downloadBtn.addEventListener('click', () => {
// Check if preview is valid
const isPreviewVisible = preview.style.display !== 'none' && preview.getAttribute('src') !== '#' && preview.src;
if (!isPreviewVisible) {
if (cropCanvas.style.display !== 'none') {
// Allow download canvas
const link = document.createElement('a');
link.download = 'ktp_scan_raw.jpg';
link.href = cropCanvas.toDataURL('image/jpeg', 0.95);
link.click();
return;
}
alert('Tidak ada gambar untuk diunduh');
return;
}
const link = document.createElement('a');
link.href = preview.src;
// Construct filename from Extracted Data if available
let filename = 'ktp_scan.jpg';
const nikInput = document.getElementById('field-nik'); // ID format might be different, let's check render logic
// renderEditableFields generates identifiers? No, extractedData global var is safest.
if (typeof extractedData !== 'undefined' && extractedData.nik) {
filename = `KTP_${extractedData.nik}.jpg`;
} else {
// Try getting from DOM inputs if extractedData not set
const domNik = document.querySelector('input[data-key="nik"]');
if (domNik && domNik.value) filename = `KTP_${domNik.value}.jpg`;
}
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
// Archive Modal Logic
const archiveBtn = document.getElementById('archiveBtn');
const archiveModal = document.getElementById('archiveModal');
const closeModalBtn = document.getElementById('closeModalBtn');
const archiveList = document.getElementById('archiveList');
const archiveLoading = document.getElementById('archiveLoading');
const archiveEmpty = document.getElementById('archiveEmpty');
// Login Logic Vars
const loginModal = document.getElementById('loginModal');
const loginUser = document.getElementById('loginUser');
const loginPass = document.getElementById('loginPass');
const submitLoginBtn = document.getElementById('submitLoginBtn');
const loginError = document.getElementById('loginError');
const closeLoginBtn = document.getElementById('closeLoginBtn');
// Check Auth Helper
async function checkAuth() {
try {
const res = await fetch('/api/check-auth');
if (res.ok) {
const data = await res.json();
return data.authenticated;
}
return false;
} catch (e) { return false; }
}
// Archive Button Logic
// const archiveBtn = document.getElementById('archiveBtn'); // Already declared
const archiveKKBtn = document.getElementById('archiveKKBtn');
async function openArchive(type) {
currentArchiveType = type;
const title = type === 'kk' ? 'Arsip Kartu Keluarga' : 'Arsip KTP';
document.querySelector('#archiveModal h2').textContent = '📂 ' + title;
if (await checkAuth()) {
archiveModal.style.display = 'block';
loadArchive();
} else {
loginModal.style.display = 'block';
// Reset login form
loginPass.value = '';
loginError.style.display = 'none';
loginPass.focus();
}
}
archiveBtn.addEventListener('click', () => openArchive('ktp'));
if (archiveKKBtn) {
archiveKKBtn.addEventListener('click', () => openArchive('kk'));
}
// Submit Login Logic
submitLoginBtn.addEventListener('click', async () => {
const user = loginUser.value;
const pass = loginPass.value;
if (!user || !pass) {
loginError.textContent = 'Username dan Password harus diisi';
loginError.style.display = 'block';
return;
}
loginError.style.display = 'none';
submitLoginBtn.disabled = true;
submitLoginBtn.innerHTML = 'Memeriksa...';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
const data = await res.json();
if (data.success) {
loginModal.style.display = 'none';
archiveModal.style.display = 'block';
loadArchive();
} else {
loginError.textContent = data.error || 'Login gagal';
loginError.style.display = 'block';
}
} catch (e) {
loginError.textContent = 'Gagal terhubung ke server';
loginError.style.display = 'block';
} finally {
submitLoginBtn.disabled = false;
submitLoginBtn.innerHTML = 'Masuk';
}
});
// Handle Enter Key
loginPass.addEventListener('keypress', (e) => {
if (e.key === 'Enter') submitLoginBtn.click();
});
// Close Login Modal
closeLoginBtn.addEventListener('click', () => { loginModal.style.display = 'none'; });
// Close Archive Modal (Restored)
closeModalBtn.addEventListener('click', () => { archiveModal.style.display = 'none'; });
// Window click handler for both modals
window.addEventListener('click', (e) => {
if (e.target === archiveModal) archiveModal.style.display = 'none';
if (e.target === loginModal) loginModal.style.display = 'none';
if (e.target === printSettingsModal) printSettingsModal.style.display = 'none';
});
async function loadArchive() {
archiveList.innerHTML = '';
archiveLoading.style.display = 'block';
archiveEmpty.style.display = 'none';
try {
const endpoint = `/api/${currentArchiveType}-archive?per_page=50`;
const response = await fetch(endpoint);
const result = await response.json();
archiveLoading.style.display = 'none';
if (result.success && result.data.length > 0) {
renderArchiveList(result.data);
} else {
archiveEmpty.style.display = 'block';
}
} catch (error) {
console.error('Archive load error:', error);
archiveLoading.innerHTML = '❌ Gagal memuat data';
}
}
function renderArchiveList(records) {
const imgPrefix = currentArchiveType === 'kk' ? '/kk-images/' : '/ktp-images/';
records.forEach(record => {
const card = document.createElement('div');
card.className = 'archive-card';
const date = new Date(record.created_at).toLocaleDateString('id-ID', {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
const title = currentArchiveType === 'kk' ? (record.kepala_keluarga || 'Tanpa Nama') : (record.nama || 'Tanpa Nama');
const idVal = currentArchiveType === 'kk' ? (record.no_kk || '-') : (record.nik || '-');
card.innerHTML = `
<div class="archive-card-img">
<img src="${imgPrefix}${record.image_path}" alt="${title}" loading="lazy">
</div>
<div class="archive-card-content">
<h3>${title}</h3>
<div class="archive-card-meta">
<span class="nik">${idVal}</span>
<span class="date">${date}</span>
</div>
<div class="archive-card-actions" style="display:flex; gap:0.5rem;">
<button class="view-btn" onclick='viewArchiveDetail(${JSON.stringify(record).replace(/'/g, "&#39;")})' style="flex:1;">👁️ Lihat</button>
<a href="${imgPrefix}${record.image_path}" download="${currentArchiveType.toUpperCase()}_${idVal}.jpg" class="view-btn" style="flex:1; text-align:center; text-decoration:none; display:flex; align-items:center; justify-content:center;">⬇️</a>
</div>
</div>
`;
archiveList.appendChild(card);
});
}
// Global function to view detail from archive
window.viewArchiveDetail = (record) => {
extractedData = record;
currentDocType = currentArchiveType; // Sync type so Save works correctly
// 1. Display results
displayResults({ data: record, raw_text: record.raw_text || '' });
// 2. Load image into preview
if (record.image_path) {
const imgPrefix = currentArchiveType === 'kk' ? '/kk-images/' : '/ktp-images/';
const imgUrl = `${imgPrefix}${record.image_path}`;
preview.src = imgUrl;
// Set originalImageData to allow re-cropping or re-saving if needed
fetch(imgUrl)
.then(res => res.blob())
.then(blob => {
const reader = new FileReader();
reader.onloadend = () => {
originalImageData = reader.result;
selectedFile = new File([blob], record.image_path, { type: blob.type });
// Initialize Image Object for rotation editor
const img = new Image();
img.onload = () => {
originalImageObject = img;
// We don't necessarily need to renderEditor() immediately if we are in "View" mode
// But having it ready is good for "Reset"
};
img.src = reader.result;
};
reader.readAsDataURL(blob);
});
// Show preview area (static image result), hide dropzone/canvas
preview.style.display = 'block';
cropCanvas.style.display = 'none';
dropzone.querySelector('.dropzone-content').style.display = 'none';
// Setup crop ui visibility
cropContainer.style.display = 'block';
cropArea.style.display = 'none';
cropActions.style.display = 'flex';
// Reset rotation slider for viewing (since we are viewing already cropped/straightened result)
currentRotation = 0;
updateRotationUI();
}
archiveModal.style.display = 'none';
};
// Reload Button
document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload();
});
</script>
<!-- Print Area -->
<div id="printArea"></div>
<script>
// ... (this comment is just marker, main script is above)
</script>
</body>
</html>