diff --git a/src/App.jsx b/src/App.jsx
index b556422..19b0f2b 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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 (
-
- STARR
-
- )
- }
-
return
}
diff --git a/src/components/generic/Breadcrumb/AppBreadcrumb.jsx b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx
new file mode 100644
index 0000000..eeb8507
--- /dev/null
+++ b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx
@@ -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
+ * , to: `/admin/${adminId}/users` },
+ * { label: "Users" },
+ * ]}
+ * />
+ *
+ * // With custom click handler
+ * , 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 (
+
+
+ {items.map((item, index) => {
+ const isLast = index === items.length - 1;
+
+ return (
+
+
+ {isLast ? (
+ // Current page — no interaction
+
+ {item.icon}
+ {item.label}
+
+ ) : (
+ // Clickable link
+
+ {
+ if (item.onClick) {
+ item.onClick(e, navigate);
+ } else if (item.to) {
+ e.preventDefault();
+ navigate(item.to);
+ }
+ }}
+ >
+ {item.icon}
+ {item.label}
+
+
+ )}
+
+
+ {!isLast && }
+
+ );
+ })}
+
+
+ );
+};
+
+export default AppBreadcrumb;
\ No newline at end of file
diff --git a/src/components/generic/DashboardGrid.jsx b/src/components/generic/DashboardGrid.jsx
new file mode 100644
index 0000000..620f36a
--- /dev/null
+++ b/src/components/generic/DashboardGrid.jsx
@@ -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 (
+
+
+ {sections.map(({ title, description, tiles }) => (
+
+
+ {/* Header */}
+
+
+ {title}
+
+
{description}
+
+
+ {/* Tiles */}
+
+ {tiles.map(({ key, label, icon: Icon, link }) => (
+ 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" },
+ }}
+ >
+
+
+
+
+ {label}
+
+
+ ))}
+
+
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/generic/Sheet/FilterSheet.jsx b/src/components/generic/Sheet/FilterSheet.jsx
new file mode 100644
index 0000000..71da76e
--- /dev/null
+++ b/src/components/generic/Sheet/FilterSheet.jsx
@@ -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 }) => (
+
+ {search ? `No results for "${search}".` : "No data found."}
+
+);
+
+// ─── Reusable item list renderer ──────────────────────────────────────────────
+const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => {
+ if (items.length === 0) return ;
+
+ return items.map((item) => (
+
+ ));
+};
+
+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 (
+
+
+
+ Filter by {column?.columnDef?.header}
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {/* ================= EMPTY STATE ================= */}
+ {isEmpty &&
}
+
+ {/* ================= BOOLEAN ================= */}
+ {!isEmpty && isBoolean && (
+
+
+
+ )}
+
+ {/* ================= ENUM ================= */}
+ {!isEmpty && isEnum && (
+
+
+
+ )}
+
+ {/* ========== TEXT / NUMBER / DATE ========== */}
+ {!isEmpty && isList && (
+ <>
+
+ setSearch(e.target.value)}
+ />
+
+
+
+ {filteredData.length === 0
+ ?
+ :
+ }
+
+
+
+ >
+ )}
+
+
+ {/* ─── Footer — hidden when empty ──────────────────────────────────── */}
+ {!isEmpty && (
+
+
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/ActiveFilterPills.jsx b/src/components/generic/Table/ActiveFilterPills.jsx
new file mode 100644
index 0000000..1aca886
--- /dev/null
+++ b/src/components/generic/Table/ActiveFilterPills.jsx
@@ -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 (
+
+ );
+ }
+
+ // ── Pill row variant ───────────────────────────────────────────────────────
+ return (
+
+
+ {filters.map((f) => {
+ const attr = attributes.find((a) => a.field === f.id);
+ return (
+
+ {attr?.name ?? f.id}:
+ {String(f.value)}
+
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/BuildRowActionsColumn.jsx b/src/components/generic/Table/BuildRowActionsColumn.jsx
new file mode 100644
index 0000000..34b0f7e
--- /dev/null
+++ b/src/components/generic/Table/BuildRowActionsColumn.jsx
@@ -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: ,
+ * onClick: (row) => navigate(`/users/${row.id}`),
+ * },
+ * {
+ * key: "edit",
+ * label: "Edit",
+ * icon: ,
+ * onClick: (row) => navigate(`/users/${row.id}/edit`),
+ * disabled: (row) => row.role === "super_admin",
+ * },
+ * {
+ * key: "archive",
+ * label: "Archive",
+ * icon: ,
+ * onClick: (row) => archiveUser(row.id),
+ * hidden: (row) => row.status === "archived",
+ * separator: true,
+ * },
+ * {
+ * key: "delete",
+ * label: "Delete",
+ * icon: ,
+ * 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 }) => (
+
+ ),
+ };
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/BuildSelectionColumn.jsx b/src/components/generic/Table/BuildSelectionColumn.jsx
new file mode 100644
index 0000000..bdf375c
--- /dev/null
+++ b/src/components/generic/Table/BuildSelectionColumn.jsx
@@ -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 }) => (
+
+
+ table.toggleAllPageRowsSelected(!!value)
+ }
+ aria-label="Select all rows on this page"
+ />
+
+ ),
+
+ cell: ({ row }) => (
+
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+
+ ),
+ };
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/ColumnActionsDropdown.jsx b/src/components/generic/Table/ColumnActionsDropdown.jsx
new file mode 100644
index 0000000..25512fd
--- /dev/null
+++ b/src/components/generic/Table/ColumnActionsDropdown.jsx
@@ -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 (
+
+
+
+
+
+
+ Column Actions
+
+
+ column.toggleSorting(false)}>
+
+ Sort Asc
+
+
+ column.toggleSorting(true)}>
+
+ Sort Desc
+
+
+ column.clearSorting()}>
+
+ Clear Sort
+
+
+ {canFilter && (
+ <>
+
+
+ Filter By
+
+ >
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/ColumnVisibilityToggle.jsx b/src/components/generic/Table/ColumnVisibilityToggle.jsx
new file mode 100644
index 0000000..ca6c211
--- /dev/null
+++ b/src/components/generic/Table/ColumnVisibilityToggle.jsx
@@ -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 (
+
+
+
+
+
+
+
+ Toggle visible columns
+
+
+
+ {/* ─── Show All ──────────────────────────────────────────────────── */}
+ table.toggleAllColumnsVisible(v)}
+ className="text-xs font-medium"
+ >
+ Show All
+
+
+
+
+ {/* ─── Individual columns ────────────────────────────────────────── */}
+ {columns.map((col) => {
+ const headerLabel = typeof col.columnDef.header === "string"
+ ? col.columnDef.header
+ : col.id;
+
+ return (
+ col.toggleVisibility(v)}
+ className="text-xs"
+ >
+ {headerLabel}
+
+ );
+ })}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/DataTable.jsx b/src/components/generic/Table/DataTable.jsx
new file mode 100644
index 0000000..6a7b290
--- /dev/null
+++ b/src/components/generic/Table/DataTable.jsx
@@ -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 (
+
+
+ {/* ── Toolbar ── */}
+ {hasSelection ? (
+
table.resetRowSelection()}
+ selectionActions={selectionActions}
+ recordLabel={recordLabel}
+ />
+ ) : (
+
+
{title}
+
+
{
+ 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
+ />
+
+ {showColumnToggle && }
+
+
+ )}
+
+ {
+ 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 ── */}
+
+
+
+
+ {table.getHeaderGroups().map((hg) => (
+
+ {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 (
+
+ {isPlain ? (
+ flexRender(header.column.columnDef.header, header.getContext())
+ ) : (
+ setActiveColumn(isOpen ? header.column.id : null)}
+ onFilterClick={(e) => handleOpenFilterSheet(e, header.column, attr)}
+ />
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {renderFilterSheet?.({
+ open: filterState.open,
+ onOpenChange: (v) => setFilterState((p) => ({ ...p, open: v })),
+ column: filterState.column,
+ attr: filterState.attr,
+ data: filterState.data,
+ loading,
+ })}
+
+
+ {table.getRowModel().rows.length === 0 ? (
+
+
+ {emptyMessage}
+
+
+ ) : (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => {
+ const isPinned = cell.column.getIsPinned();
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ );
+ })}
+
+ ))
+ )}
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+
+ {/* ── Footer ── */}
+ {
+ 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}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/RowActions.jsx b/src/components/generic/Table/RowActions.jsx
new file mode 100644
index 0000000..b8ef648
--- /dev/null
+++ b/src/components/generic/Table/RowActions.jsx
@@ -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 (
+
+
+
+
+
+
+ {dropdownLabel && (
+ <>
+
+ {dropdownLabel}
+
+
+ >
+ )}
+
+ {visibleActions.map((action) => {
+ const isDisabled = resolveFlag(action.disabled, data);
+
+ return (
+
+ {action.separator && }
+ action.onClick(data)}
+ disabled={isDisabled}
+ className={action.className}
+ >
+ {action.icon && (
+
+ {action.icon}
+
+ )}
+ {action.label}
+
+
+ );
+ })}
+
+
+ );
+}
+
+// ── 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;
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/SelectionToolbar.jsx b/src/components/generic/Table/SelectionToolbar.jsx
new file mode 100644
index 0000000..8441602
--- /dev/null
+++ b/src/components/generic/Table/SelectionToolbar.jsx
@@ -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 (
+
+ {/* Left: count + clear */}
+
+
+
+ {count} {label} selected
+
+
+
+ {/* Right: flat action buttons */}
+
+ {selectionActions
+ .filter((a) => !resolveFlag(a.hidden, selectedRows))
+ .map((action) => {
+ const isDisabled = resolveFlag(action.disabled, selectedRows);
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+// ── Helper ────────────────────────────────────────────────────────────────────
+
+function resolveFlag(flag, data) {
+ if (typeof flag === "function") return flag(data);
+ return flag ?? false;
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/TablePagination.jsx b/src/components/generic/Table/TablePagination.jsx
new file mode 100644
index 0000000..47a7b16
--- /dev/null
+++ b/src/components/generic/Table/TablePagination.jsx
@@ -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 (
+
+
+ Showing {rowCount} of {total} {pluralLabel}
+
+
+
+ {/* First */}
+
+
+ {/* Previous */}
+
+
+
+ Page {pagination.page} of {pagination.totalPages}
+
+
+ {/* Next */}
+
+
+ {/* Last */}
+
+
+ {/* Rows per page */}
+ {onPageSizeChange && (
+
+
+
+
+
+
+ Select rows
+
+ {pageSizeOptions.map((size) => (
+ onPageSizeChange(size)}
+ className={
+ currentPageSize === size ? "bg-muted font-medium" : ""
+ }
+ >
+ Show {size}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Table/ToolbarActions.jsx b/src/components/generic/Table/ToolbarActions.jsx
new file mode 100644
index 0000000..6dd9d24
--- /dev/null
+++ b/src/components/generic/Table/ToolbarActions.jsx
@@ -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 (
+
+ );
+ }
+
+ // ── Icon + label button ──────────────────────────────────────────
+ if (action.type === "button") {
+ return (
+
+ );
+ }
+
+ // ── Dropdown button ──────────────────────────────────────────────
+ if (action.type === "dropdown") {
+ const visibleItems = (action.items ?? []).filter((i) => !i.hidden);
+
+ return (
+
+
+
+
+
+
+ {action.dropdownLabel && (
+ <>
+
+ {action.dropdownLabel}
+
+
+ >
+ )}
+
+ {visibleItems.map((item) => (
+
+ {item.separator && }
+ item.onClick(table)}
+ disabled={item.disabled}
+ className={item.className}
+ >
+ {item.icon && (
+
+ {item.icon}
+
+ )}
+ {item.label}
+
+
+ ))}
+
+
+ );
+ }
+
+ return null;
+ })}
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/UserMenu.jsx b/src/components/generic/UserMenu.jsx
index 1d48873..45b9613 100644
--- a/src/components/generic/UserMenu.jsx
+++ b/src/components/generic/UserMenu.jsx
@@ -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()
diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx
new file mode 100644
index 0000000..2fe5ec0
--- /dev/null
+++ b/src/contexts/AdminUserContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/src/contexts/provider/AdminProvider.jsx b/src/contexts/provider/AdminProvider.jsx
new file mode 100644
index 0000000..4df721c
--- /dev/null
+++ b/src/contexts/provider/AdminProvider.jsx
@@ -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 (
+
+ {/* */}
+ {/* */}
+ {children}
+ {/* */}
+ {/* */}
+
+ );
+};
\ No newline at end of file
diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js
new file mode 100644
index 0000000..36a7f58
--- /dev/null
+++ b/src/data/adminTiles.data.js
@@ -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 = [
+
+]
\ No newline at end of file
diff --git a/src/modules/admin/components/UserTable.jsx b/src/modules/admin/components/UserTable.jsx
new file mode 100644
index 0000000..4ff2fc8
--- /dev/null
+++ b/src/modules/admin/components/UserTable.jsx
@@ -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 (
+ tableRefsRef.current = refs}
+ renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
+
+ )}
+ columnPinning={columnPinning}
+ toolbarActions={toolbarActions}
+ selectionActions={selectionActions}
+ recordLabel="user"
+ emptyMessage="No users match the current filters."
+ />
+ );
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/columns.config.jsx b/src/modules/admin/config/columns.config.jsx
new file mode 100644
index 0000000..8ac8c6b
--- /dev/null
+++ b/src/modules/admin/config/columns.config.jsx
@@ -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" }),
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/rowActions.config.jsx b/src/modules/admin/config/rowActions.config.jsx
new file mode 100644
index 0000000..077f775
--- /dev/null
+++ b/src/modules/admin/config/rowActions.config.jsx
@@ -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: ,
+ onClick: (row) => navigate(`view/${row.user_id}`),
+ },
+ {
+ key: "edit",
+ label: "Edit",
+ icon: ,
+ onClick: (row) => navigate(`/users/${row.id}/edit`),
+ disabled: (row) => row.role === "super_admin",
+ },
+ {
+ key: "archive",
+ label: "Archive",
+ className: "text-destructive focus:text-destructive",
+ icon: ,
+ onClick: (row) => archiveUser(row.id),
+ hidden: (row) => row.status === "archived",
+ separator: true,
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/selection.config.jsx b/src/modules/admin/config/selection.config.jsx
new file mode 100644
index 0000000..5097ed4
--- /dev/null
+++ b/src/modules/admin/config/selection.config.jsx
@@ -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: ,
+ onClick: (rows, table) =>
+ exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
+ },
+ {
+ key: "archive-selected",
+ label: "Archive",
+ icon: ,
+ onClick: (rows) => archiveUser({ ids: rows.map((r) => r.id) }),
+ hidden: (rows) => rows.every((r) => r.status === "archived"),
+ },
+ {
+ key: "delete-selected",
+ label: "Delete",
+ icon: ,
+ 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"),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/config/toolbar.config.jsx b/src/modules/admin/config/toolbar.config.jsx
new file mode 100644
index 0000000..7cc0cb3
--- /dev/null
+++ b/src/modules/admin/config/toolbar.config.jsx
@@ -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: ,
+ label: "Refresh",
+ onClick: () => fetchUsers({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort()}),
+ },
+ {
+ key: "export",
+ type: "button",
+ icon: ,
+ label: "Export",
+ onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }),
+ },
+ {
+ key: "add-user",
+ type: "button",
+ icon: ,
+ label: "Add User",
+ variant: "default",
+ className: "text-primary-foreground",
+ onClick: () => navigate("add"),
+ },
+ ];
+}
\ No newline at end of file
diff --git a/src/modules/admin/layouts/AdminLayout.jsx b/src/modules/admin/layouts/AdminLayout.jsx
index 01d701e..c721dbb 100644
--- a/src/modules/admin/layouts/AdminLayout.jsx
+++ b/src/modules/admin/layouts/AdminLayout.jsx
@@ -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 = () => {
-
-
-
-
+
+ {/* ─── All admin contexts live here, scoped to admin routes only ── */}
+
+
+
+
+
+
)
diff --git a/src/modules/admin/layouts/UserManagementLayout.jsx b/src/modules/admin/layouts/UserManagementLayout.jsx
new file mode 100644
index 0000000..2c7e172
--- /dev/null
+++ b/src/modules/admin/layouts/UserManagementLayout.jsx
@@ -0,0 +1,12 @@
+import React from 'react'
+import { Outlet } from 'react-router-dom'
+
+const UserManagementLayout = () => {
+ return (
+
+
+
+ )
+}
+
+export default UserManagementLayout
\ No newline at end of file
diff --git a/src/modules/admin/pages/user_groups/GroupList.jsx b/src/modules/admin/pages/user_groups/GroupList.jsx
new file mode 100644
index 0000000..47c7596
--- /dev/null
+++ b/src/modules/admin/pages/user_groups/GroupList.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+const GroupList = () => {
+ return (
+
+ GroupList
+
+ )
+}
+
+export default GroupList
diff --git a/src/modules/admin/pages/user_groups/ViewGroup.jsx b/src/modules/admin/pages/user_groups/ViewGroup.jsx
new file mode 100644
index 0000000..2e0a90e
--- /dev/null
+++ b/src/modules/admin/pages/user_groups/ViewGroup.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+const ViewGroup = () => {
+ return (
+
+ ViewGroup
+
+ )
+}
+
+export default ViewGroup
diff --git a/src/modules/admin/pages/users/AddUser.jsx b/src/modules/admin/pages/users/AddUser.jsx
new file mode 100644
index 0000000..fa33a73
--- /dev/null
+++ b/src/modules/admin/pages/users/AddUser.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+const AddUser = () => {
+ return (
+
+ AddUser
+
+ )
+}
+
+export default AddUser
diff --git a/src/modules/admin/pages/users/UserDashboard.jsx b/src/modules/admin/pages/users/UserDashboard.jsx
new file mode 100644
index 0000000..9e57b0f
--- /dev/null
+++ b/src/modules/admin/pages/users/UserDashboard.jsx
@@ -0,0 +1,6 @@
+import { USER_MANAGEMENT } from "@/data/adminTiles.data";
+import DashboardGrid from "@/components/generic/DashboardGrid";
+
+export default function UserDashboard() {
+ return
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/users/UserList.jsx b/src/modules/admin/pages/users/UserList.jsx
new file mode 100644
index 0000000..b2bcffe
--- /dev/null
+++ b/src/modules/admin/pages/users/UserList.jsx
@@ -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: , to: `/admin/users` },
+ { label: "Users" },
+ ]
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/src/modules/admin/pages/users/ViewUser.jsx b/src/modules/admin/pages/users/ViewUser.jsx
new file mode 100644
index 0000000..e16606a
--- /dev/null
+++ b/src/modules/admin/pages/users/ViewUser.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+const ViewUser = () => {
+ return (
+
+ ViewUser
+
+ )
+}
+
+export default ViewUser
diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx
index 5959646..706a914 100644
--- a/src/modules/admin/routes/AdminRoutes.jsx
+++ b/src/modules/admin/routes/AdminRoutes.jsx
@@ -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: ,
children: [
@@ -12,17 +29,37 @@ export const AdminRoutes = {
children: [
// Admin Management
{ index: true, element: }, // /admin
- { path: 'my-profile', element: },
+ { path: 'my-profile', element: },
+
// Users Management
- // {
- // path: 'users',
- // element: , // ← shared header/nav for user pages
- // children: [
- // { index: true, element: }, // /admin/users
- // { path: 'add', element: }, // /admin/users/add
- // { path: 'edit/:id', element: }, // /admin/users/edit/123
- // ]
- // },
+ {
+ path: 'users',
+ element: ,
+ children: [
+ { index: true, element: },
+
+ // User View
+ {
+ path: 'all',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'add', element: },
+ { path: 'view/:userId', element: }
+ ]
+ },
+
+ // User Group View
+ {
+ path: 'groups',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'view/:groupId', element: }
+ ]
+ },
+ ]
+ },
// Add here
]
diff --git a/src/utils/excel.util.js b/src/utils/excel.util.js
new file mode 100644
index 0000000..2ee9f6c
--- /dev/null
+++ b/src/utils/excel.util.js
@@ -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`);
+}
\ No newline at end of file
diff --git a/src/utils/table.util.jsx b/src/utils/table.util.jsx
new file mode 100644
index 0000000..b8df918
--- /dev/null
+++ b/src/utils/table.util.jsx
@@ -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 (
+
+ {value}
+
+ );
+}
+
+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 -;
+ }
+
+
+ switch (attr.type) {
+ case "enum":
+ if (attr.field === "is_active")
+ return ;
+ else if (attr.field === "is_verified")
+ return ;
+ else
+ return ;
+ case "date": return {formatDate(value)};
+ case "number": return {value};
+ default: return {value};
+ }
+}
+
+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 (
+
+ );
+ }
+
+ // ─────────────────────────────────────────────
+ // DATE (SINGLE)
+ // ─────────────────────────────────────────────
+ if (attr.type === "date") {
+ const value =
+ inputValue &&
+ typeof inputValue === "object" &&
+ ("from" in inputValue || "to" in inputValue)
+ ? inputValue
+ : null;
+
+ const isRange = !!value;
+
+ return (
+
+ );
+ }
+
+ // ─────────────────────────────────────────────
+ // TEXT FILTER (manual apply)
+ // ─────────────────────────────────────────────
+ return (
+
+ setInputValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ column.setFilterValue(inputValue);
+ }
+ }}
+ placeholder="Filter..."
+ className="h-7 text-xs w-full"
+ />
+
+
+
+ );
+}
+
+// ── Sort icon ──────────────────────────────────────────────────────────────────
+export function SortIcon({ column }) {
+ const s = column.getIsSorted();
+ if (s === "asc") return ;
+ if (s === "desc") return ;
+ return ;
+}
+
+// ── 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 },
+ })
+ );
+}
+
diff --git a/src/utils/timestamp.util.js b/src/utils/timestamp.util.js
new file mode 100644
index 0000000..bf925c1
--- /dev/null
+++ b/src/utils/timestamp.util.js
@@ -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}`;
+}
\ No newline at end of file