Compare commits

..

No commits in common. "96cc1bb68474e2efdfcff22fc9834deeffa31a06" and "73b31778dbaa1a9257f179e221c029c4f3a274dc" have entirely different histories.

11 changed files with 190 additions and 282 deletions

View File

@ -20,7 +20,6 @@ import {
XCircle,
Wallet,
} from "lucide-react";
import { ROUTES } from "@/lib/routes";
export default function EmployeePage() {
const { user, logout, isLoading } = useAuth();
@ -42,10 +41,8 @@ export default function EmployeePage() {
}, [user]);
useEffect(() => {
// if (!isLoading && !user) router.replace("/");
// else if (!isLoading && user?.role === "manager") router.replace("/manager");
if (!isLoading && !user) router.replace(ROUTES.home);
else if (!isLoading && user?.role === "manager") router.replace(ROUTES.manager);
if (!isLoading && !user) router.replace("/");
else if (!isLoading && user?.role === "manager") router.replace("/manager");
else if (!isLoading && user) loadData();
}, [user, isLoading, router, loadData]);

View File

@ -3,12 +3,11 @@ import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { Toaster } from "@/components/ui/sonner";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: "LeaveFlow - Leave Management",
title: "LeaveFlow Leave Management",
description: "Streamlined leave management for teams",
};
@ -25,7 +24,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body>
<AuthProvider>{children}</AuthProvider>
<Toaster richColors position="top-right" />
</body>
</html>
);

View File

