From f165e8b3bbf0b28c76cf9cebd77ad4ea65192733 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Wed, 6 May 2026 14:14:54 +0800 Subject: [PATCH] integrate users --- src/App.jsx | 10 +- .../generic/Breadcrumb/AppBreadcrumb.jsx | 104 ++++++ src/components/generic/DashboardGrid.jsx | 65 ++++ src/components/generic/Sheet/FilterSheet.jsx | 201 +++++++++++ .../generic/Table/ActiveFilterPills.jsx | 65 ++++ .../generic/Table/BuildRowActionsColumn.jsx | 88 +++++ .../generic/Table/BuildSelectionColumn.jsx | 62 ++++ .../generic/Table/ColumnActionsDropdown.jsx | 92 +++++ .../generic/Table/ColumnVisibilityToggle.jsx | 58 ++++ src/components/generic/Table/DataTable.jsx | 317 ++++++++++++++++++ src/components/generic/Table/RowActions.jsx | 103 ++++++ .../generic/Table/SelectionToolbar.jsx | 91 +++++ .../generic/Table/TablePagination.jsx | 119 +++++++ .../generic/Table/ToolbarActions.jsx | 153 +++++++++ src/components/generic/UserMenu.jsx | 1 - src/contexts/AdminUserContext.jsx | 196 +++++++++++ src/contexts/provider/AdminProvider.jsx | 16 + src/data/adminTiles.data.js | 27 ++ src/modules/admin/components/UserTable.jsx | 76 +++++ src/modules/admin/config/columns.config.jsx | 28 ++ .../admin/config/rowActions.config.jsx | 39 +++ src/modules/admin/config/selection.config.jsx | 36 ++ src/modules/admin/config/toolbar.config.jsx | 38 +++ src/modules/admin/layouts/AdminLayout.jsx | 14 +- .../admin/layouts/UserManagementLayout.jsx | 12 + .../admin/pages/user_groups/GroupList.jsx | 11 + .../admin/pages/user_groups/ViewGroup.jsx | 11 + src/modules/admin/pages/users/AddUser.jsx | 11 + .../admin/pages/users/UserDashboard.jsx | 6 + src/modules/admin/pages/users/UserList.jsx | 25 ++ src/modules/admin/pages/users/ViewUser.jsx | 11 + src/modules/admin/routes/AdminRoutes.jsx | 57 +++- src/utils/excel.util.js | 134 ++++++++ src/utils/table.util.jsx | 250 ++++++++++++++ src/utils/timestamp.util.js | 13 + 35 files changed, 2516 insertions(+), 24 deletions(-) create mode 100644 src/components/generic/Breadcrumb/AppBreadcrumb.jsx create mode 100644 src/components/generic/DashboardGrid.jsx create mode 100644 src/components/generic/Sheet/FilterSheet.jsx create mode 100644 src/components/generic/Table/ActiveFilterPills.jsx create mode 100644 src/components/generic/Table/BuildRowActionsColumn.jsx create mode 100644 src/components/generic/Table/BuildSelectionColumn.jsx create mode 100644 src/components/generic/Table/ColumnActionsDropdown.jsx create mode 100644 src/components/generic/Table/ColumnVisibilityToggle.jsx create mode 100644 src/components/generic/Table/DataTable.jsx create mode 100644 src/components/generic/Table/RowActions.jsx create mode 100644 src/components/generic/Table/SelectionToolbar.jsx create mode 100644 src/components/generic/Table/TablePagination.jsx create mode 100644 src/components/generic/Table/ToolbarActions.jsx create mode 100644 src/contexts/AdminUserContext.jsx create mode 100644 src/contexts/provider/AdminProvider.jsx create mode 100644 src/data/adminTiles.data.js create mode 100644 src/modules/admin/components/UserTable.jsx create mode 100644 src/modules/admin/config/columns.config.jsx create mode 100644 src/modules/admin/config/rowActions.config.jsx create mode 100644 src/modules/admin/config/selection.config.jsx create mode 100644 src/modules/admin/config/toolbar.config.jsx create mode 100644 src/modules/admin/layouts/UserManagementLayout.jsx create mode 100644 src/modules/admin/pages/user_groups/GroupList.jsx create mode 100644 src/modules/admin/pages/user_groups/ViewGroup.jsx create mode 100644 src/modules/admin/pages/users/AddUser.jsx create mode 100644 src/modules/admin/pages/users/UserDashboard.jsx create mode 100644 src/modules/admin/pages/users/UserList.jsx create mode 100644 src/modules/admin/pages/users/ViewUser.jsx create mode 100644 src/utils/excel.util.js create mode 100644 src/utils/table.util.jsx create mode 100644 src/utils/timestamp.util.js 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 ( +
+ {/* MODE TOGGLE */} +
+ + + +
+ + {/* SINGLE DATE */} + {!isRange && ( + { + const val = e.target.value; + setInputValue(val); + column.setFilterValue(val); + }} + className="h-7 text-xs" + /> + )} + + {/* RANGE */} + {isRange && ( +
+ { + const next = { + from: e.target.value, + to: value.to || "", + }; + setInputValue(next); + column.setFilterValue(next); + }} + className="h-7 text-xs" + /> + + { + const next = { + from: value.from || "", + to: e.target.value, + }; + setInputValue(next); + column.setFilterValue(next); + }} + className="h-7 text-xs" + /> +
+ )} +
+ ); + } + + // ───────────────────────────────────────────── + // 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