peminjaman barangg

This commit is contained in:
Flatlogic Bot 2026-05-04 10:15:32 +00:00
parent acc7ef489d
commit b75cdfbb3a
6 changed files with 1900 additions and 527 deletions

View File

@ -1,403 +1,611 @@
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;
:root {
--app-bg: #f4f5f7;
--app-surface: #ffffff;
--app-surface-muted: #f8f9fb;
--app-border: #d8dde6;
--app-border-strong: #c6ced8;
--app-text: #111827;
--app-muted: #5f6b7a;
--app-primary: #204f96;
--app-primary-dark: #183d73;
--app-success-bg: #eef6f0;
--app-success-text: #1f5132;
--app-warning-bg: #fff7e6;
--app-warning-text: #7a5316;
--app-danger-bg: #fff1f0;
--app-danger-text: #8e2f2b;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.05);
--shadow-md: 0 10px 24px rgba(17, 24, 39, 0.05);
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.main-wrapper {
display: flex;
html {
scroll-behavior: smooth;
}
body.app-body {
margin: 0;
background: var(--app-bg);
color: var(--app-text);
font-family: var(--font-sans);
line-height: 1.5;
}
a {
color: var(--app-primary);
text-decoration: none;
}
a:hover {
color: var(--app-primary-dark);
}
.app-container {
width: min(1180px, calc(100% - 2rem));
}
.app-navbar {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(17, 24, 39, 0.08);
}
.navbar-brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
color: var(--app-text);
}
.navbar-brand:hover {
color: var(--app-text);
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.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;
}
.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;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5;
width: 2.4rem;
height: 2.4rem;
border-radius: 0.85rem;
border: 1px solid var(--app-border);
background: var(--app-surface-muted);
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);
font-weight: 700;
letter-spacing: 0.04em;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
.navbar-brand strong {
font-size: 0.98rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
.navbar-brand small {
font-size: 0.76rem;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
.section-card {
background: var(--app-surface);
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
.hero-card {
padding: 1.5rem;
}
.hero-title {
font-size: clamp(1.9rem, 3vw, 2.75rem);
line-height: 1.08;
letter-spacing: -0.04em;
margin: 0;
}
.hero-copy {
color: var(--app-muted);
font-size: 1rem;
max-width: 56rem;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.32rem 0.65rem;
border-radius: 999px;
background: var(--app-surface-muted);
border: 1px solid var(--app-border);
color: var(--app-muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.hero-summary-list {
display: grid;
grid-template-columns: 1fr;
gap: 0.85rem;
}
.hero-summary-list > div {
background: var(--app-surface-muted);
border: 1px solid var(--app-border);
border-radius: var(--radius-md);
padding: 0.95rem 1rem;
}
.summary-label {
display: block;
color: var(--app-muted);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.hero-summary-list strong {
display: block;
margin-top: 0.25rem;
font-size: 1.2rem;
letter-spacing: -0.03em;
}
.app-alert {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.alert-success.app-alert {
background: var(--app-success-bg);
color: var(--app-success-text);
}
.alert-warning.app-alert {
background: var(--app-warning-bg);
color: var(--app-warning-text);
}
.alert-secondary.app-alert {
background: var(--app-surface-muted);
color: var(--app-text);
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
font-size: 1.08rem;
letter-spacing: -0.03em;
}
.section-header p {
margin: 0.3rem 0 0;
color: var(--app-muted);
font-size: 0.92rem;
}
.section-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
border: 1px solid var(--app-border);
background: var(--app-surface-muted);
color: var(--app-muted);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.form-label {
font-size: 0.84rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.form-control,
.form-select {
min-height: 2.85rem;
border-radius: 0.8rem;
border: 1px solid var(--app-border);
background: #fff;
color: var(--app-text);
padding: 0.7rem 0.85rem;
box-shadow: none;
}
textarea.form-control {
min-height: 7.25rem;
resize: vertical;
}
.form-control::placeholder,
.form-select::placeholder {
color: #8a95a4;
}
.form-control:focus,
.form-select:focus,
.filter-pill:focus,
.btn-app:focus,
.search-form .form-control:focus {
border-color: rgba(32, 79, 150, 0.48);
box-shadow: 0 0 0 0.22rem rgba(32, 79, 150, 0.12);
}
.form-helper {
color: var(--app-muted);
font-size: 0.88rem;
}
.btn-app {
border-radius: 0.8rem;
padding: 0.7rem 1rem;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: -0.01em;
box-shadow: none;
}
.btn-primary.btn-app {
background: var(--app-primary);
border-color: var(--app-primary);
}
.btn-primary.btn-app:hover,
.btn-primary.btn-app:focus {
background: var(--app-primary-dark);
border-color: var(--app-primary-dark);
}
.btn-outline-secondary.btn-app {
border-color: var(--app-border-strong);
color: var(--app-text);
background: #fff;
}
.btn-outline-secondary.btn-app:hover,
.btn-outline-secondary.btn-app:focus {
background: var(--app-surface-muted);
border-color: var(--app-border-strong);
color: var(--app-text);
}
.priority-card {
border: 1px solid var(--app-border);
border-radius: var(--radius-md);
background: var(--app-surface-muted);
padding: 1rem;
}
.priority-card-overdue {
border-color: rgba(142, 47, 43, 0.22);
background: var(--app-danger-bg);
}
.priority-card h3 {
margin: 0.25rem 0 0.35rem;
font-size: 1rem;
letter-spacing: -0.02em;
}
.priority-card p {
color: var(--app-muted);
}
.priority-actions,
.table-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.9rem;
}
.empty-state {
padding: 2.25rem 1.2rem;
text-align: center;
border: 1px dashed var(--app-border);
border-radius: var(--radius-md);
background: var(--app-surface-muted);
}
.compact-empty-state {
padding: 1.6rem 1rem;
}
.empty-state h3 {
margin: 0 0 0.45rem;
font-size: 1rem;
letter-spacing: -0.02em;
}
.empty-state p {
margin: 0;
color: var(--app-muted);
}
.search-form {
display: flex;
gap: 0.65rem;
width: min(100%, 28rem);
}
.filter-pills {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.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;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.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;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.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;
.filter-pill {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.72rem 0.9rem;
min-width: 8.75rem;
border-radius: 0.85rem;
border: 1px solid var(--app-border);
color: var(--app-text);
background: var(--app-surface-muted);
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.header-links {
display: flex;
.filter-pill:hover {
border-color: var(--app-border-strong);
transform: translateY(-1px);
}
.filter-pill strong {
font-size: 0.95rem;
letter-spacing: -0.03em;
}
.filter-pill.is-active {
background: #eef3fb;
border-color: rgba(32, 79, 150, 0.2);
color: var(--app-primary-dark);
}
.app-table {
--bs-table-bg: transparent;
--bs-table-striped-bg: transparent;
--bs-table-hover-bg: rgba(17, 24, 39, 0.03);
margin-top: 0.25rem;
}
.app-table thead th {
color: var(--app-muted);
border-bottom: 1px solid var(--app-border);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 0.9rem 0.8rem;
white-space: nowrap;
}
.app-table tbody td {
border-bottom: 1px solid rgba(17, 24, 39, 0.06);
padding: 0.95rem 0.8rem;
vertical-align: middle;
}
.loan-row-overdue {
background: rgba(255, 241, 240, 0.7);
}
.loan-reference {
color: var(--app-muted);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.9rem;
padding: 0.35rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.01em;
border: 1px solid transparent;
}
.badge-status-active {
background: #eef4ff;
color: var(--app-primary-dark);
border-color: rgba(32, 79, 150, 0.16);
}
.badge-status-overdue {
background: var(--app-danger-bg);
color: var(--app-danger-text);
border-color: rgba(142, 47, 43, 0.18);
}
.badge-status-returned {
background: var(--app-success-bg);
color: var(--app-success-text);
border-color: rgba(31, 81, 50, 0.16);
}
.badge-status-neutral {
background: var(--app-surface-muted);
color: var(--app-muted);
border-color: var(--app-border);
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
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);
.detail-grid div {
background: var(--app-surface-muted);
border: 1px solid var(--app-border);
border-radius: var(--radius-md);
padding: 0.9rem 0.95rem;
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
.detail-grid dt {
margin: 0 0 0.35rem;
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--app-muted);
}
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
.detail-grid dd {
margin: 0;
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.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);
.message-preview {
border: 1px solid var(--app-border);
border-radius: var(--radius-md);
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: var(--app-surface-muted);
color: var(--app-text);
white-space: pre-wrap;
}
.history-table {
width: 100%;
.timeline-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.85rem;
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
.timeline-list li {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.9rem 0.95rem;
border: 1px solid var(--app-border);
border-radius: var(--radius-md);
background: var(--app-surface-muted);
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
.timeline-list strong {
font-size: 0.9rem;
letter-spacing: -0.01em;
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
.timeline-list span {
color: var(--app-muted);
font-size: 0.9rem;
}
.no-messages {
text-align: center;
color: #777;
}
.loan-detail-summary {
min-width: min(100%, 20rem);
}
.not-found-card {
max-width: 42rem;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.toast {
background: rgba(17, 24, 39, 0.92);
color: #fff;
border-radius: 0.9rem;
}
.app-footer {
color: var(--app-muted);
font-size: 0.88rem;
padding: 0 0 1.5rem;
}
.app-footer a {
font-weight: 600;
}
@media (max-width: 991.98px) {
.app-container {
width: min(100% - 1rem, 100%);
}
.search-form {
width: 100%;
flex-direction: column;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 767.98px) {
.app-navbar {
padding-top: 0.7rem;
padding-bottom: 0.7rem;
}
.hero-card,
.section-card {
padding: 1rem;
}
.hero-title {
font-size: 1.75rem;
}
.app-table thead {
display: none;
}
.app-table,
.app-table tbody,
.app-table tr,
.app-table td {
display: block;
width: 100%;
}
.app-table tbody tr {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: var(--radius-md);
margin-bottom: 0.85rem;
overflow: hidden;
box-shadow: var(--shadow-sm);
background: #fff;
}
.app-table tbody td {
padding: 0.85rem 1rem;
border-bottom: 1px solid rgba(17, 24, 39, 0.05);
}
.app-table tbody td:last-child {
border-bottom: none;
}
.table-actions {
justify-content: flex-start;
}
}

View File

@ -1,39 +1,52 @@
document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatMessages = document.getElementById('chat-messages');
const toastElement = document.getElementById('appToast');
const toastBody = toastElement ? toastElement.querySelector('.toast-body') : null;
const appToast = toastElement && window.bootstrap ? new bootstrap.Toast(toastElement, { delay: 2600 }) : null;
const appendMessage = (text, sender) => {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
const showToast = (message) => {
if (!toastBody || !appToast) return;
toastBody.textContent = message;
appToast.show();
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
document.querySelectorAll('.copy-trigger').forEach((button) => {
button.addEventListener('click', async () => {
const text = button.getAttribute('data-copy-text') || '';
const successMessage = button.getAttribute('data-copy-success') || 'Teks berhasil disalin.';
appendMessage(message, 'visitor');
chatInput.value = '';
if (!text) return;
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');
}
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const temp = document.createElement('textarea');
temp.value = text;
temp.setAttribute('readonly', 'readonly');
temp.style.position = 'absolute';
temp.style.left = '-9999px';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
}
showToast(successMessage);
} catch (error) {
showToast('Gagal menyalin otomatis. Salin manual dari halaman detail.');
}
});
});
document.querySelectorAll('.auto-dismiss-alert').forEach((alertElement) => {
window.setTimeout(() => {
if (!window.bootstrap) return;
const instance = bootstrap.Alert.getOrCreateInstance(alertElement);
instance.close();
}, 5200);
});
const firstInvalidField = document.querySelector('.is-invalid');
if (firstInvalidField instanceof HTMLElement) {
firstInvalidField.focus();
}
});

26
healthz.php Normal file
View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lending_app.php';
header('Content-Type: application/json; charset=utf-8');
try {
lending_ensure_schema();
$statement = db()->query('SELECT 1 AS healthy');
$healthy = (int) ($statement ? $statement->fetchColumn() : 0) === 1;
http_response_code($healthy ? 200 : 500);
echo json_encode([
'status' => $healthy ? 'ok' : 'error',
'database' => $healthy ? 'ok' : 'error',
'checked_at' => gmdate('c'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
} catch (Throwable $exception) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'database' => 'error',
'checked_at' => gmdate('c'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}

458
index.php
View File

@ -1,150 +1,338 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/lending_app.php';
lending_ensure_schema();
$allowedFilters = ['all', 'active', 'overdue', 'returned'];
$filter = isset($_GET['status']) ? trim((string) $_GET['status']) : 'all';
if (!in_array($filter, $allowedFilters, true)) {
$filter = 'all';
}
$search = trim((string) ($_GET['q'] ?? ''));
$formValues = default_loan_form();
$formErrors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'create_loan') {
[$formValues, $formErrors] = validate_loan_input($_POST);
if ($formErrors === []) {
$loanId = create_loan($formValues);
set_flash('success', 'Peminjaman baru berhasil dibuat. Data detail siap ditindaklanjuti.');
header('Location: loan.php?id=' . $loanId);
exit;
}
}
$stats = dashboard_counts();
$priorityLoans = fetch_priority_loans(4);
$loans = fetch_loans($filter, $search, 50);
$flashes = pull_flashes();
$cssVersion = asset_version('assets/css/custom.css');
$jsVersion = asset_version('assets/js/main.js');
$filterCounts = [
'all' => $stats['total_loans'],
'active' => $stats['active_loans'] - $stats['overdue_loans'],
'overdue' => $stats['overdue_loans'],
'returned' => $stats['returned_loans'],
];
?>
<!doctype html>
<html lang="en">
<html lang="id">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
<?= render_head_meta('Dashboard Operasional', 'Kelola peminjaman barang, pantau jatuh tempo, kirim pengingat, dan catat pengembalian dari satu dashboard internal.') ?>
<meta name="robots" content="index, follow" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="assets/css/custom.css?v=<?= e($cssVersion) ?>" />
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
<body class="app-body">
<div class="toast-container position-fixed top-0 end-0 p-3">
<div id="appToast" class="toast border-0 shadow-sm" role="status" aria-live="polite" aria-atomic="true">
<div class="toast-body"></div>
</div>
</div>
<nav class="navbar navbar-expand-lg app-navbar sticky-top">
<div class="container app-container">
<a class="navbar-brand" href="index.php">
<span class="brand-mark">LP</span>
<span>
<strong><?= e(project_name()) ?></strong>
<small class="d-block text-muted">Internal lending workspace</small>
</span>
</a>
<div class="d-flex align-items-center gap-2 ms-auto">
<a href="#new-loan" class="btn btn-sm btn-primary btn-app">Buat peminjaman</a>
<a href="healthz.php" class="btn btn-sm btn-outline-secondary btn-app">Health</a>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div>
</nav>
<main class="py-4 py-lg-5">
<div class="container app-container d-grid gap-4">
<section class="section-card hero-card">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<span class="eyebrow">Sistem peminjaman barang</span>
<h1 class="hero-title mt-2">Catat barang keluar, pantau jatuh tempo, dan tutup pengembalian tanpa spreadsheet.</h1>
<p class="hero-copy mb-0">Dashboard ini memberi alur tipis namun utuh: input transaksi peminjaman, lihat pinjaman aktif atau terlambat, kirim pengingat, lalu proses pengembalian dari halaman detail.</p>
</div>
<div class="col-lg-5">
<div class="hero-summary-list">
<div>
<span class="summary-label">Transaksi tercatat</span>
<strong><?= e((string) $stats['total_loans']) ?></strong>
</div>
<div>
<span class="summary-label">Butuh perhatian hari ini</span>
<strong><?= e((string) ($stats['overdue_loans'] + $stats['due_today_loans'])) ?></strong>
</div>
<div>
<span class="summary-label">Pengembalian selesai</span>
<strong><?= e((string) $stats['returned_loans']) ?></strong>
</div>
</div>
</div>
</div>
</section>
<?php if ($stats['overdue_loans'] > 0): ?>
<div class="alert alert-warning app-alert" role="alert">
<strong><?= e((string) $stats['overdue_loans']) ?> pinjaman melewati jatuh tempo.</strong>
Prioritaskan pengingat dan proses pengembalian untuk menghindari kehilangan barang.
</div>
<?php endif; ?>
<?php foreach ($flashes as $flash): ?>
<div class="alert alert-<?= e($flash['type'] === 'success' ? 'success' : 'secondary') ?> alert-dismissible fade show app-alert auto-dismiss-alert" role="alert">
<?= e($flash['message'] ?? '') ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endforeach; ?>
<section class="row g-4 align-items-start">
<div class="col-xl-7" id="new-loan">
<div class="section-card h-100">
<div class="section-header">
<div>
<h2>Input peminjaman baru</h2>
<p>Catat siapa meminjam barang, berapa jumlahnya, dan kapan harus kembali.</p>
</div>
<span class="section-chip">Create confirm track</span>
</div>
<form method="post" novalidate class="row g-3">
<input type="hidden" name="action" value="create_loan" />
<div class="col-md-6">
<label class="form-label" for="borrower_name">Nama peminjam</label>
<input type="text" class="form-control <?= isset($formErrors['borrower_name']) ? 'is-invalid' : '' ?>" id="borrower_name" name="borrower_name" value="<?= e($formValues['borrower_name']) ?>" placeholder="Contoh: Siti Rahma" />
<?php if (isset($formErrors['borrower_name'])): ?><div class="invalid-feedback"><?= e($formErrors['borrower_name']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label" for="borrower_contact">Kontak / WhatsApp</label>
<input type="text" class="form-control <?= isset($formErrors['borrower_contact']) ? 'is-invalid' : '' ?>" id="borrower_contact" name="borrower_contact" value="<?= e($formValues['borrower_contact']) ?>" placeholder="08xx atau email internal" />
<?php if (isset($formErrors['borrower_contact'])): ?><div class="invalid-feedback"><?= e($formErrors['borrower_contact']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label" for="department">Divisi / lokasi</label>
<input type="text" class="form-control <?= isset($formErrors['department']) ? 'is-invalid' : '' ?>" id="department" name="department" value="<?= e($formValues['department']) ?>" placeholder="Marketing, Gudang, Event" />
<?php if (isset($formErrors['department'])): ?><div class="invalid-feedback"><?= e($formErrors['department']) ?></div><?php endif; ?>
</div>
<div class="col-md-6">
<label class="form-label" for="item_name">Nama barang</label>
<input type="text" class="form-control <?= isset($formErrors['item_name']) ? 'is-invalid' : '' ?>" id="item_name" name="item_name" value="<?= e($formValues['item_name']) ?>" placeholder="Laptop Dell, Proyektor, Kamera" />
<?php if (isset($formErrors['item_name'])): ?><div class="invalid-feedback"><?= e($formErrors['item_name']) ?></div><?php endif; ?>
</div>
<div class="col-md-4">
<label class="form-label" for="item_code">Kode barang</label>
<input type="text" class="form-control <?= isset($formErrors['item_code']) ? 'is-invalid' : '' ?>" id="item_code" name="item_code" value="<?= e($formValues['item_code']) ?>" placeholder="IT-014" />
<?php if (isset($formErrors['item_code'])): ?><div class="invalid-feedback"><?= e($formErrors['item_code']) ?></div><?php endif; ?>
</div>
<div class="col-md-2">
<label class="form-label" for="quantity">Qty</label>
<input type="number" min="1" max="999" class="form-control <?= isset($formErrors['quantity']) ? 'is-invalid' : '' ?>" id="quantity" name="quantity" value="<?= e($formValues['quantity']) ?>" />
<?php if (isset($formErrors['quantity'])): ?><div class="invalid-feedback"><?= e($formErrors['quantity']) ?></div><?php endif; ?>
</div>
<div class="col-md-3">
<label class="form-label" for="loaned_at">Tanggal pinjam</label>
<input type="date" class="form-control <?= isset($formErrors['loaned_at']) ? 'is-invalid' : '' ?>" id="loaned_at" name="loaned_at" value="<?= e($formValues['loaned_at']) ?>" />
<?php if (isset($formErrors['loaned_at'])): ?><div class="invalid-feedback"><?= e($formErrors['loaned_at']) ?></div><?php endif; ?>
</div>
<div class="col-md-3">
<label class="form-label" for="due_at">Jatuh tempo</label>
<input type="date" class="form-control <?= isset($formErrors['due_at']) ? 'is-invalid' : '' ?>" id="due_at" name="due_at" value="<?= e($formValues['due_at']) ?>" />
<?php if (isset($formErrors['due_at'])): ?><div class="invalid-feedback"><?= e($formErrors['due_at']) ?></div><?php endif; ?>
</div>
<div class="col-12">
<label class="form-label" for="notes">Catatan serah terima</label>
<textarea class="form-control <?= isset($formErrors['notes']) ? 'is-invalid' : '' ?>" id="notes" name="notes" rows="4" placeholder="Aksesoris, kondisi awal, atau catatan khusus."><?= e($formValues['notes']) ?></textarea>
<?php if (isset($formErrors['notes'])): ?><div class="invalid-feedback"><?= e($formErrors['notes']) ?></div><?php endif; ?>
</div>
<div class="col-12 d-flex flex-column flex-sm-row align-items-sm-center justify-content-between gap-3 pt-1">
<p class="form-helper mb-0">Setelah disimpan, sistem mengarahkan ke halaman detail untuk pengingat dan pengembalian.</p>
<button type="submit" class="btn btn-primary btn-app">Simpan transaksi</button>
</div>
</form>
</div>
</div>
<div class="col-xl-5">
<div class="section-card h-100">
<div class="section-header">
<div>
<h2>Antrian prioritas</h2>
<p>Pinjaman yang sudah terlambat atau segera jatuh tempo.</p>
</div>
<span class="section-chip">Today</span>
</div>
<?php if ($priorityLoans === []): ?>
<div class="empty-state compact-empty-state">
<h3>Belum ada pinjaman prioritas</h3>
<p>Saat ada pinjaman yang jatuh tempo atau terlambat, daftar ini akan membantu staff mengambil tindakan cepat.</p>
</div>
<?php else: ?>
<div class="d-grid gap-3">
<?php foreach ($priorityLoans as $loan): ?>
<article class="priority-card <?= $loan['is_overdue'] ? 'priority-card-overdue' : '' ?>">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<span class="loan-reference"><?= e($loan['reference']) ?></span>
<h3><?= e($loan['item_name']) ?></h3>
<p class="mb-1"><?= e($loan['borrower_name']) ?> · Qty <?= e((string) $loan['quantity']) ?></p>
<p class="small text-muted mb-0">Tempo <?= e($loan['due_at_label']) ?> · <?= e($loan['due_note']) ?></p>
</div>
<span class="status-badge <?= e($loan['status_badge_class']) ?>"><?= e($loan['status_label']) ?></span>
</div>
<div class="priority-actions">
<button type="button" class="btn btn-sm btn-outline-secondary btn-app copy-trigger" data-copy-text="<?= e($loan['reminder_message']) ?>" data-copy-success="Teks pengingat <?= e($loan['reference']) ?> disalin.">Salin pengingat</button>
<a class="btn btn-sm btn-primary btn-app" href="loan.php?id=<?= e((string) $loan['id']) ?>">Buka detail</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</section>
<section id="loan-board" class="section-card">
<div class="section-header flex-column flex-lg-row align-items-lg-center gap-3">
<div>
<h2>Board peminjaman</h2>
<p>Filter transaksi aktif, terlambat, atau selesai. Gunakan detail untuk proses reminder dan pengembalian.</p>
</div>
<form class="search-form ms-lg-auto" method="get" role="search">
<input type="hidden" name="status" value="<?= e($filter) ?>" />
<input class="form-control" type="search" name="q" value="<?= e($search) ?>" placeholder="Cari peminjam, barang, divisi" aria-label="Cari data pinjaman" />
<button class="btn btn-outline-secondary btn-app" type="submit">Cari</button>
</form>
</div>
<div class="filter-pills mb-3">
<?php foreach ($allowedFilters as $option): ?>
<?php
$params = ['status' => $option];
if ($search !== '') {
$params['q'] = $search;
}
?>
<a class="filter-pill <?= $filter === $option ? 'is-active' : '' ?>" href="?<?= e(http_build_query($params)) ?>">
<span><?= e(match ($option) {
'active' => 'Aktif',
'overdue' => 'Terlambat',
'returned' => 'Dikembalikan',
default => 'Semua',
}) ?></span>
<strong><?= e((string) max(0, (int) ($filterCounts[$option] ?? 0))) ?></strong>
</a>
<?php endforeach; ?>
</div>
<?php if ($loans === []): ?>
<div class="empty-state">
<h3>Belum ada transaksi yang cocok</h3>
<p>Mulai dari form di atas untuk membuat peminjaman pertama. Setelah tersimpan, transaksi akan muncul di board ini lengkap dengan status jatuh tempo.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover app-table align-middle mb-0">
<thead>
<tr>
<th>Ref</th>
<th>Peminjam</th>
<th>Barang</th>
<th>Periode</th>
<th>Status</th>
<th>Reminder</th>
<th class="text-end">Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($loans as $loan): ?>
<tr class="<?= $loan['is_overdue'] ? 'loan-row-overdue' : '' ?>">
<td>
<span class="loan-reference d-block"><?= e($loan['reference']) ?></span>
<span class="small text-muted"><?= e($loan['department'] ?: 'Tanpa divisi') ?></span>
</td>
<td>
<strong class="d-block"><?= e($loan['borrower_name']) ?></strong>
<span class="small text-muted"><?= e($loan['borrower_contact'] ?: 'Kontak belum diisi') ?></span>
</td>
<td>
<strong class="d-block"><?= e($loan['item_name']) ?></strong>
<span class="small text-muted"><?= e(($loan['item_code'] ?: 'Tanpa kode') . ' · Qty ' . $loan['quantity']) ?></span>
</td>
<td>
<span class="d-block"><?= e($loan['loaned_at_label']) ?> → <?= e($loan['due_at_label']) ?></span>
<span class="small text-muted"><?= e($loan['due_note']) ?></span>
</td>
<td>
<span class="status-badge <?= e($loan['status_badge_class']) ?>"><?= e($loan['status_label']) ?></span>
</td>
<td>
<span class="small d-block text-muted"><?= !empty($loan['last_reminder_at']) ? 'Terkirim ' . e($loan['last_reminder_at_label']) : 'Belum ada pengingat' ?></span>
</td>
<td>
<div class="table-actions justify-content-end">
<?php if (!$loan['is_returned']): ?>
<button type="button" class="btn btn-sm btn-outline-secondary btn-app copy-trigger" data-copy-text="<?= e($loan['reminder_message']) ?>" data-copy-success="Reminder <?= e($loan['reference']) ?> disalin.">Salin</button>
<?php endif; ?>
<a class="btn btn-sm btn-primary btn-app" href="loan.php?id=<?= e((string) $loan['id']) ?>">Detail</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
<footer class="app-footer">
<div class="container app-container d-flex flex-column flex-sm-row justify-content-between gap-2">
<span><?= e(project_name()) ?> · Dashboard peminjaman barang internal</span>
<span><a href="healthz.php">/healthz</a> siap untuk pengecekan aplikasi.</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="assets/js/main.js?v=<?= e($jsVersion) ?>" defer></script>
</body>
</html>

602
lending_app.php Normal file
View File

@ -0,0 +1,602 @@
<?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;
}

336
loan.php Normal file
View File

@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/lending_app.php';
lending_ensure_schema();
$loanId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$loan = $loanId > 0 ? fetch_loan($loanId) : null;
$returnValues = default_return_form();
$returnErrors = [];
if ($loan && $_SERVER['REQUEST_METHOD'] === 'POST') {
$action = trim((string) ($_POST['action'] ?? ''));
if ($action === 'mark_reminder_sent') {
if ($loan['is_returned']) {
set_flash('secondary', 'Pinjaman sudah selesai sehingga reminder tidak diperlukan.');
} else {
mark_reminder_sent($loanId);
set_flash('success', 'Waktu reminder terakhir sudah dicatat.');
}
header('Location: loan.php?id=' . $loanId);
exit;
}
if ($action === 'record_return') {
if ($loan['is_returned']) {
set_flash('secondary', 'Pengembalian sudah pernah dicatat sebelumnya.');
header('Location: loan.php?id=' . $loanId);
exit;
}
[$returnValues, $returnErrors] = validate_return_input($_POST, $loan);
if ($returnErrors === []) {
record_return($loanId, $returnValues);
set_flash('success', 'Pengembalian berhasil dicatat dan riwayat telah diperbarui.');
header('Location: loan.php?id=' . $loanId);
exit;
}
}
}
$loan = $loanId > 0 ? fetch_loan($loanId) : null;
$flashes = pull_flashes();
$cssVersion = asset_version('assets/css/custom.css');
$jsVersion = asset_version('assets/js/main.js');
if (!$loan):
http_response_code(404);
?>
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<?= render_head_meta('Pinjaman tidak ditemukan', 'Detail pinjaman tidak tersedia atau sudah dihapus dari sistem.') ?>
<meta name="robots" content="noindex, nofollow" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="assets/css/custom.css?v=<?= e($cssVersion) ?>" />
</head>
<body class="app-body">
<main class="py-5">
<div class="container app-container">
<section class="section-card not-found-card text-center">
<span class="eyebrow">404</span>
<h1 class="hero-title mt-2">Pinjaman tidak ditemukan</h1>
<p class="hero-copy mb-4">Periksa kembali tautan detail pinjaman atau kembali ke dashboard untuk membuka transaksi lain.</p>
<a href="index.php" class="btn btn-primary btn-app">Kembali ke dashboard</a>
</section>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="assets/js/main.js?v=<?= e($jsVersion) ?>" defer></script>
</body>
</html>
<?php
return;
endif;
?>
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<?= render_head_meta('Detail ' . $loan['reference'], 'Lihat detail transaksi peminjaman, copy pengingat, dan catat pengembalian barang.') ?>
<meta name="robots" content="index, follow" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link rel="stylesheet" href="assets/css/custom.css?v=<?= e($cssVersion) ?>" />
</head>
<body class="app-body">
<div class="toast-container position-fixed top-0 end-0 p-3">
<div id="appToast" class="toast border-0 shadow-sm" role="status" aria-live="polite" aria-atomic="true">
<div class="toast-body"></div>
</div>
</div>
<nav class="navbar navbar-expand-lg app-navbar sticky-top">
<div class="container app-container">
<a class="navbar-brand" href="index.php">
<span class="brand-mark">LP</span>
<span>
<strong><?= e(project_name()) ?></strong>
<small class="d-block text-muted">Loan detail</small>
</span>
</a>
<div class="d-flex align-items-center gap-2 ms-auto">
<a href="index.php#loan-board" class="btn btn-sm btn-outline-secondary btn-app">Kembali ke board</a>
<?php if (!$loan['is_returned']): ?>
<button type="button" class="btn btn-sm btn-primary btn-app copy-trigger" data-copy-text="<?= e($loan['reminder_message']) ?>" data-copy-success="Teks reminder <?= e($loan['reference']) ?> disalin.">Salin reminder</button>
<?php endif; ?>
</div>
</div>
</nav>
<main class="py-4 py-lg-5">
<div class="container app-container d-grid gap-4">
<?php foreach ($flashes as $flash): ?>
<div class="alert alert-<?= e($flash['type'] === 'success' ? 'success' : 'secondary') ?> alert-dismissible fade show app-alert auto-dismiss-alert" role="alert">
<?= e($flash['message'] ?? '') ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endforeach; ?>
<section class="section-card hero-card">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-start gap-4">
<div>
<span class="eyebrow">Detail transaksi</span>
<h1 class="hero-title mt-2 mb-2"><?= e($loan['reference']) ?> — <?= e($loan['item_name']) ?></h1>
<p class="hero-copy mb-3">Pinjaman atas nama <?= e($loan['borrower_name']) ?> dengan qty <?= e((string) $loan['quantity']) ?>. Gunakan halaman ini untuk reminder dan penutupan transaksi.</p>
<div class="d-flex flex-wrap gap-2">
<span class="status-badge <?= e($loan['status_badge_class']) ?>"><?= e($loan['status_label']) ?></span>
<span class="status-badge badge-status-neutral"><?= e($loan['due_note']) ?></span>
</div>
</div>
<div class="hero-summary-list loan-detail-summary">
<div>
<span class="summary-label">Jatuh tempo</span>
<strong><?= e($loan['due_at_label']) ?></strong>
</div>
<div>
<span class="summary-label">Reminder terakhir</span>
<strong><?= e($loan['last_reminder_at_label'] ?: 'Belum ada') ?></strong>
</div>
<div>
<span class="summary-label">Status pengembalian</span>
<strong><?= e($loan['is_returned'] ? $loan['returned_at_label'] : 'Belum kembali') ?></strong>
</div>
</div>
</div>
</section>
<section class="row g-4 align-items-start">
<div class="col-xl-7 d-grid gap-4">
<div class="section-card">
<div class="section-header">
<div>
<h2>Ringkasan pinjaman</h2>
<p>Data inti transaksi untuk follow-up dan audit internal.</p>
</div>
<a href="index.php#new-loan" class="btn btn-sm btn-outline-secondary btn-app">Transaksi baru</a>
</div>
<dl class="detail-grid mb-0">
<div>
<dt>Peminjam</dt>
<dd><?= e($loan['borrower_name']) ?></dd>
</div>
<div>
<dt>Kontak</dt>
<dd><?= e($loan['borrower_contact'] ?: 'Belum diisi') ?></dd>
</div>
<div>
<dt>Divisi</dt>
<dd><?= e($loan['department'] ?: 'Tanpa divisi') ?></dd>
</div>
<div>
<dt>Kode barang</dt>
<dd><?= e($loan['item_code'] ?: 'Tanpa kode') ?></dd>
</div>
<div>
<dt>Tanggal pinjam</dt>
<dd><?= e($loan['loaned_at_label']) ?></dd>
</div>
<div>
<dt>Jatuh tempo</dt>
<dd><?= e($loan['due_at_label']) ?></dd>
</div>
<div>
<dt>Jumlah</dt>
<dd><?= e((string) $loan['quantity']) ?></dd>
</div>
<div>
<dt>Catatan awal</dt>
<dd><?= e($loan['notes'] ?: 'Tidak ada catatan tambahan') ?></dd>
</div>
</dl>
</div>
<div class="section-card">
<div class="section-header">
<div>
<h2>Template reminder</h2>
<p>Salin pesan untuk WhatsApp, email, atau chat internal.</p>
</div>
<?php if (!$loan['is_returned']): ?>
<form method="post">
<input type="hidden" name="action" value="mark_reminder_sent" />
<button type="submit" class="btn btn-sm btn-outline-secondary btn-app">Catat reminder terkirim</button>
</form>
<?php endif; ?>
</div>
<div class="message-preview"><?= e($loan['reminder_message']) ?></div>
<div class="d-flex flex-wrap gap-2 pt-3">
<button type="button" class="btn btn-outline-secondary btn-app copy-trigger" data-copy-text="<?= e($loan['reminder_message']) ?>" data-copy-success="Pesan reminder disalin.">Salin teks reminder</button>
<a href="index.php#loan-board" class="btn btn-primary btn-app">Kembali ke daftar</a>
</div>
</div>
</div>
<div class="col-xl-5 d-grid gap-4">
<?php if (!$loan['is_returned']): ?>
<div class="section-card" id="return-form">
<div class="section-header">
<div>
<h2>Proses pengembalian</h2>
<p>Catat kapan barang kembali dan bagaimana kondisinya.</p>
</div>
<span class="section-chip">Close transaction</span>
</div>
<form method="post" novalidate class="row g-3">
<input type="hidden" name="action" value="record_return" />
<div class="col-12">
<label class="form-label" for="returned_at">Waktu kembali</label>
<input type="datetime-local" class="form-control <?= isset($returnErrors['returned_at']) ? 'is-invalid' : '' ?>" id="returned_at" name="returned_at" value="<?= e($returnValues['returned_at']) ?>" />
<?php if (isset($returnErrors['returned_at'])): ?><div class="invalid-feedback"><?= e($returnErrors['returned_at']) ?></div><?php endif; ?>
</div>
<div class="col-12">
<label class="form-label" for="return_condition">Kondisi barang saat kembali</label>
<select class="form-select <?= isset($returnErrors['return_condition']) ? 'is-invalid' : '' ?>" id="return_condition" name="return_condition">
<?php foreach (['baik' => 'Baik', 'catatan' => 'Ada catatan', 'rusak' => 'Rusak'] as $optionValue => $optionLabel): ?>
<option value="<?= e($optionValue) ?>" <?= $returnValues['return_condition'] === $optionValue ? 'selected' : '' ?>><?= e($optionLabel) ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($returnErrors['return_condition'])): ?><div class="invalid-feedback"><?= e($returnErrors['return_condition']) ?></div><?php endif; ?>
</div>
<div class="col-12">
<label class="form-label" for="return_notes">Catatan pengembalian</label>
<textarea class="form-control <?= isset($returnErrors['return_notes']) ? 'is-invalid' : '' ?>" id="return_notes" name="return_notes" rows="4" placeholder="Misalnya charger lengkap, tas hilang, atau ada kerusakan ringan."><?= e($returnValues['return_notes']) ?></textarea>
<?php if (isset($returnErrors['return_notes'])): ?><div class="invalid-feedback"><?= e($returnErrors['return_notes']) ?></div><?php endif; ?>
</div>
<div class="col-12 d-flex justify-content-between align-items-center gap-3 pt-1">
<p class="form-helper mb-0">Status board akan berubah otomatis setelah pengembalian disimpan.</p>
<button type="submit" class="btn btn-primary btn-app">Catat pengembalian</button>
</div>
</form>
</div>
<?php else: ?>
<div class="section-card">
<div class="section-header">
<div>
<h2>Pengembalian tercatat</h2>
<p>Transaksi ini sudah ditutup dan tersimpan di riwayat.</p>
</div>
<span class="section-chip">Closed</span>
</div>
<dl class="detail-grid mb-0">
<div>
<dt>Waktu kembali</dt>
<dd><?= e($loan['returned_at_label']) ?></dd>
</div>
<div>
<dt>Kondisi barang</dt>
<dd><?= e($loan['return_condition_label']) ?></dd>
</div>
<div>
<dt>Ketepatan</dt>
<dd><?= e($loan['due_note']) ?></dd>
</div>
<div>
<dt>Catatan akhir</dt>
<dd><?= e($loan['return_notes'] ?: 'Tidak ada catatan tambahan') ?></dd>
</div>
</dl>
</div>
<?php endif; ?>
<div class="section-card">
<div class="section-header">
<div>
<h2>Riwayat aktivitas</h2>
<p>Titik audit sederhana untuk staff dan admin.</p>
</div>
</div>
<ul class="timeline-list mb-0">
<li>
<strong>Transaksi dibuat</strong>
<span><?= e(format_date_id($loan['created_at'], true)) ?></span>
</li>
<li>
<strong>Reminder terakhir</strong>
<span><?= e($loan['last_reminder_at_label'] ?: 'Belum dicatat') ?></span>
</li>
<li>
<strong>Status saat ini</strong>
<span><?= e($loan['status_label'] . ' · ' . $loan['due_note']) ?></span>
</li>
<?php if ($loan['is_returned']): ?>
<li>
<strong>Pengembalian selesai</strong>
<span><?= e($loan['returned_at_label']) ?></span>
</li>
<?php endif; ?>
</ul>
</div>
</div>
</section>
</div>
</main>
<footer class="app-footer">
<div class="container app-container d-flex flex-column flex-sm-row justify-content-between gap-2">
<span><?= e($loan['reference']) ?> · <?= e($loan['item_name']) ?></span>
<span><a href="healthz.php">/healthz</a> tersedia untuk monitoring.</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="assets/js/main.js?v=<?= e($jsVersion) ?>" defer></script>
</body>
</html>