Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
car-crushers-portal/app/routes/events-team.tsx
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
773 lines (701 sloc)
22.5 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Box, | |
Button, | |
Card, | |
CardBody, | |
CardFooter, | |
Container, | |
Flex, | |
FormControl, | |
FormLabel, | |
Heading, | |
IconButton, | |
Link, | |
Modal, | |
ModalBody, | |
ModalCloseButton, | |
ModalContent, | |
ModalFooter, | |
ModalHeader, | |
ModalOverlay, | |
Stack, | |
StackDivider, | |
Switch, | |
Text, | |
useDisclosure, | |
useToast, | |
VStack, | |
} from "@chakra-ui/react"; | |
import { useLoaderData } from "@remix-run/react"; | |
import { useState } from "react"; | |
import calendarStyles from "react-big-calendar/lib/css/react-big-calendar.css"; | |
import { type LinksFunction } from "@remix-run/cloudflare"; | |
export const links: LinksFunction = () => { | |
return [{ href: calendarStyles, rel: "stylesheet" }]; | |
}; | |
export async function loader({ context }: { context: RequestContext }) { | |
if (!context.data.current_user) | |
throw new Response(null, { | |
status: 401, | |
}); | |
if ( | |
![1 << 3, 1 << 4, 1 << 12].find( | |
(p) => context.data.current_user.permissions & p, | |
) | |
) | |
throw new Response(null, { | |
status: 403, | |
}); | |
const now = new Date(); | |
const monthEventList = await context.env.D1.prepare( | |
"SELECT answer, approved, created_by, day, details, id, month, pending, performed_at, reached_minimum_player_count, type, year FROM events WHERE month = ? AND year = ? ORDER BY day ASC;", | |
) | |
.bind(now.getUTCMonth() + 1, now.getUTCFullYear()) | |
.all(); | |
if (monthEventList.error) | |
throw new Response(null, { | |
status: 500, | |
}); | |
const membersList = await context.env.D1.prepare( | |
"SELECT id, name FROM et_members WHERE id IN (SELECT created_by FROM events WHERE month = ? AND year = ?);", | |
) | |
.bind(now.getUTCMonth() + 1, now.getUTCFullYear()) | |
.all(); | |
if (membersList.error) | |
throw new Response(null, { | |
status: 500, | |
}); | |
return { | |
can_approve: Boolean( | |
[1 << 4, 1 << 12].find((p) => context.data.current_user.permissions & p), | |
), | |
events: monthEventList.results, | |
members: membersList.results as { id: string; name: string }[], | |
user_id: context.data.current_user.id as string, | |
}; | |
} | |
export default function () { | |
const { | |
can_approve, | |
events, | |
members, | |
user_id, | |
}: { | |
can_approve: boolean; | |
events: { [k: string]: any }[]; | |
members: { id: string; name: string }[]; | |
user_id: string; | |
} = useLoaderData<typeof loader>(); | |
const [eventData, setEventData] = useState(events); | |
const { isOpen, onClose, onOpen } = useDisclosure(); | |
const { | |
isOpen: isCompleteOpen, | |
onClose: closeComplete, | |
onOpen: openComplete, | |
} = useDisclosure(); | |
const { | |
isOpen: isForgottenOpen, | |
onClose: onForgottenClose, | |
onOpen: onForgottenOpen, | |
} = useDisclosure(); | |
const { | |
isOpen: isDeleteOpen, | |
onClose: onDeleteClose, | |
onOpen: onDeleteOpen, | |
} = useDisclosure(); | |
const { | |
isOpen: isRescheduleOpen, | |
onClose: onRescheduleClose, | |
onOpen: onRescheduleOpen, | |
} = useDisclosure(); | |
const toast = useToast(); | |
const [selectedEvent, setSelectedEvent] = useState(""); | |
const [disableClicks, setDisableClicks] = useState(false); | |
const [showOld, setShowOld] = useState(false); | |
async function decide(approved: boolean, eventId: string) { | |
if (disableClicks) return; | |
setDisableClicks(true); | |
const decisionResp = await fetch( | |
`/api/events-team/events/${eventId}/decision`, | |
{ | |
body: JSON.stringify({ approved }), | |
headers: { | |
"content-type": "application/json", | |
}, | |
method: "POST", | |
}, | |
); | |
if (!decisionResp.ok) { | |
let errorMsg = "Unknown error"; | |
try { | |
errorMsg = ((await decisionResp.json()) as { error: string }).error; | |
} catch {} | |
toast({ | |
description: errorMsg, | |
status: "error", | |
title: "Oops!", | |
}); | |
setDisableClicks(false); | |
return; | |
} | |
toast({ | |
description: `Event ${approved ? "approved" : "rejected"}`, | |
status: "success", | |
title: "Success", | |
}); | |
const newEventData = eventData; | |
const eventIdx = eventData.findIndex((e) => e.id === eventId); | |
newEventData[eventIdx].approved = approved; | |
newEventData[eventIdx].pending = false; | |
setEventData([...newEventData]); | |
setDisableClicks(false); | |
} | |
async function certify(eventId: string) { | |
if (disableClicks) return; | |
setDisableClicks(true); | |
const certifyResp = await fetch( | |
`/api/events-team/events/${eventId}/certify`, | |
{ | |
body: "{}", | |
headers: { | |
"content-type": "application/json", | |
}, | |
method: "POST", | |
}, | |
); | |
if (!certifyResp.ok) { | |
let errorMsg = "Unknown error"; | |
try { | |
errorMsg = ((await certifyResp.json()) as { error: string }).error; | |
} catch {} | |
toast({ | |
description: errorMsg, | |
status: "error", | |
title: "Failed to certify game night", | |
}); | |
setDisableClicks(false); | |
return; | |
} | |
toast({ | |
description: "Game night certified", | |
status: "success", | |
title: "Success", | |
}); | |
const newEventData = eventData; | |
newEventData[ | |
eventData.findIndex((e) => e.id === eventId) | |
].reached_minimum_player_count = true; | |
setEventData([...newEventData]); | |
setSelectedEvent(""); | |
setDisableClicks(false); | |
} | |
async function markComplete(eventId: string) { | |
setDisableClicks(true); | |
const completeResp = await fetch( | |
`/api/events-team/events/${eventId}/complete`, | |
{ | |
body: "{}", | |
headers: { | |
"content-type": "application/json", | |
}, | |
method: "POST", | |
}, | |
); | |
closeComplete(); | |
if (!completeResp.ok) { | |
let msg = "Unknown error"; | |
try { | |
msg = ((await completeResp.json()) as { error: string }).error; | |
} catch {} | |
toast({ | |
description: msg, | |
status: "error", | |
title: "Failed to complete", | |
}); | |
setDisableClicks(false); | |
return; | |
} | |
toast({ | |
description: "Event marked as completed", | |
status: "success", | |
title: "Success", | |
}); | |
const newEventData = eventData; | |
// Technically this won't be the same as the time in the db, but that doesn't matter since this is just to hide the button | |
newEventData[eventData.findIndex((e) => e.id === eventId)].performed_at = | |
Date.now(); | |
setEventData([...newEventData]); | |
setSelectedEvent(""); | |
setDisableClicks(false); | |
} | |
async function markForgotten(eventId: string) { | |
setDisableClicks(true); | |
const forgottenResp = await fetch( | |
`/api/events-team/events/${eventId}/forgotten`, | |
{ | |
body: "{}", | |
headers: { | |
"content-type": "application/json", | |
}, | |
method: "POST", | |
}, | |
); | |
onForgottenClose(); | |
if (!forgottenResp.ok) { | |
let msg = "Unknown error"; | |
try { | |
msg = ((await forgottenResp.json()) as { error: string }).error; | |
} catch {} | |
toast({ | |
description: msg, | |
status: "error", | |
title: "Failed to forget", | |
}); | |
setDisableClicks(false); | |
return; | |
} | |
const newEventData = eventData; | |
newEventData[eventData.findIndex((e) => e.id === eventId)].performed_at = 0; | |
setEventData([...newEventData]); | |
setSelectedEvent(""); | |
setDisableClicks(false); | |
} | |
async function deleteEvent(eventId: string) { | |
setDisableClicks(true); | |
const deleteResp = await fetch(`/api/events-team/events/${eventId}`, { | |
method: "DELETE", | |
}); | |
onDeleteClose(); | |
if (!deleteResp.ok) { | |
let msg = "Unknown error"; | |
try { | |
msg = ((await deleteResp.json()) as { error: string }).error; | |
} catch {} | |
toast({ | |
description: msg, | |
status: "error", | |
title: "Failed to delete", | |
}); | |
setDisableClicks(false); | |
return; | |
} | |
setEventData(eventData.filter((e) => e.id !== eventId)); | |
setSelectedEvent(""); | |
setDisableClicks(false); | |
} | |
async function reschedule(eventId: string) { | |
setDisableClicks(true); | |
const newDate = ( | |
document.getElementById("reschedule-input") as HTMLInputElement | |
).value; | |
const day = newDate.split("-").at(2); | |
const rescheduleResp = await fetch(`/api/events-team/events/${eventId}`, { | |
body: JSON.stringify({ day }), | |
headers: { | |
"content-type": "application/json", | |
}, | |
method: "PATCH", | |
}); | |
if (!rescheduleResp.ok) { | |
let msg = "Unknown error"; | |
try { | |
msg = ((await rescheduleResp.json()) as { error: string }).error; | |
} catch {} | |
toast({ | |
description: msg, | |
status: "error", | |
title: "Failed to reschedule", | |
}); | |
setDisableClicks(false); | |
return; | |
} | |
const newEventData = eventData; | |
newEventData[eventData.findIndex((e) => e.id === eventId)].day = day; | |
setEventData([...newEventData]); | |
setSelectedEvent(""); | |
onRescheduleClose(); | |
setDisableClicks(false); | |
toast({ | |
description: `Event rescheduled to ${newDate}`, | |
status: "success", | |
title: "Rescheduled", | |
}); | |
} | |
return ( | |
<Container maxW="container.lg"> | |
<Modal isOpen={isOpen} onClose={onClose}> | |
<ModalOverlay /> | |
<ModalContent> | |
<ModalHeader>Certify Game Night</ModalHeader> | |
<ModalCloseButton /> | |
<ModalBody> | |
<Text> | |
By certifying this game night, you confirm that the minimum number | |
of players was met and you were provided proof. | |
</Text> | |
</ModalBody> | |
<ModalFooter> | |
<Button | |
colorScheme="red" | |
disabled={disableClicks} | |
onClick={onClose} | |
> | |
Cancel | |
</Button> | |
<Button | |
colorScheme="blue" | |
disabled={disableClicks} | |
ml="8px" | |
onClick={async () => { | |
await certify(selectedEvent); | |
onClose(); | |
}} | |
> | |
Certify | |
</Button> | |
</ModalFooter> | |
</ModalContent> | |
</Modal> | |
<Modal isOpen={isCompleteOpen} onClose={closeComplete}> | |
<ModalOverlay /> | |
<ModalContent> | |
<ModalHeader>Mark as Completed</ModalHeader> | |
<ModalCloseButton /> | |
<ModalBody> | |
<Text> | |
By marking this event as completed, you confirm that the event | |
creator has performed this event | |
</Text> | |
</ModalBody> | |
<ModalFooter> | |
<Button disabled={disableClicks} onClick={closeComplete}> | |
Cancel | |
</Button> | |
<Button | |
colorScheme="blue" | |
disabled={disableClicks} | |
ml="8px" | |
onClick={async () => await markComplete(selectedEvent)} | |
> | |
Mark as Complete | |
</Button> | |
</ModalFooter> | |
</ModalContent> | |
</Modal> | |
<Modal isOpen={isForgottenOpen} onClose={onForgottenClose}> | |
<ModalOverlay /> | |
<ModalContent> | |
<ModalHeader>Mark as Forgotten</ModalHeader> | |
<ModalCloseButton /> | |
<ModalBody> | |
Are you sure you want to mark this event as forgotten? The creator | |
will be given a 5 point penalty. | |
</ModalBody> | |
<ModalFooter> | |
<Button disabled={disableClicks} onClick={onForgottenClose}> | |
Cancel | |
</Button> | |
<Button | |
colorScheme="blue" | |
disabled={disableClicks} | |
ml="8px" | |
onClick={async () => await markForgotten(selectedEvent)} | |
> | |
Mark Forgotten | |
</Button> | |
</ModalFooter> | |
</ModalContent> | |
</Modal> | |
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}> | |
<ModalOverlay /> | |
<ModalContent> | |
<ModalHeader>Delete Event?</ModalHeader> | |
<ModalCloseButton /> | |
<ModalBody> | |
You are about to permanently delete this event. Are you sure you | |
want to continue? | |
</ModalBody> | |
<ModalFooter> | |
<Button disabled={disableClicks} onClick={onDeleteClose}> | |
Cancel | |
</Button> | |
<Button | |
colorScheme="red" | |
disabled={disableClicks} | |
ml="8px" | |
onClick={async () => await deleteEvent(selectedEvent)} | |
> | |
Delete | |
</Button> | |
</ModalFooter> | |
</ModalContent> | |
</Modal> | |
<Modal isOpen={isRescheduleOpen} onClose={onRescheduleClose}> | |
<ModalOverlay /> | |
<ModalContent> | |
<ModalHeader>Reschedule Event</ModalHeader> | |
<ModalCloseButton /> | |
<ModalBody> | |
<Text> | |
New date: | |
<input | |
id="reschedule-input" | |
type="date" | |
style={{ marginLeft: "8px" }} | |
min={new Date().toISOString().split("T").at(0)} | |
max={(function () { | |
const date = new Date(); | |
date.setUTCMonth(date.getUTCMonth() + 1, 0); | |
return date.toISOString().split("T").at(0); | |
})()} | |
/> | |
</Text> | |
</ModalBody> | |
<ModalFooter> | |
<Button disabled={disableClicks} onClick={onRescheduleClose}> | |
Cancel | |
</Button> | |
<Button | |
colorScheme="blue" | |
disabled={disableClicks} | |
ml="8px" | |
onClick={async () => await reschedule(selectedEvent)} | |
> | |
Reschedule | |
</Button> | |
</ModalFooter> | |
</ModalContent> | |
</Modal> | |
<VStack spacing="8"> | |
{eventData | |
.map((event) => { | |
if (!showOld && event.day < new Date().getUTCDate()) return; | |
const eventCreatorName = members.find( | |
(member) => member.id === event.created_by, | |
)?.name; | |
const eventColors: { [k: string]: string } = { | |
fotd: "cyan", | |
gamenight: "blue", | |
rotw: "magenta", | |
qotd: "#9900FF", | |
}; | |
return ( | |
<Card | |
borderColor={eventColors[event.type]} | |
borderWidth="2px" | |
w="100%" | |
> | |
<CardBody> | |
<Stack divider={<StackDivider />} spacing="4"> | |
<Box> | |
<Heading size="sm">Date</Heading> | |
<Text fontSize="sm" pt="2"> | |
{event.year}-{event.month}-{event.day} | |
<IconButton | |
aria-label="Edit event date" | |
display={ | |
(event.created_by === user_id && | |
event.day > new Date().getUTCDate()) || | |
can_approve | |
? undefined | |
: "none" | |
} | |
icon={ | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
width="16" | |
height="16" | |
fill="currentColor" | |
viewBox="0 0 16 16" | |
> | |
<path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z" /> | |
<path | |
fill-rule="evenodd" | |
d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z" | |
/> | |
</svg> | |
} | |
ml="8px" | |
onClick={() => { | |
setSelectedEvent(event.id); | |
onRescheduleOpen(); | |
}} | |
/> | |
</Text> | |
</Box> | |
<Box> | |
<Heading size="sm">Event Type</Heading> | |
<Text fontSize="sm" pt="2"> | |
{event.type.toUpperCase()} | |
</Text> | |
</Box> | |
<Box> | |
<Heading size="sm">Event Details</Heading> | |
<Text fontSize="sm" pt="2"> | |
{event.details} | |
</Text> | |
</Box> | |
{event.type === "rotw" ? ( | |
<Box> | |
<Heading size="sm">Riddle Answer</Heading> | |
<Text fontSize="sm" pt="2"> | |
{event.answer} | |
</Text> | |
</Box> | |
) : null} | |
<Box> | |
<Heading size="sm">Host</Heading> | |
<Text fontSize="sm" pt="2"> | |
{eventCreatorName | |
? `${eventCreatorName} (${event.created_by})` | |
: event.created_by} | |
</Text> | |
</Box> | |
</Stack> | |
</CardBody> | |
<CardFooter> | |
<Flex gap="8px" mr="8px"> | |
{can_approve && event.pending ? ( | |
<> | |
<Button | |
colorScheme="red" | |
disabled={disableClicks} | |
onClick={async () => await decide(false, event.id)} | |
> | |
Reject | |
</Button> | |
<Button | |
colorScheme="blue" | |
disabled={disableClicks} | |
onClick={async () => await decide(true, event.id)} | |
> | |
Approve | |
</Button> | |
</> | |
) : null} | |
{can_approve && | |
!event.pending && | |
typeof event.performed_at !== "number" ? ( | |
<> | |
<Button | |
colorScheme="blue" | |
disabled={disableClicks} | |
onClick={() => { | |
setSelectedEvent(event.id); | |
openComplete(); | |
}} | |
> | |
Mark as Completed | |
</Button> | |
<Button | |
colorScheme="red" | |
disabled={disableClicks} | |
onClick={() => { | |
setSelectedEvent(event.id); | |
onForgottenOpen(); | |
}} | |
> | |
Mark as Forgotten | |
</Button> | |
</> | |
) : null} | |
{can_approve && | |
event.approved && | |
event.type === "gamenight" && | |
event.performed_at && | |
!event.reached_minimum_player_count ? ( | |
<Button | |
colorScheme="blue" | |
disabled={disableClicks} | |
onClick={() => { | |
setSelectedEvent(event.id); | |
onOpen(); | |
}} | |
> | |
Certify Game Night | |
</Button> | |
) : null} | |
</Flex> | |
<Text alignSelf="center" fontSize="sm"> | |
Status:{" "} | |
{event.pending | |
? "Pending" | |
: event.approved | |
? event.performed_at | |
? "Completed" | |
: "Approved" | |
: "Denied"} | |
</Text> | |
<IconButton | |
alignSelf="center" | |
aria-label="Delete event" | |
disabled={disableClicks} | |
display={ | |
(event.created_by === user_id && | |
event.day > new Date().getUTCDate()) || | |
can_approve | |
? undefined | |
: "none" | |
} | |
icon={ | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
width="32" | |
height="32" | |
fill="currentColor" | |
viewBox="0 0 16 16" | |
> | |
<path d="M2.5 1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1H3v9a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V4h.5a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1zm3 4a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 .5-.5M8 5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-1 0v-7A.5.5 0 0 1 8 5m3 .5v7a.5.5 0 0 1-1 0v-7a.5.5 0 0 1 1 0" /> | |
</svg> | |
} | |
marginLeft="auto" | |
marginRight={0} | |
onClick={() => { | |
setSelectedEvent(event.id); | |
onDeleteOpen(); | |
}} | |
/> | |
</CardFooter> | |
</Card> | |
); | |
}) | |
.filter((e) => e)} | |
</VStack> | |
<VStack alignItems="start" gap="8px" my="16px"> | |
<FormControl> | |
<FormLabel htmlFor="show-old-events" mb="0"> | |
Show old events | |
</FormLabel> | |
<Switch | |
id="show-old-events" | |
onChange={(e) => { | |
setShowOld(e.target.checked); | |
setEventData([...eventData]); | |
}} | |
/> | |
</FormControl> | |
<Link color="#646cff" href="/book-event" mt="16px" target="_blank"> | |
Book an Event | |
</Link> | |
<Link | |
color="#646cff" | |
href="/events-team/historical" | |
mt="8px" | |
target="_blank" | |
> | |
Historical Events | |
</Link> | |
{can_approve ? ( | |
<> | |
<Link color="#646cff" href="/events-team/outstanding" mt="8px"> | |
Outstanding Events from Last Month | |
</Link> | |
<Link color="#646cff" href="/et-members" mb="32px" mt="8px"> | |
Events Team Member Management | |
</Link> | |
</> | |
) : null} | |
</VStack> | |
</Container> | |
); | |
} |