Leave request
Request time off and see the balance.
Time off
Ayesha Khan · Design · 2026 leave year
Balances
- Annual leave12 days
- Sick leave8 days
- Unpaid leave30 days
Requests
- Annual leave16 Oct to 17 Oct · 2 daysApproved
leave-request.tsx
"use client";
import { useState, type SyntheticEvent } from "react";
import { isWeekend, parseDate, toCalendarDate, type CalendarDate } from "@internationalized/date";
import { LuPlus } from "react-icons/lu";
import {
Badge,
Button,
CalendarMonth,
Drawer,
Select,
SelectOption,
Textarea,
} from "@nexera-ui/react";
const TYPES = [
{ id: "annual", label: "Annual leave", balance: 12 },
{ id: "sick", label: "Sick leave", balance: 8 },
{ id: "unpaid", label: "Unpaid leave", balance: 30 },
];
interface Request {
type: string;
dates: string;
days: number;
status: "Approved" | "Pending";
}
const fmt = (d: CalendarDate) =>
new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", timeZone: "UTC" }).format(
d.toDate("UTC"),
);
const workingDays = (start: CalendarDate, end: CalendarDate) => {
let days = 0;
for (let d = start; d.compare(end) <= 0; d = d.add({ days: 1 }))
if (!isWeekend(d, "en-GB")) days++;
return days;
};
/** Leave request: balances and past requests, and a drawer to pick dates, a leave type and a note. */
export default function LeaveRequest() {
const [open, setOpen] = useState(false);
const [type, setType] = useState<string | number | null>("annual");
const [range, setRange] = useState<{ start: CalendarDate; end: CalendarDate } | null>({
start: parseDate("2026-11-02"),
end: parseDate("2026-11-06"),
});
const [note, setNote] = useState("");
const [requests, setRequests] = useState<Request[]>([
{ type: "Annual leave", dates: "16 Oct to 17 Oct", days: 2, status: "Approved" },
]);
const chosen = TYPES.find((t) => t.id === type);
const days = range ? workingDays(range.start, range.end) : 0;
const tooMany = chosen !== undefined && days > chosen.balance;
const submit = (event: SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
if (!range || !chosen || tooMany || days === 0) return;
setRequests((r) => [
{
type: chosen.label,
dates: `${fmt(range.start)} to ${fmt(range.end)}`,
days,
status: "Pending",
},
...r,
]);
setOpen(false);
};
return (
<div className="@container bg-page px-4 py-8 @3xl:px-8">
<div className="mx-auto grid w-full max-w-3xl gap-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-1">
<h2 className="text-heading-h3 text-primary">Time off</h2>
<p className="text-body-default text-secondary">
Ayesha Khan · Design · 2026 leave year
</p>
</div>
<Drawer
title="Request leave"
subtitle="Your manager, Mia Jensen, will be notified."
size="md"
isOpen={open}
onOpenChange={setOpen}
trigger={<Button leadingIcon={<LuPlus />}>Request leave</Button>}
secondaryAction={
<Button variant="secondary" size="md" slot="close">
Cancel
</Button>
}
primaryAction={
<Button
type="submit"
form="leave-form"
size="md"
isDisabled={!range || days === 0 || tooMany}
>
Send request
</Button>
}
>
<form id="leave-form" className="grid gap-5" onSubmit={submit}>
<Select label="Leave type" value={type} onChange={setType} isRequired>
{TYPES.map((t) => (
<SelectOption
key={t.id}
id={t.id}
label={t.label}
description={`${t.balance} days left`}
/>
))}
</Select>
<div className="grid gap-2">
<CalendarMonth
selectionMode="range"
aria-label="Leave dates"
value={range}
minValue={parseDate("2026-10-14")}
isDateUnavailable={(d) => isWeekend(d, "en-GB")}
allowsNonContiguousRanges
onChange={(r) => {
setRange({ start: toCalendarDate(r.start), end: toCalendarDate(r.end) });
}}
isInvalid={tooMany}
errorMessage={
chosen
? `You have ${chosen.balance} days of ${chosen.label.toLowerCase()} left.`
: undefined
}
/>
<p aria-live="polite" className="text-body-small text-secondary">
{range
? `${fmt(range.start)} to ${fmt(range.end)} · ${days} working ${days === 1 ? "day" : "days"}`
: "Choose the first and last day."}
</p>
</div>
<Textarea
label="Note for your manager"
description="Optional. Say who covers your work."
value={note}
onChange={setNote}
maxLength={300}
/>
</form>
</Drawer>
</header>
<section aria-labelledby="balances-title" className="grid gap-3">
<h3 id="balances-title" className="text-body-strong text-primary">
Balances
</h3>
<ul className="m-0 grid list-none gap-3 p-0 @3xl:grid-cols-3">
{TYPES.map((t) => (
<li
key={t.id}
className="grid gap-1 rounded-xl border border-default bg-surface p-4 shadow-xs"
>
<span className="text-body-small text-secondary">{t.label}</span>
<span className="text-heading-h3 text-primary">{t.balance} days</span>
</li>
))}
</ul>
</section>
<section aria-labelledby="requests-title" className="grid gap-3">
<h3 id="requests-title" className="text-body-strong text-primary">
Requests
</h3>
<ul className="m-0 grid list-none overflow-hidden rounded-xl border border-default bg-surface p-0">
{requests.map((r, i) => (
<li
key={i}
className="flex flex-wrap items-center justify-between gap-2 border-t border-default p-4 first:border-t-0"
>
<div className="grid">
<span className="text-body-strong text-primary">{r.type}</span>
<span className="text-body-small text-secondary">
{r.dates} · {r.days} {r.days === 1 ? "day" : "days"}
</span>
</div>
<Badge tone={r.status === "Approved" ? "success" : "warning"} label={r.status} />
</li>
))}
</ul>
</section>
</div>
</div>
);
}
More scheduling and calendar blocks
- Month calendarEvents across a month, with a 'more' overflow.
- Week calendarHour-by-hour week view with events.
- Team availabilityWho is free when, side by side.
- Book a meetingPick a day, then a free time slot.
- Event detailsA popover with time, place and people.
- Leave approvalReview a request and approve or decline.
- Shift pickerChoose a morning, evening or night shift.