first commit for the leave management system. built a Leave Management System with role-based access:

Employees can apply for leave and track balance, status, and history.
Managers can view all requests and approve/reject them.
Login with separate dashboards for employees and managers with the dummy json.
Tables with filters (All, Pending, Approved, Rejected) for easy management.
This commit is contained in:
sumona-banerjeee 2026-04-29 11:22:14 +05:30
parent 3fdca895cc
commit c5946ce95e
33 changed files with 7173 additions and 212 deletions

View File

@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@ -1 +0,0 @@
@AGENTS.md

135
app/employee/page.tsx Normal file
View File

@ -0,0 +1,135 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import {
getLeavesForUser,
getUserBalance,
LeaveRequest,
} from "@/lib/leavesStore";
import { LeaveSheet } from "@/components/LeaveSheet";
import { LeaveTable } from "@/components/LeaveTable";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
CalendarDays,
PlusCircle,
Clock,
CheckCircle2,
XCircle,
Wallet,
} from "lucide-react";
export default function EmployeePage() {
const { user, logout, isLoading } = useAuth();
const router = useRouter();
const [leaves, setLeaves] = useState<LeaveRequest[]>([]);
const [balance, setBalance] = useState(0);
const [sheetOpen, setSheetOpen] = useState(false);
const [dataLoaded, setDataLoaded] = useState(false);
const loadData = useCallback(async () => {
if (!user) return;
const [data, bal] = await Promise.all([
getLeavesForUser(user.id),
Promise.resolve(getUserBalance(user.id)),
]);
setLeaves(data.sort((a, b) => b.appliedAt.localeCompare(a.appliedAt)));
setBalance(bal);
setDataLoaded(true);
}, [user]);
useEffect(() => {
if (!isLoading && !user) router.replace("/");
else if (!isLoading && user?.role === "manager") router.replace("/manager");
else if (!isLoading && user) loadData();
}, [user, isLoading, router, loadData]);
if (isLoading || !user || !dataLoaded) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: "var(--surface-base)" }}>
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-400))" }}>
<CalendarDays className="w-5 h-5" style={{ color: "var(--text-on-brand)" }} />
</div>
<p className="text-sm font-medium" style={{ color: "var(--text-muted)" }}>Loading</p>
</div>
</div>
);
}
const pending = leaves.filter((l) => l.status === "Pending").length;
const approved = leaves.filter((l) => l.status === "Approved").length;
const rejected = leaves.filter((l) => l.status === "Rejected").length;
const stats = [
{ label: "Leave Balance", value: balance, unit: "days", icon: Wallet, color: "var(--brand-600)", bg: "var(--brand-50)", border: "var(--brand-200)" },
{ label: "Pending", value: pending, icon: Clock, color: "var(--color-pending)", bg: "var(--color-pending-bg)", border: "var(--color-pending-border)" },
{ label: "Approved", value: approved, icon: CheckCircle2, color: "var(--color-success)", bg: "var(--color-success-bg)", border: "var(--color-success-border)" },
{ label: "Rejected", value: rejected, icon: XCircle, color: "var(--color-danger)", bg: "var(--color-danger-bg)", border: "var(--color-danger-border)" },
];
return (
<div className="min-h-screen" style={{ background: "var(--surface-base)" }}>
<header style={{ background: "var(--surface-card)", borderBottom: "1px solid var(--surface-border)", position: "sticky", top: 0, zIndex: 40 }}>
<div className="max-w-5xl mx-auto px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-400))", boxShadow: "0 4px 10px -2px rgb(99 102 241 / 0.3)" }}>
<CalendarDays className="w-4.5 h-4.5" style={{ color: "var(--text-on-brand)" }} />
</div>
<div><span className="font-bold text-base" style={{ color: "var(--text-primary)" }}>LeaveFlow</span></div>
</div>
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>{user.name}</span>
</div>
<Button size="sm" onClick={logout} className="text-xs font-semibold px-4" style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-500))", color: "var(--text-on-brand)", border: "none", borderRadius: "var(--radius-sm)" }}>Logout</Button>
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-4 py-8 space-y-8">
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>Hello, {user.name.split(" ")[0]}</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>Apply for leave from here</p>
</div>
<Button onClick={() => setSheetOpen(true)} className="gap-2 font-semibold" style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-500))", border: "none", borderRadius: "var(--radius-md)", boxShadow: "0 4px 12px -2px rgb(99 102 241 / 0.35)" }}>
<PlusCircle className="w-4 h-4" />Apply Leave
</Button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{stats.map((s) => {
const Icon = s.icon;
return (
<Card key={s.label} style={{ border: `1px solid ${s.border}`, background: s.bg, borderRadius: "var(--radius-md)", boxShadow: "none" }}>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" style={{ color: s.color }} />
<span className="text-xs font-medium" style={{ color: s.color }}>{s.label}</span>
</div>
<p className="text-2xl font-bold" style={{ color: s.color }}>
{s.value}{s.unit && <span className="text-sm font-normal ml-1">{s.unit}</span>}
</p>
</CardContent>
</Card>
);
})}
</div>
<Card style={{ border: "1px solid var(--surface-border)", borderRadius: "var(--radius-lg)", background: "var(--surface-card)", boxShadow: "var(--shadow-card)" }}>
<CardHeader className="pb-0" style={{ borderBottom: "1px solid var(--surface-border)", paddingBottom: "1rem", marginBottom: 0 }}>
<CardTitle className="text-base font-semibold" style={{ color: "var(--text-primary)" }}>Leave History</CardTitle>
</CardHeader>
<CardContent className="p-0">
<LeaveTable leaves={leaves} />
</CardContent>
</Card>
</main>
<LeaveSheet open={sheetOpen} onOpenChange={setSheetOpen} userId={user.id} leaveBalance={balance} onSuccess={loadData} />
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,26 +1,214 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--background: #ffffff;
--foreground: #171717;
/* Brand palette */
--brand-50: #f0f4ff;
--brand-100: #e0eaff;
--brand-200: #c7d7fe;
--brand-300: #a5b8fc;
--brand-400: #818df8;
--brand-500: #6366f1;
--brand-600: #4f46e5;
--brand-700: #4338ca;
--brand-800: #3730a3;
--brand-900: #312e81;
/* Semantic colours */
--color-success: #16a34a;
--color-success-bg: #f0fdf4;
--color-success-border: #bbf7d0;
--color-warning: #d97706;
--color-warning-bg: #fffbeb;
--color-warning-border: #fde68a;
--color-danger: #dc2626;
--color-danger-bg: #fef2f2;
--color-danger-border: #fecaca;
--color-pending: #7c3aed;
--color-pending-bg: #f5f3ff;
--color-pending-border: #ddd6fe;
/* Surface */
--surface-base: #f8faff;
--surface-card: #ffffff;
--surface-border: #e5eaf5;
--surface-hover: #f0f4ff;
/* Text */
--text-primary: #1e1b4b;
--text-secondary: #4b5563;
--text-muted: #9ca3af;
--text-on-brand: #ffffff;
/* Shadows */
--shadow-card: 0 1px 3px 0 rgb(99 102 241 / 0.08), 0 1px 2px -1px rgb(99 102 241 / 0.08);
--shadow-card-hover: 0 4px 16px 0 rgb(99 102 241 / 0.12), 0 2px 6px -1px rgb(99 102 241 / 0.08);
--shadow-modal: 0 20px 60px -10px rgb(79 70 229 / 0.2);
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Shadcn compat */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
* {
box-sizing: border-box;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background-color: var(--surface-base);
color: var(--text-primary);
font-family: "DM Sans", "Geist", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Smooth transitions */
* {
transition-property: background-color, border-color, color, box-shadow;
transition-duration: 150ms;
transition-timing-function: ease;
}
/* Status badge colours via CSS variables */
.status-approved {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success-border);
}
.status-pending {
background: var(--color-pending-bg);
color: var(--color-pending);
border: 1px solid var(--color-pending-border);
}
.status-rejected {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger-border);
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@ -1,33 +1,30 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "LeaveFlow Leave Management",
description: "Streamlined leave management for teams",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className={cn("font-sans", geist.variable)}>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}

