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:
parent
45444ba8bd
commit
96cc1bb684
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ROUTES } from "@/lib/routes";
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type FilterStatus = "All" | "Pending" | "Approved" | "Rejected";
|
type FilterStatus = "All" | "Pending" | "Approved" | "Rejected";
|
||||||
|
|
||||||
@ -33,25 +34,27 @@ export default function ManagerPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if (!isLoading && !user) router.replace("/");
|
|
||||||
// else if (!isLoading && user?.role === "employee") router.replace("/employee");
|
|
||||||
if (!isLoading && !user) router.replace(ROUTES.home);
|
if (!isLoading && !user) router.replace(ROUTES.home);
|
||||||
else if (!isLoading && user?.role === "employee") router.replace(ROUTES.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) {
|
||||||
@ -147,4 +150,4 @@ export default function ManagerPage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
21
app/page.tsx
21
app/page.tsx
@ -7,21 +7,19 @@ 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 { 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);
|
router.replace(user.role === "manager" ? ROUTES.manager : ROUTES.employee);
|
||||||
}
|
}
|
||||||
}, [user, isLoading, router]);
|
}, [user, isLoading, router]);
|
||||||
@ -29,17 +27,14 @@ export default function LoginPage() {
|
|||||||
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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -77,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
|
||||||
@ -123,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,133 +100,116 @@ export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
{success ? (
|
<form onSubmit={handleSubmit} className="space-y-5 pt-2">
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
<div className="space-y-1.5">
|
||||||
<div
|
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
||||||
className="w-16 h-16 rounded-full flex items-center justify-center"
|
Leave Type
|
||||||
style={{ background: "var(--color-success-bg)" }}
|
</Label>
|
||||||
>
|
<Select value={type} onValueChange={(v) => setType(v as LeaveRequest["type"])}>
|
||||||
<CheckCircle2 className="w-8 h-8" style={{ color: "var(--color-success)" }} />
|
<SelectTrigger style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}>
|
||||||
</div>
|
<SelectValue />
|
||||||
<p className="font-semibold text-base" style={{ color: "var(--text-primary)" }}>
|
</SelectTrigger>
|
||||||
Leave Applied!
|
<SelectContent>
|
||||||
</p>
|
{LEAVE_TYPES.map((t) => (
|
||||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||||
Your request is pending approval
|
))}
|
||||||
</p>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5 pt-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* 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
|
From Date
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as any)}>
|
<Input
|
||||||
<SelectTrigger style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}>
|
type="date"
|
||||||
<SelectValue />
|
min={today}
|
||||||
</SelectTrigger>
|
value={fromDate}
|
||||||
<SelectContent>
|
onChange={(e) => {
|
||||||
{LEAVE_TYPES.map((t) => (
|
const val = e.target.value;
|
||||||
<SelectItem key={t} value={t}>
|
if (isWeekend(val)) {
|
||||||
{t}
|
toast.error("Start date cannot be a Saturday or Sunday");
|
||||||
</SelectItem>
|
return;
|
||||||
))}
|
}
|
||||||
</SelectContent>
|
setFromDate(val);
|
||||||
</Select>
|
if (toDate && val > toDate) setToDate("");
|
||||||
</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)",
|
|
||||||
}}
|
}}
|
||||||
>
|
style={{ borderColor: "var(--surface-border)", 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>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
{error && (
|
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
||||||
<Alert style={{ borderColor: "var(--color-danger-border)", background: "var(--color-danger-bg)", borderRadius: "var(--radius-sm)" }}>
|
To Date
|
||||||
<AlertCircle className="h-4 w-4" style={{ color: "var(--color-danger)" }} />
|
</Label>
|
||||||
<AlertDescription style={{ color: "var(--color-danger)" }}>{error}</AlertDescription>
|
<Input
|
||||||
</Alert>
|
type="date"
|
||||||
)}
|
min={fromDate || today}
|
||||||
|
value={toDate}
|
||||||
<div className="flex gap-2 pt-2">
|
onChange={(e) => {
|
||||||
<Button
|
const val = e.target.value;
|
||||||
type="button"
|
if (isWeekend(val)) {
|
||||||
variant="outline"
|
toast.error("End date cannot be a Saturday or Sunday");
|
||||||
className="flex-1"
|
return;
|
||||||
onClick={handleClose}
|
}
|
||||||
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
setToDate(val);
|
||||||
>
|
|
||||||
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)",
|
|
||||||
}}
|
}}
|
||||||
>
|
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Apply Leave"}
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</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 }
|
||||||
@ -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,30 +131,32 @@ 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;
|
|
||||||
|
|
||||||
if (status === "Approved" && !wasApproved) {
|
// Only adjust balance for non-Unpaid leave
|
||||||
user.leaveBalance = (user.leaveBalance ?? 0) - days;
|
if (leave.type !== "Unpaid") {
|
||||||
} else if (status === "Rejected" && wasApproved) {
|
if (status === "Approved" && !wasApproved) {
|
||||||
user.leaveBalance = (user.leaveBalance ?? 0) + days;
|
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
|
// Also update session user if it's the same person
|
||||||
const sessionUser = localStorage.getItem("lms_user");
|
const sessionUser = localStorage.getItem("lms_user");
|
||||||
if (sessionUser) {
|
if (sessionUser) {
|
||||||
const su = JSON.parse(sessionUser);
|
const su = JSON.parse(sessionUser);
|
||||||
if (su.id === user.id) {
|
if (su.id === user.id) {
|
||||||
su.leaveBalance = user.leaveBalance;
|
su.leaveBalance = user.leaveBalance;
|
||||||
localStorage.setItem("lms_user", JSON.stringify(su));
|
localStorage.setItem("lms_user", JSON.stringify(su));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,8 +167,22 @@ export function getUserBalance(userId: number): number {
|
|||||||
return u?.leaveBalance ?? 0;
|
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 (Mon–Fri) between two dates
|
||||||
export function daysBetween(from: string, to: string): number {
|
export function daysBetween(from: string, to: string): number {
|
||||||
const f = new Date(from);
|
const start = new Date(from);
|
||||||
const t = new Date(to);
|
const end = new Date(to);
|
||||||
return Math.ceil((t.getTime() - f.getTime()) / 86400000) + 1;
|
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