Skip to content

Commit 9c5f806

Browse files
authored
Merge branch 'main' into feature/add-sort-date-to-assigned
2 parents 9541b07 + 7f858b9 commit 9c5f806

File tree

14 files changed

+899
-54
lines changed

14 files changed

+899
-54
lines changed

src/backend/routers/user.ts

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,29 @@
1-
import { hasAuthenticated, router } from "../trpc";
1+
import { hasAuthenticated, hasAdmin, router } from "../trpc";
2+
import { z } from "zod";
3+
import { UserType, ROLE_OPTIONS } from "@/types/auth";
4+
import { TRPCError } from "@trpc/server";
5+
6+
export const sortOrderSchema = z.enum(["asc", "desc"]).default("asc");
7+
export const sortBySchema = z
8+
.enum(["first_name", "last_name", "email", "role"])
9+
.default("first_name");
10+
11+
const paginationInput = z.object({
12+
page: z.number().min(1).default(1),
13+
pageSize: z.number().min(1).default(10),
14+
sortBy: sortBySchema,
15+
sortOrder: sortOrderSchema,
16+
search: z.string().optional(),
17+
});
18+
19+
const createUserSchema = z.object({
20+
first_name: z.string(),
21+
last_name: z.string(),
22+
email: z.string().email(),
23+
role: z.string(),
24+
});
25+
26+
const roleValues = ROLE_OPTIONS.map((r) => r.value) as [string, ...string[]];
227