147
app/manager/page.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { getLeaves, updateLeaveStatus, LeaveRequest } from "@/lib/leavesStore";
import { LeaveTable } from "@/components/LeaveTable";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
CalendarDays,
Clock,
CheckCircle2,
XCircle,
LayoutDashboard,
} from "lucide-react";
type FilterStatus = "All" | "Pending" | "Approved" | "Rejected";
export default function ManagerPage() {
const { user, logout, isLoading } = useAuth();
const router = useRouter();
const [leaves, setLeaves] = useState<LeaveRequest[]>([]);
const [filter, setFilter] = useState<FilterStatus>("All");
const [loadingId, setLoadingId] = useState<number | null>(null);
const [dataLoaded, setDataLoaded] = useState(false);
const loadData = useCallback(async () => {
const data = await getLeaves();
setLeaves(data.sort((a, b) => b.appliedAt.localeCompare(a.appliedAt)));
setDataLoaded(true);
}, []);
useEffect(() => {
if (!isLoading && !user) router.replace("/");
else if (!isLoading && user?.role === "employee") router.replace("/employee");
else if (!isLoading && user) loadData();
}, [user, isLoading, router, loadData]);
const handleApprove = async (id: number) => {
setLoadingId(id);
await updateLeaveStatus(id, "Approved");
await loadData();
setLoadingId(null);
};
const handleReject = async (id: number) => {
setLoadingId(id);
await updateLeaveStatus(id, "Rejected");
await loadData();
setLoadingId(null);
};
if (isLoading || !user || !dataLoaded) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: "var(--surface-base)" }}>
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-400))" }}>
<CalendarDays className="w-5 h-5" style={{ color: "var(--text-on-brand)" }} />
</div>
<p className="text-sm font-medium" style={{ color: "var(--text-muted)" }}>Loading</p>
</div>
</div>
);
}
const pending = leaves.filter((l) => l.status === "Pending").length;
const approved = leaves.filter((l) => l.status === "Approved").length;
const rejected = leaves.filter((l) => l.status === "Rejected").length;
const filtered = filter === "All" ? leaves : leaves.filter((l) => l.status === filter);
const stats = [
{ label: "Total Requests", value: leaves.length, icon: LayoutDashboard, color: "var(--brand-600)", bg: "var(--brand-50)", border: "var(--brand-200)" },
{ label: "Pending", value: pending, icon: Clock, color: "var(--color-pending)", bg: "var(--color-pending-bg)", border: "var(--color-pending-border)" },
{ label: "Approved", value: approved, icon: CheckCircle2, color: "var(--color-success)", bg: "var(--color-success-bg)", border: "var(--color-success-border)" },
{ label: "Rejected", value: rejected, icon: XCircle, color: "var(--color-danger)", bg: "var(--color-danger-bg)", border: "var(--color-danger-border)" },
];
const filters: FilterStatus[] = ["All", "Pending", "Approved", "Rejected"];
const filterCounts: Record<FilterStatus, number> = { All: leaves.length, Pending: pending, Approved: approved, Rejected: rejected };
return (
<div className="min-h-screen" style={{ background: "var(--surface-base)" }}>
<header style={{ background: "var(--surface-card)", borderBottom: "1px solid var(--surface-border)", position: "sticky", top: 0, zIndex: 40 }}>
<div className="max-w-6xl mx-auto px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-400))", boxShadow: "0 4px 10px -2px rgb(99 102 241 / 0.3)" }}>
<CalendarDays className="w-4.5 h-4.5" style={{ color: "var(--text-on-brand)" }} />
</div>
<div><span className="font-bold text-base" style={{ color: "var(--text-primary)" }}>LeaveFlow</span></div>
</div>
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>{user.name}</span>
</div>
<Button size="sm" onClick={logout} className="text-xs font-semibold px-4" style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-500))", color: "var(--text-on-brand)", border: "none", borderRadius: "var(--radius-sm)" }}>Logout</Button>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8 space-y-8">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>Manager Dashboard</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>Review and manage all employee leave requests</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{stats.map((s) => {
const Icon = s.icon;
return (
<Card key={s.label} style={{ border: `1px solid ${s.border}`, background: s.bg, borderRadius: "var(--radius-md)", boxShadow: "none" }}>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" style={{ color: s.color }} />
<span className="text-xs font-medium" style={{ color: s.color }}>{s.label}</span>
</div>
<p className="text-2xl font-bold" style={{ color: s.color }}>{s.value}</p>
</CardContent>
</Card>
);
})}
</div>
<Card style={{ border: "1px solid var(--surface-border)", borderRadius: "var(--radius-lg)", background: "var(--surface-card)", boxShadow: "var(--shadow-card)" }}>
<CardHeader style={{ borderBottom: "1px solid var(--surface-border)", paddingBottom: "1rem" }}>
<div className="flex items-center justify-between flex-wrap gap-3">
<CardTitle className="text-base font-semibold" style={{ color: "var(--text-primary)" }}>Leave Requests</CardTitle>
<div className="flex gap-1 p-1 rounded-lg" style={{ background: "var(--surface-base)", border: "1px solid var(--surface-border)" }}>
{filters.map((f) => (
<button key={f} onClick={() => setFilter(f)} className="text-xs font-medium px-3 py-1.5 rounded-md transition-all flex items-center gap-1.5" style={filter === f ? { background: "var(--surface-card)", color: "var(--brand-600)", boxShadow: "var(--shadow-card)" } : { color: "var(--text-muted)" }}>
{f}
{filterCounts[f] > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full text-[10px] font-bold" style={{ background: filter === f ? "var(--brand-100)" : "var(--surface-border)", color: filter === f ? "var(--brand-700)" : "var(--text-muted)" }}>{filterCounts[f]}</span>
)}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<LeaveTable leaves={filtered} isManager onApprove={handleApprove} onReject={handleReject} loadingId={loadingId} />
</CardContent>
</Card>
</main>
</div>
);
}

