1480 lines
61 KiB
HTML
1480 lines
61 KiB
HTML
<!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">0°</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">×</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">×</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">×</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;">© 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, "'")})' 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> |