A web-based equipment loan management system for organizations. Browse inventory, request loans, and manage returns with automated email notifications.
- Browse equipment catalog with search and filtering
- Request loans with flexible date ranges
- Automated email notifications for loan reminders and updates
- Admin dashboard for managing inventory, locations, and loans
- Multi-user support with role-based permissions (Admin, User, Kiosk)
- Support for normal and temporary items
- Organized inventory with categories, locations, and boxes
- Browse available equipment in the catalog
- Request a loan by selecting dates and items
- Receive an email confirmation right away — requests are accepted automatically, there is no approval queue to wait for
- Get automated reminders before pickup, and when a loan runs late
- Return items and view loan history
- Manage equipment catalog (add, edit, remove items)
- Organize items by categories, locations, and boxes
- Track all active and past loans; reject or cancel ones that shouldn't run (rejecting is after the fact — see the note on automatic acceptance above)
- Handle returns, including items dropped into a return box
- Manage user accounts and permissions
- Self-service stations for quick item checkout and returns
- Simplified interface for public access points
- Next.js 16 (App Router) — React framework
- React 19 with TypeScript (strict)
- Prisma 7 — Database ORM
- PostgreSQL — Database
- NextAuth.js — Authentication
- Tailwind CSS 4 — Utility-first styling (CSS-first config)
- shadcn/ui — Component primitives (owned in
components/ui/) - Radix UI — Unstyled accessible primitives (Dialog, Switch, Tooltip, Label)
- next-themes — Dark mode (class-based)
- sonner — Toast notifications
- react-select — Creatable/multi selects (shadcn-styled wrapper)
- lucide-react — Icons
- SWR — Client-side data fetching
- AWS SES — Email notifications
- All UI primitives live in
components/ui/— they're source files you own and edit freely (shadcn pattern, not an installed library). - Design tokens are CSS variables in
styles/globals.css. Colors map to HSL vars (--primary,--background,--card,--destructive,--success,--warning, etc.) with.darkoverrides. - There is no
tailwind.config.ts— Tailwind 4 is configured CSS-first.styles/globals.cssdoes@import 'tailwindcss'and exposes the tokens through an@themeblock, which is what makesbg-primary,text-muted-foregroundetc. work. PostCSS wires it up via@tailwindcss/postcssinpostcss.config.js. Dark mode is class-based — toggled bynext-themesvia theclassattribute on<html>. - Toasts use
sonner. Importtoastfromsonnerand calltoast.success(...),toast.error(...),toast.warning(...). - Creatable selects use the
CreatableSelectwrapper incomponents/ui/creatable-select.tsx— it stylesreact-select's creatable via theclassNamesAPI so it respects dark mode and tokens without runtime theme juggling.
- Install dependencies:
pnpm install- Set up environment variables:
cp .env.example .envThe defaults in .env.example match docker-compose.yml, so the database
works out of the box; the AWS and Google values are only needed for real email,
photo uploads, and Google sign-in.
- Start local database:
docker-compose up -d- Run migrations and seed data:
pnpm prisma migrate dev
pnpm prisma db seed- Start development server (also boots the local SES mock automatically):
pnpm devVisit http://localhost:3000.
pnpm dev— Next dev server + local SES mockpnpm build—prisma migrate deploy+next buildpnpm start— Production serverpnpm type-check—tsc --noEmitpnpm lint— ESLint (Next config)pnpm test— Vitest against a disposable Postgres (docker-compose)pnpm test:ci— Vitest without docker (expectsDATABASE_URLalready set)
The dev script starts aws-ses-v2-local in parallel. All emails are captured instead of being sent.
Open the email viewer at http://localhost:8005 to see sent emails.
To review the templates without triggering the flows that send them:
pnpm tsx scripts/preview-emails.tsIt renders every template with sample data — no app or database needed — to
/tmp/klapi-emails/, with an index.html linking them all.
Schema is defined in prisma/schema.prisma. After schema changes:
pnpm prisma migrate dev --name description_of_changeGenerate test data:
pnpm prisma db seedThe diagram below is generated from prisma/schema.prisma
by prisma-erd-generator. Refresh it after a schema change with:
pnpm erdThat regenerates the mermaid source into .erd.md (gitignored) and splices it
between the markers below. Markdown output is pure text generation, so it costs
~25 ms — the .pdf/.svg/.png output modes are the expensive ones, because
they rasterize through a headless Chrome that puppeteer has to download first.
Don't switch the generator's output to one of those.
erDiagram
ItemHistoryAction {
CREATED CREATED
UPDATED UPDATED
ARCHIVED ARCHIVED
RESTORED RESTORED
PROMOTED PROMOTED
}
AnnouncementKind {
KORJATTAVAA KORJATTAVAA
TIEDOKSI TIEDOKSI
}
LoanHistoryAction {
CREATED CREATED
UPDATED UPDATED
APPROVED APPROVED
REJECTED REJECTED
CANCELLED CANCELLED
STARTED STARTED
RETURNED_TO_BOX RETURNED_TO_BOX
PROCESSED_FROM_BOX PROCESSED_FROM_BOX
}
Group {
ADMIN ADMIN
USER USER
KIOSK KIOSK
}
ItemType {
normal normal
temporary temporary
}
ReportStatus {
OPEN OPEN
IN_PROGRESS IN_PROGRESS
RESOLVED RESOLVED
}
ReportCreated {
BEFORE_LOAN BEFORE_LOAN
AFTER_LOAN AFTER_LOAN
}
LoanStatus {
ACCEPTED ACCEPTED
REJECTED REJECTED
CANCELLED CANCELLED
INUSE INUSE
IN_BOX IN_BOX
PARTIALLY_RETURNED PARTIALLY_RETURNED
RETURNED RETURNED
}
ReservationStatus {
ACCEPTED ACCEPTED
REJECTED REJECTED
INUSE INUSE
IN_BOX IN_BOX
RETURNED RETURNED
}
EmailType {
EXPIRING_LOAN_REMINDER EXPIRING_LOAN_REMINDER
PICKUP_REMINDER PICKUP_REMINDER
PICKUP_OVERDUE_REMINDER PICKUP_OVERDUE_REMINDER
OVERDUE_USER_REMINDER OVERDUE_USER_REMINDER
OVERDUE_ADMIN_NOTIFICATION OVERDUE_ADMIN_NOTIFICATION
OLD_BOX_ADMIN_NOTIFICATION OLD_BOX_ADMIN_NOTIFICATION
}
"Account" {
String id "🗝️"
String type
String provider
String providerAccountId
String refresh_token "❓"
String access_token "❓"
Int expires_at "❓"
String token_type "❓"
String scope "❓"
String id_token "❓"
String session_state "❓"
}
"Session" {
String id "🗝️"
String sessionToken
DateTime expires
}
"User" {
String id "🗝️"
String name "❓"
String email "❓"
DateTime emailVerified "❓"
String image "❓"
DateTime deletedAt "❓"
Group group
String password "❓"
DateTime passwordExpiresAt "❓"
String kioskPasswordEnc "❓"
String kioskElevatePin "❓"
String username "❓"
Boolean emailNewLoanNotification
Boolean emailWeeklyReminder
Boolean emailExpiringReminder
Boolean emailOldBoxNotification
Boolean emailOverdueNotification
}
"Item" {
String id "🗝️"
String name
String description "❓"
Int amount
ItemType type
DateTime deletedAt "❓"
}
"Template" {
String id "🗝️"
String name
String description "❓"
DateTime createdAt
DateTime updatedAt
}
"TemplateItem" {
String id "🗝️"
Int amount
}
"ItemHistory" {
String id "🗝️"
ItemHistoryAction action
Json details "❓"
DateTime createdAt
}
"Announcement" {
String id "🗝️"
String message
AnnouncementKind kind
DateTime createdAt
DateTime expiresAt "❓"
}
"Location" {
String name
String description "❓"
String id "🗝️"
}
"Box" {
String id "🗝️"
String name
String description "❓"
DateTime createdAt
DateTime updatedAt
}
"Category" {
String id "🗝️"
String name
String description "❓"
}
"Reservation" {
String id "🗝️"
Int amount
ReservationStatus status
}
"Loan" {
String id "🗝️"
LoanStatus status
DateTime startTime
DateTime endTime
String description "❓"
String loaner "❓"
}
"LoanHistory" {
String id "🗝️"
LoanHistoryAction action
Json details "❓"
DateTime createdAt
}
"Report" {
String id "🗝️"
String content
ReportStatus status
DateTime createdAt
ReportCreated created
}
"ReportAffectedItem" {
String id "🗝️"
Int amount
}
"EmailLog" {
String id "🗝️"
EmailType emailType
DateTime sentAt
}
"Account" }o--|| "User" : "user"
"Session" }o--|| "User" : "user"
"User" |o--|| "Group" : "enum:group"
"Item" |o--|| "ItemType" : "enum:type"
"Item" }o--|o "Location" : "location"
"Item" o{--}o "Category" : ""
"TemplateItem" }o--|| "Template" : "template"
"TemplateItem" }o--|| "Item" : "item"
"ItemHistory" |o--|| "ItemHistoryAction" : "enum:action"
"ItemHistory" }o--|| "Item" : "item"
"ItemHistory" }o--|o "User" : "actedBy"
"Announcement" |o--|| "AnnouncementKind" : "enum:kind"
"Announcement" }o--|o "Item" : "item"
"Announcement" }o--|o "Report" : "report"
"Reservation" |o--|| "ReservationStatus" : "enum:status"
"Reservation" }o--|| "Item" : "item"
"Reservation" }o--|| "Loan" : "loan"
"Loan" |o--|| "LoanStatus" : "enum:status"
"Loan" }o--|o "Box" : "box"
"Loan" }o--|| "User" : "user"
"LoanHistory" |o--|| "LoanHistoryAction" : "enum:action"
"LoanHistory" }o--|| "Loan" : "loan"
"LoanHistory" }o--|o "User" : "actedBy"
"Report" |o--|| "ReportStatus" : "enum:status"
"Report" |o--|| "ReportCreated" : "enum:created"
"Report" }o--|| "Loan" : "loan"
"ReportAffectedItem" }o--|| "Report" : "report"
"ReportAffectedItem" }o--|| "Item" : "item"
"EmailLog" |o--|| "EmailType" : "enum:emailType"
"EmailLog" }o--|| "Loan" : "loan"
"EmailLog" }o--|| "User" : "user"
Supports Google OAuth and username/password authentication via NextAuth.js. Configure providers in the NextAuth API route.
User Roles:
- Admin: Full access to manage catalog, users, and loans
- User: Browse catalog, request loans, view own history
- Kiosk: Simplified interface for self-service stations
Admins can elevate a kiosk session to ADMIN temporarily via a 4-digit PIN (set in /admin). The elevated session auto-expires after 30 minutes.
Klapi is deployed automatically when a new commit lands on main.
See .env.example for the full list with comments. The ones a deployment cannot run without:
DATABASE_URL: PostgreSQL connection stringNEXTAUTH_SECRET: Random secret for NextAuth (generate withopenssl rand -base64 32)NEXTAUTH_URL: Public URL of your deploymentGOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET: Google OAuth credentialsKLAPI_AWS_REGION,KLAPI_AWS_ACCESS_KEY_ID,KLAPI_AWS_SECRET_ACCESS_KEY: AWS credentials for SES and S3 (prefixed because Vercel reservesAWS_*)AWS_SES_FROM_EMAIL: Sender address for all notification emailAWS_BUCKET_NAME,NEXT_PUBLIC_AWS_ITEM_PHOTOS_URL: S3 bucket for item photos, and its public URLCRON_SECRET: Shared secret the/api/cron/*routes require asAuthorization: Bearer …. Without it the nightly reminder/overdue/auto-start jobs return 401 and silently stop working.
app/ App Router — pages (server components by default) and
app/api/ route handlers (`route.ts`), including the cron jobs
components/ App-specific React components
components/ui/ shadcn/ui primitives (owned source, edit freely)
contexts/ React contexts (cart, dates)
hooks/ Custom hooks
lib/ `cn()` className merger and the NextAuth config
styles/globals.css Tailwind import + `@theme` design tokens (light/dark)
types/ Shared types and the next-auth session augmentation
utils/ Server and shared helpers (Prisma client, loan helpers, etc.)
prisma/ Schema, migrations, seed
scripts/ Operator scripts run with `tsx` (kiosk user, email previews)
__tests__/ Vitest suites — mostly API integration tests