From 1d94f27e61fba2d0ca8a07906a0b0f4694cb85cf Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Sat, 9 May 2026 22:17:33 +0800 Subject: [PATCH] Adjusted --- .../generic/Dashboard/BarBreakdown.jsx | 72 +++++ .../generic/Dashboard/DashboardSection.jsx | 74 +++++ .../generic/Dashboard/PieBreakdown.jsx | 69 +++++ src/components/generic/Dashboard/StatCard.jsx | 49 ++++ src/components/generic/Dashboard/StatGrid.jsx | 36 +++ .../generic/Dashboard/TableDashboard.jsx | 140 +++++++++ src/components/generic/Sheet/AddSheet.jsx | 216 ++++++++++++++ src/components/generic/Table/DataTable.jsx | 142 +++++---- src/contexts/AdminDashboardContext.jsx | 64 +++++ src/contexts/provider/AdminProvider.jsx | 13 +- src/data/adminDashboard.data.jsx | 30 ++ .../components/user_groups/AddGroupDialog.jsx | 110 +++++++ .../user_groups/EditGroupDialog.jsx | 114 ++++++++ .../components/user_groups/GroupTable.jsx | 271 ++++++++++++------ .../admin/components/users/UserTable.jsx | 97 +++++-- .../config/user_groups/rowActions.config.jsx | 29 +- .../config/user_groups/toolbar.config.jsx | 8 +- .../user_groups/view/columns.config.jsx | 45 +++ .../user_groups/view/rowActions.config.jsx | 20 ++ .../user_groups/view/selection.config.jsx | 34 +++ .../user_groups/view/toolbar.config.jsx | 56 ++++ .../admin/config/users/rowActions.config.jsx | 2 +- src/modules/admin/layouts/AdminLayout.jsx | 3 +- src/modules/admin/pages/Admin.jsx | 11 - src/modules/admin/pages/AdminDashboard.jsx | 9 + .../admin/pages/user_groups/AddGroup.jsx | 110 +++++++ .../admin/pages/user_groups/ViewGroup.jsx | 255 +++++++++++++++- src/modules/admin/routes/AdminRoutes.jsx | 4 +- 28 files changed, 1863 insertions(+), 220 deletions(-) create mode 100644 src/components/generic/Dashboard/BarBreakdown.jsx create mode 100644 src/components/generic/Dashboard/DashboardSection.jsx create mode 100644 src/components/generic/Dashboard/PieBreakdown.jsx create mode 100644 src/components/generic/Dashboard/StatCard.jsx create mode 100644 src/components/generic/Dashboard/StatGrid.jsx create mode 100644 src/components/generic/Dashboard/TableDashboard.jsx create mode 100644 src/components/generic/Sheet/AddSheet.jsx create mode 100644 src/contexts/AdminDashboardContext.jsx create mode 100644 src/data/adminDashboard.data.jsx create mode 100644 src/modules/admin/components/user_groups/AddGroupDialog.jsx create mode 100644 src/modules/admin/components/user_groups/EditGroupDialog.jsx create mode 100644 src/modules/admin/config/user_groups/view/columns.config.jsx create mode 100644 src/modules/admin/config/user_groups/view/rowActions.config.jsx create mode 100644 src/modules/admin/config/user_groups/view/selection.config.jsx create mode 100644 src/modules/admin/config/user_groups/view/toolbar.config.jsx delete mode 100644 src/modules/admin/pages/Admin.jsx create mode 100644 src/modules/admin/pages/AdminDashboard.jsx create mode 100644 src/modules/admin/pages/user_groups/AddGroup.jsx diff --git a/src/components/generic/Dashboard/BarBreakdown.jsx b/src/components/generic/Dashboard/BarBreakdown.jsx new file mode 100644 index 0000000..299a9f7 --- /dev/null +++ b/src/components/generic/Dashboard/BarBreakdown.jsx @@ -0,0 +1,72 @@ +// components/generic/Dashboard/BarBreakdown.jsx + +import { BarChart, Bar, XAxis, YAxis, Tooltip, Cell, ResponsiveContainer } from "recharts"; + +const DEFAULT_COLORS = [ + "#6366f1", "#22c55e", "#f59e0b", "#ef4444", + "#06b6d4", "#a855f7", "#ec4899", "#84cc16", +]; + +/** + * A horizontal bar chart card for ranked data. + * + * @param {Object} props + * @param {string} props.label Card title + * @param {Array} props.data [{ label, value }] + * @param {Function} [props.onBarClick] (entry) => void — called with the clicked bar + * @param {string[]} [props.colors] + * @param {number} [props.height] Default: 240 + * @param {number} [props.yAxisWidth] Default: 130 + * @param {string} [props.className] + * + * @example + * navigate(`/admin/users/groups`)} + * /> + */ +export function BarBreakdown({ + label, + data = [], + onBarClick, + colors = DEFAULT_COLORS, + height = 240, + yAxisWidth = 130, + className = "", +}) { + if (!data.length) return null; + + const isClickable = typeof onBarClick === "function"; + + return ( +
+

