Employees can apply for leave and track balance, status, and history. Managers can view all requests and approve/reject them. Login with separate dashboards for employees and managers with the dummy json. Tables with filters (All, Pending, Approved, Rejected) for easy management.
240 lines
8.2 KiB
TypeScript
240 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
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";
|
|
|
|
interface LeaveSheetProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
userId: number;
|
|
leaveBalance: number;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
const LEAVE_TYPES = ["Sick", "Casual", "Annual", "Unpaid"] as const;
|
|
|
|
export function LeaveSheet({ open, onOpenChange, userId, leaveBalance, onSuccess }: LeaveSheetProps) {
|
|
const [type, setType] = useState<typeof LEAVE_TYPES[number]>("Casual");
|
|
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
|
|
: 0;
|
|
|
|
const reset = () => {
|
|
setType("Casual");
|
|
setFromDate("");
|
|
setToDate("");
|
|
setReason("");
|
|
setError("");
|
|
setSuccess(false);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
reset();
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
if (!fromDate || !toDate || !reason.trim()) {
|
|
setError("All fields are required");
|
|
return;
|
|
}
|
|
if (toDate < fromDate) {
|
|
setError("End date must be after start date");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
const result = await applyLeave(userId, { type, fromDate, toDate, reason });
|
|
setLoading(false);
|
|
if (!result.ok) {
|
|
setError(result.error ?? "Failed to apply");
|
|
return;
|
|
}
|
|
setSuccess(true);
|
|
setTimeout(() => {
|
|
handleClose();
|
|
onSuccess();
|
|
}, 1500);
|
|
};
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={handleClose}>
|
|
<SheetContent
|
|
className="w-full sm:max-w-xl overflow-y-auto p-6"
|
|
style={{ background: "var(--surface-card)", borderLeft: "1px solid var(--surface-border)" }}
|
|
>
|
|
<SheetHeader className="pb-4">
|
|
<SheetTitle style={{ color: "var(--text-primary)", fontSize: "1.125rem" }}>
|
|
Apply for Leave
|
|
</SheetTitle>
|
|
<SheetDescription style={{ color: "var(--text-secondary)" }}>
|
|
You have{" "}
|
|
<span className="font-semibold" style={{ color: "var(--brand-600)" }}>
|
|
{leaveBalance} days
|
|
</span>{" "}
|
|
remaining
|
|
</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)}>
|
|
<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)",
|
|
}}
|
|
>
|
|
{daysCount} day{daysCount > 1 ? "s" : ""} selected
|
|
</div>
|
|
)}
|
|
|
|
{/* Reason */}
|
|
<div className="space-y-1.5">
|
|
<Label style={{ color: "var(--text-secondary)", fontSize: "0.8125rem", fontWeight: 500 }}>
|
|
Reason
|
|
</Label>
|
|
<Textarea
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="Brief reason for leave..."
|
|
rows={3}
|
|
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)", resize: "none" }}
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<Alert style={{ borderColor: "var(--color-danger-border)", background: "var(--color-danger-bg)", borderRadius: "var(--radius-sm)" }}>
|
|
<AlertCircle className="h-4 w-4" style={{ color: "var(--color-danger)" }} />
|
|
<AlertDescription style={{ color: "var(--color-danger)" }}>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={handleClose}
|
|
style={{ borderColor: "var(--surface-border)", borderRadius: "var(--radius-sm)" }}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="flex-1 font-semibold"
|
|
disabled={loading}
|
|
style={{
|
|
background: "linear-gradient(135deg, var(--brand-600), var(--brand-500))",
|
|
border: "none",
|
|
borderRadius: "var(--radius-sm)",
|
|
}}
|
|
>
|
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Apply Leave"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
} |