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 [showOld, setShowOld] = useState(false); async function decide(approved: boolean, eventId: string) { 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!", }); 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]); } async function certify(eventId: string) { 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", }); 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(""); } async function markComplete(eventId: string) { 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", }); 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(""); } async function markForgotten(eventId: string) { 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", }); return; } const newEventData = eventData; newEventData[eventData.findIndex((e) => e.id === eventId)].performed_at = 0; setEventData([...newEventData]); setSelectedEvent(""); } async function deleteEvent(eventId: string) { 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", }); return; } setEventData(eventData.filter((e) => e.id !== eventId)); setSelectedEvent(""); } async function reschedule(eventId: string) { 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", }); return; } const newEventData = eventData; newEventData[eventData.findIndex((e) => e.id === eventId)].day = day; setEventData([...newEventData]); setSelectedEvent(""); onRescheduleClose(); 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" onClick={onClose}> Cancel </Button> <Button colorScheme="blue" 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 onClick={closeComplete}>Cancel</Button> <Button colorScheme="blue" 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 onClick={onForgottenClose}>Cancel</Button> <Button colorScheme="blue" 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 onClick={onDeleteClose}>Cancel</Button> <Button colorScheme="red" 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 onClick={onRescheduleClose}>Cancel</Button> <Button colorScheme="blue" 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" onClick={async () => await decide(false, event.id)} > Reject </Button> <Button colorScheme="blue" onClick={async () => await decide(true, event.id)} > Approve </Button> </> ) : null} {can_approve && !event.pending && typeof event.performed_at !== "number" ? ( <> <Button colorScheme="blue" onClick={() => { setSelectedEvent(event.id); openComplete(); }} > Mark as Completed </Button> <Button colorScheme="red" 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" 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" 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> ); }