Enhance SIDAK with two-way KK-KTP linkage, scanner mobile optimization, NIK validation, and UI improvements

- Implement bidirectional KK-KTP linkage system (address-based & NIK-based)
- Optimize scanner for mobile devices (touch slop, larger hit areas, modal locking)
- Add NIK validation (16-digit numeric) with client-side feedback
- Set default RT/RW values to '000' for both KK and KTP forms
- Change 'Kpl Keluarga' label to 'Kepala Keluarga'
- Improve scanner error messages and user feedback
- Remove redundant 'Deteksi Dokumen' button
- Add database schema updates and Docker support files
This commit is contained in:
2026-01-22 07:34:17 +08:00
parent 334d24b6b8
commit 05dd3f2a67
15 changed files with 1204 additions and 253 deletions

View File

@@ -30,9 +30,9 @@
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Kpl Keluarga</label>
<label class="col-sm-2 col-form-label">Kepala Keluarga</label>
<div class="col-sm-6">
<input type="text" class="form-control" id="kepala" name="kepala" placeholder="Kpl Keluarga" required>
<input type="text" class="form-control" id="kepala" name="kepala" placeholder="Kepala Keluarga" required>
</div>
</div>
@@ -46,10 +46,10 @@
<div class="form-group row">
<label class="col-sm-2 col-form-label">RT/RW</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="rt" name="rt" placeholder="RT" required>
<input type="text" class="form-control" id="rt" name="rt" placeholder="RT" value="000" required>
</div>
<div class="col-sm-3">
<input type="text" class="form-control" id="rw" name="rw" placeholder="RW" required>
<input type="text" class="form-control" id="rw" name="rw" placeholder="RW" value="000" required>
</div>
</div>
@@ -238,27 +238,28 @@ window.addEventListener('load', function() {
}
//mulai proses simpan data
$no_kk = $_POST['no_kk'];
$cek_kk = mysqli_query($koneksi, "SELECT * FROM tb_kk WHERE no_kk='$no_kk'");
if(mysqli_num_rows($cek_kk) > 0){
echo "<script>
Swal.fire({title: 'Gagal',text: 'No KK sudah terdaftar dalam sistem!',icon: 'error',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=add-kartu';
}
})</script>";
return;
}
// Sanitize Inputs
$no_kk = mysqli_real_escape_string($koneksi, $_POST['no_kk']);
$kepala = mysqli_real_escape_string($koneksi, $_POST['kepala']);
$desa = mysqli_real_escape_string($koneksi, $_POST['desa']);
$rt = mysqli_real_escape_string($koneksi, $_POST['rt']);
$rw = mysqli_real_escape_string($koneksi, $_POST['rw']);
$kec = mysqli_real_escape_string($koneksi, $_POST['kec']);
$kab = mysqli_real_escape_string($koneksi, $_POST['kab']);
$prov = mysqli_real_escape_string($koneksi, $_POST['prov']);
$no_kk_raw = trim($_POST['no_kk']);
$no_kk_clean = mysqli_real_escape_string($koneksi, $no_kk_raw);
$cek_kk = mysqli_query($koneksi, "SELECT * FROM tb_kk WHERE no_kk='$no_kk_clean'");
if(mysqli_num_rows($cek_kk) > 0){
echo "<script>
Swal.fire({title: 'Gagal',text: 'No KK sudah terdaftar dalam sistem!',icon: 'error',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=add-kartu';
}
})</script>";
return;
}
// Sanitize Inputs
$no_kk = $no_kk_clean;
$kepala = mysqli_real_escape_string($koneksi, trim($_POST['kepala']));
$desa = mysqli_real_escape_string($koneksi, trim($_POST['desa']));
$rt = mysqli_real_escape_string($koneksi, trim($_POST['rt']));
$rw = mysqli_real_escape_string($koneksi, trim($_POST['rw']));
$kec = mysqli_real_escape_string($koneksi, trim($_POST['kec']));
$kab = mysqli_real_escape_string($koneksi, trim($_POST['kab']));
$prov = mysqli_real_escape_string($koneksi, trim($_POST['prov']));
$sql_simpan = "INSERT INTO tb_kk (no_kk, kepala, desa, rt, rw, kec, kab, prov, foto_kk) VALUES (
'$no_kk',
@@ -272,43 +273,67 @@ window.addEventListener('load', function() {
'$nama_file')";
$query_simpan = mysqli_query($koneksi, $sql_simpan);
// Process Auto-Linking Members
$linked_count = 0;
$failed_count = 0;
if ($query_simpan && !empty($_POST['anggota_json'])) {
$id_kk_baru = mysqli_insert_id($koneksi);
$anggota_list = json_decode($_POST['anggota_json'], true);
if (is_array($anggota_list)) {
foreach ($anggota_list as $mem) {
$nik_mem = mysqli_real_escape_string($koneksi, $mem['nik']);
$hub_mem = mysqli_real_escape_string($koneksi, $mem['hubungan']);
// Search Resident by NIK
$sql_cek_pend = "SELECT id_pend FROM tb_pdd WHERE nik='$nik_mem'";
$q_cek_pend = mysqli_query($koneksi, $sql_cek_pend);
if ($row_pend = mysqli_fetch_assoc($q_cek_pend)) {
$id_pend_found = $row_pend['id_pend'];
// Insert into tb_anggota
$sql_add_ang = "INSERT INTO tb_anggota (id_kk, id_pend, hubungan) VALUES ('$id_kk_baru', '$id_pend_found', '$hub_mem')";
mysqli_query($koneksi, $sql_add_ang);
$linked_count++;
} else {
$failed_count++;
}
}
}
}
// Process Auto-Linking Members
$linked_count = 0;
$failed_count = 0;
$failed_members = []; // Store failed members for detailed feedback
if ($query_simpan && !empty($_POST['anggota_json'])) {
$id_kk_baru = mysqli_insert_id($koneksi);
$anggota_list = json_decode($_POST['anggota_json'], true);
if (is_array($anggota_list)) {
foreach ($anggota_list as $mem) {
$nik_mem = mysqli_real_escape_string($koneksi, $mem['nik']);
$nama_mem = isset($mem['nama']) ? mysqli_real_escape_string($koneksi, $mem['nama']) : '';
$hub_mem = mysqli_real_escape_string($koneksi, $mem['hubungan']);
// Search Resident by NIK
$sql_cek_pend = "SELECT id_pend FROM tb_pdd WHERE nik='$nik_mem'";
$q_cek_pend = mysqli_query($koneksi, $sql_cek_pend);
if ($row_pend = mysqli_fetch_assoc($q_cek_pend)) {
$id_pend_found = $row_pend['id_pend'];
// Insert into tb_anggota
$sql_add_ang = "INSERT INTO tb_anggota (id_kk, id_pend, hubungan) VALUES ('$id_kk_baru', '$id_pend_found', '$hub_mem')";
mysqli_query($koneksi, $sql_add_ang);
$linked_count++;
} else {
$failed_count++;
$failed_members[] = ['nik' => $nik_mem, 'nama' => $nama_mem, 'hubungan' => $hub_mem];
}
}
}
}
mysqli_close($koneksi);
if ($query_simpan) {
$msg_add = "";
if($linked_count > 0 || $failed_count > 0) {
$msg_add = "<br>Anggota Terhubung: <b>$linked_count</b><br>Tidak Ditemukan: <b>$failed_count</b>";
}
if ($query_simpan) {
$msg_add = "";
if($linked_count > 0 || $failed_count > 0) {
$msg_add = "<br>Anggota Terhubung: <b>$linked_count</b><br>Tidak Ditemukan: <b>$failed_count</b>";
// Add detailed failed members list if any
if (!empty($failed_members)) {
$msg_add .= "<br><br><b>Detail Anggota Tidak Ditemukan:</b><br>";
$msg_add .= "<div style='max-height: 150px; overflow-y: auto; background: #f8f9fa; border: 1px solid #ddd; padding: 5px; font-size: 0.9rem;'>";
foreach ($failed_members as $fm) {
$url_params = http_build_query([
'nik' => $fm['nik'],
'nama' => $fm['nama'],
'desa' => $desa,
'rt' => $rt,
'rw' => $rw,
'kecamatan' => $kec,
'kabupaten' => $kab,
'provinsi' => $prov
]);
$add_link = "<a href='index.php?page=add-pend&$url_params' class='btn btn-xs btn-outline-primary ml-2'>Tambah</a>";
$msg_add .= "• <b>" . htmlspecialchars($fm['nik']) . "</b> - " . htmlspecialchars($fm['nama']) . " (" . htmlspecialchars($fm['hubungan']) . ") $add_link<br>";
}
$msg_add .= "</div>";
$msg_add .= "<small class='text-muted'>Tambahkan data penduduk yang belum terdaftar melalui menu <a href='index.php?page=add-pend'>Tambah Penduduk</a>.</small>";
}
}
echo "<script>
Swal.fire({title: 'Tambah Data Berhasil',html: 'Data KK disimpan.$msg_add',icon: 'success',confirmButtonText: 'OK'

View File

@@ -52,7 +52,7 @@
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Kpl Keluarga</label>
<label class="col-sm-2 col-form-label">Kepala Keluarga</label>
<div class="col-sm-6">
<input type="text" class="form-control" id="kepala" name="kepala" value="<?php echo $data_cek['kepala']; ?>"
required>

View File

@@ -88,10 +88,10 @@
<div class="form-group row">
<label class="col-sm-2 col-form-label">RT/RW</label>
<div class="col-sm-3">
<input type="text" class="form-control" id="rt" name="rt" placeholder="RT" required>
<input type="text" class="form-control" id="rt" name="rt" placeholder="RT" value="000" required>
</div>
<div class="col-sm-3">
<input type="text" class="form-control" id="rw" name="rw" placeholder="RW" required>
<input type="text" class="form-control" id="rw" name="rw" placeholder="RW" value="000" required>
</div>
</div>
@@ -140,11 +140,62 @@
</div>
<script>
window.addEventListener('load', function() {
// Scanner Logic
var inputImage = document.getElementById('foto_ktp');
var preview = document.getElementById('preview_ktp');
var hiddenInput = document.getElementById('foto_cropped');
window.addEventListener('load', function() {
// Pre-fill form from URL parameters
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('nik')) document.getElementsByName('nik')[0].value = urlParams.get('nik');
if (urlParams.has('nama')) document.getElementsByName('nama')[0].value = urlParams.get('nama');
if (urlParams.has('desa')) document.getElementsByName('desa')[0].value = urlParams.get('desa');
if (urlParams.has('rt')) document.getElementsByName('rt')[0].value = urlParams.get('rt');
if (urlParams.has('rw')) document.getElementsByName('rw')[0].value = urlParams.get('rw');
if (urlParams.has('kecamatan')) document.getElementsByName('kecamatan')[0].value = urlParams.get('kecamatan');
if (urlParams.has('kabupaten')) document.getElementsByName('kabupaten')[0].value = urlParams.get('kabupaten');
if (urlParams.has('provinsi')) document.getElementsByName('provinsi')[0].value = urlParams.get('provinsi');
// NIK Validation (16 digits, numeric)
var nikInput = document.getElementsByName('nik')[0];
function validateNIK(nik) {
nik = nik.trim();
if (nik.length !== 16) return false;
return /^\d+$/.test(nik); // Only digits
}
function showNIKError(message) {
Swal.fire({
icon: 'warning',
title: 'Format NIK Salah',
text: message,
confirmButtonText: 'OK'
});
}
// Validate on blur
if (nikInput) {
nikInput.addEventListener('blur', function() {
var nik = this.value.trim();
if (nik === '') return;
if (!validateNIK(nik)) {
showNIKError('NIK harus terdiri dari 16 digit angka.');
this.focus();
}
});
}
// Validate on form submit
var form = document.querySelector('form');
if (form) {
form.addEventListener('submit', function(e) {
var nik = nikInput.value.trim();
if (nik !== '' && !validateNIK(nik)) {
e.preventDefault();
showNIKError('NIK harus terdiri dari 16 digit angka.');
nikInput.focus();
return false;
}
});
}
// Scanner Logic
var inputImage = document.getElementById('foto_ktp');
var preview = document.getElementById('preview_ktp');
var hiddenInput = document.getElementById('foto_cropped');
// Defines callback for scanner modal
window.handleScannerResult = function(base64) {
@@ -331,69 +382,142 @@ window.addEventListener('load', function() {
}
//mulai proses simpan data
$nik = $_POST['nik'];
$cek_nik = mysqli_query($koneksi, "SELECT * FROM tb_pdd WHERE nik='$nik'");
if(mysqli_num_rows($cek_nik) > 0){
echo "<script>
Swal.fire({title: 'Gagal',text: 'NIK sudah terdaftar dalam sistem!',icon: 'error',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=add-pend';
}
})</script>";
return;
}
$nik = mysqli_real_escape_string($koneksi, trim($_POST['nik']));
$cek_nik = mysqli_query($koneksi, "SELECT * FROM tb_pdd WHERE nik='$nik'");
if(mysqli_num_rows($cek_nik) > 0){
echo "<script>
Swal.fire({title: 'Gagal',text: 'NIK sudah terdaftar dalam sistem!',icon: 'error',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=add-pend';
}
})</script>";
return;
}
// Sanitize Input to prevent SQL Injection & Syntax Errors
$nama = mysqli_real_escape_string($koneksi, trim($_POST['nama']));
$tempat_lh = mysqli_real_escape_string($koneksi, trim($_POST['tempat_lh']));
$tgl_lh = mysqli_real_escape_string($koneksi, trim($_POST['tgl_lh']));
$jekel = mysqli_real_escape_string($koneksi, trim($_POST['jekel']));
$desa = mysqli_real_escape_string($koneksi, trim($_POST['desa']));
$rt = mysqli_real_escape_string($koneksi, trim($_POST['rt']));
$rw = mysqli_real_escape_string($koneksi, trim($_POST['rw']));
$agama = mysqli_real_escape_string($koneksi, trim($_POST['agama']));
$kawin = mysqli_real_escape_string($koneksi, trim($_POST['kawin']));
$pekerjaan = mysqli_real_escape_string($koneksi, trim($_POST['pekerjaan']));
$kecamatan = mysqli_real_escape_string($koneksi, trim($_POST['kecamatan']));
$kabupaten = mysqli_real_escape_string($koneksi, trim($_POST['kabupaten']));
$provinsi = mysqli_real_escape_string($koneksi, trim($_POST['provinsi']));
$kewarganegaraan = mysqli_real_escape_string($koneksi, trim($_POST['kewarganegaraan']));
// Sanitize Input to prevent SQL Injection & Syntax Errors
$nik = mysqli_real_escape_string($koneksi, $_POST['nik']);
$nama = mysqli_real_escape_string($koneksi, $_POST['nama']);
$tempat_lh = mysqli_real_escape_string($koneksi, $_POST['tempat_lh']);
$tgl_lh = mysqli_real_escape_string($koneksi, $_POST['tgl_lh']);
$jekel = mysqli_real_escape_string($koneksi, $_POST['jekel']);
$desa = mysqli_real_escape_string($koneksi, $_POST['desa']);
$rt = mysqli_real_escape_string($koneksi, $_POST['rt']);
$rw = mysqli_real_escape_string($koneksi, $_POST['rw']);
$agama = mysqli_real_escape_string($koneksi, $_POST['agama']);
$kawin = mysqli_real_escape_string($koneksi, $_POST['kawin']);
$pekerjaan = mysqli_real_escape_string($koneksi, $_POST['pekerjaan']);
$kecamatan = mysqli_real_escape_string($koneksi, $_POST['kecamatan']);
$kabupaten = mysqli_real_escape_string($koneksi, $_POST['kabupaten']);
$provinsi = mysqli_real_escape_string($koneksi, $_POST['provinsi']);
$kewarganegaraan = mysqli_real_escape_string($koneksi, $_POST['kewarganegaraan']);
$sql_simpan = "INSERT INTO tb_pdd (nik, nama, tempat_lh, tgl_lh, jekel, desa, rt, rw, agama, kawin, pekerjaan, foto_ktp, status, kecamatan, kabupaten, provinsi, kewarganegaraan) VALUES (
'$nik',
'$nama',
'$tempat_lh',
'$tgl_lh',
'$jekel',
'$desa',
'$rt',
'$rw',
'$agama',
'$kawin',
'$pekerjaan',
'$nama_file',
'Ada',
'$kecamatan',
'$kabupaten',
'$provinsi',
'$kewarganegaraan')";
$query_simpan = mysqli_query($koneksi, $sql_simpan);
mysqli_close($koneksi);
if ($query_simpan) {
echo "<script>
Swal.fire({title: 'Tambah Data Berhasil',text: '',icon: 'success',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=data-pend';
}
})</script>";
}else{
echo "<script>
Swal.fire({title: 'Tambah Data Gagal',text: '<?php echo mysqli_error($koneksi); ?>',icon: 'error',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=add-pend';
}
})</script>";
}}
$sql_simpan = "INSERT INTO tb_pdd (nik, nama, tempat_lh, tgl_lh, jekel, desa, rt, rw, agama, kawin, pekerjaan, foto_ktp, status, kecamatan, kabupaten, provinsi, kewarganegaraan) VALUES (
'$nik',
'$nama',
'$tempat_lh',
'$tgl_lh',
'$jekel',
'$desa',
'$rt',
'$rw',
'$agama',
'$kawin',
'$pekerjaan',
'$nama_file',
'Ada',
'$kecamatan',
'$kabupaten',
'$provinsi',
'$kewarganegaraan')";
$query_simpan = mysqli_query($koneksi, $sql_simpan);
if ($query_simpan) {
$id_pend_baru = mysqli_insert_id($koneksi);
// KTP → KK: Cari KK yang cocok berdasarkan alamat
$sql_cari_kk = "SELECT k.id_kk, k.no_kk, k.kepala, k.desa, k.rt, k.rw
FROM tb_kk k
WHERE k.desa='$desa' AND k.rt='$rt' AND k.rw='$rw'
AND k.kec='$kecamatan' AND k.kab='$kabupaten' AND k.prov='$provinsi'";
$q_cari_kk = mysqli_query($koneksi, $sql_cari_kk);
$kk_cocok = mysqli_fetch_assoc($q_cari_kk);
if ($kk_cocok) {
// Tawarkan untuk menghubungkan dengan KK
$no_kk = $kk_cocok['no_kk'];
$kepala_kk = $kk_cocok['kepala'];
$id_kk = $kk_cocok['id_kk'];
// Cek apakah sudah terhubung
$sql_cek_hubungan = "SELECT * FROM tb_anggota WHERE id_kk='$id_kk' AND id_pend='$id_pend_baru'";
$q_cek_hubungan = mysqli_query($koneksi, $sql_cek_hubungan);
if (mysqli_num_rows($q_cek_hubungan) == 0) {
// Simpan sementara data untuk konfirmasi JavaScript
$_SESSION['kk_link_data'] = [
'id_pend' => $id_pend_baru,
'id_kk' => $id_kk,
'no_kk' => $no_kk,
'kepala_kk' => $kepala_kk,
'nama_pend' => $nama
];
echo "<script>
Swal.fire({
title: 'Data Penduduk Disimpan!',
html: '<b>Data berhasil disimpan.</b><br><br>Ditemukan KK dengan alamat yang sama:<br><b>No KK: $no_kk</b><br>Kepala Keluarga: $kepala_kk<br><br>Hubungkan ke KK ini sebagai anggota?',
icon: 'success',
showCancelButton: true,
confirmButtonText: 'Ya, Hubungkan',
cancelButtonText: 'Tidak, Simpan Saja'
}).then((result) => {
if (result.value) {
// Kirim permintaan untuk menghubungkan
fetch('admin/pend/link_to_kk.php', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'id_pend=$id_pend_baru&id_kk=$id_kk&hubungan=ANGGOTA'
}).then(resp => resp.json()).then(data => {
if(data.success) {
Swal.fire('Berhasil!', 'Data telah dihubungkan dengan KK.', 'success').then(() => {
window.location = 'index.php?page=data-pend';
});
} else {
Swal.fire('Gagal', 'Gagal menghubungkan: ' + data.message, 'error').then(() => {
window.location = 'index.php?page=data-pend';
});
}
});
} else {
window.location = 'index.php?page=data-pend';
}
});
</script>";
} else {
// Sudah terhubung
echo "<script>
Swal.fire({title: 'Tambah Data Berhasil',text: 'Data telah terhubung dengan KK $no_kk',icon: 'success',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=data-pend';
}
})</script>";
}
} else {
// Tidak ada KK yang cocok
echo "<script>
Swal.fire({title: 'Tambah Data Berhasil',text: '',icon: 'success',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=data-pend';
}
})</script>";
}
} else {
echo "<script>
Swal.fire({title: 'Tambah Data Gagal',text: '" . mysqli_error($koneksi) . "',icon: 'error',confirmButtonText: 'OK'
}).then((result) => {if (result.value){
window.location = 'index.php?page=add-pend';
}
})</script>";
}
mysqli_close($koneksi);
}
//selesai proses simpan data

