From ca6eae1d561e2fda37a5a30e5ff5b6a9c585d6b7 Mon Sep 17 00:00:00 2001 From: rgrgogu Date: Thu, 7 May 2026 23:35:31 +0800 Subject: [PATCH] Adjusted --- .../generic/Dialogs/ArchiveDialog.jsx | 83 +++ .../generic/Dialogs/RestoreDialog.jsx | 83 +++ src/contexts/AdminUserContext.jsx | 11 + src/contexts/AdminUserGroupContext.jsx | 310 +++++++++++ src/contexts/provider/AdminProvider.jsx | 11 +- .../admin/components/ArchiveUserDialog.jsx | 68 --- .../admin/components/RestoreUserDialog.jsx | 67 --- .../user_groups/ArchiveGroupTable.jsx | 121 +++++ .../components/user_groups/GroupTable.jsx | 121 +++++ .../{ => users}/ArchiveUserTable.jsx | 48 +- .../components/{ => users}/UserTable.jsx | 35 +- .../user_groups/archive/columns.config.jsx | 45 ++ .../archive/rowActions.config.jsx | 0 .../user_groups/archive/selection.config.jsx | 34 ++ .../user_groups/archive/toolbar.config.jsx | 29 ++ .../config/user_groups/columns.config.jsx | 45 ++ .../{ => user_groups}/rowActions.config.jsx | 0 .../config/user_groups/selection.config.jsx | 34 ++ .../config/user_groups/toolbar.config.jsx | 47 ++ .../{ => users}/archive/columns.config.jsx | 0 .../users/archive/rowActions.config.jsx | 25 + .../{ => users}/archive/selection.config.jsx | 0 .../{ => users}/archive/toolbar.config.jsx | 0 .../config/{ => users}/columns.config.jsx | 2 +- .../admin/config/users/rowActions.config.jsx | 39 ++ .../config/{ => users}/selection.config.jsx | 0 .../config/{ => users}/toolbar.config.jsx | 8 +- .../pages/user_groups/ArchivedGroupList.jsx | 26 + .../admin/pages/user_groups/GroupList.jsx | 30 +- .../admin/pages/users/AddStaffUser.jsx | 484 ++++++++++++++++++ src/modules/admin/pages/users/AddUser.jsx | 11 - .../admin/pages/users/ArchivedUserList.jsx | 2 +- src/modules/admin/pages/users/UserList.jsx | 2 +- src/modules/admin/routes/AdminRoutes.jsx | 8 +- src/utils/table.util.jsx | 15 +- 35 files changed, 1637 insertions(+), 207 deletions(-) create mode 100644 src/components/generic/Dialogs/ArchiveDialog.jsx create mode 100644 src/components/generic/Dialogs/RestoreDialog.jsx create mode 100644 src/contexts/AdminUserGroupContext.jsx delete mode 100644 src/modules/admin/components/ArchiveUserDialog.jsx delete mode 100644 src/modules/admin/components/RestoreUserDialog.jsx create mode 100644 src/modules/admin/components/user_groups/ArchiveGroupTable.jsx create mode 100644 src/modules/admin/components/user_groups/GroupTable.jsx rename src/modules/admin/components/{ => users}/ArchiveUserTable.jsx (70%) rename src/modules/admin/components/{ => users}/UserTable.jsx (76%) create mode 100644 src/modules/admin/config/user_groups/archive/columns.config.jsx rename src/modules/admin/config/{ => user_groups}/archive/rowActions.config.jsx (100%) create mode 100644 src/modules/admin/config/user_groups/archive/selection.config.jsx create mode 100644 src/modules/admin/config/user_groups/archive/toolbar.config.jsx create mode 100644 src/modules/admin/config/user_groups/columns.config.jsx rename src/modules/admin/config/{ => user_groups}/rowActions.config.jsx (100%) create mode 100644 src/modules/admin/config/user_groups/selection.config.jsx create mode 100644 src/modules/admin/config/user_groups/toolbar.config.jsx rename src/modules/admin/config/{ => users}/archive/columns.config.jsx (100%) create mode 100644 src/modules/admin/config/users/archive/rowActions.config.jsx rename src/modules/admin/config/{ => users}/archive/selection.config.jsx (100%) rename src/modules/admin/config/{ => users}/archive/toolbar.config.jsx (100%) rename src/modules/admin/config/{ => users}/columns.config.jsx (93%) create mode 100644 src/modules/admin/config/users/rowActions.config.jsx rename src/modules/admin/config/{ => users}/selection.config.jsx (100%) rename src/modules/admin/config/{ => users}/toolbar.config.jsx (86%) create mode 100644 src/modules/admin/pages/user_groups/ArchivedGroupList.jsx create mode 100644 src/modules/admin/pages/users/AddStaffUser.jsx delete mode 100644 src/modules/admin/pages/users/AddUser.jsx diff --git a/src/components/generic/Dialogs/ArchiveDialog.jsx b/src/components/generic/Dialogs/ArchiveDialog.jsx new file mode 100644 index 0000000..ef30b38 --- /dev/null +++ b/src/components/generic/Dialogs/ArchiveDialog.jsx @@ -0,0 +1,83 @@ +// ─── components/ArchiveDialog.jsx ──────────────────────────────────────────── +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Spinner } from "@/components/ui/spinner"; + +/** + * Generic archive dialog — works for any entity (users, groups, etc.) + * + * Single: r.name} ... /> + * Bulk: + * + * @param {Function} onArchive (id | { ids }) => Promise — called with single id or { ids } + * @param {Function} getName (entity) => string — how to display the entity name + * @param {string} entityLabel e.g. "User", "Group" + * @param {boolean} loading from whichever context the parent uses + */ +export function ArchiveDialog({ + open, + onOpenChange, + entity, + ids, + entityLabel = "Item", + getName, + onArchive, + loading, + onSuccess, +}) { + const isBulk = Array.isArray(ids) && ids.length > 0; + const count = isBulk ? ids.length : 1; + + const displayName = isBulk + ? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}` + : (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item"); + + const handleArchive = async () => { + const res = isBulk + ? await onArchive({ ids }) + : await onArchive(entity); + + if (res) { + onOpenChange(false); + onSuccess?.(); + } + }; + + return ( + + + + + Archive {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel} + + + Are you sure you want to archive{" "} + {displayName}?{" "} + {isBulk + ? "They will be deactivated and lose access immediately." + : "This will deactivate the record immediately."} + + + + Cancel + + {loading && } + Archive{isBulk ? ` ${count} ${entityLabel}${count !== 1 ? "s" : ""}` : ""} + + + + + ); +} \ No newline at end of file diff --git a/src/components/generic/Dialogs/RestoreDialog.jsx b/src/components/generic/Dialogs/RestoreDialog.jsx new file mode 100644 index 0000000..bdfc28e --- /dev/null +++ b/src/components/generic/Dialogs/RestoreDialog.jsx @@ -0,0 +1,83 @@ +// ─── components/RestoreDialog.jsx ──────────────────────────────────────────── +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Spinner } from "@/components/ui/spinner"; + +/** + * Generic restore dialog — works for any entity (users, groups, etc.) + * + * Single: r.name} ... /> + * Bulk: + * + * @param {Function} onRestore (id | { ids }) => Promise — called with single id or { ids } + * @param {Function} getName (entity) => string — how to display the entity name + * @param {string} entityLabel e.g. "User", "Group" + * @param {boolean} loading from whichever context the parent uses + */ +export function RestoreDialog({ + open, + onOpenChange, + entity, + ids, + entityLabel = "Item", + getName, + onRestore, + loading, + onSuccess, +}) { + const isBulk = Array.isArray(ids) && ids.length > 0; + const count = isBulk ? ids.length : 1; + + const displayName = isBulk + ? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}` + : (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item"); + + const handleRestore = async () => { + const res = isBulk + ? await onRestore({ ids }) + : await onRestore(entity); + + if (res) { + onOpenChange(false); + onSuccess?.(); + } + }; + + return ( + + + + + Restore {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel} + + + Are you sure you want to restore{" "} + {displayName}?{" "} + {isBulk + ? "They will regain access to their accounts immediately." + : "This will reactivate the record immediately."} + + + + Cancel + + {loading && } + Restore{isBulk ? ` ${count} ${entityLabel}${count !== 1 ? "s" : ""}` : ""} + + + + + ); +} \ No newline at end of file diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx index e0c2f7b..c17d648 100644 --- a/src/contexts/AdminUserContext.jsx +++ b/src/contexts/AdminUserContext.jsx @@ -81,6 +81,16 @@ export const UserProvider = ({ children }) => { [request] ); + // ─── POST /api/admin/users/staff ────────────────────────────────────────────── + const addStaffUser = useCallback( + (payload) => + request(async () => { + const res = await api.post(`${BASE}/users/staff`, payload); + return res.data; + }), + [request] + ); + // ─── PUT /api/admin/users/:id ────────────────────────────────────────────── const updateUser = useCallback( (userId, payload) => @@ -252,6 +262,7 @@ export const UserProvider = ({ children }) => { fetchUsers, fetchArchivedUsers, fetchUser, + addStaffUser, updateUser, deactivateUser, deactivateUsers, diff --git a/src/contexts/AdminUserGroupContext.jsx b/src/contexts/AdminUserGroupContext.jsx new file mode 100644 index 0000000..cd82e41 --- /dev/null +++ b/src/contexts/AdminUserGroupContext.jsx @@ -0,0 +1,310 @@ +/*********************************************************************************************************************************************************************** + * File Name: AdminUserGroupContext.jsx + * Type of Program: Context + * Description: Admin-level user group management context. + * Covers: list groups, get single group + members, create, update, + * deactivate, restore, add/remove members. + * + * Author: rgrgogu + ***********************************************************************************************************************************************************************/ +import { createContext, useCallback, useContext, useState } from "react"; +import api from "@/utils/api.util"; +import { toast } from "sonner"; + +const BASE = "/admin"; + +// ─── Context ────────────────────────────────────────────────────────────────── +const UserGroupContext = createContext(null); + +export function useUserGroups() { + const ctx = useContext(UserGroupContext); + if (!ctx) throw new Error("useUserGroups must be used within a UserGroupProvider"); + return ctx; +} + +// ─── Provider ───────────────────────────────────────────────────────────────── +export function UserGroupProvider({ children }) { + const [groups, setGroups] = useState([]); + const [group, setGroup] = useState(null); // single group + its members + const [members, setMembers] = useState([]); // members inside current group + const [usersIn, setUsersIn] = useState([]); // users already in group (for remove) + const [usersNotIn, setUsersNotIn] = useState([]); // users not in group (for add) + const [attributes, setAttributes] = useState([]); + const [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0, totalPages: 1 }); + const [loading, setLoading] = useState(false); + + // ─── Generic request wrapper ──────────────────────────────────────────────── + 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/groups ───────────────────────────────────────────────── + const fetchGroups = useCallback( + ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => + request(async () => { + const res = await api.get(`${BASE}/groups`, { + params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) }, + }); + + const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; + + setGroups(data ?? []); + setAttributes(attrs ?? []); + setPagination(pg ?? { page, limit, total: 0, totalPages: 1 }); + + return res.data; + }), + [request] + ); + + // ─── GET /api/admin/groups/:gid ──────────────────────────────────────────── + const fetchGroup = useCallback( + (gid, paginationParams = {}) => + request(async () => { + const { page = 1, limit = 10, filters = [], sort = [] } = paginationParams; + + const res = await api.get(`${BASE}/groups/${gid}`, { + params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) }, + }); + + const { group: g, members: m } = res.data?.data ?? {}; + + setGroup(g ?? null); + setMembers(m?.data ?? []); + setPagination(m?.pagination ?? { page, limit, total: 0, totalPages: 1 }); + + return res.data; + }), + [request] + ); + + // ─── GET /api/admin/groups/:gid/users ────────────────────────────────────── + const fetchUsersInGroup = useCallback( + (gid) => + request(async () => { + const res = await api.get(`${BASE}/groups/${gid}/users`); + setUsersIn(res.data?.data ?? []); + return res.data; + }), + [request] + ); + + // ─── GET /api/admin/groups/:gid/users/add ────────────────────────────────── + const fetchUsersNotInGroup = useCallback( + (gid) => + request(async () => { + const res = await api.get(`${BASE}/groups/${gid}/users/add`); + setUsersNotIn(res.data?.data ?? []); + return res.data; + }), + [request] + ); + + // ─── POST /api/admin/groups ──────────────────────────────────────────────── + const createGroup = useCallback( + ({ name, description }) => + request(async () => { + const res = await api.post(`${BASE}/groups`, { name, description }); + + setGroups((prev) => [res.data?.data, ...prev]); + + toast.success("Group created successfully."); + return res.data; + }), + [request] + ); + + // ─── PUT /api/admin/groups/:gid ──────────────────────────────────────────── + const updateGroup = useCallback( + (gid, { name, description }) => + request(async () => { + const res = await api.put(`${BASE}/groups/${gid}`, { name, description }); + + setGroups((prev) => + prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g)) + ); + + // Update single group view if open + setGroup((prev) => (prev?.group_id === gid ? { ...prev, ...res.data?.data } : prev)); + + toast.success("Group updated successfully."); + return res.data; + }), + [request] + ); + + // ─── PATCH /api/admin/groups/:gid/deactivate ─────────────────────────────── + const deactivateGroup = useCallback( + (gid) => + request(async () => { + const res = await api.patch(`${BASE}/groups/${gid}/deactivate`); + + setGroups((prev) => + prev.map((g) => (g.group_id === gid ? { ...g, is_active: false } : g)) + ); + + toast.success("Group deactivated."); + return res.data; + }), + [request] + ); + + // ─── PATCH /api/admin/groups/:gid/restore ───────────────────────────────── + const restoreGroup = useCallback( + (gid) => + request(async () => { + const res = await api.patch(`${BASE}/groups/${gid}/restore`); + + setGroups((prev) => + prev.map((g) => (g.group_id === gid ? { ...g, is_active: true } : g)) + ); + + toast.success("Group restored."); + return res.data; + }), + [request] + ); + + // ─── POST /api/admin/groups/:gid/users ──────────────────────────────────── + const addUsersToGroup = useCallback( + (gid, user_ids) => + request(async () => { + const res = await api.post(`${BASE}/groups/${gid}/users`, { user_ids }); + + // Remove added users from usersNotIn list + setUsersNotIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); + + toast.success("Users added to group."); + return res.data; + }), + [request] + ); + + // ─── DELETE /api/admin/groups/:gid/users ────────────────────────────────── + const removeUsersFromGroup = useCallback( + (gid, user_ids) => + request(async () => { + const res = await api.delete(`${BASE}/groups/${gid}/users`, { data: { user_ids } }); + + // Remove from members list + setMembers((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); + setUsersIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id))); + + toast.success("Users removed from group."); + return res.data; + }), + [request] + ); + + // ─── DELETE /api/admin/groups/bulk ──────────────────────────────────────────── + const deactivateGroups = useCallback( + ({ ids }) => + request(async () => { + const res = await api.delete(`${BASE}/groups/bulk`, { data: { ids } }); + const { deactivated_ids } = res.data?.data ?? {}; + + if (deactivated_ids?.length) { + setGroups((prev) => + prev.map((g) => + deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g + ) + ); + } + + return res.data; + }), + [request] + ); + + // ─── POST /api/admin/groups/bulk/restore ────────────────────────────────────── + const restoreGroups = useCallback( + ({ ids }) => + request(async () => { + const res = await api.post(`${BASE}/groups/bulk/restore`, { ids }); + const { restored_ids } = res.data?.data ?? {}; + + if (restored_ids?.length) { + setGroups((prev) => + prev.map((g) => + restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g + ) + ); + } + + return res.data; + }), + [request] + ); + + // ─── GET /api/admin/groups/field-values ─────────────────────────────────────── + const fetchGroupFieldValues = useCallback( + (field) => + request(async () => { + const res = await api.get(`${BASE}/groups/field-values`, { params: { field } }); + return res.data; + }), + [request] + ); + + const fetchArchivedGroups = useCallback( + ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => + request(async () => { + const res = await api.get(`${BASE}/groups/archived`, { + params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) }, + }); + + const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {}; + + setGroups(data ?? []); + setAttributes(attrs ?? []); + setPagination(pg ?? { page, limit, total: 0, totalPages: 1 }); + + 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 4df721c..ee71e59 100644 --- a/src/contexts/provider/AdminProvider.jsx +++ b/src/contexts/provider/AdminProvider.jsx @@ -1,16 +1,13 @@ // ─── 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"; +import { UserGroupProvider } from "../AdminUserGroupContext"; export const AdminProvider = ({ children }) => { return ( - {/* */} - {/* */} - {children} - {/* */} - {/* */} + + {children} + ); }; \ No newline at end of file diff --git a/src/modules/admin/components/ArchiveUserDialog.jsx b/src/modules/admin/components/ArchiveUserDialog.jsx deleted file mode 100644 index 7512d34..0000000 --- a/src/modules/admin/components/ArchiveUserDialog.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Spinner } from "@/components/ui/spinner"; -import { useUsers } from "@/contexts/AdminUserContext"; - -/** - * Unified archive dialog — works for both single and bulk. - * - * Single: - * Bulk: - */ -export function ArchiveUserDialog({ open, onOpenChange, user, ids, onSuccess }) { - const { deactivateUser, deactivateUsers, loading } = useUsers(); - - const isBulk = Array.isArray(ids) && ids.length > 0; - const count = isBulk ? ids.length : 1; - - const handleArchive = async () => { - const res = isBulk - ? await deactivateUsers({ ids }) - : await deactivateUser(user?.user_id); - - if (res) { - onOpenChange(false); - onSuccess?.(); - } - }; - - return ( - - - - - Archive {isBulk ? `${count} Users` : "User"} - - - Are you sure you want to archive{" "} - - {isBulk - ? `${count} selected user${count !== 1 ? "s" : ""}` - : (user?.personal_info?.name?.full_name ?? user?.email)} - - ? They will be deactivated and lose access immediately. - - - - Cancel - - {loading ? : null} - Archive{isBulk ? ` ${count} User${count !== 1 ? "s" : ""}` : ""} - - - - - ); -} \ No newline at end of file diff --git a/src/modules/admin/components/RestoreUserDialog.jsx b/src/modules/admin/components/RestoreUserDialog.jsx deleted file mode 100644 index f09647f..0000000 --- a/src/modules/admin/components/RestoreUserDialog.jsx +++ /dev/null @@ -1,67 +0,0 @@ -// ─── components/RestoreUserDialog.jsx ──────────────────────────────────────── -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Spinner } from "@/components/ui/spinner"; -import { useUsers } from "@/contexts/AdminUserContext"; - -/** - * Unified restore dialog — works for both single and bulk. - * - * Single: - * Bulk: - */ -export function RestoreUserDialog({ open, onOpenChange, user, ids, onSuccess }) { - const { restoreUser, restoreUsers, loading } = useUsers(); - - const isBulk = Array.isArray(ids) && ids.length > 0; - const count = isBulk ? ids.length : 1; - - const handleRestore = async () => { - const res = isBulk - ? await restoreUsers({ ids }) - : await restoreUser(user?.user_id); - - if (res) { - onOpenChange(false); - onSuccess?.(); - } - }; - - return ( - - - - Restore {isBulk ? `${count} Users` : "User"} - - Are you sure you want to restore{" "} - - {isBulk - ? `${count} selected user${count !== 1 ? "s" : ""}` - : (user?.personal_info?.name?.full_name ?? user?.email)} - - ? They will regain access to their account{isBulk && count !== 1 ? "s" : ""} immediately. - - - - Cancel - - {loading ? : null} - Restore{isBulk ? ` ${count} User${count !== 1 ? "s" : ""}` : ""} - - - - - ); -} \ No newline at end of file diff --git a/src/modules/admin/components/user_groups/ArchiveGroupTable.jsx b/src/modules/admin/components/user_groups/ArchiveGroupTable.jsx new file mode 100644 index 0000000..f898740 --- /dev/null +++ b/src/modules/admin/components/user_groups/ArchiveGroupTable.jsx @@ -0,0 +1,121 @@ +import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useUserGroups } from "@/contexts/AdminUserGroupContext"; +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; + +import { buildDataColumns, columnPinning } from "../../config/user_groups/archive/columns.config"; +import { buildToolbarActions } from "../../config/user_groups/archive/toolbar.config"; +import { buildSelectionActions } from "../../config/user_groups/archive/selection.config"; +import { buildRowActions } from "../../config/user_groups/archive/rowActions.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function ArchiveGroupTable() { + const [restoreTarget, setRestoreTarget] = useState(null); // single: row object + const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids + const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} }); + const navigate = useNavigate(); + + const { + groups, + attributes, + pagination, + setPagination, + loading, + fetchGroupFieldValues, + fetchArchivedGroups, + restoreGroup, + restoreGroups, + } = useUserGroups(); + + const exportConfig = { + allData: groups, + attributes, + filename: `${getTimestamp()}_ArchivedUserGroups`, + sheetName: "ArchivedUserGroups", + }; + + const rowActions = buildRowActions({ + navigate, + onRestore: (row) => setRestoreTarget(row), + }); + + const toolbarActions = buildToolbarActions({ + fetchArchivedGroups, pagination, exportConfig, navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + restoreGroup: (row) => setRestoreTarget(row), // single + restoreGroups: (ids) => setRestoreIds(ids), // bulk + }); + + const columns = useMemo(() => buildDataColumns(attributes, rowActions), [attributes]); + + const handleRestoreSuccess = () => { + setRestoreTarget(null); + setRestoreIds(null); + tableRefsRef.current.resetSelection?.(); + fetchArchivedGroups({ page: 1, limit: pagination.limit }); + }; + + return ( + <> + tableRefsRef.current = refs} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="group" + emptyMessage="No archived groups match the current filters." + /> + + {/* Single restore */} + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Group" + getName={(g) => g?.name} + onRestore={(g) => restoreGroup(g?.group_id)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + {/* Bulk restore */} + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Group" + onRestore={restoreGroups} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/user_groups/GroupTable.jsx b/src/modules/admin/components/user_groups/GroupTable.jsx new file mode 100644 index 0000000..487361b --- /dev/null +++ b/src/modules/admin/components/user_groups/GroupTable.jsx @@ -0,0 +1,121 @@ +import { useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useUserGroups } from "@/contexts/AdminUserGroupContext"; +import DataTable from "@/components/generic/Table/DataTable"; +import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; + +import { buildDataColumns, columnPinning } from "../../config/user_groups/columns.config"; +import { buildToolbarActions } from "../../config/user_groups/toolbar.config"; +import { buildSelectionActions } from "../../config/user_groups/selection.config"; +import { buildRowActions } from "../../config/user_groups/rowActions.config"; + +import { getTimestamp } from "@/utils/timestamp.util"; + +export default function GroupTable() { + const [archiveTarget, setArchiveTarget] = useState(null); // single: row object + const [archiveIds, setArchiveIds] = useState(null); // bulk: array of ids + const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} }); + const navigate = useNavigate(); + + const { + groups, + attributes, + pagination, + setPagination, + loading, + fetchGroups, + fetchGroupFieldValues, + deactivateGroup, + deactivateGroups, + } = useUserGroups(); + + const exportConfig = { + allData: groups, + attributes, + filename: `${getTimestamp()}_UserGroups`, + sheetName: "UserGroups", + }; + + const rowActions = buildRowActions({ + navigate, + onArchive: (row) => setArchiveTarget(row), + }); + + const toolbarActions = buildToolbarActions({ + fetchGroups, pagination, exportConfig, navigate, + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), + }); + + const selectionActions = buildSelectionActions({ + exportConfig, + archiveGroup: (row) => setArchiveTarget(row), // single + archiveGroups: (ids) => setArchiveIds(ids), // bulk + }); + + const columns = useMemo(() => buildDataColumns(attributes, rowActions), [attributes]); + + const handleArchiveSuccess = () => { + setArchiveTarget(null); + setArchiveIds(null); + tableRefsRef.current.resetSelection?.(); + fetchGroups({ page: 1, limit: pagination.limit }); + }; + + return ( + <> + tableRefsRef.current = refs} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="group" + emptyMessage="No user groups match the current filters." + /> + + {/* Single archive */} + !v && setArchiveTarget(null)} + entity={archiveTarget} + entityLabel="Group" + getName={(g) => g?.name} + onArchive={(g) => deactivateGroup(g?.group_id)} + loading={loading} + onSuccess={handleArchiveSuccess} + /> + + {/* Bulk archive */} + !v && setArchiveIds(null)} + ids={archiveIds ?? []} + entityLabel="Group" + onArchive={deactivateGroups} + loading={loading} + onSuccess={handleArchiveSuccess} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/ArchiveUserTable.jsx b/src/modules/admin/components/users/ArchiveUserTable.jsx similarity index 70% rename from src/modules/admin/components/ArchiveUserTable.jsx rename to src/modules/admin/components/users/ArchiveUserTable.jsx index 8aceaff..24a28c6 100644 --- a/src/modules/admin/components/ArchiveUserTable.jsx +++ b/src/modules/admin/components/users/ArchiveUserTable.jsx @@ -3,20 +3,20 @@ import { useNavigate } from "react-router-dom"; import { useUsers } from "@/contexts/AdminUserContext"; import DataTable from "@/components/generic/Table/DataTable"; -import { RestoreUserDialog } from "../components/RestoreUserDialog"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog"; -import { buildUserColumns, columnPinning } from "../config/archive/columns.config"; -import { buildToolbarActions } from "../config/archive/toolbar.config"; -import { buildSelectionActions } from "../config/archive/selection.config"; -import { buildRowActions } from "../config/archive/rowActions.config"; +import { buildUserColumns, columnPinning } from "../../config/users/archive/columns.config"; +import { buildToolbarActions } from "../../config/users/archive/toolbar.config"; +import { buildSelectionActions } from "../../config/users/archive/selection.config"; +import { buildRowActions } from "../../config/users/archive/rowActions.config"; import { getTimestamp } from "@/utils/timestamp.util"; -export default function ArchiveUsersTable() { +export default function ArchiveGroupTable() { const [restoreTarget, setRestoreTarget] = useState(null); // single: row object const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids - const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} }); + const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => { } }); const navigate = useNavigate(); const { @@ -25,19 +25,24 @@ export default function ArchiveUsersTable() { pagination, setPagination, loading, - fetchUserFieldValues, fetchArchivedUsers, + fetchUserFieldValues, + restoreUser, + restoreUsers } = useUsers(); // Shared export config — passed into toolbar + selection configs const exportConfig = { allData: users, attributes, - filename: `${getTimestamp()}_Users`, - sheetName: "ArchivedUsers", + filename: `${getTimestamp()}_ArchivedUserGroups`, + sheetName: "ArchivedUserGroups", }; - const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), }); + const rowActions = buildRowActions({ + navigate, + onRestore: (row) => setRestoreTarget(row), + }); const toolbarActions = buildToolbarActions({ fetchArchivedUsers, pagination, exportConfig, navigate, getFilters: () => tableRefsRef.current.getFilters(), @@ -45,9 +50,10 @@ export default function ArchiveUsersTable() { }); const selectionActions = buildSelectionActions({ exportConfig, - restoreUser: (row) => setRestoreTarget(row), // single — open dialog with row - restoreUsers: (ids) => setRestoreIds(ids), // bulk — open dialog with ids + restoreUser: (row) => setRestoreTarget(row), + restoreUsers: (ids) => setRestoreIds(ids), }); + const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]); const handleRestoreSuccess = () => { @@ -60,7 +66,7 @@ export default function ArchiveUsersTable() { return ( <> + {/* Single restore */} - !v && setRestoreTarget(null)} - user={restoreTarget} + entity={restoreTarget} + entityLabel="User" + getName={(u) => u?.personal_info?.name?.full_name ?? u?.email} + onRestore={(u) => restoreUser(u?.user_id)} + loading={loading} onSuccess={handleRestoreSuccess} /> {/* Bulk restore */} - !v && setRestoreIds(null)} ids={restoreIds ?? []} + entityLabel="User" + onRestore={restoreUsers} + loading={loading} onSuccess={handleRestoreSuccess} /> diff --git a/src/modules/admin/components/UserTable.jsx b/src/modules/admin/components/users/UserTable.jsx similarity index 76% rename from src/modules/admin/components/UserTable.jsx rename to src/modules/admin/components/users/UserTable.jsx index 05f59a8..1620bb3 100644 --- a/src/modules/admin/components/UserTable.jsx +++ b/src/modules/admin/components/users/UserTable.jsx @@ -3,20 +3,20 @@ import { useNavigate } from "react-router-dom"; import { useUsers } from "@/contexts/AdminUserContext"; import DataTable from "@/components/generic/Table/DataTable"; -import { ArchiveUserDialog } from "./ArchiveUserDialog"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; +import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; -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 { buildDataColumns, columnPinning } from "../../config/users/columns.config"; +import { buildToolbarActions } from "../../config/users/toolbar.config"; +import { buildSelectionActions } from "../../config/users/selection.config"; +import { buildRowActions } from "../../config/users/rowActions.config"; import { getTimestamp } from "@/utils/timestamp.util"; export default function UsersTable() { const [archiveTarget, setArchiveTarget] = useState(null); // single: row object const [archiveIds, setArchiveIds] = useState(null); // bulk: array of ids - const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {} }); + const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => { } }); const navigate = useNavigate(); const { @@ -27,6 +27,8 @@ export default function UsersTable() { loading, fetchUsers, fetchUserFieldValues, + deactivateUser, + deactivateUsers, } = useUsers(); // Shared export config — passed into toolbar + selection configs @@ -48,7 +50,7 @@ export default function UsersTable() { archiveUser: (row) => setArchiveTarget(row), // single archiveUsers: (ids) => setArchiveIds(ids), // bulk }); - const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]); + const columns = useMemo(() => buildDataColumns(attributes, rowActions), [attributes]); const handleArchiveSuccess = () => { setArchiveTarget(null); @@ -86,19 +88,26 @@ export default function UsersTable() { recordLabel="user" emptyMessage="No users match the current filters." /> - {/* Single archive */} - !v && setArchiveTarget(null)} - user={archiveTarget} + entity={archiveTarget} + entityLabel="User" + getName={(u) => u?.personal_info?.name?.full_name ?? u?.email} + onArchive={(u) => deactivateUser(u?.user_id)} + loading={loading} onSuccess={handleArchiveSuccess} /> - - {/* Bulk archive */} - !v && setArchiveIds(null)} ids={archiveIds ?? []} + entityLabel="User" + onArchive={deactivateUsers} + loading={loading} onSuccess={handleArchiveSuccess} /> diff --git a/src/modules/admin/config/user_groups/archive/columns.config.jsx b/src/modules/admin/config/user_groups/archive/columns.config.jsx new file mode 100644 index 0000000..cc113da --- /dev/null +++ b/src/modules/admin/config/user_groups/archive/columns.config.jsx @@ -0,0 +1,45 @@ +// 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"; +import { Badge } from "@/components/ui/badge"; +import { Users } from "lucide-react"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +// ─── Custom cell overrides ──────────────────────────────────────────────────── +const cellOverrides = { + memberCount: (info) => { + const count = parseInt(info.getValue() ?? 0, 10); + return ( +
+ + + {count} {count === 1 ? "member" : "members"} + +
+ ); + }, +}; + +/** + * 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 buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Group Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/archive/rowActions.config.jsx b/src/modules/admin/config/user_groups/archive/rowActions.config.jsx similarity index 100% rename from src/modules/admin/config/archive/rowActions.config.jsx rename to src/modules/admin/config/user_groups/archive/rowActions.config.jsx diff --git a/src/modules/admin/config/user_groups/archive/selection.config.jsx b/src/modules/admin/config/user_groups/archive/selection.config.jsx new file mode 100644 index 0000000..06336b6 --- /dev/null +++ b/src/modules/admin/config/user_groups/archive/selection.config.jsx @@ -0,0 +1,34 @@ +// config/selection.config.jsx +import { Download, ArchiveRestore } 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, restoreGroup, restoreGroups }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }), + }, + { + key: "restore-selected", + label: "Restore", + icon: , + className: + "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700", + onClick: (rows) => { + const ids = rows.map((r) => r.group_id); + ids.length === 1 + ? restoreGroup(rows[0]) // opens single dialog + : restoreGroups(ids); // opens bulk dialog + }, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/user_groups/archive/toolbar.config.jsx b/src/modules/admin/config/user_groups/archive/toolbar.config.jsx new file mode 100644 index 0000000..4801aba --- /dev/null +++ b/src/modules/admin/config/user_groups/archive/toolbar.config.jsx @@ -0,0 +1,29 @@ +// ─── 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({ fetchArchivedGroups, pagination, exportConfig, navigate, getFilters, getSort }) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => fetchArchivedGroups({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort()}), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/user_groups/columns.config.jsx b/src/modules/admin/config/user_groups/columns.config.jsx new file mode 100644 index 0000000..cc113da --- /dev/null +++ b/src/modules/admin/config/user_groups/columns.config.jsx @@ -0,0 +1,45 @@ +// 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"; +import { Badge } from "@/components/ui/badge"; +import { Users } from "lucide-react"; + +export const columnPinning = { + right: ["actions"], + left: [], +}; + +// ─── Custom cell overrides ──────────────────────────────────────────────────── +const cellOverrides = { + memberCount: (info) => { + const count = parseInt(info.getValue() ?? 0, 10); + return ( +
+ + + {count} {count === 1 ? "member" : "members"} + +
+ ); + }, +}; + +/** + * 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 buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Group Actions" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/rowActions.config.jsx b/src/modules/admin/config/user_groups/rowActions.config.jsx similarity index 100% rename from src/modules/admin/config/rowActions.config.jsx rename to src/modules/admin/config/user_groups/rowActions.config.jsx diff --git a/src/modules/admin/config/user_groups/selection.config.jsx b/src/modules/admin/config/user_groups/selection.config.jsx new file mode 100644 index 0000000..81a5e7e --- /dev/null +++ b/src/modules/admin/config/user_groups/selection.config.jsx @@ -0,0 +1,34 @@ +// 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, archiveGroup, archiveGroups }) { + return [ + { + key: "export-selected", + label: "Export", + icon: , + onClick: (rows, table) => + exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }), + }, + { + key: "archive-selected", + label: "Archive", + icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: (rows) => { + const ids = rows.map((r) => r.group_id); + ids.length === 1 + ? archiveGroup(rows[0]) // opens single dialog + : archiveGroups(ids); // opens bulk dialog + }, + hidden: (rows) => rows.every((r) => r.status === "archived"), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/user_groups/toolbar.config.jsx b/src/modules/admin/config/user_groups/toolbar.config.jsx new file mode 100644 index 0000000..52e0798 --- /dev/null +++ b/src/modules/admin/config/user_groups/toolbar.config.jsx @@ -0,0 +1,47 @@ +// ─── config/toolbar.config.jsx ──────────────────────────────────────────────── +import { RefreshCw, Download, UserPlus, Archive } 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({ fetchGroups, pagination, exportConfig, navigate, getFilters, getSort }) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => fetchGroups({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort() }), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }), + }, + { + key: "add-group", + type: "button", + icon: , + label: "Add Group", + variant: "default", + className: "text-primary-foreground", + onClick: () => navigate("add/staff"), + }, + { + key: "archived-groups", + type: "button", + icon: , + label: "Archived Groups", + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/users/groups/archived"), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/archive/columns.config.jsx b/src/modules/admin/config/users/archive/columns.config.jsx similarity index 100% rename from src/modules/admin/config/archive/columns.config.jsx rename to src/modules/admin/config/users/archive/columns.config.jsx diff --git a/src/modules/admin/config/users/archive/rowActions.config.jsx b/src/modules/admin/config/users/archive/rowActions.config.jsx new file mode 100644 index 0000000..3d96d9f --- /dev/null +++ b/src/modules/admin/config/users/archive/rowActions.config.jsx @@ -0,0 +1,25 @@ +// 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 { RotateCcw } 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, onRestore }) { + return [ + { + key: "restore", + label: "Restore", + className: "text-emerald-600 focus:text-emerald-600", + icon: , + onClick: (row) => onRestore(row), + hidden: (row) => row.is_active, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/archive/selection.config.jsx b/src/modules/admin/config/users/archive/selection.config.jsx similarity index 100% rename from src/modules/admin/config/archive/selection.config.jsx rename to src/modules/admin/config/users/archive/selection.config.jsx diff --git a/src/modules/admin/config/archive/toolbar.config.jsx b/src/modules/admin/config/users/archive/toolbar.config.jsx similarity index 100% rename from src/modules/admin/config/archive/toolbar.config.jsx rename to src/modules/admin/config/users/archive/toolbar.config.jsx diff --git a/src/modules/admin/config/columns.config.jsx b/src/modules/admin/config/users/columns.config.jsx similarity index 93% rename from src/modules/admin/config/columns.config.jsx rename to src/modules/admin/config/users/columns.config.jsx index 8ac8c6b..889c7c0 100644 --- a/src/modules/admin/config/columns.config.jsx +++ b/src/modules/admin/config/users/columns.config.jsx @@ -17,7 +17,7 @@ export const columnPinning = { * @param {Array} rowActions Row-level kebab action definitions * @returns {Array} TanStack column definitions */ -export function buildUserColumns(attributes, rowActions) { +export function buildDataColumns(attributes, rowActions) { const visibleAttributes = attributes.filter((a) => !a.hidden); return [ diff --git a/src/modules/admin/config/users/rowActions.config.jsx b/src/modules/admin/config/users/rowActions.config.jsx new file mode 100644 index 0000000..a58e619 --- /dev/null +++ b/src/modules/admin/config/users/rowActions.config.jsx @@ -0,0 +1,39 @@ +// config/rowActions.config.jsx +// Per-row kebab menu action definitions for the Users table. +// +// Each onClick receives the row's data object from buildRowActionsColumn. + +import { Eye, Pencil, Archive } from "lucide-react"; + +/** + * @param {Object} deps + * @param {Function} deps.navigate React Router navigate + * @param {Function} deps.archiveUser Archive handler from useManagement + * @returns {Array} rowActions + */ +export function buildRowActions({ navigate, onArchive }) { + return [ + { + key: "view", + label: "View details", + icon: , + onClick: (row) => navigate(`view/${row.user_id}`), + }, + { + key: "edit", + label: "Edit", + icon: , + onClick: (row) => navigate(`edit/${row.user_id}`), + disabled: (row) => row.role === "super_admin", + }, + { + key: "archive", + label: "Archive", + className: "text-destructive focus:text-destructive", + icon: , + onClick: (row) => onArchive(row), // ← opens dialog, not deactivateUser + hidden: (row) => !row.is_active, // ← hide if already inactive + separator: true, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/selection.config.jsx b/src/modules/admin/config/users/selection.config.jsx similarity index 100% rename from src/modules/admin/config/selection.config.jsx rename to src/modules/admin/config/users/selection.config.jsx diff --git a/src/modules/admin/config/toolbar.config.jsx b/src/modules/admin/config/users/toolbar.config.jsx similarity index 86% rename from src/modules/admin/config/toolbar.config.jsx rename to src/modules/admin/config/users/toolbar.config.jsx index 28d285f..b8eb8da 100644 --- a/src/modules/admin/config/toolbar.config.jsx +++ b/src/modules/admin/config/users/toolbar.config.jsx @@ -9,7 +9,7 @@ import { exportTableToExcel } from "@/utils/excel.util"; * @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 }) { +export function buildToolbarActions({ fetchUsers, pagination, exportConfig, navigate, getFilters, getSort }) { return [ { key: "refresh", @@ -29,10 +29,10 @@ export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, expo key: "add-user", type: "button", icon: , - label: "Add User", + label: "Add Staff", variant: "default", className: "text-primary-foreground", - onClick: () => navigate("add"), + onClick: () => navigate("add/staff"), }, { key: "archived-users", @@ -41,7 +41,7 @@ export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, expo label: "Archived Users", variant: "secondary", className: "border border-border", - onClick: () => navigate("archived"), + onClick: () => navigate("/admin/users/all/archived"), }, ]; } \ No newline at end of file diff --git a/src/modules/admin/pages/user_groups/ArchivedGroupList.jsx b/src/modules/admin/pages/user_groups/ArchivedGroupList.jsx new file mode 100644 index 0000000..7eda904 --- /dev/null +++ b/src/modules/admin/pages/user_groups/ArchivedGroupList.jsx @@ -0,0 +1,26 @@ +import { House } from "lucide-react"; + +import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; +import ArchiveGroupTable from "../../components/user_groups/ArchiveGroupTable"; + +export default function ArchivedGroupList() { + const items = [ + { label: "Home", icon: , to: `/admin/users` }, + { label: "User Group", to: `/admin/users/groups` }, + { label: "Archived" }, + ] + + return ( +
+
+
+ +
+ +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/modules/admin/pages/user_groups/GroupList.jsx b/src/modules/admin/pages/user_groups/GroupList.jsx index 47c7596..3ef0481 100644 --- a/src/modules/admin/pages/user_groups/GroupList.jsx +++ b/src/modules/admin/pages/user_groups/GroupList.jsx @@ -1,11 +1,25 @@ -import React from 'react' +import { House } from "lucide-react"; + +import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; +import GroupTable from "../../components/user_groups/GroupTable"; + +export default function GroupList() { + const items = [ + { label: "Home", icon: , to: `/admin/users` }, + { label: "User Groups" }, + ] -const GroupList = () => { return ( -
- GroupList -
- ) -} +
+
+
+ +
-export default GroupList +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/modules/admin/pages/users/AddStaffUser.jsx b/src/modules/admin/pages/users/AddStaffUser.jsx new file mode 100644 index 0000000..0991c34 --- /dev/null +++ b/src/modules/admin/pages/users/AddStaffUser.jsx @@ -0,0 +1,484 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useForm, useFieldArray, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { ChevronRight, ChevronLeft, Check, User, FileText } from "lucide-react"; + +import { useUsers } from "@/contexts/AdminUserContext"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; + +// ─── Zod schema ─────────────────────────────────────────────────────────────── +const phoneSchema = z.object({ + phone_type: z.enum(["mobile", "home", "work"]), + country_code: z.string().min(1, "Required"), + number: z.string().min(1, "Number is required").regex(/^\d+$/, "Digits only"), +}); + +const addressSchema = z.object({ + address_type: z.enum(["home", "province", "work"]), + street: z.string().min(1, "Street is required"), + city: z.string().min(1, "City is required"), + state: z.string().min(1, "State is required"), + zip: z.string().min(1, "ZIP is required"), + country: z.string().min(1, "Country is required"), +}); + +const staffUserSchema = z.object({ + email: z.string().min(1, "Email is required").email("Invalid email format"), + last_name: z.string().min(1, "Last name is required"), + given_name: z.string().min(1, "Given name is required"), + middle_name: z.string().optional(), + extension_name: z.string().optional(), + date_of_birth: z.string().optional(), + occupation: z.string().optional(), + phones: z.array(phoneSchema).min(1, "At least one phone number is required"), + addresses: z.array(addressSchema).min(1, "At least one address is required"), +}); + +// ─── Steps ──────────────────────────────────────────────────────────────────── +const STEPS = [ + { id: 0, label: "Personal Information", icon: User }, + { id: 1, label: "Summary", icon: FileText }, +]; + +// ─── Default values ─────────────────────────────────────────────────────────── +const DEFAULT_VALUES = { + email: "", + last_name: "", + given_name: "", + middle_name: "", + extension_name: "", + date_of_birth: "", + occupation: "", + phones: [{ phone_type: "mobile", country_code: "63", number: "" }], + addresses: [{ address_type: "home", street: "", city: "", state: "", zip: "", country: "Philippines" }], +}; + +// ─── Field wrapper ──────────────────────────────────────────────────────────── +function Field({ label, required, error, children }) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ); +} + +// ─── Step 1 — Personal Information ─────────────────────────────────────────── +function StepPersonal({ control, register, errors }) { + const { + fields: phoneFields, + append: appendPhone, + remove: removePhone, + } = useFieldArray({ control, name: "phones" }); + + const { + fields: addressFields, + append: appendAddress, + remove: removeAddress, + } = useFieldArray({ control, name: "addresses" }); + + return ( +
+ + {/* ── Name ── */} +
+

Name

+
+ + + + + + + + + + + + + + + + + + +
+
+ + + + {/* ── Credentials ── */} +
+

Account Credentials

+
+ + + +
+ 🔐 + A temporary password will be auto-generated and sent to the provided email address. +
+
+
+ + + + {/* ── Phone Numbers ── */} +
+

Phone Numbers

+
+ {phoneFields.map((field, i) => ( +
+
+ Phone {i + 1} + {phoneFields.length > 1 && ( + + )} +
+
+ + ( + + )} + /> + + + ( + + )} + /> + + + + +
+
+ ))} + + {errors.phones?.root?.message && ( +

{errors.phones.root.message}

+ )} +
+
+ + + + {/* ── Addresses ── */} +
+

Addresses

+
+ {addressFields.map((field, i) => ( +
+
+ Address {i + 1} + {addressFields.length > 1 && ( + + )} +
+
+ + ( + + )} + /> + + + + + + + + + + + + + + + + +
+
+ ))} + + {errors.addresses?.root?.message && ( +

{errors.addresses.root.message}

+ )} +
+
+ +
+ ); +} + +// ─── Step 2 — Summary ───────────────────────────────────────────────────────── +function SummaryRow({ label, value }) { + if (!value) return null; + return ( +
+ {label} + {value} +
+ ); +} + +function StepSummary({ data }) { + const fullName = + [data.last_name, [data.given_name, data.middle_name].filter(Boolean).join(" ")] + .filter(Boolean) + .join(", ") + (data.extension_name ? ` ${data.extension_name}` : ""); + + return ( +
+
+
+ + Personal Information + Staff +
+ + + + +
+ + {data.phones?.some((p) => p.number) && ( +
+

Phone Numbers

+ {data.phones.filter((p) => p.number).map((p, i) => ( + + ))} +
+ )} + + {data.addresses?.some((a) => a.street) && ( +
+

Addresses

+ {data.addresses.filter((a) => a.street).map((a, i) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── +export default function AddStaffUserPage() { + const navigate = useNavigate(); + const { addStaffUser, loading } = useUsers(); + const [step, setStep] = useState(0); + + const { + register, + control, + trigger, + getValues, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(staffUserSchema), + defaultValues: DEFAULT_VALUES, + mode: "onTouched", + }); + + // Validate all fields then advance to summary + const handleNext = async () => { + const valid = await trigger(); + if (valid) setStep(1); + }; + + // Called manually — no
tag so no accidental submit + const handleCreate = handleSubmit(async (data) => { + const payload = { + email: data.email, + personal_info: { + name: { + given_name: data.given_name, + last_name: data.last_name, + middle_name: data.middle_name ?? "", + extension_name: data.extension_name ?? "", + }, + date_of_birth: data.date_of_birth || null, + occupation: data.occupation ?? "", + phone_number: data.phones, + addresses: data.addresses, + }, + }; + + const res = await addStaffUser(payload); + if (res) navigate("/admin/users/all"); + }); + + return ( + // ← plain div, no — prevents any accidental submit on button clicks +
+ + {/* Header */} +
+

Add Staff User

+

+ Creates a new account with staff access level. +

+
+ + {/* Stepper */} +
+ {STEPS.map((s, i) => { + const Icon = s.icon; + const isActive = step === i; + const isDone = step > i; + + return ( +
+
+
+ {isDone ? : } +
+ +
+ {i < STEPS.length - 1 && ( +
i ? "bg-emerald-600" : "bg-border" + )} /> + )} +
+ ); + })} +
+ + {/* Step content */} +
+

{STEPS[step].label}

+ {step === 0 && ( + + )} + {step === 1 && ( + + )} +
+ + {/* Navigation */} +
+ + + {step === 0 ? ( + + ) : ( + + )} +
+ +
+ ); +} \ No newline at end of file diff --git a/src/modules/admin/pages/users/AddUser.jsx b/src/modules/admin/pages/users/AddUser.jsx deleted file mode 100644 index fa33a73..0000000 --- a/src/modules/admin/pages/users/AddUser.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react' - -const AddUser = () => { - return ( -
- AddUser -
- ) -} - -export default AddUser diff --git a/src/modules/admin/pages/users/ArchivedUserList.jsx b/src/modules/admin/pages/users/ArchivedUserList.jsx index 03d91cc..f47cc56 100644 --- a/src/modules/admin/pages/users/ArchivedUserList.jsx +++ b/src/modules/admin/pages/users/ArchivedUserList.jsx @@ -1,7 +1,7 @@ import { House } from "lucide-react"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; -import ArchiveUserTable from "../../components/ArchiveUserTable"; +import ArchiveUserTable from "../../components/users/ArchiveUserTable"; export default function ArchivedUserList() { const items = [ diff --git a/src/modules/admin/pages/users/UserList.jsx b/src/modules/admin/pages/users/UserList.jsx index b2bcffe..99e2407 100644 --- a/src/modules/admin/pages/users/UserList.jsx +++ b/src/modules/admin/pages/users/UserList.jsx @@ -1,7 +1,7 @@ import { House } from "lucide-react"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; -import UsersTable from "../../components/UserTable"; +import UsersTable from "../../components/users/UserTable"; export default function UserList() { const items = [ diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx index 40c40c8..f08f917 100644 --- a/src/modules/admin/routes/AdminRoutes.jsx +++ b/src/modules/admin/routes/AdminRoutes.jsx @@ -13,13 +13,14 @@ import ProfilePage from '@/components/generic/Profile' import UsersDashboard from '../pages/users/UserDashboard' import UserList from '../pages/users/UserList' -import AddUser from '../pages/users/AddUser' +import AddUser from '../pages/users/AddStaffUser' import ViewUser from '../pages/users/ViewUser' import GroupList from '../pages/user_groups/GroupList' import ViewGroup from '../pages/user_groups/ViewGroup' import EditUser from '../pages/users/EditUser' import ArchivedUserList from '../pages/users/ArchivedUserList' +import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList' export const AdminRoutes = { @@ -46,7 +47,7 @@ export const AdminRoutes = { element: , children: [ { index: true, element: }, - { path: 'add', element: }, + { path: 'add/staff', element: }, { path: 'view/:userId', element: }, { path: 'edit/:userId', element: }, { path: 'archived', element: }, @@ -59,7 +60,8 @@ export const AdminRoutes = { element: , children: [ { index: true, element: }, - { path: 'view/:groupId', element: } + { path: 'view/:groupId', element: }, + { path: 'archived', element: } ] }, ] diff --git a/src/utils/table.util.jsx b/src/utils/table.util.jsx index d0ecb26..2a43519 100644 --- a/src/utils/table.util.jsx +++ b/src/utils/table.util.jsx @@ -230,19 +230,22 @@ export function SortIcon({ column }) { // ── Build columns dynamically from attributes ────────────────────────────────── const columnHelper = createColumnHelper(); -export function buildColumns(attrs) { - return attrs.map((attr) => +export function buildColumns(attributes, { cellOverrides = {} } = {}) { + return attributes.map((attr) => columnHelper.accessor(attr.field, { - id: attr.field, - header: attr.name, - enableSorting: true, + 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()), + cell: (info) => + cellOverrides[attr.field] // ← check override first + ? cellOverrides[attr.field](info) + : renderCell(attr, info.getValue()), meta: { attr }, }) );