From d2bf20b77f51bacd1b4a39fe344c095870678215 Mon Sep 17 00:00:00 2001 From: Thom Hickey Date: Tue, 10 Dec 2024 18:33:46 -0800 Subject: [PATCH] [Closes #51] Admin panel + new PaginatedTable component + some fixes (#474) * basic user table working with sort and search and pagination all handled back-end * feat: adding users * fix: types * feat: edit users * feat: edit user, role dropdown * fix: styling + search query counts/pagination * fix: styling * fix: role init + sharing types backend/frontend * fix: lint * feat: better error handling ux + toas fixes * fix: table2 refactor + breadcrumb + linting * fix: prettier * fix: prettier * fix: types * fix: some cleanup + user can't edit their own role * fix: rename new table * fix: code cleanup + case handling of roles cleaned up * fix: unused import * Fix lint errors --------- Co-authored-by: Francis Li Co-authored-by: Francis Li --- src/backend/routers/user.ts | 180 +++++++++++++++- src/components/CustomToast.tsx | 52 +++-- .../design_system/breadcrumbs/Breadcrumbs.tsx | 6 +- .../design_system/dropdown/Dropdown.tsx | 11 +- src/components/navbar/NavBar.tsx | 2 + src/components/styles/Toast.module.css | 1 + src/components/table/PaginatedTable.tsx | 195 ++++++++++++++++++ src/components/table/renderers.tsx | 63 ++++++ src/components/table/types.ts | 42 ++++ src/pages/_app.tsx | 50 +++-- src/pages/admin/[user_id].tsx | 188 +++++++++++++++++ src/pages/admin/index.tsx | 116 ++++++++++- src/theme.ts | 31 +++ src/types/auth.ts | 16 ++ 14 files changed, 899 insertions(+), 54 deletions(-) create mode 100644 src/components/table/PaginatedTable.tsx create mode 100644 src/components/table/renderers.tsx create mode 100644 src/components/table/types.ts create mode 100644 src/pages/admin/[user_id].tsx diff --git a/src/backend/routers/user.ts b/src/backend/routers/user.ts index 0c0bfdb3..16910d21 100644 --- a/src/backend/routers/user.ts +++ b/src/backend/routers/user.ts @@ -1,4 +1,29 @@ -import { hasAuthenticated, router } from "../trpc"; +import { hasAuthenticated, hasAdmin, router } from "../trpc"; +import { z } from "zod"; +import { UserType, ROLE_OPTIONS } from "@/types/auth"; +import { TRPCError } from "@trpc/server"; + +export const sortOrderSchema = z.enum(["asc", "desc"]).default("asc"); +export const sortBySchema = z + .enum(["first_name", "last_name", "email", "role"]) + .default("first_name"); + +const paginationInput = z.object({ + page: z.number().min(1).default(1), + pageSize: z.number().min(1).default(10), + sortBy: sortBySchema, + sortOrder: sortOrderSchema, + search: z.string().optional(), +}); + +const createUserSchema = z.object({ + first_name: z.string(), + last_name: z.string(), + email: z.string().email(), + role: z.string(), +}); + +const roleValues = ROLE_OPTIONS.map((r) => r.value) as [string, ...string[]]; export const user = router({ getMe: hasAuthenticated.query(async (req) => { @@ -20,6 +45,64 @@ export const user = router({ return user; }), + getUsers: hasAdmin.input(paginationInput).query(async (req) => { + const { page, pageSize, sortBy, sortOrder, search } = req.input; + const offset = (page - 1) * pageSize; + + let baseQuery = req.ctx.db + .selectFrom("user") + .select([ + "user_id", + "first_name", + "last_name", + "email", + "image_url", + "role", + ]); + + if (search) { + baseQuery = baseQuery.where((eb) => + eb.or([ + eb("first_name", "ilike", `%${search}%`), + eb("last_name", "ilike", `%${search}%`), + eb("email", "ilike", `%${search}%`), + eb("role", "ilike", `%${search}%`), + ]) + ); + } + + // Separate count query + let countQuery = req.ctx.db + .selectFrom("user") + .select(req.ctx.db.fn.countAll().as("count")); + + // Apply search filter to count query if exists + if (search) { + countQuery = countQuery.where((eb) => + eb.or([ + eb("first_name", "ilike", `%${search}%`), + eb("last_name", "ilike", `%${search}%`), + eb("email", "ilike", `%${search}%`), + eb("role", "ilike", `%${search}%`), + ]) + ); + } + + const [users, totalCount] = await Promise.all([ + baseQuery + .orderBy(sortBy, sortOrder) + .limit(pageSize) + .offset(offset) + .execute(), + countQuery.executeTakeFirst(), + ]); + + return { + users, + totalCount: Number(totalCount?.count ?? 0), + totalPages: Math.ceil(Number(totalCount?.count ?? 0) / pageSize), + }; + }), /** * @returns Whether the current user is a case manager */ @@ -34,4 +117,99 @@ export const user = router({ return result.length > 0; }), + + createUser: hasAdmin.input(createUserSchema).mutation(async (req) => { + const { first_name, last_name, email, role } = req.input; + + // Check if user already exists + const existingUser = await req.ctx.db + .selectFrom("user") + .where("email", "=", email) + .selectAll() + .executeTakeFirst(); + + if (existingUser) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "User with this email already exists", + }); + } + + const user = await req.ctx.db + .insertInto("user") + .values({ + first_name, + last_name, + email, + role, + }) + .returningAll() + .executeTakeFirstOrThrow(); + + return user; + }), + + getUserById: hasAdmin + .input(z.object({ user_id: z.string() })) + .query(async (req) => { + const { user_id } = req.input; + + return await req.ctx.db + .selectFrom("user") + .selectAll() + .where("user_id", "=", user_id) + .executeTakeFirstOrThrow(); + }), + + editUser: hasAdmin + .input( + z.object({ + user_id: z.string(), + first_name: z.string(), + last_name: z.string(), + email: z.string().email(), + role: z.enum(roleValues).transform((role) => { + switch (role) { + case "admin": + return UserType.Admin; + case "case_manager": + return UserType.CaseManager; + case "para": + return UserType.Para; + default: + return UserType.User; + } + }), + }) + ) + .mutation(async (req) => { + const { user_id, first_name, last_name, email, role } = req.input; + + const { userId } = req.ctx.auth; + + const dbUser = await req.ctx.db + .selectFrom("user") + .where("user_id", "=", user_id) + .selectAll() + .executeTakeFirstOrThrow(); + + if (userId === user_id && dbUser.role !== (role as string)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You cannot change your own role", + }); + } + + return await req.ctx.db + .updateTable("user") + .set({ + first_name, + last_name, + email: email.toLowerCase(), + role, + }) + .where("user_id", "=", user_id) + .returningAll() + .executeTakeFirstOrThrow(); + }), }); diff --git a/src/components/CustomToast.tsx b/src/components/CustomToast.tsx index fea3e8e2..31afc256 100644 --- a/src/components/CustomToast.tsx +++ b/src/components/CustomToast.tsx @@ -1,43 +1,37 @@ -import React, { useState } from "react"; +import React from "react"; import styles from "./styles/Toast.module.css"; import Image from "next/image"; interface CustomToastProps { errorMessage: string; + onClose: () => void; } -const CustomToast = ({ errorMessage }: CustomToastProps) => { - const [showToast, setShowToast] = useState(true); - +const CustomToast = ({ errorMessage, onClose }: CustomToastProps) => { const handleCloseToast = () => { - setShowToast(false); + onClose(); }; return ( - <> - {showToast && ( -
-
- Error Img -
{errorMessage ?? null}
- - -
-
- )} - +
+
+ Error Img +
{errorMessage}
+ +
+
); }; diff --git a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx index 7c2cb884..0a447ddc 100644 --- a/src/components/design_system/breadcrumbs/Breadcrumbs.tsx +++ b/src/components/design_system/breadcrumbs/Breadcrumbs.tsx @@ -22,8 +22,12 @@ const BreadcrumbsNav = () => { { user_id: paths[2] }, { enabled: Boolean(paths[2] && paths[1] === "staff") } ); + const { data: user } = trpc.user.getUserById.useQuery( + { user_id: paths[2] }, + { enabled: Boolean(paths[2] && paths[1] === "admin") } + ); - const personData: Student | Para | undefined = student || para; + const personData: Student | Para | undefined = student || para || user; // An array of breadcrumbs fixed to students/staff as the first index. This will be modified depending on how the address bar will be displayed. const breadcrumbs = paths.map((path, index) => { diff --git a/src/components/design_system/dropdown/Dropdown.tsx b/src/components/design_system/dropdown/Dropdown.tsx index c4625605..69940c96 100644 --- a/src/components/design_system/dropdown/Dropdown.tsx +++ b/src/components/design_system/dropdown/Dropdown.tsx @@ -24,7 +24,7 @@ interface DropdownProps { optionDisabled?: string[]; } -const Dropdown = ({ +export const Dropdown = ({ itemList, selectedOption, setSelectedOption, @@ -38,7 +38,6 @@ const Dropdown = ({ }; return ( - // Minimum styles used. More can be defined in className. {label} @@ -48,8 +47,13 @@ const Dropdown = ({ value={selectedOption} label={label} onChange={handleChange} - // Allow disabling of form disabled={formDisabled} + MenuProps={{ + PaperProps: { + elevation: 1, + sx: { maxHeight: 300 }, + }, + }} > {itemList?.map((item) => ( {item.label} diff --git a/src/components/navbar/NavBar.tsx b/src/components/navbar/NavBar.tsx index 4bc04c20..5bd641d0 100644 --- a/src/components/navbar/NavBar.tsx +++ b/src/components/navbar/NavBar.tsx @@ -3,6 +3,7 @@ import PeopleOutline from "@mui/icons-material/PeopleOutline"; import Logout from "@mui/icons-material/Logout"; import MenuIcon from "@mui/icons-material/Menu"; import SchoolOutlined from "@mui/icons-material/SchoolOutlined"; +import AdminPanelSettings from "@mui/icons-material/AdminPanelSettings"; import ContentPaste from "@mui/icons-material/ContentPaste"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import AppBar from "@mui/material/AppBar"; @@ -112,6 +113,7 @@ export default function NavBar() { icon={} text="Settings" /> + } text="Admin" /> } text="Logout" diff --git a/src/components/styles/Toast.module.css b/src/components/styles/Toast.module.css index 33aa4e5a..2c12d370 100644 --- a/src/components/styles/Toast.module.css +++ b/src/components/styles/Toast.module.css @@ -3,6 +3,7 @@ bottom: 0; right: 0; width: 400px; + z-index: 9999; } .customToast { diff --git a/src/components/table/PaginatedTable.tsx b/src/components/table/PaginatedTable.tsx new file mode 100644 index 00000000..67f195a7 --- /dev/null +++ b/src/components/table/PaginatedTable.tsx @@ -0,0 +1,195 @@ +import React, { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Box, + Button, + TableSortLabel, + TextField, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { visuallyHidden } from "@mui/utils"; +import SearchIcon from "@mui/icons-material/Search"; +import { TableProps, UserBase } from "./types"; +import { renderTableInput, renderTableCell } from "./renderers"; +import $table from "./Table.module.css"; +import $button from "@/components/design_system/button/Button.module.css"; + +const StyledTableRow = styled(TableRow)(() => ({ + "&:nth-of-type(odd)": { + backgroundColor: "var(--grey-90)", + }, + "&:hover": { + backgroundColor: "lightgray", + cursor: "pointer", + }, +})); + +export function PaginatedTable({ + data, + columns, + type, + onRowClick, + page = 1, + totalPages = 1, + onPageChange, + sortBy, + sortOrder, + onSort, + onSearch, + searchTerm = "", + onAdd, + showAddRow = false, +}: TableProps) { + const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); + const [newRowData, setNewRowData] = useState>({}); + const [isAddingRow, setIsAddingRow] = useState(false); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + onSearch?.(localSearchTerm); + } + }; + + const handleRequestSort = (property: keyof T) => { + const isAsc = sortBy === property && sortOrder === "asc"; + onSort(property, isAsc ? "desc" : "asc"); + }; + + const handleAddRow = async (e: React.FormEvent) => { + e.preventDefault(); + if (onAdd) { + await onAdd(newRowData as Omit); + setIsAddingRow(false); + setNewRowData({}); + } + }; + + return ( + + + setLocalSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + InputProps={{ + startAdornment: , + }} + /> + {showAddRow && !isAddingRow && ( + + )} + + + + + + + {columns.map((column) => ( + + handleRequestSort(column.id)} + className={$table.headerLabel} + > + {column.label} + {sortBy === column.id && ( + + {sortOrder === "desc" + ? "sorted descending" + : "sorted ascending"} + + )} + + + ))} + + + + {isAddingRow && ( + + +
+ + {columns.map((column) => ( + + {renderTableInput( + column, + newRowData[column.id], + (value) => + setNewRowData((prev) => ({ + ...prev, + [column.id]: value, + })) + )} + + ))} + + + + + + +
+
+ )} + {data.map((row) => ( + onRowClick?.(row)} + > + {columns.map((column) => ( + + {renderTableCell(column, row[column.id])} + + ))} + + ))} +
+
+
+ + {onPageChange && ( + + + + Page {page} of {totalPages} + + + + )} +
+ ); +} diff --git a/src/components/table/renderers.tsx b/src/components/table/renderers.tsx new file mode 100644 index 00000000..bd468017 --- /dev/null +++ b/src/components/table/renderers.tsx @@ -0,0 +1,63 @@ +import { TextField } from "@mui/material"; +import { Dropdown } from "@/components/design_system/dropdown/Dropdown"; +import { ColumnDefinition, UserBase, SelectOption } from "./types"; + +export function renderTableInput( + column: ColumnDefinition, + value: T[keyof T] | undefined, + onChange: (value: T[keyof T]) => void +): React.ReactNode { + switch (column.type) { + case "text": + return ( + onChange(e.target.value as T[keyof T])} + /> + ); + case "number": + return ( + onChange(Number(e.target.value) as T[keyof T])} + /> + ); + case "select": + return ( + onChange(newValue as T[keyof T])} + label={column.label} + /> + ); + default: + return String(value || ""); + } +} + +export function renderTableCell( + column: ColumnDefinition, + value: T[keyof T] +): React.ReactNode { + if (column.customRender) { + return column.customRender(value); + } + + switch (column.type) { + case "select": + return ( + column.options?.find((opt) => opt.value === value)?.label || + String(value) + ); + case "date": + return value instanceof Date ? value.toLocaleDateString() : String(value); + default: + return String(value || ""); + } +} diff --git a/src/components/table/types.ts b/src/components/table/types.ts new file mode 100644 index 00000000..e37b588b --- /dev/null +++ b/src/components/table/types.ts @@ -0,0 +1,42 @@ +import { Roles } from "@/types/auth"; + +export type ColumnType = "text" | "number" | "select" | "date"; + +export interface UserBase { + id?: string | number; + first_name: string; + last_name: string; + email: string; + role: Roles; +} + +export interface SelectOption { + value: string; + label: string; +} + +export interface ColumnDefinition { + id: keyof T; + label: string; + type: ColumnType; + options?: SelectOption[]; + validation?: (value: T[keyof T]) => boolean; + customRender?: (value: T[keyof T]) => React.ReactNode; +} + +export interface TableProps { + data: T[]; + columns: ColumnDefinition[]; + type: string; + onRowClick?: (row: T) => void; + page?: number; + totalPages?: number; + onPageChange?: (page: number) => void; + sortBy: keyof T; + sortOrder: "asc" | "desc"; + onSort: (sortBy: keyof T, sortOrder: "asc" | "desc") => void; + onSearch?: (search: string) => void; + searchTerm?: string; + onAdd?: (data: Omit) => Promise; + showAddRow?: boolean; +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 57a411e9..92c912e6 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -3,10 +3,10 @@ import { SessionProvider } from "next-auth/react"; import type { AppProps } from "next/app"; import { trpc } from "@/client/lib/trpc"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink, loggerLink } from "@trpc/client"; +import { httpBatchLink, loggerLink, TRPCClientError } from "@trpc/client"; import { useState } from "react"; import "../styles/globals.css"; -import { QueryCache } from "@tanstack/react-query"; +import { QueryCache, MutationCache } from "@tanstack/react-query"; import toast from "react-hot-toast"; import Head from "next/head"; import superjson from "superjson"; @@ -17,6 +17,7 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"; import { compassTheme as theme } from "@/theme"; import { FontProvider } from "@/components/font-provider"; +import { AppRouter } from "@/backend/routers/_app"; interface CustomPageProps { session: Session; @@ -42,20 +43,21 @@ export default function App({ () => new QueryClient({ queryCache: new QueryCache({ - onError: (error) => { - if (error instanceof Error) { - const errorMessages: { [key: string]: string } = { - BAD_REQUEST: "400: Bad request, please try again", - UNAUTHORIZED: "401: Unauthorized Error", - NOT_FOUND: "404: Page not found", - }; - - const defaultMessage = "An error occured. Please try again"; - const msg = errorMessages[error.message] || defaultMessage; - setErrorMessage(msg); - } + onError: (error: unknown) => { + handleTRPCError(error); + }, + }), + mutationCache: new MutationCache({ + onError: (error: unknown) => { + handleTRPCError(error); }, }), + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, }) ); @@ -77,6 +79,21 @@ export default function App({ }) ); + const handleTRPCError = (error: unknown) => { + const trpcError = error as TRPCClientError; + const errorCode = trpcError.data?.code as keyof typeof errorMessages; + const errorMessage = trpcError.message; + + const errorMessages = { + BAD_REQUEST: errorMessage || "400: Bad request, please try again", + UNAUTHORIZED: "401: Unauthorized Error", + NOT_FOUND: "404: Page not found", + FORBIDDEN: "403: Access denied", + } as const; + + setErrorMessage(errorMessages[errorCode] ?? "An unexpected error occurred"); + }; + return ( <> @@ -92,7 +109,10 @@ export default function App({ {errorMessage && ( - + setErrorMessage("")} + /> )} diff --git a/src/pages/admin/[user_id].tsx b/src/pages/admin/[user_id].tsx new file mode 100644 index 00000000..d71fd1e8 --- /dev/null +++ b/src/pages/admin/[user_id].tsx @@ -0,0 +1,188 @@ +import { trpc } from "@/client/lib/trpc"; +import { Box, Button, Container, Modal, Stack, TextField } from "@mui/material"; +import Typography from "@mui/material/Typography"; +import { useRouter } from "next/router"; +import { useState, useEffect } from "react"; +import { UserType, ROLE_OPTIONS } from "@/types/auth"; +import $CompassModal from "@/components/design_system/modal/CompassModal.module.css"; +import $button from "@/components/design_system/button/Button.module.css"; +import { getRoleLabel } from "@/types/auth"; +import { Dropdown } from "@/components/design_system/dropdown/Dropdown"; + +interface UserFormData { + first_name: string; + last_name: string; + email: string; + role: UserType; +} + +const ViewUserPage = () => { + const [open, setOpen] = useState(false); + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + const utils = trpc.useContext(); + const router = useRouter(); + const { user_id } = router.query; + + const { data: user, isLoading } = trpc.user.getUserById.useQuery( + { user_id: user_id as string }, + { + enabled: Boolean(user_id), + retry: false, + onError: () => returnToUserList(), + } + ); + + const returnToUserList = async () => { + await router.push(`/admin`); + }; + + const editMutation = trpc.user.editUser.useMutation({ + onSuccess: () => utils.user.getUserById.invalidate(), + }); + + const [selectedRole, setSelectedRole] = useState(""); + + useEffect(() => { + if (user?.role) { + setSelectedRole(user.role); + } + }, [user?.role]); + + const handleEditUser = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + if (!user) return; + + const userData: UserFormData = { + first_name: formData.get("firstName") as string, + last_name: formData.get("lastName") as string, + email: formData.get("email") as string, + role: selectedRole as UserType, + }; + + editMutation.mutate({ + user_id: user.user_id, + ...userData, + }); + + handleClose(); + }; + + if (isLoading) return
Loading...
; + if (!user) return null; + + return ( + + + +

+ {user.first_name} {user.last_name} +

+ +
+ + +
+ Email: {user.email} +
+
+ Role: {getRoleLabel(user.role)} +
+
+ + + + + + +
+ + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+
+
+
+ ); +}; + +export default ViewUserPage; diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 31129bd1..195f49ff 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -1,11 +1,119 @@ import { requiresAdminAuth } from "@/client/lib/protected-page"; -import Link from "next/link"; +import { trpc } from "@/client/lib/trpc"; +import { PaginatedTable } from "@/components/table/PaginatedTable"; +import { ColumnDefinition, UserBase } from "@/components/table/types"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { ROLE_OPTIONS, Roles } from "@/types/auth"; +import { getRoleLabel } from "@/types/auth"; +import { sortBySchema, sortOrderSchema } from "@/backend/routers/user"; +import { z } from "zod"; + +interface User extends UserBase { + user_id: string; + first_name: string; + last_name: string; + email: string; + role: Roles; +} + +type SortBy = z.infer; +type SortOrder = z.infer; + +const AdminHome: React.FC = () => { + const utils = trpc.useContext(); + const router = useRouter(); + const [page, setPage] = useState(1); + const [sortBy, setSortBy] = + useState>("first_name"); + const [sortOrder, setSortOrder] = useState("asc"); + const [searchTerm, setSearchTerm] = useState(""); + const pageSize = 10; + + const { data, isLoading } = trpc.user.getUsers.useQuery({ + page, + pageSize, + sortBy: sortBy as SortBy, + sortOrder, + search: searchTerm, + }); + + const createUserMutation = trpc.user.createUser.useMutation({ + onSuccess: async () => { + await utils.user.getUsers.invalidate(); + }, + }); + + const handleAddUser = async (userData: Omit) => { + try { + await createUserMutation.mutateAsync({ + ...userData, + role: userData.role || "para", + }); + } catch (error) { + console.error(error); + } + }; + + const handleSort = (newSortBy: keyof User, newSortOrder: SortOrder) => { + setSortBy(newSortBy); + setSortOrder(newSortOrder); + }; + + const handleSearch = (search: string) => { + setSearchTerm(search); + setPage(1); + }; + + const handleRowClick = async (user: User) => { + await router.push(`/admin/${user.user_id}`); + }; + + const columns: ColumnDefinition[] = [ + { + id: "first_name", + label: "First Name", + type: "text", + }, + { + id: "last_name", + label: "Last Name", + type: "text", + }, + { + id: "email", + label: "Email", + type: "text", + }, + { + id: "role", + label: "Role", + type: "select", + options: [...ROLE_OPTIONS], + customRender: (value) => (value ? getRoleLabel(value as string) : ""), + }, + ]; + + if (isLoading) return
Loading...
; -const AdminHome = () => { return (
-

Admin Utilities

- Postgres info + + data={(data?.users as User[]) ?? []} + columns={columns} + type="Users" + onRowClick={handleRowClick} + page={page} + totalPages={data?.totalPages ?? 1} + onPageChange={setPage} + sortBy={sortBy as SortBy} + sortOrder={sortOrder} + onSort={handleSort} + onSearch={handleSearch} + searchTerm={searchTerm} + onAdd={handleAddUser} + showAddRow={true} + />
); }; diff --git a/src/theme.ts b/src/theme.ts index 5bbc2bfa..2be8399e 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -202,5 +202,36 @@ export const compassTheme = createTheme({ }), }, }, + MuiSelect: { + styleOverrides: { + root: { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--primary)", + borderWidth: "1px", + }, + "&.Mui-focused": { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--primary)", + borderWidth: "2px", + }, + }, + "&:hover:not(.Mui-focused)": { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--grey-10)", + }, + }, + }, + }, + }, + MuiInputLabel: { + styleOverrides: { + root: { + color: "var(--grey-10)", + "&.Mui-focused": { + color: "var(--primary)", + }, + }, + }, + }, }, }); diff --git a/src/types/auth.ts b/src/types/auth.ts index 8d805845..05e9bc6c 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -25,3 +25,19 @@ export enum UserType { CaseManager = "case_manager", Admin = "admin", } + +export const ROLE_OPTIONS = [ + { label: "User", value: UserType.User }, + { label: "Para", value: UserType.Para }, + { label: "Case Manager", value: UserType.CaseManager }, + { label: "Admin", value: UserType.Admin }, +] as const; + +export type Roles = (typeof ROLE_OPTIONS)[number]["value"]; + +export function getRoleLabel(role: string): string { + const option = ROLE_OPTIONS.find( + (opt) => opt.value.toLowerCase() === role.toLowerCase() + ); + return option?.label || role; +}