batch 1 update style & dahsboard for rec.nurinda.co.id
This commit is contained in:
1 parent
9107ffb158
commit
f39aa60384
18485 files changed
+843
-2881869
No files matched your search
@@ -0,0 +1,4 @@
|
||||
Options -Indexes
|
||||
<FilesMatch "^(bootstrap\.php)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
@@ -0,0 +1,43 @@
|
||||
# Dashboard admin recruitment
|
||||
|
||||
Akses `/dashboard/login.php`. Dashboard memakai PHP + mysqli dan CSS responsif tanpa ketergantungan AdminLTE, CDN, Node, atau proses build. Tampilan terinspirasi dashboard modern; aplikasi ini tidak dimigrasikan ke framework Svelte.
|
||||
|
||||
## Instalasi
|
||||
|
||||
1. Siapkan database MySQL khusus recruitment, lalu impor ekspor tiga tabel `in_daper`, `in_soal`, dan `in_jawab` yang sudah tersedia. Impor hanya pada database baru/kosong; dump dapat berisi perintah DROP TABLE.
|
||||
2. Isi `includes/conn.php` sesuai database tersebut. Admin menggunakan file koneksi yang sama dengan peserta. Dashboard dan peserta selalu memakai file koneksi ini; environment `REC_DB_*` tidak digunakan.
|
||||
3. Dari terminal server, di folder root project, jalankan:
|
||||
|
||||
```sh
|
||||
php dashboard/tools/create-admin.php
|
||||
```
|
||||
|
||||
Isi nama, email, dan password admin (minimal 12 karakter). Password disembunyikan saat diketik dan disimpan sebagai hash. Perintah otomatis membuat tabel `rec_admin` menggunakan migrasi `dashboard/migrations/001_admin.sql`. Akun yang sudah ada tidak ditimpa. Tidak ada akun/password admin bawaan.
|
||||
4. Buka `/dashboard/login.php`, lalu masuk dengan akun yang baru dibuat.
|
||||
|
||||
Jika pengelola database hanya dapat membuat tabel melalui phpMyAdmin, jalankan migrasi `001_admin.sql` lebih dahulu, kemudian jalankan perintah CLI pembuat akun. PHP memerlukan ekstensi `mysqli`, dukungan mysqlnd (`get_result`), dan terminal interaktif untuk pembuatan akun. PHP minimum 7.3; pengujian dilakukan pada PHP 8.3/MySQL 8.0.
|
||||
|
||||
## Perilaku
|
||||
|
||||
- Daftar peserta: pencarian nama/email/posisi, filter seleksi, pagination 25 data, penambahan peserta, progres empat fase, dan tautan hasil.
|
||||
- Tahap seleksi mempertahankan kode lama: `0` calon kandidat, `1` tidak dilanjutkan, `2` tes offline/interview, `3` diterima, `99` demo/arsip. Status ini dipilih admin secara manual.
|
||||
- Hasil menampilkan seluruh jawaban dan kunci kategori 1–4. Admin menilai benar (`10`) atau salah (`0`). Seluruh jawaban kategori tersebut harus dinilai sebelum finalisasi. Nilai dapat dikoreksi dengan menyimpan ulang.
|
||||
- Finalisasi tersedia setelah peserta menyelesaikan empat fase (`level >= 4`). Data yang kehilangan referensi soal perlu diperbaiki sebelum finalisasi.
|
||||
- Kategori 5 (kepribadian): menampilkan pilihan L/R dan jawaban, tanpa skor atau interpretasi otomatis.
|
||||
- Ambang skor awal `300`, mengikuti halaman daftar lama. Halaman detail lama menggunakan `320`; sekarang seluruh halaman admin memakai satu nilai. Ubah environment `REC_PASS_SCORE` menjadi `320` jika itu kebijakan yang digunakan. Label lulus skor tidak otomatis mengubah keputusan seleksi.
|
||||
- Bank soal: pencarian, filter lima kategori, pagination, tambah, edit, duplikat, dan hapus. Soal yang sudah digunakan terkunci dari edit/hapus untuk menjaga hasil historis. Duplikasi membuat soal tambahan yang ikut pengacakan tes; ini bukan penggantian/penonaktifan soal lama.
|
||||
- Session admin terpisah dari peserta, kedaluwarsa setelah satu jam tanpa aktivitas. Form mutasi memakai token CSRF; query input memakai prepared statements dan keluaran di-escape.
|
||||
- Lima login gagal dalam satu sesi browser menyebabkan jeda lima menit. Untuk produksi, terapkan pembatasan percobaan login tambahan pada web server/reverse proxy.
|
||||
|
||||
## Penempatan server
|
||||
|
||||
Gunakan HTTPS. Jangan menyimpan dump SQL dalam document root produksi; `.gitignore` mengecualikan dump, dan `.htaccess` root memblokir akses HTTP ke SQL pada Apache/LiteSpeed yang mengizinkan override. Jika memakai Nginx, terapkan aturan penolakan untuk dump dan folder `dashboard/tools/` serta `dashboard/migrations/` pada konfigurasi server. PHP development server tidak memproses `.htaccess`; pakai hanya untuk pengujian lokal.
|
||||
|
||||
|
||||
## Validasi yang dilakukan
|
||||
|
||||
SQL diimpor ke database MySQL sementara lokal. Login/logout, pembatasan akses, penolakan CSRF, pencarian/filter, detail hasil, tambah peserta sintetis, tambah/edit soal, penguncian soal bersejarah, penolakan nilai tidak valid, finalisasi/koreksi nilai, serta pembaruan tahap diuji melalui HTTP. Seluruh file PHP melewati pemeriksaan sintaks. Tampilan diperiksa di browser pada ukuran desktop dan mobile. Pengujian tidak mengubah database produksi.
|
||||
|
||||
## Preview lokal yang sudah disiapkan
|
||||
|
||||
Jalankan `./dev/start-local.sh` dari root project. Dashboard berada di port 8086 dan phpMyAdmin di port 8087; detail, kredensial, backup, dan cara menghentikan layanan ada di [dev/README.md](../dev/README.md). Tabel admin baru menggunakan `email`, `nama_de`, `nama_be`, `idkar`, dan `password_hash` selain ID; script pembuat admin menerima identitas tersebut. Migrasi `002_admin_identity.sql` menangani tabel admin versi lama tanpa mengganti hash password.
|
||||
@@ -0,0 +1,10 @@
|
||||
:root{--bg:#f5f7fa;--ink:#162327;--muted:#7a858b;--line:#e6ebed;--green:#216b59;--soft:#edf5f1;--white:#fff;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);font-synthesis:none}*{box-sizing:border-box}body{margin:0;font-size:14px;line-height:1.55}a{color:inherit;text-decoration:none}button,input,select,textarea{font:inherit}button,a,input,select,textarea{ -webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.45}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #6eb39c;outline-offset:3px}h1,h2,p{margin:0}h1{font-size:29px;letter-spacing:-1px;line-height:1.25;font-weight:650}h2{font-size:18px;font-weight:650;letter-spacing:-.4px}small{font-size:12px}.muted{color:var(--muted)}.sidebar{position:fixed;inset:0 auto 0 0;width:260px;background:var(--white);border-right:1px solid var(--line);padding:32px 22px 24px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:12px;font-size:18px;font-weight:750;letter-spacing:1.5px}.brand small{display:block;font-size:10px;letter-spacing:.4px;color:var(--muted);font-weight:500;margin-top:2px}.brand-icon{display:grid;place-items:center;width:39px;height:42px;background:var(--green);color:white;border-radius:12px 12px 12px 3px;font-size:25px;font-weight:600}.nav-label{margin:50px 12px 14px;color:#98a2a8;letter-spacing:1.5px;font-size:10px;font-weight:700}nav{display:grid;gap:7px}nav a{display:flex;gap:12px;align-items:center;padding:13px 11px;color:#68757b;border-radius:8px;font-size:12px;font-weight:550}nav a span{font-size:21px;line-height:1}nav a:hover{background:#f5f7f8}nav a.active{background:var(--soft);color:var(--green)}.sidebar-bottom{margin-top:auto;display:flex;align-items:center;gap:10px;padding-top:20px;border-top:1px solid var(--line)}.sidebar-bottom strong{font-size:12px}.sidebar-bottom small{display:block;color:var(--muted);font-size:10px}.sidebar-bottom form{margin-left:auto}.avatar{display:grid;place-items:center;background:#f1e9df;color:#8a7159;width:34px;height:34px;border-radius:50%;font-weight:650;flex-shrink:0}.avatar.large{width:58px;height:58px;font-size:24px}.icon-button{background:transparent;border:0;color:var(--muted);font-size:22px}.main{margin-left:260px}.topbar{height:76px;background:#ffffffbb;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 42px;font-size:12px}.workspace-dot{color:var(--muted);font-size:11px}.workspace-dot:before{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:#7ea08e;margin-right:9px}.content{max-width:1440px;padding:36px 42px 50px;margin:auto}.heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px;gap:16px}.eyebrow{display:block;font-size:9px;font-weight:700;letter-spacing:1.9px;color:#82918d;margin-bottom:10px}.date{color:var(--muted);font-size:11px}.hero{display:flex;align-items:center;justify-content:space-between;background:#e8f0e9;border:1px solid #dae6db;border-radius:14px;padding:34px 38px;overflow:hidden;position:relative}.hero h2{font-size:30px;line-height:1.22;letter-spacing:-1px}.hero p{font-size:12px;color:#698071;max-width:370px;margin:15px 0 23px}.hero .eyebrow{color:#597d63}.hero-art{width:240px;height:200px;display:flex;align-items:center;justify-content:center;flex-direction:column;flex-shrink:0;border:1px solid #c9dbcc;border-radius:50%;background:#e0ebdf;transform:rotate(-12deg);box-shadow:0 0 0 20px #ffffff30,0 0 0 45px #ffffff20;margin-right:20px;color:#477958}.hero-art>span{font-size:110px;line-height:1.1;font-weight:200}.hero-art small{font-size:8px;letter-spacing:2px}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;background:var(--green);color:white;border:1px solid var(--green);border-radius:7px;padding:10px 17px;font-size:12px;font-weight:600;min-height:40px;white-space:nowrap;transition:background .15s}.button:hover{background:#195543}.button.secondary{background:white;color:#536469;border-color:var(--line)}.button.secondary:hover{background:#f2f6f4}.button.compact{padding:7px 11px;min-height:32px;font-size:11px}.button.danger{color:#a73939;background:#fff6f6;border-color:#f0cccc}.button.full{width:100%;justify-content:space-between}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin:24px 0}.stats article{padding:21px 24px;background:white;border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column}.stats article>span{font-size:11px;color:#77858b}.stats strong{font-size:32px;letter-spacing:-1px;margin:6px 0}.stats small{font-size:10px;color:#9aa3a8}.card{background:white;border:1px solid var(--line);border-radius:11px;margin-bottom:24px;overflow:hidden}.menu-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.menu-card{padding:25px}.menu-card:hoveLine truncated
|
||||
@media(min-width:1500px){.content{padding-top:45px}.hero{padding:40px 45px}}@media(max-width:1100px){.sidebar{width:225px;padding-inline:14px}.main{margin-left:225px}.content{padding:28px 24px}.topbar{padding:0 24px}.hero-art{width:160px;height:160px;margin-right:0}.hero h2{font-size:26px}.stats{gap:12px}.stats article{padding:17px}.auth-story{padding:32px}.auth-story h1{font-size:34px}.auth-form{padding:35px}.stage-card{align-items:flex-start;flex-direction:column}}@media(max-width:760px){.sidebar{position:relative;width:100%;padding:17px 20px;border-right:0;border-bottom:1px solid var(--line)}.brand{font-size:15px}.brand-icon{width:32px;height:34px;font-size:21px}.brand small{font-size:9px}.nav-label,.sidebar-bottom{display:none}nav{display:flex;overflow:auto;gap:6px;margin-top:20px}nav a{font-size:11px;white-space:nowrap;padding:10px 11px}nav a span{font-size:16px}.main{margin:0}.topbar{height:50px;padding:0 20px}.content{padding:25px 18px 35px}.heading{margin-bottom:22px}h1{font-size:23px}.date{font-size:10px}.workspace-dot{font-size:10px}.hero{padding:26px}.hero-art{display:none}.hero h2{font-size:28px}.hero p{font-size:12px}.stats{grid-template-columns:1fr 1fr;margin:18px 0}.stats strong{font-size:29px}.menu-grid{grid-template-columns:1fr;gap:0}.card-heading{padding:20px;align-items:flex-start;flex-wrap:wrap}.filters{flex-wrap:wrap;padding:0 20px 20px}.filters input{flex-basis:100%}.filters select{flex:1;min-width:150px}.card-heading h2{font-size:16px}td,th{padding:14px 18px}.pagination{align-items:flex-start;flex-direction:column}.participant-card{align-items:flex-start;flex-direction:column;padding:22px}.stage-card{padding:22px}.inline-form{flex-wrap:wrap;width:100%}.inline-form select{flex:1;min-width:180px}.form-grid{grid-template-columns:1fr}.form-card{padding:22px}.save-bar{padding:15px;align-items:flex-start;flex-direction:column}.save-bar .button{width:100%}.auth-main{padding:18px}.auth-layout{grid-template-columns:1fr;min-height:0}.auth-story{padding:25px;min-height:240px}.auth-story:after,.auth-story>small,.story-pills{display:none}.auth-story h1{font-size:28px;margin:20px 0 12px}.auth-story .eyebrow{display:none}.auth-story p{font-size:12px}.auth-form{padding:28px}.auth-form h2{font-size:23px}.auth-form form{margin-top:24px}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}@media print{.sidebar,.topbar,.back,.inline-form,.save-bar{display:none}.main{margin:0}.content{padding:0}.card{break-inside:avoid}.heading .date{display:block}}
|
||||
.topbar-actions{display:flex;gap:20px;align-items:center}.topbar-actions form{margin:0}
|
||||
/* Nurinda identity: signature yellow, navy text, and the official full-color logo. */
|
||||
:root{--bg:#f8f8f5;--ink:#243446;--muted:#828992;--line:#eaeae3;--green:#ffd400;--soft:#fff7cd}
|
||||
.brand-logo{width:46px;height:48px;object-fit:contain;flex-shrink:0}.brand{color:#205fa3}.brand small{color:#88909a}
|
||||
nav a.active{color:#6a5100;background:#fff5bf}nav a.active span{color:#a77c00}.button{color:#342b00;border-color:#f2ca00;background:#ffd400}.button:hover{background:#efc600}.text-link{color:#215d9e}.button.secondary{color:#4e5e70}.button.danger{color:#a73939}.tile-icon{color:#967000;background:#fff5c6}.count{color:#7b5b00;background:#fff5bf}
|
||||
.hero{background:#fff3b2;border-color:#f5e59b}.hero p{color:#88783e}.hero .eyebrow{color:#9b7a17}.hero-art{background:#ffe66b;border-color:#e6c347;color:#bd9000;box-shadow:0 0 0 20px #ffe99260,0 0 0 45px #fff8d940}.hero-art small{color:#866316}.hero h2{color:#343423}.hero .button{background:#243d5b;color:#fff;border-color:#243d5b}.hero .button:hover{background:#1e3148}
|
||||
.auth-story{background:#fff1a7}.auth-story p{color:#887b46}.auth-story:after{border-color:#e8cf69;box-shadow:0 0 0 30px #f9dc6030}.auth-story>small{color:#9b8a4d}.auth-story h1{color:#2b3b4f}.story-pills span{border-color:#e7d37c;color:#89723e}.auth-main{background:#f8f8f4}.auth-layout{border-color:#ece9db}.auth-form>.badge{background:#fff5c6;color:#8c6b00}.workspace-dot:before{background:#e9be00}.menu-card:hover{border-color:#e4ce73;box-shadow:0 4px 15px #89700010}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible{outline-color:#d8aa00}.avatar{background:#fff2c3;color:#8a6c18}
|
||||
.integrity-details{padding:0 24px 24px;margin-top:0}.integrity-details .table-wrap{margin-top:16px}
|
||||
@@ -0,0 +1,4 @@
|
||||
document.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-confirm]');
|
||||
if (button && !window.confirm(button.dataset.confirm)) event.preventDefault();
|
||||
});
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
// Admin sessions are independent from candidate sessions.
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
session_name('recruitment_admin');
|
||||
session_set_cookie_params(['httponly' => true, 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', 'samesite' => 'Lax', 'path' => '/']);
|
||||
session_start();
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header("Content-Security-Policy: default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'self'");
|
||||
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
date_default_timezone_set('Asia/Jakarta');
|
||||
const CATEGORIES = [1 => 'Matematika', 2 => 'Sinonim', 3 => 'Antonim', 4 => 'Padanan kata', 5 => 'Kepribadian'];
|
||||
const STAGES = [0 => 'Calon kandidat', 1 => 'Tidak dilanjutkan', 2 => 'Tes offline & interview', 3 => 'Diterima', 99 => 'Demo / arsip'];
|
||||
function e($value) { return htmlspecialchars(str_replace("\0", '', (string)$value), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||
function redirect($url) { header('Location: ' . $url); exit; }
|
||||
function csrf_field() { echo '<input type="hidden" name="csrf" value="' . e($_SESSION['csrf']) . '">'; }
|
||||
function verify_csrf() { if (!hash_equals($_SESSION['csrf'], (string)($_POST['csrf'] ?? ''))) { http_response_code(403); exit('Sesi formulir tidak valid. Muat ulang halaman.'); } }
|
||||
function require_admin() {
|
||||
if (empty($_SESSION['admin_id']) || time() - ($_SESSION['admin_active'] ?? 0) > 3600) {
|
||||
unset($_SESSION['admin_id']); redirect('login.php');
|
||||
}
|
||||
$_SESSION['admin_active'] = time();
|
||||
}
|
||||
function db() {
|
||||
static $connection;
|
||||
if ($connection) return $connection;
|
||||
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
|
||||
require __DIR__ . '/../includes/conn.php';
|
||||
$connection = $conn;
|
||||
$connection->set_charset('utf8mb4');
|
||||
return $connection;
|
||||
}
|
||||
function query($sql, $types = '', $params = []) {
|
||||
$statement = db()->prepare($sql);
|
||||
if ($types !== '') $statement->bind_param($types, ...$params);
|
||||
$statement->execute();
|
||||
return $statement;
|
||||
}
|
||||
function rows($sql, $types = '', $params = []) { return query($sql, $types, $params)->get_result()->fetch_all(MYSQLI_ASSOC); }
|
||||
function one($sql, $types = '', $params = []) { return rows($sql, $types, $params)[0] ?? null; }
|
||||
function threshold() { return max(0, (int)(getenv('REC_PASS_SCORE') ?: 300)); }
|
||||
function fail_page($message, $status = 503) { http_response_code($status); page_start('Layanan belum siap'); echo '<section class="card empty"><h2>Layanan belum siap</h2><p>' . e($message) . '</p></section>'; page_end(); exit; }
|
||||
set_exception_handler(function ($error) {
|
||||
error_log('Recruitment admin: ' . $error->getMessage());
|
||||
if (PHP_SAPI === 'cli') { fwrite(STDERR, "Operasi gagal. Periksa konfigurasi database dan migrasi admin.\n"); exit(1); }
|
||||
fail_page('Periksa koneksi database dan jalankan migrasi tabel admin sesuai petunjuk instalasi.');
|
||||
});
|
||||
function page_start($title, $active = '') {
|
||||
$GLOBALS['admin_layout'] = $active !== '';
|
||||
?><!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title><?=e($title)?> · Nurinda Recruitment</title><link rel="stylesheet" href="assets/admin.css"><script src="assets/admin.js" defer></script></head><body>
|
||||
<?php if ($active !== ''): ?><aside class="sidebar"><a class="brand" href="index.php"><img class="brand-logo" src="assets/nurinda-logo.png" alt="Logo Nurinda"><span>NURINDA<small>Recruitment workspace</small></span></a><span class="nav-label">WORKSPACE</span><nav><a class="<?=$active === 'home' ? 'active' : ''?>" href="index.php"><span>◫</span> Ringkasan</a><a class="<?=$active === 'participants' ? 'active' : ''?>" href="idap.php"><span>◎</span> Daftar Peserta Psikotest</a><a class="<?=$active === 'questions' ? 'active' : ''?>" href="isoal.php"><span>▤</span> Bank Soal Psikotest</a></nav><div class="sidebar-bottom"><span class="avatar"><?=e(substr($_SESSION['admin_name'] ?? 'A', 0, 1))?></span><div><strong><?=e($_SESSION['admin_name'] ?? 'Admin')?></strong><small>Administrator</small></div><form method="post" action="logout.php"><?php csrf_field(); ?><button class="icon-button" title="Keluar" aria-label="Keluar">↗</button></form></div></aside><main class="main"><header class="topbar"><span>Human Capital <span class="muted">/ Recruitment</span></span><div class="topbar-actions"><span class="workspace-dot">Admin workspace</span><form method="post" action="logout.php"><?php csrf_field(); ?><button class="icon-button" title="Keluar" aria-label="Keluar">↗</button></form></div></header><div class="content"><div class="heading"><div><span class="eyebrow">RECRUITMENT MANAGEMENT</span><h1><?=e($title)?></h1></div><span class="date"><?=date('d M Y')?></span></div><?php else: ?><main class="auth-main"><?php endif; ?>
|
||||
<?php if (!empty($_SESSION['flash'])): ?><div class="notice" role="status"><?=e($_SESSION['flash'])?></div><?php unset($_SESSION['flash']); endif;
|
||||
}
|
||||
function page_end() { echo (!empty($GLOBALS['admin_layout']) ? '</div>' : '') . '</main></body></html>'; }
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
require __DIR__ . '/bootstrap.php'; require_admin();
|
||||
$search = substr(trim($_GET['q'] ?? ''),0,100); $stage = (string)($_GET['stage'] ?? '');
|
||||
$where = ' WHERE 1=1'; $params = []; $types = '';
|
||||
if ($search !== '') { $where .= ' AND (p.nama LIKE ? OR p.email LIKE ? OR p.posisi LIKE ?)'; $params = array_fill(0,3,'%'.$search.'%'); $types = 'sss'; }
|
||||
if (array_key_exists($stage, STAGES)) { $where .= ' AND p.kode=?'; $params[] = (int)$stage; $types .= 'i'; }
|
||||
$total = (int)one('SELECT COUNT(*) n FROM in_daper p'.$where,$types,$params)['n'];
|
||||
$pages = max(1,(int)ceil($total/25)); $page = max(1,min($pages,(int)($_GET['page'] ?? 1))); $offset=($page-1)*25;
|
||||
$list = rows('SELECT p.id,p.nama,p.email,p.nowa,p.posisi,p.level,p.kode,p.cek,p.ts,COALESCE(j.score,0) score FROM in_daper p LEFT JOIN (SELECT idap,SUM(CASE WHEN cek=\'10\' THEN 10 ELSE 0 END) score FROM in_jawab GROUP BY idap) j ON j.idap=p.id'.$where.' ORDER BY p.ts DESC,p.id DESC LIMIT 25 OFFSET '.$offset,$types,$params);
|
||||
page_start('Daftar Peserta Psikotest', 'participants');
|
||||
?><section class="card"><div class="card-heading"><div><h2>Semua peserta <span class="count"><?=$total?></span></h2><p>Pantau progres tes dan hasil seleksi kandidat.</p></div><a class="button" href="participant-new.php">+ Tambah peserta</a></div><form class="filters" method="get"><input aria-label="Cari peserta" name="q" placeholder="Cari nama, email, atau posisi…" value="<?=e($search)?>"><select name="stage" aria-label="Tahap seleksi"><option value="">Semua tahap seleksi</option><?php foreach(STAGES as $k=>$v): ?><option value="<?=$k?>" <?=$stage===(string)$k?'selected':''?>><?=e($v)?></option><?php endforeach; ?></select><button class="button secondary">Terapkan</button></form><div class="table-wrap"><table><thead><tr><th>Peserta</th><th>Posisi dilamar</th><th>Progres tes</th><th>Seleksi</th><th>Skor</th><th></th></tr></thead><tbody><?php foreach($list as $p): ?><tr><td><div class="person"><span class="avatar"><?=e(substr($p['nama'],0,1))?></span><div><strong><?=e($p['nama'])?></strong><small><?=e($p['email'])?></small></div></div></td><td><?=e($p['posisi'])?></td><td><span class="badge <?=(int)$p['level']>=4?'success':''?>"><?=(int)$p['level']>=4?'Selesai':((int)$p['level']===0?'Belum mulai':'Fase '.(int)$p['level'].' / 4')?></span></td><td><?=e(STAGES[(int)$p['kode']] ?? 'Kode '.$p['kode'])?></td><td><?php if((int)$p['cek']===1): ?><strong><?=(int)$p['score']?></strong><small><?=(int)$p['score']>=threshold()?'Lulus skor':'Di bawah ambang'?></small><?php else: ?><span class="muted">Belum dinilai</span><?php endif; ?></td><td><a class="button secondary compact" href="idap_jawab.php?id=<?=(int)$p['id']?>">Lihat hasil ↗</a></td></tr><?php endforeach; ?><?php if(!$list): ?><tr><td colspan="6" class="empty">Tidak ada peserta yang sesuai pencarian.</td></tr><?php endif; ?></tbody></table></div><div class="pagination"><span><?=$total?> peserta · Halaman <?=$page?> dari <?=$pages?></span><div><?php if($page>1): ?><a class="button secondary compact" href="?<?=e(http_build_query(['q'=>$search,'stage'=>$stage,'page'=>$page-1]))?>">← Sebelumnya</a><?php endif; ?><?php if($page<$pages): ?><a class="button secondary compact" href="?<?=e(http_build_query(['q'=>$search,'stage'=>$stage,'page'=>$page+1]))?>">Selanjutnya →</a><?php endif; ?></div></div></section><?php page_end(); ?>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
require __DIR__ . '/bootstrap.php'; require_admin();
|
||||
require_once __DIR__.'/../includes/exam-service.php';
|
||||
$id=(int)($_GET['id']??0); exam_expire_due($id); $p=one('SELECT * FROM in_daper WHERE id=?','i',[$id]);
|
||||
if(!$p) { http_response_code(404); page_start('Peserta tidak ditemukan','participants'); echo '<p>Peserta tidak ditemukan.</p>'; page_end(); exit; }
|
||||
$error='';
|
||||
if($_SERVER['REQUEST_METHOD']==='POST') {
|
||||
verify_csrf(); $action=$_POST['action']??'';
|
||||
if($action==='stage' && array_key_exists((string)($_POST['kode']??''),STAGES)) {
|
||||
query('UPDATE in_daper SET kode=? WHERE id=?','ii',[(int)$_POST['kode'],$id]); $_SESSION['flash']='Tahap seleksi diperbarui.'; redirect('idap_jawab.php?id='.$id);
|
||||
}
|
||||
if($action==='grade') {
|
||||
db()->begin_transaction();
|
||||
try {
|
||||
$locked=one('SELECT level FROM in_daper WHERE id=? FOR UPDATE','i',[$id]);
|
||||
$answers=rows('SELECT j.id,j.cek FROM in_jawab j JOIN in_soal s ON s.id=j.isoal WHERE j.idap=? AND s.kat BETWEEN 1 AND 4 FOR UPDATE','i',[$id]);
|
||||
$grades=$_POST['grade']??[];
|
||||
if((int)$locked['level']<4 || !$answers || !is_array($grades)) throw new InvalidArgumentException('Penilaian dapat disimpan setelah peserta menyelesaikan seluruh fase tes.');
|
||||
foreach($answers as $answer) {
|
||||
$grade=$grades[$answer['id']]??'';
|
||||
if(!in_array($grade,['0','10'],true)) throw new InvalidArgumentException('Nilai semua jawaban fase 1–3 sebelum menyimpan.');
|
||||
query('UPDATE in_jawab SET cek=? WHERE id=? AND idap=?','sii',[$grade,(int)$answer['id'],$id]);
|
||||
}
|
||||
$orphans=one('SELECT COUNT(*) n FROM in_jawab j LEFT JOIN in_soal s ON s.id=j.isoal WHERE j.idap=? AND s.id IS NULL','i',[$id]);
|
||||
if((int)$orphans['n']>0) throw new InvalidArgumentException('Ada jawaban yang kehilangan referensi soal. Pulihkan soal sebelum menyelesaikan penilaian.');
|
||||
query('UPDATE in_daper SET cek=1 WHERE id=?','i',[$id]); db()->commit(); $_SESSION['flash']='Penilaian berhasil disimpan.'; redirect('idap_jawab.php?id='.$id);
|
||||
} catch(InvalidArgumentException $ex) { db()->rollback(); $error=$ex->getMessage(); } catch(Throwable $ex) { db()->rollback(); throw $ex; }
|
||||
}
|
||||
}
|
||||
$answers=rows('SELECT j.id,j.jawab,j.cek,s.id question_id,s.kat,s.soal,s.soal1,s.kunci FROM in_jawab j LEFT JOIN in_soal s ON s.id=j.isoal WHERE j.idap=? ORDER BY s.kat,j.id','i',[$id]);
|
||||
$score=0; $ungraded=0; $orphans=0;
|
||||
foreach($answers as $a) { if(!$a['question_id']) $orphans++; if((int)$a['kat']>=1 && (int)$a['kat']<=4) { if($a['cek']==='10') $score+=10; if(!in_array($a['cek'],['0','10'],true)) $ungraded++; } }
|
||||
$attemptLog=rows('SELECT a.phase,a.status,a.deadline,a.started_at,a.submitted_at,COUNT(e.id) event_count FROM rec_exam_attempt a LEFT JOIN rec_exam_event e ON e.attempt_id=a.id WHERE a.participant_id=? GROUP BY a.id ORDER BY a.phase','i',[$id]);
|
||||
$activityLog=rows('SELECT a.phase,e.event_type,e.created_at FROM rec_exam_event e JOIN rec_exam_attempt a ON a.id=e.attempt_id WHERE a.participant_id=? ORDER BY e.id DESC LIMIT 50','i',[$id]);
|
||||
page_start('Hasil Psikotest', 'participants');
|
||||
?><a class="text-link back" href="idap.php">← Daftar peserta</a><section class="card participant-card"><div class="person"><span class="avatar large"><?=e(substr($p['nama'],0,1))?></span><div><h2><?=e($p['nama'])?></h2><p><?=e($p['posisi'])?> · <?=e($p['jk'])?></p><small><?=e($p['email'])?> · <?=e($p['nowa'])?></small></div></div><div><span class="muted">Skor fase 1–3</span><div class="score"><?=$score?><small> / ambang <?=threshold()?></small></div><span class="badge <?=(int)$p['cek']===1?'success':''?>"><?=(int)$p['cek']!==1?'Belum difinalkan':($score>=threshold()?'Lulus skor':'Di bawah ambang')?></span></div></section><section class="card stage-card"><div><h2>Tahap seleksi</h2><p>Progres tes: <?=min(4,max(0,(int)$p['level']))?> / 4 fase · <?=e(STAGES[(int)$p['kode']]??'Lainnya')?></p></div><form method="post" class="inline-form"><?php csrf_field(); ?><input type="hidden" name="action" value="stage"><select name="kode" aria-label="Tahap seleksi"><?php foreach(STAGES as $k=>$v): ?><option value="<?=$k?>" <?=(int)$p['kode']===$k?'selected':''?>><?=e($v)?></option><?php endforeach; ?></select><button class="button secondary">Simpan tahap</button></form></section><?php if($error): ?><div class="notice error"><?=e($error)?></div><?php endif; ?><?php if($orphans): ?><div class="notice error"><?=$orphans?> jawaban tidak memiliki referensi soal. Penilaian belum dapat difinalkan.</div><?php endif; ?><section class="card"><div class="card-heading"><div><h2>Catatan integritas sesi tes</h2><p>Aktivitas browser adalah laporan dari perangkat peserta, dapat tidak lengkap, dan bukan bukti kecurangan atau keputusan seleksi otomatis.</p></div></div><div class="table-wrap"><table><thead><tr><th>Fase</th><th>Mulai</th><th>Deadline server</th><th>Status</th><th>Catatan aktivitas</th></tr></thead><tbody><?php foreach($attemptLog as $log): ?><tr><td><?=(int)$log['phase']?></td><td><?=e($log['started_at'])?></td><td><?=e(date('Y-m-d H:i:s',(int)$log['deadline']))?></td><td><?=e(['active'=>'Berjalan','expired'=>'Waktu habis','submitted'=>'Dikirim'][$log['status']]??$log['status'])?></td><td><?=(int)$log['event_count']?></td></tr><?php endforeach; if(!$attemptLog): ?><tr><td colspan="5" class="empty">Data historis: belum ada catatan sesi tes.</td></tr><?php endif; ?></tbody></table></div><?php if($activityLog): ?><details class="integrity-details"><summary>Lihat 50 aktivitas terbaru</summary><div class="table-wrap"><table><thead><tr><th>Waktu</th><th>Fase</th><th>Aktivitas</th></tr></thead><tbody><?php foreach($activityLog as $log): ?><tr><td><?=e($log['created_at'])?></td><td><?=(int)$log['phase']?></td><td><?=e(['tab_hidden'=>'Halaman tidak terlihat','window_blur'=>'Jendela kehilangan fokus','copy'=>'Copy','cut'=>'Cut','paste'=>'Paste','contextmenu'=>'Menu konteks','page_resumed'=>'Kembali ke halaman','relogin'=>'Login ulang','stale_tab'=>'Permintaan dari tab/sesi yang sudah digantikan'][$log['event_type']]??$log['event_type'])?></td></tr><?php endforeach; ?></tbody></table></div></details><?php endif; ?></section><form method="post"><?php csrf_field(); ?><input type="hidden" name="action" value="grade"><?php foreach(CATEGORIES as $category=>$label): ?><section class="card answer-section"><div class="card-heading"><div><span class="eyebrow">KATEGORI 0<?=$category?></span><h2><?=e($label)?></h2></div><span class="badge"><?=$category===5?'Deskriptif · tanpa skor':'Benar 10 · Salah 0'?></span></div><div class="table-wrap"><table><thead><tr><th>Soal<?=$category===5?' / Pilihan L':''?></th><?php if($category===5): ?><th>Pilihan R</th><?php endif; ?><th>Jawaban peserta</th><?php if($category!==5): ?><th>Kunci jawaban</th><th>Penilaian</th><?php endif; ?></tr></thead><tbody><?php $count=0; foreach($answers as $a): if((int)$a['kat']!==$category) continue; $count++; ?><tr><td class="question-text"><?=nl2br(e($a['soal']))?></td><?php if($category===5): ?><td class="question-text"><?=nl2br(e($a['soal1']))?></td><?php endif; ?><td><?=nl2br(e($a['jawab']))?></td><?php if($category!==5): ?><td><?=nl2br(e($a['kunci']))?></td><td><select name="grade[<?=(int)$a['id']?>]" aria-label="Nilai jawaban <?=(int)$a['id']?>" required><option value="">Pilih nilai</option><?php foreach(['10'=>'Benar (+10)','0'=>'Salah (0)'] as $value=>$text): ?><option value="<?=$value?>" <?=(string)($_POST['grade'][$a['id']]??$a['cek'])===(string)$value?'selected':''?>><?=$text?></option><?php endforeach; ?></select></td><?php endif; ?></tr><?php endforeach; if(!$count): ?><tr><td colspan="4" class="empty">Belum ada jawaban untuk kategori ini.</td></tr><?php endif; ?></tbody></table></div></section><?php endforeach; ?><div class="save-bar"><div><strong>Penilaian manual</strong><small><?=$ungraded?> jawaban belum dinilai. Kepribadian ditinjau secara deskriptif.</small></div><button class="button" <?=(int)$p['level']<4 || !$answers || $orphans?'disabled':''?>>Simpan penilaian</button></div></form><?php page_end(); ?>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
require __DIR__ . '/bootstrap.php'; require_admin();
|
||||
$stats = one('SELECT COUNT(*) total, SUM(level>=4) completed, SUM(cek=1) reviewed FROM in_daper');
|
||||
$questions = one('SELECT COUNT(*) total FROM in_soal');
|
||||
$recent = rows('SELECT id,nama,posisi,level,cek,ts FROM in_daper ORDER BY ts DESC,id DESC LIMIT 6');
|
||||
page_start('Ringkasan recruitment', 'home');
|
||||
?><section class="hero"><div><span class="eyebrow">YOUR RECRUITMENT, AT A GLANCE</span><h2>Ruang kerja untuk<br>keputusan yang lebih baik.</h2><p>Pantau progres tes dan tinjau jawaban peserta dalam satu tempat.</p><a class="button" href="idap.php">Tinjau peserta <span>↗</span></a></div><div class="hero-art" aria-hidden="true"><span>◎</span><small>PEOPLE & POTENTIAL</small></div></section><section class="stats"><article><span>Total peserta</span><strong><?=(int)$stats['total']?></strong><small>Seluruh data peserta</small></article><article><span>Tes selesai</span><strong><?=(int)$stats['completed']?></strong><small>Empat fase diselesaikan</small></article><article><span>Sudah dinilai</span><strong><?=(int)$stats['reviewed']?></strong><small>Penilaian disimpan admin</small></article><article><span>Bank soal</span><strong><?=(int)$questions['total']?></strong><small>Lima kategori psikotest</small></article></section><div class="menu-grid"><a class="card menu-card" href="idap.php"><span class="tile-icon">◎</span><h2>Daftar Peserta Psikotest <span>↗</span></h2><p>Lihat progres, periksa jawaban, dan kelola hasil seleksi peserta.</p></a><a class="card menu-card" href="isoal.php"><span class="tile-icon">▤</span><h2>Bank Soal Psikotest <span>↗</span></h2><p>Kelola soal, pilihan jawaban, dan kunci pada setiap kategori tes.</p></a></div><section class="card"><div class="card-heading"><div><h2>Peserta terbaru</h2><p>Aktivitas recruitment terkini</p></div><a class="text-link" href="idap.php">Lihat semua →</a></div><div class="table-wrap"><table><thead><tr><th>Peserta</th><th>Posisi</th><th>Progres tes</th><th>Tanggal</th><th></th></tr></thead><tbody><?php foreach ($recent as $p): ?><tr><td><strong><?=e($p['nama'])?></strong></td><td><?=e($p['posisi'])?></td><td><span class="badge"><?=(int)$p['level']>=4 ? 'Selesai' : 'Fase '.(int)$p['level'].' / 4'?></span></td><td><?=e(substr($p['ts'],0,10))?></td><td><a class="text-link" href="idap_jawab.php?id=<?=(int)$p['id']?>">Detail →</a></td></tr><?php endforeach; ?><?php if (!$recent): ?><tr><td colspan="5" class="empty">Belum ada peserta.</td></tr><?php endif; ?></tbody></table></div></section><?php page_end(); ?>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
require __DIR__ . '/bootstrap.php'; require_admin();
|
||||
$q=substr(trim($_GET['q']??''),0,100); $category=(int)($_GET['kat']??0); $where=' WHERE 1=1'; $params=[]; $types='';
|
||||
if($q!=='') { $where.=' AND s.soal LIKE ?'; $params[]='%'.$q.'%'; $types.='s'; }
|
||||
if(isset(CATEGORIES[$category])) { $where.=' AND s.kat=?'; $params[]=$category; $types.='i'; }
|
||||
$total=(int)one('SELECT COUNT(*) n FROM in_soal s'.$where,$types,$params)['n']; $pages=max(1,(int)ceil($total/20)); $page=max(1,min($pages,(int)($_GET['page']??1)));
|
||||
$questions=rows('SELECT s.*, (SELECT COUNT(*) FROM in_jawab j WHERE j.isoal=s.id) used FROM in_soal s'.$where.' ORDER BY s.kat,s.id LIMIT 20 OFFSET '.(($page-1)*20),$types,$params);
|
||||
page_start('Bank Soal Psikotest','questions');
|
||||
?><section class="card"><div class="card-heading"><div><h2>Kelola bank soal <span class="count"><?=$total?></span></h2><p>Lima kategori untuk mengeksplorasi potensi peserta.</p></div><a class="button" href="question-edit.php">+ Tambah soal</a></div><form class="filters"><input aria-label="Cari soal" name="q" value="<?=e($q)?>" placeholder="Cari isi soal…"><select aria-label="Kategori soal" name="kat"><option value="0">Semua kategori</option><?php foreach(CATEGORIES as $k=>$v): ?><option value="<?=$k?>" <?=$category===$k?'selected':''?>><?=e($v)?></option><?php endforeach; ?></select><button class="button secondary">Terapkan</button></form><div class="table-wrap"><table><thead><tr><th>ID</th><th>Soal</th><th>Kategori</th><th>Kunci / Pilihan R</th><th>Riwayat</th><th></th></tr></thead><tbody><?php foreach($questions as $s): ?><tr><td class="muted">#<?=(int)$s['id']?></td><td class="question-text"><?=nl2br(e($s['soal']))?></td><td><span class="badge"><?=e(CATEGORIES[(int)$s['kat']]??'Lainnya')?></span></td><td class="question-text"><?=e((int)$s['kat']===5?$s['soal1']:$s['kunci'])?></td><td><?=(int)$s['used']?> jawaban</td><td><a class="text-link" href="question-edit.php?id=<?=(int)$s['id']?>"><?=$s['used']?'Lihat':'Edit'?> →</a></td></tr><?php endforeach; if(!$questions): ?><tr><td colspan="6" class="empty">Tidak ada soal yang sesuai pencarian.</td></tr><?php endif; ?></tbody></table></div><div class="pagination"><span><?=$total?> soal · Halaman <?=$page?> dari <?=$pages?></span><div><?php if($page>1): ?><a class="button secondary compact" href="?<?=e(http_build_query(['q'=>$q,'kat'=>$category,'page'=>$page-1]))?>">← Sebelumnya</a><?php endif; ?><?php if($page<$pages): ?><a class="button secondary compact" href="?<?=e(http_build_query(['q'=>$q,'kat'=>$category,'page'=>$page+1]))?>">Selanjutnya →</a><?php endif; ?></div></div></section><?php page_end(); ?>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
if (!empty($_SESSION['admin_id']) && time() - ($_SESSION['admin_active'] ?? 0) <= 3600) redirect('index.php');
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
if (($_SESSION['login_blocked_until'] ?? 0) > time()) { $error = 'Terlalu banyak percobaan. Coba lagi dalam beberapa menit.'; }
|
||||
else {
|
||||
$admin = one('SELECT * FROM rec_admin WHERE email=?', 's', [trim($_POST['email'] ?? '')]);
|
||||
$valid = password_verify($_POST['password'] ?? '', $admin['password_hash'] ?? '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi');
|
||||
if ($admin && $valid) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['admin_id'] = (int)$admin['id']; $_SESSION['admin_name'] = trim($admin['nama_de'] . ' ' . $admin['nama_be']); $_SESSION['admin_active'] = time();
|
||||
$_SESSION['csrf'] = bin2hex(random_bytes(32)); unset($_SESSION['login_attempts'], $_SESSION['login_blocked_until']); redirect('index.php');
|
||||
}
|
||||
$_SESSION['login_attempts'] = ($_SESSION['login_attempts'] ?? 0) + 1;
|
||||
if ($_SESSION['login_attempts'] >= 5) { $_SESSION['login_blocked_until'] = time() + 300; $_SESSION['login_attempts'] = 0; }
|
||||
$error = 'Email atau password admin tidak sesuai.';
|
||||
}
|
||||
}
|
||||
page_start('Login admin');
|
||||
?><div class="auth-layout"><section class="auth-story"><a class="brand" href="../index.php"><img class="brand-logo" src="assets/nurinda-logo.png" alt="Logo Nurinda"><span>NURINDA<small>Human Capital</small></span></a><div><span class="eyebrow">THE RIGHT PEOPLE. THE RIGHT FUTURE.</span><h1>Kenali potensi.<br>Temukan talenta terbaik.</h1><p>Satu ruang untuk memantau peserta, meninjau hasil psikotest, dan mengelola bank soal.</p><div class="story-pills"><span>01 · Peserta</span><span>02 · Hasil tes</span><span>03 · Bank soal</span></div></div><small>Recruitment workspace · Nurinda</small></section><section class="auth-form"><span class="badge">AKSES ADMINISTRATOR</span><h2>Selamat datang kembali.</h2><p class="muted">Masuk untuk mengelola proses recruitment.</p><?php if ($error): ?><div class="notice error" role="alert"><?=e($error)?></div><?php endif; ?><form method="post"><?php csrf_field(); ?><label>Email admin<input type="email" name="email" autocomplete="username" required maxlength="190" value="<?=e($_POST['email'] ?? '')?>" placeholder="nama@nurinda.co.id"></label><label>Password<input type="password" name="password" autocomplete="current-password" required placeholder="Masukkan password"></label><button class="button full">Masuk ke dashboard <span>→</span></button></form><p class="auth-note">Akun admin dikelola oleh pengelola sistem.</p><a class="text-link" href="../index.php">← Kembali ke halaman peserta</a></section></div><?php page_end(); ?>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
require __DIR__ . '/bootstrap.php';
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
|
||||
verify_csrf();
|
||||
$_SESSION = [];
|
||||
session_destroy();
|
||||
setcookie(session_name(), '', ['expires'=>time()-3600, 'path'=>'/', 'httponly'=>true, 'samesite'=>'Lax']);
|
||||
redirect('login.php');
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS rec_admin (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(190) NOT NULL UNIQUE,
|
||||
nama_de VARCHAR(100) NOT NULL,
|
||||
nama_be VARCHAR(100) NOT NULL DEFAULT '',
|
||||
idkar VARCHAR(50) NOT NULL DEFAULT '',
|
||||
password_hash VARCHAR(255) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Only for an existing rec_admin table created by the original 001_admin.sql.
|
||||
-- Existing names and password hashes are preserved. created_at remains as historical data.
|
||||
ALTER TABLE rec_admin
|
||||
CHANGE COLUMN name nama_de VARCHAR(100) NOT NULL,
|
||||
ADD COLUMN nama_be VARCHAR(100) NOT NULL DEFAULT '' AFTER nama_de,
|
||||
ADD COLUMN idkar VARCHAR(50) NOT NULL DEFAULT '' AFTER nama_be;
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE IF NOT EXISTS rec_exam_attempt (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
participant_id INT NOT NULL,
|
||||
phase TINYINT UNSIGNED NOT NULL,
|
||||
questions_json MEDIUMTEXT NOT NULL,
|
||||
answers_json MEDIUMTEXT NOT NULL,
|
||||
deadline BIGINT NOT NULL,
|
||||
active_token CHAR(64) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
submitted_at DATETIME NULL,
|
||||
UNIQUE KEY participant_phase (participant_id, phase)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS rec_exam_event (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
attempt_id BIGINT UNSIGNED NOT NULL,
|
||||
event_type VARCHAR(40) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY attempt_events (attempt_id, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS rec_candidate_login_guard (
|
||||
email_hash CHAR(64) NOT NULL PRIMARY KEY,
|
||||
failures INT NOT NULL DEFAULT 0,
|
||||
blocked_until BIGINT NOT NULL DEFAULT 0,
|
||||
window_started BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
require __DIR__ . '/bootstrap.php'; require_admin(); $error='';
|
||||
if($_SERVER['REQUEST_METHOD']==='POST') {
|
||||
verify_csrf();
|
||||
$name=trim($_POST['nama']??''); $email=trim($_POST['email']??''); $phone=trim($_POST['nowa']??''); $position=trim($_POST['posisi']??''); $gender=$_POST['jk']??''; $password=$_POST['password']??'';
|
||||
if($name==='' || strlen($name)>100 || !filter_var($email,FILTER_VALIDATE_EMAIL) || strlen($email)>100 || $phone==='' || strlen($phone)>25 || $position==='' || strlen($position)>100 || !in_array($gender,['Pria','Wanita'],true) || strlen($password)<8) $error='Lengkapi data dengan benar. Password peserta minimal 8 karakter.';
|
||||
elseif(one('SELECT id FROM in_daper WHERE email=?','s',[$email])) $error='Email tersebut sudah dipakai peserta lain.';
|
||||
else { query('INSERT INTO in_daper(kode,nama,jk,email,nowa,posisi,cek,level,password) VALUES(0,?,?,?,?,?,0,0,?)','ssssss',[$name,$gender,$email,$phone,$position,password_hash($password,PASSWORD_DEFAULT)]); $_SESSION['flash']='Peserta berhasil ditambahkan. Berikan email dan password kepada peserta untuk login di halaman recruitment.'; redirect('idap.php'); }
|
||||
}
|
||||
page_start('Tambah peserta', 'participants');
|
||||
?><a class="text-link back" href="idap.php">← Daftar peserta</a><section class="card form-card"><h2>Undang talenta baru</h2><p class="muted">Email dan password ini dipakai peserta untuk mengikuti tes.</p><?php if($error): ?><div class="notice error"><?=e($error)?></div><?php endif; ?><form method="post"><?php csrf_field(); ?><div class="form-grid"><?php foreach(['nama'=>'Nama lengkap','email'=>'Email peserta','nowa'=>'Nomor WhatsApp','posisi'=>'Posisi dilamar'] as $key=>$label): ?><label><?=$label?><input name="<?=$key?>" type="<?=$key==='email'?'email':'text'?>" maxlength="<?=$key==='nowa'?25:100?>" value="<?=e($_POST[$key]??'')?>" required></label><?php endforeach; ?><label>Jenis kelamin<select name="jk" required><option value="">Pilih</option><?php foreach(['Pria','Wanita'] as $gender): ?><option <?=($_POST['jk']??'')===$gender?'selected':''?>><?=$gender?></option><?php endforeach; ?></select></label><label>Password awal<input name="password" type="password" minlength="8" autocomplete="new-password" required></label></div><div class="form-actions"><a class="button secondary" href="idap.php">Batal</a><button class="button">Simpan peserta</button></div></form></section><?php page_end(); ?>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
require __DIR__.'/bootstrap.php'; require_admin();
|
||||
$id=(int)($_GET['id']??0); $copy=(int)($_GET['copy']??0);
|
||||
$fields=['kat','soal','soal1','opa','opb','opc','opd','ope','kunci'];
|
||||
$s=array_fill_keys($fields,''); $s['kat']=1; $error=''; $used=0;
|
||||
if($id || $copy) {
|
||||
$s=one('SELECT * FROM in_soal WHERE id=?','i',[$id?:$copy]);
|
||||
if(!$s) { http_response_code(404); page_start('Soal tidak ditemukan','questions'); echo '<p>Soal tidak ditemukan.</p>'; page_end(); exit; }
|
||||
if($id) $used=(int)one('SELECT COUNT(*) n FROM in_jawab WHERE isoal=?','i',[$id])['n'];
|
||||
}
|
||||
if($_SERVER['REQUEST_METHOD']==='POST') {
|
||||
verify_csrf();
|
||||
foreach($fields as $field) $s[$field]=trim($_POST[$field]??'');
|
||||
$category=(int)$s['kat']; $deleting=($_POST['action']??'')==='delete';
|
||||
if($deleting && !$id) $error='Soal belum tersimpan.';
|
||||
elseif(!isset(CATEGORIES[$category]) || $s['soal']==='' || ($category===5 && $s['soal1']==='') || ($category!==5 && $s['kunci']==='')) $error='Lengkapi kategori, soal, dan kunci jawaban (atau pilihan R untuk kepribadian).';
|
||||
else {
|
||||
foreach(['opa','opb','opc','opd','ope'] as $field) if(strlen($s[$field])>100) $error='Pilihan jawaban maksimal 100 karakter.';
|
||||
if(strlen($s['kunci'])>200) $error='Kunci jawaban maksimal 200 karakter.';
|
||||
if(!$error) {
|
||||
db()->begin_transaction();
|
||||
try {
|
||||
if($id) {
|
||||
one('SELECT id FROM in_soal WHERE id=? FOR UPDATE','i',[$id]);
|
||||
if(one('SELECT id FROM in_jawab WHERE isoal=? LIMIT 1','i',[$id])) throw new InvalidArgumentException('Soal sudah digunakan peserta. Duplikat soal untuk membuat versi baru; riwayat hasil harus dipertahankan.');
|
||||
}
|
||||
if($deleting) query('DELETE FROM in_soal WHERE id=?','i',[$id]);
|
||||
elseif($id) query('UPDATE in_soal SET kat=?,soal=?,soal1=?,opa=?,opb=?,opc=?,opd=?,ope=?,kunci=? WHERE id=?','issssssssi',[$category,$s['soal'],$s['soal1'],$s['opa'],$s['opb'],$s['opc'],$s['opd'],$s['ope'],$s['kunci'],$id]);
|
||||
else query('INSERT INTO in_soal(kat,soal,soal1,opa,opb,opc,opd,ope,kunci) VALUES(?,?,?,?,?,?,?,?,?)','issssssss',[$category,$s['soal'],$s['soal1'],$s['opa'],$s['opb'],$s['opc'],$s['opd'],$s['ope'],$s['kunci']]);
|
||||
db()->commit(); $_SESSION['flash']=$deleting?'Soal dihapus.':'Soal berhasil disimpan.'; redirect('isoal.php');
|
||||
} catch(InvalidArgumentException $ex) { db()->rollback(); $error=$ex->getMessage(); } catch(Throwable $ex) { db()->rollback(); throw $ex; }
|
||||
}
|
||||
}
|
||||
}
|
||||
page_start($id?'Detail soal #'.$id:($copy?'Duplikat soal':'Tambah soal'),'questions');
|
||||
?><a class="text-link back" href="isoal.php">← Bank soal</a><section class="card form-card"><h2><?=$id?'Detail bank soal':'Soal baru'?></h2><p class="muted">Soal kepribadian memiliki dua pernyataan (L / R), tanpa skor benar atau salah.</p><?php if($used): ?><div class="notice">Soal ini sudah memiliki <?=$used?> jawaban. Isi dikunci untuk menjaga riwayat penilaian. Duplikat akan menjadi soal tambahan yang juga masuk dalam pengacakan tes.</div><a class="button secondary" href="question-edit.php?copy=<?=$id?>">Duplikat sebagai soal baru →</a><?php endif; ?><?php if($error): ?><div class="notice error"><?=e($error)?></div><?php endif; ?><form method="post"><?php csrf_field(); ?><fieldset <?=$used?'disabled':''?>><label>Kategori<select name="kat" required><?php foreach(CATEGORIES as $k=>$v): ?><option value="<?=$k?>" <?=(int)$s['kat']===$k?'selected':''?>><?=e($v)?></option><?php endforeach; ?></select></label><div class="form-grid"><label>Soal / Pernyataan L<textarea name="soal" rows="5" required><?=e($s['soal'])?></textarea></label><label>Pernyataan R (khusus kepribadian)<textarea name="soal1" rows="5"><?=e($s['soal1'])?></textarea></label></div><label>Kunci jawaban (kategori 1–4)<textarea name="kunci" rows="2" maxlength="200"><?=e($s['kunci'])?></textarea></label><details><summary>Pilihan jawaban A–E (jika digunakan)</summary><p class="muted">Alur tes saat ini memakai jawaban teks untuk kategori 1–4. Pilihan ini disimpan sebagai bagian bank soal.</p><div class="form-grid"><?php foreach(['opa'=>'A','opb'=>'B','opc'=>'C','opd'=>'D','ope'=>'E'] as $key=>$label): ?><label>Pilihan <?=$label?><input name="<?=$key?>" maxlength="100" value="<?=e($s[$key])?>"></label><?php endforeach; ?></div></details><div class="form-actions"><?php if($id): ?><button class="button danger" name="action" value="delete" data-confirm="Hapus soal ini? Tindakan ini tidak dapat dibatalkan.">Hapus soal</button><?php endif; ?><button class="button" name="action" value="save">Simpan soal</button></div></fieldset></form></section><?php page_end(); ?>
|
||||
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
|
||||
require __DIR__ . '/../bootstrap.php';
|
||||
$name = trim(readline('Nama depan: '));
|
||||
$last = trim(readline('Nama belakang: '));
|
||||
$idkar = trim(readline('ID karyawan: '));
|
||||
$email = trim(readline('Email admin: '));
|
||||
if ($name === '' || strlen($name) > 100 || strlen($last) > 100 || strlen($idkar) > 50 || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 190) exit("Nama atau email tidak valid.\n");
|
||||
echo 'Password admin (minimal 12 karakter, input disembunyikan): ';
|
||||
$tty = function_exists('shell_exec') ? shell_exec('stty -g 2>/dev/null') : null;
|
||||
if (!$tty) exit("Jalankan melalui terminal interaktif.\n");
|
||||
system('stty -echo');
|
||||
try { $password = rtrim(fgets(STDIN), "\r\n"); } finally { system('stty ' . escapeshellarg(trim($tty))); echo "\n"; }
|
||||
if (strlen($password) < 12) exit("Password minimal 12 karakter.\n");
|
||||
db()->query(file_get_contents(__DIR__ . '/../migrations/001_admin.sql'));
|
||||
if (one("SHOW COLUMNS FROM rec_admin LIKE 'name'")) db()->query(file_get_contents(__DIR__ . '/../migrations/002_admin_identity.sql'));
|
||||
query('INSERT INTO rec_admin (email,nama_de,nama_be,idkar,password_hash) VALUES (?,?,?,?,?)', 'sssss', [$email, $name, $last, $idkar, password_hash($password, PASSWORD_DEFAULT)]);
|
||||
echo "Admin berhasil dibuat. Buka /dashboard/login.php.\n";
|
||||
Reference in new issue
Block a user