integrate users

This commit is contained in:
rgrgogu
2026-05-06 14:14:54 +08:00
parent 569442bf33
commit f165e8b3bb
35 changed files with 2516 additions and 24 deletions
@@ -0,0 +1,104 @@
import { useNavigate } from "react-router-dom";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
/**
* AppBreadcrumb
*
* Generic breadcrumb driven entirely by the `items` prop.
* The last item is always rendered as the current page (non-clickable).
* All preceding items are rendered as clickable links.
*
* ─── Item definition shape ────────────────────────────────────────────────────
*
* @field {string} label Display text
* @field {ReactNode} [icon] Optional icon rendered before the label
* @field {string} [to] navigate() path — if omitted, item is non-navigable
* @field {Function} [onClick] Custom click handler (e, navigate) => void
* Overrides `to` when provided.
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Array} items Ordered list of breadcrumb item definitions
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { House, Users } from "lucide-react";
* import AppBreadcrumb from "@/components/generic/AppBreadcrumb";
*
* // Basic — just paths
* <AppBreadcrumb
* items={[
* { label: "Home", icon: <House className="size-4" />, to: `/admin/${adminId}/users` },
* { label: "Users" },
* ]}
* />
*
* // With custom click handler
* <AppBreadcrumb
* items={[
* { label: "Home", icon: <House className="size-4" />, onClick: (e, navigate) => navigate(-1) },
* { label: "Settings", to: `/admin/${adminId}/settings` },
* { label: "Profile" },
* ]}
* />
*
* ─────────────────────────────────────────────────────────────────────────────
*/
const AppBreadcrumb = ({ items = [] }) => {
const navigate = useNavigate();
if (!items.length) return null;
return (
<Breadcrumb>
<BreadcrumbList>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<span key={index} className="flex items-center gap-1.5">
<BreadcrumbItem>
{isLast ? (
// Current page — no interaction
<BreadcrumbPage className="flex items-center gap-2">
{item.icon}
{item.label}
</BreadcrumbPage>
) : (
// Clickable link
<BreadcrumbLink asChild>
<div
className="flex items-center gap-2 select-none cursor-pointer"
onClick={(e) => {
if (item.onClick) {
item.onClick(e, navigate);
} else if (item.to) {
e.preventDefault();
navigate(item.to);
}
}}
>
{item.icon}
<span>{item.label}</span>
</div>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</span>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
};
export default AppBreadcrumb;
+65
View File
@@ -0,0 +1,65 @@
// ─── DashboardGrid.jsx ─────────────────────────────────────────────────────────
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
export default function DashboardGrid({ sections = [] }) {
const navigate = useNavigate();
const handleNavigate = (e, link) => {
e.preventDefault()
navigate(link)
}
return (
<section className="bg-muted/60 xs:h-full lg:h-screen">
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 px-4">
{sections.map(({ title, description, tiles }) => (
<div key={title} className="flex flex-col items-start gap-4 w-full">
{/* Header */}
<div className="flex flex-col gap-2 mt-6">
<h1 className="text-2xl leading-tighter font-medium tracking-tighter">
{title}
</h1>
<p className="text-muted-foreground">{description}</p>
</div>
{/* Tiles */}
<div className="grid xs:grid-cols-2 lg:grid-cols-5 h-fit w-full gap-4">
{tiles.map(({ key, label, icon: Icon, link }) => (
<motion.div
key={key}
onClick={(e) => handleNavigate(e, link)}
className="group bg-card border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer"
whileHover={{
y: -6,
scale: 1.02,
backgroundColor: "var(--primary)",
color: "var(--motion-card-hover)"
}}
transition={{
y: { type: "spring", stiffness: 300, damping: 20 },
scale: { type: "spring", stiffness: 300, damping: 20 },
backgroundColor: { duration: 0.2, ease: "easeOut" },
}}
>
<motion.div
className="w-full flex justify-end"
whileHover={{ rotate: -18, scale: 1.05 }}
transition={{ type: "spring", stiffness: 200 }}
>
<Icon className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
</motion.div>
<motion.div className="p-4 font-medium mt-auto" whileHover={{ y: -2 }}>
{label}
</motion.div>
</motion.div>
))}
</div>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,201 @@
import { useState, useEffect, useMemo } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
// ─── Field-specific display maps ──────────────────────────────────────────────
const FIELD_DISPLAY_MAP = {
is_active: { true: "Active", false: "Inactive" },
is_verified: { true: "Verified", false: "Not Verified" },
};
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
// ─── Generic formatter ────────────────────────────────────────────────────────
const formatFilterItem = (item, field, type) => {
if (FIELD_DISPLAY_MAP[field]) {
return FIELD_DISPLAY_MAP[field][String(item)] ?? item;
}
if (type === "date" && item) {
return new Date(item).toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric",
});
}
return item;
};
// ─── Reusable empty state ─────────────────────────────────────────────────────
const EmptyState = ({ search }) => (
<p className="text-sm text-muted-foreground text-center py-4">
{search ? `No results for "${search}".` : "No data found."}
</p>
);
// ─── Reusable item list renderer ──────────────────────────────────────────────
const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => {
if (items.length === 0) return <EmptyState />;
return items.map((item) => (
<label key={item} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type={inputType}
name={inputType === "radio" ? field : undefined}
checked={selected.includes(String(item))}
onChange={() => onToggle(item)}
/>
{formatFilterItem(item, field, type)}
</label>
));
};
export function FilterSheet({ open, onOpenChange, column, attr, data = [], loading }) {
const type = attr?.type;
const field = attr?.field;
const enumData = attr?.options?.choices || [];
const [search, setSearch] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
const val = column?.getFilterValue();
setSelected(Array.isArray(val) ? val.map(String) : []);
}, [column]);
const sourceData = useMemo(() => {
if (BOOLEAN_FIELDS.includes(field)) return ["true", "false"];
if (type === "enum") return enumData;
return data || [];
}, [field, type, enumData, data]);
const filteredData = useMemo(() => {
if (!search) return sourceData;
return sourceData.filter((item) =>
formatFilterItem(item, field, type)
.toString()
.toLowerCase()
.includes(search.toLowerCase())
);
}, [search, sourceData, field, type]);
const toggle = (item) => {
setSelected((prev) =>
prev.includes(String(item))
? prev.filter((v) => v !== String(item))
: [...prev, String(item)]
);
};
const toggleRadio = (item) => setSelected([String(item)]);
const isBoolean = BOOLEAN_FIELDS.includes(field);
const isEnum = type === "enum" && !isBoolean;
const isList = !isBoolean && !isEnum;
const isEmpty = !loading && sourceData.length === 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[380px] flex flex-col">
<SheetHeader className="shrink-0 border-b">
<SheetTitle>Filter by {column?.columnDef?.header}</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-hidden flex flex-col gap-4 px-4 relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
{/* ================= EMPTY STATE ================= */}
{isEmpty && <EmptyState />}
{/* ================= BOOLEAN ================= */}
{!isEmpty && isBoolean && (
<div className="space-y-2 overflow-auto pr-2 pt-2">
<FilterList
items={sourceData}
field={field}
type={type}
selected={selected}
onToggle={toggleRadio}
inputType="radio"
/>
</div>
)}
{/* ================= ENUM ================= */}
{!isEmpty && isEnum && (
<div className="space-y-2 overflow-auto pr-2 pt-2">
<FilterList
items={sourceData}
field={field}
type={type}
selected={selected}
onToggle={toggle}
/>
</div>
)}
{/* ========== TEXT / NUMBER / DATE ========== */}
{!isEmpty && isList && (
<>
<div>
<Input
placeholder={`Search ${type}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<ScrollArea className="flex-1 h-[550px] pr-2">
<div className="space-y-2">
{filteredData.length === 0
? <EmptyState search={search} />
: <FilterList
items={filteredData}
field={field}
type={type}
selected={selected}
onToggle={toggle}
/>
}
</div>
<ScrollBar orientation="vertical" />
</ScrollArea>
</>
)}
</div>
{/* ─── Footer — hidden when empty ──────────────────────────────────── */}
{!isEmpty && (
<div className="shrink-0 flex gap-2 border-t p-4">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setSelected([]);
column.setFilterValue(undefined);
onOpenChange(false);
}}
>
Clear
</Button>
<Button
className="flex-1"
onClick={() => {
column.setFilterValue(selected);
onOpenChange(false);
}}
>
Apply
</Button>
</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,65 @@
import { Filter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* ActiveFilterPills
*
* Displays active column filters as dismissible pill badges.
*
* Props:
* @param {Array} filters - Array of { id, value } (TanStack columnFilters shape)
* @param {Array} attributes - Array of { field, name } for display labels
* @param {Function} onRemove - (id: string) => void — remove a single filter
* @param {Function} [onClearAll] - () => void — clear all filters
* @param {boolean} [showClearButton]- Render the "Clear filters (n)" button instead of pills
* (used in the toolbar area; omit for the pill row)
*/
export function ActiveFilterPills({
filters = [],
attributes = [],
onRemove,
onClearAll,
showClearButton = false,
}) {
if (filters.length === 0) return null;
// ── Toolbar variant: just a "Clear filters (n)" ghost button ──────────────
if (showClearButton) {
return (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
Clear filters ({filters.length})
</Button>
);
}
// ── Pill row variant ───────────────────────────────────────────────────────
return (
<div className="flex items-center gap-2 flex-wrap mb-3">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{filters.map((f) => {
const attr = attributes.find((a) => a.field === f.id);
return (
<span
key={f.id}
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary border border-primary/20 rounded-full px-2.5 py-0.5"
>
<span className="font-medium">{attr?.name ?? f.id}:</span>
{String(f.value)}
<button
onClick={() => onRemove(f.id)}
className="ml-0.5 hover:text-destructive transition-colors"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
);
})}
</div>
);
}
@@ -0,0 +1,88 @@
import { RowActions } from "@/components/generic/Table/RowActions";
/**
* buildRowActionsColumn
*
* Factory that returns a TanStack column definition for the kebab actions column.
* Append the result to your `columns` array — no other setup needed.
*
* @param {Array} rowActions Action definitions (see RowActions for shape)
* @param {Object} [options]
* @param {string} [options.id] Column id (default: "actions")
* @param {string} [options.header] Column header text (default: "")
* @param {string} [options.dropdownLabel] Kebab menu label (default: "Actions")
* @param {string} [options.align] Dropdown alignment (default: "end")
* @param {number} [options.size] Column px width (default: 48)
* @returns {Object} TanStack ColumnDef
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { buildRowActionsColumn } from "@/components/generic/buildRowActionsColumn";
* import { Pencil, Trash2, Eye, Archive } from "lucide-react";
*
* const columns = [
* ...buildColumns(attributes),
*
* buildRowActionsColumn(
* [
* {
* key: "view",
* label: "View details",
* icon: <Eye className="h-3.5 w-3.5" />,
* onClick: (row) => navigate(`/users/${row.id}`),
* },
* {
* key: "edit",
* label: "Edit",
* icon: <Pencil className="h-3.5 w-3.5" />,
* onClick: (row) => navigate(`/users/${row.id}/edit`),
* disabled: (row) => row.role === "super_admin",
* },
* {
* key: "archive",
* label: "Archive",
* icon: <Archive className="h-3.5 w-3.5" />,
* onClick: (row) => archiveUser(row.id),
* hidden: (row) => row.status === "archived",
* separator: true,
* },
* {
* key: "delete",
* label: "Delete",
* icon: <Trash2 className="h-3.5 w-3.5" />,
* className: "text-destructive focus:text-destructive",
* onClick: (row) => confirmDelete(row.id),
* disabled: (row) => row.role === "admin",
* },
* ],
* { dropdownLabel: "User Actions" }
* ),
* ];
*
* ─────────────────────────────────────────────────────────────────────────────
*/
export function buildRowActionsColumn(rowActions, options = {}) {
const {
id = "actions",
header = "Actions",
dropdownLabel = "Actions",
align = "end",
size = 48,
} = options;
return {
id,
header,
size,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<RowActions
row={row}
rowActions={rowActions}
dropdownLabel={dropdownLabel}
align={align}
/>
),
};
}
@@ -0,0 +1,62 @@
import { Checkbox } from "@/components/ui/checkbox";
/**
* buildSelectionColumn
*
* Returns a TanStack column definition for the checkbox selection column.
* Prepend this to your `columns` array.
*
* The header renders a "select all on this page" checkbox with an
* indeterminate state when only some rows are checked.
* Each cell renders a per-row checkbox.
*
* @returns {Object} TanStack ColumnDef
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { buildSelectionColumn } from "@/components/generic/buildSelectionColumn";
*
* const columns = [
* buildSelectionColumn(),
* ...buildColumns(attributes),
* buildRowActionsColumn(rowActions),
* ];
*
* ─────────────────────────────────────────────────────────────────────────────
*/
export function buildSelectionColumn() {
return {
id: "select",
size: 40,
enableSorting: false,
enableHiding: false,
header: ({ table }) => (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all rows on this page"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
};
}
@@ -0,0 +1,92 @@
import { flexRender } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronDown,
X,
} from "lucide-react";
/**
* ColumnActionsDropdown
*
* Per-column header button that opens a dropdown with sort and filter actions.
*
* Props:
* @param {Object} header - TanStack header object
* @param {Object} [attr] - Attribute definition (from column meta.attr)
* @param {boolean} isOpen - Controlled open state
* @param {Function} onOpenChange - (isOpen: boolean) => void
* @param {Function} onFilterClick - (e) => void — triggered when "Filter By" is clicked
* @param {boolean} [showFilter] - Whether to render the Filter By option (default: !!attr)
*/
export function ColumnActionsDropdown({
header,
attr,
isOpen,
onOpenChange,
onFilterClick,
showFilter,
}) {
const column = header.column;
const sorted = column.getIsSorted();
const canFilter = showFilter ?? !!attr;
return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-3">
{sorted === "asc" && (
<ArrowUp className="size-3.5 text-primary" />
)}
{sorted === "desc" && (
<ArrowDown className="size-3.5 text-primary" />
)}
{!sorted && (
<ArrowUpDown className="size-3.5 text-muted-foreground" />
)}
{flexRender(column.columnDef.header, header.getContext())}
<ChevronDown className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="text-xs">Column Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp className="mr-2 size-4" />
Sort Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown className="mr-2 size-4" />
Sort Desc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.clearSorting()}>
<X className="mr-2 size-4" />
Clear Sort
</DropdownMenuItem>
{canFilter && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onFilterClick}>
Filter By
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,58 @@
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Columns2 } from "lucide-react";
export function ColumnVisibilityToggle({ table, label = "Columns" }) {
const columns = table.getAllLeafColumns().filter(
(col) => col.id !== "select" && col.id !== "actions"
);
const allVisible = columns.every((col) => col.getIsVisible());
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1.5">
<Columns2 className="h-3.5 w-3.5" />
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Toggle visible columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* ─── Show All ──────────────────────────────────────────────────── */}
<DropdownMenuCheckboxItem
checked={allVisible}
onCheckedChange={(v) => table.toggleAllColumnsVisible(v)}
className="text-xs font-medium"
>
Show All
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{/* ─── Individual columns ────────────────────────────────────────── */}
{columns.map((col) => {
const headerLabel = typeof col.columnDef.header === "string"
? col.columnDef.header
: col.id;
return (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(v) => col.toggleVisibility(v)}
className="text-xs"
>
{headerLabel}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+317
View File
@@ -0,0 +1,317 @@
import { useState, useMemo, useRef, useEffect } from "react";
import { useReactTable, getCoreRowModel, flexRender, } from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Spinner } from "@/components/ui/spinner";
import { pageSizes } from "@/utils/table.util";
import { ActiveFilterPills } from "./ActiveFilterPills";
import { ColumnActionsDropdown } from "./ColumnActionsDropdown";
import { TablePagination } from "./TablePagination";
import { ColumnVisibilityToggle } from "./ColumnVisibilityToggle";
import { ToolbarActions } from "./ToolbarActions";
import { SelectionToolbar } from "./SelectionToolbar";
import { cn } from "@/lib/utils";
export default function DataTable({
data,
columns,
attributes = [],
pagination,
setPagination,
loading,
onFetch,
onFetchFilterData,
onRefsReady,
renderFilterSheet,
toolbarActions = [],
selectionActions = [],
showColumnToggle = true,
title = "Records",
emptyMessage = "No records match the current filters.",
recordLabel = "record",
pageSizeOptions = pageSizes,
columnPinning = { right: [], left: [] },
className = "",
}) {
// ─── Refs — always hold latest filters and sort ───────────────────────────
const filtersRef = useRef([]);
const sortRef = useRef([]);
// ─── State ────────────────────────────────────────────────────────────────
const [activeColumn, setActiveColumn] = useState(null);
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const [columnVisibility, setColumnVisibility] = useState({});
const [rowSelection, setRowSelection] = useState({});
const [filterState, setFilterState] = useState({
open: false, column: null, attr: null, data: [],
});
const activeFilters = columnFilters.filter((f) => f.value !== "");
// ─── Initial fetch on mount only ─────────────────────────────────────────
useEffect(() => {
onRefsReady?.({
getFilters: () => filtersRef.current,
getSort: () => sortRef.current,
});
onFetch({ page: 1, limit: pagination.limit, filters: [], sort: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleOpenFilterSheet = async (e, column, attr) => {
e.preventDefault();
setActiveColumn(null);
const data = await onFetchFilterData(attr.field);
setFilterState({ open: true, column, attr, data });
};
const table = useReactTable({
data,
columns,
initialState: { columnPinning },
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination: {
pageIndex: pagination.page - 1,
pageSize: pagination.limit,
},
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: pagination.totalPages,
// ─── Sort change ────────────────────────────────────────────────────────
onSortingChange: (updater) => {
const next = typeof updater === "function" ? updater(sorting) : updater;
const newSort = next.length ? [{ id: next[0].id, desc: next[0].desc }] : [];
setSorting(next);
sortRef.current = newSort;
onFetch({
page: 1,
limit: pagination.limit,
filters: filtersRef.current,
sort: newSort,
});
},
// ─── Filter change ──────────────────────────────────────────────────────
onColumnFiltersChange: (updater) => {
const next = typeof updater === "function" ? updater(columnFilters) : updater;
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({
page: 1,
limit: pagination.limit,
filters: newFilters,
sort: sortRef.current,
});
},
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
});
// ─── Single handler for all page changes ──────────────────────────────────────
const handlePageChange = (page) => {
setPagination((p) => ({ ...p, page }));
onFetch({
page,
limit: pagination.limit,
filters: filtersRef.current,
sort: sortRef.current,
});
};
const selectedRows = useMemo(
() => table.getSelectedRowModel().rows.map((r) => r.original),
// eslint-disable-next-line react-hooks/exhaustive-deps
[rowSelection, data]
);
const hasSelection = selectedRows.length > 0;
return (
<div className={`w-full bg-card rounded-lg border border-border mb-6 px-6 py-4 relative ${className}`}>
{/* ── Toolbar ── */}
{hasSelection ? (
<SelectionToolbar
selectedRows={selectedRows}
onClearSelection={() => table.resetRowSelection()}
selectionActions={selectionActions}
recordLabel={recordLabel}
/>
) : (
<div className="flex items-start justify-between gap-4 mb-5">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<div className="flex items-center gap-2 shrink-0">
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
onClearAll={() => {
setColumnFilters([]);
filtersRef.current = [];
onFetch({ page: 1, limit: pagination.limit, filters: [], sort: sortRef.current });
}}
onRemove={(id) => {
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({ page: 1, limit: pagination.limit, filters: newFilters, sort: sortRef.current });
}}
showClearButton
/>
<ToolbarActions actions={toolbarActions} />
{showColumnToggle && <ColumnVisibilityToggle table={table} />}
</div>
</div>
)}
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
onRemove={(id) => {
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({ page: 1, limit: pagination.limit, filters: newFilters, sort: sortRef.current });
}}
/>
{/* ── Table ── */}
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="bg-muted/40 hover:bg-muted/40">
{hg.headers.map((header) => {
const isPinned = header.column.getIsPinned();
const attr = header.column.columnDef.meta?.attr;
const isPlain = header.column.id === "select" || header.column.id === "actions";
return (
<TableHead
key={header.id}
className={cn("align-middle whitespace-nowrap", isPinned ? "bg-muted" : "")}
style={{
position: isPinned ? "sticky" : "relative",
right: isPinned === "right" ? header.column.getStart("right") : undefined,
left: isPinned === "left" ? header.column.getStart("left") : undefined,
zIndex: isPinned ? 2 : 0,
width: header.column.columnDef.size,
}}
>
{isPlain ? (
flexRender(header.column.columnDef.header, header.getContext())
) : (
<ColumnActionsDropdown
header={header}
attr={attr}
isOpen={activeColumn === header.column.id}
onOpenChange={(isOpen) => setActiveColumn(isOpen ? header.column.id : null)}
onFilterClick={(e) => handleOpenFilterSheet(e, header.column, attr)}
/>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
{renderFilterSheet?.({
open: filterState.open,
onOpenChange: (v) => setFilterState((p) => ({ ...p, open: v })),
column: filterState.column,
attr: filterState.attr,
data: filterState.data,
loading,
})}
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="text-center py-16 text-muted-foreground text-sm"
>
{emptyMessage}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
className="hover:bg-muted/30 transition-colors data-[state=selected]:bg-primary/5"
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned();
return (
<TableCell
key={cell.id}
className={cn("m-0", isPinned && "bg-card")}
style={{
position: isPinned ? "sticky" : "relative",
right: isPinned === "right" ? 0 : undefined,
left: isPinned === "left" ? cell.column.getStart("left") : undefined,
zIndex: isPinned ? 1 : 0,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
</div>
{/* ── Footer ── */}
<TablePagination
table={table}
pagination={pagination}
onPageChange={handlePageChange} // ← replaces setPagination
totalRecords={pagination.totalRecords}
rowCount={data.length}
onPageSizeChange={(size) => {
setPagination((p) => ({ ...p, limit: size, page: 1 })); // ← updates your state
onFetch({
page: 1,
limit: size,
filters: filtersRef.current,
sort: sortRef.current,
});
}}
pageSizeOptions={pageSizeOptions}
recordLabel={recordLabel}
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
/**
* RowActions
*
* Renders a single kebab (⋯) dropdown per row containing all defined actions.
* Each action is evaluated against the row's data for conditional
* visibility and disabled state.
*
* ─── Action definition shape ─────────────────────────────────────────────────
*
* @field {string} key Unique identifier
* @field {string} label Menu item text
* @field {ReactNode} [icon] Optional leading icon
* @field {Function} onClick (rowData) => void
* @field {Function|boolean} [hidden] (rowData) => boolean — hide for specific rows
* @field {Function|boolean} [disabled](rowData) => boolean — disable for specific rows
* @field {boolean} [separator] Render a separator BEFORE this item
* @field {string} [className] Extra classes on the menu item
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Object} row TanStack row object (row.original = raw data)
* @param {Array} rowActions Array of action definitions
* @param {string} [dropdownLabel] Label shown at top of the menu (default: "Actions")
* @param {string} [align] Dropdown alignment ("end" | "start", default: "end")
*/
export function RowActions({
row,
rowActions = [],
dropdownLabel = "Actions",
align = "end",
}) {
const data = row.original;
const visibleActions = rowActions.filter((a) => !resolveFlag(a.hidden, data));
if (!visibleActions.length) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 data-[state=open]:bg-muted"
aria-label="Open actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-44">
{dropdownLabel && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{dropdownLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{visibleActions.map((action) => {
const isDisabled = resolveFlag(action.disabled, data);
return (
<span key={action.key}>
{action.separator && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => action.onClick(data)}
disabled={isDisabled}
className={action.className}
>
{action.icon && (
<span className="mr-2 flex items-center size-4">
{action.icon}
</span>
)}
{action.label}
</DropdownMenuItem>
</span>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
// ── Helper ────────────────────────────────────────────────────────────────────
/** Resolves a flag that is either a static boolean or a (rowData) => boolean fn */
function resolveFlag(flag, data) {
if (typeof flag === "function") return flag(data);
return flag ?? false;
}
@@ -0,0 +1,91 @@
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
/**
* SelectionToolbar
*
* Replaces the normal toolbar area when one or more rows are selected.
* Shows a selection count and flat inline action buttons — no dropdowns,
* since the actions are already visible.
*
* ─── Selection action definition shape ───────────────────────────────────────
*
* @field {string} key Unique identifier
* @field {string} label Button text
* @field {ReactNode} [icon] Optional leading icon
* @field {Function} onClick (selectedRows: rowData[], table: TableInstance) => void
* Receives selected row data AND the TanStack table instance
* so handlers like export can call table.getVisibleLeafColumns().
* @field {string} [variant] shadcn Button variant (default: "outline")
* @field {string} [className] Extra classes
* @field {boolean|Function} [disabled] Static boolean or (selectedRows) => boolean
* @field {boolean|Function} [hidden] Static boolean or (selectedRows) => boolean
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Array} selectedRows Array of raw row data objects
* @param {Function} onClearSelection () => void — clears all checkboxes
* @param {Array} selectionActions Action button definitions (see above)
* @param {string} [recordLabel] Singular label (default: "record")
* @param {TableInstance} table TanStack table instance forwarded from DataTable.
* Passed as the second argument to every onClick handler.
*/
export function SelectionToolbar({
selectedRows = [],
onClearSelection,
selectionActions = [],
recordLabel = "record",
table,
}) {
if (!selectedRows.length) return null;
const count = selectedRows.length;
const label = count === 1 ? recordLabel : `${recordLabel}s`;
return (
<div className="flex items-center justify-between gap-4 mb-5">
{/* Left: count + clear */}
<div className="flex items-center gap-2">
<button
onClick={onClearSelection}
className="flex items-center justify-center h-5 w-5 rounded-sm border border-primary bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
aria-label="Clear selection"
>
<X className="h-3 w-3" />
</button>
<span className="text-sm font-medium">
{count} {label} selected
</span>
</div>
{/* Right: flat action buttons */}
<div className="flex items-center gap-2 shrink-0">
{selectionActions
.filter((a) => !resolveFlag(a.hidden, selectedRows))
.map((action) => {
const isDisabled = resolveFlag(action.disabled, selectedRows);
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size="sm"
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
onClick={() => action.onClick(selectedRows, table)}
disabled={isDisabled}
>
{action.icon}
{action.label}
</Button>
);
})}
</div>
</div>
);
}
// ── Helper ────────────────────────────────────────────────────────────────────
function resolveFlag(flag, data) {
if (typeof flag === "function") return flag(data);
return flag ?? false;
}
@@ -0,0 +1,119 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ChevronsLeft, ChevronsRight } from "lucide-react";
import { pageSizes } from "@/utils/table.util";
/**
* TablePagination
*
* Footer bar with First / Previous / Next / Last page controls,
* a "Page X of Y" indicator, a row count summary, and a rows-per-page picker.
*
* Props:
* @param {Object} pagination - { page, limit, totalPages, totalRecords, hasPrevPage, hasNextPage }
* @param {Function} onPageChange - (updater | patch) => void
* @param {number} rowCount - Number of rows currently visible (e.g. users.length)
* @param {number} [totalRecords] - Grand total record count (falls back to pagination.totalRecords)
* @param {Function} [onPageSizeChange] - (size: number) => void — called when rows-per-page changes
* @param {number[]} [pageSizeOptions] - Options for rows-per-page picker (default: [10, 50, 100])
* @param {string} [recordLabel] - Singular label for records (default: "record")
* @param {Object} [table] - TanStack table instance (used to read current pageSize)
*/
export function TablePagination({
pagination,
onPageChange,
rowCount,
totalRecords,
onPageSizeChange,
pageSizeOptions = pageSizes,
recordLabel = "record",
table,
}) {
const total = totalRecords ?? pagination.totalRecords;
const currentPageSize = pagination.limit;
const pluralLabel = rowCount === 1 ? recordLabel : `${recordLabel}s`;
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-4 border-t border-border">
<div className="text-sm text-muted-foreground">
Showing {rowCount} of {total} {pluralLabel}
</div>
<div className="flex flex-wrap items-center gap-2 justify-end">
{/* First */}
<Button
variant="outline"
onClick={() => onPageChange(1)}
disabled={!pagination.hasPrevPage}
>
<ChevronsLeft className="size-4" /> First
</Button>
{/* Previous */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.page - 1)}
disabled={!pagination.hasPrevPage}
>
Previous
</Button>
<span className="text-sm font-medium px-2">
Page {pagination.page} of {pagination.totalPages}
</span>
{/* Next */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.page + 1)}
disabled={!pagination.hasNextPage}
>
Next
</Button>
{/* Last */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.totalPages)}
disabled={!pagination.hasNextPage}
>
Last <ChevronsRight className="size-4" />
</Button>
{/* Rows per page */}
{onPageSizeChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="font-medium text-sm">
Rows per page: {currentPageSize}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select rows</DropdownMenuLabel>
<DropdownMenuSeparator />
{pageSizeOptions.map((size) => (
<DropdownMenuItem
key={size}
onClick={() => onPageSizeChange(size)}
className={
currentPageSize === size ? "bg-muted font-medium" : ""
}
>
Show {size}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ChevronDown } from "lucide-react";
/**
* ToolbarActions
*
* Renders a list of configurable toolbar buttons beside the column visibility toggle.
* Each button can be one of three types: "icon", "button", or "dropdown".
*
* ─── Button definition shape ───────────────────────────────────────────────────
*
* Common fields (all types):
* @field {string} key Unique identifier
* @field {"icon"|"button"|"dropdown"} type Render mode
* @field {ReactNode} icon Lucide icon or any ReactNode
* @field {string} [label] Text label (required for "button"; used as tooltip for "icon")
* @field {string} [variant] shadcn Button variant (default: "outline")
* @field {string} [size] shadcn Button size (default: "sm")
* @field {string} [className] Extra classes on the button
* @field {boolean} [disabled] Disable the button
* @field {boolean} [hidden] Completely hide the button
*
* For type "icon" | "button":
* @field {Function} onClick (table: TableInstance) => void
* Receives the TanStack table instance so handlers
* like export can call table.getVisibleLeafColumns().
*
* For type "dropdown":
* @field {string} [dropdownLabel] Optional label shown at the top of the dropdown
* @field {Array} items Dropdown item definitions:
* @field {string} key Unique key
* @field {ReactNode} icon Item icon
* @field {string} label Item label
* @field {Function} onClick (table: TableInstance) => void
* @field {boolean} [disabled] Disable this item
* @field {boolean} [hidden] Hide this item
* @field {boolean} [separator] Render a separator BEFORE this item
* @field {string} [className] Extra classes on the item
*
* ───────────────────────────────────────────────────────────────────────────────
*
* @param {Array} actions Array of button definitions (see above)
* @param {string} [align] DropdownMenuContent align ("end" | "start", default: "end")
* @param {TableInstance} table TanStack table instance forwarded from DataTable.
* Passed as the first argument to every onClick handler.
*/
export function ToolbarActions({ actions = [], align = "end", table }) {
if (!actions.length) return null;
return (
<>
{actions
.filter((action) => !action.hidden)
.map((action) => {
// ── Icon-only button ─────────────────────────────────────────────
if (action.type === "icon") {
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 w-8 p-0 ${action.className ?? ""}`}
onClick={() => action.onClick(table)}
disabled={action.disabled}
title={action.label}
aria-label={action.label}
>
{action.icon}
</Button>
);
}
// ── Icon + label button ──────────────────────────────────────────
if (action.type === "button") {
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
onClick={() => action.onClick(table)}
disabled={action.disabled}
>
{action.icon}
{action.label}
</Button>
);
}
// ── Dropdown button ──────────────────────────────────────────────
if (action.type === "dropdown") {
const visibleItems = (action.items ?? []).filter((i) => !i.hidden);
return (
<DropdownMenu key={action.key}>
<DropdownMenuTrigger asChild>
<Button
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
disabled={action.disabled}
>
{action.icon}
{action.label}
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-48">
{action.dropdownLabel && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{action.dropdownLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{visibleItems.map((item) => (
<span key={item.key}>
{item.separator && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => item.onClick(table)}
disabled={item.disabled}
className={item.className}
>
{item.icon && (
<span className="mr-2 flex items-center">
{item.icon}
</span>
)}
{item.label}
</DropdownMenuItem>
</span>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</>
);
}
-1
View File
@@ -239,7 +239,6 @@ export default function UserMenu() {
const given = user?.personal_info?.name?.given_name ?? ''
const last = user?.personal_info?.name?.last_name ?? ''
console.log('given', user, given, last)
const initials = given && last
? (given[0] + last[0]).toUpperCase()
: (user?.email?.[0] ?? 'U').toUpperCase()