Skip to content

Commit 06b2453

Browse files
committed
add the ability to change attendance
1 parent 1980a99 commit 06b2453

File tree

10 files changed

+252
-5
lines changed

10 files changed

+252
-5
lines changed

packages/backend/src/rsvp/dto/update-rsvp.dto.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ApiProperty } from '@nestjs/swagger';
22
import { RSVPStatus } from '@prisma/client';
3-
import { IsString, IsEnum, IsOptional } from 'class-validator';
3+
import { IsString, IsEnum, IsOptional, IsBoolean } from 'class-validator';
44

55
/**
66
* The data used to update an RSVP
@@ -14,4 +14,8 @@ export class UpdateRsvpDto {
1414
@ApiProperty({ enum: RSVPStatus, required: false })
1515
@IsOptional()
1616
status?: RSVPStatus;
17+
@IsBoolean()
18+
@ApiProperty({ required: false })
19+
@IsOptional()
20+
attended?: boolean;
1721
}

packages/frontend/src/features/event/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,6 @@ export const UserCheckinPage = loadable(
1818
() => import("./pages/UserCheckinPage")
1919
);
2020
export const AttendancePage = loadable(() => import("./pages/AttendancePage"));
21+
export const AdminAttendancePage = loadable(
22+
() => import("./pages/AdminAttendancePage")
23+
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { AdminRSVPList, RSVPList } from "@/features/rsvp";
2+
import { useParams } from "react-router-dom";
3+
4+
export default function AdminAtttendancePage() {
5+
const { eventId } = useParams();
6+
return <AdminRSVPList eventId={eventId} />;
7+
}

packages/frontend/src/features/event/pages/EventPage.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ export default function EventPage() {
8585
<Tab onClick={() => navigate(`/event/${eventId}/attendance`)}>
8686
Attendance
8787
</Tab>
88+
<Tab onClick={() => navigate(`/event/${eventId}/admin-attendance`)}>
89+
Admin Attendance
90+
</Tab>
8891
<Tab onClick={() => navigate(`/event/${eventId}/checkin`)}>
8992
User Check-in
9093
</Tab>
@@ -106,12 +109,16 @@ export default function EventPage() {
106109
<TabPanel>
107110
<Outlet />
108111
</TabPanel>
109-
{auth.isLoading ||
110-
(auth.isAdmin && (
112+
{!auth.isLoading && auth.isAdmin ? (
113+
<>
111114
<TabPanel>
112115
<Outlet />
113116
</TabPanel>
114-
))}
117+
<TabPanel>
118+
<Outlet />
119+
</TabPanel>
120+
</>
121+
) : null}
115122
</TabPanels>
116123
</Tabs>
117124
</>
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { DataTable } from "@/components/DataTable";
2+
import useRoles from "@/features/bot/hooks/useRoles";
3+
import { ApiError, RsvpUser, UpdateRsvpDto } from "@/generated";
4+
import { Checkbox, filter, Select, TableContainer } from "@chakra-ui/react";
5+
import { createColumnHelper } from "@tanstack/react-table";
6+
import { DateTime } from "luxon";
7+
import React, { useMemo, useState } from "react";
8+
import useEventRSVPStatuses from "../hooks/useEventRSVPStatuses";
9+
import Autocomplete from "@/components/Autocomplete";
10+
import useEditUserRSVP from "../hooks/useEditUserRSVP";
11+
import api from "@/services/api";
12+
import { rsvpKeys } from "../hooks/keys";
13+
import { useQuery } from "@tanstack/react-query";
14+
15+
function RSVPCell(rsvp: RsvpUser) {
16+
const editRsvp = useEditUserRSVP();
17+
const rsvpStatus = useQuery<RsvpUser[], ApiError, RsvpUser | undefined>({
18+
queryFn: () => api.event.getEventRsvps(rsvp.eventId),
19+
queryKey: rsvpKeys.event(rsvp.eventId),
20+
select: (data) => data.find((r) => r.id === rsvp.id),
21+
});
22+
23+
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
24+
const value = e.target.value as UpdateRsvpDto["status"] | undefined;
25+
editRsvp.mutate({
26+
rsvpId: rsvp.id,
27+
rsvp: {
28+
status: value,
29+
},
30+
});
31+
};
32+
33+
return (
34+
<Select
35+
value={rsvpStatus.data?.status ?? ""}
36+
onChange={handleChange}
37+
disabled={editRsvp.isLoading}
38+
>
39+
<option value={""}>Unknown</option>
40+
<option value={UpdateRsvpDto["status"].LATE}>Late</option>
41+
<option value={UpdateRsvpDto["status"].MAYBE}>Maybe</option>
42+
<option value={UpdateRsvpDto["status"].NO}>No</option>
43+
<option value={UpdateRsvpDto["status"].YES}>Yes</option>
44+
</Select>
45+
);
46+
}
47+
48+
function AttendedCell(rsvp: RsvpUser) {
49+
const editRsvp = useEditUserRSVP();
50+
const rsvpStatus = useQuery<RsvpUser[], ApiError, RsvpUser | undefined>({
51+
queryFn: () => api.event.getEventRsvps(rsvp.eventId),
52+
queryKey: rsvpKeys.event(rsvp.eventId),
53+
select: (data) => data.find((r) => r.id === rsvp.id),
54+
});
55+
56+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
57+
const value = e.target.checked;
58+
editRsvp.mutate({
59+
rsvpId: rsvp.id,
60+
rsvp: {
61+
attended: value,
62+
},
63+
});
64+
};
65+
66+
return (
67+
<Checkbox
68+
onChange={handleChange}
69+
isChecked={rsvpStatus.data?.attended ?? false}
70+
disabled={editRsvp.isLoading}
71+
/>
72+
);
73+
}
74+
75+
const columnHelper = createColumnHelper<RsvpUser>();
76+
77+
const columns = [
78+
columnHelper.accessor("user.username", {
79+
header: "Name",
80+
// footer: "First Name",
81+
}),
82+
83+
columnHelper.group({
84+
id: "status",
85+
header: "Status",
86+
columns: [
87+
columnHelper.accessor("status", {
88+
id: "status",
89+
header: "RSVP Status",
90+
// footer: "RSVP Status",
91+
cell: (props) => <RSVPCell {...props.row.original} />,
92+
}),
93+
columnHelper.accessor("attended", {
94+
id: "attended",
95+
header: "Attended",
96+
// footer: "Attended",
97+
cell: (props) => <AttendedCell {...props.row.original} />,
98+
}),
99+
],
100+
}),
101+
columnHelper.group({
102+
id: "meta",
103+
columns: [
104+
columnHelper.accessor("updatedAt", {
105+
id: "updatedAt",
106+
header: "Updated At",
107+
// footer: "Updated At",
108+
cell: (props) =>
109+
DateTime.fromISO(props.getValue()).toLocaleString(
110+
DateTime.DATETIME_MED
111+
),
112+
}),
113+
columnHelper.accessor("createdAt", {
114+
id: "createdAt",
115+
header: "Created At",
116+
// footer: "Created At",
117+
cell: (props) =>
118+
DateTime.fromISO(props.getValue()).toLocaleString(
119+
DateTime.DATETIME_MED
120+
),
121+
}),
122+
],
123+
}),
124+
];
125+
126+
const readableStatus = (status: RsvpUser.status | null) => {
127+
if (status === null) {
128+
return "Unknown";
129+
} else if (status === "YES") {
130+
return "Coming";
131+
} else if (status === "MAYBE") {
132+
return "Maybe";
133+
} else if (status === "LATE") {
134+
return "Late";
135+
} else {
136+
return "Not Coming";
137+
}
138+
};
139+
140+
export interface RSVPListProps {
141+
eventId?: string;
142+
}
143+
144+
interface RoleItem {
145+
label: string;
146+
value: string;
147+
}
148+
149+
export const RSVPList: React.FC<RSVPListProps> = ({ eventId }) => {
150+
const { data: rsvps } = useEventRSVPStatuses(eventId);
151+
const roles = useRoles();
152+
153+
const options = useMemo(() => {
154+
if (!roles.data) return [];
155+
return roles.data.map((role) => ({ label: role.name, value: role.id }));
156+
}, [roles.data]);
157+
158+
const handleSelectedItemsChange = (selectedItems?: RoleItem[]) => {
159+
if (selectedItems) {
160+
setSelected(selectedItems);
161+
}
162+
};
163+
// const [sorting, setSorting] = React.useState<SortingState>([]);
164+
// const table = useReactTable({
165+
// data: rsvps ?? [],
166+
// state: {
167+
// sorting,
168+
// },
169+
// onSortingChange: setSorting,
170+
// columns,
171+
// getCoreRowModel: getCoreRowModel(),
172+
// });
173+
const [selected, setSelected] = useState<Array<RoleItem>>([]);
174+
175+
const filteredRsvps = useMemo(() => {
176+
if (!rsvps) return [];
177+
if (!selected.length) return rsvps;
178+
179+
return rsvps.filter((rsvp) =>
180+
selected.every((v) => rsvp.user.roles.includes(v.value))
181+
);
182+
}, [selected, rsvps]);
183+
184+
return (
185+
<TableContainer>
186+
<Autocomplete
187+
options={options}
188+
filter={(items, filterValue) =>
189+
items.filter((item) =>
190+
item.label.toLowerCase().includes(filterValue.toLowerCase())
191+
)
192+
}
193+
itemToString={(item) => item?.label ?? ""}
194+
onChange={setSelected}
195+
value={selected}
196+
/>
197+
<DataTable columns={columns} data={filteredRsvps ?? []} />
198+
</TableContainer>
199+
);
200+
};
201+
202+
export default RSVPList;

packages/frontend/src/features/rsvp/components/RSVPStatus.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { EventResponseType } from "@/generated";
33
import { Center, Heading, Stack } from "@chakra-ui/react";
44
import RSVPButtonRow from "./RSVPButtonRow";
55

6-
interface AttendanceProps {
6+
export interface AttendanceProps {
77
event: EventResponseType;
88
}
99

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Rsvp, ApiError, UpdateOrCreateRSVP, UpdateRsvpDto } from "@/generated";
2+
import api from "@/services/api";
3+
import queryClient from "@/services/queryClient";
4+
import { useMutation } from "@tanstack/react-query";
5+
import { rsvpKeys } from "./keys";
6+
7+
export default function useEditUserRSVP() {
8+
return useMutation<Rsvp, ApiError, { rsvpId: string; rsvp: UpdateRsvpDto }>({
9+
mutationFn: ({ rsvpId: rsvpId, rsvp }) => api.rsvp.editRsvp(rsvpId, rsvp),
10+
onSuccess: (data, { rsvpId: eventId }) => {
11+
queryClient.invalidateQueries(rsvpKeys.event(eventId));
12+
// queryClient.invalidateQueries(["EventRSVP", eventId]);
13+
},
14+
});
15+
}

packages/frontend/src/features/rsvp/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import loadable from "@loadable/component";
22

33
export const RSVPStatus = loadable(() => import("./components/RSVPStatus"));
44
export const RSVPList = loadable(() => import("./components/RSVPList"));
5+
export const AdminRSVPList = loadable(
6+
() => import("./components/AdminRSVPList")
7+
);
58
export const RSVPButtonRow = loadable(
69
() => import("./components/RSVPButtonRow")
710
);

packages/frontend/src/generated/models/UpdateRsvpDto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
export type UpdateRsvpDto = {
66
eventId?: string;
77
status?: UpdateRsvpDto.status;
8+
attended?: boolean;
89
};
910

1011
export namespace UpdateRsvpDto {

packages/frontend/src/main.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ReactDOM from "react-dom/client";
55
import { BrowserRouter, Route, Routes } from "react-router-dom";
66
import { AuthWrapper } from "./features/auth";
77
import {
8+
AdminAttendancePage,
89
AdminCheckinPage,
910
AgendaPage,
1011
AttendancePage,
@@ -76,6 +77,10 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
7677
path="/event/:eventId/admin-checkin"
7778
element={<AdminCheckinPage />}
7879
/>
80+
<Route
81+
path="/event/:eventId/admin-attendance"
82+
element={<AdminAttendancePage />}
83+
/>
7984
</Route>
8085
</Routes>
8186

0 commit comments

Comments
 (0)