$type, 'message' => $message, ]; } function pull_flashes(): array { $flashes = $_SESSION['app_flash'] ?? []; unset($_SESSION['app_flash']); return is_array($flashes) ? $flashes : []; } function lending_ensure_schema(): void { static $schemaReady = false; if ($schemaReady) { return; } db()->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS item_loans ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, borrower_name VARCHAR(120) NOT NULL, borrower_contact VARCHAR(120) DEFAULT NULL, department VARCHAR(120) DEFAULT NULL, item_name VARCHAR(150) NOT NULL, item_code VARCHAR(60) DEFAULT NULL, quantity INT UNSIGNED NOT NULL DEFAULT 1, loaned_at DATE NOT NULL, due_at DATE NOT NULL, returned_at DATETIME DEFAULT NULL, return_condition VARCHAR(40) DEFAULT NULL, notes TEXT DEFAULT NULL, return_notes TEXT DEFAULT NULL, last_reminder_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), INDEX idx_item_loans_due_returned (due_at, returned_at), INDEX idx_item_loans_created (created_at), INDEX idx_item_loans_item (item_name), INDEX idx_item_loans_borrower (borrower_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL); $schemaReady = true; } function default_loan_form(): array { return [ 'borrower_name' => '', 'borrower_contact' => '', 'department' => '', 'item_name' => '', 'item_code' => '', 'quantity' => '1', 'loaned_at' => date('Y-m-d'), 'due_at' => date('Y-m-d', strtotime('+7 days')), 'notes' => '', ]; } function default_return_form(): array { return [ 'returned_at' => date('Y-m-d\TH:i'), 'return_condition' => 'baik', 'return_notes' => '', ]; } function is_valid_date(string $value): bool { $date = DateTimeImmutable::createFromFormat('Y-m-d', $value); return $date instanceof DateTimeImmutable && $date->format('Y-m-d') === $value; } function is_valid_datetime_local(string $value): bool { $dateTime = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value); return $dateTime instanceof DateTimeImmutable && $dateTime->format('Y-m-d\TH:i') === $value; } function validate_loan_input(array $input): array { $values = default_loan_form(); $allowedKeys = array_keys($values); foreach ($allowedKeys as $key) { if (isset($input[$key])) { $values[$key] = trim((string) $input[$key]); } } $errors = []; if ($values['borrower_name'] === '') { $errors['borrower_name'] = 'Nama peminjam wajib diisi.'; } elseif (text_length($values['borrower_name']) > 120) { $errors['borrower_name'] = 'Nama peminjam maksimal 120 karakter.'; } if ($values['borrower_contact'] !== '' && text_length($values['borrower_contact']) > 120) { $errors['borrower_contact'] = 'Kontak maksimal 120 karakter.'; } if ($values['department'] !== '' && text_length($values['department']) > 120) { $errors['department'] = 'Divisi maksimal 120 karakter.'; } if ($values['item_name'] === '') { $errors['item_name'] = 'Nama barang wajib diisi.'; } elseif (text_length($values['item_name']) > 150) { $errors['item_name'] = 'Nama barang maksimal 150 karakter.'; } if ($values['item_code'] !== '' && text_length($values['item_code']) > 60) { $errors['item_code'] = 'Kode barang maksimal 60 karakter.'; } if ($values['quantity'] === '' || filter_var($values['quantity'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 999]]) === false) { $errors['quantity'] = 'Jumlah harus berupa angka 1–999.'; } if (!is_valid_date($values['loaned_at'])) { $errors['loaned_at'] = 'Tanggal pinjam tidak valid.'; } if (!is_valid_date($values['due_at'])) { $errors['due_at'] = 'Tanggal jatuh tempo tidak valid.'; } if (!isset($errors['loaned_at']) && !isset($errors['due_at'])) { $loanedAt = new DateTimeImmutable($values['loaned_at']); $dueAt = new DateTimeImmutable($values['due_at']); if ($dueAt < $loanedAt) { $errors['due_at'] = 'Jatuh tempo harus sama atau setelah tanggal pinjam.'; } } if ($values['notes'] !== '' && text_length($values['notes']) > 1000) { $errors['notes'] = 'Catatan maksimal 1000 karakter.'; } return [$values, $errors]; } function validate_return_input(array $input, array $loan): array { $values = default_return_form(); foreach (array_keys($values) as $key) { if (isset($input[$key])) { $values[$key] = trim((string) $input[$key]); } } $errors = []; $allowedConditions = ['baik', 'catatan', 'rusak']; if (!is_valid_datetime_local($values['returned_at'])) { $errors['returned_at'] = 'Waktu pengembalian tidak valid.'; } if (!in_array($values['return_condition'], $allowedConditions, true)) { $errors['return_condition'] = 'Pilih kondisi barang yang tersedia.'; } if ($values['return_notes'] !== '' && text_length($values['return_notes']) > 1000) { $errors['return_notes'] = 'Catatan pengembalian maksimal 1000 karakter.'; } if (!isset($errors['returned_at'])) { $returnedAt = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $values['returned_at']); $loanedAt = new DateTimeImmutable($loan['loaned_at']); if ($returnedAt instanceof DateTimeImmutable && $returnedAt->format('Y-m-d') < $loanedAt->format('Y-m-d')) { $errors['returned_at'] = 'Tanggal kembali tidak boleh lebih awal dari tanggal pinjam.'; } } return [$values, $errors]; } function create_loan(array $values): int { lending_ensure_schema(); $statement = db()->prepare( 'INSERT INTO item_loans ( borrower_name, borrower_contact, department, item_name, item_code, quantity, loaned_at, due_at, notes ) VALUES ( :borrower_name, :borrower_contact, :department, :item_name, :item_code, :quantity, :loaned_at, :due_at, :notes )' ); $statement->bindValue(':borrower_name', $values['borrower_name']); $statement->bindValue(':borrower_contact', $values['borrower_contact'] !== '' ? $values['borrower_contact'] : null, PDO::PARAM_STR); $statement->bindValue(':department', $values['department'] !== '' ? $values['department'] : null, PDO::PARAM_STR); $statement->bindValue(':item_name', $values['item_name']); $statement->bindValue(':item_code', $values['item_code'] !== '' ? $values['item_code'] : null, PDO::PARAM_STR); $statement->bindValue(':quantity', (int) $values['quantity'], PDO::PARAM_INT); $statement->bindValue(':loaned_at', $values['loaned_at']); $statement->bindValue(':due_at', $values['due_at']); $statement->bindValue(':notes', $values['notes'] !== '' ? $values['notes'] : null, PDO::PARAM_STR); $statement->execute(); return (int) db()->lastInsertId(); } function record_return(int $loanId, array $values): void { lending_ensure_schema(); $returnedAt = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $values['returned_at']); $statement = db()->prepare( 'UPDATE item_loans SET returned_at = :returned_at, return_condition = :return_condition, return_notes = :return_notes WHERE id = :id AND returned_at IS NULL' ); $statement->bindValue(':returned_at', $returnedAt instanceof DateTimeImmutable ? $returnedAt->format('Y-m-d H:i:s') : null, PDO::PARAM_STR); $statement->bindValue(':return_condition', $values['return_condition']); $statement->bindValue(':return_notes', $values['return_notes'] !== '' ? $values['return_notes'] : null, PDO::PARAM_STR); $statement->bindValue(':id', $loanId, PDO::PARAM_INT); $statement->execute(); } function mark_reminder_sent(int $loanId): void { lending_ensure_schema(); $statement = db()->prepare( 'UPDATE item_loans SET last_reminder_at = NOW() WHERE id = :id AND returned_at IS NULL' ); $statement->bindValue(':id', $loanId, PDO::PARAM_INT); $statement->execute(); } function dashboard_counts(): array { lending_ensure_schema(); $statement = db()->query( 'SELECT COUNT(*) AS total_loans, SUM(CASE WHEN returned_at IS NULL THEN 1 ELSE 0 END) AS active_loans, SUM(CASE WHEN returned_at IS NULL AND due_at < CURDATE() THEN 1 ELSE 0 END) AS overdue_loans, SUM(CASE WHEN returned_at IS NULL AND due_at = CURDATE() THEN 1 ELSE 0 END) AS due_today_loans, SUM(CASE WHEN returned_at IS NOT NULL THEN 1 ELSE 0 END) AS returned_loans FROM item_loans' ); $row = $statement->fetch() ?: []; return [ 'total_loans' => (int) ($row['total_loans'] ?? 0), 'active_loans' => (int) ($row['active_loans'] ?? 0), 'overdue_loans' => (int) ($row['overdue_loans'] ?? 0), 'due_today_loans' => (int) ($row['due_today_loans'] ?? 0), 'returned_loans' => (int) ($row['returned_loans'] ?? 0), ]; } function fetch_priority_loans(int $limit = 4): array { lending_ensure_schema(); $limit = max(1, min($limit, 12)); $statement = db()->prepare( 'SELECT * FROM item_loans WHERE returned_at IS NULL AND due_at <= DATE_ADD(CURDATE(), INTERVAL 2 DAY) ORDER BY CASE WHEN due_at < CURDATE() THEN 0 ELSE 1 END ASC, due_at ASC, created_at DESC LIMIT ' . $limit ); $statement->execute(); return array_map('decorate_loan', $statement->fetchAll() ?: []); } function fetch_loans(string $filter = 'all', string $search = '', int $limit = 50): array { lending_ensure_schema(); $allowedFilters = ['all', 'active', 'overdue', 'returned']; if (!in_array($filter, $allowedFilters, true)) { $filter = 'all'; } $limit = max(1, min($limit, 200)); $params = []; $conditions = []; if ($filter === 'active') { $conditions[] = 'returned_at IS NULL AND due_at >= CURDATE()'; } elseif ($filter === 'overdue') { $conditions[] = 'returned_at IS NULL AND due_at < CURDATE()'; } elseif ($filter === 'returned') { $conditions[] = 'returned_at IS NOT NULL'; } $search = trim($search); if ($search !== '') { $conditions[] = '(borrower_name LIKE :term OR item_name LIKE :term OR COALESCE(item_code, \'\') LIKE :term OR COALESCE(department, \'\') LIKE :term)'; $params[':term'] = '%' . $search . '%'; } $sql = 'SELECT * FROM item_loans'; if ($conditions !== []) { $sql .= ' WHERE ' . implode(' AND ', $conditions); } $sql .= ' ORDER BY CASE WHEN returned_at IS NULL AND due_at < CURDATE() THEN 0 ELSE 1 END ASC, CASE WHEN returned_at IS NULL THEN 0 ELSE 1 END ASC, due_at ASC, created_at DESC LIMIT ' . $limit; $statement = db()->prepare($sql); foreach ($params as $param => $value) { $statement->bindValue($param, $value, PDO::PARAM_STR); } $statement->execute(); return array_map('decorate_loan', $statement->fetchAll() ?: []); } function fetch_loan(int $loanId): ?array { lending_ensure_schema(); $statement = db()->prepare('SELECT * FROM item_loans WHERE id = :id LIMIT 1'); $statement->bindValue(':id', $loanId, PDO::PARAM_INT); $statement->execute(); $loan = $statement->fetch(); return $loan ? decorate_loan($loan) : null; } function loan_reference(int $loanId): string { return 'PJM-' . str_pad((string) $loanId, 5, '0', STR_PAD_LEFT); } function loan_status_key(array $loan): string { if (!empty($loan['returned_at'])) { return 'returned'; } $today = new DateTimeImmutable('today'); $dueAt = new DateTimeImmutable($loan['due_at']); if ($dueAt < $today) { return 'overdue'; } return 'active'; } function loan_status_label(array $loan): string { return match (loan_status_key($loan)) { 'overdue' => 'Terlambat', 'returned' => 'Dikembalikan', default => 'Aktif', }; } function loan_status_badge_class(array $loan): string { return match (loan_status_key($loan)) { 'overdue' => 'badge-status-overdue', 'returned' => 'badge-status-returned', default => 'badge-status-active', }; } function format_date_id(?string $value, bool $withTime = false): string { if (!$value) { return '—'; } try { $date = new DateTimeImmutable($value); } catch (Throwable $exception) { return '—'; } $months = [ 1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'Mei', 6 => 'Jun', 7 => 'Jul', 8 => 'Agu', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Des', ]; $formatted = $date->format('d') . ' ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y'); if ($withTime) { $formatted .= ' · ' . $date->format('H:i'); } return $formatted; } function loan_delay_days(array $loan): int { $dueAt = new DateTimeImmutable($loan['due_at']); if (!empty($loan['returned_at'])) { $endDate = (new DateTimeImmutable($loan['returned_at']))->setTime(0, 0); } else { $endDate = new DateTimeImmutable('today'); } return (int) $dueAt->diff($endDate)->format('%r%a'); } function loan_due_note(array $loan): string { $delayDays = loan_delay_days($loan); if (loan_status_key($loan) === 'returned') { if ($delayDays > 0) { return 'Dikembalikan ' . $delayDays . ' hari lewat tempo'; } return 'Dikembalikan tepat waktu'; } if ($delayDays > 0) { return 'Terlambat ' . $delayDays . ' hari'; } if ($delayDays === 0) { return 'Jatuh tempo hari ini'; } return 'Jatuh tempo ' . abs($delayDays) . ' hari lagi'; } function loan_condition_label(?string $value): string { return match ($value) { 'catatan' => 'Ada catatan', 'rusak' => 'Rusak', 'baik' => 'Baik', default => '—', }; } function build_reminder_message(array $loan): string { $name = trim((string) ($loan['borrower_name'] ?? '')); $item = trim((string) ($loan['item_name'] ?? '')); $qty = (int) ($loan['quantity'] ?? 1); $dueAt = format_date_id((string) ($loan['due_at'] ?? '')); $reference = loan_reference((int) ($loan['id'] ?? 0)); return "Halo {$name}, ini pengingat pengembalian barang {$item} (qty {$qty}) dengan jatuh tempo {$dueAt}. Mohon dikembalikan ke tim operasional. Ref {$reference}."; } function decorate_loan(array $loan): array { $loan['status_key'] = loan_status_key($loan); $loan['status_label'] = loan_status_label($loan); $loan['status_badge_class'] = loan_status_badge_class($loan); $loan['reference'] = loan_reference((int) $loan['id']); $loan['due_note'] = loan_due_note($loan); $loan['loaned_at_label'] = format_date_id($loan['loaned_at'] ?? null); $loan['due_at_label'] = format_date_id($loan['due_at'] ?? null); $loan['returned_at_label'] = format_date_id($loan['returned_at'] ?? null, true); $loan['last_reminder_at_label'] = format_date_id($loan['last_reminder_at'] ?? null, true); $loan['return_condition_label'] = loan_condition_label($loan['return_condition'] ?? null); $loan['reminder_message'] = build_reminder_message($loan); $loan['is_active'] = $loan['status_key'] === 'active'; $loan['is_overdue'] = $loan['status_key'] === 'overdue'; $loan['is_returned'] = $loan['status_key'] === 'returned'; return $loan; }