mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,6 +81,16 @@ export const UserProvider = ({ children }) => {
|
|||||||
[request]
|
[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 ──────────────────────────────────────────────
|
// ─── PUT /api/admin/users/:id ──────────────────────────────────────────────
|
||||||
const updateUser = useCallback(
|
const updateUser = useCallback(
|
||||||
(userId, payload) =>
|
(userId, payload) =>
|
||||||
@@ -252,6 +262,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchArchivedUsers,
|
fetchArchivedUsers,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
|
addStaffUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
deactivateUser,
|
deactivateUser,
|
||||||
deactivateUsers,
|
deactivateUsers,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
// ─── AdminProvider.jsx ─────────────────────────────────────────────────────────
|
// ─── AdminProvider.jsx ─────────────────────────────────────────────────────────
|
||||||
import { UserProvider } from "../AdminUserContext";
|
import { UserProvider } from "../AdminUserContext";
|
||||||
// import { GroupProvider } from "@/modules/admin_side/user_management/group/context/GroupContext";
|
import { UserGroupProvider } from "../AdminUserGroupContext";
|
||||||
// import { ContentProvider } from "@/modules/admin_side/content_management/context/ContentContext";
|
|
||||||
|
|
||||||
export const AdminProvider = ({ children }) => {
|
export const AdminProvider = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
{/* <GroupProvider> */}
|
<UserGroupProvider>
|
||||||
{/* <ContentProvider> */}
|
{children}
|
||||||
{children}
|
</UserGroupProvider>
|
||||||
{/* </ContentProvider> */}
|
|
||||||
{/* </GroupProvider> */}
|
|
||||||
</UserProvider>
|
</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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-17
@@ -3,20 +3,20 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useUsers } from "@/contexts/AdminUserContext";
|
import { useUsers } from "@/contexts/AdminUserContext";
|
||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { RestoreUserDialog } from "../components/RestoreUserDialog";
|
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
|
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||||
|
|
||||||
import { buildUserColumns, columnPinning } from "../config/archive/columns.config";
|
import { buildUserColumns, columnPinning } from "../../config/users/archive/columns.config";
|
||||||
import { buildToolbarActions } from "../config/archive/toolbar.config";
|
import { buildToolbarActions } from "../../config/users/archive/toolbar.config";
|
||||||
import { buildSelectionActions } from "../config/archive/selection.config";
|
import { buildSelectionActions } from "../../config/users/archive/selection.config";
|
||||||
import { buildRowActions } from "../config/archive/rowActions.config";
|
import { buildRowActions } from "../../config/users/archive/rowActions.config";
|
||||||
|
|
||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
export default function ArchiveUsersTable() {
|
export default function ArchiveGroupTable() {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null); // single: row object
|
const [restoreTarget, setRestoreTarget] = useState(null); // single: row object
|
||||||
const [restoreIds, setRestoreIds] = useState(null); // bulk: array of ids
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -25,19 +25,24 @@ export default function ArchiveUsersTable() {
|
|||||||
pagination,
|
pagination,
|
||||||
setPagination,
|
setPagination,
|
||||||
loading,
|
loading,
|
||||||
fetchUserFieldValues,
|
|
||||||
fetchArchivedUsers,
|
fetchArchivedUsers,
|
||||||
|
fetchUserFieldValues,
|
||||||
|
restoreUser,
|
||||||
|
restoreUsers
|
||||||
} = useUsers();
|
} = useUsers();
|
||||||
|
|
||||||
// Shared export config — passed into toolbar + selection configs
|
// Shared export config — passed into toolbar + selection configs
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
allData: users,
|
allData: users,
|
||||||
attributes,
|
attributes,
|
||||||
filename: `${getTimestamp()}_Users`,
|
filename: `${getTimestamp()}_ArchivedUserGroups`,
|
||||||
sheetName: "ArchivedUsers",
|
sheetName: "ArchivedUserGroups",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), });
|
const rowActions = buildRowActions({
|
||||||
|
navigate,
|
||||||
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
});
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
fetchArchivedUsers, pagination, exportConfig, navigate,
|
fetchArchivedUsers, pagination, exportConfig, navigate,
|
||||||
getFilters: () => tableRefsRef.current.getFilters(),
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
@@ -45,9 +50,10 @@ export default function ArchiveUsersTable() {
|
|||||||
});
|
});
|
||||||
const selectionActions = buildSelectionActions({
|
const selectionActions = buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
restoreUser: (row) => setRestoreTarget(row), // single — open dialog with row
|
restoreUser: (row) => setRestoreTarget(row),
|
||||||
restoreUsers: (ids) => setRestoreIds(ids), // bulk — open dialog with ids
|
restoreUsers: (ids) => setRestoreIds(ids),
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
|
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
|
||||||
|
|
||||||
const handleRestoreSuccess = () => {
|
const handleRestoreSuccess = () => {
|
||||||
@@ -60,7 +66,7 @@ export default function ArchiveUsersTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Users"
|
title="Archived Users"
|
||||||
data={users}
|
data={users}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
@@ -86,19 +92,27 @@ export default function ArchiveUsersTable() {
|
|||||||
recordLabel="user"
|
recordLabel="user"
|
||||||
emptyMessage="No users match the current filters."
|
emptyMessage="No users match the current filters."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Single restore */}
|
{/* Single restore */}
|
||||||
<RestoreUserDialog
|
<RestoreDialog
|
||||||
open={!!restoreTarget}
|
open={!!restoreTarget}
|
||||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
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}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bulk restore */}
|
{/* Bulk restore */}
|
||||||
<RestoreUserDialog
|
<RestoreDialog
|
||||||
open={!!restoreIds}
|
open={!!restoreIds}
|
||||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
ids={restoreIds ?? []}
|
ids={restoreIds ?? []}
|
||||||
|
entityLabel="User"
|
||||||
|
onRestore={restoreUsers}
|
||||||
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
+22
-13
@@ -3,20 +3,20 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useUsers } from "@/contexts/AdminUserContext";
|
import { useUsers } from "@/contexts/AdminUserContext";
|
||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { ArchiveUserDialog } from "./ArchiveUserDialog";
|
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
|
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
|
|
||||||
import { buildUserColumns, columnPinning } from "../config/columns.config";
|
import { buildDataColumns, columnPinning } from "../../config/users/columns.config";
|
||||||
import { buildToolbarActions } from "../config/toolbar.config";
|
import { buildToolbarActions } from "../../config/users/toolbar.config";
|
||||||
import { buildSelectionActions } from "../config/selection.config";
|
import { buildSelectionActions } from "../../config/users/selection.config";
|
||||||
import { buildRowActions } from "../config/rowActions.config";
|
import { buildRowActions } from "../../config/users/rowActions.config";
|
||||||
|
|
||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
export default function UsersTable() {
|
export default function UsersTable() {
|
||||||
const [archiveTarget, setArchiveTarget] = useState(null); // single: row object
|
const [archiveTarget, setArchiveTarget] = useState(null); // single: row object
|
||||||
const [archiveIds, setArchiveIds] = useState(null); // bulk: array of ids
|
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 navigate = useNavigate();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -27,6 +27,8 @@ export default function UsersTable() {
|
|||||||
loading,
|
loading,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchUserFieldValues,
|
fetchUserFieldValues,
|
||||||
|
deactivateUser,
|
||||||
|
deactivateUsers,
|
||||||
} = useUsers();
|
} = useUsers();
|
||||||
|
|
||||||
// Shared export config — passed into toolbar + selection configs
|
// Shared export config — passed into toolbar + selection configs
|
||||||
@@ -48,7 +50,7 @@ export default function UsersTable() {
|
|||||||
archiveUser: (row) => setArchiveTarget(row), // single
|
archiveUser: (row) => setArchiveTarget(row), // single
|
||||||
archiveUsers: (ids) => setArchiveIds(ids), // bulk
|
archiveUsers: (ids) => setArchiveIds(ids), // bulk
|
||||||
});
|
});
|
||||||
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
|
const columns = useMemo(() => buildDataColumns(attributes, rowActions), [attributes]);
|
||||||
|
|
||||||
const handleArchiveSuccess = () => {
|
const handleArchiveSuccess = () => {
|
||||||
setArchiveTarget(null);
|
setArchiveTarget(null);
|
||||||
@@ -86,19 +88,26 @@ export default function UsersTable() {
|
|||||||
recordLabel="user"
|
recordLabel="user"
|
||||||
emptyMessage="No users match the current filters."
|
emptyMessage="No users match the current filters."
|
||||||
/>
|
/>
|
||||||
{/* Single archive */}
|
{/* Single restore */}
|
||||||
<ArchiveUserDialog
|
<ArchiveDialog
|
||||||
open={!!archiveTarget}
|
open={!!archiveTarget}
|
||||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
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}
|
onSuccess={handleArchiveSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bulk archive */}
|
{/* Bulk restore */}
|
||||||
<ArchiveUserDialog
|
<ArchiveDialog
|
||||||
open={!!archiveIds}
|
open={!!archiveIds}
|
||||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||||
ids={archiveIds ?? []}
|
ids={archiveIds ?? []}
|
||||||
|
entityLabel="User"
|
||||||
|
onArchive={deactivateUsers}
|
||||||
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+1
-1
@@ -17,7 +17,7 @@ export const columnPinning = {
|
|||||||
* @param {Array} rowActions Row-level kebab action definitions
|
* @param {Array} rowActions Row-level kebab action definitions
|
||||||
* @returns {Array} TanStack column definitions
|
* @returns {Array} TanStack column definitions
|
||||||
*/
|
*/
|
||||||
export function buildUserColumns(attributes, rowActions) {
|
export function buildDataColumns(attributes, rowActions) {
|
||||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
|
||||||
return [
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+4
-4
@@ -9,7 +9,7 @@ import { exportTableToExcel } from "@/utils/excel.util";
|
|||||||
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
||||||
* @param {Function} deps.navigate React Router navigate
|
* @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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "refresh",
|
key: "refresh",
|
||||||
@@ -29,10 +29,10 @@ export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, expo
|
|||||||
key: "add-user",
|
key: "add-user",
|
||||||
type: "button",
|
type: "button",
|
||||||
icon: <UserPlus className="h-3.5 w-3.5" />,
|
icon: <UserPlus className="h-3.5 w-3.5" />,
|
||||||
label: "Add User",
|
label: "Add Staff",
|
||||||
variant: "default",
|
variant: "default",
|
||||||
className: "text-primary-foreground",
|
className: "text-primary-foreground",
|
||||||
onClick: () => navigate("add"),
|
onClick: () => navigate("add/staff"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "archived-users",
|
key: "archived-users",
|
||||||
@@ -41,7 +41,7 @@ export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, expo
|
|||||||
label: "Archived Users",
|
label: "Archived Users",
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
className: "border border-border",
|
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 (
|
return (
|
||||||
<div>
|
<section className="bg-muted/60 h-full">
|
||||||
GroupList
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||||
</div>
|
<div className="flex flex-col gap-2 my-6 ">
|
||||||
)
|
<AppBreadcrumb items={items} />
|
||||||
}
|
</div>
|
||||||
|
|
||||||
export default GroupList
|
<div className="w-full">
|
||||||
|
<GroupTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { House } from "lucide-react";
|
||||||
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import ArchiveUserTable from "../../components/ArchiveUserTable";
|
import ArchiveUserTable from "../../components/users/ArchiveUserTable";
|
||||||
|
|
||||||
export default function ArchivedUserList() {
|
export default function ArchivedUserList() {
|
||||||
const items = [
|
const items = [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { House } from "lucide-react";
|
import { House } from "lucide-react";
|
||||||
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import UsersTable from "../../components/UserTable";
|
import UsersTable from "../../components/users/UserTable";
|
||||||
|
|
||||||
export default function UserList() {
|
export default function UserList() {
|
||||||
const items = [
|
const items = [
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import ProfilePage from '@/components/generic/Profile'
|
|||||||
import UsersDashboard from '../pages/users/UserDashboard'
|
import UsersDashboard from '../pages/users/UserDashboard'
|
||||||
|
|
||||||
import UserList from '../pages/users/UserList'
|
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 ViewUser from '../pages/users/ViewUser'
|
||||||
|
|
||||||
import GroupList from '../pages/user_groups/GroupList'
|
import GroupList from '../pages/user_groups/GroupList'
|
||||||
import ViewGroup from '../pages/user_groups/ViewGroup'
|
import ViewGroup from '../pages/user_groups/ViewGroup'
|
||||||
import EditUser from '../pages/users/EditUser'
|
import EditUser from '../pages/users/EditUser'
|
||||||
import ArchivedUserList from '../pages/users/ArchivedUserList'
|
import ArchivedUserList from '../pages/users/ArchivedUserList'
|
||||||
|
import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList'
|
||||||
|
|
||||||
|
|
||||||
export const AdminRoutes = {
|
export const AdminRoutes = {
|
||||||
@@ -46,7 +47,7 @@ export const AdminRoutes = {
|
|||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <UserList /> },
|
{ index: true, element: <UserList /> },
|
||||||
{ path: 'add', element: <AddUser /> },
|
{ path: 'add/staff', element: <AddUser /> },
|
||||||
{ path: 'view/:userId', element: <ViewUser /> },
|
{ path: 'view/:userId', element: <ViewUser /> },
|
||||||
{ path: 'edit/:userId', element: <EditUser /> },
|
{ path: 'edit/:userId', element: <EditUser /> },
|
||||||
{ path: 'archived', element: <ArchivedUserList /> },
|
{ path: 'archived', element: <ArchivedUserList /> },
|
||||||
@@ -59,7 +60,8 @@ export const AdminRoutes = {
|
|||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <GroupList /> },
|
{ index: true, element: <GroupList /> },
|
||||||
{ path: 'view/:groupId', element: <ViewGroup /> }
|
{ path: 'view/:groupId', element: <ViewGroup /> },
|
||||||
|
{ path: 'archived', element: <ArchivedGroupList/>}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -230,19 +230,22 @@ export function SortIcon({ column }) {
|
|||||||
// ── Build columns dynamically from attributes ──────────────────────────────────
|
// ── Build columns dynamically from attributes ──────────────────────────────────
|
||||||
const columnHelper = createColumnHelper();
|
const columnHelper = createColumnHelper();
|
||||||
|
|
||||||
export function buildColumns(attrs) {
|
export function buildColumns(attributes, { cellOverrides = {} } = {}) {
|
||||||
return attrs.map((attr) =>
|
return attributes.map((attr) =>
|
||||||
columnHelper.accessor(attr.field, {
|
columnHelper.accessor(attr.field, {
|
||||||
id: attr.field,
|
id: attr.field,
|
||||||
header: attr.name,
|
header: attr.name,
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
enableColumnFilter: true,
|
enableColumnFilter: true,
|
||||||
filterFn: (row, colId, filterValue) => {
|
filterFn: (row, colId, filterValue) => {
|
||||||
if (!filterValue) return true;
|
if (!filterValue) return true;
|
||||||
const val = String(row.getValue(colId) ?? "").toLowerCase();
|
const val = String(row.getValue(colId) ?? "").toLowerCase();
|
||||||
return val.includes(String(filterValue).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 },
|
meta: { attr },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user