integrate users

This commit is contained in:
rgrgogu
2026-05-06 14:14:54 +08:00
parent 569442bf33
commit f165e8b3bb
35 changed files with 2516 additions and 24 deletions
+1 -9
View File
@@ -11,7 +11,7 @@ import AppRouter from './routes/AppRouter';
import './index.css'; import './index.css';
function AppWithAuth() { function AppWithAuth() {
const { accessTokenRef, setAccessToken, setUser, loading, restoreSession, logout } = useAuth() const { accessTokenRef, setAccessToken, setUser, restoreSession, logout } = useAuth()
useEffect(() => { useEffect(() => {
attachCsrfInterceptor() attachCsrfInterceptor()
@@ -32,14 +32,6 @@ function AppWithAuth() {
restoreSession() // ← only here, never in route guards restoreSession() // ← only here, never in route guards
}, []) }, [])
if (loading) {
return (
<div className="app-loading">
<span>STARR</span>
</div>
)
}
return <AppRouter /> return <AppRouter />
} }
@@ -0,0 +1,104 @@
import { useNavigate } from "react-router-dom";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
/**
* AppBreadcrumb
*
* Generic breadcrumb driven entirely by the `items` prop.
* The last item is always rendered as the current page (non-clickable).
* All preceding items are rendered as clickable links.
*
* ─── Item definition shape ────────────────────────────────────────────────────
*
* @field {string} label Display text
* @field {ReactNode} [icon] Optional icon rendered before the label
* @field {string} [to] navigate() path — if omitted, item is non-navigable
* @field {Function} [onClick] Custom click handler (e, navigate) => void
* Overrides `to` when provided.
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Array} items Ordered list of breadcrumb item definitions
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { House, Users } from "lucide-react";
* import AppBreadcrumb from "@/components/generic/AppBreadcrumb";
*
* // Basic — just paths
* <AppBreadcrumb
* items={[
* { label: "Home", icon: <House className="size-4" />, to: `/admin/${adminId}/users` },
* { label: "Users" },
* ]}
* />
*
* // With custom click handler
* <AppBreadcrumb
* items={[
* { label: "Home", icon: <House className="size-4" />, onClick: (e, navigate) => navigate(-1) },
* { label: "Settings", to: `/admin/${adminId}/settings` },
* { label: "Profile" },
* ]}
* />
*
* ─────────────────────────────────────────────────────────────────────────────
*/
const AppBreadcrumb = ({ items = [] }) => {
const navigate = useNavigate();
if (!items.length) return null;
return (
<Breadcrumb>
<BreadcrumbList>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<span key={index} className="flex items-center gap-1.5">
<BreadcrumbItem>
{isLast ? (
// Current page — no interaction
<BreadcrumbPage className="flex items-center gap-2">
{item.icon}
{item.label}
</BreadcrumbPage>
) : (
// Clickable link
<BreadcrumbLink asChild>
<div
className="flex items-center gap-2 select-none cursor-pointer"
onClick={(e) => {
if (item.onClick) {
item.onClick(e, navigate);
} else if (item.to) {
e.preventDefault();
navigate(item.to);
}
}}
>
{item.icon}
<span>{item.label}</span>
</div>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</span>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
};
export default AppBreadcrumb;
+65
View File
@@ -0,0 +1,65 @@
// ─── DashboardGrid.jsx ─────────────────────────────────────────────────────────
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
export default function DashboardGrid({ sections = [] }) {
const navigate = useNavigate();
const handleNavigate = (e, link) => {
e.preventDefault()
navigate(link)
}
return (
<section className="bg-muted/60 xs:h-full lg:h-screen">
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 px-4">
{sections.map(({ title, description, tiles }) => (
<div key={title} className="flex flex-col items-start gap-4 w-full">
{/* Header */}
<div className="flex flex-col gap-2 mt-6">
<h1 className="text-2xl leading-tighter font-medium tracking-tighter">
{title}
</h1>
<p className="text-muted-foreground">{description}</p>
</div>
{/* Tiles */}
<div className="grid xs:grid-cols-2 lg:grid-cols-5 h-fit w-full gap-4">
{tiles.map(({ key, label, icon: Icon, link }) => (
<motion.div
key={key}
onClick={(e) => handleNavigate(e, link)}
className="group bg-card border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer"
whileHover={{
y: -6,
scale: 1.02,
backgroundColor: "var(--primary)",
color: "var(--motion-card-hover)"
}}
transition={{
y: { type: "spring", stiffness: 300, damping: 20 },
scale: { type: "spring", stiffness: 300, damping: 20 },
backgroundColor: { duration: 0.2, ease: "easeOut" },
}}
>
<motion.div
className="w-full flex justify-end"
whileHover={{ rotate: -18, scale: 1.05 }}
transition={{ type: "spring", stiffness: 200 }}
>
<Icon className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
</motion.div>
<motion.div className="p-4 font-medium mt-auto" whileHover={{ y: -2 }}>
{label}
</motion.div>
</motion.div>
))}
</div>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,201 @@
import { useState, useEffect, useMemo } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
// ─── Field-specific display maps ──────────────────────────────────────────────
const FIELD_DISPLAY_MAP = {
is_active: { true: "Active", false: "Inactive" },
is_verified: { true: "Verified", false: "Not Verified" },
};
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
// ─── Generic formatter ────────────────────────────────────────────────────────
const formatFilterItem = (item, field, type) => {
if (FIELD_DISPLAY_MAP[field]) {
return FIELD_DISPLAY_MAP[field][String(item)] ?? item;
}
if (type === "date" && item) {
return new Date(item).toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric",
});
}
return item;
};
// ─── Reusable empty state ─────────────────────────────────────────────────────
const EmptyState = ({ search }) => (
<p className="text-sm text-muted-foreground text-center py-4">
{search ? `No results for "${search}".` : "No data found."}
</p>
);
// ─── Reusable item list renderer ──────────────────────────────────────────────
const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => {
if (items.length === 0) return <EmptyState />;
return items.map((item) => (
<label key={item} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type={inputType}
name={inputType === "radio" ? field : undefined}
checked={selected.includes(String(item))}
onChange={() => onToggle(item)}
/>
{formatFilterItem(item, field, type)}
</label>
));
};
export function FilterSheet({ open, onOpenChange, column, attr, data = [], loading }) {
const type = attr?.type;
const field = attr?.field;
const enumData = attr?.options?.choices || [];
const [search, setSearch] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
const val = column?.getFilterValue();
setSelected(Array.isArray(val) ? val.map(String) : []);
}, [column]);
const sourceData = useMemo(() => {
if (BOOLEAN_FIELDS.includes(field)) return ["true", "false"];
if (type === "enum") return enumData;
return data || [];
}, [field, type, enumData, data]);
const filteredData = useMemo(() => {
if (!search) return sourceData;
return sourceData.filter((item) =>
formatFilterItem(item, field, type)
.toString()
.toLowerCase()
.includes(search.toLowerCase())
);
}, [search, sourceData, field, type]);
const toggle = (item) => {
setSelected((prev) =>
prev.includes(String(item))
? prev.filter((v) => v !== String(item))
: [...prev, String(item)]
);
};
const toggleRadio = (item) => setSelected([String(item)]);
const isBoolean = BOOLEAN_FIELDS.includes(field);
const isEnum = type === "enum" && !isBoolean;
const isList = !isBoolean && !isEnum;
const isEmpty = !loading && sourceData.length === 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[380px] flex flex-col">
<SheetHeader className="shrink-0 border-b">
<SheetTitle>Filter by {column?.columnDef?.header}</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-hidden flex flex-col gap-4 px-4 relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
{/* ================= EMPTY STATE ================= */}
{isEmpty && <EmptyState />}
{/* ================= BOOLEAN ================= */}
{!isEmpty && isBoolean && (
<div className="space-y-2 overflow-auto pr-2 pt-2">
<FilterList
items={sourceData}
field={field}
type={type}
selected={selected}
onToggle={toggleRadio}
inputType="radio"
/>
</div>
)}
{/* ================= ENUM ================= */}
{!isEmpty && isEnum && (
<div className="space-y-2 overflow-auto pr-2 pt-2">
<FilterList
items={sourceData}
field={field}
type={type}
selected={selected}
onToggle={toggle}
/>
</div>
)}
{/* ========== TEXT / NUMBER / DATE ========== */}
{!isEmpty && isList && (
<>
<div>
<Input
placeholder={`Search ${type}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<ScrollArea className="flex-1 h-[550px] pr-2">
<div className="space-y-2">
{filteredData.length === 0
? <EmptyState search={search} />
: <FilterList
items={filteredData}
field={field}
type={type}
selected={selected}
onToggle={toggle}
/>
}
</div>
<ScrollBar orientation="vertical" />
</ScrollArea>
</>
)}
</div>
{/* ─── Footer — hidden when empty ──────────────────────────────────── */}
{!isEmpty && (
<div className="shrink-0 flex gap-2 border-t p-4">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setSelected([]);
column.setFilterValue(undefined);
onOpenChange(false);
}}
>
Clear
</Button>
<Button
className="flex-1"
onClick={() => {
column.setFilterValue(selected);
onOpenChange(false);
}}
>
Apply
</Button>
</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,65 @@
import { Filter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* ActiveFilterPills
*
* Displays active column filters as dismissible pill badges.
*
* Props:
* @param {Array} filters - Array of { id, value } (TanStack columnFilters shape)
* @param {Array} attributes - Array of { field, name } for display labels
* @param {Function} onRemove - (id: string) => void — remove a single filter
* @param {Function} [onClearAll] - () => void — clear all filters
* @param {boolean} [showClearButton]- Render the "Clear filters (n)" button instead of pills
* (used in the toolbar area; omit for the pill row)
*/
export function ActiveFilterPills({
filters = [],
attributes = [],
onRemove,
onClearAll,
showClearButton = false,
}) {
if (filters.length === 0) return null;
// ── Toolbar variant: just a "Clear filters (n)" ghost button ──────────────
if (showClearButton) {
return (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
Clear filters ({filters.length})
</Button>
);
}
// ── Pill row variant ───────────────────────────────────────────────────────
return (
<div className="flex items-center gap-2 flex-wrap mb-3">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{filters.map((f) => {
const attr = attributes.find((a) => a.field === f.id);
return (
<span
key={f.id}
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary border border-primary/20 rounded-full px-2.5 py-0.5"
>
<span className="font-medium">{attr?.name ?? f.id}:</span>
{String(f.value)}
<button
onClick={() => onRemove(f.id)}
className="ml-0.5 hover:text-destructive transition-colors"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
);
})}
</div>
);
}
@@ -0,0 +1,88 @@
import { RowActions } from "@/components/generic/Table/RowActions";
/**
* buildRowActionsColumn
*
* Factory that returns a TanStack column definition for the kebab actions column.
* Append the result to your `columns` array — no other setup needed.
*
* @param {Array} rowActions Action definitions (see RowActions for shape)
* @param {Object} [options]
* @param {string} [options.id] Column id (default: "actions")
* @param {string} [options.header] Column header text (default: "")
* @param {string} [options.dropdownLabel] Kebab menu label (default: "Actions")
* @param {string} [options.align] Dropdown alignment (default: "end")
* @param {number} [options.size] Column px width (default: 48)
* @returns {Object} TanStack ColumnDef
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { buildRowActionsColumn } from "@/components/generic/buildRowActionsColumn";
* import { Pencil, Trash2, Eye, Archive } from "lucide-react";
*
* const columns = [
* ...buildColumns(attributes),
*
* buildRowActionsColumn(
* [
* {
* key: "view",
* label: "View details",
* icon: <Eye className="h-3.5 w-3.5" />,
* onClick: (row) => navigate(`/users/${row.id}`),
* },
* {
* key: "edit",
* label: "Edit",
* icon: <Pencil className="h-3.5 w-3.5" />,
* onClick: (row) => navigate(`/users/${row.id}/edit`),
* disabled: (row) => row.role === "super_admin",
* },
* {
* key: "archive",
* label: "Archive",
* icon: <Archive className="h-3.5 w-3.5" />,
* onClick: (row) => archiveUser(row.id),
* hidden: (row) => row.status === "archived",
* separator: true,
* },
* {
* key: "delete",
* label: "Delete",
* icon: <Trash2 className="h-3.5 w-3.5" />,
* className: "text-destructive focus:text-destructive",
* onClick: (row) => confirmDelete(row.id),
* disabled: (row) => row.role === "admin",
* },
* ],
* { dropdownLabel: "User Actions" }
* ),
* ];
*
* ─────────────────────────────────────────────────────────────────────────────
*/
export function buildRowActionsColumn(rowActions, options = {}) {
const {
id = "actions",
header = "Actions",
dropdownLabel = "Actions",
align = "end",
size = 48,
} = options;
return {
id,
header,
size,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<RowActions
row={row}
rowActions={rowActions}
dropdownLabel={dropdownLabel}
align={align}
/>
),
};
}
@@ -0,0 +1,62 @@
import { Checkbox } from "@/components/ui/checkbox";
/**
* buildSelectionColumn
*
* Returns a TanStack column definition for the checkbox selection column.
* Prepend this to your `columns` array.
*
* The header renders a "select all on this page" checkbox with an
* indeterminate state when only some rows are checked.
* Each cell renders a per-row checkbox.
*
* @returns {Object} TanStack ColumnDef
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { buildSelectionColumn } from "@/components/generic/buildSelectionColumn";
*
* const columns = [
* buildSelectionColumn(),
* ...buildColumns(attributes),
* buildRowActionsColumn(rowActions),
* ];
*
* ─────────────────────────────────────────────────────────────────────────────
*/
export function buildSelectionColumn() {
return {
id: "select",
size: 40,
enableSorting: false,
enableHiding: false,
header: ({ table }) => (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all rows on this page"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
};
}
@@ -0,0 +1,92 @@
import { flexRender } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronDown,
X,
} from "lucide-react";
/**
* ColumnActionsDropdown
*
* Per-column header button that opens a dropdown with sort and filter actions.
*
* Props:
* @param {Object} header - TanStack header object
* @param {Object} [attr] - Attribute definition (from column meta.attr)
* @param {boolean} isOpen - Controlled open state
* @param {Function} onOpenChange - (isOpen: boolean) => void
* @param {Function} onFilterClick - (e) => void — triggered when "Filter By" is clicked
* @param {boolean} [showFilter] - Whether to render the Filter By option (default: !!attr)
*/
export function ColumnActionsDropdown({
header,
attr,
isOpen,
onOpenChange,
onFilterClick,
showFilter,
}) {
const column = header.column;
const sorted = column.getIsSorted();
const canFilter = showFilter ?? !!attr;
return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-3">
{sorted === "asc" && (
<ArrowUp className="size-3.5 text-primary" />
)}
{sorted === "desc" && (
<ArrowDown className="size-3.5 text-primary" />
)}
{!sorted && (
<ArrowUpDown className="size-3.5 text-muted-foreground" />
)}
{flexRender(column.columnDef.header, header.getContext())}
<ChevronDown className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="text-xs">Column Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp className="mr-2 size-4" />
Sort Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown className="mr-2 size-4" />
Sort Desc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.clearSorting()}>
<X className="mr-2 size-4" />
Clear Sort
</DropdownMenuItem>
{canFilter && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onFilterClick}>
Filter By
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,58 @@
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Columns2 } from "lucide-react";
export function ColumnVisibilityToggle({ table, label = "Columns" }) {
const columns = table.getAllLeafColumns().filter(
(col) => col.id !== "select" && col.id !== "actions"
);
const allVisible = columns.every((col) => col.getIsVisible());
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1.5">
<Columns2 className="h-3.5 w-3.5" />
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Toggle visible columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* ─── Show All ──────────────────────────────────────────────────── */}
<DropdownMenuCheckboxItem
checked={allVisible}
onCheckedChange={(v) => table.toggleAllColumnsVisible(v)}
className="text-xs font-medium"
>
Show All
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{/* ─── Individual columns ────────────────────────────────────────── */}
{columns.map((col) => {
const headerLabel = typeof col.columnDef.header === "string"
? col.columnDef.header
: col.id;
return (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(v) => col.toggleVisibility(v)}
className="text-xs"
>
{headerLabel}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
+317
View File
@@ -0,0 +1,317 @@
import { useState, useMemo, useRef, useEffect } from "react";
import { useReactTable, getCoreRowModel, flexRender, } from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Spinner } from "@/components/ui/spinner";
import { pageSizes } from "@/utils/table.util";
import { ActiveFilterPills } from "./ActiveFilterPills";
import { ColumnActionsDropdown } from "./ColumnActionsDropdown";
import { TablePagination } from "./TablePagination";
import { ColumnVisibilityToggle } from "./ColumnVisibilityToggle";
import { ToolbarActions } from "./ToolbarActions";
import { SelectionToolbar } from "./SelectionToolbar";
import { cn } from "@/lib/utils";
export default function DataTable({
data,
columns,
attributes = [],
pagination,
setPagination,
loading,
onFetch,
onFetchFilterData,
onRefsReady,
renderFilterSheet,
toolbarActions = [],
selectionActions = [],
showColumnToggle = true,
title = "Records",
emptyMessage = "No records match the current filters.",
recordLabel = "record",
pageSizeOptions = pageSizes,
columnPinning = { right: [], left: [] },
className = "",
}) {
// ─── Refs — always hold latest filters and sort ───────────────────────────
const filtersRef = useRef([]);
const sortRef = useRef([]);
// ─── State ────────────────────────────────────────────────────────────────
const [activeColumn, setActiveColumn] = useState(null);
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const [columnVisibility, setColumnVisibility] = useState({});
const [rowSelection, setRowSelection] = useState({});
const [filterState, setFilterState] = useState({
open: false, column: null, attr: null, data: [],
});
const activeFilters = columnFilters.filter((f) => f.value !== "");
// ─── Initial fetch on mount only ─────────────────────────────────────────
useEffect(() => {
onRefsReady?.({
getFilters: () => filtersRef.current,
getSort: () => sortRef.current,
});
onFetch({ page: 1, limit: pagination.limit, filters: [], sort: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleOpenFilterSheet = async (e, column, attr) => {
e.preventDefault();
setActiveColumn(null);
const data = await onFetchFilterData(attr.field);
setFilterState({ open: true, column, attr, data });
};
const table = useReactTable({
data,
columns,
initialState: { columnPinning },
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination: {
pageIndex: pagination.page - 1,
pageSize: pagination.limit,
},
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: pagination.totalPages,
// ─── Sort change ────────────────────────────────────────────────────────
onSortingChange: (updater) => {
const next = typeof updater === "function" ? updater(sorting) : updater;
const newSort = next.length ? [{ id: next[0].id, desc: next[0].desc }] : [];
setSorting(next);
sortRef.current = newSort;
onFetch({
page: 1,
limit: pagination.limit,
filters: filtersRef.current,
sort: newSort,
});
},
// ─── Filter change ──────────────────────────────────────────────────────
onColumnFiltersChange: (updater) => {
const next = typeof updater === "function" ? updater(columnFilters) : updater;
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({
page: 1,
limit: pagination.limit,
filters: newFilters,
sort: sortRef.current,
});
},
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
});
// ─── Single handler for all page changes ──────────────────────────────────────
const handlePageChange = (page) => {
setPagination((p) => ({ ...p, page }));
onFetch({
page,
limit: pagination.limit,
filters: filtersRef.current,
sort: sortRef.current,
});
};
const selectedRows = useMemo(
() => table.getSelectedRowModel().rows.map((r) => r.original),
// eslint-disable-next-line react-hooks/exhaustive-deps
[rowSelection, data]
);
const hasSelection = selectedRows.length > 0;
return (
<div className={`w-full bg-card rounded-lg border border-border mb-6 px-6 py-4 relative ${className}`}>
{/* ── Toolbar ── */}
{hasSelection ? (
<SelectionToolbar
selectedRows={selectedRows}
onClearSelection={() => table.resetRowSelection()}
selectionActions={selectionActions}
recordLabel={recordLabel}
/>
) : (
<div className="flex items-start justify-between gap-4 mb-5">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<div className="flex items-center gap-2 shrink-0">
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
onClearAll={() => {
setColumnFilters([]);
filtersRef.current = [];
onFetch({ page: 1, limit: pagination.limit, filters: [], sort: sortRef.current });
}}
onRemove={(id) => {
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({ page: 1, limit: pagination.limit, filters: newFilters, sort: sortRef.current });
}}
showClearButton
/>
<ToolbarActions actions={toolbarActions} />
{showColumnToggle && <ColumnVisibilityToggle table={table} />}
</div>
</div>
)}
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
onRemove={(id) => {
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({ page: 1, limit: pagination.limit, filters: newFilters, sort: sortRef.current });
}}
/>
{/* ── Table ── */}
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="bg-muted/40 hover:bg-muted/40">
{hg.headers.map((header) => {
const isPinned = header.column.getIsPinned();
const attr = header.column.columnDef.meta?.attr;
const isPlain = header.column.id === "select" || header.column.id === "actions";
return (
<TableHead
key={header.id}
className={cn("align-middle whitespace-nowrap", isPinned ? "bg-muted" : "")}
style={{
position: isPinned ? "sticky" : "relative",
right: isPinned === "right" ? header.column.getStart("right") : undefined,
left: isPinned === "left" ? header.column.getStart("left") : undefined,
zIndex: isPinned ? 2 : 0,
width: header.column.columnDef.size,
}}
>
{isPlain ? (
flexRender(header.column.columnDef.header, header.getContext())
) : (
<ColumnActionsDropdown
header={header}
attr={attr}
isOpen={activeColumn === header.column.id}
onOpenChange={(isOpen) => setActiveColumn(isOpen ? header.column.id : null)}
onFilterClick={(e) => handleOpenFilterSheet(e, header.column, attr)}
/>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
{renderFilterSheet?.({
open: filterState.open,
onOpenChange: (v) => setFilterState((p) => ({ ...p, open: v })),
column: filterState.column,
attr: filterState.attr,
data: filterState.data,
loading,
})}
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="text-center py-16 text-muted-foreground text-sm"
>
{emptyMessage}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
className="hover:bg-muted/30 transition-colors data-[state=selected]:bg-primary/5"
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned();
return (
<TableCell
key={cell.id}
className={cn("m-0", isPinned && "bg-card")}
style={{
position: isPinned ? "sticky" : "relative",
right: isPinned === "right" ? 0 : undefined,
left: isPinned === "left" ? cell.column.getStart("left") : undefined,
zIndex: isPinned ? 1 : 0,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
</div>
{/* ── Footer ── */}
<TablePagination
table={table}
pagination={pagination}
onPageChange={handlePageChange} // ← replaces setPagination
totalRecords={pagination.totalRecords}
rowCount={data.length}
onPageSizeChange={(size) => {
setPagination((p) => ({ ...p, limit: size, page: 1 })); // ← updates your state
onFetch({
page: 1,
limit: size,
filters: filtersRef.current,
sort: sortRef.current,
});
}}
pageSizeOptions={pageSizeOptions}
recordLabel={recordLabel}
/>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
/**
* RowActions
*
* Renders a single kebab (⋯) dropdown per row containing all defined actions.
* Each action is evaluated against the row's data for conditional
* visibility and disabled state.
*
* ─── Action definition shape ─────────────────────────────────────────────────
*
* @field {string} key Unique identifier
* @field {string} label Menu item text
* @field {ReactNode} [icon] Optional leading icon
* @field {Function} onClick (rowData) => void
* @field {Function|boolean} [hidden] (rowData) => boolean — hide for specific rows
* @field {Function|boolean} [disabled](rowData) => boolean — disable for specific rows
* @field {boolean} [separator] Render a separator BEFORE this item
* @field {string} [className] Extra classes on the menu item
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Object} row TanStack row object (row.original = raw data)
* @param {Array} rowActions Array of action definitions
* @param {string} [dropdownLabel] Label shown at top of the menu (default: "Actions")
* @param {string} [align] Dropdown alignment ("end" | "start", default: "end")
*/
export function RowActions({
row,
rowActions = [],
dropdownLabel = "Actions",
align = "end",
}) {
const data = row.original;
const visibleActions = rowActions.filter((a) => !resolveFlag(a.hidden, data));
if (!visibleActions.length) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 data-[state=open]:bg-muted"
aria-label="Open actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-44">
{dropdownLabel && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{dropdownLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{visibleActions.map((action) => {
const isDisabled = resolveFlag(action.disabled, data);
return (
<span key={action.key}>
{action.separator && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => action.onClick(data)}
disabled={isDisabled}
className={action.className}
>
{action.icon && (
<span className="mr-2 flex items-center size-4">
{action.icon}
</span>
)}
{action.label}
</DropdownMenuItem>
</span>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
// ── Helper ────────────────────────────────────────────────────────────────────
/** Resolves a flag that is either a static boolean or a (rowData) => boolean fn */
function resolveFlag(flag, data) {
if (typeof flag === "function") return flag(data);
return flag ?? false;
}
@@ -0,0 +1,91 @@
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
/**
* SelectionToolbar
*
* Replaces the normal toolbar area when one or more rows are selected.
* Shows a selection count and flat inline action buttons — no dropdowns,
* since the actions are already visible.
*
* ─── Selection action definition shape ───────────────────────────────────────
*
* @field {string} key Unique identifier
* @field {string} label Button text
* @field {ReactNode} [icon] Optional leading icon
* @field {Function} onClick (selectedRows: rowData[], table: TableInstance) => void
* Receives selected row data AND the TanStack table instance
* so handlers like export can call table.getVisibleLeafColumns().
* @field {string} [variant] shadcn Button variant (default: "outline")
* @field {string} [className] Extra classes
* @field {boolean|Function} [disabled] Static boolean or (selectedRows) => boolean
* @field {boolean|Function} [hidden] Static boolean or (selectedRows) => boolean
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Array} selectedRows Array of raw row data objects
* @param {Function} onClearSelection () => void — clears all checkboxes
* @param {Array} selectionActions Action button definitions (see above)
* @param {string} [recordLabel] Singular label (default: "record")
* @param {TableInstance} table TanStack table instance forwarded from DataTable.
* Passed as the second argument to every onClick handler.
*/
export function SelectionToolbar({
selectedRows = [],
onClearSelection,
selectionActions = [],
recordLabel = "record",
table,
}) {
if (!selectedRows.length) return null;
const count = selectedRows.length;
const label = count === 1 ? recordLabel : `${recordLabel}s`;
return (
<div className="flex items-center justify-between gap-4 mb-5">
{/* Left: count + clear */}
<div className="flex items-center gap-2">
<button
onClick={onClearSelection}
className="flex items-center justify-center h-5 w-5 rounded-sm border border-primary bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
aria-label="Clear selection"
>
<X className="h-3 w-3" />
</button>
<span className="text-sm font-medium">
{count} {label} selected
</span>
</div>
{/* Right: flat action buttons */}
<div className="flex items-center gap-2 shrink-0">
{selectionActions
.filter((a) => !resolveFlag(a.hidden, selectedRows))
.map((action) => {
const isDisabled = resolveFlag(action.disabled, selectedRows);
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size="sm"
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
onClick={() => action.onClick(selectedRows, table)}
disabled={isDisabled}
>
{action.icon}
{action.label}
</Button>
);
})}
</div>
</div>
);
}
// ── Helper ────────────────────────────────────────────────────────────────────
function resolveFlag(flag, data) {
if (typeof flag === "function") return flag(data);
return flag ?? false;
}
@@ -0,0 +1,119 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ChevronsLeft, ChevronsRight } from "lucide-react";
import { pageSizes } from "@/utils/table.util";
/**
* TablePagination
*
* Footer bar with First / Previous / Next / Last page controls,
* a "Page X of Y" indicator, a row count summary, and a rows-per-page picker.
*
* Props:
* @param {Object} pagination - { page, limit, totalPages, totalRecords, hasPrevPage, hasNextPage }
* @param {Function} onPageChange - (updater | patch) => void
* @param {number} rowCount - Number of rows currently visible (e.g. users.length)
* @param {number} [totalRecords] - Grand total record count (falls back to pagination.totalRecords)
* @param {Function} [onPageSizeChange] - (size: number) => void — called when rows-per-page changes
* @param {number[]} [pageSizeOptions] - Options for rows-per-page picker (default: [10, 50, 100])
* @param {string} [recordLabel] - Singular label for records (default: "record")
* @param {Object} [table] - TanStack table instance (used to read current pageSize)
*/
export function TablePagination({
pagination,
onPageChange,
rowCount,
totalRecords,
onPageSizeChange,
pageSizeOptions = pageSizes,
recordLabel = "record",
table,
}) {
const total = totalRecords ?? pagination.totalRecords;
const currentPageSize = pagination.limit;
const pluralLabel = rowCount === 1 ? recordLabel : `${recordLabel}s`;
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-4 border-t border-border">
<div className="text-sm text-muted-foreground">
Showing {rowCount} of {total} {pluralLabel}
</div>
<div className="flex flex-wrap items-center gap-2 justify-end">
{/* First */}
<Button
variant="outline"
onClick={() => onPageChange(1)}
disabled={!pagination.hasPrevPage}
>
<ChevronsLeft className="size-4" /> First
</Button>
{/* Previous */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.page - 1)}
disabled={!pagination.hasPrevPage}
>
Previous
</Button>
<span className="text-sm font-medium px-2">
Page {pagination.page} of {pagination.totalPages}
</span>
{/* Next */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.page + 1)}
disabled={!pagination.hasNextPage}
>
Next
</Button>
{/* Last */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.totalPages)}
disabled={!pagination.hasNextPage}
>
Last <ChevronsRight className="size-4" />
</Button>
{/* Rows per page */}
{onPageSizeChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="font-medium text-sm">
Rows per page: {currentPageSize}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select rows</DropdownMenuLabel>
<DropdownMenuSeparator />
{pageSizeOptions.map((size) => (
<DropdownMenuItem
key={size}
onClick={() => onPageSizeChange(size)}
className={
currentPageSize === size ? "bg-muted font-medium" : ""
}
>
Show {size}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ChevronDown } from "lucide-react";
/**
* ToolbarActions
*
* Renders a list of configurable toolbar buttons beside the column visibility toggle.
* Each button can be one of three types: "icon", "button", or "dropdown".
*
* ─── Button definition shape ───────────────────────────────────────────────────
*
* Common fields (all types):
* @field {string} key Unique identifier
* @field {"icon"|"button"|"dropdown"} type Render mode
* @field {ReactNode} icon Lucide icon or any ReactNode
* @field {string} [label] Text label (required for "button"; used as tooltip for "icon")
* @field {string} [variant] shadcn Button variant (default: "outline")
* @field {string} [size] shadcn Button size (default: "sm")
* @field {string} [className] Extra classes on the button
* @field {boolean} [disabled] Disable the button
* @field {boolean} [hidden] Completely hide the button
*
* For type "icon" | "button":
* @field {Function} onClick (table: TableInstance) => void
* Receives the TanStack table instance so handlers
* like export can call table.getVisibleLeafColumns().
*
* For type "dropdown":
* @field {string} [dropdownLabel] Optional label shown at the top of the dropdown
* @field {Array} items Dropdown item definitions:
* @field {string} key Unique key
* @field {ReactNode} icon Item icon
* @field {string} label Item label
* @field {Function} onClick (table: TableInstance) => void
* @field {boolean} [disabled] Disable this item
* @field {boolean} [hidden] Hide this item
* @field {boolean} [separator] Render a separator BEFORE this item
* @field {string} [className] Extra classes on the item
*
* ───────────────────────────────────────────────────────────────────────────────
*
* @param {Array} actions Array of button definitions (see above)
* @param {string} [align] DropdownMenuContent align ("end" | "start", default: "end")
* @param {TableInstance} table TanStack table instance forwarded from DataTable.
* Passed as the first argument to every onClick handler.
*/
export function ToolbarActions({ actions = [], align = "end", table }) {
if (!actions.length) return null;
return (
<>
{actions
.filter((action) => !action.hidden)
.map((action) => {
// ── Icon-only button ─────────────────────────────────────────────
if (action.type === "icon") {
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 w-8 p-0 ${action.className ?? ""}`}
onClick={() => action.onClick(table)}
disabled={action.disabled}
title={action.label}
aria-label={action.label}
>
{action.icon}
</Button>
);
}
// ── Icon + label button ──────────────────────────────────────────
if (action.type === "button") {
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
onClick={() => action.onClick(table)}
disabled={action.disabled}
>
{action.icon}
{action.label}
</Button>
);
}
// ── Dropdown button ──────────────────────────────────────────────
if (action.type === "dropdown") {
const visibleItems = (action.items ?? []).filter((i) => !i.hidden);
return (
<DropdownMenu key={action.key}>
<DropdownMenuTrigger asChild>
<Button
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
disabled={action.disabled}
>
{action.icon}
{action.label}
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-48">
{action.dropdownLabel && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{action.dropdownLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{visibleItems.map((item) => (
<span key={item.key}>
{item.separator && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => item.onClick(table)}
disabled={item.disabled}
className={item.className}
>
{item.icon && (
<span className="mr-2 flex items-center">
{item.icon}
</span>
)}
{item.label}
</DropdownMenuItem>
</span>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</>
);
}
-1
View File
@@ -239,7 +239,6 @@ export default function UserMenu() {
const given = user?.personal_info?.name?.given_name ?? '' const given = user?.personal_info?.name?.given_name ?? ''
const last = user?.personal_info?.name?.last_name ?? '' const last = user?.personal_info?.name?.last_name ?? ''
console.log('given', user, given, last)
const initials = given && last const initials = given && last
? (given[0] + last[0]).toUpperCase() ? (given[0] + last[0]).toUpperCase()
: (user?.email?.[0] ?? 'U').toUpperCase() : (user?.email?.[0] ?? 'U').toUpperCase()
+196
View File
@@ -0,0 +1,196 @@
// ─── UserContext.jsx ───────────────────────────────────────────────────────────
import { createContext, useContext, useState, useCallback } from "react";
import api from "@/utils/api.util";
const UserContext = createContext(null);
export const useUsers = () => {
const ctx = useContext(UserContext);
if (!ctx) throw new Error("useUsers must be used inside UserProvider");
return ctx;
};
// ─── Initial States ────────────────────────────────────────────────────────────
const PAGINATION_INIT = {
page: 1,
limit: 10,
totalRecords: 0,
totalPages: 0,
hasPrevPage: false,
hasNextPage: false,
};
const BASE = "/admin";
export const UserProvider = ({ children }) => {
// ─── State ─────────────────────────────────────────────────────────────────
const [users, setUsers] = useState([]);
const [user, setUser] = useState(null);
const [sessions, setSessions] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT);
const [attributes, setAttributes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// ─── Helpers ───────────────────────────────────────────────────────────────
const request = useCallback(async (fn) => {
setLoading(true);
setError(null);
try {
return await fn();
} catch (err) {
const message = err?.response?.data?.message || err.message || "Something went wrong.";
setError(message);
return null;
} finally {
setLoading(false);
}
}, []);
// ─── GET /api/admin/users ──────────────────────────────────────────────────
const fetchUsers = useCallback(
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
request(async () => {
const { data } = await api.get(`${BASE}/users`, {
params: {
page,
limit,
filters: filters.length ? JSON.stringify(filters) : undefined,
sort: sort.length ? JSON.stringify(sort) : undefined,
},
});
const final_data = data?.data
setUsers(final_data?.data ?? []);
setPagination(final_data?.pagination ?? PAGINATION_INIT);
setAttributes(final_data?.attributes ?? []);
return data?.data;
}),
[request]
);
// ─── GET /api/admin/users/:id ──────────────────────────────────────────────
const fetchUser = useCallback(
(userId) =>
request(async () => {
const res = await api.get(`${BASE}/users/${userId}`);
setUser(res.data?.data ?? null);
return res.data;
}),
[request]
);
// ─── PUT /api/admin/users/:id ──────────────────────────────────────────────
const updateUser = useCallback(
(userId, payload) =>
request(async () => {
const res = await api.put(`${BASE}/users/${userId}`, payload);
// Sync local list if user exists in it
setUsers((prev) =>
prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u))
);
return res.data;
}),
[request]
);
// ─── DELETE /api/admin/users/:id (soft delete) ────────────────────────────
const deactivateUser = useCallback(
(userId) =>
request(async () => {
const res = await api.delete(`${BASE}/users/${userId}`);
setUsers((prev) =>
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
);
return res.data;
}),
[request]
);
// ─── POST /api/admin/users/:id/restore ────────────────────────────────────
const restoreUser = useCallback(
(userId) =>
request(async () => {
const res = await api.post(`${BASE}/users/${userId}/restore`);
setUsers((prev) =>
prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u))
);
return res.data;
}),
[request]
);
// ─── GET /api/admin/users/:id/sessions ────────────────────────────────────
const fetchUserSessions = useCallback(
(userId) =>
request(async () => {
const res = await api.get(`${BASE}/users/${userId}/sessions`);
setSessions(res.data?.data ?? []);
return res.data;
}),
[request]
);
// ─── DELETE /api/admin/users/:id/sessions/:sid ────────────────────────────
const terminateSession = useCallback(
(userId, sessionId) =>
request(async () => {
const res = await api.delete(`${BASE}/users/${userId}/sessions/${sessionId}`);
setSessions((prev) =>
prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s))
);
return res.data;
}),
[request]
);
// ─── GET /api/admin/users/field-values?field=acc_type ─────────────────────────
const fetchUserFieldValues = useCallback(
(field) =>
request(async () => {
const res = await api.get(`${BASE}/users/field-values`, { params: { field } });
return res.data?.data;
}),
[request]
);
// ─── Provider ──────────────────────────────────────────────────────────────
return (
<UserContext.Provider
value={{
// state
users,
user,
sessions,
pagination,
attributes,
loading,
error,
// setters
setPagination,
// actions
fetchUserFieldValues,
fetchUsers,
fetchUser,
updateUser,
deactivateUser,
restoreUser,
fetchUserSessions,
terminateSession,
}}
>
{children}
</UserContext.Provider>
);
};
+16
View File
@@ -0,0 +1,16 @@
// ─── AdminProvider.jsx ─────────────────────────────────────────────────────────
import { UserProvider } from "../AdminUserContext";
// import { GroupProvider } from "@/modules/admin_side/user_management/group/context/GroupContext";
// import { ContentProvider } from "@/modules/admin_side/content_management/context/ContentContext";
export const AdminProvider = ({ children }) => {
return (
<UserProvider>
{/* <GroupProvider> */}
{/* <ContentProvider> */}
{children}
{/* </ContentProvider> */}
{/* </GroupProvider> */}
</UserProvider>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { Users, GitFork } from "lucide-react";
export const USER_MANAGEMENT = [
{
title: "User Management",
description: "Manage people across different systems",
tiles: [
{ key: "users", label: "Users", icon: Users, link: "all" },
{ key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" },
],
},
]
export const CONTENT_MANAGEMENT = [
// {
// title: "User Management",
// description: "Manage people with their account permissions here.",
// tiles: [
// { key: "users", label: "Users", icon: Users, link: "all" },
// { key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" },
// ],
// },
]
export const SITE_CONTENT = [
]
@@ -0,0 +1,76 @@
import { useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useUsers } from "@/contexts/AdminUserContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { buildUserColumns, columnPinning } from "../config/columns.config";
import { buildToolbarActions } from "../config/toolbar.config";
import { buildSelectionActions } from "../config/selection.config";
import { buildRowActions } from "../config/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function UsersTable() {
const navigate = useNavigate();
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] });
const {
users,
attributes,
pagination,
setPagination,
loading,
fetchUsers,
fetchUserFieldValues,
deactivateUser,
} = useUsers();
// Shared export config — passed into toolbar + selection configs
const exportConfig = {
allData: users,
attributes,
filename: `${getTimestamp()}_Users`,
sheetName: "Users",
};
const rowActions = buildRowActions({ navigate, deactivateUser });
const toolbarActions = buildToolbarActions({
fetchUsers, pagination, exportConfig, navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
});
const selectionActions = buildSelectionActions({ exportConfig, deactivateUser });
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
return (
<DataTable
title="Users"
data={users}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={fetchUsers}
onFetchFilterData={fetchUserFieldValues}
onRefsReady={(refs) => tableRefsRef.current = refs}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="user"
emptyMessage="No users match the current filters."
/>
);
}
@@ -0,0 +1,28 @@
// config/columns.config.jsx
// Column definitions and pinning config for the Users table.
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
export const columnPinning = {
right: ["actions"],
left: [],
};
/**
* Builds the full column array for the Users table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
export function buildUserColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes),
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
];
}
@@ -0,0 +1,39 @@
// config/rowActions.config.jsx
// Per-row kebab menu action definitions for the Users table.
//
// Each onClick receives the row's data object from buildRowActionsColumn.
import { Eye, Pencil, Archive } from "lucide-react";
/**
* @param {Object} deps
* @param {Function} deps.navigate React Router navigate
* @param {Function} deps.archiveUser Archive handler from useManagement
* @returns {Array} rowActions
*/
export function buildRowActions({ navigate, archiveUser }) {
return [
{
key: "view",
label: "View details",
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`view/${row.user_id}`),
},
{
key: "edit",
label: "Edit",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/users/${row.id}/edit`),
disabled: (row) => row.role === "super_admin",
},
{
key: "archive",
label: "Archive",
className: "text-destructive focus:text-destructive",
icon: <Archive className="h-3.5 w-3.5" />,
onClick: (row) => archiveUser(row.id),
hidden: (row) => row.status === "archived",
separator: true,
},
];
}
@@ -0,0 +1,36 @@
// config/selection.config.jsx
import { Download, Archive, Trash2 } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
* @param {Function} deps.archiveUser Archive handler from useManagement
* @param {Function} deps.deleteUser Delete handler from useManagement
*/
export function buildSelectionActions({ exportConfig, archiveUser, deleteUser }) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
},
{
key: "archive-selected",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
onClick: (rows) => archiveUser({ ids: rows.map((r) => r.id) }),
hidden: (rows) => rows.every((r) => r.status === "archived"),
},
{
key: "delete-selected",
label: "Delete",
icon: <Trash2 className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => deleteUser({ ids: rows.map((r) => r.id) }),
disabled: (rows) => rows.some((r) => r.role === "admin" || r.role === "super_admin"),
},
];
}
@@ -0,0 +1,38 @@
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
import { RefreshCw, Download, UserPlus } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
* @param {Object} deps
* @param {Function} deps.fetchUsers Refetch handler from useUsers
* @param {Object} deps.pagination Current pagination state
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
* @param {Function} deps.navigate React Router navigate
*/
export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, exportConfig, navigate, getFilters, getSort }) {
return [
{
key: "refresh",
type: "button",
icon: <RefreshCw className="h-3.5 w-3.5" />,
label: "Refresh",
onClick: () => fetchUsers({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort()}),
},
{
key: "export",
type: "button",
icon: <Download className="h-3.5 w-3.5" />,
label: "Export",
onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }),
},
{
key: "add-user",
type: "button",
icon: <UserPlus className="h-3.5 w-3.5" />,
label: "Add User",
variant: "default",
className: "text-primary-foreground",
onClick: () => navigate("add"),
},
];
}
@@ -22,6 +22,8 @@ import AdminSideTabs from "../components/AdminSideTabs"
import UserMenu from "@/components/generic/UserMenu" import UserMenu from "@/components/generic/UserMenu"
import { ROLE_CONFIG } from "@/data/profile.data" import { ROLE_CONFIG } from "@/data/profile.data"
import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add
const AdminLayout = () => { const AdminLayout = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -81,10 +83,14 @@ const AdminLayout = () => {
<AdminSideTabs /> <AdminSideTabs />
</div> </div>
</div> </div>
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
<AdminProvider>
<div id="main-body"> <div id="main-body">
<Outlet /> <Outlet />
<Toaster position="bottom-right" richColors /> <Toaster position="bottom-right" richColors />
</div> </div>
</AdminProvider>
</TooltipProvider> </TooltipProvider>
</section> </section>
) )
@@ -0,0 +1,12 @@
import React from 'react'
import { Outlet } from 'react-router-dom'
const UserManagementLayout = () => {
return (
<div>
<Outlet />
</div>
)
}
export default UserManagementLayout
@@ -0,0 +1,11 @@
import React from 'react'
const GroupList = () => {
return (
<div>
GroupList
</div>
)
}
export default GroupList
@@ -0,0 +1,11 @@
import React from 'react'
const ViewGroup = () => {
return (
<div>
ViewGroup
</div>
)
}
export default ViewGroup
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
const AddUser = () => {
return (
<div>
AddUser
</div>
)
}
export default AddUser
@@ -0,0 +1,6 @@
import { USER_MANAGEMENT } from "@/data/adminTiles.data";
import DashboardGrid from "@/components/generic/DashboardGrid";
export default function UserDashboard() {
return <DashboardGrid sections={USER_MANAGEMENT} />
}
@@ -0,0 +1,25 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import UsersTable from "../../components/UserTable";
export default function UserList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
{ label: "Users" },
]
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<UsersTable />
</div>
</div>
</section>
)
}
@@ -0,0 +1,11 @@
import React from 'react'
const ViewUser = () => {
return (
<div>
ViewUser
</div>
)
}
export default ViewUser
+46 -9
View File
@@ -1,8 +1,25 @@
import { Outlet } from 'react-router-dom'
import ProtectedRoute from '../../../routes/ProtectedRoute' import ProtectedRoute from '../../../routes/ProtectedRoute'
// Layouts
import AdminLayout from '../layouts/AdminLayout' import AdminLayout from '../layouts/AdminLayout'
import UserManagementLayout from '../layouts/UserManagementLayout'
// Global Pages
import Admin from '../pages/Admin' import Admin from '../pages/Admin'
import ProfilePage from '@/components/generic/Profile' 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 = { export const AdminRoutes = {
element: <ProtectedRoute allowedRoles={['admin']} />, element: <ProtectedRoute allowedRoles={['admin']} />,
children: [ children: [
@@ -13,16 +30,36 @@ export const AdminRoutes = {
// Admin Management // Admin Management
{ index: true, element: <Admin /> }, // /admin { index: true, element: <Admin /> }, // /admin
{ path: 'my-profile', element: <ProfilePage /> }, { path: 'my-profile', element: <ProfilePage /> },
// Users Management // Users Management
// { {
// path: 'users', path: 'users',
// element: <UsersLayout />, // ← shared header/nav for user pages element: <UserManagementLayout />,
// children: [ children: [
// { index: true, element: <Users /> }, // /admin/users { index: true, element: <UsersDashboard /> },
// { path: 'add', element: <UsersAdd /> }, // /admin/users/add
// { path: 'edit/:id', element: <UsersEdit /> }, // /admin/users/edit/123 // User View
// ] {
// }, path: 'all',
element: <Outlet />,
children: [
{ index: true, element: <UserList /> },
{ path: 'add', element: <AddUser /> },
{ path: 'view/:userId', element: <ViewUser /> }
]
},
// User Group View
{
path: 'groups',
element: <Outlet />,
children: [
{ index: true, element: <GroupList /> },
{ path: 'view/:groupId', element: <ViewGroup /> }
]
},
]
},
// Add here // Add here
] ]
+134
View File
@@ -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`);
}
+250
View File
@@ -0,0 +1,250 @@
import { useState, useEffect } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { format } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Search } from "lucide-react";
export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000]
// ── Badge style map for enum values ───────────────────────────────────────────
const BADGE_STYLES = {
"Active": "bg-emerald-100 text-emerald-800 border-emerald-200",
"Not Active": "bg-rose-100 text-rose-700 border-rose-200",
"Verified": "bg-blue-100 text-blue-800 border-blue-200",
"Not Verified": "bg-amber-100 text-amber-700 border-amber-200",
"system": "bg-violet-50 text-violet-700 border-violet-200",
"google": "bg-orange-50 text-orange-700 border-orange-200",
"admin": "bg-red-100 text-red-700 border-red-200",
"user": "bg-blue-100 text-blue-700 border-blue-200",
"staff": "bg-green-100 text-green-700 border-green-200",
};
function EnumBadge({ value }) {
return (
<Badge
variant="outline"
className={`text-[11px] font-medium px-2 py-0.5 ${BADGE_STYLES[value] ?? ""}`}
>
{value}
</Badge>
);
}
export function formatDate(value) {
if (!value) return null;
try { return format(new Date(value), "MMM d, yyyy"); }
catch { return value; }
}
function renderCell(attr, value) {
if (value === null || value === undefined || value === "") {
return <span className="text-muted-foreground/40">-</span>;
}
switch (attr.type) {
case "enum":
if (attr.field === "is_active")
return <EnumBadge value={value ? "Active" : "Not Active"} />;
else if (attr.field === "is_verified")
return <EnumBadge value={value ? "Verified" : "Not Verified"} />;
else
return <EnumBadge value={value} />;
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>;
default: return <span className="text-sm">{value}</span>;
}
}
export function ColumnFilter({ column, attr }) {
const columnValue = column.getFilterValue() ?? "";
const [inputValue, setInputValue] = useState(columnValue);
// keep in sync when external reset happens
useEffect(() => {
setInputValue(columnValue);
}, [columnValue]);
// ─────────────────────────────────────────────
// ENUM (AUTO APPLY)
// ─────────────────────────────────────────────
if (attr.type === "enum") {
return (
<Select
value={inputValue || "__all__"}
onValueChange={(v) => {
const value = v === "__all__" ? "" : v;
setInputValue(value);
column.setFilterValue(value); // ✅ auto apply
}}
>
<SelectTrigger className="h-7 text-xs w-full">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All</SelectItem>
{attr.options?.choices?.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ─────────────────────────────────────────────
// DATE (SINGLE)
// ─────────────────────────────────────────────
if (attr.type === "date") {
const value =
inputValue &&
typeof inputValue === "object" &&
("from" in inputValue || "to" in inputValue)
? inputValue
: null;
const isRange = !!value;
return (
<div className="flex flex-col gap-1">
{/* MODE TOGGLE */}
<div className="flex items-center gap-2 text-xs">
<label className="flex items-center gap-1">
<input
type="radio"
checked={!isRange}
onChange={() => {
setInputValue("");
column.setFilterValue("");
}}
/>
Single
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={isRange}
onChange={() => {
setInputValue({ from: "", to: "" });
column.setFilterValue({ from: "", to: "" });
}}
/>
Range
</label>
</div>
{/* SINGLE DATE */}
{!isRange && (
<Input
type="date"
value={inputValue || ""}
onChange={(e) => {
const val = e.target.value;
setInputValue(val);
column.setFilterValue(val);
}}
className="h-7 text-xs"
/>
)}
{/* RANGE */}
{isRange && (
<div className="flex gap-1">
<Input
type="date"
value={value.from}
onChange={(e) => {
const next = {
from: e.target.value,
to: value.to || "",
};
setInputValue(next);
column.setFilterValue(next);
}}
className="h-7 text-xs"
/>
<Input
type="date"
value={value.to}
onChange={(e) => {
const next = {
from: value.from || "",
to: e.target.value,
};
setInputValue(next);
column.setFilterValue(next);
}}
className="h-7 text-xs"
/>
</div>
)}
</div>
);
}
// ─────────────────────────────────────────────
// TEXT FILTER (manual apply)
// ─────────────────────────────────────────────
return (
<div className="flex gap-1">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
column.setFilterValue(inputValue);
}
}}
placeholder="Filter..."
className="h-7 text-xs w-full"
/>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
onClick={() => column.setFilterValue(inputValue)}
>
<Search className="size-3.5" />
</Button>
</div>
);
}
// ── Sort icon ──────────────────────────────────────────────────────────────────
export function SortIcon({ column }) {
const s = column.getIsSorted();
if (s === "asc") return <ArrowUp className="ml-1 h-3 w-3 text-primary shrink-0" />;
if (s === "desc") return <ArrowDown className="ml-1 h-3 w-3 text-primary shrink-0" />;
return <ArrowUpDown className="ml-1 h-3 w-3 text-muted-foreground/40 shrink-0" />;
}
// ── Build columns dynamically from attributes ──────────────────────────────────
const columnHelper = createColumnHelper();
export function buildColumns(attrs) {
return attrs.map((attr) =>
columnHelper.accessor(attr.field, {
id: attr.field,
header: attr.name,
enableSorting: true,
enableColumnFilter: true,
filterFn: (row, colId, filterValue) => {
if (!filterValue) return true;
const val = String(row.getValue(colId) ?? "").toLowerCase();
return val.includes(String(filterValue).toLowerCase());
},
cell: (info) => renderCell(attr, info.getValue()),
meta: { attr },
})
);
}
+13
View File
@@ -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}`;
}