{label}

+ + { + if (activePayload?.[0]) onBarClick(activePayload[0].payload); + } : undefined} + style={isClickable ? { cursor: "pointer" } : undefined} + > + + + + + {data.map((_, i) => ( + + ))} + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/generic/Dashboard/DashboardSection.jsx b/src/components/generic/Dashboard/DashboardSection.jsx new file mode 100644 index 0000000..2374647 --- /dev/null +++ b/src/components/generic/Dashboard/DashboardSection.jsx @@ -0,0 +1,74 @@ +// components/generic/Dashboard/DashboardSection.jsx + +import { StatGrid } from "./StatGrid"; +import { PieBreakdown } from "./PieBreakdown"; +import { BarBreakdown } from "./BarBreakdown"; + +/** + * Renders a titled section with a stat grid and breakdown charts. + * + * @param {Object} props + * @param {string} props.title + * @param {Array} props.stats [{ key, label, value, icon? }] + * @param {Array} [props.breakdowns] [{ key, label, chartType, data[] }] + * @param {Object} [props.iconMap] { [statKey]: } + * @param {Object} [props.linkMap] { [statKey]: () => void } — stat card click handlers + * @param {Object} [props.chartLinkMap] { [breakdownKey]: (entry) => void } — chart segment click handlers + * @param {string} [props.className] + * + * @example + * navigate('/admin/users/all'), + * archived: () => navigate('/admin/users/all/archived'), + * }} + * chartLinkMap={{ + * acc_type: (entry) => navigate(`/admin/users/all`), + * }} + * /> + */ +export function DashboardSection({ + title, + stats = [], + breakdowns = [], + iconMap = {}, + linkMap = {}, + chartLinkMap = {}, + className = "", +}) { + return ( +
+ {title &&

{title}

} + + {stats.length > 0 && ( + + )} + + {breakdowns.length > 0 && ( +
1 ? "sm:grid-cols-2" : "grid-cols-1"}`}> + {breakdowns.map((b) => + b.chartType === "bar" ? ( + + ) : ( + + ) + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/generic/Dashboard/PieBreakdown.jsx b/src/components/generic/Dashboard/PieBreakdown.jsx new file mode 100644 index 0000000..64422d4 --- /dev/null +++ b/src/components/generic/Dashboard/PieBreakdown.jsx @@ -0,0 +1,69 @@ +// components/generic/Dashboard/PieBreakdown.jsx + +import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from "recharts"; + +const DEFAULT_COLORS = [ + "#6366f1", "#22c55e", "#f59e0b", "#ef4444", + "#06b6d4", "#a855f7", "#ec4899", "#84cc16", +]; + +/** + * A pie chart card for small-cardinality breakdowns. + * + * @param {Object} props + * @param {string} props.label Card title + * @param {Array} props.data [{ label, value }] + * @param {Function} [props.onSliceClick] (entry) => void — called with the clicked slice + * @param {string[]} [props.colors] + * @param {number} [props.height] Default: 220 + * @param {string} [props.className] + * + * @example + * navigate(`/admin/users/all?acc_type=${entry.label}`)} + * /> + */ +export function PieBreakdown({ + label, + data = [], + onSliceClick, + colors = DEFAULT_COLORS, + height = 220, + className = "", +}) { + if (!data.length) return null; + + const isClickable = typeof onSliceClick === "function"; + + return ( +
+

{label}

+ + + + `${l} ${(percent * 100).toFixed(0)}%` + } + labelLine={false} + onClick={isClickable ? (entry) => onSliceClick(entry) : undefined} + cursor={isClickable ? "pointer" : undefined} + > + {data.map((_, i) => ( + + ))} + + [v, n]} /> + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/generic/Dashboard/StatCard.jsx b/src/components/generic/Dashboard/StatCard.jsx new file mode 100644 index 0000000..78bed3d --- /dev/null +++ b/src/components/generic/Dashboard/StatCard.jsx @@ -0,0 +1,49 @@ +// components/generic/Dashboard/StatCard.jsx + +/** + * A single metric card. Optionally clickable for navigation. + * + * @param {Object} props + * @param {string} props.label Display label + * @param {number|string} props.value Metric value + * @param {ReactNode} [props.icon] Optional icon + * @param {Function} [props.onClick] If provided, card becomes clickable + * @param {string} [props.className] Extra classes on the card + * + * @example + * } + * onClick={() => navigate('/admin/users/all')} + * /> + */ +export function StatCard({ label, value, icon, onClick, className = "" }) { + const isClickable = typeof onClick === "function"; + + return ( +
e.key === "Enter" && onClick() : undefined} + className={` + bg-card border rounded-xl px-4 py-4 flex items-center gap-4 + ${isClickable ? "cursor-pointer hover:border-foreground/30 hover:bg-accent transition-colors" : ""} + ${className} + `} + > + {icon && ( +
+ {icon} +
+ )} +
+

+ {label} +

+

{value ?? 0}

+
+
+ ); +} \ No newline at end of file diff --git a/src/components/generic/Dashboard/StatGrid.jsx b/src/components/generic/Dashboard/StatGrid.jsx new file mode 100644 index 0000000..c411058 --- /dev/null +++ b/src/components/generic/Dashboard/StatGrid.jsx @@ -0,0 +1,36 @@ +// components/generic/Dashboard/StatGrid.jsx + +import { StatCard } from "./StatCard"; + +/** + * Renders a responsive grid of StatCards from a stats array. + * + * @param {Object} props + * @param {Array} props.stats [{ key, label, value, icon? }] + * @param {Object} [props.iconMap] { [key]: } + * @param {Object} [props.linkMap] { [key]: () => void } — maps stat key → navigate callback + * @param {string} [props.className] + * + * @example + * const linkMap = { + * total: () => navigate('/admin/users/all'), + * active: () => navigate('/admin/users/all'), + * archived: () => navigate('/admin/users/all/archived'), + * }; + * + */ +export function StatGrid({ stats = [], iconMap = {}, linkMap = {}, className = "" }) { + return ( +
+ {stats.map((s) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/generic/Dashboard/TableDashboard.jsx b/src/components/generic/Dashboard/TableDashboard.jsx new file mode 100644 index 0000000..90f96dd --- /dev/null +++ b/src/components/generic/Dashboard/TableDashboard.jsx @@ -0,0 +1,140 @@ +// components/generic/Dashboard/TableDashboard.jsx + +import { StatCard } from "./StatCard"; +import { PieBreakdown } from "./PieBreakdown"; +import { BarBreakdown } from "./BarBreakdown"; + +/** + * A dashboard strip rendered above a DataTable. + * Clicking any stat card or chart segment applies a filter to the table + * via tableRefsRef.current.setFilters([{ id, value }]). + * + * Clicking the same card/segment again clears that filter (toggle). + * + * @param {Object} props + * @param {Array} props.stats + * [{ key, label, value, icon?, filterId?, filterValue? }] + * - filterId: column id to filter on (e.g. "is_active") + * - filterValue: value array to apply (e.g. ["true"]) + * + * @param {Array} props.breakdowns + * [{ key, label, chartType, filterId?, data: [{ label, value }] }] + * - filterId: column id for the breakdown (e.g. "acc_type") + * - Each data entry's `label` becomes the filterValue when clicked + * + * @param {Object} props.iconMap { [statKey]: } + * @param {Array} props.activeFilters filtersRef.current — to highlight active items + * @param {Object} props.tableRefsRef ref with { setFilters, getFilters } + * @param {string} [props.className] + * + * @example — Users table + * + */ +export function TableDashboard({ + stats = [], + breakdowns = [], + statMap = {}, + tableRefsRef, // ← remove activeFilters prop entirely + className = "", +}) { + // ─── Always read live from ref ──────────────────────────────────────────── + function getActiveFilters() { + return tableRefsRef?.current?.getFilters?.() ?? []; + } + + function isFilterActive(filterId, filterValue) { + if (!filterId) return false; + const existing = getActiveFilters().find((f) => f.id === filterId); + if (!existing) return false; + if (!filterValue) return true; + return filterValue.every((v) => existing.value?.includes(v)); + } + + function toggleFilter(filterId, filterValue) { + if (!filterId || !tableRefsRef?.current?.setFilters) return; + + const managedIds = new Set([ + ...stats.map((s) => s.filterId), + ...breakdowns.map((b) => b.filterId), + ].filter(Boolean)); + + const current = getActiveFilters(); + const existing = current.find((f) => f.id === filterId); + const isActive = existing && + (!filterValue || filterValue.every((v) => existing.value?.includes(v))); + + const unrelated = current.filter((f) => !managedIds.has(f.id)); + + if (isActive) { + tableRefsRef.current.setFilters(unrelated); + } else { + tableRefsRef.current.setFilters([ + ...unrelated, + { id: filterId, value: filterValue ?? [] }, + ]); + } + } + + function handleStatClick(stat) { + if (!stat.filterId) return; + toggleFilter(stat.filterId, stat.filterValue); + } + + function handleChartClick(breakdown, entry) { + if (!breakdown.filterId) return; + toggleFilter(breakdown.filterId, [String(entry.label)]); + } + + if (!stats.length && !breakdowns.length) return null; + + return ( +
+ {stats.length > 0 && ( +
+ {stats.map((s) => { + const active = isFilterActive(s.filterId, s.filterValue); + const mapping = statMap[s.key] ?? {}; + return ( + handleStatClick(s) : undefined} + className={active ? "ring-2 ring-primary border-primary" : ""} + /> + ); + })} +
+ )} + + {breakdowns.length > 0 && ( +
1 ? "sm:grid-cols-2" : "grid-cols-1"}`}> + {breakdowns.map((b) => + b.chartType === "bar" ? ( + handleChartClick(b, entry) : undefined} + /> + ) : ( + handleChartClick(b, entry) : undefined} + /> + ) + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/generic/Sheet/AddSheet.jsx b/src/components/generic/Sheet/AddSheet.jsx new file mode 100644 index 0000000..cd6264c --- /dev/null +++ b/src/components/generic/Sheet/AddSheet.jsx @@ -0,0 +1,216 @@ +// components/generic/Sheet/AddUsersSheet.jsx + +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"; + +/** + * Generic sheet for selecting and adding users to any entity + * (groups, tasks, projects, etc.) + * + * @param {Object} props + * @param {boolean} props.open + * @param {Function} props.onOpenChange + * + * @param {string} [props.title] Sheet heading. Default: "Add members" + * @param {string} [props.submitLabel] Submit button label. Default: "Add" + * + * @param {Array} props.users [{ [idKey], [labelKey] }] — list to display + * @param {boolean} props.loading + * @param {Function} props.onFetch Called on open to (re)load the list + * + * @param {string} [props.idKey] Key for the user id. Default: "user_id" + * @param {string} [props.labelKey] Key for the display name. Default: "full_name" + * @param {string} [props.subLabelKey] Optional secondary line (e.g. "email") + * + * @param {Function} props.onSubmit Called with selected ids[] + * + * @example — groups + * fetchUsersNotInGroup(gid)} + * onSubmit={(ids) => addUsersToGroup(gid, ids)} + * ... + * /> + * + * @example — tasks + * fetchUnassignedUsers(taskId)} + * onSubmit={(ids) => assignUsersToTask(taskId, ids)} + * idKey="user_id" + * labelKey="full_name" + * subLabelKey="email" + * ... + * /> + */ +export function AddSheet({ + open, + onOpenChange, + + title = "Add members", + submitLabel = "Add", + + users = [], + loading = false, + onFetch, + + idKey = "user_id", + labelKey = "full_name", + subLabelKey = null, + + onSubmit, +}) { + const [search, setSearch] = useState(""); + const [selected, setSelected] = useState([]); + + useEffect(() => { + if (open) { + onFetch?.(); + setSearch(""); + setSelected([]); + } + }, [open]); + + const filtered = useMemo(() => { + if (!search.trim()) return users; + return users.filter((u) => + String(u[labelKey] ?? "").toLowerCase().includes(search.toLowerCase()) + ); + }, [search, users, labelKey]); + + const toggle = (id) => + setSelected((prev) => + prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id] + ); + + const toggleAll = () => { + const allIds = filtered.map((u) => u[idKey]); + const allSelected = allIds.every((id) => selected.includes(id)); + setSelected((prev) => + allSelected + ? prev.filter((id) => !allIds.includes(id)) + : [...new Set([...prev, ...allIds])] + ); + }; + + const allFilteredSelected = + filtered.length > 0 && filtered.every((u) => selected.includes(u[idKey])); + + async function handleSubmit() { + if (!selected.length) return; + await onSubmit(selected); + onOpenChange(false); + } + + function handleClose() { + setSearch(""); + setSelected([]); + onOpenChange(false); + } + + return ( + + {/* + SheetContent is a flex column with fixed height (100dvh). + We split it into 3 rows: header (shrink-0), body (flex-1 overflow-hidden), footer (shrink-0). + The body itself is a flex column — search and select-all shrink, list overflows. + */} + + + {/* Header */} + + {title} + + + {/* Body — fills remaining space, clips overflow */} +
+ {loading && ( +
+ +
+ )} + + {/* Search — fixed height */} +
+ setSearch(e.target.value)} + /> +
+ + {/* Select all — fixed height */} + {filtered.length > 0 && ( + + )} + + {/* Scrollable list — takes all remaining space */} +
+
+ {!loading && filtered.length === 0 ? ( +

+ {search ? `No results for "${search}".` : "No users available."} +

+ ) : ( + filtered.map((user) => { + const id = user[idKey]; + const label = user[labelKey]; + const sub = subLabelKey ? user[subLabelKey] : null; + + return ( + + ); + }) + )} +
+
+
+ + {/* Footer */} +
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/generic/Table/DataTable.jsx b/src/components/generic/Table/DataTable.jsx index 109dd27..24758f8 100644 --- a/src/components/generic/Table/DataTable.jsx +++ b/src/components/generic/Table/DataTable.jsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef, useEffect } from "react"; +import { useState, useMemo, useRef, useEffect, useCallback } 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"; @@ -35,31 +35,62 @@ export default function DataTable({ }) { // ─── Refs — always hold latest filters and sort ─────────────────────────── const filtersRef = useRef([]); - const sortRef = 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({ + 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 ───────────────────────────────────────── + // ─── setFilters — called externally by dashboard charts ────────────────── + // Merges incoming filters with existing ones (replaces by id, appends new). + // Pass an empty array [] to clear all filters. + const setFilters = useCallback((incomingFilters) => { + setColumnFilters((prev) => { + let next; + + if (!incomingFilters.length) { + next = []; + } else { + // Replace matching ids, keep the rest + const incomingIds = new Set(incomingFilters.map((f) => f.id)); + const kept = prev.filter((f) => !incomingIds.has(f.id)); + next = [...kept, ...incomingFilters]; + } + + const newFilters = next.filter((f) => f.value !== ""); + filtersRef.current = newFilters; + + onFetch({ + page: 1, + limit: pagination.limit, + filters: newFilters, + sort: sortRef.current, + }); + + return next; + }); + }, [onFetch, pagination.limit]); + + // ─── Expose refs to parent ──────────────────────────────────────────────── useEffect(() => { onRefsReady?.({ - getFilters: () => filtersRef.current, - getSort: () => sortRef.current, - resetSelection: () => table.resetRowSelection(), // ← expose this + getFilters: () => filtersRef.current, + getSort: () => sortRef.current, + resetSelection: () => table.resetRowSelection(), + setFilters, // ← new }); onFetch({ page: 1, limit: pagination.limit, filters: [], sort: [] }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [setFilters]); const handleOpenFilterSheet = async (e, column, attr) => { e.preventDefault(); @@ -79,61 +110,60 @@ export default function DataTable({ rowSelection, pagination: { pageIndex: pagination.page - 1, - pageSize: pagination.limit, + pageSize: pagination.limit, }, }, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - manualPagination: true, - manualSorting: true, - manualFiltering: true, - pageCount: pagination.totalPages, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + manualPagination: true, + manualSorting: true, + manualFiltering: true, + pageCount: pagination.totalPages, - // ─── Sort change ──────────────────────────────────────────────────────── + // ─── Sort change ────────────────────────────────────────────────────── onSortingChange: (updater) => { - const next = typeof updater === "function" ? updater(sorting) : 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, + page: 1, + limit: pagination.limit, filters: filtersRef.current, - sort: newSort, + sort: newSort, }); }, - // ─── Filter change ────────────────────────────────────────────────────── + // ─── Filter change ──────────────────────────────────────────────────── onColumnFiltersChange: (updater) => { - const next = typeof updater === "function" ? updater(columnFilters) : 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, + page: 1, + limit: pagination.limit, filters: newFilters, - sort: sortRef.current, + sort: sortRef.current, }); }, onColumnVisibilityChange: setColumnVisibility, - - getCoreRowModel: getCoreRowModel(), + getCoreRowModel: getCoreRowModel(), }); - // ─── Single handler for all page changes ────────────────────────────────────── + // ─── Page change ────────────────────────────────────────────────────────── const handlePageChange = (page) => { setPagination((p) => ({ ...p, page })); onFetch({ page, - limit: pagination.limit, + limit: pagination.limit, filters: filtersRef.current, - sort: sortRef.current, + sort: sortRef.current, }); }; @@ -169,7 +199,7 @@ export default function DataTable({ onFetch({ page: 1, limit: pagination.limit, filters: [], sort: sortRef.current }); }} onRemove={(id) => { - const next = columnFilters.filter((c) => c.id !== id); + const next = columnFilters.filter((c) => c.id !== id); const newFilters = next.filter((f) => f.value !== ""); setColumnFilters(next); filtersRef.current = newFilters; @@ -187,7 +217,7 @@ export default function DataTable({ filters={activeFilters} attributes={attributes} onRemove={(id) => { - const next = columnFilters.filter((c) => c.id !== id); + const next = columnFilters.filter((c) => c.id !== id); const newFilters = next.filter((f) => f.value !== ""); setColumnFilters(next); filtersRef.current = newFilters; @@ -204,8 +234,8 @@ export default function DataTable({ {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"; + const attr = header.column.columnDef.meta?.attr; + const isPlain = header.column.id === "select" || header.column.id === "actions"; return ( {isPlain ? ( @@ -238,11 +268,11 @@ export default function DataTable({ {renderFilterSheet?.({ - open: filterState.open, + open: filterState.open, onOpenChange: (v) => setFilterState((p) => ({ ...p, open: v })), - column: filterState.column, - attr: filterState.attr, - data: filterState.data, + column: filterState.column, + attr: filterState.attr, + data: filterState.data, loading, })} @@ -271,9 +301,9 @@ export default function DataTable({ 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, + right: isPinned === "right" ? 0 : undefined, + left: isPinned === "left" ? cell.column.getStart("left") : undefined, + zIndex: isPinned ? 1 : 0, }} > {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -298,16 +328,16 @@ export default function DataTable({ { - setPagination((p) => ({ ...p, limit: size, page: 1 })); // ← updates your state + setPagination((p) => ({ ...p, limit: size, page: 1 })); onFetch({ - page: 1, - limit: size, + page: 1, + limit: size, filters: filtersRef.current, - sort: sortRef.current, + sort: sortRef.current, }); }} pageSizeOptions={pageSizeOptions} diff --git a/src/contexts/AdminDashboardContext.jsx b/src/contexts/AdminDashboardContext.jsx new file mode 100644 index 0000000..4f8b72a --- /dev/null +++ b/src/contexts/AdminDashboardContext.jsx @@ -0,0 +1,64 @@ +import { createContext, useCallback, useContext, useState } from "react"; +import api from "@/utils/api.util"; +import { toast } from "sonner"; + +const AdminDashboardContext = createContext(null); + +export function useDashboard() { + const ctx = useContext(AdminDashboardContext); + if (!ctx) throw new Error("useDashboard must be used within a DashboardProvider"); + return ctx; +} + +export function AdminDashboardProvider({ children }) { + const [usersDashboard, setUsersDashboard] = useState(null); + const [groupsDashboard, setGroupsDashboard] = useState(null); + const [loading, setLoading] = useState(false); + + const request = useCallback(async (fn) => { + setLoading(true); + try { + return await fn(); + } catch (err) { + const message = err?.response?.data?.message ?? "Something went wrong."; + toast.error(message); + return null; + } finally { + setLoading(false); + } + }, []); + + // ─── GET /api/admin/dashboard/users ─────────────────────────────────────── + const fetchUsersDashboard = useCallback( + () => + request(async () => { + const res = await api.get("/admin/dashboard/users"); + setUsersDashboard(res.data?.data ?? null); + return res.data; + }), + [request] + ); + + // ─── GET /api/admin/dashboard/groups ────────────────────────────────────── + const fetchGroupsDashboard = useCallback( + () => + request(async () => { + const res = await api.get("/admin/dashboard/groups"); + setGroupsDashboard(res.data?.data ?? null); + return res.data; + }), + [request] + ); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/contexts/provider/AdminProvider.jsx b/src/contexts/provider/AdminProvider.jsx index ee71e59..bc8b21e 100644 --- a/src/contexts/provider/AdminProvider.jsx +++ b/src/contexts/provider/AdminProvider.jsx @@ -1,13 +1,16 @@ // ─── AdminProvider.jsx ───────────────────────────────────────────────────────── +import { AdminDashboardProvider } from "../AdminDashboardContext" import { UserProvider } from "../AdminUserContext"; import { UserGroupProvider } from "../AdminUserGroupContext"; export const AdminProvider = ({ children }) => { return ( - - - {children} - - + + + + {children} + + + ); }; \ No newline at end of file diff --git a/src/data/adminDashboard.data.jsx b/src/data/adminDashboard.data.jsx new file mode 100644 index 0000000..640ff01 --- /dev/null +++ b/src/data/adminDashboard.data.jsx @@ -0,0 +1,30 @@ +// modules/admin/data/dashboard.data.jsx +// +// Centralizes all icon maps, link maps, and chart link maps for the +// admin dashboard. To add a new section (e.g. Tasks), just add a new +// export block here and import it in UsersDashboard.jsx. + +import { + Users, UserCheck, UserMinus, ShieldCheck, + Archive, FolderOpen, Layers, +} from "lucide-react"; + +// ─── Users ─────────────────────────────────────────────────────────────────── + +export const USER_STAT_MAP = { + total: { label: "Total Users", icon: }, + active: { label: "Active", icon: }, + inactive: { label: "Inactive", icon: }, + verified: { label: "Verified", icon: }, + archived: { label: "Archived", icon: }, +}; + +// ─── User Groups ────────────────────────────────────────────────────────────── + +export const GROUP_STAT_MAP = { + total: { label: "Total Groups", icon: }, + active: { label: "Active", icon: }, + inactive: { label: "Inactive", icon: }, + archived: { label: "Archived", icon: }, + empty: { label: "Empty Groups", icon: }, +}; \ No newline at end of file diff --git a/src/modules/admin/components/user_groups/AddGroupDialog.jsx b/src/modules/admin/components/user_groups/AddGroupDialog.jsx new file mode 100644 index 0000000..d150e1b --- /dev/null +++ b/src/modules/admin/components/user_groups/AddGroupDialog.jsx @@ -0,0 +1,110 @@ +// modules/admin/components/user_groups/AddGroupDialog.jsx + +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +const schema = z.object({ + name: z.string().min(1, "Name is required."), + description: z.string().min(1, "Description is required."), +}); + +/** + * Dialog for creating a new group. + * Matches context: createGroup({ name, description }) + * + * @param {Object} props + * @param {boolean} props.open + * @param {Function} props.onOpenChange + * @param {Function} props.onSubmit Called with { name, description } + * @param {boolean} [props.loading] + */ +export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: "", description: "" }, + }); + + async function onValid(values) { + await onSubmit(values); + reset(); + } + + function handleClose() { + reset(); + onOpenChange(false); + } + + return ( + + + + Add group + + +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ +