peminjaman barangg
This commit is contained in:
parent
acc7ef489d
commit
b75cdfbb3a
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
26
healthz.php
Normal 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
458
index.php
@ -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
602
lending_app.php
Normal 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 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;
|
||||
}
|
||||
336
loan.php
Normal file
336
loan.php
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user