diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cc53f5a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.vercel diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 22367d5..0000000 --- a/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM php:8.3-fpm-alpine - -RUN apk add --no-linux-headers --no-cache \ - zip unzip curl libpng-dev libxml2-dev libzip-dev postgresql-dev nginx supervisor - -RUN docker-php-ext-install pdo pdo_pgsql zip bcmath gd - -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -WORKDIR /var/www/html - -COPY lentera-backend/ . - -RUN composer install --no-dev --optimize-autoloader - -RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache -RUN chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache - -EXPOSE 80 - -CMD ["sh", "-c", "php artisan migrate --force && php artisan config:cache && php artisan route:cache && php artisan serve --host=0.0.0.0 --port=80"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3508ec --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Lentera — Lending & Tracking Application + +Laboratory asset lending and tracking system with kiosk-based self-service borrowing and return. + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| **Frontend** | Next.js 16, React 19, TypeScript | +| **Styling** | Tailwind CSS v4 | +| **API Layer** | Next.js Route Handlers (Vercel Serverless Functions) — inside `lentera-frontend/app/api/` | +| **Database** | Supabase (PostgreSQL) | +| **Deployment** | Vercel | + +**Note:** The `lentera-backend/` directory contains an abandoned Laravel prototype used only as a reference during initial development. It is NOT used in production. All API logic lives in the Next.js route handlers (`lentera-frontend/app/api/`). + +## Project Structure + +``` +lentera/ +├── lentera-frontend/ # Next.js web app (the actual application) +│ ├── app/ +│ │ ├── api/ # Serverless API route handlers +│ │ │ ├── assets/ # Asset CRUD + scan +│ │ │ ├── borrow/ # Borrow workflow +│ │ │ ├── return/ # Return workflow +│ │ │ ├── gateway/ # Kiosk QR token generate & validate +│ │ │ ├── categories/ # Category CRUD +│ │ │ ├── admin/ # Admin settings & sudo +│ │ │ ├── login/ # Authentication +│ │ │ └── logout/ # Session end +│ │ ├── (admin/) # Admin dashboard, print labels +│ │ ├── (cek-alat/) # Public asset check page +│ │ ├── (display/) # Kiosk QR display monitor +│ │ ├── (form/borrow/) # Borrow form (kiosk) +│ │ ├── (form/return/) # Return form (kiosk) +│ │ └── (scan/) # QR scan landing page +│ ├── components/ # Reusable React components (UI kit) +│ ├── lib/ # API client, types, utilities +│ └── supabase/ # DB migrations & CLI config +│ └── migrations/ # SQL migration files +├── lentera-backend/ # Laravel prototype (unused / reference only) +└── README.md +``` + +## Flow + +1. **Admin** manages assets, categories, and settings via the dashboard (`/admin/dashboard`). +2. **Kiosk monitor** (`/display`) shows a rotating QR code with a gateway token. +3. **Student** scans the QR → `/scan` validates the token → picks **Pinjam** or **Kembalikan**. +4. **Borrow** (`/form/borrow`): Student fills form (name, NPM, subject, lecturer, return date) → submits → asset status set to `borrowed`. +5. **Return** (`/form/return`): Student selects borrowed asset + enters NPM → submits → asset status back to `available`. + +## Database + +Hosted on **Supabase** (PostgreSQL). Schema migrations in `lentera-frontend/supabase/migrations/`: + +- `20260729000001_init.sql` — Tables: `admins`, `categories`, `assets`, `transactions`, `settings` +- `20260729000002_add_transaction_fields.sql` — Adds `student_npm`, `student_prodi`, `lecturer`, `expected_return_at` + +## Development + +```bash +cd lentera-frontend +npm install +npm run dev +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anonymous key | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key (API routes) | + +## Developed By + +- **Backend (original Laravel prototype):** [@rannd1nt](https://github.com/rannd1nt) (Zahraan Dzakii Ts.) +- **Frontend & Current Backend (Next.js + Supabase):** [@MuadzHdz](https://github.com/MuadzHdz) (Mu'adz Hudzaifah) diff --git a/lentera-frontend/.gitignore b/lentera-frontend/.gitignore index 5ef6a52..b83e760 100644 --- a/lentera-frontend/.gitignore +++ b/lentera-frontend/.gitignore @@ -34,7 +34,7 @@ yarn-error.log* .env* # vercel -.vercel +.vercel/*.env # typescript *.tsbuildinfo diff --git a/lentera-frontend/.vercel/project.json b/lentera-frontend/.vercel/project.json new file mode 100644 index 0000000..178247a --- /dev/null +++ b/lentera-frontend/.vercel/project.json @@ -0,0 +1 @@ +{"projectId":"prj_YpwoVilhpNOGVH3XErGd8uoCyoLL","orgId":"team_p7Y24AzkJDCLJMVSGhyG4D5a","projectName":"lentera"} \ No newline at end of file diff --git a/lentera-frontend/app/admin/dashboard/page.tsx b/lentera-frontend/app/admin/dashboard/page.tsx index 473de3e..4ead5c9 100644 --- a/lentera-frontend/app/admin/dashboard/page.tsx +++ b/lentera-frontend/app/admin/dashboard/page.tsx @@ -15,6 +15,17 @@ import Skeleton from '@/components/ui/Skeleton'; import { QRCodeCanvas } from 'qrcode.react'; import { Asset, Category, PendingAction, CategoryModalData, AssetModalData, QrModalData } from '@/lib/types'; +function formatWIB(iso: string): string { + const d = new Date(iso); + const dateStr = d.toLocaleDateString('id-ID', { + timeZone: 'Asia/Jakarta', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' + }); + const timeStr = d.toLocaleTimeString('id-ID', { + timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit', hour12: false + }); + return `${dateStr}, ${timeStr} WIB`; +} + export default function AdminDashboard() { const router = useRouter(); const toast = useToast(); @@ -41,13 +52,15 @@ export default function AdminDashboard() { const [catModal, setCatModal] = useState<{isOpen: boolean, type: 'add'|'edit', data: CategoryModalData}>({isOpen: false, type: 'add', data: {name: ''}}); const [assetModal, setAssetModal] = useState<{isOpen: boolean, type: 'add'|'edit', data: AssetModalData}>({isOpen: false, type: 'add', data: {category_id: '', name: '', code: ''}}); - const [settingsModal, setSettingsModal] = useState<{isOpen: boolean, qr_interval: number, form_interval: number}>(() => { + const [settingsModal, setSettingsModal] = useState<{isOpen: boolean, qr_interval: number | '', form_interval: number | ''}>(() => { const saved = typeof window !== 'undefined' ? localStorage.getItem('settings') : null; const defaults = { qr_interval: 30, form_interval: 15 }; const parsed = saved ? JSON.parse(saved) : defaults; return { isOpen: false, ...defaults, ...parsed }; }); const [qrModal, setQrModal] = useState<{isOpen: boolean, asset: QrModalData}>({isOpen: false, asset: null}); + const [confirmModal, setConfirmModal] = useState<'batch-qr' | 'export-csv' | null>(null); + const [actionLoading, setActionLoading] = useState(false); const qrRef = useRef(null); const fetchData = useCallback(async () => { @@ -107,9 +120,9 @@ export default function AdminDashboard() { if (pendingAction) { await executeAction(pendingAction.type, pendingAction.id, pendingAction.payload); + } else { + toast.success(res.data.message || "Otorisasi berhasil!"); } - - toast.success(res.data.message || "Aksi berhasil dieksekusi!"); setSudoPassword(''); setShowSudoModal(false); } catch (err: unknown) { @@ -221,14 +234,10 @@ export default function AdminDashboard() { }; const saveSettings = () => { - localStorage.setItem('settings', JSON.stringify({ - qr_interval: settingsModal.qr_interval, - form_interval: settingsModal.form_interval - })); - triggerAction('UPDATE_SETTINGS', undefined, { - qr_interval: Number(settingsModal.qr_interval), - form_interval: Number(settingsModal.form_interval) - }); + const qr = settingsModal.qr_interval === '' ? 30 : settingsModal.qr_interval; + const fi = settingsModal.form_interval === '' ? 15 : settingsModal.form_interval; + localStorage.setItem('settings', JSON.stringify({ qr_interval: qr, form_interval: fi })); + triggerAction('UPDATE_SETTINGS', undefined, { qr_interval: Number(qr), form_interval: Number(fi) }); setSettingsModal(p => ({ ...p, isOpen: false })); }; @@ -260,9 +269,10 @@ export default function AdminDashboard() { // Feature #7: Batch download all QR codes const downloadAllQR = async () => { + setActionLoading(true); toast.info("Menyiapkan download semua QR code..."); const containers = qrBatchRef.current?.querySelectorAll('[data-qr-batch]'); - if (!containers) return; + if (!containers) { setActionLoading(false); return; } for (const container of Array.from(containers)) { const canvas = container.querySelector('canvas'); if (!canvas) continue; @@ -277,6 +287,7 @@ export default function AdminDashboard() { link.click(); await new Promise(resolve => setTimeout(resolve, 300)); } + setActionLoading(false); toast.success("Semua QR code berhasil di-download!"); }; @@ -292,6 +303,7 @@ export default function AdminDashboard() { // Feature #15: Export CSV const exportCSV = () => { + setActionLoading(true); const headers = ["Kode", "Nama", "Kategori", "Status", "Peminjam", "Dipinjam Sejak"]; const rows = filteredAssets.map(a => { const txn = getTxn(a); @@ -301,7 +313,7 @@ export default function AdminDashboard() { a.category?.name || '-', a.status, txn?.student_name || '-', - txn?.borrowed_at || '-' + (txn ? formatWIB(txn.borrowed_at) : '-') ]; }); const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n'); @@ -310,6 +322,7 @@ export default function AdminDashboard() { link.href = URL.createObjectURL(blob); link.download = `lentera-assets-${new Date().toISOString().slice(0, 10)}.csv`; link.click(); + setActionLoading(false); toast.success("Data aset berhasil di-export!"); }; @@ -446,14 +459,13 @@ export default function AdminDashboard() { setSearchQuery(e.target.value)} placeholder="Cari alat... (Ctrl+K)" className="w-full pl-10 pr-4 py-2 rounded-xl bg-[var(--card)] border border-[var(--card-border)] text-sm text-[var(--foreground)] placeholder:text-[var(--subtle)] focus:outline-none focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)] transition-all" /> - - + + -
- + @@ -463,7 +475,12 @@ export default function AdminDashboard() { {filteredAssets.length === 0 && ( - + )} {filteredAssets.map(asset => ( @@ -478,7 +495,7 @@ export default function AdminDashboard() { {(() => { const txn = getTxn(asset); return asset.status === 'borrowed' && txn ? ( -
{txn.student_name}
{txn.borrowed_at}
+
{txn.student_name}
{formatWIB(txn.borrowed_at)}
) : ( - ); @@ -541,12 +558,12 @@ export default function AdminDashboard() {
- setSettingsModal({...settingsModal, qr_interval: Number(e.target.value)})} /> + setSettingsModal({...settingsModal, qr_interval: e.target.value === '' ? '' : Number(e.target.value)})} />

Siklus perubahan gambar barcode di layar Monitor Kiosk.

- setSettingsModal({...settingsModal, form_interval: Number(e.target.value)})} /> + setSettingsModal({...settingsModal, form_interval: e.target.value === '' ? '' : Number(e.target.value)})} />

Lama waktu token akses mahasiswa berlaku sebelum kadaluarsa.

@@ -588,6 +605,27 @@ export default function AdminDashboard() { )} + {/* CONFIRMATION MODAL */} + setConfirmModal(null)} title="Konfirmasi" size="sm"> +
+
+ +
+

Konfirmasi {confirmModal === 'batch-qr' ? 'Download Semua QR' : 'Ekspor CSV'}

+

+ {confirmModal === 'batch-qr' + ? `Anda akan men-download QR code untuk ${filteredAssets.length} aset yang ditampilkan.` + : `Anda akan mengexport data ${filteredAssets.length} aset ke file CSV.`} +

+
+ + +
+
+
+ {/* LOGOUT CONFIRMATION MODAL */} setShowLogoutModal(false)} title="Konfirmasi Logout" size="sm">
diff --git a/lentera-frontend/app/api/assets/form-options/route.ts b/lentera-frontend/app/api/assets/form-options/route.ts index abcedf2..e690ede 100644 --- a/lentera-frontend/app/api/assets/form-options/route.ts +++ b/lentera-frontend/app/api/assets/form-options/route.ts @@ -4,7 +4,7 @@ import { createClient } from '@supabase/supabase-js'; function getSupabase() { return createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + process.env.SUPABASE_SERVICE_ROLE_KEY! ); } @@ -44,7 +44,7 @@ export async function GET(request: Request) { const catName = Array.isArray(cat) ? (cat[0]?.name as string) : (cat?.name as string); return { label: `${a.code} — ${a.name}`, - value: String(a.id), + value: a.code, category: catName || '', }; }); diff --git a/lentera-frontend/app/api/assets/route.ts b/lentera-frontend/app/api/assets/route.ts index 6f93878..b7d5b65 100644 --- a/lentera-frontend/app/api/assets/route.ts +++ b/lentera-frontend/app/api/assets/route.ts @@ -10,6 +10,7 @@ function getSupabase() { export async function GET() { const supabase = getSupabase(); + const { data: assets, error } = await supabase .from('assets') .select('*, category:categories(*)') @@ -19,7 +20,27 @@ export async function GET() { return NextResponse.json({ status: 'error', message: error.message }, { status: 500 }); } - return NextResponse.json({ status: 'success', data: assets || [] }); + const result = assets || []; + + const borrowedIds = result.filter(a => a.status === 'borrowed').map(a => a.id); + if (borrowedIds.length > 0) { + const { data: transactions } = await supabase + .from('transactions') + .select('*') + .in('asset_id', borrowedIds) + .is('returned_at', null); + + if (transactions) { + for (const asset of result) { + if (asset.status === 'borrowed') { + const txn = transactions.find(t => t.asset_id === asset.id); + (asset as Record).lastTransaction = txn || null; + } + } + } + } + + return NextResponse.json({ status: 'success', data: result }); } export async function POST(request: Request) { diff --git a/lentera-frontend/app/api/assets/scan/[code]/route.ts b/lentera-frontend/app/api/assets/scan/[code]/route.ts index 0f43b07..0ac3ccf 100644 --- a/lentera-frontend/app/api/assets/scan/[code]/route.ts +++ b/lentera-frontend/app/api/assets/scan/[code]/route.ts @@ -29,10 +29,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ cod .is('returned_at', null) .order('borrowed_at', { ascending: false }) .limit(1) - .single(); + .maybeSingle(); return NextResponse.json({ status: 'success', - data: { ...data, lastTransaction: transaction || null }, + data: { + ...data, + lastTransaction: transaction || null, + }, }); } diff --git a/lentera-frontend/app/api/borrow/route.ts b/lentera-frontend/app/api/borrow/route.ts index 91f3bca..ed8eb4b 100644 --- a/lentera-frontend/app/api/borrow/route.ts +++ b/lentera-frontend/app/api/borrow/route.ts @@ -11,9 +11,9 @@ function getSupabase() { export async function POST(request: Request) { const supabase = getSupabase(); const body = await request.json(); - const { asset_id, student_name, student_class, subject, submission_token } = body; + const { asset_code, student_name, student_npm, student_prodi, student_class, subject, lecturer, expected_return_at, submission_token } = body; - if (!asset_id || !student_name) { + if (!asset_code || !student_name) { return NextResponse.json({ status: 'error', message: 'Data tidak lengkap' }, { status: 400 }); } @@ -32,9 +32,19 @@ export async function POST(request: Request) { return NextResponse.json({ status: 'error', message: 'Token tidak valid' }, { status: 401 }); } + const { data: asset } = await supabase + .from('assets') + .select('id') + .eq('code', asset_code) + .single(); + + if (!asset) { + return NextResponse.json({ status: 'error', message: 'Alat tidak ditemukan' }, { status: 404 }); + } + const { data: transaction, error: txnErr } = await supabase .from('transactions') - .insert({ asset_id: Number(asset_id), student_name, student_class, subject }) + .insert({ asset_id: asset.id, student_name, student_npm, student_prodi, student_class, subject, lecturer, expected_return_at }) .select() .single(); @@ -45,7 +55,9 @@ export async function POST(request: Request) { await supabase .from('assets') .update({ status: 'borrowed', updated_at: new Date().toISOString() }) - .eq('id', asset_id); + .eq('id', asset.id); + + await supabase.from('settings').update({ value: JSON.stringify({ token: '', expires_at: 0 }) }).eq('key', 'gateway_token'); return NextResponse.json({ status: 'success', data: transaction }, { status: 201 }); } diff --git a/lentera-frontend/app/api/gateway/generate/route.ts b/lentera-frontend/app/api/gateway/generate/route.ts index 0196747..ce50af5 100644 --- a/lentera-frontend/app/api/gateway/generate/route.ts +++ b/lentera-frontend/app/api/gateway/generate/route.ts @@ -11,7 +11,15 @@ function getSupabase() { export async function GET() { const supabase = getSupabase(); const qrToken = Math.random().toString(36).substring(2, 12); - const expiresAt = Date.now() + 15 * 60 * 1000; + + const { data: settingsData } = await supabase + .from('settings') + .select('value') + .eq('key', 'form_interval') + .single(); + + const formInterval = settingsData ? Number(JSON.parse(settingsData.value)) : 15; + const expiresAt = Date.now() + formInterval * 60 * 1000; await supabase.from('settings').upsert( { key: 'gateway_token', value: JSON.stringify({ token: qrToken, expires_at: expiresAt }) }, @@ -19,6 +27,7 @@ export async function GET() { ); const expiresAtStr = new Date(expiresAt).toLocaleString('id-ID', { + timeZone: 'Asia/Jakarta', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); diff --git a/lentera-frontend/app/api/gateway/validate/route.ts b/lentera-frontend/app/api/gateway/validate/route.ts index 36d4685..afa5749 100644 --- a/lentera-frontend/app/api/gateway/validate/route.ts +++ b/lentera-frontend/app/api/gateway/validate/route.ts @@ -30,8 +30,6 @@ export async function GET(request: Request) { return NextResponse.json({ status: 'error', message: 'Token tidak valid atau kadaluarsa' }, { status: 401 }); } - await supabase.from('settings').update({ value: JSON.stringify({ token: '', expires_at: 0 }) }).eq('key', 'gateway_token'); - return NextResponse.json({ status: 'success', data: { submission_token: token }, diff --git a/lentera-frontend/app/api/return/route.ts b/lentera-frontend/app/api/return/route.ts index a7fa1ef..e242aa2 100644 --- a/lentera-frontend/app/api/return/route.ts +++ b/lentera-frontend/app/api/return/route.ts @@ -11,9 +11,9 @@ function getSupabase() { export async function POST(request: Request) { const supabase = getSupabase(); const body = await request.json(); - const { asset_id, student_name, submission_token } = body; + const { asset_code, student_npm, submission_token } = body; - if (!asset_id || !student_name) { + if (!asset_code || !student_npm) { return NextResponse.json({ status: 'error', message: 'Data tidak lengkap' }, { status: 400 }); } @@ -32,11 +32,21 @@ export async function POST(request: Request) { return NextResponse.json({ status: 'error', message: 'Token tidak valid' }, { status: 401 }); } + const { data: asset } = await supabase + .from('assets') + .select('id') + .eq('code', asset_code) + .single(); + + if (!asset) { + return NextResponse.json({ status: 'error', message: 'Alat tidak ditemukan' }, { status: 404 }); + } + const { data: txn } = await supabase .from('transactions') .select('id') - .eq('asset_id', Number(asset_id)) - .eq('student_name', student_name) + .eq('asset_id', asset.id) + .eq('student_npm', student_npm) .is('returned_at', null) .single(); @@ -58,7 +68,9 @@ export async function POST(request: Request) { await supabase .from('assets') .update({ status: 'available', updated_at: new Date().toISOString() }) - .eq('id', asset_id); + .eq('id', asset.id); + + await supabase.from('settings').update({ value: JSON.stringify({ token: '', expires_at: 0 }) }).eq('key', 'gateway_token'); return NextResponse.json({ status: 'success', data: transaction }); } diff --git a/lentera-frontend/app/cek-alat/page.tsx b/lentera-frontend/app/cek-alat/page.tsx index 62b23da..29effb0 100644 --- a/lentera-frontend/app/cek-alat/page.tsx +++ b/lentera-frontend/app/cek-alat/page.tsx @@ -10,17 +10,37 @@ import Input from '@/components/ui/Input'; import { Asset, Category } from '@/lib/types'; import { isApiStatus } from '@/lib/api'; +interface TransactionData { + student_name: string; + student_class?: string; + subject?: string; + borrowed_at: string; +} + +interface CategoryData { + id: number; + name: string; +} + interface ScanData { - category: string; + category: string | CategoryData; name: string; code: string; status: 'available' | 'borrowed' | 'maintenance'; - borrower?: { - name: string; - class?: string; - subject?: string; - borrowed_at: string; - }; + lastTransaction?: TransactionData | null; +} + +const catName = (cat: string | CategoryData) => typeof cat === 'string' ? cat : cat.name; + +function formatWIB(iso: string): string { + const d = new Date(iso); + const dateStr = d.toLocaleDateString('id-ID', { + timeZone: 'Asia/Jakarta', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' + }); + const timeStr = d.toLocaleTimeString('id-ID', { + timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit', hour12: false + }); + return `${dateStr}, ${timeStr} WIB`; } function CekAlatContent() { @@ -165,7 +185,7 @@ function CekAlatContent() {
-
{scanData.category}
+
{catName(scanData.category)}

{scanData.name}

{scanData.code} @@ -173,14 +193,14 @@ function CekAlatContent() {
{statusBadge(scanData.status)}
- {scanData.status === 'borrowed' && scanData.borrower && ( + {scanData.status === 'borrowed' && scanData.lastTransaction && (

Sedang Dipinjam Oleh:

-
Nama{scanData.borrower.name}
-
Kelas{scanData.borrower.class}
-
Matkul{scanData.borrower.subject}
-
Sejak{scanData.borrower.borrowed_at}
+
Nama{scanData.lastTransaction.student_name}
+
Kelas{scanData.lastTransaction.student_class}
+
Matkul{scanData.lastTransaction.subject}
+
Sejak{formatWIB(scanData.lastTransaction.borrowed_at)}
)} diff --git a/lentera-frontend/app/display/page.tsx b/lentera-frontend/app/display/page.tsx index 3d9b113..e2419cc 100644 --- a/lentera-frontend/app/display/page.tsx +++ b/lentera-frontend/app/display/page.tsx @@ -12,6 +12,13 @@ export default function DisplayQR() { const [qrToken, setQrToken] = useState(''); const [expiresAt, setExpiresAt] = useState(''); const [error, setError] = useState(''); + const [qrInterval, setQrInterval] = useState(1); + + useEffect(() => { + api.get('/admin/settings').then(res => { + if (res.data.qr_interval) setQrInterval(res.data.qr_interval); + }).catch(() => {}); + }, []); useEffect(() => { const fetchData = async () => { @@ -30,12 +37,9 @@ export default function DisplayQR() { }; fetchData(); - const interval = setInterval(fetchData, 60000); + const interval = setInterval(fetchData, qrInterval * 60 * 1000); return () => clearInterval(interval); - }, []); - - const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || (typeof window !== 'undefined' ? window.location.origin : ''); - const scanUrl = frontendUrl ? `${frontendUrl}/scan?token=${qrToken}` : ''; + }, [qrInterval]); if (error) { return ( @@ -71,7 +75,7 @@ export default function DisplayQR() {
{qrToken ? ( )} - -
-

Dev Mode: Klik link di bawah untuk test tanpa scan:

- - {scanUrl} - -
); } diff --git a/lentera-frontend/app/form/borrow/page.tsx b/lentera-frontend/app/form/borrow/page.tsx index 0e8e19c..6bd69cc 100644 --- a/lentera-frontend/app/form/borrow/page.tsx +++ b/lentera-frontend/app/form/borrow/page.tsx @@ -185,6 +185,7 @@ function BorrowFormContent() { value={formData.return_time} onChange={handleChange} required + step="60" />
diff --git a/lentera-frontend/app/form/return/page.tsx b/lentera-frontend/app/form/return/page.tsx index 50002a6..69d016b 100644 --- a/lentera-frontend/app/form/return/page.tsx +++ b/lentera-frontend/app/form/return/page.tsx @@ -16,7 +16,7 @@ function ReturnFormContent() { const submissionToken = searchParams.get('submission_token'); const [options, setOptions] = useState([]); - const [loadingOptions, setLoadingOptions] = useState(!submissionToken); // true if no token + const [loadingOptions, setLoadingOptions] = useState(true); const [error, setError] = useState(submissionToken ? '' : 'Akses ditolak. Token sesi tidak ditemukan.'); const [formData, setFormData] = useState({ @@ -52,7 +52,7 @@ function ReturnFormContent() { await api.post('/return', payload); toast.success('Pengembalian berhasil! Terima kasih.'); - router.push('/'); + router.push(`/cek-alat?code=${encodeURIComponent(formData.asset_code)}`); } catch (err: unknown) { toast.error(getApiErrorMessage(err, 'Pengembalian gagal.')); } @@ -93,7 +93,7 @@ function ReturnFormContent() {

Form Pengembalian Alat

- {options.length === 0 ? ( + {options.length === 0 && !loadingOptions ? (

Tidak ada alat yang sedang dipinjam saat ini.

diff --git a/lentera-frontend/components/ui/Toast.tsx b/lentera-frontend/components/ui/Toast.tsx index e01dd22..aa43d1b 100644 --- a/lentera-frontend/components/ui/Toast.tsx +++ b/lentera-frontend/components/ui/Toast.tsx @@ -100,7 +100,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { }} > {children} -
+
{toasts.map((toast) => (
diff --git a/lentera-frontend/supabase/migrations/20260729000002_add_transaction_fields.sql b/lentera-frontend/supabase/migrations/20260729000002_add_transaction_fields.sql new file mode 100644 index 0000000..902f228 --- /dev/null +++ b/lentera-frontend/supabase/migrations/20260729000002_add_transaction_fields.sql @@ -0,0 +1,4 @@ +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS student_npm TEXT; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS student_prodi TEXT; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS lecturer TEXT; +ALTER TABLE transactions ADD COLUMN IF NOT EXISTS expected_return_at TIMESTAMPTZ; diff --git a/start.sh b/start.sh deleted file mode 100755 index 0081a2d..0000000 --- a/start.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -e - -cleanup() { - echo "" - echo "Shutting down..." - kill "$BACKEND_PID" 2>/dev/null || true - kill "$FRONTEND_PID" 2>/dev/null || true - exit 0 -} -trap cleanup SIGINT SIGTERM - -cd "$(dirname "$0")" - -(cd lentera-backend && php artisan serve --host=0.0.0.0 --port=8000) & -BACKEND_PID=$! - -(cd lentera-frontend && npm run dev) & -FRONTEND_PID=$! - -echo "Backend : http://localhost:8000" -echo "Frontend : http://localhost:3000" -echo "Press Ctrl+C to stop both services." - -wait
KODE | MERK ALAT STATUS
{searchQuery ? `Tidak ada alat yang cocok dengan "${searchQuery}"` : "Tidak ada alat di kategori ini."}
+
+ +

{searchQuery ? `Tidak ada alat yang cocok dengan "${searchQuery}"` : "Tidak ada alat di kategori ini."}

+
+