62
admin/pend/link_to_kk.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
session_start();
include "../inc/koneksi.php";
header('Content-Type: application/json');
if (!isset($_SESSION["ses_username"])) {
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'message' => 'Invalid request method']);
exit;
}
$id_pend = isset($_POST['id_pend']) ? intval($_POST['id_pend']) : 0;
$id_kk = isset($_POST['id_kk']) ? intval($_POST['id_kk']) : 0;
$hubungan = isset($_POST['hubungan']) ? mysqli_real_escape_string($koneksi, $_POST['hubungan']) : 'ANGGOTA';
if ($id_pend <= 0 || $id_kk <= 0) {
echo json_encode(['success' => false, 'message' => 'Invalid ID']);
exit;
}
// Cek apakah sudah terhubung
$sql_cek = "SELECT * FROM tb_anggota WHERE id_kk='$id_kk' AND id_pend='$id_pend'";
$q_cek = mysqli_query($koneksi, $sql_cek);
if (mysqli_num_rows($q_cek) > 0) {
echo json_encode(['success' => false, 'message' => 'Sudah terhubung']);
exit;
}
// Cek apakah penduduk ada
$sql_cek_pend = "SELECT * FROM tb_pdd WHERE id_pend='$id_pend'";
$q_cek_pend = mysqli_query($koneksi, $sql_cek_pend);
if (mysqli_num_rows($q_cek_pend) == 0) {
echo json_encode(['success' => false, 'message' => 'Data penduduk tidak ditemukan']);
exit;
}
// Cek apakah KK ada
$sql_cek_kk = "SELECT * FROM tb_kk WHERE id_kk='$id_kk'";
$q_cek_kk = mysqli_query($koneksi, $sql_cek_kk);
if (mysqli_num_rows($q_cek_kk) == 0) {
echo json_encode(['success' => false, 'message' => 'Data KK tidak ditemukan']);
exit;
}
// Hubungkan
$sql_link = "INSERT INTO tb_anggota (id_kk, id_pend, hubungan) VALUES ('$id_kk', '$id_pend', '$hubungan')";
$q_link = mysqli_query($koneksi, $sql_link);
if ($q_link) {
echo json_encode(['success' => true, 'message' => 'Berhasil dihubungkan']);
} else {
echo json_encode(['success' => false, 'message' => 'Database error: ' . mysqli_error($koneksi)]);
}
mysqli_close($koneksi);
?>