View File

@ -1,65 +1,157 @@
import Image from "next/image";
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { CalendarDays, AlertCircle, Loader2 } from "lucide-react";
export default function LoginPage() {
const { login, user, isLoading } = useAuth();
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isLoading && user) {
router.replace(user.role === "manager" ? "/manager" : "/employee");
}
}, [user, isLoading, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
setError("Please enter username and password");
return;
}
setLoading(true);
setError("");
const result = await login(username, password);
setLoading(false);
if (!result.ok) {
setError(result.error ?? "Login failed");
} else if (result.ok) {
// redirect handled by useEffect
}
};
if (isLoading) return null;
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<main className="min-h-screen flex items-center justify-center p-4" style={{ background: "var(--surface-base)" }}>
{/* Decorative blobs */}
<div
className="fixed top-0 left-0 w-96 h-96 rounded-full pointer-events-none"
style={{
background: "radial-gradient(circle, var(--brand-200) 0%, transparent 70%)",
opacity: 0.4,
transform: "translate(-30%, -30%)",
}}
/>
<div
className="fixed bottom-0 right-0 w-80 h-80 rounded-full pointer-events-none"
style={{
background: "radial-gradient(circle, var(--brand-300) 0%, transparent 70%)",
opacity: 0.3,
transform: "translate(30%, 30%)",
}}
/>
<div className="w-full max-w-md relative z-10">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mb-4"
style={{ background: "linear-gradient(135deg, var(--brand-600), var(--brand-400))", boxShadow: "0 8px 24px -4px rgb(99 102 241 / 0.35)" }}
>
<CalendarDays className="w-7 h-7" style={{ color: "var(--text-on-brand)" }} />
</div>
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
LeaveFlow
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
<Card
style={{
border: "1px solid var(--surface-border)",
boxShadow: "var(--shadow-modal)",
background: "var(--surface-card)",
borderRadius: "var(--radius-lg)",
}}
>
<CardHeader className="pb-4">
<CardTitle className="text-lg" style={{ color: "var(--text-primary)" }}>
Sign in to your account
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username" style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
Username
</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="e.g. emp1"
autoComplete="username"
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password" style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
Password
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
autoComplete="current-password"
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
/>
</div>
{error && (
<Alert style={{ borderColor: "var(--color-danger-border)", background: "var(--color-danger-bg)", borderRadius: "var(--radius-sm)" }}>
<AlertCircle className="h-4 w-4" style={{ color: "var(--color-danger)" }} />
<AlertDescription style={{ color: "var(--color-danger)" }}>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full font-semibold"
disabled={loading}
style={{
background: "linear-gradient(135deg, var(--brand-600), var(--brand-500))",
borderRadius: "var(--radius-sm)",
boxShadow: "0 4px 12px -2px rgb(99 102 241 / 0.3)",
border: "none",
}}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in
</>
) : (
"Sign In"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
</main>
);
}

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