@ -14,8 +14,6 @@ import {
XCircle,
LayoutDashboard,
} from "lucide-react";
import { ROUTES } from "@/lib/routes";
import { toast } from "sonner";
type FilterStatus = "All" | "Pending" | "Approved" | "Rejected";
@ -34,27 +32,23 @@ export default function ManagerPage() {
}, []);
useEffect(() => {
if (!isLoading && !user) router.replace(ROUTES.home);
else if (!isLoading && user?.role === "employee") router.replace(ROUTES.employee);
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) => {
const leave = leaves.find((l) => l.id === id);
setLoadingId(id);
await updateLeaveStatus(id, "Approved");
await loadData();
setLoadingId(null);
toast.success(`Leave approved for ${leave?.userName ?? "employee"}`);
};
const handleReject = async (id: number) => {
const leave = leaves.find((l) => l.id === id);
setLoadingId(id);
await updateLeaveStatus(id, "Rejected");
await loadData();
setLoadingId(null);
toast.error(`Leave rejected for ${leave?.userName ?? "employee"}`);
};
if (isLoading || !user || !dataLoaded) {

View File

@ -7,34 +7,37 @@ 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 { CalendarDays, Loader2 } from "lucide-react";
import { ROUTES } from "@/lib/routes";
import { toast } from "sonner";
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" ? ROUTES.manager : ROUTES.employee);
router.replace(user.role === "manager" ? "/manager" : "/employee");
}
}, [user, isLoading, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
toast.error("Please enter username and password");
setError("Please enter username and password");
return;
}
setLoading(true);
setError("");
const result = await login(username, password);
setLoading(false);
if (!result.ok) {
toast.error(result.error ?? "Login failed");
setError(result.error ?? "Login failed");
} else if (result.ok) {
// redirect handled by useEffect
}
};
@ -72,6 +75,7 @@ export default function LoginPage() {
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
LeaveFlow
</h1>
</div>
<Card
@ -117,6 +121,13 @@ export default function LoginPage() {
/>
</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"

View File

@ -19,9 +19,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { applyLeave, daysBetween, isWeekend, LeaveRequest } from "@/lib/leavesStore";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
import { applyLeave } from "@/lib/leavesStore";
interface LeaveSheetProps {
open: boolean;
@ -38,13 +38,15 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
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
? daysBetween(fromDate, toDate)
? Math.ceil((new Date(toDate).getTime() - new Date(fromDate).getTime()) / 86400000) + 1
: 0;
const reset = () => {
@ -52,6 +54,8 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
setFromDate("");
setToDate("");
setReason("");
setError("");
setSuccess(false);
};
const handleClose = () => {
@ -61,24 +65,27 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!fromDate || !toDate || !reason.trim()) {
toast.error("All fields are required");
setError("All fields are required");
return;
}
if (toDate < fromDate) {
toast.error("End date must be after start date");
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) {
toast.error(result.error ?? "Failed to apply");
setError(result.error ?? "Failed to apply");
return;
}
toast.success("Leave applied! Your request is pending approval.");
handleClose();
onSuccess();
setSuccess(true);
setTimeout(() => {
handleClose();
onSuccess();
}, 1500);
};
return (
@ -100,116 +107,133 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="space-y-5 pt-2">
<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 LeaveRequest["type"])}>
<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>
<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) => {
const val = e.target.value;
if (isWeekend(val)) {
toast.error("Start date cannot be a Saturday or Sunday");
return;
}
setFromDate(val);
if (toDate && val > 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) => {
const val = e.target.value;
if (isWeekend(val)) {
toast.error("End date cannot be a Saturday or Sunday");
return;
}
setToDate(val);
}}
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
/>
</div>
</div>
{daysCount > 0 && (
{success ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<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)",
}}
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ background: "var(--color-success-bg)" }}
>
{daysCount} day{daysCount > 1 ? "s" : ""} selected
<CheckCircle2 className="w-8 h-8" style={{ color: "var(--color-success)" }} />
</div>
)}
<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" }}
/>
<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>
<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>
{/* 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>
);

View File

@ -1,49 +0,0 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@ -1,5 +1,4 @@
"use client";
import { ROUTES } from "@/lib/routes";
import React, {
createContext,
@ -62,8 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = () => {
setUser(null);
localStorage.removeItem("lms_user");
// router.push("/");
router.push(ROUTES.home);
router.push("/");
};
return (

View File

@ -5,7 +5,6 @@
"type": "Sick",
"fromDate": "2026-04-25",
"toDate": "2026-04-26",
"days": 2,
"reason": "Fever",
"status": "Approved",
"appliedAt": "2026-04-20"
@ -16,7 +15,6 @@
"type": "Casual",
"fromDate": "2026-04-28",
"toDate": "2026-04-30",
"days": 3,
"reason": "Family trip",
"status": "Pending",
"appliedAt": "2026-04-27"

View File

@ -5,8 +5,6 @@ export interface LeaveRequest {
type: "Sick" | "Casual" | "Annual" | "Unpaid";
fromDate: string;
toDate: string;
/** Immutable day count stored at apply-time; used for all balance calculations. */
days: number;
reason: string;
status: "Pending" | "Approved" | "Rejected";
appliedAt: string;
@ -68,46 +66,25 @@ export async function applyLeave(
): Promise<{ ok: boolean; error?: string }> {
await seedIfNeeded();
// Block if fromDate or toDate is a weekend
if (isWeekend(data.fromDate) || isWeekend(data.toDate)) {
return { ok: false, error: "Leave cannot start or end on a weekend (Saturday/Sunday)." };
}
// Calculate days
const from = new Date(data.fromDate);
const to = new Date(data.toDate);
const days = Math.ceil((to.getTime() - from.getTime()) / 86400000) + 1;
// Calculate days using the shared util (no inline duplication)
const days = daysBetween(data.fromDate, data.toDate);
if (days === 0) {
return { ok: false, error: "Selected range has no working days." };
}
// Check balance — account for already-pending requests
// 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) ?? "[]");
// Only check balance for non-Unpaid leave
if (data.type !== "Unpaid") {
const pendingDays = leaves
.filter((l) => l.userId === userId && l.status === "Pending" && l.type !== "Unpaid")
.reduce((sum, l) => sum + (l.days ?? daysBetween(l.fromDate, l.toDate)), 0);
const availableBalance = (user.leaveBalance ?? 0) - pendingDays;
if (availableBalance < days)
return {
ok: false,
error: `Insufficient balance. You have ${user.leaveBalance} total days, but ${pendingDays} are reserved by pending requests (${availableBalance} available).`,
};
}
const newLeave: LeaveRequest = {
id: Date.now(),
userId,
type: data.type,
fromDate: data.fromDate,
toDate: data.toDate,
days,
reason: data.reason,
status: "Pending",
appliedAt: new Date().toISOString().split("T")[0],
@ -131,32 +108,30 @@ export async function updateLeaveStatus(
leaves[idx] = { ...leave, status };
localStorage.setItem(LEAVES_KEY, JSON.stringify(leaves));
// Adjust balance using the immutable `days` stored at apply-time.
// Fallback to daysBetween for legacy records that pre-date the `days` field.
// Adjust balance
const users = getUsers();
const user = users.find((u) => u.id === leave.userId);
if (!user || user.role !== "employee") return;
const days = leave.days ?? daysBetween(leave.fromDate, leave.toDate);
const from = new Date(leave.fromDate);
const to = new Date(leave.toDate);
const days = Math.ceil((to.getTime() - from.getTime()) / 86400000) + 1;
// Only adjust balance for non-Unpaid leave
if (leave.type !== "Unpaid") {
if (status === "Approved" && !wasApproved) {
user.leaveBalance = (user.leaveBalance ?? 0) - days;
} else if (status === "Rejected" && wasApproved) {
user.leaveBalance = (user.leaveBalance ?? 0) + days;
}
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));
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));
}
// 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));
}
}
}
@ -167,22 +142,8 @@ export function getUserBalance(userId: number): number {
return u?.leaveBalance ?? 0;
}
// Returns true if date is Saturday or Sunday
export function isWeekend(dateStr: string): boolean {
const day = new Date(dateStr).getDay();
return day === 0 || day === 6;
}
// Count only working days (MonFri) between two dates
export function daysBetween(from: string, to: string): number {
const start = new Date(from);
const end = new Date(to);
let count = 0;
const current = new Date(start);
while (current <= end) {
const day = current.getDay();
if (day !== 0 && day !== 6) count++;
current.setDate(current.getDate() + 1);
}
return count;
const f = new Date(from);
const t = new Date(to);
return Math.ceil((t.getTime() - f.getTime()) / 86400000) + 1;
}

22
package-lock.json generated
View File

@ -12,12 +12,10 @@
"clsx": "^2.1.1",
"lucide-react": "^1.11.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
@ -8616,16 +8614,6 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -10195,16 +10183,6 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@ -13,12 +13,10 @@
"clsx": "^2.1.1",
"lucide-react": "^1.11.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},