diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx index 2fe5ec0..b7bd755 100644 --- a/src/contexts/AdminUserContext.jsx +++ b/src/contexts/AdminUserContext.jsx @@ -31,7 +31,7 @@ export const UserProvider = ({ children }) => { const [attributes, setAttributes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - + // ─── Helpers ─────────────────────────────────────────────────────────────── const request = useCallback(async (fn) => { setLoading(true); @@ -112,6 +112,29 @@ export const UserProvider = ({ children }) => { [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 ──────────────────────────────────── const restoreUser = useCallback( (userId) => @@ -182,6 +205,7 @@ export const UserProvider = ({ children }) => { // actions fetchUserFieldValues, fetchUsers, + fetchArchivedUsers, fetchUser, updateUser, deactivateUser, diff --git a/src/modules/admin/components/ArchiveUserDialog.jsx b/src/modules/admin/components/ArchiveUserDialog.jsx new file mode 100644 index 0000000..52fe355 --- /dev/null +++ b/src/modules/admin/components/ArchiveUserDialog.jsx @@ -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 ( + + + + Archive User + + Are you sure you want to archive{" "} + + {user?.personal_info?.name?.full_name ?? user?.email} + + ? They will be deactivated and lose access immediately. + + + + Cancel + + {loading ? : null} + Archive + + + + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/ArchiveUserTable.jsx b/src/modules/admin/components/ArchiveUserTable.jsx new file mode 100644 index 0000000..6d02fd7 --- /dev/null +++ b/src/modules/admin/components/ArchiveUserTable.jsx @@ -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 ( + <> + tableRefsRef.current = refs} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="user" + emptyMessage="No users match the current filters." + /> + !v && setRestoreTarget(null)} + user={restoreTarget} + onSuccess={() => { + setRestoreTarget(null); + fetchArchivedUsers({ page: 1, limit: pagination.limit }); + }} + /> + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/RestoreUserDialog.jsx b/src/modules/admin/components/RestoreUserDialog.jsx new file mode 100644 index 0000000..0ec356b --- /dev/null +++ b/src/modules/admin/components/RestoreUserDialog.jsx @@ -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 ( + + + + Restore User + + Are you sure you want to restore{" "} + + {user?.personal_info?.name?.full_name ?? user?.email} + + ? They will regain access to their account immediately. + + + + Cancel + + {loading ? : null} + Restore + + + + + ); +} \ No newline at end of file diff --git a/src/modules/admin/components/UserTable.jsx b/src/modules/admin/components/UserTable.jsx index 4ff2fc8..2cdc44c 100644 --- a/src/modules/admin/components/UserTable.jsx +++ b/src/modules/admin/components/UserTable.jsx @@ -1,8 +1,9 @@ -import { useMemo, useRef } from "react"; +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 { ArchiveUserDialog } from "./ArchiveUserDialog"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { buildUserColumns, columnPinning } from "../config/columns.config"; @@ -13,9 +14,10 @@ import { buildRowActions } from "../config/rowActions.config"; import { getTimestamp } from "@/utils/timestamp.util"; export default function UsersTable() { - const navigate = useNavigate(); + const [archiveTarget, setArchiveTarget] = useState(null); const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [] }); - + const navigate = useNavigate(); + const { users, attributes, @@ -24,7 +26,7 @@ export default function UsersTable() { loading, fetchUsers, fetchUserFieldValues, - deactivateUser, + deactivateUser } = useUsers(); // Shared export config — passed into toolbar + selection configs @@ -35,7 +37,7 @@ export default function UsersTable() { sheetName: "Users", }; - const rowActions = buildRowActions({ navigate, deactivateUser }); + const rowActions = buildRowActions({ navigate, onArchive: (row) => setArchiveTarget(row) }); const toolbarActions = buildToolbarActions({ fetchUsers, pagination, exportConfig, navigate, getFilters: () => tableRefsRef.current.getFilters(), @@ -45,32 +47,43 @@ export default function UsersTable() { const columns = useMemo(() => buildUserColumns(attributes, rowActions), [attributes]); return ( - tableRefsRef.current = refs} - renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( - - )} - columnPinning={columnPinning} - toolbarActions={toolbarActions} - selectionActions={selectionActions} - recordLabel="user" - emptyMessage="No users match the current filters." - /> + <> + tableRefsRef.current = refs} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="user" + emptyMessage="No users match the current filters." + /> + !v && setArchiveTarget(null)} + user={archiveTarget} + onSuccess={() => { + setArchiveTarget(null); + fetchUsers({ page: 1, limit: pagination.limit }); + }} + /> + ); } \ No newline at end of file diff --git a/src/modules/admin/config/archive/columns.config.jsx b/src/modules/admin/config/archive/columns.config.jsx new file mode 100644 index 0000000..8ac8c6b --- /dev/null +++ b/src/modules/admin/config/archive/columns.config.jsx @@ -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" }), + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/archive/rowActions.config.jsx b/src/modules/admin/config/archive/rowActions.config.jsx new file mode 100644 index 0000000..3d96d9f --- /dev/null +++ b/src/modules/admin/config/archive/rowActions.config.jsx @@ -0,0 +1,25 @@ +// config/rowActions.config.jsx +// Per-row kebab menu action definitions for the Users table. +// +// Each onClick receives the row's data object from buildRowActionsColumn. + +import { RotateCcw } from "lucide-react"; + +/** + * @param {Object} deps + * @param {Function} deps.navigate React Router navigate + * @param {Function} deps.archiveUser Archive handler from useManagement + * @returns {Array} rowActions + */ +export function buildRowActions({ navigate, onRestore }) { + return [ + { + key: "restore", + label: "Restore", + className: "text-emerald-600 focus:text-emerald-600", + icon: , + onClick: (row) => onRestore(row), + hidden: (row) => row.is_active, + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/archive/selection.config.jsx b/src/modules/admin/config/archive/selection.config.jsx new file mode 100644 index 0000000..db825c1 --- /dev/null +++ b/src/modules/admin/config/archive/selection.config.jsx @@ -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 [ + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/archive/toolbar.config.jsx b/src/modules/admin/config/archive/toolbar.config.jsx new file mode 100644 index 0000000..934c7d6 --- /dev/null +++ b/src/modules/admin/config/archive/toolbar.config.jsx @@ -0,0 +1,29 @@ +// ─── config/toolbar.config.jsx ──────────────────────────────────────────────── +import { RefreshCw, Download, UserPlus } from "lucide-react"; +import { exportTableToExcel } from "@/utils/excel.util"; + +/** + * @param {Object} deps + * @param {Function} deps.fetchUsers Refetch handler from useUsers + * @param {Object} deps.pagination Current pagination state + * @param {Object} deps.exportConfig { allData, attributes, filename, sheetName } + * @param {Function} deps.navigate React Router navigate + */ +export function buildToolbarActions({ fetchArchivedUsers, pagination, exportConfig, navigate, getFilters, getSort }) { + return [ + { + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => fetchArchivedUsers({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort()}), + }, + { + key: "export", + type: "button", + icon: , + label: "Export", + onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }), + }, + ]; +} \ No newline at end of file diff --git a/src/modules/admin/config/rowActions.config.jsx b/src/modules/admin/config/rowActions.config.jsx index 077f775..a58e619 100644 --- a/src/modules/admin/config/rowActions.config.jsx +++ b/src/modules/admin/config/rowActions.config.jsx @@ -11,7 +11,7 @@ import { Eye, Pencil, Archive } from "lucide-react"; * @param {Function} deps.archiveUser Archive handler from useManagement * @returns {Array} rowActions */ -export function buildRowActions({ navigate, archiveUser }) { +export function buildRowActions({ navigate, onArchive }) { return [ { key: "view", @@ -23,7 +23,7 @@ export function buildRowActions({ navigate, archiveUser }) { key: "edit", label: "Edit", icon: , - onClick: (row) => navigate(`/users/${row.id}/edit`), + onClick: (row) => navigate(`edit/${row.user_id}`), disabled: (row) => row.role === "super_admin", }, { @@ -31,8 +31,8 @@ export function buildRowActions({ navigate, archiveUser }) { label: "Archive", className: "text-destructive focus:text-destructive", icon: , - onClick: (row) => archiveUser(row.id), - hidden: (row) => row.status === "archived", + onClick: (row) => onArchive(row), // ← opens dialog, not deactivateUser + hidden: (row) => !row.is_active, // ← hide if already inactive separator: true, }, ]; diff --git a/src/modules/admin/config/selection.config.jsx b/src/modules/admin/config/selection.config.jsx index 5097ed4..f39e7b1 100644 --- a/src/modules/admin/config/selection.config.jsx +++ b/src/modules/admin/config/selection.config.jsx @@ -8,7 +8,7 @@ import { exportTableToExcel } from "@/utils/excel.util"; * @param {Function} deps.archiveUser Archive handler from useManagement * @param {Function} deps.deleteUser Delete handler from useManagement */ -export function buildSelectionActions({ exportConfig, archiveUser, deleteUser }) { +export function buildSelectionActions({ exportConfig, archiveUser }) { return [ { key: "export-selected", @@ -21,16 +21,9 @@ export function buildSelectionActions({ exportConfig, archiveUser, deleteUser }) key: "archive-selected", label: "Archive", icon: , + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", onClick: (rows) => archiveUser({ ids: rows.map((r) => r.id) }), hidden: (rows) => rows.every((r) => r.status === "archived"), }, - { - key: "delete-selected", - label: "Delete", - icon: , - 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"), - }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/toolbar.config.jsx b/src/modules/admin/config/toolbar.config.jsx index 7cc0cb3..28d285f 100644 --- a/src/modules/admin/config/toolbar.config.jsx +++ b/src/modules/admin/config/toolbar.config.jsx @@ -1,5 +1,5 @@ // ─── 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"; /** @@ -12,27 +12,36 @@ import { exportTableToExcel } from "@/utils/excel.util"; export function buildToolbarActions({ fetchUsers, refetchUsers, pagination, exportConfig, navigate, getFilters, getSort }) { return [ { - key: "refresh", - type: "button", - icon: , - label: "Refresh", - onClick: () => fetchUsers({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort()}), + key: "refresh", + type: "button", + icon: , + label: "Refresh", + onClick: () => fetchUsers({ page: 1, limit: pagination.limit, filters: getFilters(), sort: getSort() }), }, { - key: "export", - type: "button", - icon: , - label: "Export", + key: "export", + type: "button", + icon: , + label: "Export", onClick: (table) => exportTableToExcel({ ...exportConfig, tableInstance: table }), }, { - key: "add-user", - type: "button", - icon: , - label: "Add User", - variant: "default", + key: "add-user", + type: "button", + icon: , + label: "Add User", + variant: "default", className: "text-primary-foreground", - onClick: () => navigate("add"), + onClick: () => navigate("add"), + }, + { + key: "archived-users", + type: "button", + icon: , + label: "Archived Users", + variant: "secondary", + className: "border border-border", + onClick: () => navigate("archived"), }, ]; } \ No newline at end of file diff --git a/src/modules/admin/pages/users/ArchivedUserList.jsx b/src/modules/admin/pages/users/ArchivedUserList.jsx new file mode 100644 index 0000000..03d91cc --- /dev/null +++ b/src/modules/admin/pages/users/ArchivedUserList.jsx @@ -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: , to: `/admin/users` }, + { label: "Users", to: `/admin/users/all` }, + { label: "Archived" }, + ] + + return ( +
+
+
+ +
+ +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/src/modules/admin/pages/users/EditUser.jsx b/src/modules/admin/pages/users/EditUser.jsx new file mode 100644 index 0000000..8c4c2cb --- /dev/null +++ b/src/modules/admin/pages/users/EditUser.jsx @@ -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 ( +
+ +
+ ); + + return ( +
+ {/* ─── Header ──────────────────────────────────────────────────────── */} +
+
+ +
+

Edit User

+

{user?.email}

+
+
+ +
+ + {/* ─── Avatar ──────────────────────────────────────────────────────── */} + +
+ {/* Preview */} +
+ {avatarPreview + ? Avatar + : + } +
+ + {/* Upload */} +
+ + +

+ JPG, PNG or WEBP. Avatar upload requires a separate file upload API. +

+
+
+
+ + {/* ─── Account Information ─────────────────────────────────────────── */} + + + ( + + )} + /> + + + + ( + + )} + /> + + + + {/* ─── Personal Information ─────────────────────────────────────────── */} + + + + + + + + + + + + + + + + + + + + + + {/* ─── Addresses ───────────────────────────────────────────────────── */} +
+
+

+ Addresses +

+ +
+ + {addressFields.length === 0 && ( +

No addresses added.

+ )} + + {addressFields.map((field, i) => ( +
+ {/* ─── Address Header ──────────────────────────────────────── */} +
+ Address {i + 1} + +
+ +
+ + ( + + )} + /> + + + + + + + + + + + + + + + + +
+
+ ))} +
+ + {/* ─── Phone Numbers ───────────────────────────────────────────────── */} +
+
+