240
components/LeaveSheet.tsx Normal file
View File

@ -0,0 +1,240 @@
"use client";
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
import { applyLeave } from "@/lib/leavesStore";
interface LeaveSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: number;
leaveBalance: number;
onSuccess: () => void;
}
const LEAVE_TYPES = ["Sick", "Casual", "Annual", "Unpaid"] as const;
export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess }: LeaveSheetProps) {
const [type, setType] = useState<typeof LEAVE_TYPES[number]>("Casual");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const today = new Date().toISOString().split("T")[0];
const daysCount =
fromDate && toDate && toDate >= fromDate
? Math.ceil((new Date(toDate).getTime() - new Date(fromDate).getTime()) / 86400000) + 1
: 0;
const reset = () => {
setType("Casual");
setFromDate("");
setToDate("");
setReason("");
setError("");
setSuccess(false);
};
const handleClose = () => {
reset();
onOpenChange(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!fromDate || !toDate || !reason.trim()) {
setError("All fields are required");
return;
}
if (toDate < fromDate) {
setError("End date must be after start date");
return;
}
setLoading(true);
const result = await applyLeave(userId, { type, fromDate, toDate, reason });
setLoading(false);
if (!result.ok) {
setError(result.error ?? "Failed to apply");
return;
}
setSuccess(true);
setTimeout(() => {
handleClose();
onSuccess();
}, 1500);
};
return (
<Sheet open={open} onOpenChange={handleClose}>
<SheetContent
className="w-full sm:max-w-xl overflow-y-auto p-6"
style={{ background: "var(--surface-card)", borderLeft: "1px solid var(--surface-border)" }}
>
<SheetHeader className="pb-4">
<SheetTitle style={{ color: "var(--text-primary)", fontSize: "1.125rem" }}>
Apply for Leave
</SheetTitle>
<SheetDescription style={{ color: "var(--text-secondary)" }}>
You have{" "}
<span className="font-semibold" style={{ color: "var(--brand-600)" }}>
{leaveBalance} days
</span>{" "}
remaining
</SheetDescription>
</SheetHeader>
{success ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: "var(--color-success-bg)" }}
>
<CheckCircle2 className="w-8 h-8" style={{ color: "var(--color-success)" }} />
</div>
<p className="font-semibold text-base" style={{ color: "var(--text-primary)" }}>
Leave Applied!
</p>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
Your request is pending approval
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5 pt-2">
{/* Leave Type */}
<div className="space-y-1.5">
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
Leave Type
</Label>
<Select value={type} onValueChange={(v) => setType(v as any)}>
<SelectTrigger style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEAVE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Date range */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
From Date
</Label>
<Input
type="date"
min={today}
value={fromDate}
onChange={(e) => {
setFromDate(e.target.value);
if (toDate && e.target.value > toDate) setToDate("");
}}
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
/>
</div>
<div className="space-y-1.5">
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
To Date
</Label>
<Input
type="date"
min={fromDate || today}
value={toDate}
onChange={(e) => setToDate(e.target.value)}
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
/>
</div>
</div>
{daysCount > 0 && (
<div
className="text-sm px-3 py-2 rounded-md font-medium"
style={{
background: "var(--brand-50)",
color: "var(--brand-700)",
border: "1px solid var(--brand-200)",
borderRadius: "var(--radius-sm)",
}}
>
{daysCount} day{daysCount > 1 ? "s" : ""} selected
</div>
)}
{/* Reason */}
<div className="space-y-1.5">
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
Reason
</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Brief reason for leave..."
rows={3}
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)", resize: "none" }}
/>
</div>
{error && (
<Alert style={{ borderColor: "var(--color-danger-border)", background: "var(--color-danger-bg)", borderRadius: "var(--radius-sm)" }}>
<AlertCircle className="h-4 w-4" style={{ color: "var(--color-danger)" }} />
<AlertDescription style={{ color: "var(--color-danger)" }}>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={handleClose}
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1 font-semibold"
disabled={loading}
style={{
background: "linear-gradient(135deg, var(--brand-600), var(--brand-500))",
border: "none",
borderRadius: "var(--radius-sm)",
}}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Apply Leave"}
</Button>
</div>
</form>
)}
</SheetContent>
</Sheet>
);
}