View File

@@ -20,11 +20,11 @@
</div>
</div>
<div class="modal-footer justify-content-between">
<div>
<button type="button" class="btn btn-secondary" id="btnScanRotateLeft" title="Putar Kiri (-90°)"><i class="fas fa-undo"></i></button>
<button type="button" class="btn btn-secondary" id="btnScanRotateRight" title="Putar Kanan (+90°)"><i class="fas fa-redo"></i></button>
<button type="button" class="btn btn-warning" id="btnScanReset"><i class="fas fa-sync"></i> Reset Sudut</button>
</div>
<div>
<button type="button" class="btn btn-secondary" id="btnScanRotateLeft" title="Putar Kiri (-90°)"><i class="fas fa-undo"></i></button>
<button type="button" class="btn btn-secondary" id="btnScanRotateRight" title="Putar Kanan (+90°)"><i class="fas fa-redo"></i></button>
<button type="button" class="btn btn-warning" id="btnScanReset"><i class="fas fa-sync"></i> Reset Sudut</button>
</div>
<div>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="button" class="btn btn-primary" id="btnScanSave"><i class="fas fa-check"></i> Simpan Hasil Scan</button>
@@ -34,6 +34,117 @@
</div>
</div>
<style>
/* Disable all transitions and hover effects for scanner modal */
#modalScanner .card,
#modalScanner .modal-content,
#modalScanner .modal-body,
#modalScanner .modal-header,
#modalScanner .modal-footer,
#modalScanner .modal-dialog,
#modalScanner #scanner-container,
#modalScanner #scanner-container * {
transition: none !important;
}
#modalScanner .card:hover,
#modalScanner .modal-content:hover,
#modalScanner .modal-body:hover,
#modalScanner .modal-header:hover,
#modalScanner .modal-footer:hover,
#modalScanner .modal-dialog:hover,
#modalScanner #scanner-container:hover,
#modalScanner #scanner-container *:hover {
transform: none !important;
}
/* Ensure canvas is fully visible */
#canvas-image {
opacity: 1 !important;
filter: none !important;
background-color: white !important;
}
#canvas-overlay {
opacity: 1 !important;
filter: none !important;
}
/* Prevent canvas from moving on hover */
#canvas-image:hover,
#canvas-overlay:hover {
transform: none !important;
}
/* Prevent modal dragging and improve touch handling on mobile */
#modalScanner .modal-dialog,
#modalScanner .modal-content,
#modalScanner .modal-header,
#modalScanner .modal-footer {
touch-action: none !important; /* Prevent browser touch gestures (pan, zoom, swipe) */
user-select: none !important; /* Prevent text selection during drag */
-webkit-user-select: none !important;
-webkit-touch-callout: none !important;
}
/* Allow touch interaction only on canvas and buttons */
#modalScanner .modal-body,
#canvas-image,
#canvas-overlay {
touch-action: manipulation !important; /* Allow pinch-zoom and pan on canvas only */
}
/* Prevent modal backdrop from responding to touch */
.modal-backdrop {
touch-action: none !important;
}
/* Lock modal position on mobile */
@media (max-width: 768px) {
#modalScanner {
padding-right: 0 !important; /* Prevent shift from scrollbar */
}
#modalScanner .modal-dialog {
margin: 0 !important;
max-height: 100vh !important;
height: 100vh !important;
width: 100vw !important;
max-width: 100vw !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
transform: none !important;
transition: none !important;
}
#modalScanner .modal-content {
border-radius: 0 !important;
height: 100vh !important;
max-height: 100vh !important;
width: 100vw !important;
max-width: 100vw !important;
overflow: hidden !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
transform: none !important;
transition: none !important;
}
#modalScanner .modal-body {
height: calc(100vh - 120px) !important; /* Account for header and footer */
overflow: hidden !important;
}
/* Prevent any modal movement */
.modal-open #modalScanner {
overflow: hidden !important;
}
/* Larger touch target for close button on mobile */
#modalScanner .modal-header .close {
padding: 20px !important;
font-size: 2rem !important;
line-height: 1 !important;
margin: -10px -10px -10px auto !important;
}
}
</style>
<script>
window.addEventListener('load', function() {
// Scanner Variables
@@ -43,19 +154,31 @@ window.addEventListener('load', function() {
var ctxImg = canvasImage.getContext('2d');
var ctxOver = canvasOverlay.getContext('2d');
var scanner = new jscanify();
var scanner = null;
try {
scanner = new jscanify();
} catch (e) {
console.error('Failed to initialize jscanify:', e);
// scanner remains null
}
var originalImg = new Image();
// Corner Points (tl, tr, bl, br)
var corners = [];
var activePoint = null;
var isDragging = false;
// Corner Points (tl, tr, bl, br)
var corners = [];
var activePoint = null;
var isDragging = false;
var touchStartPos = null;
var isTouchInteraction = false;
var touchOffset = null;
// Config
const POINT_RADIUS = 15;
const POINT_COLOR = '#007bff';
const LINE_COLOR = '#00ff00';
const LINE_WIDTH = 3;
// Config
const POINT_RADIUS = 15;
const POINT_COLOR = '#007bff';
const LINE_COLOR = '#00ff00';
const LINE_WIDTH = 3;
const TOUCH_RADIUS_MULTIPLIER = 2.5; // Larger hit area for touch devices
const TOUCH_SLOP = 5; // pixels threshold before dragging starts
const IS_TOUCH_DEVICE = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// --- Public Function to Open Scanner ---
// --- Public Function to Open Scanner ---
@@ -91,75 +214,139 @@ window.addEventListener('load', function() {
};
reader.readAsDataURL(file);
// 2. Show Modal & Listen
scannerModal.off('shown.bs.modal'); // Remove old listeners
scannerModal.on('shown.bs.modal', function() {
modalShown = true;
checkReady();
});
// 2. Show Modal & Listen
scannerModal.off('shown.bs.modal'); // Remove old listeners
scannerModal.on('shown.bs.modal', function() {
modalShown = true;
checkReady();
// Prevent body scrolling on mobile
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
// Prevent modal dragging on mobile
var modalDialog = document.querySelector('#modalScanner .modal-dialog');
var modalContent = document.querySelector('#modalScanner .modal-content');
var modalHeader = document.querySelector('#modalScanner .modal-header');
var modalFooter = document.querySelector('#modalScanner .modal-footer');
var preventTouch = function(e) {
// Only prevent if not clicking a button
if (e.target.tagName !== 'BUTTON' && !e.target.closest('button')) {
e.preventDefault();
e.stopPropagation();
}
};
if (modalDialog) {
modalDialog.addEventListener('touchstart', preventTouch, { passive: false });
modalDialog.addEventListener('touchmove', preventTouch, { passive: false });
}
if (modalContent) {
modalContent.addEventListener('touchstart', preventTouch, { passive: false });
modalContent.addEventListener('touchmove', preventTouch, { passive: false });
}
if (modalHeader) {
modalHeader.addEventListener('touchstart', preventTouch, { passive: false });
modalHeader.addEventListener('touchmove', preventTouch, { passive: false });
}
if (modalFooter) {
modalFooter.addEventListener('touchstart', preventTouch, { passive: false });
modalFooter.addEventListener('touchmove', preventTouch, { passive: false });
}
});
// Restore scrolling when modal is hidden
scannerModal.on('hidden.bs.modal', function() {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
});
scannerModal.modal('show');
};
function initScanner() {
// Resize Canvas to fit screen but keep aspect ratio
var maxWidth = $('#modalScanner .modal-body').width() - 20;
var maxHeight = $('#modalScanner .modal-body').height() - 20;
var scale = Math.min(maxWidth / originalImg.width, maxHeight / originalImg.height);
var w = originalImg.width * scale;
var h = originalImg.height * scale;
canvasImage.width = w;
canvasImage.height = h;
canvasOverlay.width = w;
canvasOverlay.height = h;
// Resize container
$('#scanner-container').css({ width: w, height: h, marginTop: '10px' });
// Draw Image
ctxImg.drawImage(originalImg, 0, 0, w, h);
// Detect Contour using jscanify
try {
// jscanify expects an image element, we can pass originalImg but we need to map coordinates
// Wait, jscanify uses OpenCV which might not be ready.
if (typeof cv !== 'undefined' && cv.Mat) {
// We need to work on the original image for detection, then scale points
var contour = scanner.findPaper(originalImg);
// contour returns { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } each {x, y}
if (contour) {
corners = [
{ x: contour.topLeftCorner.x * scale, y: contour.topLeftCorner.y * scale },
{ x: contour.topRightCorner.x * scale, y: contour.topRightCorner.y * scale },
{ x: contour.bottomRightCorner.x * scale, y: contour.bottomRightCorner.y * scale }, // Order: tr -> br -> bl ?? No, usually tl, tr, br, bl order for polygon drawing
{ x: contour.bottomLeftCorner.x * scale, y: contour.bottomLeftCorner.y * scale }
];
// Reorder primarily for logic: TL, TR, BR, BL
corners = [
{ x: contour.topLeftCorner.x * scale, y: contour.topLeftCorner.y * scale },
{ x: contour.topRightCorner.x * scale, y: contour.topRightCorner.y * scale },
{ x: contour.bottomRightCorner.x * scale, y: contour.bottomRightCorner.y * scale },
{ x: contour.bottomLeftCorner.x * scale, y: contour.bottomLeftCorner.y * scale }
];
function detectDocument(scale, w, h, showAlert = false) {
// Detect Contour using jscanify
try {
// jscanify expects an image element, we can pass originalImg but we need to map coordinates
// Wait, jscanify uses OpenCV which might not be ready.
if (typeof cv !== 'undefined' && cv.Mat) {
// We need to work on the original image for detection, then scale points
var contour = scanner ? scanner.findPaper(originalImg) : null;
// contour returns { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } each {x, y}
if (contour) {
// Order: TL, TR, BR, BL (clockwise)
corners = [
{ x: contour.topLeftCorner.x * scale, y: contour.topLeftCorner.y * scale },
{ x: contour.topRightCorner.x * scale, y: contour.topRightCorner.y * scale },
{ x: contour.bottomRightCorner.x * scale, y: contour.bottomRightCorner.y * scale },
{ x: contour.bottomLeftCorner.x * scale, y: contour.bottomLeftCorner.y * scale }
];
} else {
defaultCorners(w, h);
}
} else {
console.warn("OpenCV not ready yet");
defaultCorners(w, h);
}
} catch(e) {
console.error("Scanner Error:", e);
defaultCorners(w, h); // Fallback
}
$('#scanner-loading').hide();
drawOverlay();
}
} else {
defaultCorners(w, h);
if (showAlert) {
Swal.fire({
icon: 'warning',
title: 'Deteksi Gagal',
text: 'Dokumen tidak terdeteksi. Silakan atur sudut secara manual.',
confirmButtonText: 'OK'
});
}
}
} else {
console.warn("OpenCV not ready yet");
defaultCorners(w, h);
if (showAlert) {
Swal.fire({
icon: 'warning',
title: 'Scanner Tidak Siap',
text: 'OpenCV belum siap. Silakan atur sudut secara manual.',
confirmButtonText: 'OK'
});
}
}
} catch(e) {
console.error("Scanner Error:", e);
defaultCorners(w, h); // Fallback
if (showAlert) {
Swal.fire({
icon: 'error',
title: 'Error Scanner',
text: 'Terjadi kesalahan saat mendeteksi dokumen: ' + e.message,
confirmButtonText: 'OK'
});
}
}
drawOverlay();
}
function initScanner() {
// Resize Canvas to fit screen but keep aspect ratio
var maxWidth = $('#modalScanner .modal-body').width() - 20;
var maxHeight = $('#modalScanner .modal-body').height() - 20;
var scale = Math.min(maxWidth / originalImg.width, maxHeight / originalImg.height);
var w = originalImg.width * scale;
var h = originalImg.height * scale;
canvasImage.width = w;
canvasImage.height = h;
canvasOverlay.width = w;
canvasOverlay.height = h;
// Resize container
$('#scanner-container').css({ width: w, height: h, marginTop: '10px' });
// Draw Image
ctxImg.drawImage(originalImg, 0, 0, w, h);
detectDocument(scale, w, h, false);
$('#scanner-loading').hide();
}
function defaultCorners(w, h) {
// Default 20% margin
@@ -211,44 +398,115 @@ window.addEventListener('load', function() {
};
}
function isInside(pos, point) {
var dx = pos.x - point.x;
var dy = pos.y - point.y;
return dx * dx + dy * dy <= POINT_RADIUS * POINT_RADIUS * 2; // Bigger hit area
}
function isInside(pos, point) {
var dx = pos.x - point.x;
var dy = pos.y - point.y;
return dx * dx + dy * dy <= POINT_RADIUS * POINT_RADIUS * 2; // Bigger hit area
}
function getClosestCorner(pos, isTouch) {
var closestIdx = -1;
var closestDist = Infinity;
var radius = POINT_RADIUS * (isTouch ? TOUCH_RADIUS_MULTIPLIER : 1.5); // Larger radius for touch
var radiusSq = radius * radius;
corners.forEach((p, i) => {
var dx = pos.x - p.x;
var dy = pos.y - p.y;
var distSq = dx * dx + dy * dy;
if (distSq < radiusSq && distSq < closestDist) {
closestDist = distSq;
closestIdx = i;
}
});
return closestIdx;
}
canvasOverlay.addEventListener('mousedown', function(e) { handleStart(getMousePos(e)); });
canvasOverlay.addEventListener('touchstart', function(e) { handleStart(getMousePos(e)); e.preventDefault(); }, {passive: false});
canvasOverlay.addEventListener('mousedown', function(e) {
handleStart(getMousePos(e), false);
e.stopPropagation();
});
canvasOverlay.addEventListener('touchstart', function(e) {
var pos = getMousePos(e);
handleStart(pos, true);
e.preventDefault();
e.stopPropagation();
}, {passive: false});
window.addEventListener('mousemove', function(e) { if(isDragging) handleMove(getMousePos(e)); }); // Window to catch drag out
canvasOverlay.addEventListener('touchmove', function(e) { if(isDragging) handleMove(getMousePos(e)); e.preventDefault(); }, {passive: false});
window.addEventListener('mousemove', function(e) { if(isDragging) handleMove(getMousePos(e)); }); // Window to catch drag out
canvasOverlay.addEventListener('touchmove', function(e) {
if(isDragging) {
handleMove(getMousePos(e));
e.preventDefault();
e.stopPropagation();
}
}, {passive: false});
window.addEventListener('mouseup', function() { handleEnd(); });
window.addEventListener('touchend', function() { handleEnd(); });
function handleStart(pos) {
activePoint = null;
corners.forEach((p, i) => {
if (isInside(pos, p)) {
activePoint = i;
isDragging = true;
}
});
}
function handleStart(pos, isTouch = false) {
activePoint = getClosestCorner(pos, isTouch);
if (activePoint !== -1) {
isTouchInteraction = isTouch;
touchOffset = { x: pos.x - corners[activePoint].x, y: pos.y - corners[activePoint].y };
if (isTouch) {
touchStartPos = pos;
// Start dragging immediately but handle slop in handleMove
isDragging = true;
} else {
isDragging = true;
touchStartPos = null;
}
} else {
activePoint = null;
isDragging = false;
isTouchInteraction = false;
touchStartPos = null;
touchOffset = null;
}
}
function handleMove(pos) {
if (activePoint !== null) {
// Constrain to canvas?? Optional but good
corners[activePoint].x = pos.x;
corners[activePoint].y = pos.y;
drawOverlay();
}
}
function handleMove(pos) {
if (activePoint !== null) {
// Touch slop detection
if (isTouchInteraction && touchStartPos) {
var dx = pos.x - touchStartPos.x;
var dy = pos.y - touchStartPos.y;
var distSq = dx * dx + dy * dy;
if (distSq < TOUCH_SLOP * TOUCH_SLOP) {
return; // Ignore small movements until slop exceeded
}
// Slop exceeded, clear touchStartPos so we don't check again
touchStartPos = null;
}
// Apply offset to maintain relative position
if (touchOffset) {
corners[activePoint].x = pos.x - touchOffset.x;
corners[activePoint].y = pos.y - touchOffset.y;
} else {
corners[activePoint].x = pos.x;
corners[activePoint].y = pos.y;
}
// Optional: constrain to canvas bounds
var w = canvasOverlay.width;
var h = canvasOverlay.height;
corners[activePoint].x = Math.max(0, Math.min(w, corners[activePoint].x));
corners[activePoint].y = Math.max(0, Math.min(h, corners[activePoint].y));
drawOverlay();
}
}
function handleEnd() {
isDragging = false;
activePoint = null;
}
function handleEnd() {
isDragging = false;
activePoint = null;
isTouchInteraction = false;
touchStartPos = null;
touchOffset = null;
}
// --- Rotate Functions ---
function rotateImage(degree) {
@@ -271,8 +529,8 @@ window.addEventListener('load', function() {
originalImg.src = rotatedUrl;
}
$('#btnScanRotateLeft').click(function() { rotateImage(-90); });
$('#btnScanRotateRight').click(function() { rotateImage(90); });
$('#btnScanRotateLeft').click(function() { rotateImage(-90); });
$('#btnScanRotateRight').click(function() { rotateImage(90); });
// --- Reset Button ---
$('#btnScanReset').click(function() {
@@ -309,7 +567,13 @@ window.addEventListener('load', function() {
};
// 3. Extract with dynamic dimensions
var resultCanvas = scanner.extractPaper(originalImg, outputWidth, outputHeight, extractPoints);
var resultCanvas = null;
if (scanner && scanner.extractPaper) {
resultCanvas = scanner.extractPaper(originalImg, outputWidth, outputHeight, extractPoints);
} else {
alert("Scanner library not loaded. Please refresh the page.");
return;
}
var base64 = resultCanvas.toDataURL('image/jpeg');
if (window.handleScannerResult) {