Approvals
Requests from your team. Drag to reorder, press a card to review it.
Ayesha KhanAnnual leave, 5 daysLeave · Mon 19 – Fri 23 Oct 2026
Luis OrtegaConference ticket, £420.00Expense · Submitted 6 Oct 2026
Omar HaddadOvertime, 6 hoursOvertime · Sat 3 Oct 2026
Chloe MartinSick leave, 1 dayLeave · Tue 6 Oct 2026
Ayesha Khan
Leave- Request
- Annual leave, 5 days
- When
- Mon 19 – Fri 23 Oct 2026
- Note
- Family trip. Handover doc shared with Mia.
approvals-inbox.tsx
"use client";
import { useState } from "react";
import { LuCheck, LuInbox, LuX } from "react-icons/lu";
import {
Badge,
Board,
BoardCard,
BoardColumn,
Button,
EmptyState,
Tab,
TabList,
TabPanel,
Tabs,
type BoardColumnData,
} from "@nexera-ui/react";
type Kind = "Leave" | "Expense" | "Overtime";
type Decision = "Approved" | "Declined";
interface Request {
id: string;
name: string;
kind: Kind;
summary: string;
detail: string;
note: string;
decision?: Decision;
}
const TONE = { Leave: "leave", Expense: "info", Overtime: "warning" } as const;
const WAITING: [Request, ...Request[]] = [
{
id: "q1",
name: "Ayesha Khan",
kind: "Leave",
summary: "Annual leave, 5 days",
detail: "Mon 19 – Fri 23 Oct 2026",
note: "Family trip. Handover doc shared with Mia.",
},
{
id: "q2",
name: "Luis Ortega",
kind: "Expense",
summary: "Conference ticket, £420.00",
detail: "Submitted 6 Oct 2026",
note: "React Summit London, learning budget 2026.",
},
{
id: "q3",
name: "Omar Haddad",
kind: "Overtime",
summary: "Overtime, 6 hours",
detail: "Sat 3 Oct 2026",
note: "Weekend support rota during the billing migration.",
},
{
id: "q4",
name: "Chloe Martin",
kind: "Leave",
summary: "Sick leave, 1 day",
detail: "Tue 6 Oct 2026",
note: "Fit note not needed for one day.",
},
];
/** Approvals inbox: requests waiting for your decision, in the order you want to handle them. */
export default function ApprovalsInbox() {
const [columns, setColumns] = useState<BoardColumnData<Request>[]>([
{ id: "waiting", title: "Waiting for you", items: WAITING },
]);
const [decided, setDecided] = useState<Request[]>([]);
const [openId, setOpenId] = useState<string>(WAITING[0].id);
const waiting = columns[0]?.items ?? [];
const open = waiting.find((r) => r.id === openId) ?? waiting[0];
const decide = (request: Request, decision: Decision) => {
const rest = waiting.filter((r) => r.id !== request.id);
setColumns([{ id: "waiting", title: "Waiting for you", items: rest }]);
setDecided([{ ...request, decision }, ...decided]);
if (rest[0]) setOpenId(rest[0].id);
};
const history = (decision: Decision) => {
const rows = decided.filter((r) => r.decision === decision);
if (rows.length === 0)
return (
<p className="p-4 text-body-small text-muted">
Nothing {decision.toLowerCase()} yet. Decide a waiting request to see it here.
</p>
);
return (
<ul className="m-0 grid list-none divide-y divide-subtle p-0">
{rows.map((r) => (
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-3">
<span className="grid">
<span className="text-body-strong text-primary">{r.name}</span>
<span className="text-body-small text-secondary">{r.summary}</span>
</span>
<Badge tone={decision === "Approved" ? "success" : "danger"} label={decision} />
</li>
))}
</ul>
);
};
return (
<div className="@container bg-page px-4 py-8 @3xl:px-8">
<div className="mx-auto grid w-full max-w-5xl gap-5">
<header className="grid gap-1">
<h2 className="text-heading-h2 text-primary">Approvals</h2>
<p className="text-body-default text-secondary">
Requests from your team. Drag to reorder, press a card to review it.
</p>
</header>
<Tabs defaultSelectedKey="waiting">
<TabList aria-label="Approvals" variant="underline">
<Tab id="waiting" label="Waiting" count={String(waiting.length)} />
<Tab
id="approved"
label="Approved"
count={String(decided.filter((r) => r.decision === "Approved").length)}
/>
<Tab
id="declined"
label="Declined"
count={String(decided.filter((r) => r.decision === "Declined").length)}
/>
</TabList>
<TabPanel id="waiting" className="pt-4">
{open ? (
<div className="grid gap-4 @3xl:grid-cols-[17rem_minmax(0,1fr)] @3xl:items-start">
<Board columns={columns} onColumnsChange={setColumns} className="block">
<BoardColumn<Request>
columnId="waiting"
aria-label="Waiting for you"
onAction={(key) => {
setOpenId(String(key));
}}
className="rounded-lg bg-sunken p-2"
>
{(r) => (
<BoardCard
name={r.name}
subtitle={r.summary}
detail={`${r.kind} · ${r.detail}`}
/>
)}
</BoardColumn>
</Board>
<section
aria-labelledby="request-title"
className="grid gap-4 rounded-xl border border-default bg-surface p-5 shadow-xs"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 id="request-title" className="text-heading-h4 text-primary">
{open.name}
</h3>
<Badge tone={TONE[open.kind]} label={open.kind} />
</div>
<dl className="grid gap-3 text-body-small">
{[
["Request", open.summary],
["When", open.detail],
["Note", open.note],
].map(([label, value]) => (
<div key={label} className="grid gap-0.5">
<dt className="text-muted">{label}</dt>
<dd className="text-primary">{value}</dd>
</div>
))}
</dl>
<div className="flex flex-wrap gap-2">
<Button
leadingIcon={<LuCheck />}
onPress={() => {
decide(open, "Approved");
}}
>
Approve
</Button>
<Button
variant="secondary"
leadingIcon={<LuX />}
onPress={() => {
decide(open, "Declined");
}}
>
Decline
</Button>
</div>
</section>
</div>
) : (
<EmptyState
headingLevel={3}
icon={<LuInbox />}
title="You’re all caught up"
description="New requests from your team will show up here."
/>
)}
</TabPanel>
<TabPanel id="approved" className="pt-2">
{history("Approved")}
</TabPanel>
<TabPanel id="declined" className="pt-2">
{history("Declined")}
</TabPanel>
</Tabs>
</div>
</div>
);
}