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:
+1
-9
@@ -11,7 +11,7 @@ import AppRouter from './routes/AppRouter';
|
||||
import './index.css';
|
||||
|
||||
function AppWithAuth() {
|
||||
const { accessTokenRef, setAccessToken, setUser, loading, restoreSession, logout } = useAuth()
|
||||
const { accessTokenRef, setAccessToken, setUser, restoreSession, logout } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
attachCsrfInterceptor()
|
||||
@@ -32,14 +32,6 @@ function AppWithAuth() {
|
||||
restoreSession() // ← only here, never in route guards
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<span>STARR</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <AppRouter />
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// ─── UserContext.jsx ───────────────────────────────────────────────────────────
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const UserContext = createContext(null);
|
||||
|
||||
export const useUsers = () => {
|
||||
const ctx = useContext(UserContext);
|
||||
if (!ctx) throw new Error("useUsers must be used inside UserProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// ─── Initial States ────────────────────────────────────────────────────────────
|
||||
const PAGINATION_INIT = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalRecords: 0,
|
||||
totalPages: 0,
|
||||
hasPrevPage: false,
|
||||
hasNextPage: false,
|
||||
};
|
||||
|
||||
const BASE = "/admin";
|
||||
|
||||
export const UserProvider = ({ children }) => {
|
||||
// ─── State ─────────────────────────────────────────────────────────────────
|
||||
const [users, setUsers] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
const request = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── GET /api/admin/users ──────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(
|
||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const { data } = await api.get(`${BASE}/users`, {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const final_data = data?.data
|
||||
setUsers(final_data?.data ?? []);
|
||||
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||
setAttributes(final_data?.attributes ?? []);
|
||||
|
||||
return data?.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/users/:id ──────────────────────────────────────────────
|
||||
const fetchUser = useCallback(
|
||||
(userId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/users/${userId}`);
|
||||
setUser(res.data?.data ?? null);
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── PUT /api/admin/users/:id ──────────────────────────────────────────────
|
||||
const updateUser = useCallback(
|
||||
(userId, payload) =>
|
||||
request(async () => {
|
||||
const res = await api.put(`${BASE}/users/${userId}`, payload);
|
||||
|
||||
// Sync local list if user exists in it
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u))
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── DELETE /api/admin/users/:id (soft delete) ────────────────────────────
|
||||
const deactivateUser = useCallback(
|
||||
(userId) =>
|
||||
request(async () => {
|
||||
const res = await api.delete(`${BASE}/users/${userId}`);
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── POST /api/admin/users/:id/restore ────────────────────────────────────
|
||||
const restoreUser = useCallback(
|
||||
(userId) =>
|
||||
request(async () => {
|
||||
const res = await api.post(`${BASE}/users/${userId}/restore`);
|
||||
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u))
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/users/:id/sessions ────────────────────────────────────
|
||||
const fetchUserSessions = useCallback(
|
||||
(userId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/users/${userId}/sessions`);
|
||||
setSessions(res.data?.data ?? []);
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── DELETE /api/admin/users/:id/sessions/:sid ────────────────────────────
|
||||
const terminateSession = useCallback(
|
||||
(userId, sessionId) =>
|
||||
request(async () => {
|
||||
const res = await api.delete(`${BASE}/users/${userId}/sessions/${sessionId}`);
|
||||
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s))
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/users/field-values?field=acc_type ─────────────────────────
|
||||
const fetchUserFieldValues = useCallback(
|
||||
(field) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/users/field-values`, { params: { field } });
|
||||
return res.data?.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── Provider ──────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<UserContext.Provider
|
||||
value={{
|
||||
// state
|
||||
users,
|
||||
user,
|
||||
sessions,
|
||||
pagination,
|
||||
attributes,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// setters
|
||||
setPagination,
|
||||
|
||||
// actions
|
||||
fetchUserFieldValues,
|
||||
fetchUsers,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
deactivateUser,
|
||||
restoreUser,
|
||||
fetchUserSessions,
|
||||
terminateSession,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// ─── AdminProvider.jsx ─────────────────────────────────────────────────────────
|
||||
import { UserProvider } from "../AdminUserContext";
|
||||
// import { GroupProvider } from "@/modules/admin_side/user_management/group/context/GroupContext";
|
||||
// import { ContentProvider } from "@/modules/admin_side/content_management/context/ContentContext";
|
||||
|
||||
export const AdminProvider = ({ children }) => {
|
||||
return (
|
||||
<UserProvider>
|
||||
{/* <GroupProvider> */}
|
||||
{/* <ContentProvider> */}
|
||||
{children}
|
||||
{/* </ContentProvider> */}
|
||||
{/* </GroupProvider> */}
|
||||
</UserProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Users, GitFork } from "lucide-react";
|
||||
|
||||
export const USER_MANAGEMENT = [
|
||||
{
|
||||
title: "User Management",
|
||||
description: "Manage people across different systems",
|
||||
tiles: [
|
||||
{ key: "users", label: "Users", icon: Users, link: "all" },
|
||||
{ key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const CONTENT_MANAGEMENT = [
|
||||
// {
|
||||
// title: "User Management",
|
||||
// description: "Manage people with their account permissions here.",
|
||||
// tiles: [
|
||||
// { key: "users", label: "Users", icon: Users, link: "all" },
|
||||
// { key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" },
|
||||
// ],
|
||||
// },
|
||||
]
|
||||
|
||||
export const SITE_CONTENT = [
|
||||
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
|
||||
import { buildUserColumns, columnPinning } from "../config/columns.config";
|
||||
import { buildToolbarActions } from "../config/toolbar.config";
|
||||
import { buildSelectionActions } from "../config/selection.config";
|
||||
import { buildRowActions } from "../config/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function UsersTable() {
|
||||
const navigate = useNavigate();
|
||||
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] });
|
||||
|
||||
const {
|
||||
users,
|
||||
attributes,
|
||||
pagination,
|
||||
setPagination,
|
||||
loading,
|
||||
fetchUsers,
|
||||
fetchUserFieldValues,
|
||||
deactivateUser,
|
||||
} = useUsers();
|
||||
|
||||
// Shared export config — passed into toolbar + selection configs
|
||||
const exportConfig = {
|
||||
allData: users,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_Users`,
|
||||
sheetName: "Users",
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({ navigate, deactivateUser });
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchUsers, pagination, exportConfig, navigate,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
});
|
||||
const selectionActions = buildSelectionActions({ exportConfig, deactivateUser });
|
||||
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
title="Users"
|
||||
data={users}
|
||||
columns={columns}
|
||||
attributes={attributes}
|
||||
pagination={pagination}
|
||||
setPagination={setPagination}
|
||||
loading={loading}
|
||||
onFetch={fetchUsers}
|
||||
onFetchFilterData={fetchUserFieldValues}
|
||||
onRefsReady={(refs) => tableRefsRef.current = refs}
|
||||
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||
<FilterSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
column={column}
|
||||
attr={attr}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
columnPinning={columnPinning}
|
||||
toolbarActions={toolbarActions}
|
||||
selectionActions={selectionActions}
|
||||
recordLabel="user"
|
||||
emptyMessage="No users match the current filters."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// config/columns.config.jsx
|
||||
// Column definitions and pinning config for the Users table.
|
||||
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||
* @param {Array} rowActions Row-level kebab action definitions
|
||||
* @returns {Array} TanStack column definitions
|
||||
*/
|
||||
export function buildUserColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// config/rowActions.config.jsx
|
||||
// Per-row kebab menu action definitions for the Users table.
|
||||
//
|
||||
// Each onClick receives the row's data object from buildRowActionsColumn.
|
||||
|
||||
import { Eye, Pencil, Archive } from "lucide-react";
|
||||
|
||||
/**
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.navigate React Router navigate
|
||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||
* @returns {Array} rowActions
|
||||
*/
|
||||
export function buildRowActions({ navigate, archiveUser }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View details",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`view/${row.user_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",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => archiveUser(row.id),
|
||||
hidden: (row) => row.status === "archived",
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// config/selection.config.jsx
|
||||
import { Download, Archive, Trash2 } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
/**
|
||||
* @param {Object} deps
|
||||
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||
* @param {Function} deps.deleteUser Delete handler from useManagement
|
||||
*/
|
||||
export function buildSelectionActions({ exportConfig, archiveUser, deleteUser }) {
|
||||
return [
|
||||
{
|
||||
key: "export-selected",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
onClick: (rows, table) =>
|
||||
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
|
||||
},
|
||||
{
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
onClick: (rows) => archiveUser({ ids: rows.map((r) => r.id) }),
|
||||
hidden: (rows) => rows.every((r) => r.status === "archived"),
|
||||
},
|
||||
{
|
||||
key: "delete-selected",
|
||||
label: "Delete",
|
||||
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||
onClick: (rows) => deleteUser({ ids: rows.map((r) => r.id) }),
|
||||
disabled: (rows) => rows.some((r) => r.role === "admin" || r.role === "super_admin"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
|
||||
import { RefreshCw, Download, UserPlus } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
/**
|
||||
* @param {Object} deps
|
||||
* @param {Function} deps.fetchUsers Refetch handler from useUsers
|
||||
* @param {Object} deps.pagination Current pagination state
|
||||
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
||||
* @param {Function} deps.navigate React Router navigate
|
||||
*/
|
||||
export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, exportConfig, navigate, getFilters, getSort }) {
|
||||
return [
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
label: "Refresh",
|
||||
onClick: () => fetchUsers({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort()}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
label: "Export",
|
||||
onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }),
|
||||
},
|
||||
{
|
||||
key: "add-user",
|
||||
type: "button",
|
||||
icon: <UserPlus className="h-3.5 w-3.5" />,
|
||||
label: "Add User",
|
||||
variant: "default",
|
||||
className: "text-primary-foreground",
|
||||
onClick: () => navigate("add"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import AdminSideTabs from "../components/AdminSideTabs"
|
||||
import UserMenu from "@/components/generic/UserMenu"
|
||||
import { ROLE_CONFIG } from "@/data/profile.data"
|
||||
|
||||
import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add
|
||||
|
||||
const AdminLayout = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -81,10 +83,14 @@ const AdminLayout = () => {
|
||||
<AdminSideTabs />
|
||||
</div>
|
||||
</div>
|
||||
<div id="main-body">
|
||||
<Outlet />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</div>
|
||||
|
||||
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
|
||||
<AdminProvider>
|
||||
<div id="main-body">
|
||||
<Outlet />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</div>
|
||||
</AdminProvider>
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
|
||||
const UserManagementLayout = () => {
|
||||
return (
|
||||
<div>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserManagementLayout
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
const GroupList = () => {
|
||||
return (
|
||||
<div>
|
||||
GroupList
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupList
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
const ViewGroup = () => {
|
||||
return (
|
||||
<div>
|
||||
ViewGroup
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ViewGroup
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
const AddUser = () => {
|
||||
return (
|
||||
<div>
|
||||
AddUser
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddUser
|
||||
@@ -0,0 +1,6 @@
|
||||
import { USER_MANAGEMENT } from "@/data/adminTiles.data";
|
||||
import DashboardGrid from "@/components/generic/DashboardGrid";
|
||||
|
||||
export default function UserDashboard() {
|
||||
return <DashboardGrid sections={USER_MANAGEMENT} />
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { House } from "lucide-react";
|
||||
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import UsersTable from "../../components/UserTable";
|
||||
|
||||
export default function UserList() {
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
|
||||
{ label: "Users" },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||
<div className="flex flex-col gap-2 my-6 ">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<UsersTable />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
const ViewUser = () => {
|
||||
return (
|
||||
<div>
|
||||
ViewUser
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ViewUser
|
||||
@@ -1,8 +1,25 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import ProtectedRoute from '../../../routes/ProtectedRoute'
|
||||
|
||||
// Layouts
|
||||
import AdminLayout from '../layouts/AdminLayout'
|
||||
import UserManagementLayout from '../layouts/UserManagementLayout'
|
||||
|
||||
// Global Pages
|
||||
import Admin from '../pages/Admin'
|
||||
import ProfilePage from '@/components/generic/Profile'
|
||||
|
||||
// Specific Pages
|
||||
import UsersDashboard from '../pages/users/UserDashboard'
|
||||
|
||||
import UserList from '../pages/users/UserList'
|
||||
import AddUser from '../pages/users/AddUser'
|
||||
import ViewUser from '../pages/users/ViewUser'
|
||||
|
||||
import GroupList from '../pages/user_groups/GroupList'
|
||||
import ViewGroup from '../pages/user_groups/ViewGroup'
|
||||
|
||||
|
||||
export const AdminRoutes = {
|
||||
element: <ProtectedRoute allowedRoles={['admin']} />,
|
||||
children: [
|
||||
@@ -12,17 +29,37 @@ export const AdminRoutes = {
|
||||
children: [
|
||||
// Admin Management
|
||||
{ index: true, element: <Admin /> }, // /admin
|
||||
{ path: 'my-profile', element: <ProfilePage /> },
|
||||
{ path: 'my-profile', element: <ProfilePage /> },
|
||||
|
||||
// Users Management
|
||||
// {
|
||||
// path: 'users',
|
||||
// element: <UsersLayout />, // ← shared header/nav for user pages
|
||||
// children: [
|
||||
// { index: true, element: <Users /> }, // /admin/users
|
||||
// { path: 'add', element: <UsersAdd /> }, // /admin/users/add
|
||||
// { path: 'edit/:id', element: <UsersEdit /> }, // /admin/users/edit/123
|
||||
// ]
|
||||
// },
|
||||
{
|
||||
path: 'users',
|
||||
element: <UserManagementLayout />,
|
||||
children: [
|
||||
{ index: true, element: <UsersDashboard /> },
|
||||
|
||||
// User View
|
||||
{
|
||||
path: 'all',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <UserList /> },
|
||||
{ path: 'add', element: <AddUser /> },
|
||||
{ path: 'view/:userId', element: <ViewUser /> }
|
||||
]
|
||||
},
|
||||
|
||||
// User Group View
|
||||
{
|
||||
path: 'groups',
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <GroupList /> },
|
||||
{ path: 'view/:groupId', element: <ViewGroup /> }
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// Add here
|
||||
]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// ─── utils/exportToExcel.js ───────────────────────────────────────────────────
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
const SKIP_IDS = new Set(["select", "actions"]);
|
||||
|
||||
// ─── Resolve dot-notation or direct key from a row ────────────────────────────
|
||||
function resolveValue(row, key) {
|
||||
if (!key) return "";
|
||||
|
||||
// ─── Try direct key first e.g. "email", "acc_type" ────────────────────────
|
||||
if (key in row) return row[key] ?? "";
|
||||
|
||||
// ─── Dot-notation fallback e.g. "personal_info.name.full_name" ────────────
|
||||
return key.split(".").reduce((acc, part) => acc?.[part] ?? "", row) ?? "";
|
||||
}
|
||||
|
||||
// ─── Flatten a nested row based on attributes ─────────────────────────────────
|
||||
function flattenRow(row, attributes) {
|
||||
const result = {};
|
||||
|
||||
for (const attr of attributes) {
|
||||
const { field } = attr;
|
||||
result[field] = resolveValue(row, field);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which columns to export based on the TanStack table instance
|
||||
* (reads visible columns) or falls back to the raw attributes array.
|
||||
*
|
||||
* @param {TableInstance|null} tableInstance
|
||||
* @param {Array} attributes
|
||||
* @returns {Array} [{ key, label, width }]
|
||||
*/
|
||||
function resolveColumns(tableInstance, attributes) {
|
||||
if (tableInstance) {
|
||||
return tableInstance
|
||||
.getVisibleLeafColumns()
|
||||
.filter((col) => !SKIP_IDS.has(col.id))
|
||||
.map((col) => ({
|
||||
key: col.id,
|
||||
label:
|
||||
col.columnDef.meta?.label ??
|
||||
(typeof col.columnDef.header === "string" ? col.columnDef.header : col.id),
|
||||
width: col.columnDef.meta?.exportWidth ?? 20,
|
||||
}));
|
||||
}
|
||||
|
||||
return (attributes ?? [])
|
||||
.filter((attr) => attr.hidden !== true && !SKIP_IDS.has(attr.field))
|
||||
.map((attr) => ({
|
||||
key: attr.field,
|
||||
label: attr.name ?? attr.field,
|
||||
width: attr.exportWidth ?? 20,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic export handler — designed to be called directly from toolbar
|
||||
* and selection action configs across any table.
|
||||
*
|
||||
* Resolves columns from the live TanStack table instance (respects column
|
||||
* visibility toggles) and resolves rows from the selection or full dataset.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Array} options.allData Full dataset for this table
|
||||
* @param {Array} options.attributes Field definitions (fallback if no tableInstance)
|
||||
* @param {Array} [options.selectedRows] Checked rows — exports these if non-empty
|
||||
* @param {TableInstance|null}[options.tableInstance] TanStack table — reads visible columns
|
||||
* @param {string} [options.filename] Without extension. Default: "export"
|
||||
* @param {string} [options.sheetName] Sheet tab name. Default: "Sheet1"
|
||||
*
|
||||
* @example
|
||||
* // Toolbar export (all rows, visible columns)
|
||||
* onClick: (table) => exportTableToExcel({ allData: users, attributes, tableInstance: table })
|
||||
*
|
||||
* @example
|
||||
* // Selection export (checked rows only, visible columns)
|
||||
* onClick: (rows, table) => exportTableToExcel({ allData: users, attributes, selectedRows: rows, tableInstance: table })
|
||||
*/
|
||||
export function exportTableToExcel({
|
||||
allData = [],
|
||||
attributes = [],
|
||||
selectedRows = [],
|
||||
tableInstance = null,
|
||||
filename = "export",
|
||||
sheetName = "Sheet1",
|
||||
} = {}) {
|
||||
const columns = resolveColumns(tableInstance, attributes);
|
||||
const rawData = Array.isArray(selectedRows) && selectedRows.length > 0
|
||||
? selectedRows
|
||||
: allData;
|
||||
|
||||
// ─── Flatten rows so dot-notation keys resolve correctly ──────────────────
|
||||
const data = rawData.map((row) => flattenRow(row, attributes));
|
||||
|
||||
exportToExcel({ data, columns, filename, sheetName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Core export — writes a plain array of objects to an .xlsx file.
|
||||
* Use exportTableToExcel above for table-aware dynamic exports.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Array} options.data Rows to export
|
||||
* @param {Array} options.columns [{ key, label, width }]
|
||||
* @param {string} [options.filename] Without extension. Default: "export"
|
||||
* @param {string} [options.sheetName] Sheet tab name. Default: "Sheet1"
|
||||
*/
|
||||
export function exportToExcel({
|
||||
data = [],
|
||||
columns = [],
|
||||
filename = "export",
|
||||
sheetName = "Sheet1",
|
||||
} = {}) {
|
||||
if (!data.length || !columns.length) {
|
||||
console.warn("exportToExcel: no data or columns to export.");
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = columns.map((c) => c.label ?? c.key);
|
||||
const rows = data.map((row) =>
|
||||
columns.map((c) => resolveValue(row, c.key))
|
||||
);
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
ws["!cols"] = columns.map((c) => ({ wch: c.width ?? 20 }));
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
XLSX.writeFile(wb, `${filename}.xlsx`);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000]
|
||||
|
||||
// ── Badge style map for enum values ───────────────────────────────────────────
|
||||
const BADGE_STYLES = {
|
||||
"Active": "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
"Not Active": "bg-rose-100 text-rose-700 border-rose-200",
|
||||
"Verified": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"Not Verified": "bg-amber-100 text-amber-700 border-amber-200",
|
||||
"system": "bg-violet-50 text-violet-700 border-violet-200",
|
||||
"google": "bg-orange-50 text-orange-700 border-orange-200",
|
||||
"admin": "bg-red-100 text-red-700 border-red-200",
|
||||
"user": "bg-blue-100 text-blue-700 border-blue-200",
|
||||
"staff": "bg-green-100 text-green-700 border-green-200",
|
||||
};
|
||||
|
||||
function EnumBadge({ value }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[11px] font-medium px-2 py-0.5 ${BADGE_STYLES[value] ?? ""}`}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(value) {
|
||||
if (!value) return null;
|
||||
try { return format(new Date(value), "MMM d, yyyy"); }
|
||||
catch { return value; }
|
||||
}
|
||||
|
||||
function renderCell(attr, value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return <span className="text-muted-foreground/40">-</span>;
|
||||
}
|
||||
|
||||
|
||||
switch (attr.type) {
|
||||
case "enum":
|
||||
if (attr.field === "is_active")
|
||||
return <EnumBadge value={value ? "Active" : "Not Active"} />;
|
||||
else if (attr.field === "is_verified")
|
||||
return <EnumBadge value={value ? "Verified" : "Not Verified"} />;
|
||||
else
|
||||
return <EnumBadge value={value} />;
|
||||
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
|
||||
case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>;
|
||||
default: return <span className="text-sm">{value}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
export function ColumnFilter({ column, attr }) {
|
||||
const columnValue = column.getFilterValue() ?? "";
|
||||
const [inputValue, setInputValue] = useState(columnValue);
|
||||
|
||||
// keep in sync when external reset happens
|
||||
useEffect(() => {
|
||||
setInputValue(columnValue);
|
||||
}, [columnValue]);
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// ENUM (AUTO APPLY)
|
||||
// ─────────────────────────────────────────────
|
||||
if (attr.type === "enum") {
|
||||
return (
|
||||
<Select
|
||||
value={inputValue || "__all__"}
|
||||
onValueChange={(v) => {
|
||||
const value = v === "__all__" ? "" : v;
|
||||
setInputValue(value);
|
||||
column.setFilterValue(value); // ✅ auto apply
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-full">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All</SelectItem>
|
||||
{attr.options?.choices?.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// DATE (SINGLE)
|
||||
// ─────────────────────────────────────────────
|
||||
if (attr.type === "date") {
|
||||
const value =
|
||||
inputValue &&
|
||||
typeof inputValue === "object" &&
|
||||
("from" in inputValue || "to" in inputValue)
|
||||
? inputValue
|
||||
: null;
|
||||
|
||||
const isRange = !!value;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* MODE TOGGLE */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!isRange}
|
||||
onChange={() => {
|
||||
setInputValue("");
|
||||
column.setFilterValue("");
|
||||
}}
|
||||
/>
|
||||
Single
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={isRange}
|
||||
onChange={() => {
|
||||
setInputValue({ from: "", to: "" });
|
||||
column.setFilterValue({ from: "", to: "" });
|
||||
}}
|
||||
/>
|
||||
Range
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* SINGLE DATE */}
|
||||
{!isRange && (
|
||||
<Input
|
||||
type="date"
|
||||
value={inputValue || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setInputValue(val);
|
||||
column.setFilterValue(val);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* RANGE */}
|
||||
{isRange && (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="date"
|
||||
value={value.from}
|
||||
onChange={(e) => {
|
||||
const next = {
|
||||
from: e.target.value,
|
||||
to: value.to || "",
|
||||
};
|
||||
setInputValue(next);
|
||||
column.setFilterValue(next);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
value={value.to}
|
||||
onChange={(e) => {
|
||||
const next = {
|
||||
from: value.from || "",
|
||||
to: e.target.value,
|
||||
};
|
||||
setInputValue(next);
|
||||
column.setFilterValue(next);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// TEXT FILTER (manual apply)
|
||||
// ─────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
column.setFilterValue(inputValue);
|
||||
}
|
||||
}}
|
||||
placeholder="Filter..."
|
||||
className="h-7 text-xs w-full"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
onClick={() => column.setFilterValue(inputValue)}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sort icon ──────────────────────────────────────────────────────────────────
|
||||
export function SortIcon({ column }) {
|
||||
const s = column.getIsSorted();
|
||||
if (s === "asc") return <ArrowUp className="ml-1 h-3 w-3 text-primary shrink-0" />;
|
||||
if (s === "desc") return <ArrowDown className="ml-1 h-3 w-3 text-primary shrink-0" />;
|
||||
return <ArrowUpDown className="ml-1 h-3 w-3 text-muted-foreground/40 shrink-0" />;
|
||||
}
|
||||
|
||||
// ── Build columns dynamically from attributes ──────────────────────────────────
|
||||
const columnHelper = createColumnHelper();
|
||||
|
||||
export function buildColumns(attrs) {
|
||||
return attrs.map((attr) =>
|
||||
columnHelper.accessor(attr.field, {
|
||||
id: attr.field,
|
||||
header: attr.name,
|
||||
enableSorting: true,
|
||||
enableColumnFilter: true,
|
||||
filterFn: (row, colId, filterValue) => {
|
||||
if (!filterValue) return true;
|
||||
const val = String(row.getValue(colId) ?? "").toLowerCase();
|
||||
return val.includes(String(filterValue).toLowerCase());
|
||||
},
|
||||
cell: (info) => renderCell(attr, info.getValue()),
|
||||
meta: { attr },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function getTimestamp() {
|
||||
const now = new Date();
|
||||
|
||||
const YYYY = now.getFullYear();
|
||||
const MM = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const DD = String(now.getDate()).padStart(2, "0");
|
||||
|
||||
const HH = String(now.getHours()).padStart(2, "0");
|
||||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||||
const SS = String(now.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${YYYY}${MM}${DD}_${HH}${mm}${SS}`;
|
||||
}
|
||||
Reference in New Issue
Block a user