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:
parent
3fdca895cc
commit
c5946ce95e
@ -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 -->
|
||||
135
app/employee/page.tsx
Normal file
135
app/employee/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
222
app/globals.css
222
app/globals.css
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
147
app/manager/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
app/page.tsx
210
app/page.tsx
@ -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
25
components.json
Normal 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
240
components/LeaveSheet.tsx
Normal 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
189
components/LeaveTable.tsx
Normal 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
76
components/ui/alert.tsx
Normal 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
49
components/ui/badge.tsx
Normal 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
67
components/ui/button.tsx
Normal 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
103
components/ui/card.tsx
Normal 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
19
components/ui/input.tsx
Normal 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
24
components/ui/label.tsx
Normal 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
192
components/ui/select.tsx
Normal 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
147
components/ui/sheet.tsx
Normal 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
116
components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal 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
78
context/AuthContext.tsx
Normal 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
22
data/leaves.json
Normal 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
25
data/users.json
Normal 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
149
lib/leavesStore.ts
Normal 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
6
lib/utils.ts
Normal 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
5061
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
Loading…
x
Reference in New Issue
Block a user