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:
1 parent
45444ba8bd
commit
96cc1bb684
9 files changed
+265
-183
No files matched your search
+111
-135
@@ -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(() => {
|
||||
handleClose();
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
toast.success("Leave applied! Your request is pending approval.");
|
||||
handleClose();
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -107,133 +100,116 @@ 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>
|
||||
<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>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 pt-2">
|
||||
{/* Leave Type */}
|
||||
|
||||
<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 }}>
|
||||
Leave Type
|
||||
From Date
|
||||
</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as any)}>
|
||||
<SelectTrigger style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LEAVE_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
||||
From Date
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
min={today}
|
||||
value={fromDate}
|
||||
onChange={(e) => {
|
||||
setFromDate(e.target.value);
|
||||
if (toDate && e.target.value > toDate) setToDate("");
|
||||
}}
|
||||
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
||||
To Date
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
min={fromDate || today}
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{daysCount > 0 && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded-md font-medium"
|
||||
style={{
|
||||
background: "var(--brand-50)",
|
||||
color: "var(--brand-700)",
|
||||
border: "1px solid var(--brand-200)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
<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("");
|
||||
}}
|
||||
>
|
||||
{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" }}
|
||||
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert style={{ borderColor: "var(--color-danger-border)", background: "var(--color-danger-bg)", borderRadius: "var(--radius-sm)" }}>
|
||||
<AlertCircle className="h-4 w-4" style={{ color: "var(--color-danger)" }} />
|
||||
<AlertDescription style={{ color: "var(--color-danger)" }}>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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)",
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Apply Leave"}
|
||||
</Button>
|
||||
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
||||
/>
|
||||
</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>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user