mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
integrate users
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user