diff --git a/apps/api/package.json b/apps/api/package.json index 63247c96..25f67450 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,7 +21,7 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@acme/api": "workspace:*", + "@acme/api": "workspace:^0.2.0", "@acme/auth": "workspace:^0.1.0", "@acme/db": "workspace:^0.1.0", "@acme/env": "workspace:^0.1.0", diff --git a/apps/map/package.json b/apps/map/package.json index 9195e711..085d4790 100644 --- a/apps/map/package.json +++ b/apps/map/package.json @@ -21,7 +21,7 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { - "@acme/api": "workspace:*", + "@acme/api": "workspace:^0.2.0", "@acme/auth": "workspace:^0.1.0", "@acme/db": "workspace:^0.1.0", "@acme/env": "workspace:^0.1.0", diff --git a/apps/map/src/app/_components/map/event-chip.tsx b/apps/map/src/app/_components/map/event-chip.tsx index 467cd71e..6fe4fb33 100644 --- a/apps/map/src/app/_components/map/event-chip.tsx +++ b/apps/map/src/app/_components/map/event-chip.tsx @@ -9,7 +9,9 @@ import { useCallback } from "react"; import type { DayOfWeek } from "@acme/shared/app/enums"; import { cn } from "@acme/ui"; +import type { MapStatus } from "~/utils/types"; import { getWhenFromWorkout } from "~/utils/get-when-from-workout"; +import { getSelectedChipBg } from "~/utils/map-status-colors"; import { setView } from "~/utils/set-view"; import { setSelectedItem } from "~/utils/store/selected-item"; import BootSvgComponent from "../SVGs/boot-camp"; @@ -20,6 +22,7 @@ export const EventChip = (props: { variant?: "interactive" | "non-interactive"; size?: "small" | "medium" | "large"; selected?: boolean; + mapStatus?: MapStatus; onClick?: (e?: React.MouseEvent) => void; event: { name?: string; @@ -104,8 +107,7 @@ export const EventChip = (props: { "px-2 shadow", "cursor-pointer", { "pointer-events-none bg-muted": !isInteractive }, - { "bg-red-600": isInteractive && selected }, - { "bg-muted": isInteractive && !selected }, + isInteractive && selected && getSelectedChipBg(props.mapStatus ?? null), { "gap-1 py-[1px]": size === "small" }, { "gap-1 py-[2px]": size === "medium" }, { "gap-2 py-[3px]": size === "large" }, @@ -116,7 +118,7 @@ export const EventChip = (props: {
) : event.eventTypes.some((et) => et.name === "Ruck") ? ( ) : event.eventTypes.some((et) => et.name === "Run") ? ( ) : null}
diff --git a/apps/map/src/app/_components/map/filtered-map-results-provider.tsx b/apps/map/src/app/_components/map/filtered-map-results-provider.tsx index 49a254f2..69acf35a 100644 --- a/apps/map/src/app/_components/map/filtered-map-results-provider.tsx +++ b/apps/map/src/app/_components/map/filtered-map-results-provider.tsx @@ -4,9 +4,11 @@ import type { ReactNode } from "react"; import { createContext, useContext, useMemo } from "react"; import { DEFAULT_CENTER } from "@acme/shared/app/constants"; +import type { DayOfWeek } from "@acme/shared/app/enums"; import { RERENDER_LOGS } from "@acme/shared/common/constants"; -import type { SparseF3Marker } from "~/utils/types"; +import { DAYS_OF_WEEK } from "~/utils/days-of-week"; +import type { MapStatus, SparseF3Marker } from "~/utils/types"; import { orpc, useQuery } from "~/orpc/react"; import { filterData } from "~/utils/filtered-data"; import { filterStore } from "~/utils/store/filter"; @@ -33,6 +35,45 @@ const FilteredMapResultsContext = createContext<{ allLocationMarkersWithLatLngAndFilterData: undefined, }); +function dateToDayOfWeek(dateStr: string): DayOfWeek { + const d = new Date(dateStr + "T00:00:00"); + return DAYS_OF_WEEK[d.getUTCDay()] ?? "sunday"; +} + +function computeMapStatus( + event: { startDate: string | null; endDate: string | null; id: number }, + instanceLookup: Map< + number, + { seriesException: string | null; startDate: string }[] + >, +): MapStatus { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const thirtyDaysOut = new Date(now); + thirtyDaysOut.setDate(thirtyDaysOut.getDate() + 30); + + if (event.endDate) { + const end = new Date(event.endDate + "T00:00:00"); + const started = event.startDate + ? new Date(event.startDate + "T00:00:00") <= now + : true; + if (started && end >= now && end <= thirtyDaysOut) return "closing"; + } + + const instances = instanceLookup.get(event.id) ?? []; + if (instances.some((i) => i.seriesException === "closed")) return "closing"; + + if (event.startDate) { + const start = new Date(event.startDate + "T00:00:00"); + if (start > now && start <= thirtyDaysOut) return "deviation"; + } + + if (instances.some((i) => i.seriesException === "different-time")) + return "deviation"; + + return null; +} + export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { RERENDER_LOGS && console.log("FilteredMapResultsProvider rerender"); const nearbyLocationCenter = mapStore.use.nearbyLocationCenter(); @@ -42,6 +83,12 @@ export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { }), ); + const { data: upcomingInstancesData } = useQuery( + orpc.map.location.upcomingInstances.queryOptions({ + input: undefined, + }), + ); + const filters = filterStore.useBoundStore(); /** @@ -50,6 +97,27 @@ export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { const allLocationMarkersWithLatLngAndFilterData = useMemo(() => { if (!mapEventAndLocationData) return undefined; + const instancesBySeriesId = new Map< + number, + { seriesException: string | null; startDate: string }[] + >(); + const standaloneInstances: NonNullable = []; + + if (upcomingInstancesData) { + for (const instance of upcomingInstancesData) { + if (instance.seriesId != null) { + const list = instancesBySeriesId.get(instance.seriesId) ?? []; + list.push({ + seriesException: instance.seriesException, + startDate: instance.startDate, + }); + instancesBySeriesId.set(instance.seriesId, list); + } else { + standaloneInstances.push(instance); + } + } + } + const allLocationMarkerFilterData = mapEventAndLocationData.map( (location) => { return { @@ -60,42 +128,71 @@ export const FilteredMapResultsProvider = (params: { children: ReactNode }) => { lon: location[4], fullAddress: location[5], events: location[6].map((event) => { - return { + const eventObj = { id: event[0], name: event[1], dayOfWeek: event[2], startTime: event[3], eventTypes: event[4], + startDate: event[5], + endDate: event[6], + }; + return { + ...eventObj, + mapStatus: computeMapStatus(eventObj, instancesBySeriesId), }; }), }; }, ); - const locationIdToLatLng = allLocationMarkerFilterData.reduce( - (acc, location) => { - acc[location.id] = location; - return acc; - }, - {} as Record< - number, - { - lat: number | null; - lon: number | null; - fullAddress: string | null; - } - >, - ); + const locationMap = new Map< + number, + (typeof allLocationMarkerFilterData)[number] + >(); + for (const loc of allLocationMarkerFilterData) { + locationMap.set(loc.id, loc); + } - return allLocationMarkerFilterData.map((location) => { - return { - ...location, - lat: locationIdToLatLng[location.id]?.lat ?? null, - lon: locationIdToLatLng[location.id]?.lon ?? null, - fullAddress: locationIdToLatLng[location.id]?.fullAddress ?? null, + for (const instance of standaloneInstances) { + if ( + instance.locationId == null || + instance.lat == null || + instance.lon == null + ) + continue; + const dayOfWeek = dateToDayOfWeek(instance.startDate); + const instanceEvent = { + id: -instance.id, + name: instance.name, + dayOfWeek: dayOfWeek as DayOfWeek, + startTime: instance.startTime, + eventTypes: instance.eventTypes, + startDate: instance.startDate, + endDate: null, + mapStatus: "highlight" as MapStatus, }; - }); - }, [mapEventAndLocationData]); + + const existing = locationMap.get(instance.locationId); + if (existing) { + existing.events.push(instanceEvent); + } else { + const newLocation = { + id: instance.locationId, + aoName: instance.aoName ?? "", + logo: instance.aoLogo, + lat: instance.lat, + lon: instance.lon, + fullAddress: instance.fullAddress, + events: [instanceEvent], + }; + locationMap.set(instance.locationId, newLocation); + allLocationMarkerFilterData.push(newLocation); + } + } + + return allLocationMarkerFilterData; + }, [mapEventAndLocationData, upcomingInstancesData]); /** * Filter the location markers by the filters diff --git a/apps/map/src/app/_components/map/group-marker.tsx b/apps/map/src/app/_components/map/group-marker.tsx index ffad62fc..469a7188 100644 --- a/apps/map/src/app/_components/map/group-marker.tsx +++ b/apps/map/src/app/_components/map/group-marker.tsx @@ -10,6 +10,13 @@ import { dayOfWeekToShortDayOfWeek } from "@acme/shared/app/functions"; import { cn } from "@acme/ui"; import { groupMarkerClick } from "~/utils/actions/group-marker-click"; +import { + getDominantStatus, + getSelectedBg, + getSelectedBorder, + getStatusBase, + STATUS_BASE_DEFAULT, +} from "~/utils/map-status-colors"; import { appStore } from "~/utils/store/app"; import { mapStore } from "~/utils/store/map"; import { @@ -93,12 +100,15 @@ export const FeatureMarker = ({ [id], ); + const dominantStatus = events?.length ? getDominantStatus(events) : null; + return !events?.length ? null : !isClose ? ( { const eventId = events.find( (event) => event.id === selectedEventId, @@ -170,21 +180,24 @@ export const FeatureMarker = ({ }} className={cn( // min-h-[32.5px] so it doesn't collapse with no text - "min-h-[32.5px] flex-1 cursor-pointer bg-foreground py-2 text-center text-background", - "border-b-2 border-l-2 border-r-2 border-t-2 border-foreground ", + "min-h-[32.5px] flex-1 cursor-pointer py-2 text-center", + "border-b-2 border-l-2 border-r-2 border-t-2", `google-eventid-${event.id}`, + (event.mapStatus && getStatusBase(event.mapStatus)) ?? + STATUS_BASE_DEFAULT, { "rounded-r-full": isEnd, "rounded-l-full": isStart, - "border-red-600 dark:border-red-400": - isCurrentSelectedEvent || - (isCurrentSelectedLocation && noSelectedEvent), - "border-red-600 bg-red-600 font-bold dark:bg-red-400": - // On mobile we always use the background - (touchDevice && isCurrentSelectedEvent) || - isCurrentPanelEvent || - (isCurrentPanelLocation && noSelectedPanelEvent), }, + (isCurrentSelectedEvent || + (isCurrentSelectedLocation && noSelectedEvent)) && + getSelectedBorder(event.mapStatus), + ((touchDevice && isCurrentSelectedEvent) || + isCurrentPanelEvent || + (isCurrentPanelLocation && noSelectedPanelEvent)) && [ + getSelectedBg(event.mapStatus), + "font-bold", + ], )} > {dayText} @@ -198,9 +211,7 @@ export const FeatureMarker = ({ style={{ zIndex: 0 }} > event.name) .filter(onlyUnique) @@ -45,7 +52,9 @@ export const MobileNearbyLocationsItem = (props: { "p-2", "h-32", "border-2 border-foreground/10", - { "border-red-500": isSelected }, + isSelected && + ((dominantStatus && getSelectedBorder(dominantStatus)) ?? + "border-red-500"), )} onClick={() => { searchBarRef.current?.blur(); @@ -96,6 +105,7 @@ export const MobileNearbyLocationsItem = (props: { key={event.id} selected={false} size="small" + mapStatus={event.mapStatus} event={event} location={searchResult} /> diff --git a/apps/map/src/app/_components/map/nearby-location-item.tsx b/apps/map/src/app/_components/map/nearby-location-item.tsx index 22460f71..c924e98d 100644 --- a/apps/map/src/app/_components/map/nearby-location-item.tsx +++ b/apps/map/src/app/_components/map/nearby-location-item.tsx @@ -150,6 +150,7 @@ export const NearbyLocationItem = (props: { location={item} size="small" hideName + mapStatus={event.mapStatus} selected={event.id === eventId && item.id === locationId} onClick={(e) => { handleClick({ diff --git a/apps/map/src/app/_components/map/selected-group-marker.tsx b/apps/map/src/app/_components/map/selected-group-marker.tsx index b43a6288..cf2c4904 100644 --- a/apps/map/src/app/_components/map/selected-group-marker.tsx +++ b/apps/map/src/app/_components/map/selected-group-marker.tsx @@ -9,6 +9,11 @@ import { cn } from "@acme/ui"; import type { SparseF3Marker } from "~/utils/types"; import { groupMarkerClick } from "~/utils/actions/group-marker-click"; import { isTouchDevice } from "~/utils/is-touch-device"; +import { + getSelectedBg, + getStatusBase, + STATUS_BASE_DEFAULT, +} from "~/utils/map-status-colors"; import { selectedItemStore, setSelectedItem, @@ -80,18 +85,19 @@ export const MemoSelectedGroupMarker = memo( void groupMarkerClick({ locationId: id, eventId }); }} className={cn( - "flex-1 cursor-pointer border-b-2 border-t-2 border-foreground bg-foreground py-2 text-center text-background", - "border-l-2 border-r-2", + "flex-1 cursor-pointer py-2 text-center", + "border-b-2 border-l-2 border-r-2 border-t-2", `google-eventid-${event.id}`, + (event.mapStatus && getStatusBase(event.mapStatus)) ?? + STATUS_BASE_DEFAULT, { "rounded-r-full": isEnd, "rounded-l-full": isStart, - "border-red-600 font-bold dark:bg-red-400": - selectedIndex === markerIdx, - "bg-red-600": true, - // (!!panel || alwaysShowFillInsteadOfOutline) && - // ( selectedIndex === markerIdx || selectedIndex === -1 ), }, + selectedIndex === markerIdx && [ + getSelectedBg(event.mapStatus), + "font-bold", + ], )} > {dayText} diff --git a/apps/map/src/app/_components/map/use-selected-item.tsx b/apps/map/src/app/_components/map/use-selected-item.tsx index 0895dcc2..07efeb29 100644 --- a/apps/map/src/app/_components/map/use-selected-item.tsx +++ b/apps/map/src/app/_components/map/use-selected-item.tsx @@ -6,6 +6,7 @@ import { CLOSE_ZOOM } from "@acme/shared/app/constants"; import { RERENDER_LOGS } from "@acme/shared/common/constants"; import { orpc, useQuery } from "~/orpc/react"; +import { DAYS_OF_WEEK } from "~/utils/days-of-week"; import { mapStore } from "~/utils/store/map"; import { selectedItemStore } from "~/utils/store/selected-item"; @@ -27,6 +28,12 @@ export const useSelectedItem = () => { }), ); + const { data: upcomingInstancesData } = useQuery( + orpc.map.location.upcomingInstances.queryOptions({ + input: undefined, + }), + ); + const selectedLocation = useMemo(() => { if (debouncedLocationId !== locationId) return undefined; @@ -68,18 +75,46 @@ export const useSelectedItem = () => { // eslint-disable-next-line react-hooks/exhaustive-deps -- zoom is not a dependency but we need to monitor its changes }, [selectedLocation?.lat, selectedLocation?.lon, map, zoom, bounds]); - const selectedEvent = useMemo( - () => - !selectedLocation - ? undefined - : // check for null or undefined - eventId === null || eventId === undefined - ? // get the first of the week (monday is first) - selectedLocation.events[0] - : // use == incase it is a string - selectedLocation.events.find((event) => event.id == eventId), - [selectedLocation, eventId], - ); + const selectedEvent = useMemo(() => { + if (!selectedLocation) return undefined; + if (eventId === null || eventId === undefined) { + return selectedLocation.events[0]; + } + + const realEvent = selectedLocation.events.find( + (event) => event.id == eventId, + ); + if (realEvent) return realEvent; + + if (eventId < 0) { + const instanceId = -eventId; + const instance = upcomingInstancesData?.find((i) => i.id === instanceId); + if (instance) { + const dayOfWeek = instance.startDate + ? DAYS_OF_WEEK[ + new Date(instance.startDate + "T00:00:00").getUTCDay() + ] ?? null + : null; + return { + id: eventId, + name: instance.name, + dayOfWeek, + startTime: instance.startTime, + endTime: instance.endTime, + description: null, + eventTypes: instance.eventTypes, + aoId: null, + aoName: instance.aoName, + aoLogo: instance.aoLogo, + aoWebsite: null, + } as NonNullable< + NonNullable["location"] + >["events"][number]; + } + } + + return undefined; + }, [selectedLocation, eventId, upcomingInstancesData]); // Create memoized debounced function const debouncedSetSelectedItem = useMemo( diff --git a/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx b/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx index 995befa9..6f066b17 100644 --- a/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx +++ b/apps/map/src/app/_components/marker-clusters/features-cluster-marker.tsx @@ -4,6 +4,14 @@ import { useAdvancedMarkerRef, } from "@vis.gl/react-google-maps"; +import { cn } from "@acme/ui"; + +import type { MapStatus } from "~/utils/types"; +import { + getClusterBg, + getClusterInner, + getClusterMid, +} from "~/utils/map-status-colors"; import { closePanel } from "~/utils/store/selected-item"; interface TreeClusterMarkerProps { @@ -15,6 +23,7 @@ interface TreeClusterMarkerProps { position: google.maps.LatLngLiteral; size: number; sizeAsText: string; + statusColor?: MapStatus; } export const FeaturesClusterMarker = ({ @@ -23,6 +32,7 @@ export const FeaturesClusterMarker = ({ sizeAsText, onMarkerClick, clusterId, + statusColor, }: TreeClusterMarkerProps) => { const [markerRef, marker] = useAdvancedMarkerRef(); const markerSize = Math.floor(36 + Math.sqrt(size) * 2); @@ -41,15 +51,26 @@ export const FeaturesClusterMarker = ({ closePanel(); return onMarkerClick?.(marker, clusterId); }} - className={ - "marker cluster flex items-center justify-center rounded-full bg-foreground/30" - } + className={cn( + "marker cluster flex items-center justify-center rounded-full", + statusColor ? getClusterBg(statusColor) : "bg-foreground/30", + )} style={{ width: markerSize, height: markerSize }} anchorPoint={AdvancedMarkerAnchorPoint.CENTER} > -
+
{sizeAsText} diff --git a/apps/map/src/app/_components/modal/admin-delete-modal.tsx b/apps/map/src/app/_components/modal/admin-delete-modal.tsx index 1b8882d4..e116d0b1 100644 --- a/apps/map/src/app/_components/modal/admin-delete-modal.tsx +++ b/apps/map/src/app/_components/modal/admin-delete-modal.tsx @@ -32,6 +32,8 @@ export default function AdminDeleteModal({ eventId?: number; locationId?: number; userId?: number; + eventTagId?: number; + eventInstanceId?: number; } | void>; switch (data.type) { @@ -54,6 +56,12 @@ export default function AdminDeleteModal({ case DeleteType.LOCATION: mutation = orpc.location.delete.call; break; + case DeleteType.EVENT_TAG: + mutation = orpc.eventTag.delete.call; + break; + case DeleteType.EVENT_INSTANCE: + mutation = orpc.eventInstance.delete.call; + break; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid delete type: ${data.type}`); @@ -90,6 +98,14 @@ export default function AdminDeleteModal({ await invalidateQueries("location"); await invalidateQueries("map"); break; + case DeleteType.EVENT_TAG: + await invalidateQueries("eventTag"); + await invalidateQueries("map"); + break; + case DeleteType.EVENT_INSTANCE: + await invalidateQueries("eventInstance"); + await invalidateQueries("map"); + break; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid delete type: ${data.type}`); @@ -171,6 +187,10 @@ const dataTypeToName = ( return "User"; case DeleteType.LOCATION: return "Location"; + case DeleteType.EVENT_TAG: + return "Event Tag"; + case DeleteType.EVENT_INSTANCE: + return "Event Instance"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Invalid delete type: ${dataType}`); diff --git a/apps/map/src/app/_components/modal/admin-event-instances-modal.tsx b/apps/map/src/app/_components/modal/admin-event-instances-modal.tsx new file mode 100644 index 00000000..b6a38600 --- /dev/null +++ b/apps/map/src/app/_components/modal/admin-event-instances-modal.tsx @@ -0,0 +1,749 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { z } from "zod"; + +import { Z_INDEX } from "@acme/shared/app/constants"; +import { SeriesException } from "@acme/shared/app/enums"; +import { + convertHH_mmToHHmm, + convertHHmmToHH_mm, +} from "@acme/shared/app/functions"; +import { safeParseInt } from "@acme/shared/common/functions"; +import { cn } from "@acme/ui"; +import { Button } from "@acme/ui/button"; +import { Checkbox } from "@acme/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@acme/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@acme/ui/form"; +import { Input } from "@acme/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@acme/ui/select"; +import { Spinner } from "@acme/ui/spinner"; +import { Textarea } from "@acme/ui/textarea"; +import { toast } from "@acme/ui/toast"; + +import gte from "lodash/gte"; +import { + invalidateQueries, + orpc, + ORPCError, + useMutation, + useQuery, +} from "~/orpc/react"; +import type { DataType } from "~/utils/store/modal"; +import { + closeModal, + DeleteType, + ModalType, + openModal, +} from "~/utils/store/modal"; +import { ControlledTimeInput } from "../time-input"; +import { VirtualizedCombobox } from "../virtualized-combobox"; + +const NONE = "__none__"; + +function seriesExceptionLabel(value: string): string { + switch (value) { + case "closed": + return "Closed"; + case "different-time": + return "Different time"; + case "miscellaneous": + return "Miscellaneous"; + default: + return value; + } +} + +const EventInstanceFormSchema = z + .object({ + regionId: z.number().optional(), + orgId: z + .number() + .refine((n) => n > 0, { message: "Select an organization" }), + locationId: z.number().nullable().optional(), + startDate: z.string().min(1, { message: "Start date is required" }), + endDate: z.string().nullable().optional(), + name: z.string().optional(), + description: z.string().nullish(), + startTime: z.string().nullable().optional(), + endTime: z.string().nullable().optional(), + eventTypeId: z.number().optional(), + eventTagId: z.number().optional(), + seriesId: z.number().nullable().optional(), + seriesException: z + .enum(["closed", "different-time", "miscellaneous"]) + .nullable() + .optional(), + isPrivate: z.boolean(), + }) + .superRefine((data, ctx) => { + if ( + data.startTime && + data.endTime && + data.startTime.length === 5 && + data.endTime.length === 5 + ) { + if (data.startTime > data.endTime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End time must be after start time", + path: ["endTime"], + }); + } + } + if (data.startDate && data.endDate && data.endDate < data.startDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End date must be on or after start date", + path: ["endDate"], + }); + } + }); + +type EventInstanceFormValues = z.infer; + +export default function AdminEventInstancesModal({ + data, +}: { + data: DataType[ModalType.ADMIN_EVENT_INSTANCES]; +}) { + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: regions } = useQuery( + orpc.org.all.queryOptions({ input: { orgTypes: ["region"] } }), + ); + const { data: aos } = useQuery( + orpc.org.all.queryOptions({ input: { orgTypes: ["ao"] } }), + ); + const { data: locations } = useQuery( + orpc.location.all.queryOptions({ input: { statuses: ["active"] } }), + ); + + const { data: instanceResponse, isLoading: isLoadingInstance } = useQuery( + orpc.eventInstance.byId.queryOptions({ + input: { id: data.id ?? -1 }, + enabled: gte(data.id, 0), + }), + ); + + const instance = instanceResponse ?? undefined; + const isEditing = !!instance; + const isLoading = gte(data.id, 0) && isLoadingInstance; + + const form = useForm({ + schema: EventInstanceFormSchema, + }); + + const formRegionId = form.watch("regionId"); + + const { data: eventTypes } = useQuery( + orpc.eventType.all.queryOptions({ + input: { + pageSize: 200, + orgIds: formRegionId ? [formRegionId] : undefined, + sorting: [{ id: "name", desc: false }], + }, + }), + ); + + const { data: eventTagsResponse } = useQuery( + orpc.eventTag.all.queryOptions({ + input: { + pageSize: 200, + statuses: ["active"], + orgIds: formRegionId ? [formRegionId] : undefined, + sorting: [{ id: "name", desc: false }], + }, + }), + ); + + const { data: eventsResponse } = useQuery( + orpc.event.all.queryOptions({ + input: { + pageSize: 500, + regionIds: formRegionId ? [formRegionId] : undefined, + sorting: [{ id: "name", desc: false }], + }, + }), + ); + + const sortedEventTypes = eventTypes?.eventTypes ?? []; + const sortedEventTags = eventTagsResponse?.eventTags ?? []; + const events = eventsResponse?.events ?? []; + + useEffect(() => { + console.log("instance", instance); + + form.reset({ + regionId: instance?.org?.parentId ?? undefined, + orgId: instance?.orgId ?? 0, + locationId: instance?.locationId ?? null, + startDate: instance?.startDate ?? "", + endDate: instance?.endDate ?? null, + name: instance?.name ?? "", + description: instance?.description ?? "", + startTime: + instance?.startTime?.length === 4 + ? convertHHmmToHH_mm(instance.startTime) + : null, + endTime: + instance?.endTime?.length === 4 + ? convertHHmmToHH_mm(instance.endTime) + : null, + eventTypeId: instance?.eventTypes?.[0]?.eventTypeId, + eventTagId: instance?.eventTags?.[0]?.eventTagId, + seriesId: instance?.seriesId ?? null, + seriesException: instance?.seriesException ?? null, + isPrivate: instance?.isPrivate ?? false, + }); + }, [form, instance]); + + const crupdateEventInstance = useMutation( + orpc.eventInstance.crupdate.mutationOptions({ + onSuccess: async () => { + await invalidateQueries("eventInstance"); + closeModal(); + toast.success( + isEditing + ? "Successfully updated event instance" + : "Successfully created event instance", + ); + router.refresh(); + }, + onError: (err) => { + toast.error( + err instanceof ORPCError && err?.code === "UNAUTHORIZED" + ? err.message ?? + "You are not authorized to create or update event instances" + : "Failed to save event instance", + ); + }, + }), + ); + + const onSubmit = async (formData: EventInstanceFormValues) => { + setIsSubmitting(true); + try { + const startTime = + formData.startTime && formData.startTime.length === 5 + ? convertHH_mmToHHmm(formData.startTime) + : undefined; + const endTime = + formData.endTime && formData.endTime.length === 5 + ? convertHH_mmToHHmm(formData.endTime) + : undefined; + + const trimmedName = formData.name?.trim(); + const descTrim = formData.description?.trim(); + await crupdateEventInstance.mutateAsync({ + ...(isEditing && data.id != null ? { id: data.id } : {}), + orgId: formData.orgId, + startDate: formData.startDate, + endDate: formData.endDate, + locationId: formData.locationId ?? null, + name: trimmedName ? trimmedName : undefined, + description: descTrim == null || descTrim === "" ? null : descTrim, + startTime: startTime ? startTime : undefined, + endTime: endTime ? endTime : undefined, + ...(formData.eventTypeId ? { eventTypeId: formData.eventTypeId } : {}), + ...(formData.eventTagId ? { eventTagId: formData.eventTagId } : {}), + seriesId: formData.seriesId ?? null, + seriesException: formData.seriesException ?? null, + isPrivate: formData.isPrivate, + }); + } catch { + // handled in onError + } finally { + setIsSubmitting(false); + } + }; + + return ( + closeModal()}> + + + + {isEditing ? "Edit" : "Add"} Event Instance + + + + {isLoading ? ( +
+ +
+ ) : ( +
+ + ( + + Region + + ({ + value: region.id.toString(), + label: region.name, + })) ?? [] + } + searchPlaceholder="Select a region" + onSelect={(value) => { + const orgId = safeParseInt(value as string); + if (orgId == null) { + toast.error("Invalid region"); + return; + } + field.onChange(orgId); + form.setValue("orgId", 0); + form.setValue("locationId", null); + form.setValue("eventTypeId", undefined); + form.setValue("eventTagId", undefined); + form.setValue("seriesId", null); + }} + isMulti={false} + /> + + + + )} + /> + { + const filteredAOs = aos?.orgs.filter( + (ao) => ao.parentId === form.watch("regionId"), + ); + return ( + + AO + + + + ); + }} + /> + ( + + Start date + + + + + + )} + /> + ( + + End date (optional) + + + + + + )} + /> +
+ + +
+ { + const regionId = form.getValues("regionId"); + const filteredLocations = locations?.locations.filter( + (location) => location.regionId === regionId, + ); + return ( + + Location (optional) + + + + ); + }} + /> + ( + + Event type (optional) + + + + )} + /> + ( + + Event tag (optional) + + + + )} + /> + ( + + Series (optional) + + + + )} + /> + ( + + Series exception (optional) + + + + )} + /> + ( + + + field.onChange(v === true)} + /> + + Private + + )} + /> + ( + + Name (optional) + + + + + + )} + /> + ( + + Description (optional) + +