This commit is contained in:
rgrgogu
2026-05-07 23:35:31 +08:00
parent 42b842c480
commit ca6eae1d56
35 changed files with 1637 additions and 207 deletions
@@ -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: <ArchiveDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <ArchiveDialog ids={[1, 2, 3]} entityLabel="Group" ... />
*
* @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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Archive {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to archive{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
{isBulk
? "They will be deactivated and lose access immediately."
: "This will deactivate the record immediately."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchive}
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Spinner className="size-4 mr-2" />}
Archive{isBulk ? ` ${count} ${entityLabel}${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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: <RestoreDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <RestoreDialog ids={[1, 2, 3]} entityLabel="Group" ... />
*
* @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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Restore {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to restore{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
{isBulk
? "They will regain access to their accounts immediately."
: "This will reactivate the record immediately."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRestore}
disabled={loading}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{loading && <Spinner className="size-4 mr-2" />}
Restore{isBulk ? ` ${count} ${entityLabel}${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+11
View File
@@ -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,
+310
View File
@@ -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 (
<UserGroupContext.Provider
value={{
// state
groups,
group,
members,
usersIn,
usersNotIn,
attributes,
pagination,
setPagination,
loading,
// actions
fetchGroups,
fetchGroup,
fetchUsersInGroup,
fetchUsersNotInGroup,
fetchGroupFieldValues,
fetchArchivedGroups,
createGroup,
updateGroup,
deactivateGroup,
deactivateGroups,
restoreGroup,
restoreGroups,
addUsersToGroup,
removeUsersFromGroup,
}}
>
{children}
</UserGroupContext.Provider>
);
}
+3 -6
View File
@@ -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 (
<UserProvider>
{/* <GroupProvider> */}
{/* <ContentProvider> */}
<UserGroupProvider>
{children}
{/* </ContentProvider> */}
{/* </GroupProvider> */}
</UserGroupProvider>
</UserProvider>
);
};
@@ -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: <ArchiveUserDialog user={rowObject} ... />
* Bulk: <ArchiveUserDialog ids={[1, 2, 3]} ... />
*/
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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Archive {isBulk ? `${count} Users` : "User"}
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to archive{" "}
<span className="font-medium text-foreground">
{isBulk
? `${count} selected user${count !== 1 ? "s" : ""}`
: (user?.personal_info?.name?.full_name ?? user?.email)}
</span>
? They will be deactivated and lose access immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchive}
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading ? <Spinner className="size-4 mr-2" /> : null}
Archive{isBulk ? ` ${count} User${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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: <RestoreUserDialog user={rowObject} ... />
* Bulk: <RestoreUserDialog ids={[1, 2, 3]} ... />
*/
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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore {isBulk ? `${count} Users` : "User"}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to restore{" "}
<span className="font-medium text-foreground">
{isBulk
? `${count} selected user${count !== 1 ? "s" : ""}`
: (user?.personal_info?.name?.full_name ?? user?.email)}
</span>
? They will regain access to their account{isBulk && count !== 1 ? "s" : ""} immediately.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRestore}
disabled={loading}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{loading ? <Spinner className="size-4 mr-2" /> : null}
Restore{isBulk ? ` ${count} User${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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 (
<>
<DataTable
title="User Groups"
data={groups}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={fetchArchivedGroups}
onFetchFilterData={fetchGroupFieldValues}
onRefsReady={(refs) => tableRefsRef.current = refs}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="group"
emptyMessage="No archived groups match the current filters."
/>
{/* Single restore */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Group"
getName={(g) => g?.name}
onRestore={(g) => restoreGroup(g?.group_id)}
loading={loading}
onSuccess={handleRestoreSuccess}
/>
{/* Bulk restore */}
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Group"
onRestore={restoreGroups}
loading={loading}
onSuccess={handleRestoreSuccess}
/>
</>
);
}
@@ -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 (
<>
<DataTable
title="User Groups"
data={groups}
columns={columns}
attributes={attributes}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={fetchGroups}
onFetchFilterData={fetchGroupFieldValues}
onRefsReady={(refs) => tableRefsRef.current = refs}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="group"
emptyMessage="No user groups match the current filters."
/>
{/* Single archive */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Group"
getName={(g) => g?.name}
onArchive={(g) => deactivateGroup(g?.group_id)}
loading={loading}
onSuccess={handleArchiveSuccess}
/>
{/* Bulk archive */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Group"
onArchive={deactivateGroups}
loading={loading}
onSuccess={handleArchiveSuccess}
/>
</>
);
}
@@ -3,17 +3,17 @@ 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: () => { } });
@@ -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 (
<>
<DataTable
title="Users"
title="Archived Users"
data={users}
columns={columns}
attributes={attributes}
@@ -86,19 +92,27 @@ export default function ArchiveUsersTable() {
recordLabel="user"
emptyMessage="No users match the current filters."
/>
{/* Single restore */}
<RestoreUserDialog
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !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 */}
<RestoreUserDialog
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="User"
onRestore={restoreUsers}
loading={loading}
onSuccess={handleRestoreSuccess}
/>
</>
@@ -3,13 +3,13 @@ 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";
@@ -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 */}
<ArchiveUserDialog
{/* Single restore */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !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 */}
<ArchiveUserDialog
{/* Bulk restore */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="User"
onArchive={deactivateUsers}
loading={loading}
onSuccess={handleArchiveSuccess}
/>
</>
@@ -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 (
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "member" : "members"}
</Badge>
</div>
);
},
};
/**
* 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" }),
];
}
@@ -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: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
},
{
key: "restore-selected",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
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
},
},
];
}
@@ -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: <RefreshCw className="h-3.5 w-3.5" />,
label: "Refresh",
onClick: () => fetchArchivedGroups({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort()}),
},
{
key: "export",
type: "button",
icon: <Download className="h-3.5 w-3.5" />,
label: "Export",
onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }),
},
];
}
@@ -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 (
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "member" : "members"}
</Badge>
</div>
);
},
};
/**
* 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" }),
];
}
@@ -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: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({ ...exportConfig, selectedRows: rows, tableInstance: table }),
},
{
key: "archive-selected",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
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"),
},
];
}
@@ -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: <RefreshCw className="h-3.5 w-3.5" />,
label: "Refresh",
onClick: () => fetchGroups({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort() }),
},
{
key: "export",
type: "button",
icon: <Download className="h-3.5 w-3.5" />,
label: "Export",
onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }),
},
{
key: "add-group",
type: "button",
icon: <UserPlus className="h-3.5 w-3.5" />,
label: "Add Group",
variant: "default",
className: "text-primary-foreground",
onClick: () => navigate("add/staff"),
},
{
key: "archived-groups",
type: "button",
icon: <Archive className="h-3.5 w-3.5" />,
label: "Archived Groups",
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/users/groups/archived"),
},
];
}
@@ -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: <RotateCcw className="h-3.5 w-3.5" />,
onClick: (row) => onRestore(row),
hidden: (row) => row.is_active,
},
];
}
@@ -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 [
@@ -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: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`view/${row.user_id}`),
},
{
key: "edit",
label: "Edit",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`edit/${row.user_id}`),
disabled: (row) => row.role === "super_admin",
},
{
key: "archive",
label: "Archive",
className: "text-destructive focus:text-destructive",
icon: <Archive className="h-3.5 w-3.5" />,
onClick: (row) => onArchive(row), // ← opens dialog, not deactivateUser
hidden: (row) => !row.is_active, // ← hide if already inactive
separator: true,
},
];
}
@@ -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: <UserPlus className="h-3.5 w-3.5" />,
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"),
},
];
}
@@ -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: <House className="size-4" />, to: `/admin/users` },
{ label: "User Group", to: `/admin/users/groups` },
{ label: "Archived" },
]
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<ArchiveGroupTable />
</div>
</div>
</section>
)
}
@@ -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: <House className="size-4" />, to: `/admin/users` },
{ label: "User Groups" },
]
const GroupList = () => {
return (
<div>
GroupList
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<GroupTable />
</div>
</div>
</section>
)
}
export default GroupList
@@ -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 (
<div className="space-y-1.5">
<Label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</Label>
{children}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── 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 (
<div className="space-y-6">
{/* ── Name ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Name</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Last Name" required error={errors.last_name?.message}>
<Input {...register("last_name")} placeholder="Dela Cruz" />
</Field>
<Field label="Given Name" required error={errors.given_name?.message}>
<Input {...register("given_name")} placeholder="Juan" />
</Field>
<Field label="Middle Name" error={errors.middle_name?.message}>
<Input {...register("middle_name")} placeholder="Santos" />
</Field>
<Field label="Extension Name" error={errors.extension_name?.message}>
<Input {...register("extension_name")} placeholder="Jr., Sr., III" />
</Field>
<Field label="Date of Birth" error={errors.date_of_birth?.message}>
<Input type="date" {...register("date_of_birth")} />
</Field>
<Field label="Occupation" error={errors.occupation?.message}>
<Input {...register("occupation")} placeholder="Sales Agent" />
</Field>
</div>
</div>
<Separator />
{/* ── Credentials ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Account Credentials</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field label="Email" required error={errors.email?.message}>
<Input type="email" {...register("email")} placeholder="juan@example.com" />
</Field>
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground sm:col-span-2">
<span>🔐</span>
<span>A temporary password will be auto-generated and sent to the provided email address.</span>
</div>
</div>
</div>
<Separator />
{/* ── Phone Numbers ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Phone Numbers</p>
<div className="space-y-3">
{phoneFields.map((field, i) => (
<div key={field.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Phone {i + 1}</span>
{phoneFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive h-7 px-2"
onClick={() => removePhone(i)}
>
Remove
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Field label="Type" error={errors.phones?.[i]?.phone_type?.message}>
<Controller
control={control}
name={`phones.${i}.phone_type`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="mobile">Mobile</SelectItem>
<SelectItem value="home">Home</SelectItem>
<SelectItem value="work">Work</SelectItem>
</SelectContent>
</Select>
)}
/>
</Field>
<Field label="Country Code" error={errors.phones?.[i]?.country_code?.message}>
<Controller
control={control}
name={`phones.${i}.country_code`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="63">+63 (PH)</SelectItem>
<SelectItem value="1">+1 (US)</SelectItem>
<SelectItem value="44">+44 (UK)</SelectItem>
</SelectContent>
</Select>
)}
/>
</Field>
<Field label="Number" required error={errors.phones?.[i]?.number?.message}>
<Input {...register(`phones.${i}.number`)} placeholder="9123456789" />
</Field>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => appendPhone({ phone_type: "mobile", country_code: "63", number: "" })}
>
+ Add Phone Number
</Button>
{errors.phones?.root?.message && (
<p className="text-xs text-destructive">{errors.phones.root.message}</p>
)}
</div>
</div>
<Separator />
{/* ── Addresses ── */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Addresses</p>
<div className="space-y-3">
{addressFields.map((field, i) => (
<div key={field.id} className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Address {i + 1}</span>
{addressFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive h-7 px-2"
onClick={() => removeAddress(i)}
>
Remove
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Address Type" error={errors.addresses?.[i]?.address_type?.message}>
<Controller
control={control}
name={`addresses.${i}.address_type`}
render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="home">Home</SelectItem>
<SelectItem value="province">Province</SelectItem>
<SelectItem value="work">Work</SelectItem>
</SelectContent>
</Select>
)}
/>
</Field>
<Field label="Street" required error={errors.addresses?.[i]?.street?.message}>
<Input {...register(`addresses.${i}.street`)} placeholder="123 Mabini St" />
</Field>
<Field label="City" required error={errors.addresses?.[i]?.city?.message}>
<Input {...register(`addresses.${i}.city`)} placeholder="Manila" />
</Field>
<Field label="State / Region" required error={errors.addresses?.[i]?.state?.message}>
<Input {...register(`addresses.${i}.state`)} placeholder="NCR" />
</Field>
<Field label="ZIP Code" required error={errors.addresses?.[i]?.zip?.message}>
<Input {...register(`addresses.${i}.zip`)} placeholder="1000" />
</Field>
<Field label="Country" error={errors.addresses?.[i]?.country?.message}>
<Input {...register(`addresses.${i}.country`)} placeholder="Philippines" />
</Field>
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => appendAddress({ address_type: "province", street: "", city: "", state: "", zip: "", country: "Philippines" })}
>
+ Add Address
</Button>
{errors.addresses?.root?.message && (
<p className="text-xs text-destructive">{errors.addresses.root.message}</p>
)}
</div>
</div>
</div>
);
}
// ─── Step 2 — Summary ─────────────────────────────────────────────────────────
function SummaryRow({ label, value }) {
if (!value) return null;
return (
<div className="flex justify-between py-1.5 text-sm">
<span className="text-muted-foreground min-w-[140px]">{label}</span>
<span className="text-foreground text-right">{value}</span>
</div>
);
}
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 (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 space-y-1">
<div className="flex items-center gap-2 mb-3">
<User className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Personal Information</span>
<Badge variant="secondary" className="ml-auto text-xs">Staff</Badge>
</div>
<SummaryRow label="Full Name" value={fullName || "—"} />
<SummaryRow label="Email" value={data.email} />
<SummaryRow label="Date of Birth" value={data.date_of_birth} />
<SummaryRow label="Occupation" value={data.occupation} />
</div>
{data.phones?.some((p) => p.number) && (
<div className="border border-border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Phone Numbers</p>
{data.phones.filter((p) => p.number).map((p, i) => (
<SummaryRow
key={i}
label={p.phone_type.charAt(0).toUpperCase() + p.phone_type.slice(1)}
value={`+${p.country_code} ${p.number}`}
/>
))}
</div>
)}
{data.addresses?.some((a) => a.street) && (
<div className="border border-border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Addresses</p>
{data.addresses.filter((a) => a.street).map((a, i) => (
<SummaryRow
key={i}
label={a.address_type.charAt(0).toUpperCase() + a.address_type.slice(1)}
value={[a.street, a.city, a.state, a.country, a.zip].filter(Boolean).join(", ")}
/>
))}
</div>
)}
</div>
);
}
// ─── 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 <form> 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 <form> — prevents any accidental submit on button clicks
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div>
<h1 className="text-xl font-semibold tracking-tight">Add Staff User</h1>
<p className="text-sm text-muted-foreground mt-1">
Creates a new account with staff access level.
</p>
</div>
{/* Stepper */}
<div className="flex items-center gap-0">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = step === i;
const isDone = step > i;
return (
<div key={s.id} className="flex items-center flex-1 last:flex-none">
<div className="flex flex-col items-center gap-1">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
isDone && "bg-emerald-600 border-emerald-600 text-white",
isActive && "border-primary bg-primary text-primary-foreground",
!isActive && !isDone && "border-border bg-background text-muted-foreground"
)}>
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
</div>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
isActive ? "text-foreground" : "text-muted-foreground",
isDone ? "text-emerald-600" : ""
)}>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div className={cn(
"flex-1 h-px mx-2 mb-4 transition-colors",
step > i ? "bg-emerald-600" : "bg-border"
)} />
)}
</div>
);
})}
</div>
{/* Step content */}
<div className="border border-border rounded-xl p-5 bg-card min-h-[320px]">
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
{step === 0 && (
<StepPersonal control={control} register={register} errors={errors} />
)}
{step === 1 && (
<StepSummary data={getValues()} />
)}
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={step === 0 ? () => navigate(-1) : () => setStep(0)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
</Button>
{step === 0 ? (
<Button type="button" onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
type="button" // ← type="button", not "submit"
disabled={loading}
onClick={handleCreate} // ← called manually
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{loading ? "Creating..." : "Create Staff User"}
</Button>
)}
</div>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
import React from 'react'
const AddUser = () => {
return (
<div>
AddUser
</div>
)
}
export default AddUser
@@ -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 = [
+1 -1
View File
@@ -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 = [
+5 -3
View File
@@ -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: <Outlet />,
children: [
{ index: true, element: <UserList /> },
{ path: 'add', element: <AddUser /> },
{ path: 'add/staff', element: <AddUser /> },
{ path: 'view/:userId', element: <ViewUser /> },
{ path: 'edit/:userId', element: <EditUser /> },
{ path: 'archived', element: <ArchivedUserList /> },
@@ -59,7 +60,8 @@ export const AdminRoutes = {
element: <Outlet />,
children: [
{ index: true, element: <GroupList /> },
{ path: 'view/:groupId', element: <ViewGroup /> }
{ path: 'view/:groupId', element: <ViewGroup /> },
{ path: 'archived', element: <ArchivedGroupList/>}
]
},
]
+6 -3
View File
@@ -230,8 +230,8 @@ 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,
@@ -242,7 +242,10 @@ export function buildColumns(attrs) {
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 },
})
);