Skip to content

Commit 9745490

Browse files
committed
Allow users to see specific attendances in attendance table
1 parent a804d84 commit 9745490

11 files changed

Lines changed: 186 additions & 21 deletions

File tree

src/backend/src/controllers/attendance.controllers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ export default class AttendanceController {
2121
}
2222
}
2323

24+
static async getAttendanceById(req: Request, res: Response, next: NextFunction) {
25+
try {
26+
const { meetingAttendanceId } = req.params as Record<string, string>;
27+
const attendance = await AttendanceService.getAttendanceById(meetingAttendanceId, req.organization);
28+
res.status(200).json(attendance);
29+
} catch (error: unknown) {
30+
next(error);
31+
}
32+
}
33+
2434
static async getOngoingAttendance(req: Request, res: Response, next: NextFunction) {
2535
try {
2636
const { teamId } = req.params as Record<string, string>;

src/backend/src/prisma-query-args/attendance.query-args.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Prisma } from '@prisma/client';
22
import { getUserQueryArgs } from './user.query-args.js';
33

44
export type MeetingAttendanceQueryArgs = ReturnType<typeof getMeetingAttendanceQueryArgs>;
5+
export type MeetingAttendanceWithAttendeesQueryArgs = ReturnType<typeof getMeetingAttendanceWithAttendeesQueryArgs>;
56

67
export const getMeetingAttendanceQueryArgs = (organizationId: string) =>
78
Prisma.validator<Prisma.Meeting_AttendanceDefaultArgs>()({
@@ -19,3 +20,20 @@ export const getMeetingAttendanceQueryArgs = (organizationId: string) =>
1920
attendees: { select: { userId: true } }
2021
}
2122
});
23+
24+
export const getMeetingAttendanceWithAttendeesQueryArgs = (organizationId: string) =>
25+
Prisma.validator<Prisma.Meeting_AttendanceDefaultArgs>()({
26+
include: {
27+
userCreated: getUserQueryArgs(organizationId),
28+
team: {
29+
select: {
30+
teamId: true,
31+
teamName: true,
32+
headId: true,
33+
members: { select: { userId: true } },
34+
leads: { select: { userId: true } }
35+
}
36+
},
37+
attendees: getUserQueryArgs(organizationId)
38+
}
39+
});

src/backend/src/routes/attendance.routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ attendanceRouter.post(
1414
);
1515

1616
attendanceRouter.get('/', AttendanceController.getAllAttendances);
17-
1817
attendanceRouter.get('/ongoing/:teamId', AttendanceController.getOngoingAttendance);
1918
attendanceRouter.post('/close/:teamId', AttendanceController.closeOngoingAttendance);
2019
attendanceRouter.get('/check-channel/:teamId', AttendanceController.checkChannel);
20+
attendanceRouter.get('/:meetingAttendanceId', AttendanceController.getAttendanceById);
2121

2222
export default attendanceRouter;

src/backend/src/services/attendance.services.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { Organization } from '@prisma/client';
2-
import { MeetingAttendance, RoleEnum, User, isAtLeastRank } from 'shared';
2+
import { MeetingAttendance, MeetingAttendanceWithAttendees, RoleEnum, User, isAtLeastRank } from 'shared';
33
import prisma from '../prisma/prisma.js';
44
import { AccessDeniedException, HttpException, NotFoundException } from '../utils/errors.utils.js';
5-
import { getMeetingAttendanceQueryArgs } from '../prisma-query-args/attendance.query-args.js';
6-
import { meetingAttendanceTransformer } from '../transformers/attendance.transformer.js';
5+
import {
6+
getMeetingAttendanceQueryArgs,
7+
getMeetingAttendanceWithAttendeesQueryArgs
8+
} from '../prisma-query-args/attendance.query-args.js';
9+
import {
10+
meetingAttendanceTransformer,
11+
meetingAttendanceWithAttendeesTransformer
12+
} from '../transformers/attendance.transformer.js';
713
import { editMessage, getChannelName, replyToMessageInThread, sendMessage } from '../integrations/slack.js';
814
import { userHasPermission } from '../utils/users.utils.js';
915

