diff --git a/app/event/[mode]/[eventId]/[[...locationId]]/page.tsx b/app/event/[mode]/[eventId]/[[...locationId]]/page.tsx index 717b3fd..eacc926 100644 --- a/app/event/[mode]/[eventId]/[[...locationId]]/page.tsx +++ b/app/event/[mode]/[eventId]/[[...locationId]]/page.tsx @@ -74,7 +74,7 @@ export default async function EventPage({ ); } diff --git a/app/home/page.tsx b/app/home/page.tsx index 2716d19..ac83ac5 100644 --- a/app/home/page.tsx +++ b/app/home/page.tsx @@ -18,16 +18,22 @@ import { prisma } from "@/lib/api/db"; import { Box } from "@mui/material"; import { Suspense } from "react"; -// Collect all events from doradev database +// Collect all unarchived events from doradev database export default async function EventSelect() { const events = await prisma.event.findMany({ + where: { + isArchived: false, + }, select: { id: true, name: true, isGS: true, + isCC: true, + isArchived: true, locations: { select: { id: true, + locationId: true, }, }, }, diff --git a/components/ArchivedEventsDialog.tsx b/components/ArchivedEventsDialog.tsx new file mode 100644 index 0000000..b6d9a3c --- /dev/null +++ b/components/ArchivedEventsDialog.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { ArchiveRestore, CopyPlus, Trash } from "lucide-react"; +import { useEffect, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { GetArchivedEvents } from "@/lib/api/read/GetArchivedEvents"; +import { UpdateArchive } from "@/lib/api/update/Event"; +import DeleteEntity from "@/lib/api/delete/DeleteEntity"; +import { duplicateEvent } from "@/lib/api/create/duplicateEvent"; +import Link from "next/link"; +import { EventWithLocationIds } from "@/types/Event"; + +interface ArchivedEventsProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function ArchivedEventsDialog({ open, onOpenChange }: ArchivedEventsProps) { + const [archivedEvents, setArchivedEvents] = useState([]); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [unarchiveDialogOpen, setUnarchiveDialogOpen] = useState(false); + const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + const [selectedEvent, setSelectedEvent] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Fetch archived events when dialog opens + useEffect(() => { + if (open) { + loadArchivedEvents(); + } + }, [open]); + + async function loadArchivedEvents() { + setIsLoading(true); + try { + const events = await GetArchivedEvents(); + setArchivedEvents(events); + } catch (error) { + console.error("Failed to load archived events:", error); + } finally { + setIsLoading(false); + } + } + + async function handleUnarchive(eventId: string) { + try { + await UpdateArchive(eventId, false); + setArchivedEvents(archivedEvents.filter((e) => e.id !== eventId)); + setUnarchiveDialogOpen(false); + setSelectedEvent(null); + } catch (error) { + console.error("Failed to unarchive event:", error); + } + } + + async function handleDelete(eventId: string) { + try { + await DeleteEntity("event", eventId); + setArchivedEvents(archivedEvents.filter((e) => e.id !== eventId)); + setDeleteDialogOpen(false); + setSelectedEvent(null); + } catch (error) { + console.error("Failed to delete event:", error); + } + } + + return ( + <> + + + + Archived Events + +
+ +
+ {isLoading ? ( +
+ Loading archived events... +
+ ) : archivedEvents.length === 0 ? ( +
+ No archived events found +
+ ) : ( + archivedEvents.map((event) => ( +
+ onOpenChange(false)} + > +

+ {event.name} +

+
+ {event.locations.length > 0 && ( + + {event.locations.length} location{event.locations.length !== 1 ? 's' : ''} + + )} + {event.isGS && ( + + Getting Started + + )} + {event.isCC && ( + + Campus Christmas + + )} +
+ +
+ + + +
+
+ )) + )} +
+
+
+
+ + {/* Unarchive Confirmation Dialog */} + + + + Unarchive Event + + Are you sure you want to unarchive "{selectedEvent?.name} + "? It will be restored to the active events list. + + + + { + setUnarchiveDialogOpen(false); + setSelectedEvent(null); + }} + > + Cancel + + { + if (selectedEvent) { + handleUnarchive(selectedEvent.id); + } + }} + > + Unarchive + + + + + + {/* Delete Confirmation Dialog */} + + + + Delete Event + + Are you sure you want to permanently delete " + {selectedEvent?.name}"? This action cannot be undone. + + + + { + setDeleteDialogOpen(false); + setSelectedEvent(null); + }} + > + Cancel + + { + if (selectedEvent) { + handleDelete(selectedEvent.id); + } + }} + className="bg-red-500 hover:bg-red-600" + > + Delete + + + + + + {/* Duplicate Confirmation Dialog */} + + + + Duplicate Event + + {`Are you sure you want to duplicate "${selectedEvent?.name}"?`} + + + + setDuplicateDialogOpen(false)}> + Cancel + + { + if (selectedEvent) { + await duplicateEvent(selectedEvent); + setDuplicateDialogOpen(false); + } + }} + > + Confirm + + + + + + ); +} diff --git a/components/EventFlow.tsx b/components/EventFlow.tsx index d26be5e..defac26 100644 --- a/components/EventFlow.tsx +++ b/components/EventFlow.tsx @@ -625,7 +625,7 @@ function Flow({ {isEditable && ( - + )} diff --git a/components/EventSelectForm.tsx b/components/EventSelectForm.tsx index 1c849c3..9e8601f 100644 --- a/components/EventSelectForm.tsx +++ b/components/EventSelectForm.tsx @@ -35,7 +35,12 @@ import CreateEvent from "@/lib/api/create/createEvent"; import DeleteEntity from "@/lib/api/delete/DeleteEntity"; import AddIcon from "@mui/icons-material/Add"; import RemoveIcon from "@mui/icons-material/Remove"; +import { CopyPlus, Archive } from "lucide-react"; import { EventWithLocationIds } from "@/types/Event"; +import { + UpdateArchive, +} from "@/lib/api/update/Event"; +import { duplicateEvent as duplicateEventUtil } from "@/lib/api/create/duplicateEvent"; export default function EventSelectForm({ events, @@ -47,7 +52,9 @@ export default function EventSelectForm({ const [eventId, setEventId] = useState(""); const [locationAdderOpen, setLocationAdderOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); const [insertDialogOpen, setInsertDialogOpen] = useState(false); + const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [eventToCreate, setEventToCreate] = useState(""); const [dropdownEvents, setDropdownEvents] = useState(events); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -56,6 +63,14 @@ export default function EventSelectForm({ entity: Event | Location; type: "event" | "location"; }>(); + const [eventToArchive, setEventToArchive] = useState<{ + entity: Event; + type: "event"; + }>(); + const [eventToDuplicate, setEventToDuplicate] = useState<{ + entity: Event; + type: "event"; + }>(); const [selectedEventLocations, setSelectedEventLocations] = useState< Location[] >([]); @@ -76,6 +91,28 @@ export default function EventSelectForm({ setSelectedEventLocations([]); } + async function duplicateEvent(id: string) { + const eventToDup = events.find((e) => e.id === id); + if (!eventToDup) return; + + // Use the shared utility function + const newEvent = await duplicateEventUtil(eventToDup); + setDropdownEvents([...dropdownEvents, newEvent]); + setEventId(newEvent.id); + setSelectedEventLocations([]); + router.push(`/home/event/${newEvent.id}`); + } + + async function archiveEvent(id: string) { + setArchiveDialogOpen(false); + + // Archive the event in the database + await UpdateArchive(id, true); + + // Remove the event from the dropdown list + setDropdownEvents(dropdownEvents.filter((e) => e.id !== id)); + } + function deleteLocation(id: string, eventId: string) { DeleteEntity("location", id, eventId); setDeleteDialogOpen(false); @@ -133,17 +170,39 @@ export default function EventSelectForm({
{event.name} {canEdit && ( - +
+ + + +
)}
@@ -217,6 +276,29 @@ export default function EventSelectForm({ + + + + Archive Event + + {`Are you sure you want to archive "${eventToArchive?.entity.name}"?`} + + + + setArchiveDialogOpen(false)}> + Cancel + + { + archiveEvent(eventToArchive!.entity.id); + }} + > + Confirm + + + + +
+ + + + Duplicate Event + + {`Are you sure you want to duplicate "${eventToDuplicate?.entity.name}"?`} + + + + setDuplicateDialogOpen(false)}> + Cancel + + { + duplicateEvent(eventToDuplicate!.entity.id); + setDuplicateDialogOpen(false); + }} + > + Confirm + + + + + v === locationId)) { @@ -84,10 +87,11 @@ export default function EventSettings({ currentLocations.map((location) => location.id) ); form.setValue("isGettingStarted", event.isGS); + form.setValue("isCampusChristmas", event.isCC); setCurrentLocations( currentLocations.sort((a, b) => a.name.localeCompare(b.name)) ); - }, [currentLocations, form, event.isGS]); + }, [currentLocations, form, event.isGS, event.isCC]); return ( @@ -193,6 +197,29 @@ export default function EventSettings({ )} /> + {/* Campus Christmas switch */} + ( + +
+ + Campus Christmas + + + Is this event related to Campus Christmas? + +
+ + + +
+ )} + />
); @@ -144,27 +153,35 @@ const Legend: React.FC = ({ isGettingStarted, onDrop }) => { - {categories.map( - (category) => - (category.value !== "getting-started" || - (category.value === "getting-started" && isGettingStarted)) && ( - - {category.title} - -
- {category.items.map((item, index) => ( - - ))} -
-
-
- ) - )} + {categories.map((category) => { + // Skip getting-started if not enabled + if (category.value === "getting-started" && !isGettingStarted) { + return null; + } + + // Skip campus-christmas if not enabled + if (category.value === "campus-christmas" && !isCampusChristmas) { + return null; + } + + return ( + + {category.title} + +
+ {category.items.map((item, index) => ( + + ))} +
+
+
+ ); + })}
diff --git a/components/LocationList.tsx b/components/LocationList.tsx index 504d56a..9dde35d 100644 --- a/components/LocationList.tsx +++ b/components/LocationList.tsx @@ -32,8 +32,9 @@ export default function LocationList({ eventId, eventName }: Props) { const [locations, setLocations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isNavigating, setIsNavigating] = useState(false); + const [event, setEvent] = useState<{ isArchived: boolean } | null>(null); const { data: session } = useSession(); - const canEdit = session?.role === "ADMIN" || session?.role === "EDITOR"; + const canEdit = (session?.role === "ADMIN" || session?.role === "EDITOR") && !event?.isArchived; // added state const [locationAdderOpen, setLocationAdderOpen] = useState(false); @@ -55,7 +56,10 @@ export default function LocationList({ eventId, eventName }: Props) { eventData?.locations.some((evLoc) => evLoc.locationId === l.id) ) .sort((a, b) => a.name.localeCompare(b.name)) || []; - if (active) setLocations(filtered); + if (active) { + setLocations(filtered); + setEvent(eventData ? { isArchived: eventData.isArchived } : null); + } } finally { if (active) setIsLoading(false); } diff --git a/components/layout/ArchivedEventsButton.tsx b/components/layout/ArchivedEventsButton.tsx new file mode 100644 index 0000000..f0a6e72 --- /dev/null +++ b/components/layout/ArchivedEventsButton.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Archive } from "lucide-react"; +import { Button } from "../ui/button"; +import { useState } from "react"; +import ArchivedEventsDialog from "../ArchivedEventsDialog"; + +export default function ArchivedEventsButton() { + const [open, setOpen] = useState(false); + + return ( + <> +
setOpen(true)} + > + Archived Events + +
+ + + ); +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index f385ec0..45a2039 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import Image from "next/image"; import Logo from "@/public/pinpoint-logo-color.png"; import { getRecents } from "@/lib/recents/read"; import Link from "next/link"; +import ArchivedEventsButton from "./ArchivedEventsButton"; export default async function Sidebar() { const session = await getServerSession(); @@ -47,6 +48,7 @@ export default async function Sidebar() { ))}
+
Settings diff --git a/lib/api/create/duplicateEvent.ts b/lib/api/create/duplicateEvent.ts new file mode 100644 index 0000000..0f456b0 --- /dev/null +++ b/lib/api/create/duplicateEvent.ts @@ -0,0 +1,75 @@ +"use server"; + +import CreateEvent from "./createEvent"; +import { SyncLocations, UpdateGettingStarted, UpdateCampusChristmas } from "../update/Event"; +import { GetEventLocationInfo } from "../read/GetEventLocationInfo"; +import SaveState from "../update/ReactFlowSave"; +import { EventWithLocationIds } from "@/types/Event"; +import { revalidatePath } from "next/cache"; + +/** + * Duplicates an event with all its locations and node states + * @param eventToCopy - The event to duplicate + * @param options - Optional configuration + * @returns The newly created event + */ +export async function duplicateEvent( + eventToCopy: EventWithLocationIds, + options?: { + nameSuffix?: string; + isArchived?: boolean; + } +) { + const { nameSuffix = " (Copy)" } = options || {}; + + // Create the new event + const newEvent = await CreateEvent(eventToCopy.name + nameSuffix); + + // Copy all locations and their states + const locationLinks = eventToCopy.locations; + + if (locationLinks && locationLinks.length > 0) { + // Extract location IDs directly from the links + const locationIds = locationLinks.map((link) => link.locationId); + + if (locationIds.length > 0) { + await SyncLocations(newEvent, locationIds); + + // For each location, copy the state from the original event + for (const locationId of locationIds) { + // Get the original event's state for this location + const originalEventLocation = await GetEventLocationInfo( + eventToCopy.id, + locationId + ); + + if (originalEventLocation?.state) { + // Save the same state to the new event's location + await SaveState( + newEvent.id, + locationId, + originalEventLocation.state, + "" // Empty client ID since this is a copy operation + ); + } + } + } + } + + // Copy the isGS and isCC flags + if (eventToCopy.isGS !== undefined) { + await UpdateGettingStarted(newEvent.id, eventToCopy.isGS); + } + if (eventToCopy.isCC !== undefined) { + await UpdateCampusChristmas(newEvent.id, eventToCopy.isCC); + } + + // The new event's archive status is determined by the options parameter + // By default it's false (not archived) since newly created events start as isArchived: false + // If you want to explicitly set it, you can add UpdateArchive call here + + // Revalidate the home page to show the new event + revalidatePath("/home"); + + return newEvent; +} diff --git a/lib/api/read/GetArchivedEvents.ts b/lib/api/read/GetArchivedEvents.ts new file mode 100644 index 0000000..c795e20 --- /dev/null +++ b/lib/api/read/GetArchivedEvents.ts @@ -0,0 +1,28 @@ +"use server"; + +import { prisma } from "../db"; + +export async function GetArchivedEvents() { + const events = await prisma.event.findMany({ + where: { + isArchived: true, + }, + select: { + id: true, + name: true, + isGS: true, + isCC: true, + isArchived: true, + locations: { + select: { + id: true, + locationId: true, + }, + }, + }, + orderBy: { + name: 'asc', + }, + }); + return events; +} diff --git a/lib/api/update/Event.ts b/lib/api/update/Event.ts index e273c4d..c2ce300 100644 --- a/lib/api/update/Event.ts +++ b/lib/api/update/Event.ts @@ -62,3 +62,28 @@ export async function UpdateGettingStarted(eventId: string, isGS: boolean) { }, }); } + +export async function UpdateArchive(eventId: string, isArchived: boolean) { + revalidatePath("event/"); + revalidatePath("/home"); + return await prisma.event.update({ + where: { + id: eventId, + }, + data: { + isArchived, + }, + }); +} + +export async function UpdateCampusChristmas(eventId: string, isCC: boolean) { + revalidatePath("event/"); + return await prisma.event.update({ + where: { + id: eventId, + }, + data: { + isCC, + }, + }); +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f15d45..d396815 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,11 +8,13 @@ datasource db { } model Event { - id String @id @default(cuid()) - name String - isGS Boolean @default(false) - locations EventToLocation[] - Recents Recents[] + id String @id @default(cuid()) + name String + isGS Boolean @default(false) + isCC Boolean @default(false) + isArchived Boolean @default(false) + locations EventToLocation[] + Recents Recents[] } model EventToLocation { diff --git a/types/Event.ts b/types/Event.ts index 9e8ac18..1ad5252 100644 --- a/types/Event.ts +++ b/types/Event.ts @@ -18,5 +18,6 @@ export type EventWithLocations = { export interface EventWithLocationIds extends Event { locations: Array<{ id: string; + locationId: string; }>; }