mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -112,6 +112,29 @@ export const UserProvider = ({ children }) => {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/admin/users/archived ────────────────────────────────────────────
|
||||||
|
const fetchArchivedUsers = useCallback(
|
||||||
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.get(`${BASE}/users/archived`, {
|
||||||
|
params: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const final_data = data?.data
|
||||||
|
setUsers(final_data?.data ?? []);
|
||||||
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||||
|
setAttributes(final_data?.attributes ?? []);
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/users/:id/restore ────────────────────────────────────
|
// ─── POST /api/admin/users/:id/restore ────────────────────────────────────
|
||||||
const restoreUser = useCallback(
|
const restoreUser = useCallback(
|
||||||
(userId) =>
|
(userId) =>
|
||||||
@@ -182,6 +205,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
// actions
|
// actions
|
||||||
fetchUserFieldValues,
|
fetchUserFieldValues,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
|
fetchArchivedUsers,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
deactivateUser,
|
deactivateUser,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// ─── components/ArchiveUserDialog.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";
|
||||||
|
|
||||||
|
export function ArchiveUserDialog({ open, onOpenChange, user, onSuccess }) {
|
||||||
|
const { deactivateUser, loading } = useUsers();
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
const res = await deactivateUser(user?.user_id);
|
||||||
|
if (res) {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Archive User</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to archive{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{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
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
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 { 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 { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
|
export default function UsersTable() {
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
|
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
users,
|
||||||
|
attributes,
|
||||||
|
pagination,
|
||||||
|
setPagination,
|
||||||
|
loading,
|
||||||
|
fetchUserFieldValues,
|
||||||
|
fetchArchivedUsers,
|
||||||
|
restoreUser
|
||||||
|
} = useUsers();
|
||||||
|
|
||||||
|
// Shared export config — passed into toolbar + selection configs
|
||||||
|
const exportConfig = {
|
||||||
|
allData: users,
|
||||||
|
attributes,
|
||||||
|
filename: `${getTimestamp()}_Users`,
|
||||||
|
sheetName: "ArchivedUsers",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(users)
|
||||||
|
|
||||||
|
const rowActions = buildRowActions({ navigate, onRestore: (row) => setRestoreTarget(row), });
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchArchivedUsers, pagination, exportConfig, navigate,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
});
|
||||||
|
const selectionActions = buildSelectionActions({ exportConfig, restoreUser });
|
||||||
|
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Users"
|
||||||
|
data={users}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchArchivedUsers}
|
||||||
|
onFetchFilterData={fetchUserFieldValues}
|
||||||
|
onRefsReady={(refs) => tableRefsRef.current = refs}
|
||||||
|
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
column={column}
|
||||||
|
attr={attr}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columnPinning={columnPinning}
|
||||||
|
toolbarActions={toolbarActions}
|
||||||
|
selectionActions={selectionActions}
|
||||||
|
recordLabel="user"
|
||||||
|
emptyMessage="No users match the current filters."
|
||||||
|
/>
|
||||||
|
<RestoreUserDialog
|
||||||
|
open={!!restoreTarget}
|
||||||
|
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||||
|
user={restoreTarget}
|
||||||
|
onSuccess={() => {
|
||||||
|
setRestoreTarget(null);
|
||||||
|
fetchArchivedUsers({ page: 1, limit: pagination.limit });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// ─── 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";
|
||||||
|
|
||||||
|
export function RestoreUserDialog({ open, onOpenChange, user, onSuccess }) {
|
||||||
|
const { restoreUser, loading } = useUsers();
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
const res = await restoreUser(user?.user_id);
|
||||||
|
if (res) {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Restore User</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to restore{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{user?.personal_info?.name?.full_name ?? user?.email}
|
||||||
|
</span>
|
||||||
|
? They will regain access to their account 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
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { buildUserColumns, columnPinning } from "../config/columns.config";
|
import { buildUserColumns, columnPinning } from "../config/columns.config";
|
||||||
@@ -13,8 +14,9 @@ import { buildRowActions } from "../config/rowActions.config";
|
|||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
export default function UsersTable() {
|
export default function UsersTable() {
|
||||||
const navigate = useNavigate();
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] });
|
const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
users,
|
users,
|
||||||
@@ -24,7 +26,7 @@ export default function UsersTable() {
|
|||||||
loading,
|
loading,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchUserFieldValues,
|
fetchUserFieldValues,
|
||||||
deactivateUser,
|
deactivateUser
|
||||||
} = useUsers();
|
} = useUsers();
|
||||||
|
|
||||||
// Shared export config — passed into toolbar + selection configs
|
// Shared export config — passed into toolbar + selection configs
|
||||||
@@ -35,7 +37,7 @@ export default function UsersTable() {
|
|||||||
sheetName: "Users",
|
sheetName: "Users",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowActions = buildRowActions({ navigate, deactivateUser });
|
const rowActions = buildRowActions({ navigate, onArchive: (row) => setArchiveTarget(row) });
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
fetchUsers, pagination, exportConfig, navigate,
|
fetchUsers, pagination, exportConfig, navigate,
|
||||||
getFilters: () => tableRefsRef.current.getFilters(),
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
@@ -45,6 +47,7 @@ export default function UsersTable() {
|
|||||||
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
|
const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Users"
|
title="Users"
|
||||||
data={users}
|
data={users}
|
||||||
@@ -72,5 +75,15 @@ export default function UsersTable() {
|
|||||||
recordLabel="user"
|
recordLabel="user"
|
||||||
emptyMessage="No users match the current filters."
|
emptyMessage="No users match the current filters."
|
||||||
/>
|
/>
|
||||||
|
<ArchiveUserDialog
|
||||||
|
open={!!archiveTarget}
|
||||||
|
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||||
|
user={archiveTarget}
|
||||||
|
onSuccess={() => {
|
||||||
|
setArchiveTarget(null);
|
||||||
|
fetchUsers({ page: 1, limit: pagination.limit });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// config/columns.config.jsx
|
||||||
|
// Column definitions and pinning config for the Users table.
|
||||||
|
|
||||||
|
import { buildColumns } from "@/utils/table.util";
|
||||||
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
|
|
||||||
|
export const columnPinning = {
|
||||||
|
right: ["actions"],
|
||||||
|
left: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full column array for the Users table.
|
||||||
|
*
|
||||||
|
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||||
|
* @param {Array} rowActions Row-level kebab action definitions
|
||||||
|
* @returns {Array} TanStack column definitions
|
||||||
|
*/
|
||||||
|
export function buildUserColumns(attributes, rowActions) {
|
||||||
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
|
||||||
|
return [
|
||||||
|
buildSelectionColumn(),
|
||||||
|
...buildColumns(visibleAttributes),
|
||||||
|
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// config/selection.config.jsx
|
||||||
|
import { Download, Archive, Trash2 } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} deps
|
||||||
|
* @param {Object} deps.exportConfig { allData, attributes, filename, sheetName }
|
||||||
|
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||||
|
* @param {Function} deps.deleteUser Delete handler from useManagement
|
||||||
|
*/
|
||||||
|
export function buildSelectionActions({ exportConfig, archiveUser, deleteUser }) {
|
||||||
|
return [
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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({ fetchArchivedUsers, pagination, exportConfig, navigate, getFilters, getSort }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "refresh",
|
||||||
|
type: "button",
|
||||||
|
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||||
|
label: "Refresh",
|
||||||
|
onClick: () => fetchArchivedUsers({ 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 }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { Eye, Pencil, Archive } from "lucide-react";
|
|||||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||||
* @returns {Array} rowActions
|
* @returns {Array} rowActions
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ navigate, archiveUser }) {
|
export function buildRowActions({ navigate, onArchive }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -23,7 +23,7 @@ export function buildRowActions({ navigate, archiveUser }) {
|
|||||||
key: "edit",
|
key: "edit",
|
||||||
label: "Edit",
|
label: "Edit",
|
||||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => navigate(`/users/${row.id}/edit`),
|
onClick: (row) => navigate(`edit/${row.user_id}`),
|
||||||
disabled: (row) => row.role === "super_admin",
|
disabled: (row) => row.role === "super_admin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,8 +31,8 @@ export function buildRowActions({ navigate, archiveUser }) {
|
|||||||
label: "Archive",
|
label: "Archive",
|
||||||
className: "text-destructive focus:text-destructive",
|
className: "text-destructive focus:text-destructive",
|
||||||
icon: <Archive className="h-3.5 w-3.5" />,
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => archiveUser(row.id),
|
onClick: (row) => onArchive(row), // ← opens dialog, not deactivateUser
|
||||||
hidden: (row) => row.status === "archived",
|
hidden: (row) => !row.is_active, // ← hide if already inactive
|
||||||
separator: true,
|
separator: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { exportTableToExcel } from "@/utils/excel.util";
|
|||||||
* @param {Function} deps.archiveUser Archive handler from useManagement
|
* @param {Function} deps.archiveUser Archive handler from useManagement
|
||||||
* @param {Function} deps.deleteUser Delete handler from useManagement
|
* @param {Function} deps.deleteUser Delete handler from useManagement
|
||||||
*/
|
*/
|
||||||
export function buildSelectionActions({ exportConfig, archiveUser, deleteUser }) {
|
export function buildSelectionActions({ exportConfig, archiveUser }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "export-selected",
|
key: "export-selected",
|
||||||
@@ -21,16 +21,9 @@ export function buildSelectionActions({ exportConfig, archiveUser, deleteUser })
|
|||||||
key: "archive-selected",
|
key: "archive-selected",
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
icon: <Archive className="h-3.5 w-3.5" />,
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||||
onClick: (rows) => archiveUser({ ids: rows.map((r) => r.id) }),
|
onClick: (rows) => archiveUser({ ids: rows.map((r) => r.id) }),
|
||||||
hidden: (rows) => rows.every((r) => r.status === "archived"),
|
hidden: (rows) => rows.every((r) => r.status === "archived"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "delete-selected",
|
|
||||||
label: "Delete",
|
|
||||||
icon: <Trash2 className="h-3.5 w-3.5" />,
|
|
||||||
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
|
||||||
onClick: (rows) => deleteUser({ ids: rows.map((r) => r.id) }),
|
|
||||||
disabled: (rows) => rows.some((r) => r.role === "admin" || r.role === "super_admin"),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
|
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
|
||||||
import { RefreshCw, Download, UserPlus } from "lucide-react";
|
import { RefreshCw, Download, UserPlus, Archive } from "lucide-react";
|
||||||
import { exportTableToExcel } from "@/utils/excel.util";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,5 +34,14 @@ export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, expo
|
|||||||
className: "text-primary-foreground",
|
className: "text-primary-foreground",
|
||||||
onClick: () => navigate("add"),
|
onClick: () => navigate("add"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "archived-users",
|
||||||
|
type: "button",
|
||||||
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
|
label: "Archived Users",
|
||||||
|
variant: "secondary",
|
||||||
|
className: "border border-border",
|
||||||
|
onClick: () => navigate("archived"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { House } from "lucide-react";
|
||||||
|
|
||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import ArchiveUserTable from "../../components/ArchiveUserTable";
|
||||||
|
|
||||||
|
export default function ArchivedUserList() {
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
|
||||||
|
{ label: "Users", to: `/admin/users/all` },
|
||||||
|
{ 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">
|
||||||
|
<ArchiveUserTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// ─── pages/users/EditUser.jsx ─────────────────────────────────────────────────
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useUsers } from "@/contexts/AdminUserContext";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem,
|
||||||
|
SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
const addressSchema = z.object({
|
||||||
|
address_type: z.enum(["home", "work", "province", "other"]),
|
||||||
|
street: z.string().min(1, "Street is required."),
|
||||||
|
city: z.string().min(1, "City is required."),
|
||||||
|
state: z.string().min(1, "State is required."),
|
||||||
|
country: z.string().min(1, "Country is required."),
|
||||||
|
zip: z.string().min(1, "ZIP is required."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const phoneSchema = z.object({
|
||||||
|
phone_type: z.enum(["mobile", "home", "work", "other"]),
|
||||||
|
country_code: z.string().min(1, "Country code is required."),
|
||||||
|
number: z.string().min(1, "Number is required."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const editUserSchema = z.object({
|
||||||
|
acc_type: z.enum(["user", "staff", "admin"]),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
given_name: z.string().min(1, "First name is required."),
|
||||||
|
middle_name: z.string().optional(),
|
||||||
|
last_name: z.string().min(1, "Last name is required."),
|
||||||
|
extension_name: z.string().optional(),
|
||||||
|
date_of_birth: z.string().optional(),
|
||||||
|
occupation: z.string().optional(),
|
||||||
|
addresses: z.array(addressSchema).optional(),
|
||||||
|
phone_number: z.array(phoneSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
||||||
|
const DEFAULT_ADDRESS = {
|
||||||
|
address_type: "home", street: "", city: "",
|
||||||
|
state: "", country: "", zip: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PHONE = {
|
||||||
|
phone_type: "mobile", country_code: "63", number: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditUser() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, fetchUser, updateUser, loading } = useUsers();
|
||||||
|
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState(null);
|
||||||
|
const [avatarFile, setAvatarFile] = useState(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(editUserSchema),
|
||||||
|
defaultValues: {
|
||||||
|
acc_type: "user", is_active: true,
|
||||||
|
given_name: "", middle_name: "", last_name: "",
|
||||||
|
extension_name: "", date_of_birth: "", occupation: "",
|
||||||
|
addresses: [], phone_number: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Field arrays ──────────────────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
fields: addressFields,
|
||||||
|
append: appendAddress,
|
||||||
|
remove: removeAddress,
|
||||||
|
} = useFieldArray({ control, name: "addresses" });
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: phoneFields,
|
||||||
|
append: appendPhone,
|
||||||
|
remove: removePhone,
|
||||||
|
} = useFieldArray({ control, name: "phone_number" });
|
||||||
|
|
||||||
|
// ─── Fetch user ────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => { fetchUser(id); }, [id]);
|
||||||
|
|
||||||
|
// ─── Populate form ────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const info = user.personal_info ?? {};
|
||||||
|
const name = info.name ?? {};
|
||||||
|
|
||||||
|
setAvatarPreview(info.avatar?.url ?? null);
|
||||||
|
|
||||||
|
reset({
|
||||||
|
acc_type: user.acc_type ?? "user",
|
||||||
|
is_active: user.is_active ?? true,
|
||||||
|
given_name: name.given_name ?? "",
|
||||||
|
middle_name: name.middle_name ?? "",
|
||||||
|
last_name: name.last_name ?? "",
|
||||||
|
extension_name: name.extension_name ?? "",
|
||||||
|
date_of_birth: info.date_of_birth ?? "",
|
||||||
|
occupation: info.occupation ?? "",
|
||||||
|
addresses: (info.addresses ?? []).map(({ full_address: _, ...rest }) => rest),
|
||||||
|
phone_number: (info.phone_number ?? []).map(({ full_number: _, ...rest }) => rest),
|
||||||
|
});
|
||||||
|
}, [user, reset]);
|
||||||
|
|
||||||
|
// ─── Avatar preview ───────────────────────────────────────────────────────
|
||||||
|
const handleAvatarChange = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setAvatarFile(file);
|
||||||
|
setAvatarPreview(URL.createObjectURL(file));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Submit ───────────────────────────────────────────────────────────────
|
||||||
|
const onSubmit = async (values) => {
|
||||||
|
const payload = {
|
||||||
|
acc_type: values.acc_type,
|
||||||
|
is_active: values.is_active,
|
||||||
|
personal_info: {
|
||||||
|
...user.personal_info,
|
||||||
|
date_of_birth: values.date_of_birth,
|
||||||
|
occupation: values.occupation,
|
||||||
|
name: {
|
||||||
|
...user.personal_info?.name,
|
||||||
|
given_name: values.given_name,
|
||||||
|
middle_name: values.middle_name,
|
||||||
|
last_name: values.last_name,
|
||||||
|
extension_name: values.extension_name,
|
||||||
|
},
|
||||||
|
addresses: values.addresses,
|
||||||
|
phone_number: values.phone_number,
|
||||||
|
// ─── avatar handled separately via file upload API ────────────────
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await updateUser(id, payload);
|
||||||
|
if (res) {
|
||||||
|
toast.success("User updated successfully.");
|
||||||
|
navigate(`../view/${id}`);
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to update user.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !user) return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<Spinner className="size-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="lg:container lg:mx-auto px-4 py-6 flex flex-col gap-6"
|
||||||
|
>
|
||||||
|
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users/all`)}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Edit User</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={loading || !isDirty}>
|
||||||
|
{loading ? <Spinner className="size-4 mr-2" /> : <Save className="size-4 mr-2" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Avatar ──────────────────────────────────────────────────────── */}
|
||||||
|
<EditSection title="Profile Picture">
|
||||||
|
<div className="col-span-full flex items-center gap-6">
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="size-24 rounded-full border-2 border-border overflow-hidden bg-muted flex items-center justify-center shrink-0">
|
||||||
|
{avatarPreview
|
||||||
|
? <img src={avatarPreview} alt="Avatar" className="object-cover w-full h-full" />
|
||||||
|
: <UserCircle2 className="size-12 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Upload new photo
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
className="w-fit cursor-pointer text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
JPG, PNG or WEBP. Avatar upload requires a separate file upload API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EditSection>
|
||||||
|
|
||||||
|
{/* ─── Account Information ─────────────────────────────────────────── */}
|
||||||
|
<EditSection title="Account Information">
|
||||||
|
<EditField label="Account Type" error={errors.acc_type?.message} required>
|
||||||
|
<Controller
|
||||||
|
name="acc_type"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className={errors.acc_type ? "border-destructive" : ""}>
|
||||||
|
<SelectValue placeholder="Select type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
<SelectItem value="staff">Staff</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</EditField>
|
||||||
|
|
||||||
|
<EditField label="Status" error={errors.is_active?.message}>
|
||||||
|
<Controller
|
||||||
|
name="is_active"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
value={String(field.value)}
|
||||||
|
onValueChange={(v) => field.onChange(v === "true")}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">Active</SelectItem>
|
||||||
|
<SelectItem value="false">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</EditField>
|
||||||
|
</EditSection>
|
||||||
|
|
||||||
|
{/* ─── Personal Information ─────────────────────────────────────────── */}
|
||||||
|
<EditSection title="Personal Information">
|
||||||
|
<EditField label="First Name" error={errors.given_name?.message} required>
|
||||||
|
<Input {...register("given_name")} className={errors.given_name ? "border-destructive" : ""} placeholder="First name" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Middle Name" error={errors.middle_name?.message}>
|
||||||
|
<Input {...register("middle_name")} placeholder="Middle name" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Last Name" error={errors.last_name?.message} required>
|
||||||
|
<Input {...register("last_name")} className={errors.last_name ? "border-destructive" : ""} placeholder="Last name" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Extension Name" error={errors.extension_name?.message}>
|
||||||
|
<Input {...register("extension_name")} placeholder="e.g. Jr., Sr." />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Date of Birth" error={errors.date_of_birth?.message}>
|
||||||
|
<Input type="date" {...register("date_of_birth")} />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Occupation" error={errors.occupation?.message}>
|
||||||
|
<Input {...register("occupation")} placeholder="Occupation" />
|
||||||
|
</EditField>
|
||||||
|
</EditSection>
|
||||||
|
|
||||||
|
{/* ─── Addresses ───────────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Addresses
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => appendAddress(DEFAULT_ADDRESS)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 mr-1" /> Add Address
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addressFields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No addresses added.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addressFields.map((field, i) => (
|
||||||
|
<div key={field.id} className="border rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
{/* ─── Address Header ──────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Address {i + 1}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeAddress(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<EditField label="Type" error={errors.addresses?.[i]?.address_type?.message} required>
|
||||||
|
<Controller
|
||||||
|
name={`addresses.${i}.address_type`}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="home">Home</SelectItem>
|
||||||
|
<SelectItem value="work">Work</SelectItem>
|
||||||
|
<SelectItem value="province">Province</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Street" error={errors.addresses?.[i]?.street?.message} required>
|
||||||
|
<Input {...register(`addresses.${i}.street`)} placeholder="Street" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="City" error={errors.addresses?.[i]?.city?.message} required>
|
||||||
|
<Input {...register(`addresses.${i}.city`)} placeholder="City" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="State / Region" error={errors.addresses?.[i]?.state?.message} required>
|
||||||
|
<Input {...register(`addresses.${i}.state`)} placeholder="State / Region" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Country" error={errors.addresses?.[i]?.country?.message} required>
|
||||||
|
<Input {...register(`addresses.${i}.country`)} placeholder="Country" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="ZIP Code" error={errors.addresses?.[i]?.zip?.message} required>
|
||||||
|
<Input {...register(`addresses.${i}.zip`)} placeholder="ZIP Code" />
|
||||||
|
</EditField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Phone Numbers ───────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Phone Numbers
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => appendPhone(DEFAULT_PHONE)}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 mr-1" /> Add Phone
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phoneFields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No phone numbers added.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phoneFields.map((field, i) => (
|
||||||
|
<div key={field.id} className="border rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Phone {i + 1}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removePhone(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<EditField label="Type" error={errors.phone_number?.[i]?.phone_type?.message} required>
|
||||||
|
<Controller
|
||||||
|
name={`phone_number.${i}.phone_type`}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="mobile">Mobile</SelectItem>
|
||||||
|
<SelectItem value="home">Home</SelectItem>
|
||||||
|
<SelectItem value="work">Work</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Country Code" error={errors.phone_number?.[i]?.country_code?.message} required>
|
||||||
|
<Input {...register(`phone_number.${i}.country_code`)} placeholder="e.g. 63" />
|
||||||
|
</EditField>
|
||||||
|
<EditField label="Number" error={errors.phone_number?.[i]?.number?.message} required>
|
||||||
|
<Input {...register(`phone_number.${i}.number`)} placeholder="e.g. 9123456789" />
|
||||||
|
</EditField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reusable EditSection ─────────────────────────────────────────────────────
|
||||||
|
function EditSection({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reusable EditField ───────────────────────────────────────────────────────
|
||||||
|
function EditField({ label, error, required, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,160 @@
|
|||||||
import React from 'react'
|
// ─── pages/users/ViewUser.jsx ─────────────────────────────────────────────────
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { useUsers } from "@/contexts/AdminUserContext";
|
||||||
|
|
||||||
const ViewUser = () => {
|
import { Button } from "@/components/ui/button";
|
||||||
return (
|
import { Badge } from "@/components/ui/badge";
|
||||||
<div>
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
ViewUser
|
import { Pencil, ArrowLeft } from "lucide-react";
|
||||||
|
import { ROLE_CONFIG } from "@/data/profile.data";
|
||||||
|
import { BADGE_STYLES } from "@/utils/table.util";
|
||||||
|
|
||||||
|
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
const StatusBadge = ({ value }) => (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-md border text-xs font-medium ${BADGE_STYLES[value] ?? "bg-muted text-muted-foreground border-border"}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ViewUser() {
|
||||||
|
const { userId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, fetchUser, loading } = useUsers();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser(userId);
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<Spinner className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
|
if (!user) return (
|
||||||
|
<div className="flex items-center justify-center h-screen text-muted-foreground">
|
||||||
|
User not found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const info = user.personal_info ?? {};
|
||||||
|
const name = info.name ?? {};
|
||||||
|
const role = ROLE_CONFIG[user.acc_type] ?? { label: user.acc_type, variant: "outline" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:container lg:mx-auto px-4 py-6 flex flex-col gap-6">
|
||||||
|
|
||||||
|
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/all`)}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
{name.full_name ?? "—"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => navigate(`../edit/${userId}`)}>
|
||||||
|
<Pencil className="size-4 mr-2" /> Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Account Info ────────────────────────────────────────────────── */}
|
||||||
|
<Section title="Account Information">
|
||||||
|
<Field label="Account Type">
|
||||||
|
<StatusBadge value={user.acc_type} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<StatusBadge value={user.is_active ? "Active" : "Not Active"} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Verified">
|
||||||
|
<StatusBadge value={user.is_verified ? "Verified" : "Not Verified"} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Registration Type">
|
||||||
|
<StatusBadge value={user.reg_type} />
|
||||||
|
</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Personal Info ───────────────────────────────────────────────── */}
|
||||||
|
<Section title="Personal Information">
|
||||||
|
<Field label="First Name">{name.given_name ?? "—"}</Field>
|
||||||
|
<Field label="Middle Name">{name.middle_name ?? "—"}</Field>
|
||||||
|
<Field label="Last Name">{name.last_name ?? "—"}</Field>
|
||||||
|
<Field label="Extension">{name.extension_name ?? "—"}</Field>
|
||||||
|
<Field label="Date of Birth">{info.date_of_birth ?? "—"}</Field>
|
||||||
|
<Field label="Occupation">{info.occupation ?? "—"}</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ─── Addresses ───────────────────────────────────────────────────── */}
|
||||||
|
{info.addresses?.length > 0 && (
|
||||||
|
<Section title="Addresses">
|
||||||
|
{info.addresses.map((addr, i) => (
|
||||||
|
<Field key={i} label={`${addr.address_type} address`} capitalize>
|
||||||
|
{addr.full_address ?? "—"}
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Phone Numbers ───────────────────────────────────────────────── */}
|
||||||
|
{info.phone_number?.length > 0 && (
|
||||||
|
<Section title="Phone Numbers">
|
||||||
|
{info.phone_number.map((ph, i) => (
|
||||||
|
<Field key={i} label={`${ph.phone_type} number`} capitalize>
|
||||||
|
+{ph.full_number ?? "—"}
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Groups ──────────────────────────────────────────────────────── */}
|
||||||
|
{user.groups?.length > 0 && (
|
||||||
|
<Section title="Groups">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{user.groups.map((g) => (
|
||||||
|
<Badge key={g.group_id} variant="outline">{g.name}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Audit ───────────────────────────────────────────────────────── */}
|
||||||
|
<Section title="Audit Trail">
|
||||||
|
<Field label="Created At">{user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}</Field>
|
||||||
|
<Field label="Updated At">{user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "—"}</Field>
|
||||||
|
<Field label="Deleted At">{user.deletedAt ? new Date(user.deletedAt).toLocaleString() : "—"}</Field>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ViewUser
|
// ─── Reusable Section ─────────────────────────────────────────────────────────
|
||||||
|
function Section({ title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reusable Field ───────────────────────────────────────────────────────────
|
||||||
|
function Field({ label, children, capitalize }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className={`text-sm font-medium ${capitalize ? "capitalize" : ""}`}>
|
||||||
|
{children ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ 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 ArchivedUserList from '../pages/users/ArchivedUserList'
|
||||||
|
|
||||||
|
|
||||||
export const AdminRoutes = {
|
export const AdminRoutes = {
|
||||||
@@ -45,7 +47,9 @@ export const AdminRoutes = {
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <UserList /> },
|
{ index: true, element: <UserList /> },
|
||||||
{ path: 'add', element: <AddUser /> },
|
{ path: 'add', element: <AddUser /> },
|
||||||
{ path: 'view/:userId', element: <ViewUser /> }
|
{ path: 'view/:userId', element: <ViewUser /> },
|
||||||
|
{ path: 'edit/:userId', element: <EditUser /> },
|
||||||
|
{ path: 'archived', element: <ArchivedUserList /> },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const { user } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
switch (user?.acc_type) {
|
navigate(-1)
|
||||||
case 'admin': navigate('/admin'); break
|
|
||||||
case 'client': navigate('/client'); break
|
|
||||||
case 'staff': navigate('/staff'); break
|
|
||||||
default: navigate(-1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
|
||||||
|
|
||||||
export default function Unauthorized() {
|
export default function Unauthorized() {
|
||||||
const { user } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
switch (user?.acc_type) {
|
navigate(-1)
|
||||||
case 'admin': navigate('/admin'); break
|
|
||||||
case 'client': navigate('/client'); break
|
|
||||||
case 'staff': navigate('/staff'); break
|
|
||||||
default: navigate(-1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Search } from "lucide-react";
|
|||||||
export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000]
|
export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000]
|
||||||
|
|
||||||
// ── Badge style map for enum values ───────────────────────────────────────────
|
// ── Badge style map for enum values ───────────────────────────────────────────
|
||||||
const BADGE_STYLES = {
|
export const BADGE_STYLES = {
|
||||||
"Active": "bg-emerald-100 text-emerald-800 border-emerald-200",
|
"Active": "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
"Not Active": "bg-rose-100 text-rose-700 border-rose-200",
|
"Not Active": "bg-rose-100 text-rose-700 border-rose-200",
|
||||||
"Verified": "bg-blue-100 text-blue-800 border-blue-200",
|
"Verified": "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
|||||||
Reference in New Issue
Block a user