Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
33f3c8d
fix: use window.location.origin for scan URL instead of hardcoded loc…
muadzhdz Jul 29, 2026
3272513
chore: remove Dev Mode text from display page
muadzhdz Jul 29, 2026
c5bc8e9
chore: remove Dockerfile & start.sh, add README with tech stack
muadzhdz Jul 29, 2026
d46a79d
chore: add vercel.json with rootDirectory for auto-deploy
muadzhdz Jul 29, 2026
ce15cc4
test: trigger auto-deploy from production branch
muadzhdz Jul 29, 2026
6ed6b90
chore: remove accidental .gitignore
muadzhdz Jul 29, 2026
56e5b48
ci: add GitHub Actions workflow for Vercel deployment
muadzhdz Jul 29, 2026
abd2584
chore: remove vercel.json (rootDirectory sudah di-set via API)
muadzhdz Jul 29, 2026
540d1e3
ci: add vercel link step to workflow
muadzhdz Jul 29, 2026
af9cd05
fix: commit .vercel/project.json for GitHub Actions deployment
muadzhdz Jul 29, 2026
218fec8
ci: add --scope flag to vercel deploy
muadzhdz Jul 29, 2026
874cd8e
UX: toast bottom-right, sticky header, konfirmasi modal & loading state
muadzhdz Jul 29, 2026
35cfe12
fix: vercel.json rootDirectory buat auto-deploy preview
muadzhdz Jul 29, 2026
706d7d5
hapus vercel.json (gak ngefek, di-override project setting)
muadzhdz Jul 29, 2026
294f082
bersihin .vercel files, tambah .gitignore, set rootDirectory lentera-…
muadzhdz Jul 29, 2026
1fd0e58
hapus teks search result count dari dashboard
muadzhdz Jul 29, 2026
6fc6395
fix: hapus working-directory di workflow biar gak dobel path sama roo…
muadzhdz Jul 29, 2026
4d16244
chore: hapus GH Actions workflow, pake native Vercel auto-deploy
muadzhdz Jul 29, 2026
c5a0bbd
fix: gateway flow, field mismatch, interval settings, number input
muadzhdz Jul 29, 2026
ac61f64
fix: input number bisa kosong pas backspace
muadzhdz Jul 29, 2026
257a073
fix: form-options kirim asset code bukan id
muadzhdz Jul 29, 2026
83a9f25
feat: tambah migration transaction fields (npm, prodi, lecturer, expe…
muadzhdz Jul 29, 2026
5fcbc38
fix: scan API maybeSingle, assets API include lastTransaction, cek-al…
muadzhdz Jul 29, 2026
432ad8a
fix: category type, format WIB, time 24h
muadzhdz Jul 29, 2026
1bdfa5a
fix: return form glitch + WIB + redirect, sudo toast, CSV WIB
muadzhdz Jul 29, 2026
2ceabcd
chore: migrate supabase sydney to singapore
muadzhdz Jul 29, 2026
3d8e002
chore: cleanup dump files
muadzhdz Jul 29, 2026
41400a6
docs: update readme with project structure & tech stack
muadzhdz Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/.vercel
21 changes: 0 additions & 21 deletions Dockerfile

This file was deleted.

80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion lentera-frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ yarn-error.log*
.env*

# vercel
.vercel
.vercel/*.env

# typescript
*.tsbuildinfo
Expand Down
1 change: 1 addition & 0 deletions lentera-frontend/.vercel/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"projectId":"prj_YpwoVilhpNOGVH3XErGd8uoCyoLL","orgId":"team_p7Y24AzkJDCLJMVSGhyG4D5a","projectName":"lentera"}
80 changes: 59 additions & 21 deletions lentera-frontend/app/admin/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<HTMLDivElement>(null);

const fetchData = useCallback(async () => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }));
};

Expand Down Expand Up @@ -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;
Expand All @@ -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!");
};

Expand All @@ -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);
Expand All @@ -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');
Expand All @@ -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!");
};

Expand Down Expand Up @@ -446,14 +459,13 @@ export default function AdminDashboard() {
<input ref={searchRef} type="text" value={searchQuery} onChange={(e) => 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" />
</div>
<Button variant="success" size="sm" onClick={() => openAssetForm('add')} className="!shadow-none">+ Tambah</Button>
<Button variant="ghost" size="sm" onClick={downloadAllQR} className="text-xs">Batch QR</Button>
<Button variant="ghost" size="sm" onClick={exportCSV} className="text-xs">Export CSV</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmModal('batch-qr')} className="text-xs" disabled={actionLoading}>Unduh Semua QR</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmModal('export-csv')} className="text-xs" disabled={actionLoading}>Ekspor CSV</Button>
</div>
</div>

<div className="h-[750px] overflow-y-auto overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="border-b border-[var(--card-border)] text-sm text-slate-400">
<thead className="sticky top-0 bg-[var(--card)] border-b border-[var(--card-border)] text-sm text-slate-400">
<tr>
<th className="p-4 font-semibold">KODE | MERK ALAT</th>
<th className="p-4 font-semibold">STATUS</th>
Expand All @@ -463,7 +475,12 @@ export default function AdminDashboard() {
</thead>
<tbody className="divide-y divide-[var(--card-border)]">
{filteredAssets.length === 0 && (
<tr><td colSpan={4} className="p-8 text-center text-slate-500">{searchQuery ? `Tidak ada alat yang cocok dengan "${searchQuery}"` : "Tidak ada alat di kategori ini."}</td></tr>
<tr><td colSpan={4} className="p-12 text-center">
<div className="flex flex-col items-center gap-3">
<svg className="w-10 h-10 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /></svg>
<p className="text-slate-500">{searchQuery ? `Tidak ada alat yang cocok dengan "${searchQuery}"` : "Tidak ada alat di kategori ini."}</p>
</div>
</td></tr>
)}
{filteredAssets.map(asset => (
<tr key={asset.id} className="hover:bg-[var(--overlay)]/50 transition">
Expand All @@ -478,7 +495,7 @@ export default function AdminDashboard() {
{(() => {
const txn = getTxn(asset);
return asset.status === 'borrowed' && txn ? (
<div><div className="text-sm font-semibold">{txn.student_name}</div><div className="text-xs text-slate-500 font-mono">{txn.borrowed_at}</div></div>
<div><div className="text-sm font-semibold">{txn.student_name}</div><div className="text-xs text-slate-500">{formatWIB(txn.borrowed_at)}</div></div>
) : (
<span className="text-slate-600 text-sm italic">-</span>
);
Expand Down Expand Up @@ -541,12 +558,12 @@ export default function AdminDashboard() {
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">Interval Refresh Barcode (Menit)</label>
<Input type="number" min="1" value={settingsModal.qr_interval} onChange={(e) => setSettingsModal({...settingsModal, qr_interval: Number(e.target.value)})} />
<Input type="number" min="1" value={settingsModal.qr_interval} onChange={(e) => setSettingsModal({...settingsModal, qr_interval: e.target.value === '' ? '' : Number(e.target.value)})} />
<p className="text-xs text-slate-500 mt-1">Siklus perubahan gambar barcode di layar Monitor Kiosk.</p>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">Durasi Isi Form Peminjaman (Menit)</label>
<Input type="number" min="1" value={settingsModal.form_interval} onChange={(e) => setSettingsModal({...settingsModal, form_interval: Number(e.target.value)})} />
<Input type="number" min="1" value={settingsModal.form_interval} onChange={(e) => setSettingsModal({...settingsModal, form_interval: e.target.value === '' ? '' : Number(e.target.value)})} />
<p className="text-xs text-slate-500 mt-1">Lama waktu token akses mahasiswa berlaku sebelum kadaluarsa.</p>
</div>
</div>
Expand Down Expand Up @@ -588,6 +605,27 @@ export default function AdminDashboard() {
)}
</Modal>

{/* CONFIRMATION MODAL */}
<Modal isOpen={confirmModal !== null} onClose={() => setConfirmModal(null)} title="Konfirmasi" size="sm">
<div className="text-center">
<div className="w-16 h-16 bg-amber-500/15 text-amber-400 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
</div>
<h3 className="text-xl font-bold mb-2">Konfirmasi {confirmModal === 'batch-qr' ? 'Download Semua QR' : 'Ekspor CSV'}</h3>
<p className="text-sm text-slate-400 mb-6">
{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.`}
</p>
<div className="flex gap-3">
<Button variant="ghost" fullWidth onClick={() => setConfirmModal(null)}>Batal</Button>
<Button variant="primary" fullWidth onClick={() => { setConfirmModal(null); if (confirmModal === 'batch-qr') downloadAllQR(); else exportCSV(); }}>
Lanjutkan
</Button>
</div>
</div>
</Modal>

{/* LOGOUT CONFIRMATION MODAL */}
<Modal isOpen={showLogoutModal} onClose={() => setShowLogoutModal(false)} title="Konfirmasi Logout" size="sm">
<div className="text-center">
Expand Down
4 changes: 2 additions & 2 deletions lentera-frontend/app/api/assets/form-options/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
);
}

Expand Down Expand Up @@ -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 || '',
};
});
Expand Down
23 changes: 22 additions & 1 deletion lentera-frontend/app/api/assets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function getSupabase() {

export async function GET() {
const supabase = getSupabase();

const { data: assets, error } = await supabase
.from('assets')
.select('*, category:categories(*)')
Expand All @@ -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<string, unknown>).lastTransaction = txn || null;
}
}
}
}

return NextResponse.json({ status: 'success', data: result });
}

export async function POST(request: Request) {
Expand Down
7 changes: 5 additions & 2 deletions lentera-frontend/app/api/assets/scan/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Loading