+ Phone Numbers +

+ +
+ + {phoneFields.length === 0 && ( +

No phone numbers added.

+ )} + + {phoneFields.map((field, i) => ( +
+
+ Phone {i + 1} + +
+ +
+ + ( + + )} + /> + + + + + + + +
+
+ ))} +
+ +
+ ); +} + +// ─── Reusable EditSection ───────────────────────────────────────────────────── +function EditSection({ title, children }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +// ─── Reusable EditField ─────────────────────────────────────────────────────── +function EditField({ label, error, required, children }) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ); +} \ No newline at end of file diff --git a/src/modules/admin/pages/users/ViewUser.jsx b/src/modules/admin/pages/users/ViewUser.jsx index e16606a..9433ad4 100644 --- a/src/modules/admin/pages/users/ViewUser.jsx +++ b/src/modules/admin/pages/users/ViewUser.jsx @@ -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 = () => { - return ( -
- ViewUser +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Spinner } from "@/components/ui/spinner"; +import { Pencil, ArrowLeft } from "lucide-react"; +import { ROLE_CONFIG } from "@/data/profile.data"; +import { BADGE_STYLES } from "@/utils/table.util"; + +// ─── Helper ─────────────────────────────────────────────────────────────────── +const StatusBadge = ({ value }) => ( + + {value} + +); + +export default function ViewUser() { + const { userId } = useParams(); + const navigate = useNavigate(); + const { user, fetchUser, loading } = useUsers(); + + useEffect(() => { + fetchUser(userId); + }, [userId]); + + if (loading) return ( +
+
- ) + ); + + if (!user) return ( +
+ User not found. +
+ ); + + const info = user.personal_info ?? {}; + const name = info.name ?? {}; + const role = ROLE_CONFIG[user.acc_type] ?? { label: user.acc_type, variant: "outline" }; + + return ( +
+ + {/* ─── Header ──────────────────────────────────────────────────────── */} +
+
+ +
+

+ {name.full_name ?? "—"} +

+

{user.email}

+
+
+ +
+ + {/* ─── Account Info ────────────────────────────────────────────────── */} +
+ + + + + + + + + + + + +
+ + {/* ─── Personal Info ───────────────────────────────────────────────── */} +
+ {name.given_name ?? "—"} + {name.middle_name ?? "—"} + {name.last_name ?? "—"} + {name.extension_name ?? "—"} + {info.date_of_birth ?? "—"} + {info.occupation ?? "—"} +
+ + {/* ─── Addresses ───────────────────────────────────────────────────── */} + {info.addresses?.length > 0 && ( +
+ {info.addresses.map((addr, i) => ( + + {addr.full_address ?? "—"} + + ))} +
+ )} + + {/* ─── Phone Numbers ───────────────────────────────────────────────── */} + {info.phone_number?.length > 0 && ( +
+ {info.phone_number.map((ph, i) => ( + + +{ph.full_number ?? "—"} + + ))} +
+ )} + + {/* ─── Groups ──────────────────────────────────────────────────────── */} + {user.groups?.length > 0 && ( +
+
+ {user.groups.map((g) => ( + {g.name} + ))} +
+
+ )} + + {/* ─── Audit ───────────────────────────────────────────────────────── */} +
+ {user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"} + {user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "—"} + {user.deletedAt ? new Date(user.deletedAt).toLocaleString() : "—"} +
+ +
+ ); } -export default ViewUser +// ─── Reusable Section ───────────────────────────────────────────────────────── +function Section({ title, children }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +// ─── Reusable Field ─────────────────────────────────────────────────────────── +function Field({ label, children, capitalize }) { + return ( +
+ {label} + + {children ?? "—"} + +
+ ); +} \ No newline at end of file diff --git a/src/modules/admin/routes/AdminRoutes.jsx b/src/modules/admin/routes/AdminRoutes.jsx index 706a914..40c40c8 100644 --- a/src/modules/admin/routes/AdminRoutes.jsx +++ b/src/modules/admin/routes/AdminRoutes.jsx @@ -18,6 +18,8 @@ import ViewUser from '../pages/users/ViewUser' import GroupList from '../pages/user_groups/GroupList' import ViewGroup from '../pages/user_groups/ViewGroup' +import EditUser from '../pages/users/EditUser' +import ArchivedUserList from '../pages/users/ArchivedUserList' export const AdminRoutes = { @@ -45,7 +47,9 @@ export const AdminRoutes = { children: [ { index: true, element: }, { path: 'add', element: }, - { path: 'view/:userId', element: } + { path: 'view/:userId', element: }, + { path: 'edit/:userId', element: }, + { path: 'archived', element: }, ] }, diff --git a/src/modules/public/pages/NotFound.jsx b/src/modules/public/pages/NotFound.jsx index 0fbbfd4..c62ef53 100644 --- a/src/modules/public/pages/NotFound.jsx +++ b/src/modules/public/pages/NotFound.jsx @@ -1,17 +1,10 @@ import { useNavigate } from 'react-router-dom' -import { useAuth } from '@/contexts/AuthContext' export default function NotFound() { - const { user } = useAuth() const navigate = useNavigate() const goHome = () => { - switch (user?.acc_type) { - case 'admin': navigate('/admin'); break - case 'client': navigate('/client'); break - case 'staff': navigate('/staff'); break - default: navigate(-1) - } + navigate(-1) } return ( diff --git a/src/modules/public/pages/Unauthorized.jsx b/src/modules/public/pages/Unauthorized.jsx index a8772ac..9eb3bb6 100644 --- a/src/modules/public/pages/Unauthorized.jsx +++ b/src/modules/public/pages/Unauthorized.jsx @@ -1,17 +1,10 @@ import { useNavigate } from 'react-router-dom' -import { useAuth } from '@/contexts/AuthContext' export default function Unauthorized() { - const { user } = useAuth() const navigate = useNavigate() const goHome = () => { - switch (user?.acc_type) { - case 'admin': navigate('/admin'); break - case 'client': navigate('/client'); break - case 'staff': navigate('/staff'); break - default: navigate(-1) - } + navigate(-1) } return ( diff --git a/src/utils/table.util.jsx b/src/utils/table.util.jsx index b8df918..d0ecb26 100644 --- a/src/utils/table.util.jsx +++ b/src/utils/table.util.jsx @@ -11,7 +11,7 @@ import { Search } from "lucide-react"; export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000] // ── Badge style map for enum values ─────────────────────────────────────────── -const BADGE_STYLES = { +export const BADGE_STYLES = { "Active": "bg-emerald-100 text-emerald-800 border-emerald-200", "Not Active": "bg-rose-100 text-rose-700 border-rose-200", "Verified": "bg-blue-100 text-blue-800 border-blue-200",