189
components/LeaveTable.tsx Normal file
View File

@ -0,0 +1,189 @@
"use client";
import { LeaveRequest, daysBetween } from "@/lib/leavesStore";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { CheckCircle2, XCircle, Clock, CalendarRange } from "lucide-react";
interface LeaveTableProps {
leaves: LeaveRequest[];
isManager?: boolean;
onApprove?: (id: number) => void;
onReject?: (id: number) => void;
loadingId?: number | null;
}
const STATUS_CONFIG = {
Approved: {
className: "status-approved",
icon: CheckCircle2,
},
Pending: {
className: "status-pending",
icon: Clock,
},
Rejected: {
className: "status-rejected",
icon: XCircle,
},
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const day = date.getDate();
const month = date.toLocaleString("en-US", { month: "short" });
const year = date.getFullYear();
return `${day} ${month}, ${year}`;
};
export function LeaveTable({ leaves, isManager, onApprove, onReject, loadingId }: LeaveTableProps) {
if (leaves.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2">
<CalendarRange className="w-10 h-10" style={{ color: "var(--text-muted)" }} />
<p className="font-medium" style={{ color: "var(--text-secondary)" }}>
No leave requests yet
</p>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{isManager ? "No requests to review" : "Apply for your first leave"}
</p>
</div>
);
}
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow style={{ borderBottom: "1px solid var(--surface-border)" }}>
{isManager && (
<TableHead style={{ color: "var(--text-muted)", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
Employee
</TableHead>
)}
<TableHead style={{ color: "var(--text-muted)", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
Type
</TableHead>
<TableHead style={{ color: "var(--text-muted)", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
Duration
</TableHead>
<TableHead style={{ color: "var(--text-muted)", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
Days
</TableHead>
<TableHead style={{ color: "var(--text-muted)", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
Reason
</TableHead>
<TableHead style={{ color: "var(--text-muted)", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
Status
</TableHead>
{isManager && (
<TableHead style={{ color: "var(--text-muted)", fontSize: "0.75rem", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em" }}>
Action
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{leaves.map((leave) => {
const config = STATUS_CONFIG[leave.status];
const StatusIcon = config.icon;
const days = daysBetween(leave.fromDate, leave.toDate);
const isLoading = loadingId === leave.id;
return (
<TableRow
key={leave.id}
style={{ borderBottom: "1px solid var(--surface-border)" }}
className="hover:bg-[var(--surface-hover)] transition-colors"
>
{isManager && (
<TableCell>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
{leave.userName}
</span>
</TableCell>
)}
<TableCell>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
{leave.type}
</span>
</TableCell>
<TableCell>
<span className="text-sm" style={{ color: "var(--text-secondary)", fontFamily: "DM Mono, monospace", fontSize: "0.8rem" }}>
{formatDate(leave.fromDate)}-{formatDate(leave.toDate)}
</span>
</TableCell>
<TableCell>
<span
className="inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold"
style={{ background: "var(--brand-50)", color: "var(--brand-700)" }}
>
{days}
</span>
</TableCell>
<TableCell>
<span className="text-sm max-w-[180px] truncate block" style={{ color: "var(--text-secondary)" }}>
{leave.reason}
</span>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full ${config.className}`}
>
<StatusIcon className="w-3 h-3" />
{leave.status}
</span>
</TableCell>
{isManager && (
<TableCell>
{leave.status === "Pending" ? (
<div className="flex items-center gap-1.5">
<Button
size="sm"
onClick={() => onApprove?.(leave.id)}
disabled={isLoading}
className="h-7 text-xs font-semibold"
style={{
background: "var(--color-success-bg)",
color: "var(--color-success)",
border: "1px solid var(--color-success-border)",
borderRadius: "var(--radius-sm)",
}}
>
{isLoading ? "…" : <><CheckCircle2 className="w-3 h-3 mr-1" />Approve</>}
</Button>
<Button
size="sm"
onClick={() => onReject?.(leave.id)}
disabled={isLoading}
className="h-7 text-xs font-semibold"
style={{
background: "var(--color-danger-bg)",
color: "var(--color-danger)",
border: "1px solid var(--color-danger-border)",
borderRadius: "var(--radius-sm)",
}}
>
<XCircle className="w-3 h-3 mr-1" />Reject
</Button>
</div>
) : (
<span className="text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

76
components/ui/alert.tsx Normal file
View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

49
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

67
components/ui/button.tsx Normal file
View File

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
components/ui/card.tsx Normal file
View File

@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

19
components/ui/input.tsx Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

192
components/ui/select.tsx Normal file
View File

@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

147
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 p-6 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

116
components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

78
context/AuthContext.tsx Normal file
View File

@ -0,0 +1,78 @@
"use client";
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from "react";
import { useRouter } from "next/navigation";
export interface User {
id: number;
username: string;
name: string;
role: "employee" | "manager";
leaveBalance?: number;
}
interface AuthContextType {
user: User | null;
login: (
username: string,
password: string,
) => Promise<{ ok: boolean; error?: string }>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const stored = localStorage.getItem("lms_user");
if (stored) {
try {
setUser(JSON.parse(stored));
} catch {}
}
setIsLoading(false);
}, []);
const login = async (username: string, password: string) => {
const mod = await import("@/data/users.json");
const raw = mod.default ?? mod;
const users: any[] = Array.isArray(raw) ? raw : ((raw as any).users ?? []);
const found = users.find(
(u) => u.username === username && u.password === password,
);
if (!found) return { ok: false, error: "Invalid username or password" };
const { password: _pw, ...safeUser } = found;
setUser(safeUser);
localStorage.setItem("lms_user", JSON.stringify(safeUser));
return { ok: true };
};
const logout = () => {
setUser(null);
localStorage.removeItem("lms_user");
router.push("/");
};
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}

22
data/leaves.json Normal file
View File

@ -0,0 +1,22 @@
[
{
"id": 101,
"userId": 1,
"type": "Sick",
"fromDate": "2026-04-25",
"toDate": "2026-04-26",
"reason": "Fever",
"status": "Approved",
"appliedAt": "2026-04-20"
},
{
"id": 102,
"userId": 2,
"type": "Casual",
"fromDate": "2026-04-28",
"toDate": "2026-04-30",
"reason": "Family trip",
"status": "Pending",
"appliedAt": "2026-04-27"
}
]

25
data/users.json Normal file
View File

@ -0,0 +1,25 @@
[
{
"id": 1,
"username": "emp1",
"password": "123",
"name": "Rahul Sharma",
"role": "employee",
"leaveBalance": 12
},
{
"id": 2,
"username": "emp2",
"password": "123",
"name": "Priya Das",
"role": "employee",
"leaveBalance": 8
},
{
"id": 3,
"username": "manager",
"password": "123",
"name": "Amit Manager",
"role": "manager"
}
]

149
lib/leavesStore.ts Normal file
View File

@ -0,0 +1,149 @@
export interface LeaveRequest {
id: number;
userId: number;
userName?: string;
type: "Sick" | "Casual" | "Annual" | "Unpaid";
fromDate: string;
toDate: string;
reason: string;
status: "Pending" | "Approved" | "Rejected";
appliedAt: string;
}
export interface UserRecord {
id: number;
username: string;
name: string;
role: "employee" | "manager";
leaveBalance?: number;
}
const LEAVES_KEY = "lms_leaves";
const USERS_KEY = "lms_users";
// Seed from JSON on first load
async function seedIfNeeded() {
if (typeof window === "undefined") return;
if (!localStorage.getItem(LEAVES_KEY)) {
const mod = await import("@/data/leaves.json");
const raw = mod.default ?? mod;
const leaves = Array.isArray(raw) ? raw : ((raw as any).leaves ?? []);
localStorage.setItem(LEAVES_KEY, JSON.stringify(leaves));
}
if (!localStorage.getItem(USERS_KEY)) {
const mod = await import("@/data/users.json");
const raw = mod.default ?? mod;
const users = Array.isArray(raw) ? raw : ((raw as any).users ?? []);
localStorage.setItem(USERS_KEY, JSON.stringify(users));
}
}
export async function getLeaves(): Promise<LeaveRequest[]> {
await seedIfNeeded();
const raw = localStorage.getItem(LEAVES_KEY) ?? "[]";
const leaves: LeaveRequest[] = JSON.parse(raw);
// Attach userName from users
const users = getUsers();
return leaves.map((l) => {
const u = users.find((u) => u.id === l.userId);
return { ...l, userName: u?.name ?? "Unknown" };
});
}
export function getUsers(): UserRecord[] {
const raw = localStorage.getItem(USERS_KEY) ?? "[]";
return JSON.parse(raw);
}
export async function getLeavesForUser(userId: number): Promise<LeaveRequest[]> {
const all = await getLeaves();
return all.filter((l) => l.userId === userId);
}
export async function applyLeave(
userId: number,
data: { type: LeaveRequest["type"]; fromDate: string; toDate: string; reason: string }
): Promise<{ ok: boolean; error?: string }> {
await seedIfNeeded();
// Calculate days
const from = new Date(data.fromDate);
const to = new Date(data.toDate);
const days = Math.ceil((to.getTime() - from.getTime()) / 86400000) + 1;
// Check balance
const users = getUsers();
const user = users.find((u) => u.id === userId);
if (!user) return { ok: false, error: "User not found" };
if ((user.leaveBalance ?? 0) < days)
return { ok: false, error: `Insufficient balance. You have ${user.leaveBalance} days left.` };
const leaves: LeaveRequest[] = JSON.parse(localStorage.getItem(LEAVES_KEY) ?? "[]");
const newLeave: LeaveRequest = {
id: Date.now(),
userId,
type: data.type,
fromDate: data.fromDate,
toDate: data.toDate,
reason: data.reason,
status: "Pending",
appliedAt: new Date().toISOString().split("T")[0],
};
leaves.push(newLeave);
localStorage.setItem(LEAVES_KEY, JSON.stringify(leaves));
return { ok: true };
}
export async function updateLeaveStatus(
leaveId: number,
status: "Approved" | "Rejected"
): Promise<void> {
await seedIfNeeded();
const leaves: LeaveRequest[] = JSON.parse(localStorage.getItem(LEAVES_KEY) ?? "[]");
const idx = leaves.findIndex((l) => l.id === leaveId);
if (idx === -1) return;
const leave = leaves[idx];
const wasApproved = leave.status === "Approved";
leaves[idx] = { ...leave, status };
localStorage.setItem(LEAVES_KEY, JSON.stringify(leaves));
// Adjust balance
const users = getUsers();
const user = users.find((u) => u.id === leave.userId);
if (!user || user.role !== "employee") return;
const from = new Date(leave.fromDate);
const to = new Date(leave.toDate);
const days = Math.ceil((to.getTime() - from.getTime()) / 86400000) + 1;
if (status === "Approved" && !wasApproved) {
user.leaveBalance = (user.leaveBalance ?? 0) - days;
} else if (status === "Rejected" && wasApproved) {
user.leaveBalance = (user.leaveBalance ?? 0) + days;
}
localStorage.setItem(USERS_KEY, JSON.stringify(users));
// Also update session user if it's the same person
const sessionUser = localStorage.getItem("lms_user");
if (sessionUser) {
const su = JSON.parse(sessionUser);
if (su.id === user.id) {
su.leaveBalance = user.leaveBalance;
localStorage.setItem("lms_user", JSON.stringify(su));
}
}
}
export function getUserBalance(userId: number): number {
const users = getUsers();
const u = users.find((u) => u.id === userId);
return u?.leaveBalance ?? 0;
}
export function daysBetween(from: string, to: string): number {
const f = new Date(from);
const t = new Date(to);
return Math.ceil((t.getTime() - f.getTime()) / 86400000) + 1;
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

5061
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,9 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.11.0",
"next": "16.2.4",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"shadcn": "^4.5.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B