@@ -63,6 +69,22 @@ export default class AttendanceService {
6369
return meetingAttendanceTransformer(attendance);
6470
}
6571

72+
static async getAttendanceById(
73+
meetingAttendanceId: string,
74+
organization: Organization
75+
): Promise<MeetingAttendanceWithAttendees> {
76+
const attendance = await prisma.meeting_Attendance.findUnique({
77+
where: { meetingAttendanceId },
78+
...getMeetingAttendanceWithAttendeesQueryArgs(organization.organizationId)
79+
});
80+
81+
if (!attendance || attendance.organizationId !== organization.organizationId) {
82+
throw new NotFoundException('Meeting Attendance', meetingAttendanceId);
83+
}
84+
85+
return meetingAttendanceWithAttendeesTransformer(attendance);
86+
}
87+
6688
static async getAllAttendances(organization: Organization): Promise<MeetingAttendance[]> {
6789
const attendances = await prisma.meeting_Attendance.findMany({
6890
where: { organizationId: organization.organizationId },

src/backend/src/transformers/attendance.transformer.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { Prisma } from '@prisma/client';
2-
import { MeetingAttendance } from 'shared';
3-
import { MeetingAttendanceQueryArgs } from '../prisma-query-args/attendance.query-args.js';
2+
import { MeetingAttendance, MeetingAttendanceWithAttendees } from 'shared';
3+
import {
4+
MeetingAttendanceQueryArgs,
5+
MeetingAttendanceWithAttendeesQueryArgs
6+
} from '../prisma-query-args/attendance.query-args.js';
47
import { userTransformer } from './user.transformer.js';
58

69
export const meetingAttendanceTransformer = (
@@ -25,3 +28,27 @@ export const meetingAttendanceTransformer = (
2528
teamMemberAttendancePercent: teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0
2629
};
2730
};
31+
32+
export const meetingAttendanceWithAttendeesTransformer = (
33+
attendance: Prisma.Meeting_AttendanceGetPayload<MeetingAttendanceWithAttendeesQueryArgs>
34+
): MeetingAttendanceWithAttendees => {
35+
const teamMemberIds = new Set([
36+
...attendance.team.members.map((m) => m.userId),
37+
...attendance.team.leads.map((l) => l.userId),
38+
attendance.team.headId
39+
]);
40+
const attendeeIds = new Set(attendance.attendees.map((a) => a.userId));
41+
const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length;
42+
43+
return {
44+
meetingAttendanceId: attendance.meetingAttendanceId,
45+
teamId: attendance.teamId,
46+
teamName: attendance.team.teamName,
47+
userCreated: userTransformer(attendance.userCreated),
48+
openedAt: attendance.openedAt,
49+
closedAt: attendance.closedAt ?? undefined,
50+
attendeesCount: attendance.attendees.length,
51+
teamMemberAttendancePercent: teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0,
52+
attendees: attendance.attendees.map(userTransformer)
53+
};
54+
};

src/backend/src/utils/errors.utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,5 @@ export type ExceptionObjectNames =
212212
| 'Event'
213213
| 'Schedule Slot'
214214
| 'ProspectiveSponsor'
215-
| 'SponsorTier';
215+
| 'SponsorTier'
216+
| 'Meeting Attendance';

src/frontend/src/apis/attendance.api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import axios from '../utils/axios';
22
import { apiUrls } from '../utils/urls';
3-
import { MeetingAttendance } from 'shared';
3+
import { MeetingAttendance, MeetingAttendanceWithAttendees } from 'shared';
44

55
export const postTakeAttendance = (payload: { teamId: string; message: string }) => {
66
return axios.post<MeetingAttendance>(apiUrls.attendanceTakeAttendance(), payload, {
@@ -29,3 +29,9 @@ export const getOngoingAttendance = (teamId: string) => {
2929
export const postCloseAttendance = (teamId: string) => {
3030
return axios.post(apiUrls.attendanceCloseOngoing(teamId));
3131
};
32+
33+
export const getAttendanceById = (meetingAttendanceId: string) => {
34+
return axios.get<MeetingAttendanceWithAttendees>(apiUrls.attendanceGetById(meetingAttendanceId), {
35+
transformResponse: (data) => JSON.parse(data) as MeetingAttendanceWithAttendees
36+
});
37+
};

src/frontend/src/hooks/attendance.hooks.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useMutation, useQuery, useQueryClient } from 'react-query';
2-
import { MeetingAttendance } from 'shared';
2+
import { MeetingAttendance, MeetingAttendanceWithAttendees } from 'shared';
33
import {
44
getAllAttendances,
5+
getAttendanceById,
56
getCheckChannelName,
67
getOngoingAttendance,
78
postCloseAttendance,
@@ -41,6 +42,14 @@ export const useOngoingAttendance = (teamId: string) => {
4142
);
4243
};
4344

45+
export const useAttendanceById = (meetingAttendanceId: string, enabled: boolean) => {
46+
return useQuery<MeetingAttendanceWithAttendees, Error>(
47+
['attendance', meetingAttendanceId],
48+
() => getAttendanceById(meetingAttendanceId).then((res) => res.data),
49+
{ enabled }
50+
);
51+
};
52+
4453
export const useCloseAttendance = () => {
4554
const queryClient = useQueryClient();
4655
return useMutation<void, Error, string>((teamId) => postCloseAttendance(teamId).then(() => undefined), {

src/frontend/src/pages/AdminToolsPage/AdminToolsAttendanceConfig.tsx

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,81 @@
1-
import { Box, Paper, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
1+
import { useState } from 'react';
2+
import {
3+
Box,
4+
Collapse,
5+
IconButton,
6+
Paper,
7+
Table,
8+
TableBody,
9+
TableCell,
10+
TableHead,
11+
TableRow,
12+
Typography
13+
} from '@mui/material';
14+
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
15+
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
216
import LoadingIndicator from '../../components/LoadingIndicator';
317
import ErrorPage from '../ErrorPage';
4-
import { useAllAttendances } from '../../hooks/attendance.hooks';
18+
import { useAllAttendances, useAttendanceById } from '../../hooks/attendance.hooks';
519
import { fullNamePipe } from '../../utils/pipes';
20+
import { MeetingAttendance } from 'shared';
21+
22+
interface AttendanceRowProps {
23+
attendance: MeetingAttendance;
24+
}
25+
26+
const AttendanceRow: React.FC<AttendanceRowProps> = ({ attendance }) => {
27+
const [open, setOpen] = useState(false);
28+
const { data: attendanceWithAttendees, isLoading } = useAttendanceById(attendance.meetingAttendanceId, open);
29+
30+
return (
31+
<>
32+
<TableRow hover sx={{ cursor: 'pointer' }} onClick={() => setOpen((prev) => !prev)}>
33+
<TableCell padding="checkbox">
34+
<IconButton
35+
size="small"
36+
onClick={(e) => {
37+
e.stopPropagation();
38+
setOpen((prev) => !prev);
39+
}}
40+
>
41+
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
42+
</IconButton>
43+
</TableCell>
44+
<TableCell>{attendance.teamName}</TableCell>
45+
<TableCell>{fullNamePipe(attendance.userCreated)}</TableCell>
46+
<TableCell>{new Date(attendance.openedAt).toLocaleString()}</TableCell>
47+
<TableCell>{attendance.attendeesCount}</TableCell>
48+
<TableCell>{attendance.teamMemberAttendancePercent.toFixed(1)}%</TableCell>
49+
</TableRow>
50+
<TableRow>
51+
<TableCell colSpan={6} sx={{ py: 0 }}>
52+
<Collapse in={open} timeout="auto" unmountOnExit>
53+
<Box sx={{ py: 1, px: 2 }}>
54+
<Typography variant="subtitle2" gutterBottom>
55+
Attendees
56+
</Typography>
57+
{isLoading ? (
58+
<LoadingIndicator />
59+
) : !attendanceWithAttendees || attendanceWithAttendees.attendees.length === 0 ? (
60+
<Typography variant="body2" color="text.secondary">
61+
No attendees recorded.
62+
</Typography>
63+
) : (
64+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
65+
{attendanceWithAttendees.attendees.map((user) => (
66+
<Typography key={user.userId} variant="body2">
67+
{fullNamePipe(user)}
68+
</Typography>
69+
))}
70+
</Box>
71+
)}
72+
</Box>
73+
</Collapse>
74+
</TableCell>
75+
</TableRow>
76+
</>
77+
);
78+
};
679

780
const AdminToolsAttendanceConfig: React.FC = () => {
881
const { data: attendances, isLoading, isError, error } = useAllAttendances();
@@ -19,6 +92,7 @@ const AdminToolsAttendanceConfig: React.FC = () => {
1992
<Table size="small">
2093
<TableHead>
2194
<TableRow>
95+
<TableCell />
2296
<TableCell sx={{ fontWeight: 600 }}>Team Name</TableCell>
2397
<TableCell sx={{ fontWeight: 600 }}>Initiated By</TableCell>
2498
<TableCell sx={{ fontWeight: 600 }}>Date/Time</TableCell>
@@ -29,20 +103,12 @@ const AdminToolsAttendanceConfig: React.FC = () => {
29103
<TableBody>
30104
{!attendances || attendances.length === 0 ? (
31105
<TableRow>
32-
<TableCell colSpan={5} align="center">
106+
<TableCell colSpan={6} align="center">
33107
No attendance records yet.
34108
</TableCell>
35109
</TableRow>
36110
) : (
37-
attendances.map((attendance) => (
38-
<TableRow key={attendance.meetingAttendanceId} hover>
39-
<TableCell>{attendance.teamName}</TableCell>
40-
<TableCell>{fullNamePipe(attendance.userCreated)}</TableCell>
41-
<TableCell>{new Date(attendance.openedAt).toLocaleString()}</TableCell>
42-
<TableCell>{attendance.attendeesCount}</TableCell>
43-
<TableCell>{attendance.teamMemberAttendancePercent.toFixed(1)}%</TableCell>
44-
</TableRow>
45-
))
111+
attendances.map((attendance) => <AttendanceRow key={attendance.meetingAttendanceId} attendance={attendance} />)
46112
)}
47113
</TableBody>
48114
</Table>

src/frontend/src/utils/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ const attendanceGetAll = () => `${attendance()}/`;
505505
const attendanceCheckChannel = (teamId: string) => `${attendance()}/check-channel/${teamId}`;
506506
const attendanceGetOngoing = (teamId: string) => `${attendance()}/ongoing/${teamId}`;
507507
const attendanceCloseOngoing = (teamId: string) => `${attendance()}/close/${teamId}`;
508+
const attendanceGetById = (meetingAttendanceId: string) => `${attendance()}/${meetingAttendanceId}`;
508509

509510
/**************** Other Endpoints ****************/
510511
const version = () => `https://api.github.com/repos/Northeastern-Electric-Racing/FinishLine/releases/latest`;
@@ -870,6 +871,7 @@ export const apiUrls = {
870871
attendanceCheckChannel,
871872
attendanceGetOngoing,
872873
attendanceCloseOngoing,
874+
attendanceGetById,
873875

874876
version
875877
};

0 commit comments

Comments
 (0)