diff --git a/archive_bootstrap.php b/archive_bootstrap.php
new file mode 100644
index 0000000..ebe4460
--- /dev/null
+++ b/archive_bootstrap.php
@@ -0,0 +1,679 @@
+ $title,
+ 'description' => $projectDescription,
+ 'image' => $projectImageUrl,
+ ];
+}
+
+function csrf_token(): string
+{
+ if (empty($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+
+ return $_SESSION['csrf_token'];
+}
+
+function verify_csrf(?string $token): bool
+{
+ $sessionToken = $_SESSION['csrf_token'] ?? '';
+ return is_string($token) && $sessionToken !== '' && hash_equals($sessionToken, $token);
+}
+
+function set_flash(string $type, string $message): void
+{
+ $_SESSION['flashes'][] = [
+ 'type' => $type,
+ 'message' => $message,
+ ];
+}
+
+function get_flashes(): array
+{
+ $flashes = $_SESSION['flashes'] ?? [];
+ unset($_SESSION['flashes']);
+ return is_array($flashes) ? $flashes : [];
+}
+
+function demo_users(): array
+{
+ return [
+ 'superadmin@kbriharare.go.id' => [
+ 'email' => 'superadmin@kbriharare.go.id',
+ 'name' => 'Super Admin KBRI',
+ 'role' => 'super_admin',
+ 'role_label' => 'Super Admin',
+ 'department' => 'Semua Departemen',
+ 'seed_password' => 'Harare2026!',
+ 'avatar' => 'SA',
+ ],
+ 'staf.politik@kbriharare.go.id' => [
+ 'email' => 'staf.politik@kbriharare.go.id',
+ 'name' => 'Staf Politik',
+ 'role' => 'staff',
+ 'role_label' => 'Staf',
+ 'department' => 'Politik',
+ 'seed_password' => 'Harare2026!',
+ 'avatar' => 'SP',
+ ],
+ 'staf.konsuler@kbriharare.go.id' => [
+ 'email' => 'staf.konsuler@kbriharare.go.id',
+ 'name' => 'Staf Konsuler',
+ 'role' => 'staff',
+ 'role_label' => 'Staf',
+ 'department' => 'Kekonsuleran',
+ 'seed_password' => 'Harare2026!',
+ 'avatar' => 'SK',
+ ],
+ ];
+}
+
+function authenticate_demo_user(string $email, string $password): ?array
+{
+ $key = strtolower(trim($email));
+ $users = demo_users();
+
+ if (!isset($users[$key])) {
+ return null;
+ }
+
+ $user = $users[$key];
+ $hash = password_hash($user['seed_password'], PASSWORD_BCRYPT);
+ if (!password_verify($password, $hash)) {
+ return null;
+ }
+
+ unset($user['seed_password']);
+ return $user;
+}
+
+function mask_email(string $email): string
+{
+ if (!str_contains($email, '@')) {
+ return $email;
+ }
+
+ [$local, $domain] = explode('@', $email, 2);
+ $prefix = substr($local, 0, 2);
+ return $prefix . str_repeat('•', max(2, strlen($local) - 2)) . '@' . $domain;
+}
+
+function login_locked_until(): int
+{
+ return (int)($_SESSION['login_locked_until'] ?? 0);
+}
+
+function current_user(): ?array
+{
+ return isset($_SESSION['auth_user']) && is_array($_SESSION['auth_user']) ? $_SESSION['auth_user'] : null;
+}
+
+function is_authenticated(): bool
+{
+ return current_user() !== null;
+}
+
+function is_super_admin(): bool
+{
+ return (current_user()['role'] ?? '') === 'super_admin';
+}
+
+function process_login_request(): void
+{
+ if (!verify_csrf($_POST['csrf_token'] ?? null)) {
+ set_flash('danger', 'Permintaan login tidak valid. Silakan muat ulang halaman.');
+ return;
+ }
+
+ if (time() < login_locked_until()) {
+ $seconds = login_locked_until() - time();
+ set_flash('danger', 'Terlalu banyak percobaan login. Coba lagi dalam ' . max(1, $seconds) . ' detik.');
+ return;
+ }
+
+ $email = trim((string)($_POST['email'] ?? ''));
+ $password = (string)($_POST['password'] ?? '');
+
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
+ set_flash('danger', 'Masukkan email dan password yang valid.');
+ return;
+ }
+
+ $user = authenticate_demo_user($email, $password);
+ if ($user === null) {
+ $_SESSION['login_failures'] = (int)($_SESSION['login_failures'] ?? 0) + 1;
+ if ((int)$_SESSION['login_failures'] >= 5) {
+ $_SESSION['login_locked_until'] = time() + 120;
+ $_SESSION['login_failures'] = 0;
+ }
+ set_flash('danger', 'Email atau password tidak cocok.');
+ return;
+ }
+
+ unset($_SESSION['login_failures'], $_SESSION['login_locked_until']);
+
+ $otp = (string)random_int(100000, 999999);
+ $pending = [
+ 'user' => $user,
+ 'otp' => $otp,
+ 'expires_at' => time() + 300,
+ 'masked_email' => mask_email($user['email']),
+ 'mail_sent' => false,
+ ];
+
+ $mailSent = false;
+ try {
+ $subject = 'Kode OTP Arsip Digital KBRI Harare';
+ $html = '
Kode OTP Anda:
' . h($otp) . '
Kode berlaku selama 5 menit.
';
+ $result = MailService::sendMail($user['email'], $subject, $html, 'Kode OTP Anda: ' . $otp);
+ $mailSent = !empty($result['success']);
+ } catch (Throwable $exception) {
+ $mailSent = false;
+ }
+
+ $pending['mail_sent'] = $mailSent;
+ $_SESSION['pending_auth'] = $pending;
+
+ if ($mailSent) {
+ set_flash('success', 'Kode OTP telah dikirim ke ' . $pending['masked_email'] . '.');
+ } else {
+ set_flash('warning', 'SMTP belum terpasang untuk pengujian. Gunakan OTP demo ' . $otp . ' untuk melanjutkan.');
+ }
+}
+
+function process_otp_verification(): void
+{
+ if (!verify_csrf($_POST['csrf_token'] ?? null)) {
+ set_flash('danger', 'Permintaan MFA tidak valid.');
+ return;
+ }
+
+ $pending = $_SESSION['pending_auth'] ?? null;
+ if (!is_array($pending)) {
+ set_flash('danger', 'Sesi OTP tidak ditemukan. Silakan login ulang.');
+ return;
+ }
+
+ if ((int)($pending['expires_at'] ?? 0) < time()) {
+ unset($_SESSION['pending_auth']);
+ set_flash('danger', 'Kode OTP telah kedaluwarsa. Silakan login ulang.');
+ return;
+ }
+
+ $otp = preg_replace('/\D+/', '', (string)($_POST['otp'] ?? ''));
+ if ($otp === '' || !hash_equals((string)$pending['otp'], $otp)) {
+ set_flash('danger', 'Kode OTP tidak valid.');
+ return;
+ }
+
+ $_SESSION['auth_user'] = $pending['user'];
+ $_SESSION['login_audit'] = [
+ 'timestamp' => date('Y-m-d H:i:s'),
+ 'mfa_method' => 'Email OTP',
+ ];
+ unset($_SESSION['pending_auth']);
+ session_regenerate_id(true);
+ set_flash('success', 'Autentikasi berhasil. Selamat datang di brankas digital KBRI Harare.');
+}
+
+function logout_user(): void
+{
+ $token = $_SESSION['csrf_token'] ?? null;
+ $_SESSION = [];
+ if ($token) {
+ $_SESSION['csrf_token'] = $token;
+ }
+ session_regenerate_id(true);
+ set_flash('info', 'Sesi Anda telah diakhiri.');
+}
+
+function require_auth(): void
+{
+ if (!is_authenticated()) {
+ header('Location: index.php');
+ exit;
+ }
+}
+
+function archive_folder_tree(): array
+{
+ return [
+ [
+ 'group' => 'Informasi Negara',
+ 'items' => [
+ ['folder' => 'Informasi Negara / Zimbabwe', 'label' => 'Zimbabwe', 'hint' => 'Keterangan dasar, informasi penting, catatan peristiwa penting.'],
+ ['folder' => 'Informasi Negara / Zambia', 'label' => 'Zambia', 'hint' => 'Keterangan dasar, informasi penting, catatan peristiwa penting.'],
+ ],
+ ],
+ [
+ 'group' => 'Politik',
+ 'items' => [
+ ['folder' => 'Politik / Nota Diplomatik', 'label' => 'Nota Diplomatik', 'hint' => 'Permintaan dukungan, pernyataan resmi, laporan pertemuan.'],
+ ['folder' => 'Politik / Perjanjian & Kesepakatan', 'label' => 'Perjanjian & Kesepakatan', 'hint' => 'MoU, agreement bilateral, multilateral.'],
+ ['folder' => 'Politik / Hubungan Bilateral', 'label' => 'Hubungan Bilateral', 'hint' => 'Laporan menteri, pertukaran delegasi, pernyataan bersama.'],
+ ['folder' => 'Politik / Kebijakan Strategis', 'label' => 'Kebijakan Strategis', 'hint' => 'Instruksi pusat, analisis geopolitik, rekomendasi.'],
+ ['folder' => 'Politik / Keamanan & Pertahanan', 'label' => 'Keamanan & Pertahanan', 'hint' => 'MoU pertahanan, contingency plan, intelijen terbatas.'],
+ ],
+ ],
+ [
+ 'group' => 'Pensosbud',
+ 'items' => [
+ ['folder' => 'Pensosbud / Program Kerja Sama', 'label' => 'Program Kerja Sama', 'hint' => 'Dokumen kolaborasi dan agenda kerja.'],
+ ['folder' => 'Pensosbud / Scholarship', 'label' => 'Scholarship', 'hint' => 'Permohonan, persetujuan, monitoring & evaluasi.'],
+ ['folder' => 'Pensosbud / Event Budaya', 'label' => 'Event Budaya', 'hint' => 'Undangan dan laporan kegiatan budaya.'],
+ ],
+ ],
+ [
+ 'group' => 'Ekonomi & Perdagangan',
+ 'items' => [
+ ['folder' => 'Ekonomi & Perdagangan / Nota Kesepahaman Dagang', 'label' => 'Nota Kesepahaman Dagang', 'hint' => 'Zimbabwe, Zambia, Indonesia.'],
+ ['folder' => 'Ekonomi & Perdagangan / Agreement Investasi', 'label' => 'Agreement Investasi', 'hint' => 'Dokumen investasi lintas negara.'],
+ ['folder' => 'Ekonomi & Perdagangan / Dukungan Perusahaan', 'label' => 'Dukungan Perusahaan', 'hint' => 'Dukungan perusahaan Indonesia, Zimbabwe, Zambia.'],
+ ],
+ ],
+ [
+ 'group' => 'Kekonsuleran',
+ 'items' => [
+ ['folder' => 'Kekonsuleran / Dokumen Perjalanan & Identitas', 'label' => 'Dokumen Perjalanan & Identitas', 'hint' => 'Visa, izin tinggal, SPLP, legalisasi, apostille, pencatatan sipil.'],
+ ['folder' => 'Kekonsuleran / Legalisasi & Administrasi Dokumen', 'label' => 'Legalisasi & Administrasi Dokumen', 'hint' => 'Administrasi dokumen konsuler.'],
+ ['folder' => 'Kekonsuleran / Perlindungan & Bantuan WNI', 'label' => 'Perlindungan & Bantuan WNI', 'hint' => 'Kasus perlindungan dan bantuan warga negara.'],
+ ['folder' => 'Kekonsuleran / Fasilitas Diplomatik & Transportasi', 'label' => 'Fasilitas Diplomatik & Transportasi', 'hint' => 'Fasilitas diplomatik dan logistik perjalanan.'],
+ ],
+ ],
+ [
+ 'group' => 'Kanselerai/HOC',
+ 'items' => [
+ ['folder' => 'Kanselerai/HOC / SOP', 'label' => 'SOP', 'hint' => 'Standar operasional prosedur.'],
+ ['folder' => 'Kanselerai/HOC / Kepegawaian', 'label' => 'Kepegawaian', 'hint' => 'Dokumen personalia dan administrasi SDM.'],
+ ['folder' => 'Kanselerai/HOC / Perkantoran', 'label' => 'Perkantoran', 'hint' => 'Operasional perkantoran dan fasilitas kerja.'],
+ ['folder' => 'Kanselerai/HOC / Wisma Dubes', 'label' => 'Wisma Dubes', 'hint' => 'Dokumen rumah dinas dan operasional.'],
+ ],
+ ],
+ [
+ 'group' => 'PID',
+ 'items' => [
+ ['folder' => 'PID / Contingency Plan', 'label' => 'Contingency Plan', 'hint' => 'Rencana kontingensi dan respons insiden.'],
+ ['folder' => 'PID / Komputerisasi', 'label' => 'Komputerisasi', 'hint' => 'Sistem, inventaris TI, dan konfigurasi.'],
+ ['folder' => 'PID / Pengamanan Terpadu', 'label' => 'Pengamanan Terpadu', 'hint' => 'Pengamanan terpadu dan SOP keamanan.'],
+ ],
+ ],
+ [
+ 'group' => 'Administrasi & Internal',
+ 'items' => [
+ ['folder' => 'Administrasi & Internal / Surat Edaran', 'label' => 'Surat Edaran', 'hint' => 'Edaran dan komunikasi internal.'],
+ ['folder' => 'Administrasi & Internal / Laporan Internal', 'label' => 'Laporan Internal', 'hint' => 'Laporan manajemen dan operasional.'],
+ ['folder' => 'Administrasi & Internal / Arsip Kepegawaian', 'label' => 'Arsip Kepegawaian', 'hint' => 'Arsip kepegawaian dan administrasi personal.'],
+ ['folder' => 'Administrasi & Internal / Inventaris & Logistik', 'label' => 'Inventaris & Logistik', 'hint' => 'Persediaan dan logistik internal.'],
+ ],
+ ],
+ [
+ 'group' => 'ARSIP',
+ 'items' => [
+ ['folder' => 'ARSIP / File Penting', 'label' => 'File Penting', 'hint' => 'Dokumen prioritas dan referensi inti.'],
+ ['folder' => 'ARSIP / Photo', 'label' => 'Photo', 'hint' => 'Dokumentasi foto kegiatan dan arsip visual.'],
+ ['folder' => 'ARSIP / Video', 'label' => 'Video', 'hint' => 'Video resmi dan dokumentasi audiovisual.'],
+ ],
+ ],
+ ];
+}
+
+function folder_options(): array
+{
+ $options = [];
+ foreach (archive_folder_tree() as $section) {
+ foreach ($section['items'] as $item) {
+ $options[] = $item['folder'];
+ }
+ }
+ return $options;
+}
+
+function category_options(): array
+{
+ return [
+ 'Nota Diplomatik',
+ 'Laporan Pertemuan',
+ 'Perjanjian',
+ 'Instruksi',
+ 'Scholarship',
+ 'Event Budaya',
+ 'Dokumen Konsuler',
+ 'SOP',
+ 'Kepegawaian',
+ 'Inventaris',
+ 'File Penting',
+ 'Photo',
+ 'Video',
+ ];
+}
+
+function upload_root(): string
+{
+ return __DIR__ . '/uploads/archives';
+}
+
+function ensure_upload_root(): void
+{
+ $root = upload_root();
+ if (!is_dir($root)) {
+ mkdir($root, 0775, true);
+ }
+}
+
+function ensure_archive_schema(): void
+{
+ ensure_upload_root();
+
+ db()->exec(
+ "CREATE TABLE IF NOT EXISTS archive_documents (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(190) NOT NULL,
+ document_date DATE NOT NULL,
+ category VARCHAR(120) NOT NULL,
+ folder_path VARCHAR(255) NOT NULL,
+ department VARCHAR(120) NOT NULL,
+ notes TEXT NULL,
+ attachment_name VARCHAR(255) NOT NULL,
+ attachment_path VARCHAR(255) NOT NULL,
+ attachment_ext VARCHAR(16) NOT NULL,
+ attachment_size INT UNSIGNED NOT NULL,
+ status ENUM('pending', 'validated') NOT NULL DEFAULT 'pending',
+ created_by VARCHAR(190) NOT NULL,
+ created_role VARCHAR(50) NOT NULL,
+ validated_by VARCHAR(190) DEFAULT NULL,
+ validation_notes TEXT DEFAULT NULL,
+ activity_log LONGTEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
+ );
+}
+
+function department_from_folder(string $folder): string
+{
+ $parts = explode('/', $folder);
+ return trim($parts[0] ?? 'Umum');
+}
+
+function append_activity(array $entries, string $action, string $actor, string $notes = ''): array
+{
+ $entries[] = [
+ 'action' => $action,
+ 'actor' => $actor,
+ 'notes' => $notes,
+ 'timestamp' => date('Y-m-d H:i:s'),
+ ];
+ return $entries;
+}
+
+function create_document(array $post, array $files, array $user): bool
+{
+ if (!verify_csrf($post['csrf_token'] ?? null)) {
+ set_flash('danger', 'Token formulir unggah tidak valid.');
+ return false;
+ }
+
+ $title = trim((string)($post['title'] ?? ''));
+ $documentDate = trim((string)($post['document_date'] ?? ''));
+ $category = trim((string)($post['category'] ?? ''));
+ $folderPath = trim((string)($post['folder_path'] ?? ''));
+ $notes = trim((string)($post['notes'] ?? ''));
+
+ if ($title === '' || strlen($title) < 4) {
+ set_flash('danger', 'Judul dokumen minimal 4 karakter.');
+ return false;
+ }
+
+ if ($documentDate === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $documentDate)) {
+ set_flash('danger', 'Tanggal dokumen wajib diisi.');
+ return false;
+ }
+
+ if ($category === '') {
+ set_flash('danger', 'Kategori dokumen wajib diisi.');
+ return false;
+ }
+
+ if (!in_array($folderPath, folder_options(), true)) {
+ set_flash('danger', 'Folder tujuan tidak tersedia.');
+ return false;
+ }
+
+ if (!isset($files['attachment']) || !is_array($files['attachment']) || (int)($files['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+ set_flash('danger', 'Lampiran wajib diunggah.');
+ return false;
+ }
+
+ $file = $files['attachment'];
+ $allowedExtensions = ['pdf', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'mp4'];
+ $originalName = (string)($file['name'] ?? '');
+ $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
+
+ if (!in_array($extension, $allowedExtensions, true)) {
+ set_flash('danger', 'Format file harus PDF, DOC, DOCX, JPG, PNG, atau MP4.');
+ return false;
+ }
+
+ $size = (int)($file['size'] ?? 0);
+ if ($size <= 0 || $size > 10 * 1024 * 1024) {
+ set_flash('danger', 'Ukuran file maksimum 10 MB.');
+ return false;
+ }
+
+ ensure_upload_root();
+ $storedName = bin2hex(random_bytes(16)) . '.' . $extension;
+ $relativePath = 'uploads/archives/' . $storedName;
+ $targetPath = __DIR__ . '/' . $relativePath;
+
+ if (!move_uploaded_file((string)$file['tmp_name'], $targetPath)) {
+ set_flash('danger', 'Lampiran gagal disimpan ke server.');
+ return false;
+ }
+
+ $department = department_from_folder($folderPath);
+ $activity = append_activity([], 'upload', $user['name'], 'Dokumen ditambahkan ke folder ' . $folderPath);
+
+ $sql = 'INSERT INTO archive_documents
+ (title, document_date, category, folder_path, department, notes, attachment_name, attachment_path, attachment_ext, attachment_size, created_by, created_role, activity_log)
+ VALUES
+ (:title, :document_date, :category, :folder_path, :department, :notes, :attachment_name, :attachment_path, :attachment_ext, :attachment_size, :created_by, :created_role, :activity_log)';
+
+ $stmt = db()->prepare($sql);
+ $stmt->bindValue(':title', $title);
+ $stmt->bindValue(':document_date', $documentDate);
+ $stmt->bindValue(':category', $category);
+ $stmt->bindValue(':folder_path', $folderPath);
+ $stmt->bindValue(':department', $department);
+ $stmt->bindValue(':notes', $notes !== '' ? $notes : null, $notes !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
+ $stmt->bindValue(':attachment_name', $originalName);
+ $stmt->bindValue(':attachment_path', $relativePath);
+ $stmt->bindValue(':attachment_ext', $extension);
+ $stmt->bindValue(':attachment_size', $size, PDO::PARAM_INT);
+ $stmt->bindValue(':created_by', $user['name']);
+ $stmt->bindValue(':created_role', $user['role']);
+ $stmt->bindValue(':activity_log', json_encode($activity, JSON_UNESCAPED_UNICODE));
+ $stmt->execute();
+
+ set_flash('success', 'Dokumen berhasil ditambahkan. Menunggu validasi Super Admin.');
+ return true;
+}
+
+function get_documents(array $filters = []): array
+{
+ $where = [];
+ $params = [];
+
+ if (!empty($filters['status'])) {
+ $where[] = 'status = :status';
+ $params[':status'] = $filters['status'];
+ }
+
+ if (!empty($filters['folder'])) {
+ $where[] = 'folder_path = :folder';
+ $params[':folder'] = $filters['folder'];
+ }
+
+ if (!empty($filters['department'])) {
+ $where[] = 'department = :department';
+ $params[':department'] = $filters['department'];
+ }
+
+ $sql = 'SELECT * FROM archive_documents';
+ if ($where) {
+ $sql .= ' WHERE ' . implode(' AND ', $where);
+ }
+ $sql .= ' ORDER BY updated_at DESC';
+
+ $limit = isset($filters['limit']) ? max(1, min(100, (int)$filters['limit'])) : 20;
+ $sql .= ' LIMIT ' . $limit;
+
+ $stmt = db()->prepare($sql);
+ foreach ($params as $key => $value) {
+ $stmt->bindValue($key, $value);
+ }
+ $stmt->execute();
+ return $stmt->fetchAll();
+}
+
+function get_document(int $id): ?array
+{
+ $stmt = db()->prepare('SELECT * FROM archive_documents WHERE id = :id LIMIT 1');
+ $stmt->bindValue(':id', $id, PDO::PARAM_INT);
+ $stmt->execute();
+ $row = $stmt->fetch();
+ return $row ?: null;
+}
+
+function validate_document(int $id, array $user, string $notes = ''): bool
+{
+ if (!is_super_admin()) {
+ set_flash('danger', 'Hanya Super Admin yang dapat melakukan validasi dokumen.');
+ return false;
+ }
+
+ if (!verify_csrf($_POST['csrf_token'] ?? null)) {
+ set_flash('danger', 'Token validasi tidak valid.');
+ return false;
+ }
+
+ $document = get_document($id);
+ if (!$document) {
+ set_flash('danger', 'Dokumen tidak ditemukan.');
+ return false;
+ }
+
+ if ($document['status'] === 'validated') {
+ set_flash('info', 'Dokumen ini sudah tervalidasi.');
+ return false;
+ }
+
+ $activity = json_decode((string)$document['activity_log'], true);
+ if (!is_array($activity)) {
+ $activity = [];
+ }
+ $activity = append_activity($activity, 'validation', $user['name'], $notes !== '' ? $notes : 'Dokumen disetujui untuk akses staf.');
+
+ $stmt = db()->prepare('UPDATE archive_documents SET status = :status, validated_by = :validated_by, validation_notes = :validation_notes, activity_log = :activity_log WHERE id = :id');
+ $stmt->bindValue(':status', 'validated');
+ $stmt->bindValue(':validated_by', $user['name']);
+ $stmt->bindValue(':validation_notes', $notes !== '' ? $notes : null, $notes !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL);
+ $stmt->bindValue(':activity_log', json_encode($activity, JSON_UNESCAPED_UNICODE));
+ $stmt->bindValue(':id', $id, PDO::PARAM_INT);
+ $stmt->execute();
+
+ set_flash('success', 'Dokumen berhasil divalidasi dan dapat diakses staf terkait.');
+ return true;
+}
+
+function archive_dashboard_metrics(): array
+{
+ $totals = db()->query("SELECT
+ COUNT(*) AS total,
+ SUM(status = 'pending') AS pending,
+ SUM(status = 'validated') AS validated,
+ SUM(DATE(created_at) = CURDATE()) AS today_uploads
+ FROM archive_documents")->fetch() ?: [];
+
+ return [
+ 'total' => (int)($totals['total'] ?? 0),
+ 'pending' => (int)($totals['pending'] ?? 0),
+ 'validated' => (int)($totals['validated'] ?? 0),
+ 'today_uploads' => (int)($totals['today_uploads'] ?? 0),
+ ];
+}
+
+function format_filesize(int $bytes): string
+{
+ if ($bytes >= 1048576) {
+ return number_format($bytes / 1048576, 1) . ' MB';
+ }
+ if ($bytes >= 1024) {
+ return number_format($bytes / 1024, 1) . ' KB';
+ }
+ return $bytes . ' B';
+}
+
+function document_activity(array $document): array
+{
+ $activity = json_decode((string)($document['activity_log'] ?? '[]'), true);
+ return is_array($activity) ? $activity : [];
+}
+
+function can_access_document_file(array $document): bool
+{
+ if (is_super_admin()) {
+ return true;
+ }
+ return ($document['status'] ?? '') === 'validated';
+}
+
+function attachment_mime(string $extension): string
+{
+ return match (strtolower($extension)) {
+ 'pdf' => 'application/pdf',
+ 'doc' => 'application/msword',
+ 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'mp4' => 'video/mp4',
+ default => 'application/octet-stream',
+ };
+}
+
+function can_preview_inline(array $document): bool
+{
+ return in_array(strtolower((string)($document['attachment_ext'] ?? '')), ['pdf', 'jpg', 'jpeg', 'png', 'mp4'], true);
+}
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..fa2a173 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,763 @@
+:root {
+ --bg: #f3f5f7;
+ --bg-elevated: #ffffff;
+ --bg-alt: #e9edf1;
+ --surface: #ffffff;
+ --surface-strong: #f8fafc;
+ --text: #111827;
+ --muted: #667085;
+ --border: #d7dde5;
+ --border-strong: #c2ccd7;
+ --primary: #1f3b57;
+ --primary-strong: #13283d;
+ --accent: #5f6f82;
+ --success: #166534;
+ --warning: #b45309;
+ --danger: #b42318;
+ --shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
+ --shadow-soft: 0 10px 24px rgba(15, 23, 42, 0.06);
+ --radius-sm: 10px;
+ --radius-md: 14px;
+ --radius-lg: 18px;
+ --sidebar-width: 340px;
+}
+
+html[data-bs-theme='dark'] {
+ --bg: #0f141b;
+ --bg-elevated: #121a23;
+ --bg-alt: #1a2430;
+ --surface: #151d27;
+ --surface-strong: #101720;
+ --text: #e7edf4;
+ --muted: #99a7b8;
+ --border: #243140;
+ --border-strong: #324457;
+ --primary: #cbd5e1;
+ --primary-strong: #f8fafc;
+ --accent: #7f8ea3;
+ --shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
+ --shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.28);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
body {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
- margin: 0;
- min-height: 100vh;
+ min-height: 100%;
}
-.main-wrapper {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
+body.archive-app {
+ margin: 0;
+ font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
}
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
+img,
+video,
+iframe {
+ max-width: 100%;
}
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
- display: flex;
- flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
- overflow: hidden;
+a {
+ color: inherit;
}
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
+.btn,
+.form-control,
+.form-select,
+.dropdown-menu,
+.card,
+.toast,
+.badge,
+.alert {
+ border-radius: var(--radius-sm);
}
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
+.btn-primary {
+ --bs-btn-bg: var(--primary);
+ --bs-btn-border-color: var(--primary);
+ --bs-btn-hover-bg: var(--primary-strong);
+ --bs-btn-hover-border-color: var(--primary-strong);
+ --bs-btn-active-bg: var(--primary-strong);
+ --bs-btn-active-border-color: var(--primary-strong);
}
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
+.btn-outline-secondary {
+ --bs-btn-color: var(--text);
+ --bs-btn-border-color: var(--border-strong);
+ --bs-btn-hover-bg: var(--surface-strong);
+ --bs-btn-hover-border-color: var(--border-strong);
+ --bs-btn-hover-color: var(--text);
}
-::-webkit-scrollbar-track {
- background: transparent;
+.form-control,
+.form-select {
+ border-color: var(--border);
+ background: var(--surface);
+ color: var(--text);
+ min-height: 46px;
}
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
+.form-control:focus,
+.form-select:focus {
+ border-color: var(--primary);
+ box-shadow: 0 0 0 0.2rem rgba(31, 59, 87, 0.15);
}
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
+.card,
+.dropdown-menu,
+.toast {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow-soft);
}
-.message {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+.section-kicker,
+.eyebrow {
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.74rem;
+ font-weight: 700;
}
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
+.text-secondary,
+.small,
+small,
+.form-text {
+ color: var(--muted) !important;
}
-.message.visitor {
- align-self: flex-end;
- background: linear-gradient(135deg, #212529 0%, #343a40 100%);
- color: #fff;
- border-bottom-right-radius: 4px;
+code {
+ background: var(--surface-strong);
+ color: var(--text);
+ padding: 0.18rem 0.4rem;
+ border-radius: 8px;
+ border: 1px solid var(--border);
}
-.message.bot {
- align-self: flex-start;
- background: #ffffff;
- color: #212529;
- border-bottom-left-radius: 4px;
+.login-shell {
+ min-height: 100vh;
+ display: grid;
+ grid-template-columns: minmax(0, 1.1fr) minmax(340px, 520px);
}
-.chat-input-area {
- padding: 1.25rem;
- background: rgba(255, 255, 255, 0.5);
- border-top: 1px solid rgba(0, 0, 0, 0.05);
+.login-splash {
+ position: relative;
+ overflow: hidden;
+ padding: 48px 56px;
+ background: #0d141b;
+ color: #eef4fa;
+ display: flex;
+ align-items: flex-end;
}
-.chat-input-area form {
- display: flex;
- gap: 0.75rem;
+.glass-city {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ opacity: 0.92;
}
-.chat-input-area input {
- flex: 1;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- padding: 0.75rem 1rem;
- outline: none;
- background: rgba(255, 255, 255, 0.9);
- transition: all 0.3s ease;
+.tower,
+.grid-line {
+ position: absolute;
+ border: 1px solid rgba(233, 239, 246, 0.18);
+ background: rgba(220, 230, 239, 0.04);
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03), 0 20px 50px rgba(0,0,0,0.28);
}
-.chat-input-area input:focus {
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
+.tower::before {
+ content: '';
+ position: absolute;
+ inset: 14px;
+ border: 1px solid rgba(255,255,255,0.06);
+ opacity: 0.45;
}
-.chat-input-area button {
- background: #212529;
- color: #fff;
- border: none;
- padding: 0.75rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
+.tower-a {
+ width: 28%;
+ height: 76%;
+ bottom: -6%;
+ left: 6%;
+ transform: skewY(-8deg);
}
-.chat-input-area button:hover {
- background: #000;
- transform: translateY(-2px);
- box-shadow: 0 5px 15px rgba(0,0,0,0.2);
+.tower-b {
+ width: 26%;
+ height: 64%;
+ bottom: 2%;
+ left: 30%;
+ transform: skewY(-6deg);
}
-/* Background Animations */
-.bg-animations {
+.tower-c {
+ width: 30%;
+ height: 82%;
+ right: 10%;
+ bottom: -8%;
+ transform: skewY(7deg);
+}
+
+.grid-line {
+ border: 0;
+ background: rgba(255,255,255,0.08);
+}
+
+.grid-a {
+ width: 1px;
+ top: 0;
+ bottom: 0;
+ left: 62%;
+}
+
+.grid-b {
+ height: 1px;
+ left: 0;
+ right: 0;
+ bottom: 24%;
+}
+
+.login-copy {
+ position: relative;
+ z-index: 1;
+ max-width: 620px;
+}
+
+.login-copy h1 {
+ font-size: clamp(2.2rem, 4vw, 4rem);
+ line-height: 1.05;
+ letter-spacing: -0.04em;
+ margin: 0 0 1rem;
+ max-width: 14ch;
+}
+
+.login-copy .lead {
+ font-size: 1.12rem;
+ margin-bottom: 0.5rem;
+ color: #f8fbff;
+}
+
+.muted-copy {
+ max-width: 58ch;
+ color: rgba(238, 244, 250, 0.78);
+}
+
+.security-badges {
+ margin-top: 1.75rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.security-badges span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.65rem 0.85rem;
+ background: rgba(255,255,255,0.06);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 999px;
+ font-size: 0.92rem;
+}
+
+.login-panel {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ background: var(--bg);
+}
+
+.login-panel .card {
+ width: 100%;
+ max-width: 460px;
+ box-shadow: var(--shadow);
+}
+
+.demo-credentials {
+ border-top: 1px solid var(--border);
+ padding-top: 1rem;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
+ min-height: 100vh;
+}
+
+.sidebar-panel {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ padding: 20px;
+ border-right: 1px solid var(--border);
+ background: var(--bg-elevated);
+ overflow-y: auto;
+}
+
+.sidebar-top {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.folder-tree {
+ display: grid;
+ gap: 0.9rem;
+}
+
+.folder-group {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ overflow: hidden;
+}
+
+.folder-group summary {
+ list-style: none;
+ cursor: pointer;
+ padding: 0.9rem 1rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-weight: 700;
+}
+
+.folder-group summary::-webkit-details-marker {
+ display: none;
+}
+
+.folder-group[open] summary i {
+ transform: rotate(180deg);
+}
+
+.folder-group summary i {
+ transition: transform 0.2s ease;
+}
+
+.folder-links {
+ padding: 0 0.85rem 0.85rem;
+ display: grid;
+ gap: 0.65rem;
+}
+
+.folder-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.65rem;
+ align-items: center;
+}
+
+.folder-select {
+ text-align: left;
+ padding: 0.85rem 0.9rem;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--surface-strong);
+ color: var(--text);
+}
+
+.folder-select:hover,
+.folder-select.active {
+ border-color: var(--primary);
+ background: rgba(31, 59, 87, 0.06);
+}
+
+.folder-select small,
+.folder-name {
+ display: block;
+}
+
+.folder-name {
+ font-weight: 600;
+ margin-bottom: 0.2rem;
+}
+
+.folder-add {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+}
+
+.app-main {
+ min-width: 0;
+}
+
+.topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ padding: 18px 24px;
+ border-bottom: 1px solid var(--border);
+ background: rgba(243, 245, 247, 0.94);
+ backdrop-filter: blur(12px);
+}
+
+html[data-bs-theme='dark'] .topbar {
+ background: rgba(15, 20, 27, 0.92);
+}
+
+.topbar-static {
+ position: sticky;
+}
+
+.profile-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.75rem;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ padding: 0.35rem 0.5rem 0.35rem 0.35rem;
+ color: var(--text);
+}
+
+.profile-chip.static-chip {
+ cursor: default;
+}
+
+.avatar {
+ width: 38px;
+ height: 38px;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ background: var(--primary);
+ color: #fff;
+}
+
+html[data-bs-theme='dark'] .avatar {
+ background: var(--primary-strong);
+ color: #111827;
+}
+
+.content-grid {
+ padding: 24px;
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.8fr);
+ gap: 1.25rem;
+}
+
+.content-grid > .hero-panel,
+.content-grid > .table-card {
+ grid-column: 1 / -1;
+}
+
+.display-title {
+ margin: 0;
+ font-size: clamp(1.7rem, 2vw, 2.45rem);
+ line-height: 1.08;
+ letter-spacing: -0.04em;
+ max-width: 18ch;
+}
+
+.session-panel {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(120px, 1fr));
+ gap: 0.85rem;
+ min-width: min(100%, 340px);
+}
+
+.metric-box {
+ padding: 1rem;
+ background: var(--surface-strong);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+}
+
+.metric-box span {
+ display: block;
+ color: var(--muted);
+ font-size: 0.86rem;
+ margin-bottom: 0.35rem;
+}
+
+.metric-box strong {
+ font-size: 1.45rem;
+}
+
+.dropzone {
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface-strong);
+ min-height: 148px;
+ display: grid;
+ place-items: center;
+ text-align: center;
+ padding: 1.2rem;
+ cursor: pointer;
+ transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
+}
+
+.dropzone i {
+ font-size: 1.4rem;
+ display: inline-flex;
+ margin-bottom: 0.55rem;
+ color: var(--primary);
+}
+
+.dropzone.dragover {
+ border-color: var(--primary);
+ background: rgba(31, 59, 87, 0.08);
+ transform: translateY(-1px);
+}
+
+.queue-list {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.queue-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ text-decoration: none;
+ padding: 0.95rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-strong);
+}
+
+.queue-item:hover {
+ border-color: var(--primary);
+}
+
+.queue-item strong,
+.queue-item span {
+ display: block;
+}
+
+.search-box {
+ position: relative;
+ max-width: 320px;
+ width: 100%;
+}
+
+.search-box i {
+ position: absolute;
+ left: 0.85rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--muted);
+}
+
+.search-box input {
+ padding-left: 2.6rem;
+}
+
+.archive-table th {
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+
+.archive-table td,
+.archive-table th {
+ padding: 1rem 0.75rem;
+ border-color: var(--border);
+ background: transparent;
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 112px;
+ padding: 0.42rem 0.72rem;
+ border-radius: 999px;
+ font-size: 0.82rem;
+ font-weight: 700;
+ border: 1px solid transparent;
+}
+
+.status-pill.pending {
+ color: var(--warning);
+ background: rgba(180, 83, 9, 0.1);
+ border-color: rgba(180, 83, 9, 0.18);
+}
+
+.status-pill.validated {
+ color: var(--success);
+ background: rgba(22, 101, 52, 0.1);
+ border-color: rgba(22, 101, 52, 0.18);
+}
+
+.security-list {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.security-list li {
+ display: flex;
+ gap: 0.7rem;
+ align-items: flex-start;
+}
+
+.security-list i {
+ color: var(--primary);
+ margin-top: 0.15rem;
+}
+
+.empty-panel {
+ min-height: 148px;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-md);
+ background: var(--surface-strong);
+ display: grid;
+ place-items: center;
+ text-align: center;
+ padding: 1.5rem;
+}
+
+.empty-panel.tall {
+ min-height: 240px;
+}
+
+.empty-panel i {
+ font-size: 1.6rem;
+ color: var(--muted);
+ margin-bottom: 0.7rem;
+}
+
+.detail-layout {
+ max-width: 1460px;
+}
+
+.detail-meta strong,
+.meta-label {
+ display: block;
+}
+
+.meta-label {
+ color: var(--muted);
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 0.35rem;
+}
+
+.annotation-box {
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-strong);
+}
+
+.preview-frame {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ background: var(--surface-strong);
+}
+
+.preview-iframe {
+ width: 100%;
+ min-height: 640px;
+ border: 0;
+ background: #fff;
+}
+
+.timeline {
+ display: grid;
+ gap: 1rem;
+}
+
+.timeline-item {
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr);
+ gap: 0.9rem;
+ align-items: start;
+}
+
+.timeline-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin-top: 0.35rem;
+ background: var(--primary);
+ box-shadow: 0 0 0 4px rgba(31, 59, 87, 0.12);
+}
+
+.theme-toggle {
+ min-width: 42px;
+}
+
+.sidebar-open .sidebar-panel {
+ transform: translateX(0);
+}
+
+@media (max-width: 1199.98px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar-panel {
position: fixed;
- top: 0;
- left: 0;
+ z-index: 40;
+ width: min(92vw, 360px);
+ transform: translateX(-102%);
+ transition: transform 0.24s ease;
+ box-shadow: var(--shadow);
+ }
+
+ .content-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 991.98px) {
+ .login-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .login-splash {
+ min-height: 50vh;
+ padding: 32px 24px;
+ }
+
+ .login-panel {
+ padding: 20px;
+ }
+
+ .topbar {
+ padding: 16px;
+ }
+
+ .content-grid {
+ padding: 16px;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .login-copy h1 {
+ max-width: none;
+ }
+
+ .session-panel {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .hero-actions,
+ .detail-actions {
width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
- pointer-events: none;
+ }
+
+ .hero-actions .btn,
+ .detail-actions .btn {
+ flex: 1 1 auto;
+ }
}
-
-.blob {
- position: absolute;
- width: 500px;
- height: 500px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
- filter: blur(80px);
- animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
-}
-
-.blob-1 {
- top: -10%;
- left: -10%;
- background: rgba(238, 119, 82, 0.4);
-}
-
-.blob-2 {
- bottom: -10%;
- right: -10%;
- background: rgba(35, 166, 213, 0.4);
- animation-delay: -7s;
- width: 600px;
- height: 600px;
-}
-
-.blob-3 {
- top: 40%;
- left: 30%;
- background: rgba(231, 60, 126, 0.3);
- animation-delay: -14s;
- width: 450px;
- height: 450px;
-}
-
-@keyframes move {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
- 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
- 100% { transform: translate(0, 0) rotate(360deg) scale(1); }
-}
-
-.header-link {
- font-size: 14px;
- color: #fff;
- text-decoration: none;
- background: rgba(0, 0, 0, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 8px;
- transition: all 0.3s ease;
-}
-
-.header-link:hover {
- background: rgba(0, 0, 0, 0.4);
- text-decoration: none;
-}
-
-/* Admin Styles */
-.admin-container {
- max-width: 900px;
- margin: 3rem auto;
- padding: 2.5rem;
- background: rgba(255, 255, 255, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 24px;
- box-shadow: 0 20px 50px rgba(0,0,0,0.15);
- border: 1px solid rgba(255, 255, 255, 0.4);
- position: relative;
- z-index: 1;
-}
-
-.admin-container h1 {
- margin-top: 0;
- color: #212529;
- font-weight: 800;
-}
-
-.table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0 8px;
- margin-top: 1.5rem;
-}
-
-.table th {
- background: transparent;
- border: none;
- padding: 1rem;
- color: #6c757d;
- font-weight: 600;
- text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
-}
-
-.table td {
- background: #fff;
- padding: 1rem;
- border: none;
-}
-
-.table tr td:first-child { border-radius: 12px 0 0 12px; }
-.table tr td:last-child { border-radius: 0 12px 12px 0; }
-
-.form-group {
- margin-bottom: 1.25rem;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
- font-weight: 600;
- font-size: 0.9rem;
-}
-
-.form-control {
- width: 100%;
- padding: 0.75rem 1rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- background: #fff;
- transition: all 0.3s ease;
- box-sizing: border-box;
-}
-
-.form-control:focus {
- outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
-}
-
-.header-container {
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.header-links {
- display: flex;
- gap: 1rem;
-}
-
-.admin-card {
- background: rgba(255, 255, 255, 0.6);
- padding: 2rem;
- border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-bottom: 2.5rem;
- box-shadow: 0 10px 30px rgba(0,0,0,0.05);
-}
-
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
- font-weight: 700;
-}
-
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
-}
-
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
-}
-
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- width: 100%;
- transition: all 0.3s ease;
-}
-
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
-}
-
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
- padding: 1rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
-}
-
-.history-table {
- width: 100%;
-}
-
-.history-table-time {
- width: 15%;
- white-space: nowrap;
- font-size: 0.85em;
- color: #555;
-}
-
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
-}
-
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
-}
-
-.no-messages {
- text-align: center;
- color: #777;
-}
\ No newline at end of file
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..824b31c 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,110 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
-
- const appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
- };
-
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
-
- appendMessage(message, 'visitor');
- chatInput.value = '';
-
- try {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
- });
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
- } catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
- }
+ const root = document.documentElement;
+ const storedTheme = localStorage.getItem('archive-theme');
+ const applyTheme = (theme) => {
+ root.setAttribute('data-bs-theme', theme);
+ document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
+ const icon = button.querySelector('i');
+ if (icon) {
+ icon.className = theme === 'dark' ? 'bi bi-sun' : 'bi bi-moon-stars';
+ }
});
+ };
+
+ applyTheme(storedTheme === 'dark' ? 'dark' : 'light');
+
+ document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const nextTheme = root.getAttribute('data-bs-theme') === 'dark' ? 'light' : 'dark';
+ localStorage.setItem('archive-theme', nextTheme);
+ applyTheme(nextTheme);
+ });
+ });
+
+ document.querySelectorAll('.toast').forEach((toastElement) => {
+ const toast = new bootstrap.Toast(toastElement);
+ toast.show();
+ });
+
+ const sidebarToggles = document.querySelectorAll('[data-sidebar-toggle]');
+ sidebarToggles.forEach((button) => {
+ button.addEventListener('click', () => {
+ document.body.classList.toggle('sidebar-open');
+ });
+ });
+
+ const folderSelect = document.getElementById('folder_path');
+ const categoryInput = document.getElementById('category');
+ document.querySelectorAll('[data-folder-select]').forEach((button) => {
+ button.addEventListener('click', () => {
+ const folder = button.getAttribute('data-folder') || '';
+ const category = button.getAttribute('data-category') || '';
+ if (folderSelect) {
+ folderSelect.value = folder;
+ folderSelect.dispatchEvent(new Event('change'));
+ folderSelect.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ if (categoryInput && categoryInput.value.trim() === '') {
+ categoryInput.value = category;
+ }
+ document.querySelectorAll('.folder-select').forEach((node) => node.classList.remove('active'));
+ const cardButton = button.classList.contains('folder-add') ? button.previousElementSibling : button;
+ if (cardButton) {
+ cardButton.classList.add('active');
+ }
+ document.getElementById('uploadCard')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ document.body.classList.remove('sidebar-open');
+ });
+ });
+
+ const dropzone = document.querySelector('[data-dropzone]');
+ const fileInput = document.getElementById('attachment');
+ const fileLabel = document.querySelector('[data-file-label]');
+
+ const updateFileLabel = (files) => {
+ if (!fileLabel) return;
+ if (!files || !files.length) {
+ fileLabel.textContent = 'PDF, DOC, DOCX, JPG, PNG, MP4 · maksimum 10 MB';
+ return;
+ }
+ const file = files[0];
+ fileLabel.textContent = `${file.name} · ${Math.max(1, Math.round(file.size / 1024))} KB`;
+ };
+
+ if (dropzone && fileInput) {
+ dropzone.addEventListener('click', () => fileInput.click());
+ fileInput.addEventListener('change', () => updateFileLabel(fileInput.files));
+
+ ['dragenter', 'dragover'].forEach((eventName) => {
+ dropzone.addEventListener(eventName, (event) => {
+ event.preventDefault();
+ dropzone.classList.add('dragover');
+ });
+ });
+
+ ['dragleave', 'drop'].forEach((eventName) => {
+ dropzone.addEventListener(eventName, (event) => {
+ event.preventDefault();
+ dropzone.classList.remove('dragover');
+ });
+ });
+
+ dropzone.addEventListener('drop', (event) => {
+ const files = event.dataTransfer?.files;
+ if (!files || !files.length) return;
+ fileInput.files = files;
+ updateFileLabel(files);
+ });
+ }
+
+ const searchInput = document.getElementById('documentSearch');
+ if (searchInput) {
+ searchInput.addEventListener('input', () => {
+ const query = searchInput.value.trim().toLowerCase();
+ document.querySelectorAll('[data-search-row]').forEach((row) => {
+ const haystack = row.getAttribute('data-search-row') || '';
+ row.style.display = haystack.includes(query) ? '' : 'none';
+ });
+ });
+ }
});
diff --git a/document.php b/document.php
new file mode 100644
index 0000000..7247545
--- /dev/null
+++ b/document.php
@@ -0,0 +1,251 @@
+ 0 ? get_document($documentId) : null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string)($_POST['action'] ?? '') === 'validate_document' && $document) {
+ validate_document($documentId, current_user(), trim((string)($_POST['validation_notes'] ?? '')));
+ header('Location: document.php?id=' . $documentId);
+ exit;
+}
+
+$document = $documentId > 0 ? get_document($documentId) : null;
+if (!$document) {
+ http_response_code(404);
+}
+
+$meta = page_meta('Detail Arsip – KBRI Harare', 'Detail arsip digital, validasi, dan akses pratinjau aman KBRI Harare.');
+$flashes = get_flashes();
+$user = current_user();
+$activity = $document ? document_activity($document) : [];
+$canAccessFile = $document ? can_access_document_file($document) : false;
+$canPreview = $document ? ($canAccessFile && can_preview_inline($document)) : false;
+?>
+
+
+
+
+
+ = h($meta['title']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= h($flash['message']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dokumen tidak ditemukan
+
ID arsip tidak tersedia atau telah berubah.
+
Kembali ke dashboard
+
+
+
+
+
+
+
+
+
+
+ = $document['status'] === 'validated' ? 'Tervalidasi' : 'Menunggu Validasi' ?>
+ = h($document['category']) ?>
+
+
= h($document['title']) ?>
+
= h($document['folder_path']) ?>
+
+
+
+
+
+
+
+
+
Catatan dokumen
+
= nl2br(h($document['notes'])) ?>
+
+
+
+
+
+
+
+
+
+
Pratinjau aman
+
Akses file sesuai status validasi
+
+
= $canAccessFile ? 'Akses dibuka' : 'Terkunci' ?>
+
+
+
+
+
+
![Pratinjau <?= h($document['title']) ?>](file.php?id=<?= (int)$document['id'] ?>&disposition=inline)
+
+
+
+
+
+
+
+
+
+
Jenis file tidak mendukung pratinjau inline.
+
Unduh lampiran untuk membuka dokumen ini.
+
+
+
+
+
Pratinjau dikunci sampai validasi selesai.
+
Setelah disetujui Super Admin, staf dapat pratinjau, unduh, dan cetak dari halaman ini.
+
+
+
+
+
+
+
+
+
+
+
Aksi Super Admin
+
Validasi dokumen
+
Setelah disetujui, dokumen terbuka untuk pratinjau, unduh, dan cetak oleh staf terkait.
+
+
+
+
+
+
+
+
Status akses
+
Ringkasan kepatuhan
+
+ - Password user demo diproses dengan verifikasi Bcrypt.
+ - Form terlindungi CSRF token dan prepared statements PDO.
+ - File hanya di-stream melalui gerbang aplikasi, bukan tautan publik langsung.
+ - = $document['status'] === 'validated' ? 'Dokumen siap diakses sesuai hak role.' : 'Dokumen menunggu otorisasi Super Admin.' ?>
+
+
+
+
+
+
+
Audit trail
+
Riwayat aktivitas
+
+
+
+
+
+
= h($entry['action'] ?? '') ?>
+
= h($entry['actor'] ?? '') ?> · = h($entry['timestamp'] ?? '') ?>
+
+
= h($entry['notes']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/file.php b/file.php
new file mode 100644
index 0000000..33a7234
--- /dev/null
+++ b/file.php
@@ -0,0 +1,38 @@
+ 0 ? get_document($documentId) : null;
+
+if (!$document) {
+ http_response_code(404);
+ echo 'Dokumen tidak ditemukan.';
+ exit;
+}
+
+if (!can_access_document_file($document)) {
+ http_response_code(403);
+ echo 'Dokumen belum tersedia untuk diakses.';
+ exit;
+}
+
+$filePath = __DIR__ . '/' . ltrim((string)$document['attachment_path'], '/');
+if (!is_file($filePath)) {
+ http_response_code(404);
+ echo 'Lampiran tidak ditemukan di server.';
+ exit;
+}
+
+header('Content-Type: ' . attachment_mime((string)$document['attachment_ext']));
+header('Content-Length: ' . filesize($filePath));
+header('Content-Disposition: ' . $disposition . '; filename="' . rawurlencode((string)$document['attachment_name']) . '"');
+header('X-Content-Type-Options: nosniff');
+readfile($filePath);
+exit;
diff --git a/index.php b/index.php
index 7205f3d..4c5de22 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,463 @@
0, 'pending' => 0, 'validated' => 0, 'today_uploads' => 0];
+$recentDocuments = is_authenticated() ? get_documents(['limit' => 8]) : [];
+$pendingDocuments = is_authenticated() ? get_documents(['status' => 'pending', 'limit' => 5]) : [];
+$loginAudit = $_SESSION['login_audit'] ?? null;
?>
-
+
- New Style
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ = h($meta['title']) ?>
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
-
-
Analyzing your requirements and generating your website…
-
-
Loading…
+
+
+
+
+
+
= h($flash['message']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sistem Informasi Arsip Digital
+
KEDUTAAN BESAR REPUBLIK INDONESIA HARARE
+
Selamat datang di Sistem Informasi Arsip Digital KBRI Harare.
+
Mewujudkan tata kelola administrasi yang tertib, transparan, dan akuntabel untuk pelayanan publik prima.
+
+ Bcrypt + CSRF
+ MFA via Email OTP
+ Audit trail dokumen
+
+
+
+
+
+
+
+
+
Akses aman staf internal
+
= is_array($pendingAuth) ? 'Verifikasi MFA' : 'Masuk ke brankas digital' ?>
+
= is_array($pendingAuth) ? 'Masukkan kode OTP yang berlaku 5 menit.' : 'Gunakan email kedinasan, password, dan verifikasi OTP.' ?>
+
+
+
+
+
+
+ Kode OTP dikirim ke = h($pendingAuth['masked_email'] ?? '') ?>.
+
+ Mode uji aktif karena SMTP belum dikonfigurasi.
+
+
+
+
+
+
+
+
+
Akun demo internal
+
Super Admin: superadmin@kbriharare.go.id
+
Staf: staf.politik@kbriharare.go.id
+
Password: Harare2026!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sistem Informasi Arsip Digital
+
KBRI Harare Secure Vault
+
+
+
+
+
+
+
+ - Foto profil dapat diaktifkan pada iterasi berikutnya.
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
Workflow MVP siap dipakai
+
Unggah → Validasi → Pratinjau aman → Riwayat audit
+
Staf dapat menempatkan dokumen pada folder yang tepat, sementara Super Admin memvalidasi sebelum dokumen dapat dipratinjau, diunduh, atau dicetak.
+
+
+
+
+ Total arsip
+ = number_format($dashboardMetrics['total']) ?>
+
+
+ Menunggu validasi
+ = number_format($dashboardMetrics['pending']) ?>
+
+
+ Tervalidasi
+ = number_format($dashboardMetrics['validated']) ?>
+
+
+ Upload hari ini
+ = number_format($dashboardMetrics['today_uploads']) ?>
+
+
+
+
+
+
+
+
+
+
+
Input dokumen
+
Tambah dokumen ke folder terpilih
+
Gunakan drag-and-drop, isi metadata inti, lalu sistem menyimpan arsip ke folder terkait secara otomatis.
+
+
CSRF aktif
+
+
+
+
+
+
+
+
+
+
Kontrol akses
+
Status sesi & keamanan
+
+
= h($user['role_label'] ?? '') ?>
+
+
+ - Login terakhir: = h($loginAudit['timestamp'] ?? date('Y-m-d H:i:s')) ?>
+ - MFA: = h($loginAudit['mfa_method'] ?? 'Email OTP') ?>
+ - Hak akses: = is_super_admin() ? 'validasi, unggah, dan akses semua dokumen' : 'unggah dan akses dokumen setelah validasi' ?>
+ - Proteksi aktif: prepared statements, CSRF token, dan rate limiting sesi.
+
+
+
+
+
+
+
+
+
Validasi dokumen
+
Queue persetujuan
+
+
= number_format($dashboardMetrics['pending']) ?> pending
+
+
+
+
+
+
+
Belum ada dokumen yang menunggu validasi.
+
+
+
+
+
+
+
+
+
+
Daftar arsip
+
Dokumen terbaru
+
Setiap arsip memiliki detail metadata, status validasi, dan jejak audit.
+
+
+
+
+
+
+
+
+
+
+
+
+ | Dokumen |
+ Folder |
+ Upload |
+ Status |
+ Aksi |
+
+
+
+
+
+ |
+ = h($document['title']) ?>
+ = h($document['category']) ?> · = h(format_filesize((int)$document['attachment_size'])) ?>
+ |
+ = h($document['folder_path']) ?> |
+
+ = h($document['created_by']) ?>
+ = h(date('d M Y H:i', strtotime((string)$document['created_at']))) ?>
+ |
+ = $document['status'] === 'validated' ? 'Tervalidasi' : 'Menunggu' ?> |
+ Buka detail |
+
+
+
+
+
+
+
+
+
Belum ada arsip.
+
Mulai dengan memilih folder di sidebar lalu unggah dokumen pertama Anda.
+
+
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
-
+
+
+
+