Patterns
Data table — people
UserCell (avatar with status, name, email) · role as a dot badge · RowActions: quick icons on hover, ⋯ for the rest, danger last.
KA Karim Ahmed karim@gulshanparking.com | — | now | |||||
NJ Nusrat Jahan nusrat.j@gulshanparking.com | $182,400 | 2 min ago | |||||
SI Shahidul Islam shahidul@gulshanparking.com | $96,350 | 18 min ago | |||||
FA Farhana Akter farhana.akter@gmail.com | $74,120 | on shift | |||||
RH Rafiq Hasan rafiq.h@gulshanparking.com | $51,800 | yesterday | |||||
TS Tania Sultana tania@gulshanparking.com | — | 5 min ago | |||||
MR Mahmud Rana mahmud.rana@gmail.com | $38,900 | 3 days ago | |||||
SC Sabrina Chowdhury sabrina.c@gulshanparking.com | $88,240 | now |
"use client";
import {
Avatar,
Button,
ConfirmDialog,
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
FormField,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
type ColumnDef,
DataTable,
Money,
RowActions,
UserCell,
StatusBadge,
} from "@waveinno/ui";
import { Copy, Eye, KeyRound, Mail, Pencil, Trash2, UserCog, UserX } from "lucide-react";
import { toast } from "sonner";
import * as React from "react";
interface Person {
id: string;
name: string;
email: string;
role: "Owner" | "Manager" | "Supervisor" | "Attendant" | "Accountant";
site: string;
status: "online" | "away" | "busy" | "offline";
shifts: number;
collected: number;
lastSeen: string;
verified?: boolean;
}
const PEOPLE: Person[] = [
["Karim Ahmed", "karim@gulshanparking.com", "Owner", "All sites", "online", 0, 0, "now", true],
["Nusrat Jahan", "nusrat.j@gulshanparking.com", "Manager", "Gulshan", "online", 22, 182400, "2 min ago", true],
["Shahidul Islam", "shahidul@gulshanparking.com", "Supervisor", "Banani", "away", 19, 96350, "18 min ago"],
["Farhana Akter", "farhana.akter@gmail.com", "Attendant", "Gulshan", "busy", 24, 74120, "on shift"],
["Rafiq Hasan", "rafiq.h@gulshanparking.com", "Attendant", "Dhanmondi", "offline", 17, 51800, "yesterday"],
["Tania Sultana", "tania@gulshanparking.com", "Accountant", "All sites", "online", 0, 0, "5 min ago", true],
["Mahmud Rana", "mahmud.rana@gmail.com", "Attendant", "Banani", "offline", 12, 38900, "3 days ago"],
["Sabrina Chowdhury", "sabrina.c@gulshanparking.com", "Supervisor", "Dhanmondi", "online", 21, 88240, "now"],
].map(([name, email, role, site, status, shifts, collected, lastSeen, verified], i) => ({
id: `p${i}`,
name: name as string,
email: email as string,
role: role as Person["role"],
site: site as string,
status: status as Person["status"],
shifts: shifts as number,
collected: collected as number,
lastSeen: lastSeen as string,
verified: verified as boolean | undefined,
}));
const ROLE_TONE: Record<Person["role"], "accent" | "info" | "success" | "warning" | "neutral"> = {
Owner: "accent",
Manager: "info",
Supervisor: "success",
Attendant: "neutral",
Accountant: "warning",
};
const ROLE_OPTIONS = ["Owner", "Manager", "Supervisor", "Attendant", "Accountant"].map((x) => ({ value: x, label: x }));
const PERSON_SITES = ["All sites", "Gulshan", "Banani", "Dhanmondi"].map((x) => ({ value: x, label: x }));
function peopleColumns(act: { edit: (p: Person) => void; remove: (p: Person) => void; suspend: (p: Person) => void }): ColumnDef<Person, unknown>[] {
return [
{
accessorKey: "name",
header: "Member",
size: 280,
meta: { filter: { type: "text", label: "Name" } },
cell: ({ row }) => <UserCell name={row.original.name} secondary={row.original.email} status={row.original.status} verified={row.original.verified} />,
},
{ accessorKey: "role", header: "Role", meta: { filter: { type: "select" }, edit: { type: "select", options: ROLE_OPTIONS } }, cell: ({ row }) => <StatusBadge label={row.original.role} tone={ROLE_TONE[row.original.role]} variant="dot" /> },
{ accessorKey: "site", header: "Site", meta: { filter: { type: "select" }, edit: { type: "select", options: PERSON_SITES } } },
{ accessorKey: "shifts", header: "Shifts (Sep)", meta: { align: "right", num: true, filter: { type: "number" }, edit: { type: "number" } } },
{
accessorKey: "collected",
header: "Cash collected",
meta: { align: "right", filter: { type: "number" } },
cell: ({ row }) => (row.original.collected ? <Money value={row.original.collected} decimals={0} /> : <span className="text-muted">—</span>),
},
{ accessorKey: "lastSeen", header: "Last seen", cell: ({ row }) => <span className="text-muted">{row.original.lastSeen}</span> },
{
id: "actions",
header: "",
size: 120,
enableSorting: false,
enableHiding: false,
meta: { sticky: "right" },
cell: ({ row }) => (
<RowActions
actions={[
{ label: "View profile", icon: Eye, quick: true, onSelect: () => toast(`Open ${row.original.name}`) },
{ label: "Edit", icon: Pencil, quick: true, shortcut: "E", onSelect: () => act.edit(row.original) },
{ label: "Send email", icon: Mail, onSelect: () => toast(`Email ${row.original.email}`) },
{ label: "Copy email", icon: Copy, onSelect: () => toast("Copied") },
{ label: "Reset PIN", icon: KeyRound, onSelect: () => toast("PIN reset sent") },
{ label: "Suspend", icon: UserX, danger: true, disabled: row.original.role === "Owner", onSelect: () => act.suspend(row.original) },
{ label: "Remove from team", icon: Trash2, danger: true, disabled: row.original.role === "Owner", onSelect: () => act.remove(row.original) },
]}
/>
),
},
];
}
function PersonEditor({ person, onClose, onSave }: { person: Person | null; onClose: () => void; onSave: (p: Person) => void }) {
const [draft, setDraft] = React.useState<Person | null>(person);
React.useEffect(() => setDraft(person), [person]);
const emailBad = !!draft && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(draft.email);
return (
<Dialog open={!!person} onOpenChange={(o) => !o && onClose()}>
<DialogContent size="md">
{draft ? (
<>
<DialogHeader icon={<UserCog />} eyebrow={draft.role}>
<DialogTitle>Edit {person?.name}</DialogTitle>
<DialogDescription>Changes apply the next time they sign in.</DialogDescription>
</DialogHeader>
<DialogBody className="space-y-4">
<div className="flex items-center gap-3 rounded-[12px] border border-border bg-surface p-3">
<Avatar name={draft.name} size="lg" status={draft.status} verified={draft.verified} />
<div className="min-w-0">
<p className="truncate font-medium text-text">{draft.name || "—"}</p>
<p className="truncate text-[12px] text-muted">{draft.email}</p>
</div>
</div>
<FormField label="Full name" htmlFor="pe-name" required error={draft.name.trim() ? undefined : "A name is required."}>
<Input id="pe-name" value={draft.name} state={draft.name.trim() ? "default" : "error"} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
</FormField>
<FormField label="Email" htmlFor="pe-mail" required error={emailBad ? "Enter a valid email." : undefined}>
<Input id="pe-mail" value={draft.email} state={emailBad ? "error" : "default"} leading={<Mail />} onChange={(e) => setDraft({ ...draft, email: e.target.value })} />
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Role">
<Select value={draft.role} onValueChange={(v) => setDraft({ ...draft, role: v as Person["role"] })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ROLE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label="Site">
<Select value={draft.site} onValueChange={(v) => setDraft({ ...draft, site: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERSON_SITES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</DialogBody>
<DialogFooter>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="primary"
disabled={!draft.name.trim() || emailBad}
onClick={() => {
onSave(draft);
toast.success("Saved");
onClose();
}}
>
Save changes
</Button>
</DialogFooter>
</>
) : null}
</DialogContent>
</Dialog>
);
}
export default function PeopleTable() {
const [people, setPeople] = React.useState(PEOPLE);
const [editing, setEditing] = React.useState<Person | null>(null);
const [removing, setRemoving] = React.useState<Person | null>(null);
const update = (id: string, patch: Partial<Person>) => setPeople((all) => all.map((p) => (p.id === id ? { ...p, ...patch } : p)));
const columns = React.useMemo(
() =>
peopleColumns({
edit: setEditing,
remove: setRemoving,
suspend: (p) => {
update(p.id, { status: "offline", lastSeen: "suspended" });
toast.success(`${p.name} is suspended`);
},
}),
[],
);
return (
<>
<DataTable
columns={columns}
data={people}
getRowId={(r) => r.id}
getRowLabel={(r) => `${r.name} · ${r.role}`}
pageSize={8}
searchPlaceholder="Search people…"
exportName="team"
onCellEdit={async (row, col, value) => update(row.id, { [col]: col === "shifts" ? Number(value) : value } as Partial<Person>)}
onBulkEdit={async (rows, col, value) => rows.forEach((r) => update(r.id, { [col]: col === "shifts" ? Number(value) : value } as Partial<Person>))}
onBulkDelete={async (rows) => {
const ids = new Set(rows.map((r) => r.id));
setPeople((all) => all.filter((p) => !ids.has(p.id) || p.role === "Owner"));
}}
/>
<PersonEditor person={editing} onClose={() => setEditing(null)} onSave={(p) => update(p.id, p)} />
<ConfirmDialog
open={!!removing}
onOpenChange={(o) => !o && setRemoving(null)}
title={`Remove ${removing?.name ?? ""} from the team?`}
description="They lose access to every car park at once. Their past shifts and cash records stay."
items={removing ? [`${removing.name} · ${removing.role} · ${removing.site}`] : []}
confirmLabel="Remove from team"
onConfirm={async () => {
await new Promise((r) => setTimeout(r, 400));
setPeople((all) => all.filter((p) => p.id !== removing?.id));
toast.success(`${removing?.name} was removed`);
}}
/>
</>
);
}Import#
Everything comes from the package root. The example above uses:
import {
Avatar,
Button,
ConfirmDialog,
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
FormField,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
type ColumnDef,
DataTable,
Money,
RowActions,
UserCell,
StatusBadge,
} from "@waveinno/ui";Props#
Read from the source of this version, so they match the code exactly.
<Avatar>
src/components/ui/avatar.tsx| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | string | — | |
src | string | null | — | |
size | keyof typeof sizes | "md" | |
status | keyof typeof STATUS | — | |
verified | boolean | — | |
ring | "gradient" | — | |
shape | "circle" | "square" | "circle" | |
className | string | — |
<Button>
src/components/ui/button.tsx| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | |
loading | boolean | — |
Also takes ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>.
<ConfirmDialog>
src/components/ui/confirm-dialog.tsx| Prop | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | — | |
onOpenChangerequired | (open: boolean) => void | — | |
titlerequired | React.ReactNode | — | |
description | React.ReactNode | — | |
items | string[] | — | What is affected: names shown in a list (first `previewCount`, then "+ n more"). |
previewCount | number | 3 | |
confirmWord | string | — | Require typing this word (e.g. "DELETE") before the button unlocks. |
hold | boolean | number | — | Press and hold the button to confirm; a number sets the hold in ms (default 1200). |
confirmLabelrequired | React.ReactNode | — | |
cancelLabel | React.ReactNode | "Cancel" | |
tone | "danger" | "warning" | "primary" | "danger" | |
variant | "center" | "split" | "banner" | "sheet" | "center" | |
icon | React.ReactNode | — | |
onConfirmrequired | () => void | Promise<void> | — | Runs on confirm; the button spins until it settles, the dialog closes on success. |
labels | { typeToConfirm?: (word: string) => string; more?: (n: number) => string; hold?: string } | — |
<DialogBody>
src/components/ui/dialog.tsxAlso takes HTMLAttributes<HTMLDivElement> (passed through).
<DialogContent>
src/components/ui/dialog.tsx| Prop | Type | Default | Description |
|---|---|---|---|
width | string | — | Maximum width, e.g. "max-w-[560px]". The drawer is full width on phones. |
size | keyof typeof SIZES | "md" | Width preset; `width` wins when both are given. |
resizable | boolean | true | Drawer only: show the widen / full-width buttons. On by default. |
hideClose | boolean | — | |
variant | "drawer" | "center" | "drawer" |
Also takes ComponentPropsWithoutRef<typeof DialogPrimitive.Content>.
<DialogDescription>
src/components/ui/dialog.tsxAlso takes ComponentPropsWithoutRef<typeof DialogPrimitive.Description> (passed through).
<DialogFooter>
src/components/ui/dialog.tsxAlso takes HTMLAttributes<HTMLDivElement> (passed through).
<DialogHeader>
src/components/ui/dialog.tsx| Prop | Type | Default | Description |
|---|---|---|---|
icon | React.ReactNode | — | A lucide icon (or any node) in a tinted tile beside the title. |
eyebrow | React.ReactNode | — | One short line above the title: the object or step, e.g. "Rate plan" or "Step 2 of 3". |
Also takes HTMLAttributes<HTMLDivElement>.
<DialogTitle>
src/components/ui/dialog.tsxAlso takes ComponentPropsWithoutRef<typeof DialogPrimitive.Title> (passed through).
<FormField>
src/components/ui/form-field.tsx| Prop | Type | Default | Description |
|---|---|---|---|
label | React.ReactNode | — | |
htmlFor | string | — | The control's id, so clicking the label focuses it. |
required | boolean | — | |
hint | React.ReactNode | — | |
error | React.ReactNode | — | |
warning | React.ReactNode | — | |
success | React.ReactNode | — | |
count | { value: number; max: number } | — | Character counter, e.g. { value: note.length, max: 240 }. |
className | string | — | |
childrenrequired | React.ReactNode | — |
<Input>
src/components/ui/input.tsx| Prop | Type | Default | Description |
|---|---|---|---|
state | "default" | "error" | "warning" | "success" | "default" | |
leading | React.ReactNode | — | Icon or text inside the box, before the value (a search icon, "৳", "+880"). |
trailing | React.ReactNode | — | After the value: a unit, a clear button, a status icon. |
Also takes InputHTMLAttributes<HTMLInputElement>.
<SelectContent>
src/components/ui/select.tsxAlso takes ComponentPropsWithoutRef<typeof SelectPrimitive.Content> (passed through).
<SelectItem>
src/components/ui/select.tsxAlso takes ComponentPropsWithoutRef<typeof SelectPrimitive.Item> (passed through).
<SelectTrigger>
src/components/ui/select.tsxAlso takes ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> (passed through).
<DataTable>
src/components/patterns/data-table.tsx| Prop | Type | Default | Description |
|---|---|---|---|
columnsrequired | ColumnDef<T, unknown>[] | — | |
datarequired | T[] | — | |
getRowIdrequired | (row: T) => string | — | |
onRowClick | (row: T) => void | — | |
activeId | string | null | — | Row currently shown in the drawer — highlighted, not navigated to. |
searchPlaceholder | string | "Search…" | |
pageSize | number | 12 | |
footer | React.ReactNode | — | Rendered in <tfoot>: totals turn a table into a report (§8). |
emptyState | React.ReactNode | — | |
savedViews | SavedView[] | [] | |
toolbarExtra | React.ReactNode | — | |
exportName | string | "waveinno-export" | |
defaultFilters | FilterState | EMPTY_FILTERS | Filters to start with (e.g. from the URL). |
onFiltersChange | (filters: FilterState) => void | — | Told every time the filters change — save them, or send them to the API. |
manualFiltering | boolean | false | The server filters: rules are shown and reported but rows are not filtered here. |
filterLabels | Partial<FilterLabels> | — | |
variant | "default" | "striped" | "bordered" | "cards" | "minimal" | "default" | The look: `default` (hairline rows), `striped` (zebra, for long scans), `bordered` (a spreadsheet grid — the one for editing), `cards` (every row a floating card, for few, rich rows), `minimal` (no frame, inside cards and drawers). |
onCellEdit | (row: T, columnId: string, value: string) => void | Promise<void> | — | Save an edited cell. Return a promise to show a spinner; reject to keep the editor open. |
onBulkEdit | (rows: T[], columnId: string, value: string) => void | Promise<void> | — | Save one value into many rows (the bulk bar's Edit). Without it, the table calls `onCellEdit` once per selected row. |
getRowLabel | (row: T) => string | — | Name of a row in confirmations ("Delete these 3: …"). Defaults to the row id. |
onBulkDelete | (rows: T[]) => void | Promise<void> | — | Delete the selected rows (the bulk bar's Delete, after a confirmation). |
bulkActions | (rows: T[], clear: () => void) => React.ReactNode | — | Extra buttons in the bulk bar, e.g. Assign or Archive. `clear` empties the selection. |
className | string | — |
<Money>
src/components/patterns/money.tsx| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | number | null | undefined | — | |
compact | boolean | — | |
className | string | — | |
decimals | number | — | Override the currency's decimals — whole-number prices read cleaner. |
symbol | string | — | Escape hatch for a figure explicitly stated in another currency. |
<RowActions>
src/components/patterns/data-table-cells.tsx| Prop | Type | Default | Description |
|---|---|---|---|
actionsrequired | RowAction[] | — | |
label | string | "Row actions" |
<UserCell>
src/components/patterns/data-table-cells.tsx| Prop | Type | Default | Description |
|---|---|---|---|
namerequired | string | — | |
secondary | React.ReactNode | — | Second line: email, phone or role. |
src | string | null | — | |
status | "online" | "away" | "busy" | "offline" | — | |
badge | { label: string; tone?: Tone } | — | |
verified | boolean | — |
<StatusBadge>
src/components/ui/badge.tsx| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | |
tone | Tone | "neutral" | |
variant | "soft" | "solid" | "outline" | "dot" | "gradient" | "live" | — | |
className | string | — |
Install#
If the project does not have the package yet, add it, then follow the setup for Next.js or Vite once.
$ npm install @waveinno/ui