328
export const user = router({
429
getMe: hasAuthenticated.query(async (req) => {
@@ -20,6 +45,64 @@ export const user = router({
2045
return user;
2146
}),
2247

48+
getUsers: hasAdmin.input(paginationInput).query(async (req) => {
49+
const { page, pageSize, sortBy, sortOrder, search } = req.input;
50+
const offset = (page - 1) * pageSize;
51+
52+
let baseQuery = req.ctx.db
53+
.selectFrom("user")
54+
.select([
55+
"user_id",
56+
"first_name",
57+
"last_name",
58+
"email",
59+
"image_url",
60+
"role",
61+
]);
62+
63+
if (search) {
64+
baseQuery = baseQuery.where((eb) =>
65+
eb.or([
66+
eb("first_name", "ilike", `%${search}%`),
67+
eb("last_name", "ilike", `%${search}%`),
68+
eb("email", "ilike", `%${search}%`),
69+
eb("role", "ilike", `%${search}%`),
70+
])
71+
);
72+
}
73+
74+
// Separate count query
75+
let countQuery = req.ctx.db
76+
.selectFrom("user")
77+
.select(req.ctx.db.fn.countAll().as("count"));
78+
79+
// Apply search filter to count query if exists
80+
if (search) {
81+
countQuery = countQuery.where((eb) =>
82+
eb.or([
83+
eb("first_name", "ilike", `%${search}%`),
84+
eb("last_name", "ilike", `%${search}%`),
85+
eb("email", "ilike", `%${search}%`),
86+
eb("role", "ilike", `%${search}%`),
87+
])
88+
);
89+
}
90+
91+
const [users, totalCount] = await Promise.all([
92+
baseQuery
93+
.orderBy(sortBy, sortOrder)
94+
.limit(pageSize)
95+
.offset(offset)
96+
.execute(),
97+
countQuery.executeTakeFirst(),
98+
]);
99+
100+
return {
101+
users,
102+
totalCount: Number(totalCount?.count ?? 0),
103+
totalPages: Math.ceil(Number(totalCount?.count ?? 0) / pageSize),
104+
};
105+
}),
23106
/**
24107
* @returns Whether the current user is a case manager
25108
*/
@@ -34,4 +117,99 @@ export const user = router({
34117

35118
return result.length > 0;
36119
}),
120+
121+
createUser: hasAdmin.input(createUserSchema).mutation(async (req) => {
122+
const { first_name, last_name, email, role } = req.input;
123+
124+
// Check if user already exists
125+
const existingUser = await req.ctx.db
126+
.selectFrom("user")
127+
.where("email", "=", email)
128+
.selectAll()
129+
.executeTakeFirst();
130+
131+
if (existingUser) {
132+
throw new TRPCError({
133+
code: "BAD_REQUEST",
134+
message: "User with this email already exists",
135+
});
136+
}
137+
138+
const user = await req.ctx.db
139+
.insertInto("user")
140+
.values({
141+
first_name,
142+
last_name,
143+
email,
144+
role,
145+
})
146+
.returningAll()
147+
.executeTakeFirstOrThrow();
148+
149+
return user;
150+
}),
151+
152+
getUserById: hasAdmin
153+
.input(z.object({ user_id: z.string() }))
154+
.query(async (req) => {
155+
const { user_id } = req.input;
156+
157+
return await req.ctx.db
158+
.selectFrom("user")
159+
.selectAll()
160+
.where("user_id", "=", user_id)
161+
.executeTakeFirstOrThrow();
162+
}),
163+
164+
editUser: hasAdmin
165+
.input(
166+
z.object({
167+
user_id: z.string(),
168+
first_name: z.string(),
169+
last_name: z.string(),
170+
email: z.string().email(),
171+
role: z.enum(roleValues).transform((role) => {
172+
switch (role) {
173+
case "admin":
174+
return UserType.Admin;
175+
case "case_manager":
176+
return UserType.CaseManager;
177+
case "para":
178+
return UserType.Para;
179+
default:
180+
return UserType.User;
181+
}
182+
}),
183+
})
184+
)
185+
.mutation(async (req) => {
186+
const { user_id, first_name, last_name, email, role } = req.input;
187+
188+
const { userId } = req.ctx.auth;
189+
190+
const dbUser = await req.ctx.db
191+
.selectFrom("user")
192+
.where("user_id", "=", user_id)
193+
.selectAll()
194+
.executeTakeFirstOrThrow();
195+
196+
if (userId === user_id && dbUser.role !== (role as string)) {
197+
throw new TRPCError({
198+
code: "BAD_REQUEST",
199+
message: "You cannot change your own role",
200+
});
201+
}
202+
203+
return await req.ctx.db
204+
.updateTable("user")
205+
.set({
206+
first_name,
207+
last_name,
208+
email: email.toLowerCase(),
209+
role,
210+
})
211+
.where("user_id", "=", user_id)
212+
.returningAll()
213+
.executeTakeFirstOrThrow();
214+
}),
37215
});

src/components/CustomToast.tsx

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,37 @@
1-
import React, { useState } from "react";
1+
import React from "react";
22
import styles from "./styles/Toast.module.css";
33
import Image from "next/image";
44

55
interface CustomToastProps {
66
errorMessage: string;
7+
onClose: () => void;
78
}
89

9-
const CustomToast = ({ errorMessage }: CustomToastProps) => {
10-
const [showToast, setShowToast] = useState(true);
11-
10+
const CustomToast = ({ errorMessage, onClose }: CustomToastProps) => {
1211
const handleCloseToast = () => {
13-
setShowToast(false);
12+
onClose();
1413
};
1514

1615
return (
17-
<>
18-
{showToast && (
19-
<div className={styles.customToastWrapper}>
20-
<div className={styles.customToast}>
21-
<Image
22-
src="/img/error.filled.svg"
23-
alt="Error Img"
24-
width={24}
25-
height={24}
26-
></Image>
27-
<div>{errorMessage ?? null}</div>
28-
29-
<button className={styles.closeButton} onClick={handleCloseToast}>
30-
<Image
31-
src="/img/cross-outline.svg"
32-
alt="Close Toast"
33-
width={24}
34-
height={24}
35-
></Image>
36-
</button>
37-
</div>
38-
</div>
39-
)}
40-
</>
16+
<div className={styles.customToastWrapper}>
17+
<div className={styles.customToast}>
18+
<Image
19+
src="/img/error.filled.svg"
20+
alt="Error Img"
21+
width={24}
22+
height={24}
23+
/>
24+
<div>{errorMessage}</div>
25+
<button className={styles.closeButton} onClick={handleCloseToast}>
26+
<Image
27+
src="/img/cross-outline.svg"
28+
alt="Close Toast"
29+
width={24}
30+
height={24}
31+
/>
32+
</button>
33+
</div>
34+
</div>
4135
);
4236
};
4337

src/components/design_system/breadcrumbs/Breadcrumbs.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ const BreadcrumbsNav = () => {
2222
{ user_id: paths[2] },
2323
{ enabled: Boolean(paths[2] && paths[1] === "staff") }
2424
);
25+
const { data: user } = trpc.user.getUserById.useQuery(
26+
{ user_id: paths[2] },
27+
{ enabled: Boolean(paths[2] && paths[1] === "admin") }
28+
);
2529

26-
const personData: Student | Para | undefined = student || para;
30+
const personData: Student | Para | undefined = student || para || user;
2731

2832
// 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.
2933
const breadcrumbs = paths.map((path, index) => {

src/components/design_system/dropdown/Dropdown.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ interface DropdownProps {
2424
optionDisabled?: string[];
2525
}
2626

27-
const Dropdown = ({
27+
export const Dropdown = ({
2828
itemList,
2929
selectedOption,
3030
setSelectedOption,
@@ -38,7 +38,6 @@ const Dropdown = ({
3838
};
3939

4040
return (
41-
// Minimum styles used. More can be defined in className.
4241
<Box sx={{ minWidth: 120, maxWidth: "fit-content" }} className={className}>
4342
<FormControl fullWidth>
4443
<InputLabel id="dropdown-label">{label}</InputLabel>
@@ -48,8 +47,13 @@ const Dropdown = ({
4847
value={selectedOption}
4948
label={label}
5049
onChange={handleChange}
51-
// Allow disabling of form
5250
disabled={formDisabled}
51+
MenuProps={{
52+
PaperProps: {
53+
elevation: 1,
54+
sx: { maxHeight: 300 },
55+
},
56+
}}
5357
>
5458
{itemList?.map((item) => (
5559
<MenuItem
@@ -58,7 +62,6 @@ const Dropdown = ({
5862
className={`${
5963
selectedOption === item.value ? $dropdown.selected : ""
6064
} ${$dropdown.default}`}
61-
// Allow disabling of named keys used as an array of strings
6265
disabled={optionDisabled?.includes(item.value)}
6366
>
6467
{item.label}

src/components/navbar/NavBar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PeopleOutline from "@mui/icons-material/PeopleOutline";
33
import Logout from "@mui/icons-material/Logout";
44
import MenuIcon from "@mui/icons-material/Menu";
55
import SchoolOutlined from "@mui/icons-material/SchoolOutlined";
6+
import AdminPanelSettings from "@mui/icons-material/AdminPanelSettings";
67
import ContentPaste from "@mui/icons-material/ContentPaste";
78
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
89
import AppBar from "@mui/material/AppBar";
@@ -112,6 +113,7 @@ export default function NavBar() {
112113
icon={<SettingsOutlined />}
113114
text="Settings"
114115
/>
116+
<NavItem href="/admin" icon={<AdminPanelSettings />} text="Admin" />
115117
<NavItem
116118
icon={<Logout />}
117119
text="Logout"

src/components/styles/Toast.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
bottom: 0;
44
right: 0;
55
width: 400px;
6+
z-index: 9999;
67
}
78

89
.customToast {

0 commit comments

Comments
 (0)