Added working-day-only calculation, Fixed leave logic so balance is deducted only on approval and pending requests are considered to prevent over-application.

Ensured consistent day calculation using stored days field and removed duplicate daysBetween logic.
Implemented working-day-only calculation, excluding weekends (Saturday & Sunday), and blocked invalid date selections.
Corrected handling of Unpaid leave so it does not affect leave balance.
Added routing and integrated Sonner notifications for login, manager actions, and employee leave submission.
This commit is contained in:
sumona-banerjeee 2026-04-29 19:14:58 +05:30
parent 45444ba8bd
commit 96cc1bb684
9 changed files with 265 additions and 183 deletions

View File

@ -3,11 +3,12 @@ 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",
};
@ -24,6 +25,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body>
<AuthProvider>{children}</AuthProvider>
<Toaster richColors position="top-right" />
</body>
</html>
);

View File

@ -15,6 +15,7 @@ import {
LayoutDashboard,
} from "lucide-react";
import { ROUTES } from "@/lib/routes";
import { toast } from "sonner";
type FilterStatus = "All" | "Pending" | "Approved" | "Rejected";
@ -33,25 +34,27 @@ export default function ManagerPage() {
}, []);
useEffect(() => {
// if (!isLoading && !user) router.replace("/");
// else if (!isLoading && user?.role === "employee") router.replace("/employee");
if (!isLoading && !user) router.replace(ROUTES.home);
else if (!isLoading && user?.role === "employee") router.replace(ROUTES.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,21 +7,19 @@ 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";
import { CalendarDays, Loader2 } from "lucide-react";
import { ROUTES } from "@/lib/routes";
import { toast } from "sonner";
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");
router.replace(user.role === "manager" ? ROUTES.manager : ROUTES.employee);
}
}, [user, isLoading, router]);
@ -29,17 +27,14 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
setError("Please enter username and password");
toast.error("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
toast.error(result.error ?? "Login failed");
}
};
@ -77,7 +72,6 @@ export default function LoginPage() {
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
LeaveFlow
</h1>
</div>
<Card
@ -123,13 +117,6 @@ 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 { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
import { applyLeave } from "@/lib/leavesStore";
import { Loader2 } from "lucide-react";
import { applyLeave, daysBetween, isWeekend, LeaveRequest } from "@/lib/leavesStore";
import { toast } from "sonner";
interface LeaveSheetProps {
open: boolean;
@ -38,15 +38,13 @@ 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
? Math.ceil((new Date(toDate).getTime() - new Date(fromDate).getTime()) / 86400000) + 1
? daysBetween(fromDate, toDate)
: 0;
const reset = () => {
@ -54,8 +52,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
setFromDate("");
setToDate("");
setReason("");
setError("");
setSuccess(false);
};
const handleClose = () => {
@ -65,27 +61,24 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!fromDate || !toDate || !reason.trim()) {
setError("All fields are required");
toast.error("All fields are required");
return;
}
if (toDate < fromDate) {
setError("End date must be after start date");
toast.error("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");
toast.error(result.error ?? "Failed to apply");
return;
}
setSuccess(true);
setTimeout(() => {
toast.success("Leave applied! Your request is pending approval.");
handleClose();
onSuccess();
}, 1500);
};
return (
@ -107,43 +100,23 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
</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)}>
<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>
<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 }}>
@ -154,8 +127,13 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
min={today}
value={fromDate}
onChange={(e) => {
setFromDate(e.target.value);
if (toDate && e.target.value > toDate) setToDate("");
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)" }}
/>
@ -168,7 +146,14 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
type="date"
min={fromDate || today}
value={toDate}
onChange={(e) => setToDate(e.target.value)}
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>
@ -188,7 +173,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
</div>
)}
{/* Reason */}
<div className="space-y-1.5">
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
Reason
@ -202,13 +186,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
/>
</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"
@ -233,7 +210,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
</Button>
</div>
</form>
)}
</SheetContent>
</Sheet>
);

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

@ -0,0 +1,49 @@
"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

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

View File

@ -5,6 +5,8 @@ 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;
@ -66,25 +68,46 @@ export async function applyLeave(
): 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;
// 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)." };
}
// Check balance
// 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
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],
@ -108,15 +131,16 @@ export async function updateLeaveStatus(
leaves[idx] = { ...leave, status };
localStorage.setItem(LEAVES_KEY, JSON.stringify(leaves));
// Adjust balance
// Adjust balance using the immutable `days` stored at apply-time.
// Fallback to daysBetween for legacy records that pre-date the `days` field.
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;
const days = leave.days ?? daysBetween(leave.fromDate, leave.toDate);
// 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) {
@ -135,6 +159,7 @@ export async function updateLeaveStatus(
}
}
}
}
export function getUserBalance(userId: number): number {
const users = getUsers();
@ -142,8 +167,22 @@ export function getUserBalance(userId: number): number {
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;
// 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;
}

22
package-lock.json generated
View File

@ -12,10 +12,12 @@
"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"
},
@ -8614,6 +8616,16 @@
}
}
},
"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",
@ -10183,6 +10195,16 @@
"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,10 +13,12 @@
"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"
},