prepare( 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name' ); $stmt->execute([ ':schema' => DB_NAME, ':table_name' => $table, ':column_name' => $column, ]); return (int) $stmt->fetchColumn() > 0; } function schema_index_exists(string $table, string $index): bool { $stmt = db()->prepare( 'SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table_name AND INDEX_NAME = :index_name' ); $stmt->execute([ ':schema' => DB_NAME, ':table_name' => $table, ':index_name' => $index, ]); return (int) $stmt->fetchColumn() > 0; } function ensure_column_exists(string $table, string $column, string $definition): void { if (!schema_column_exists($table, $column)) { db()->exec(sprintf('ALTER TABLE %s ADD COLUMN %s %s', $table, $column, $definition)); } } function ensure_index_exists(string $table, string $index, string $definition): void { if (!schema_index_exists($table, $index)) { db()->exec(sprintf('ALTER TABLE %s ADD INDEX %s %s', $table, $index, $definition)); } } function ensure_app_schema(): void { static $done = false; if ($done) { return; } $usersSql = <<<'SQL' CREATE TABLE IF NOT EXISTS app_users ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, email VARCHAR(190) NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uniq_user_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL; $itemsSql = <<<'SQL' CREATE TABLE IF NOT EXISTS service_tracker_items ( id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, item_type ENUM('service','setting') NOT NULL DEFAULT 'service', user_id INT UNSIGNED DEFAULT NULL, slug_key VARCHAR(120) DEFAULT NULL, vehicle_name VARCHAR(120) DEFAULT NULL, vehicle_category VARCHAR(20) DEFAULT NULL, plate_number VARCHAR(40) DEFAULT NULL, service_name VARCHAR(80) DEFAULT NULL, last_service_date DATE DEFAULT NULL, reminder_interval_days INT DEFAULT NULL, next_due_date DATE DEFAULT NULL, odometer_km INT DEFAULT NULL, notes TEXT DEFAULT NULL, content_longtext LONGTEXT DEFAULT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uniq_item_slug (item_type, slug_key), KEY idx_service_due (item_type, next_due_date), KEY idx_vehicle_name (vehicle_name), KEY idx_user_service_due (user_id, item_type, next_due_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL; db()->exec($usersSql); db()->exec($itemsSql); ensure_column_exists('service_tracker_items', 'user_id', 'INT UNSIGNED DEFAULT NULL AFTER item_type'); ensure_index_exists('service_tracker_items', 'idx_user_service_due', '(user_id, item_type, next_due_date)'); $done = true; } ensure_app_schema(); function service_catalog(): array { return [ 'Ganti oli mesin' => 90, 'Filter udara' => 120, 'Bersihin CVT' => 90, 'Vanbelt' => 180, 'Ganti oli gardan' => 180, 'Busi' => 180, 'Tune up ringan' => 120, 'Cek rem & kampas' => 90, ]; } function normalize_email(string $email): string { return strtolower(trim($email)); } function current_user(): ?array { $user = $_SESSION['user'] ?? null; return is_array($user) ? $user : null; } function current_user_id(): int { return (int) ((current_user()['id'] ?? 0)); } function current_user_name(): string { $user = current_user(); if (!$user) { return ''; } $name = trim((string) ($user['name'] ?? '')); if ($name !== '') { return $name; } return (string) ($user['email'] ?? ''); } function is_user_logged_in(): bool { return current_user_id() > 0; } function login_user(array $user): void { $_SESSION['user'] = [ 'id' => (int) ($user['id'] ?? 0), 'name' => (string) ($user['name'] ?? ''), 'email' => (string) ($user['email'] ?? ''), ]; session_regenerate_id(true); } function logout_user(): void { unset($_SESSION['user']); session_regenerate_id(true); } function auth_redirect_target(string $default = ''): string { $fallback = $default !== '' ? $default : app_url('dashboard.php'); if (!str_starts_with($fallback, '/') && preg_match('#^[a-z][a-z0-9+.-]*://#i', $fallback) !== 1) { $fallback = app_url($fallback); } $target = trim((string) ($_POST['redirect'] ?? $_GET['redirect'] ?? '')); if ($target === '') { return $fallback; } if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $target) === 1 || str_starts_with($target, '//')) { return $fallback; } return str_starts_with($target, '/') ? $target : app_url($target); } function require_user_login(): void { if (is_user_logged_in()) { return; } set_flash('warning', 'Silakan login dulu supaya catatan servis hanya terlihat oleh akun kamu sendiri.'); $target = (string) ($_SERVER['REQUEST_URI'] ?? app_url('dashboard.php')); header('Location: ' . app_url('login.php') . '?redirect=' . urlencode($target)); exit; } function find_user_by_email(string $email): ?array { $stmt = db()->prepare('SELECT * FROM app_users WHERE email = :email LIMIT 1'); $stmt->execute([ ':email' => normalize_email($email), ]); $row = $stmt->fetch(); return $row ?: null; } function create_user_account(string $name, string $email, string $password): int { $stmt = db()->prepare( 'INSERT INTO app_users (name, email, password_hash) VALUES (:name, :email, :password_hash)' ); $stmt->execute([ ':name' => trim($name), ':email' => normalize_email($email), ':password_hash' => password_hash($password, PASSWORD_DEFAULT), ]); return (int) db()->lastInsertId(); } function verify_user_login(string $email, string $password): ?array { $user = find_user_by_email($email); if (!$user) { return null; } return password_verify($password, (string) $user['password_hash']) ? $user : null; } function app_setting(string $key, string $default = ''): string { $stmt = db()->prepare('SELECT content_longtext FROM service_tracker_items WHERE item_type = :type AND slug_key = :slug LIMIT 1'); $stmt->execute([ ':type' => 'setting', ':slug' => $key, ]); $value = $stmt->fetchColumn(); return is_string($value) ? $value : $default; } function save_setting(string $key, string $value): void { $stmt = db()->prepare( 'INSERT INTO service_tracker_items (item_type, slug_key, content_longtext) VALUES (:type, :slug, :content) ON DUPLICATE KEY UPDATE content_longtext = VALUES(content_longtext), updated_at = CURRENT_TIMESTAMP' ); $stmt->execute([ ':type' => 'setting', ':slug' => $key, ':content' => $value, ]); } function head_ad_code(): string { return app_setting('ads_head'); } function body_ad_code(): string { return app_setting('ads_body'); } function app_project_name(): string { return app_env('PROJECT_NAME', 'ServisIngat'); } function app_brand_initial(): string { $projectName = trim(app_project_name()); if ($projectName === '') { return 'S'; } if (function_exists('mb_substr')) { return mb_strtoupper((string) mb_substr($projectName, 0, 1, 'UTF-8'), 'UTF-8'); } return strtoupper(substr($projectName, 0, 1)); } function app_logo_url(): string { return app_setting('brand_logo_url'); } function app_favicon_url(): string { return app_setting('brand_favicon_url'); } function set_flash(string $type, string $message): void { $_SESSION['flash'] = [ 'type' => $type, 'message' => $message, ]; } function consume_flash(): ?array { if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) { return null; } $flash = $_SESSION['flash']; unset($_SESSION['flash']); return $flash; } function empty_dashboard_summary(): array { return [ 'total_services' => 0, 'overdue_count' => 0, 'due_soon_count' => 0, 'last_update' => null, ]; } function dashboard_summary(): array { $stmt = db()->query( "SELECT SUM(CASE WHEN item_type = 'service' THEN 1 ELSE 0 END) AS total_services, SUM(CASE WHEN item_type = 'service' AND next_due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_count, SUM(CASE WHEN item_type = 'service' AND next_due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY) THEN 1 ELSE 0 END) AS due_soon_count, MAX(CASE WHEN item_type = 'service' THEN updated_at ELSE NULL END) AS last_update FROM service_tracker_items" ); $row = $stmt->fetch() ?: []; return [ 'total_services' => (int) ($row['total_services'] ?? 0), 'overdue_count' => (int) ($row['overdue_count'] ?? 0), 'due_soon_count' => (int) ($row['due_soon_count'] ?? 0), 'last_update' => $row['last_update'] ?? null, ]; } function dashboard_summary_for_user(int $userId): array { if ($userId <= 0) { return empty_dashboard_summary(); } $stmt = db()->prepare( "SELECT COUNT(*) AS total_services, SUM(CASE WHEN next_due_date < CURDATE() THEN 1 ELSE 0 END) AS overdue_count, SUM(CASE WHEN next_due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 14 DAY) THEN 1 ELSE 0 END) AS due_soon_count, MAX(updated_at) AS last_update FROM service_tracker_items WHERE item_type = 'service' AND user_id = :user_id" ); $stmt->execute([ ':user_id' => $userId, ]); $row = $stmt->fetch() ?: []; return [ 'total_services' => (int) ($row['total_services'] ?? 0), 'overdue_count' => (int) ($row['overdue_count'] ?? 0), 'due_soon_count' => (int) ($row['due_soon_count'] ?? 0), 'last_update' => $row['last_update'] ?? null, ]; } function fetch_services(int $limit = 100): array { $stmt = db()->prepare( 'SELECT * FROM service_tracker_items WHERE item_type = :type ORDER BY next_due_date IS NULL, next_due_date ASC, updated_at DESC LIMIT :limit' ); $stmt->bindValue(':type', 'service'); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } function fetch_services_for_user(int $userId, int $limit = 100): array { if ($userId <= 0) { return []; } $stmt = db()->prepare( 'SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id ORDER BY next_due_date IS NULL, next_due_date ASC, updated_at DESC LIMIT :limit' ); $stmt->bindValue(':type', 'service'); $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } function fetch_recent_services(int $limit = 5): array { $stmt = db()->prepare( 'SELECT * FROM service_tracker_items WHERE item_type = :type ORDER BY created_at DESC LIMIT :limit' ); $stmt->bindValue(':type', 'service'); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } function fetch_recent_services_for_user(int $userId, int $limit = 5): array { if ($userId <= 0) { return []; } $stmt = db()->prepare( 'SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id ORDER BY created_at DESC LIMIT :limit' ); $stmt->bindValue(':type', 'service'); $stmt->bindValue(':user_id', $userId, PDO::PARAM_INT); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } function fetch_service_by_id(int $id): ?array { $stmt = db()->prepare('SELECT * FROM service_tracker_items WHERE item_type = :type AND id = :id LIMIT 1'); $stmt->execute([ ':type' => 'service', ':id' => $id, ]); $row = $stmt->fetch(); return $row ?: null; } function fetch_service_by_id_for_user(int $userId, int $id): ?array { if ($userId <= 0 || $id <= 0) { return null; } $stmt = db()->prepare('SELECT * FROM service_tracker_items WHERE item_type = :type AND user_id = :user_id AND id = :id LIMIT 1'); $stmt->execute([ ':type' => 'service', ':user_id' => $userId, ':id' => $id, ]); $row = $stmt->fetch(); return $row ?: null; } function due_state(?string $dateValue): array { if (!$dateValue) { return [ 'label' => 'Belum dijadwalkan', 'class' => 'status-neutral', 'tone' => 'secondary', 'description' => 'Atur tanggal servis berikutnya untuk mulai dipantau.', ]; } $today = new DateTimeImmutable('today'); $dueDate = new DateTimeImmutable($dateValue); $diff = (int) $today->diff($dueDate)->format('%r%a'); if ($diff < 0) { $days = abs($diff); return [ 'label' => 'Terlambat ' . $days . ' hari', 'class' => 'status-overdue', 'tone' => 'danger', 'description' => 'Sudah melewati jadwal, sebaiknya segera ditindaklanjuti.', ]; } if ($diff <= 14) { return [ 'label' => 'Jatuh tempo ' . $diff . ' hari lagi', 'class' => 'status-soon', 'tone' => 'warning', 'description' => 'Sudah dekat dengan jadwal berikutnya, cocok masuk prioritas.', ]; } return [ 'label' => 'Aman ' . $diff . ' hari lagi', 'class' => 'status-ok', 'tone' => 'success', 'description' => 'Masih aman, tapi sudah tercatat untuk pengingat berikutnya.', ]; } function validate_service_payload(array $data): array { $catalog = service_catalog(); $vehicleName = trim((string) ($data['vehicle_name'] ?? '')); $vehicleCategory = trim((string) ($data['vehicle_category'] ?? '')); $plateNumber = strtoupper(trim((string) ($data['plate_number'] ?? ''))); $serviceName = trim((string) ($data['service_name'] ?? '')); $lastServiceDate = trim((string) ($data['last_service_date'] ?? '')); $intervalDays = (int) ($data['reminder_interval_days'] ?? 0); $odometerKm = trim((string) ($data['odometer_km'] ?? '')); $notes = trim((string) ($data['notes'] ?? '')); $errors = []; if ($vehicleName === '' || strlen($vehicleName) < 3) { $errors['vehicle_name'] = 'Nama kendaraan minimal 3 karakter.'; } if (!in_array($vehicleCategory, ['Motor', 'Mobil'], true)) { $errors['vehicle_category'] = 'Pilih jenis kendaraan.'; } if ($serviceName === '') { $errors['service_name'] = 'Pilih item servis utama.'; } if ($lastServiceDate === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $lastServiceDate)) { $errors['last_service_date'] = 'Tanggal servis terakhir wajib diisi.'; } if ($intervalDays < 14 || $intervalDays > 365) { $errors['reminder_interval_days'] = 'Interval pengingat harus 14–365 hari.'; } if ($odometerKm !== '' && (!ctype_digit($odometerKm) || (int) $odometerKm < 0)) { $errors['odometer_km'] = 'Kilometer harus berupa angka positif.'; } $nextDueDate = null; if (!isset($errors['last_service_date']) && !isset($errors['reminder_interval_days'])) { try { $nextDueDate = (new DateTimeImmutable($lastServiceDate)) ->modify('+' . $intervalDays . ' days') ->format('Y-m-d'); } catch (Throwable $exception) { $errors['last_service_date'] = 'Tanggal servis terakhir tidak valid.'; } } return [ 'errors' => $errors, 'clean' => [ 'vehicle_name' => $vehicleName, 'vehicle_category' => $vehicleCategory, 'plate_number' => $plateNumber, 'service_name' => $serviceName, 'last_service_date' => $lastServiceDate, 'reminder_interval_days' => $intervalDays, 'next_due_date' => $nextDueDate, 'odometer_km' => $odometerKm === '' ? null : (int) $odometerKm, 'notes' => $notes, 'suggested_days' => $catalog[$serviceName] ?? null, ], ]; } function create_service(array $payload): int { $userId = current_user_id(); if ($userId <= 0) { throw new RuntimeException('User must be logged in before creating a service record.'); } $stmt = db()->prepare( 'INSERT INTO service_tracker_items ( item_type, user_id, vehicle_name, vehicle_category, plate_number, service_name, last_service_date, reminder_interval_days, next_due_date, odometer_km, notes ) VALUES ( :item_type, :user_id, :vehicle_name, :vehicle_category, :plate_number, :service_name, :last_service_date, :reminder_interval_days, :next_due_date, :odometer_km, :notes )' ); $stmt->execute([ ':item_type' => 'service', ':user_id' => $userId, ':vehicle_name' => $payload['vehicle_name'], ':vehicle_category' => $payload['vehicle_category'], ':plate_number' => $payload['plate_number'], ':service_name' => $payload['service_name'], ':last_service_date' => $payload['last_service_date'], ':reminder_interval_days' => $payload['reminder_interval_days'], ':next_due_date' => $payload['next_due_date'], ':odometer_km' => $payload['odometer_km'], ':notes' => $payload['notes'], ]); return (int) db()->lastInsertId(); } function is_admin_logged_in(): bool { return !empty($_SESSION['is_admin_logged_in']); } function admin_username(): string { return app_env('ADMIN_PORTAL_USER', 'admin'); } function admin_default_password_hint(): string { return app_env('ADMIN_PORTAL_PASSWORD_HINT', 'servis123!'); } function admin_password_hash(): string { $storedHash = app_setting('admin_password_hash'); if ($storedHash !== '') { return $storedHash; } return app_env('ADMIN_PORTAL_PASSWORD_HASH', '$2y$10$riyTXC1R9fEPRr2T18rxUuycZVTjpQVvCDOmRQD4ID1EVWw9fyDHC'); } function admin_has_custom_password(): bool { return app_setting('admin_password_hash') !== ''; } function save_admin_password(string $password): void { save_setting('admin_password_hash', password_hash($password, PASSWORD_DEFAULT)); } function verify_admin_login(string $username, string $password): bool { $expectedUser = admin_username(); $normalizedUsername = trim($username); return hash_equals($expectedUser, $normalizedUsername) && password_verify($password, admin_password_hash()); }