Compare commits
2 Commits
73b31778db
...
96cc1bb684
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96cc1bb684 | ||
|
|
45444ba8bd |
@ -20,6 +20,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
export default function EmployeePage() {
|
export default function EmployeePage() {
|
||||||
const { user, logout, isLoading } = useAuth();
|
const { user, logout, isLoading } = useAuth();
|
||||||
@ -41,8 +42,10 @@ export default function EmployeePage() {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !user) router.replace("/");
|
// if (!isLoading && !user) router.replace("/");
|
||||||
else if (!isLoading && user?.role === "manager") router.replace("/manager");
|
// 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);
|
||||||
else if (!isLoading && user) loadData();
|
else if (!isLoading && user) loadData();
|
||||||
}, [user, isLoading, router, loadData]);
|
}, [user, isLoading, router, loadData]);
|
||||||
|
|
||||||
|
|||||||
@ -3,11 +3,12 @@ import "./globals.css";
|
|||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "LeaveFlow – Leave Management",
|
title: "LeaveFlow - Leave Management",
|
||||||
description: "Streamlined leave management for teams",
|
description: "Streamlined leave management for teams",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
<Toaster richColors position="top-right" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type FilterStatus = "All" | "Pending" | "Approved" | "Rejected";
|
type FilterStatus = "All" | "Pending" | "Approved" | "Rejected";
|
||||||
|
|
||||||
@ -32,23 +34,27 @@ export default function ManagerPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !user) router.replace("/");
|
if (!isLoading && !user) router.replace(ROUTES.home);
|
||||||
else if (!isLoading && user?.role === "employee") router.replace("/employee");
|
else if (!isLoading && user?.role === "employee") router.replace(ROUTES.employee);
|
||||||
else if (!isLoading && user) loadData();
|
else if (!isLoading && user) loadData();
|
||||||
}, [user, isLoading, router, loadData]);
|
}, [user, isLoading, router, loadData]);
|
||||||
|
|
||||||
const handleApprove = async (id: number) => {
|
const handleApprove = async (id: number) => {
|
||||||
|
const leave = leaves.find((l) => l.id === id);
|
||||||
setLoadingId(id);
|
setLoadingId(id);
|
||||||
await updateLeaveStatus(id, "Approved");
|
await updateLeaveStatus(id, "Approved");
|
||||||
await loadData();
|
await loadData();
|
||||||
setLoadingId(null);
|
setLoadingId(null);
|
||||||
|
toast.success(`Leave approved for ${leave?.userName ?? "employee"}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReject = async (id: number) => {
|
const handleReject = async (id: number) => {
|
||||||
|
const leave = leaves.find((l) => l.id === id);
|
||||||
setLoadingId(id);
|
setLoadingId(id);
|
||||||
await updateLeaveStatus(id, "Rejected");
|
await updateLeaveStatus(id, "Rejected");
|
||||||
await loadData();
|
await loadData();
|
||||||
setLoadingId(null);
|
setLoadingId(null);
|
||||||
|
toast.error(`Leave rejected for ${leave?.userName ?? "employee"}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || !user || !dataLoaded) {
|
if (isLoading || !user || !dataLoaded) {
|
||||||
|
|||||||
23
app/page.tsx
23
app/page.tsx
@ -7,37 +7,34 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { CalendarDays, Loader2 } from "lucide-react";
|
||||||
import { CalendarDays, AlertCircle, Loader2 } from "lucide-react";
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { login, user, isLoading } = useAuth();
|
const { login, user, isLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && user) {
|
if (!isLoading && user) {
|
||||||
router.replace(user.role === "manager" ? "/manager" : "/employee");
|
router.replace(user.role === "manager" ? ROUTES.manager : ROUTES.employee);
|
||||||
}
|
}
|
||||||
}, [user, isLoading, router]);
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setError("Please enter username and password");
|
toast.error("Please enter username and password");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
const result = await login(username, password);
|
const result = await login(username, password);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.error ?? "Login failed");
|
toast.error(result.error ?? "Login failed");
|
||||||
} else if (result.ok) {
|
|
||||||
// redirect handled by useEffect
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -75,7 +72,6 @@ export default function LoginPage() {
|
|||||||
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
|
<h1 className="text-2xl font-bold tracking-tight" style={{ color: "var(--text-primary)" }}>
|
||||||
LeaveFlow
|
LeaveFlow
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@ -121,13 +117,6 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full font-semibold"
|
className="w-full font-semibold"
|
||||||
|
|||||||
@ -19,9 +19,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Loader2 } from "lucide-react";
|
||||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
import { applyLeave, daysBetween, isWeekend, LeaveRequest } from "@/lib/leavesStore";
|
||||||
import { applyLeave } from "@/lib/leavesStore";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface LeaveSheetProps {
|
interface LeaveSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -38,15 +38,13 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
const [fromDate, setFromDate] = useState("");
|
const [fromDate, setFromDate] = useState("");
|
||||||
const [toDate, setToDate] = useState("");
|
const [toDate, setToDate] = useState("");
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
const daysCount =
|
const daysCount =
|
||||||
fromDate && toDate && toDate >= fromDate
|
fromDate && toDate && toDate >= fromDate
|
||||||
? Math.ceil((new Date(toDate).getTime() - new Date(fromDate).getTime()) / 86400000) + 1
|
? daysBetween(fromDate, toDate)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
@ -54,8 +52,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
setFromDate("");
|
setFromDate("");
|
||||||
setToDate("");
|
setToDate("");
|
||||||
setReason("");
|
setReason("");
|
||||||
setError("");
|
|
||||||
setSuccess(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@ -65,27 +61,24 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
|
||||||
if (!fromDate || !toDate || !reason.trim()) {
|
if (!fromDate || !toDate || !reason.trim()) {
|
||||||
setError("All fields are required");
|
toast.error("All fields are required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (toDate < fromDate) {
|
if (toDate < fromDate) {
|
||||||
setError("End date must be after start date");
|
toast.error("End date must be after start date");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result = await applyLeave(userId, { type, fromDate, toDate, reason });
|
const result = await applyLeave(userId, { type, fromDate, toDate, reason });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.error ?? "Failed to apply");
|
toast.error(result.error ?? "Failed to apply");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSuccess(true);
|
toast.success("Leave applied! Your request is pending approval.");
|
||||||
setTimeout(() => {
|
|
||||||
handleClose();
|
handleClose();
|
||||||
onSuccess();
|
onSuccess();
|
||||||
}, 1500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -107,43 +100,23 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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">
|
<form onSubmit={handleSubmit} className="space-y-5 pt-2">
|
||||||
{/* Leave Type */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
||||||
Leave Type
|
Leave Type
|
||||||
</Label>
|
</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)" }}>
|
<SelectTrigger style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{LEAVE_TYPES.map((t) => (
|
{LEAVE_TYPES.map((t) => (
|
||||||
<SelectItem key={t} value={t}>
|
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||||
{t}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date range */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
<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}
|
min={today}
|
||||||
value={fromDate}
|
value={fromDate}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFromDate(e.target.value);
|
const val = e.target.value;
|
||||||
if (toDate && e.target.value > toDate) setToDate("");
|
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)" }}
|
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
||||||
/>
|
/>
|
||||||
@ -168,7 +146,14 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
type="date"
|
type="date"
|
||||||
min={fromDate || today}
|
min={fromDate || today}
|
||||||
value={toDate}
|
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)" }}
|
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -188,7 +173,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reason */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
||||||
Reason
|
Reason
|
||||||
@ -202,13 +186,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -233,7 +210,6 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
|
|||||||
49
components/ui/sonner.tsx
Normal file
49
components/ui/sonner.tsx
Normal 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 }
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
@ -61,7 +62,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const logout = () => {
|
const logout = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
localStorage.removeItem("lms_user");
|
localStorage.removeItem("lms_user");
|
||||||
router.push("/");
|
// router.push("/");
|
||||||
|
router.push(ROUTES.home);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
"type": "Sick",
|
"type": "Sick",
|
||||||
"fromDate": "2026-04-25",
|
"fromDate": "2026-04-25",
|
||||||
"toDate": "2026-04-26",
|
"toDate": "2026-04-26",
|
||||||
|
"days": 2,
|
||||||
"reason": "Fever",
|
"reason": "Fever",
|
||||||
"status": "Approved",
|
"status": "Approved",
|
||||||
"appliedAt": "2026-04-20"
|
"appliedAt": "2026-04-20"
|
||||||
@ -15,6 +16,7 @@
|
|||||||
"type": "Casual",
|
"type": "Casual",
|
||||||
"fromDate": "2026-04-28",
|
"fromDate": "2026-04-28",
|
||||||
"toDate": "2026-04-30",
|
"toDate": "2026-04-30",
|
||||||
|
"days": 3,
|
||||||
"reason": "Family trip",
|
"reason": "Family trip",
|
||||||
"status": "Pending",
|
"status": "Pending",
|
||||||
"appliedAt": "2026-04-27"
|
"appliedAt": "2026-04-27"
|
||||||
|
|||||||
@ -5,6 +5,8 @@ export interface LeaveRequest {
|
|||||||
type: "Sick" | "Casual" | "Annual" | "Unpaid";
|
type: "Sick" | "Casual" | "Annual" | "Unpaid";
|
||||||
fromDate: string;
|
fromDate: string;
|
||||||
toDate: string;
|
toDate: string;
|
||||||
|
/** Immutable day count stored at apply-time; used for all balance calculations. */
|
||||||
|
days: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
status: "Pending" | "Approved" | "Rejected";
|
status: "Pending" | "Approved" | "Rejected";
|
||||||
appliedAt: string;
|
appliedAt: string;
|
||||||
@ -66,25 +68,46 @@ export async function applyLeave(
|
|||||||
): Promise<{ ok: boolean; error?: string }> {
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
await seedIfNeeded();
|
await seedIfNeeded();
|
||||||
|
|
||||||
// Calculate days
|
// Block if fromDate or toDate is a weekend
|
||||||
const from = new Date(data.fromDate);
|
if (isWeekend(data.fromDate) || isWeekend(data.toDate)) {
|
||||||
const to = new Date(data.toDate);
|
return { ok: false, error: "Leave cannot start or end on a weekend (Saturday/Sunday)." };
|
||||||
const days = Math.ceil((to.getTime() - from.getTime()) / 86400000) + 1;
|
}
|
||||||
|
|
||||||
// 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 users = getUsers();
|
||||||
const user = users.find((u) => u.id === userId);
|
const user = users.find((u) => u.id === userId);
|
||||||
if (!user) return { ok: false, error: "User not found" };
|
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 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 = {
|
const newLeave: LeaveRequest = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
userId,
|
userId,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
fromDate: data.fromDate,
|
fromDate: data.fromDate,
|
||||||
toDate: data.toDate,
|
toDate: data.toDate,
|
||||||
|
days,
|
||||||
reason: data.reason,
|
reason: data.reason,
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
appliedAt: new Date().toISOString().split("T")[0],
|
appliedAt: new Date().toISOString().split("T")[0],
|
||||||
@ -108,15 +131,16 @@ export async function updateLeaveStatus(
|
|||||||
leaves[idx] = { ...leave, status };
|
leaves[idx] = { ...leave, status };
|
||||||
localStorage.setItem(LEAVES_KEY, JSON.stringify(leaves));
|
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 users = getUsers();
|
||||||
const user = users.find((u) => u.id === leave.userId);
|
const user = users.find((u) => u.id === leave.userId);
|
||||||
if (!user || user.role !== "employee") return;
|
if (!user || user.role !== "employee") return;
|
||||||
|
|
||||||
const from = new Date(leave.fromDate);
|
const days = leave.days ?? daysBetween(leave.fromDate, leave.toDate);
|
||||||
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) {
|
if (status === "Approved" && !wasApproved) {
|
||||||
user.leaveBalance = (user.leaveBalance ?? 0) - days;
|
user.leaveBalance = (user.leaveBalance ?? 0) - days;
|
||||||
} else if (status === "Rejected" && wasApproved) {
|
} else if (status === "Rejected" && wasApproved) {
|
||||||
@ -134,6 +158,7 @@ export async function updateLeaveStatus(
|
|||||||
localStorage.setItem("lms_user", JSON.stringify(su));
|
localStorage.setItem("lms_user", JSON.stringify(su));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserBalance(userId: number): number {
|
export function getUserBalance(userId: number): number {
|
||||||
@ -142,8 +167,22 @@ export function getUserBalance(userId: number): number {
|
|||||||
return u?.leaveBalance ?? 0;
|
return u?.leaveBalance ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function daysBetween(from: string, to: string): number {
|
// Returns true if date is Saturday or Sunday
|
||||||
const f = new Date(from);
|
export function isWeekend(dateStr: string): boolean {
|
||||||
const t = new Date(to);
|
const day = new Date(dateStr).getDay();
|
||||||
return Math.ceil((t.getTime() - f.getTime()) / 86400000) + 1;
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count only working days (Mon–Fri) 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
22
package-lock.json
generated
@ -12,10 +12,12 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"shadcn": "^4.5.0",
|
"shadcn": "^4.5.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@ -10183,6 +10195,16 @@
|
|||||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
|||||||
@ -13,10 +13,12 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.11.0",
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"shadcn": "^4.5.0",
|
"shadcn": "^4.5.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user