39888-vm/lending_app.php
2026-05-04 10:15:32 +00:00

603 lines
18 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function text_length(string $value): int
{
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
}
function project_name(): string
{
$name = $_SERVER['PROJECT_NAME'] ?? getenv('PROJECT_NAME') ?: '';
$name = trim((string) $name);
return $name !== '' ? $name : 'Sistem Peminjaman Barang';
}
function project_description(string $fallback = ''): string
{
$description = $_SERVER['PROJECT_DESCRIPTION'] ?? getenv('PROJECT_DESCRIPTION') ?: '';
$description = trim((string) $description);
return $description !== '' ? $description : $fallback;
}
function project_image_url(): string
{
$url = $_SERVER['PROJECT_IMAGE_URL'] ?? getenv('PROJECT_IMAGE_URL') ?: '';
return trim((string) $url);
}
function render_head_meta(string $pageTitle, string $fallbackDescription = ''): string
{
$title = trim($pageTitle);
$project = project_name();
if ($project !== '' && stripos($title, $project) === false) {
$title .= ' | ' . $project;
}
$projectDescription = project_description($fallbackDescription);
$projectImageUrl = project_image_url();
ob_start();
?>
<title><?= e($title) ?></title>
<?php if ($projectDescription !== ''): ?>
<meta name="description" content="<?= e($projectDescription) ?>" />
<meta property="og:description" content="<?= e($projectDescription) ?>" />
<meta property="twitter:description" content="<?= e($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl !== ''): ?>
<meta property="og:image" content="<?= e($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= e($projectImageUrl) ?>" />
<?php endif; ?>
<?php
return (string) ob_get_clean();
}
function asset_version(string $relativePath): string
{
$fullPath = __DIR__ . '/' . ltrim($relativePath, '/');
if (is_file($fullPath)) {
$mtime = @filemtime($fullPath);
if ($mtime !== false) {
return (string) $mtime;
}
}
return (string) time();
}
function set_flash(string $type, string $message): void
{
$_SESSION['app_flash'][] = [
'type' => $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 1999.';
}
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;
}