diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build-push.yml similarity index 54% rename from .github/workflows/docker-build.yml rename to .github/workflows/docker-build-push.yml index aa86536..6fbd4aa 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build-push.yml @@ -1,9 +1,10 @@ -name: Build and Push Docker Image to GitHub Packages +name: Build and Push Docker Image on: push: branches: - master + - master + - next jobs: build-and-push: @@ -13,18 +14,20 @@ jobs: packages: write steps: - # Step 1: Check out the repository - name: Checkout repository uses: actions/checkout@v3 - # Step 2: Extract Commit Hash - - name: Extract Commit Hash + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT + id: extract_branch + + - name: Extract commit hash id: commit_hash run: | COMMIT_HASH=${GITHUB_SHA::7} # Get the first 7 characters of the commit hash echo "COMMIT_HASH=${COMMIT_HASH,,}" >> $GITHUB_ENV # Convert to lowercase - # Step 3: Check for localhost in NEXT_PUBLIC_ env variables - name: Check for localhost in NEXT_PUBLIC_ variables run: | for var in $(printenv | grep '^NEXT_PUBLIC_' | cut -d= -f1); do @@ -35,15 +38,24 @@ jobs: fi done - # Step 4: Log in to GitHub Container Registry - - name: Log in to GitHub Container Registry + - name: Login to GCR uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Step 5: Build and Push Docker Image using commit hash as the tag + - name: Set version number + run: | + if [ -f "package.json" ]; then + VERSION=v$(node -p "require('./package.json').version")-${{ steps.extract_branch.outputs.branch }}.${{ env.COMMIT_HASH }} + echo "VERSION=${VERSION}" >> $GITHUB_ENV + npm version "${VERSION}" --no-git-tag-version --allow-same-version + else + echo "package.json not found!" + exit 1 + fi + - name: Build and push Docker image uses: docker/build-push-action@v4 with: @@ -51,5 +63,5 @@ jobs: file: ./Dockerfile push: true tags: | - ghcr.io/${{ github.repository }}:latest - ghcr.io/${{ github.repository }}:${{ env.COMMIT_HASH }} + ghcr.io/${{ github.repository }}:${{ steps.extract_branch.outputs.branch }}-latest + ghcr.io/${{ github.repository }}:${{ steps.extract_branch.outputs.branch }}-${{ env.COMMIT_HASH }} diff --git a/Dockerfile b/Dockerfile index f9857cf..aa0ca68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,8 @@ COPY --from=builder /app/public ./public # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma + USER nextjs diff --git a/actions/event.ts b/actions/event.ts index 13e30e8..4dcad33 100644 --- a/actions/event.ts +++ b/actions/event.ts @@ -1,270 +1,284 @@ 'use server'; -import {Event, EventType, Prisma} from "@prisma/client"; -import {revalidatePath} from "next/cache"; + import prisma from "@/lib/db"; -import {log} from "@/actions/log"; -import {z} from "zod"; -import {UTApi} from "uploadthing/server"; -import {GridFilterItem, GridPaginationModel, GridSortModel} from "@mui/x-data-grid"; +import { log } from "./log"; +import { after } from "next/server"; +import { GridFilterItem } from "@mui/x-data-grid"; +import { GridPaginationModel } from "@mui/x-data-grid"; +import { GridSortModel } from "@mui/x-data-grid"; +import { EventType, Prisma } from "@prisma/client"; +import { SafeParseReturnType, z } from "zod"; +import { UTApi } from "uploadthing/server"; +import { revalidatePath } from "next/cache"; +import { sendEventPositionRemovalEmail } from "./mail/event"; +import { User } from "next-auth"; +import { ZodErrorSlimResponse } from "@/types"; const MAX_FILE_SIZE = 1024 * 1024 * 4; const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif']; const ut = new UTApi(); -export const lockUpcomingEvents = async () => { - const in48Hours = new Date(); - in48Hours.setHours(in48Hours.getHours() + 48); +export const fetchEvents = async (pagination: GridPaginationModel, sort: GridSortModel, filter?: GridFilterItem, archived?: boolean) => { - await prisma.event.updateMany({ - where: { - start: { - lte: in48Hours, - }, - positionsLocked: false, - }, - data: { - positionsLocked: true, - }, - }); - -} - -export const deleteStaleEvents = async () => { - - const oneWeekAgo = new Date(); - oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); - - const eventsToDelete = await prisma.event.findMany({ - where: { - end: { - lte: oneWeekAgo, - }, - }, - }); + const orderBy: Prisma.EventOrderByWithRelationInput = {}; - for (const event of eventsToDelete) { - await deleteEvent(event.id); - await ut.deleteFiles(eventsToDelete.map((e) => e.bannerKey)); + if (sort.length > 0) { + orderBy[sort[0].field as keyof Prisma.EventOrderByWithRelationInput] = sort[0].sort === 'asc' ? 'asc' : 'desc'; } + return prisma.$transaction([ + prisma.event.count({ + where: getWhere(filter, archived), + }), + prisma.event.findMany({ + orderBy, + where: getWhere(filter, archived), + take: pagination.pageSize, + skip: pagination.page * pagination.pageSize, + }) + ]); } +const getWhere = (filter?: GridFilterItem, archived?: boolean): Prisma.EventWhereInput => { + const baseWhere: Prisma.EventWhereInput = { + archived: archived ? { not: null } : null + }; -export const deleteEvent = async (id: string) => { - revalidatePath('/admin/events'); - const data = await prisma.event.delete({ - where: { - id, - }, - }); - await log('DELETE', 'EVENT', `Deleted event ${data.name}`); - const res = await ut.deleteFiles(data.bannerKey); - if (!res.success) { - throw new Error("Failed to delete banner image"); + if (!filter) { + return baseWhere; + } + + switch (filter.field) { + case 'name': + return { + ...baseWhere, + name: { + [filter.operator]: filter.value as string, + mode: 'insensitive', + }, + }; + case 'type': + return { + ...baseWhere, + type: { + equals: filter.value as EventType, + }, + }; + case 'hidden': + return { + ...baseWhere, + hidden: { + equals: filter.value as boolean, + }, + }; + default: + return baseWhere; } - return data; } -export const createOrUpdateEvent = async (formData: FormData) => { +export const validateEvent = async (input: { [key: string]: any }, zodResponse?: boolean): Promise> => { + + const isAfterToday = (date: Date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + return date > today; + }; + + const isBeforeEndDate = (start: Date, end: Date) => { + return start <= end; + }; + + const bannerImageOrUrlExists = (data: any) => { + if (data.id) { + return true; // Skip validation if editing + } + + if (data.bannerImage && (data.bannerImage as File).size > 0 ) { + const file = data.bannerImage as File; + return ALLOWED_FILE_TYPES.includes(file?.type || '') && file.size <= MAX_FILE_SIZE; + } else if (data.bannerUrl) { + return z.string().url().safeParse(data.bannerUrl).success; + } + + return (data.bannerImage && (data.bannerImage as File).size > 0) || data.bannerUrl; + }; + + const isLongerThan30Minutes = (start: Date, end: Date) => { + const duration = (end.getTime() - start.getTime()) / (1000 * 60); // duration in minutes + return duration > 30; + }; const eventZ = z.object({ id: z.string().optional(), - name: z.string(), - host: z.string().optional(), - type: z.string().min(1, "Type is required"), - description: z.string(), - start: z.date(), - end: z.date(), + name: z.string().min(3, { message: "Name must be between 3 and 255 characters" }).max(255, { message: "Name must be between 3 and 255 characters" }), + start: z.date({ required_error: 'Start date is required' }).refine(isAfterToday, { message: "Start date must be after today" }), + end: z.date({ required_error: 'End date is required' }), + type: z.nativeEnum(EventType, { required_error: 'Type is required' }), + description: z.string().min(10, { message: "Description must be at least 10 characters." }), + bannerImage: z.any().optional(), + bannerUrl: z.string().optional(), featuredFields: z.array(z.string()), - bannerImage: z - .any() - .optional() - .or( - z.any().refine((file) => { - return formData.get('id') || !file || file.size <= MAX_FILE_SIZE; - }, 'File size must be less than 4MB') - .refine((file) => { - return formData.get('id') || ALLOWED_FILE_TYPES.includes(file?.type || ''); - }, 'File must be a PNG, JPEG, or GIF') - ), - bannerUrl: z.any().optional() - }); + }).refine(data => isLongerThan30Minutes(data.start, data.end), { + message: "Event duration must be longer than 30 minutes.", + path: ["end"], + }).refine(data => isBeforeEndDate(data.start, data.end), { + message: "Start date must be before the end date.", + path: ["start"], + }).refine(bannerImageOrUrlExists, { + message: "Either banner image or a VALID banner URL must exist.", + path: ["bannerImage", "bannerUrl"], + }) + + const data = eventZ.safeParse(input); + + if (zodResponse) { + return data; + } + + return { + success: data.success, + errors: data.error ? data.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })) : [], + } +} + +export const upsertEvent = async (formData: FormData) => { - const result = eventZ.safeParse({ + const result = await validateEvent({ id: formData.get('id'), name: formData.get('name'), - host: formData.get('host'), + start: new Date(formData.get('start') as string), + end: new Date(formData.get('end') as string), type: formData.get('type'), description: formData.get('description'), - featuredFields: formData.get('featuredFields')?.toString().split(',').map((f) => f.trim()) || [], - start: new Date(formData.get('start') as unknown as string), - end: new Date(formData.get('end') as unknown as string), bannerImage: formData.get('bannerImage') as File, - bannerUrl: formData.get('bannerUrl') as string - }); + bannerUrl: (formData.get('bannerUrl') as string) || undefined, + featuredFields: JSON.parse(formData.get('featuredFields') as string), + }, true) as SafeParseReturnType; if (!result.success) { - return {errors: result.error.errors}; - } - - if (result.data.start.getTime() >= result.data.end.getTime()) { - return {errors: [{message: "eventZ start time must be before the end time",}]}; + return { errors: result.error.errors }; } - const eventExists = await prisma.event.findUnique({ - where: { - id: result.data.id, - }, + const { data } = result; + const existingEvent = await prisma.event.findUnique({ + where: { + id: data.id, + }, }); - if (!eventExists && !result.data.bannerImage && !result.data.bannerUrl) { - return {errors: [{message: "Banner image is required",}]}; - } + let bannerKey = existingEvent?.bannerKey; - let bannerKey = eventExists?.bannerKey || ''; - if (!eventExists) { - if (result.data.bannerUrl) { - const res = await ut.uploadFilesFromUrl(result.data.bannerUrl) + if ((data.bannerImage as File)?.size > 0 || data.bannerUrl) { + if ((data.bannerImage as File)?.size > 0) { + const res = await ut.uploadFiles(data.bannerImage as File); if (!res.data) { - throw new Error("Failed to upload banner image"); + return { errors: [{message: 'Failed to upload banner image.'}] }; } + bannerKey = res.data?.key; - }else{ - const res = await ut.uploadFiles(result.data.bannerImage); + } else if (data.bannerUrl) { + const res = await ut.uploadFilesFromUrl(data.bannerUrl); if (!res.data) { - throw new Error("Failed to upload banner image"); + return { errors: [{message: 'Failed to upload banner image from URL.'}] }; } + bannerKey = res.data?.key; } - } else if ((result.data.bannerImage as File) !== null && (result.data.bannerImage as File).size > 0 || result.data.bannerUrl) { - const deletion = await ut.deleteFiles(eventExists.bannerKey); - if (!deletion.success) { - throw new Error("Failed to delete old banner image"); - } - if (result.data.bannerUrl) { - const res = await ut.uploadFilesFromUrl(result.data.bannerUrl) + if (existingEvent?.bannerKey) { + const res = await ut.deleteFiles(existingEvent.bannerKey); - if (!res.data) { - throw new Error("Failed to upload banner image"); + if (!res.success) { + return { errors: [{message: 'Failed to delete old banner image.'}] }; } - bannerKey = res.data?.key; - }else{ - const res = await ut.uploadFiles(result.data.bannerImage); - - if (!res.data) { - throw new Error("Failed to upload banner image"); - } - bannerKey = res.data?.key; } } const event = await prisma.event.upsert({ - create: { - name: result.data.name, - host: result.data.host, - type: result.data.type as EventType, - description: result.data.description, - start: result.data.start, - end: result.data.end, - external: !!result.data.host, - featuredFields: result.data.featuredFields, - positionsLocked: false, - bannerKey, + where: { + id: data.id, }, update: { - name: result.data.name, - host: result.data.host, - type: result.data.type as EventType, - description: result.data.description, - start: result.data.start, - end: result.data.end, - external: !!result.data.host, - featuredFields: result.data.featuredFields, + name: data.name, + start: data.start, + end: data.end, + type: data.type, + description: data.description, + featuredFields: data.featuredFields, + bannerKey, + }, + create: { + name: data.name, + start: data.start, + end: data.end, + type: data.type, + description: data.description, + featuredFields: data.featuredFields, bannerKey, }, - where: { - id: result.data.id, - } }); - if (result.data.id) { - await log('UPDATE', 'EVENT', `Updated event ${event.name}`); - } else { - await log('CREATE', 'EVENT', `Created event ${event.name}`); + if (data.id) { + revalidatePath(`/events/admin/events/${data.id}`); } - revalidatePath('/admin/events'); - revalidatePath(`/events`); - revalidatePath(`/events/${event.id}`); + after(async () => { + if (data.id) { + await log("UPDATE", "EVENT", `Updated event ${data.name}.`); + } else { + await log("CREATE", "EVENT", `Created event ${data.name}.`); + } + }); return {event}; } -export const setPositionsLock = async (event: Event, lock: boolean) => { - const data = await prisma.event.update({ +export const deleteEvent = async (id: string) => { + + const event = await prisma.event.delete({ where: { - id: event.id, + id, }, - data: { - positionsLocked: lock, + include: { + positions: { + include: { + user: true, + }, + }, }, }); - await log('UPDATE', 'EVENT', `Set positions lock for event ${data.name} to ${lock}`); + after(async () => { + await log("DELETE", "EVENT", `Deleted event ${event.name}.`); + + for (const position of event.positions) { + sendEventPositionRemovalEmail(position.user as User, position, event); + } + }); - revalidatePath(`/admin/events/${event.id}/positions`); - revalidatePath(`/admin/events/${event.id}`); - revalidatePath(`/admin/events`); - return data; + revalidatePath('/events/admin/events'); } -export const fetchEvents = async (pagination: GridPaginationModel, sort: GridSortModel, filter?: GridFilterItem) => { - const orderBy: Prisma.EventOrderByWithRelationInput = {}; - if (sort.length > 0) { - const sortField = sort[0].field as keyof Prisma.EventOrderByWithRelationInput; - orderBy[sortField] = sort[0].sort === 'asc' ? 'asc' : 'desc'; - } +export const updateEventPresetPositions = async (eventId: string, positions: string[]) => { + const event = await prisma.event.update({ + where: { + id: eventId, + }, + data: { + presetPositions: { + set: positions, + }, + }, + }); - return prisma.$transaction([ - prisma.event.count({ - where: getWhere(filter), - }), - prisma.event.findMany({ - orderBy, - where: getWhere(filter), - take: pagination.pageSize, - skip: pagination.page * pagination.pageSize, - }) - ]); -}; + revalidatePath(`/events/admin/events/${eventId}/manager`); -const getWhere = (filter?: GridFilterItem): Prisma.EventWhereInput => { - if (!filter) { - return {}; - } - switch (filter?.field) { - case 'name': - return { - name: { - [filter.operator]: filter.value as string, - mode: 'insensitive', - }, - }; - case 'type': - return { - type: filter.value as EventType, - }; - case 'host': - return { - host: { - [filter.operator]: filter.value as string, - mode: 'insensitive', - }, - }; - default: - return {}; - } -}; \ No newline at end of file + after(async () => { + await log("UPDATE", "EVENT", `Updated '${event.name}' preset positions.`); + }); +} \ No newline at end of file diff --git a/actions/eventManagement.ts b/actions/eventManagement.ts new file mode 100644 index 0000000..3f84c83 --- /dev/null +++ b/actions/eventManagement.ts @@ -0,0 +1,91 @@ +'use server'; + +import prisma from "@/lib/db"; +import { after } from "next/server"; +import { log } from "./log"; +import { User as NAUser } from "next-auth"; +import { Event } from "@prisma/client"; +import { revalidatePath } from "next/cache"; +import { sendEventPositionEmail, sendEventPositionRemovalEmail, sendEventPostedEmail } from "./mail/event"; +import { UTApi } from "uploadthing/server"; + +const ut = new UTApi(); + +export const toggleEventHidden = async (event: Event) => { + + await prisma.event.update({ + where: { + id: event.id, + }, + data: { + hidden: !event.hidden, + positionsLocked: true, + manualPositionsOpen: false, + } + }); + + revalidatePath(`/admin/events/${event.id}/manager`); + + after(async () => { + await log("UPDATE", "EVENT", `${event.hidden ? 'Showed' : 'Hidden'} event ${event.name}.`); + + if (event.hidden) { + const users = await prisma.user.findMany({ + where: { + newEventNotifications: true, + controllerStatus: { + not: 'NONE', + }, + }, + }); + + for (const user of users) { + sendEventPostedEmail(user as NAUser, event); + } + } + }); +} + +export const toggleEventArchived = async (event: Event) => { + + const updatedEvent = await prisma.event.update({ + where: { + id: event.id, + }, + data: { + archived: event.archived ? null : new Date(), + hidden: true, + positionsLocked: true, + manualPositionsOpen: false, + bannerKey: event.archived ? event.bannerKey : null, + }, + include: { + positions: { + include: { + user: true, + }, + }, + }, + }); + + revalidatePath(`/admin/events/${event.id}/manager`); + revalidatePath(`/admin/events/${event.id}`); + + after(async () => { + await log("UPDATE", "EVENT", `${event.archived ? 'Unarchived' : 'Archived'} event ${event.name}.`); + + if (updatedEvent.archived) { + await ut.deleteFiles(event.bannerKey || ''); + } + + if (updatedEvent.start.getTime() < new Date().getTime()) { + for (const position of updatedEvent.positions.filter(p => p.published)) { + if (updatedEvent.archived) { + sendEventPositionRemovalEmail(position.user as NAUser, position, updatedEvent); + } else { + sendEventPositionEmail(position.user as NAUser, position, updatedEvent); + } + } + } + }); + } \ No newline at end of file diff --git a/actions/eventPosition.ts b/actions/eventPosition.ts index dba08ca..ee11c04 100644 --- a/actions/eventPosition.ts +++ b/actions/eventPosition.ts @@ -1,222 +1,341 @@ 'use server'; +import { authOptions } from "@/auth/auth"; import prisma from "@/lib/db"; -import {log} from "@/actions/log"; -import {Event, EventPosition} from "@prisma/client"; -import {z} from "zod"; -import {revalidatePath} from "next/cache"; -import {User} from "next-auth"; -import {sendEventPositionEmail, sendEventPositionRemovalEmail} from "@/actions/mail/event"; - -export const deleteEventPosition = async (id: string) => { - const data = await prisma.eventPosition.delete({ - where: { - id, +import { Event, EventPosition } from "@prisma/client"; +import { getServerSession, User } from "next-auth"; +import { after } from "next/server"; +import { SafeParseReturnType, z } from "zod"; +import { log } from "./log"; +import { revalidatePath } from "next/cache"; +import { sendEventPositionEmail, sendEventPositionRemovalEmail, sendEventPositionRequestDeletedEmail } from "./mail/event"; +import { ZodErrorSlimResponse } from "@/types"; + +export const toggleManualPositionOpen = async (event: Event) => { + + await prisma.event.update({ + where: { id: event.id }, + data: { + manualPositionsOpen: !event.manualPositionsOpen, }, - include: { - event: true, - controllers: true, + }); + + after(async () => { + await log("UPDATE", "EVENT", `Toggled manual position open for event ${event.name}`); + }); + + revalidatePath(`/admin/events/${event.id}/manager`); +} + +export const togglePositionsLocked = async (event: Event) => { + + await prisma.event.update({ + where: { id: event.id }, + data: { + positionsLocked: !event.positionsLocked, }, }); - await log('DELETE', 'EVENT_POSITION', `Deleted event position ${data.position} for ${data.event.name}`); - for (const controller of data.controllers) { - await sendEventPositionRemovalEmail(controller as User, data, data.event); - } + after(async () => { + await log("UPDATE", "EVENT", `Toggled positions locked for event ${event.name}`); + }); - revalidatePath(`/admin/events/edit/${data.event.id}/positions`); - revalidatePath(`/admin/events`); - return data; + revalidatePath(`/admin/events/${event.id}/manager`); } -export const createOrUpdateEventPosition = async (formData: FormData) => { +export const saveEventPosition = async (event: Event, formData: FormData, admin?: boolean) => { + + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { errors: [{ message: 'You must be logged in to perform this action' }] }; + } + + if (!session.user.roles.includes('STAFF') && !session.user.roles.includes("EVENT_STAFF") && admin) { + return { errors: [{ message: 'You do not have permission to perform this action' }] }; + } + + if (!admin && (await prisma.event.findUnique({ where: { id: event.id } }))?.positionsLocked) { + return { errors: [{ message: 'Positions are locked for this event' }] }; + } + + if ((await prisma.eventPosition.count({ where: { eventId: event.id, userId: admin ? formData.get('userId') as string : session.user.id } })) > 0) { + return { errors: [{ message: admin ? 'This controller already has a position request' : 'You have already requested a position for this event' }] }; + } + const eventPositionZ = z.object({ - eventId: z.string(), - id: z.string().optional(), - position: z.string().min(1, "Position Name is required.").max(40, 'Position name must be less than 40 characters'), - signupCap: z.number().optional(), - minRating: z.number().min(-1, "Rating is invalid").max(10, "Rating is invalid"), + controllerId: z.string().min(1, { message: 'Controller is required' }), + requestedPosition: z.string().min(1, { message: 'Requested Position is required' }).max(50, { message: 'Requested Position must be less than 50 characters' }), + requestedStartTime: z.date().min(event.start, { message: 'Requested time must be within the event' }).max(event.end, { message: 'Requested time must be within the event' }), + requestedEndTime: z.date().min(event.start, { message: 'Requested time must be within the event' }).max(event.end, { message: 'Requested time must be within the event' }), + notes: z.string().optional(), }); const result = eventPositionZ.safeParse({ - eventId: formData.get('eventId') as string, - id: formData.get('id') as string, - position: formData.get('position'), - signupCap: Number(formData.get('signupCap') as string), - minRating: Number(formData.get('minRating') as string), + controllerId: admin ? formData.get('userId') : session.user.id, + requestedPosition: formData.get('requestedPosition'), + requestedStartTime: new Date(formData.get('requestedStartTime') as string), + requestedEndTime: new Date(formData.get('requestedEndTime') as string), + notes: formData.get('notes'), }); if (!result.success) { - return {errors: result.error.errors}; + return { errors: result.error.errors }; } - const eventPositionExists = await prisma.eventPosition.findUnique({ - where: { - id: result.data.id, - }, - }); - - const eventPosition = await prisma.eventPosition.upsert({ - where: { - id: result.data.id, - }, - update: { - position: result.data.position, - signupCap: result.data.signupCap, - minRating: result.data.minRating, - }, - create: { - position: result.data.position, - signupCap: result.data.signupCap, - minRating: result.data.minRating, - event: { - connect: { - id: result.data.eventId, - }, - }, + const eventPosition = await prisma.eventPosition.create({ + data: { + eventId: event.id, + userId: result.data.controllerId, + requestedPosition: result.data.requestedPosition, + requestedStartTime: result.data.requestedStartTime, + requestedEndTime: result.data.requestedEndTime, + notes: `${result.data.notes}${admin ? `\n(MAN ASSIGN)` : ''}`, }, include: { - event: true, + user: true, }, }); - if (eventPositionExists) { - await log('UPDATE', 'EVENT_POSITION', `Updated event position ${eventPosition.position} for ${eventPosition.event.name}`); - } else { - await log('CREATE', 'EVENT_POSITION', `Created event position ${eventPosition.position} for ${eventPosition.event.name}`); + if (admin) { + await prisma.eventPosition.update({ + where: { + id: eventPosition.id, + }, + data: { + finalPosition: result.data.requestedPosition, + finalStartTime: result.data.requestedStartTime, + finalEndTime: result.data.requestedEndTime, + finalNotes: result.data.notes, + }, + include: { + user: true, + }, + }); } + + after(async () => { + if (admin && eventPosition) { + await log("CREATE", "EVENT_POSITION", `Created event position for ${eventPosition.user?.firstName} ${eventPosition.user?.lastName} for ${eventPosition.requestedPosition} from ${eventPosition.requestedStartTime.toUTCString()} to ${eventPosition.requestedEndTime.toUTCString()}`); + } + }); - revalidatePath(`/admin/events/edit/${eventPosition.eventId}/positions`); - revalidatePath(`/admin/events`); - return {eventPosition}; + revalidatePath(`/events/${event.id}`); + + return { eventPosition }; } -export const assignEventPosition = async (event: Event, eventPosition: EventPosition, controllers: User[], user: User) => { - if (!isAbleToSignup(eventPosition, controllers, user)) { - throw new Error("User is not able to signup for this position"); - } - // const signedUpPositions = await prisma.eventPosition.findMany({ - // where: { - // controllers: { - // some: { - // id: user.id, - // }, - // }, - // id: { - // not: eventPosition.id, - // }, - // }, - // }); - // if (signedUpPositions.length > 0) { - // throw new Error("User is already signed up for another position"); - // } - if (event.positionsLocked) { - throw new Error("Event positions are locked"); - } - if (eventPosition.signupCap && controllers.length >= eventPosition.signupCap) { - throw new Error("Position is full"); - } - const data = await prisma.eventPosition.update({ - where: { - id: eventPosition.id, - }, - data: { - controllers: { - connect: { - id: user.id, - }, +export const deleteEventPosition = async (event: Event, eventPositionId: string, admin?: boolean) => { + + const session = await getServerSession(authOptions); + + if (!session?.user) { + return { errors: [{ message: 'You must be logged in to perform this action' }] }; + } + + const eventPosition = await prisma.eventPosition.findUnique({ + where: { + id: eventPositionId, }, - }, + include: { + user: true, + }, + }); + + if (!eventPosition) { + return { errors: [{ message: 'Event position not found' }] }; + } + + if (!session.user.roles.includes('STAFF') && eventPosition.userId !== session.user.id) { + return { errors: [{ message: 'You do not have permission to perform this action' }] }; + } + + const deletedPosition = await prisma.eventPosition.delete({ + where: { + id: eventPositionId, + }, + }); + + after(async () => { + if (deletedPosition && admin) { + await log("DELETE", "EVENT_POSITION", `Deleted event position for ${eventPosition.user?.firstName} ${eventPosition.user?.lastName}`); + } + + if (deletedPosition.published) { + sendEventPositionRemovalEmail(eventPosition.user as User, eventPosition, event); + } else if (admin) { + sendEventPositionRequestDeletedEmail(eventPosition.user as User, event); + } + }); + + revalidatePath(`/events/${event.id}`); + +} + +export const validateFinalEventPosition = async (event: Event, formData: FormData, zodResponse?: boolean): Promise> => { + const eventPositionZ = z.object({ + finalPosition: z.string().min(1, { message: 'Final Position is required and could not be autofilled.' }).max(50, { message: 'Final Position must be less than 50 characters' }), + finalStartTime: z.date().min(event.start, { message: 'Final time must be within the event' }).max(event.end, { message: 'Final time must be within the event' }), + finalEndTime: z.date().min(event.start, { message: 'Final time must be within the event' }).max(event.end, { message: 'Final time must be within the event' }), + finalNotes: z.string().optional(), }); - await sendEventPositionEmail(user, eventPosition, event); + const requestedPosition = formData.get('requestedPosition') as string; + let finalPosition = formData.get('finalPosition') as string; + if (!finalPosition && event.presetPositions.includes(requestedPosition)) { + finalPosition = requestedPosition; + } + + const data = eventPositionZ.safeParse({ + finalPosition, + finalStartTime: new Date(formData.get('finalStartTime') as string), + finalEndTime: new Date(formData.get('finalEndTime') as string), + finalNotes: formData.get('finalNotes'), + }); - revalidatePath(`/admin/events/edit/${eventPosition.eventId}/positions`); - revalidatePath(`/events/${eventPosition.eventId}`); - return data; + if (zodResponse) { + return data; + } + + return { + success: data.success, + errors: data.error ? data.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })) : [], + }; } -export const unassignEventPosition = async (event: Event, eventPosition: EventPosition, user: User, force?: boolean) => { +export const adminSaveEventPosition = async (event: Event, position: EventPosition, formData: FormData) => { + + const result = await validateFinalEventPosition(event, formData, true) as SafeParseReturnType; - if (event.positionsLocked && !force) { - throw new Error("Event positions are locked"); + if (!result.success) { + return { errors: result.error.errors }; } - const data = await prisma.eventPosition.update({ + const eventPosition = await prisma.eventPosition.update({ where: { - id: eventPosition.id, + id: position.id, }, data: { - controllers: { - disconnect: { - id: user.id, - }, - }, + finalPosition: result.data.finalPosition, + finalStartTime: result.data.finalStartTime, + finalEndTime: result.data.finalEndTime, + finalNotes: result.data.finalNotes, + }, + include: { + user: true, }, }); - await sendEventPositionRemovalEmail(user, eventPosition, event); + after(async () => { + if (eventPosition) { + await log("UPDATE", "EVENT_POSITION", `Updated event position for ${eventPosition.user?.firstName} ${eventPosition.user?.lastName} to ${eventPosition.finalPosition} from ${eventPosition.finalStartTime?.toUTCString()} to ${eventPosition.finalEndTime?.toUTCString()}`); + } - revalidatePath(`/admin/events/edit/${event.id}/positions`); - revalidatePath(`/events/${event.id}`); - return data; + if (eventPosition.published) { + sendEventPositionEmail(eventPosition.user as User, eventPosition, event); + } + }); + + revalidatePath(`/admin/events/${event.id}/manager`); + + return { eventPosition }; } -export const forceAssignPosition = async (formData: FormData) => { +export const publishEventPosition = async (event: Event, position: EventPosition) => { - const assignZ = z.object({ - position: z.string(), - controller: z.string(), - }); + const formData = new FormData(); + let finalPosition = position.finalPosition || ''; - const result = assignZ.safeParse({ - position: formData.get('position') as string, - controller: formData.get('controller') as string, - }); + if (!finalPosition && event.presetPositions.includes(position.requestedPosition)) { + finalPosition = position.requestedPosition; + } + + formData.set('finalPosition', finalPosition); + formData.set('finalStartTime', position.finalStartTime?.toISOString() || position.requestedStartTime?.toISOString() || ''); + formData.set('finalEndTime', position.finalEndTime?.toISOString() || position.requestedEndTime?.toISOString() || ''); + formData.set('finalNotes', position.finalNotes || ''); + + const result = await validateFinalEventPosition(event, formData, true) as SafeParseReturnType; if (!result.success) { - return {errors: [{message: 'Invalid form data'}]}; + return { error: { + success: false, + errors: result.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + } as ZodErrorSlimResponse }; } const eventPosition = await prisma.eventPosition.update({ where: { - id: result.data.position, + id: position.id, }, data: { - controllers: { - connect: { - id: result.data.controller, - }, - }, + finalPosition, + published: true, }, include: { - event: true, + user: true, + }, + }); + + after(async () => { + if (eventPosition && eventPosition.user) { + await log("UPDATE", "EVENT_POSITION", `Published event position for ${eventPosition.user?.firstName} ${eventPosition.user?.lastName} for ${eventPosition.finalPosition} from ${eventPosition.finalStartTime?.toUTCString()} to ${eventPosition.finalEndTime?.toUTCString()}`); } + + sendEventPositionEmail(eventPosition.user as User, eventPosition, event); }); - const controller = await prisma.user.findUniqueOrThrow({ + revalidatePath(`/admin/events/${event.id}/manager`); + + return { eventPosition }; +} + +export const unpublishEventPosition = async (event: Event, position: EventPosition) => { + const eventPosition = await prisma.eventPosition.update({ where: { - id: result.data.controller, + id: position.id, + }, + data: { + published: false, + }, + include: { + user: true, + }, + }); + + after(async () => { + if (eventPosition) { + await log("UPDATE", "EVENT_POSITION", `Unpublished event position for ${eventPosition.user?.firstName} ${eventPosition.user?.lastName} for ${eventPosition.finalPosition} from ${eventPosition.finalStartTime?.toUTCString()} to ${eventPosition.finalEndTime?.toUTCString()}`); + + sendEventPositionRemovalEmail(eventPosition.user as User, eventPosition, event); } }); - await sendEventPositionEmail(controller as User, eventPosition, eventPosition.event); - await log('UPDATE', 'EVENT_POSITION', `Forced assigned ${eventPosition.position} to ${controller.firstName} ${controller.lastName} (${controller.cid}) in ${eventPosition.event.name}`); - revalidatePath(`/admin/events/edit/${eventPosition.eventId}/positions`); - revalidatePath(`/events/${eventPosition.eventId}`); - return { eventPosition, controller }; + revalidatePath(`/admin/events/${event.id}/manager`); + + return { eventPosition }; } -const isAbleToSignup = (eventPosition: EventPosition, controllersSignedUp: User[], controller: User) => { - if (controller.noEventSignup) { - return false; - } - if (controller.controllerStatus === "NONE") { - return false; - } - if (eventPosition.signupCap && controllersSignedUp.length >= eventPosition.signupCap) { - return false; - } - return !(eventPosition.minRating && eventPosition.minRating > controller.rating); +export const fetchAllUsers = async () => { + return prisma.user.findMany({ + select: { + id: true, + cid: true, + firstName: true, + lastName: true, + rating: true, + }, + where: { + controllerStatus: { + not: 'NONE', + }, + }, + }); +} -} \ No newline at end of file diff --git a/actions/eventPreset.ts b/actions/eventPreset.ts new file mode 100644 index 0000000..0b44917 --- /dev/null +++ b/actions/eventPreset.ts @@ -0,0 +1,122 @@ +'use server'; + +import prisma from "@/lib/db"; +import { GridPaginationModel } from "@mui/x-data-grid"; + +import { GridSortModel } from "@mui/x-data-grid"; + +import { GridFilterItem } from "@mui/x-data-grid"; +import { Prisma } from "@prisma/client"; +import { after } from "next/server"; +import { log } from "./log"; +import { z } from "zod"; +import { revalidatePath } from "next/cache"; + +export const fetchEventPresets = async (pagination: GridPaginationModel, sort: GridSortModel, filter?: GridFilterItem) => { + + const orderBy: Prisma.EventPositionPresetOrderByWithRelationInput = {}; + + if (sort.length > 0) { + orderBy[sort[0].field as keyof Prisma.EventPositionPresetOrderByWithRelationInput] = sort[0].sort === 'asc' ? 'asc' : 'desc'; + } + + return prisma.$transaction([ + prisma.eventPositionPreset.count({ + where: getWhere(filter), + }), + prisma.eventPositionPreset.findMany({ + orderBy, + where: getWhere(filter), + take: pagination.pageSize, + skip: pagination.page * pagination.pageSize, + }) + ]); +} + +const getWhere = (filter?: GridFilterItem): Prisma.EventPositionPresetWhereInput => { + + if (!filter) { + return {}; + } + + switch (filter.field) { + case 'name': + return { + name: { + [filter.operator]: filter.value as string, + mode: 'insensitive', + }, + }; + case 'positions': + return { + positions: { + hasSome: filter.value.split(','), + }, + } + default: + return {}; + } +} + +export const deleteEventPreset = async (id: string) => { + const positionPreset = await prisma.eventPositionPreset.delete({ + where: { + id, + } + }); + + revalidatePath('/admin/event-presets'); + + after(async () => { + await log('DELETE', 'EVENT_POSITION_PRESET', `Deleted event position preset '${positionPreset.name}'`); + }); +} + +export const createOrUpdateEventPreset = async (formData: FormData) => { + + const presetZ = z.object({ + id: z.string().optional(), + name: z.string().min(1, 'Name must be at least 1 character long'), + positions: z.array(z.string()), + }); + + const result = presetZ.safeParse({ + id: formData.get('id') as string, + name: formData.get('name') as string, + positions: (formData.get('positions') as string).split(','), + }); + + if (!result.success) { + return {errors: result.error.errors}; + } + + const positionPreset = await prisma.eventPositionPreset.upsert({ + where: { + id: result.data.id, + }, + update: { + name: result.data.name, + positions: { + set: result.data.positions, + }, + }, + create: { + name: result.data.name, + positions: { + set: result.data.positions, + }, + }, + }); + + after(async () => { + if (result.data.id) { + await log('UPDATE', 'EVENT_POSITION_PRESET', `Updated event position preset '${positionPreset.name}'`); + } else { + await log('CREATE', 'EVENT_POSITION_PRESET', `Created event position preset '${positionPreset.name}'`); + } + }); + + revalidatePath('/admin/event-presets'); + + return {positionPreset}; +} \ No newline at end of file diff --git a/actions/mail/event.ts b/actions/mail/event.ts index b42d76b..e2d8b65 100644 --- a/actions/mail/event.ts +++ b/actions/mail/event.ts @@ -5,6 +5,20 @@ import {FROM_EMAIL, mailTransport} from "@/lib/email"; import {formatZuluDate} from "@/lib/date"; import {eventPositionAssigned} from "@/templates/EventPosition/EventPositionAssigned"; import {eventPositionRemoved} from "@/templates/EventPosition/EventPositionRemoved"; +import { positionRequestDeleted } from "@/templates/EventPosition/RequestDeleted"; +import { newEventPosted } from "@/templates/Event/NewEventPosted"; + +export const sendEventPostedEmail = async (controller: User, event: Event) => { + + const {html} = await newEventPosted(controller, event); + + await mailTransport.sendMail({ + from: FROM_EMAIL, + to: controller.email, + subject: `New Event Posted: ${event.name}`, + html, + }); +} export const sendEventPositionEmail = async (controller: User, eventPosition: EventPosition, event: Event) => { @@ -42,4 +56,17 @@ export const sendEventPositionRemovalEmail = async (controller: User, eventPosit subject: `Event Position Removal: ${event.name}`, html, }); +} + +export const sendEventPositionRequestDeletedEmail = async (controller: User, event: Event) => { + + const {html} = await positionRequestDeleted(controller, event); + + await mailTransport.sendMail({ + from: FROM_EMAIL, + to: controller.email, + subject: `Event Position Request Deleted: ${event.name}`, + html, + }); + } \ No newline at end of file diff --git a/actions/profile.ts b/actions/profile.ts index 3d09f20..af633c4 100644 --- a/actions/profile.ts +++ b/actions/profile.ts @@ -11,6 +11,7 @@ export const updateCurrentProfile = async (user: User) => { bio: z.string().max(400, "Bio must not be over 400 characters").optional(), operatingInitials: z.string().length(2, "Operating Initials must be 2 characters").toUpperCase(), receiveEmail: z.boolean(), + newEventNotifications: z.boolean(), }); const result = User.parse(user); diff --git a/app/admin/events/edit/[id]/page.tsx b/app/admin/events/edit/[id]/page.tsx deleted file mode 100644 index b768903..0000000 --- a/app/admin/events/edit/[id]/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import prisma from "@/lib/db"; -import {notFound} from "next/navigation"; -import {Box, Button, Card, CardContent, IconButton, Stack, Tooltip, Typography} from "@mui/material"; -import Link from "next/link"; -import {ArrowBack, Checklist} from "@mui/icons-material"; -import EventForm from "@/components/Events/EventForm"; -import {UTApi} from "uploadthing/server"; -import EventPositionsLockButton from "@/components/EventPosition/EventPositionsLockButton"; -import Placeholder from "../../../../../public/img/logo_large.png"; - -const ut = new UTApi(); - -export default async function Page(props: { params: Promise<{ id: string; }> }) { - const params = await props.params; - - const { id } = params; - - const event = await prisma.event.findUnique({ - where: { - id, - }, - }); - - if (!event) { - notFound(); - } - - const urls = await ut.getFileUrls([event.bannerKey]); - const imageUrl = ['png','jpeg','jpg','gif'].indexOf(urls.data[0]?.url.split('.').at(-1)!) > -1 ? urls.data[0]?.url : Placeholder; - - return ( - - - - - - - - - - - Edit Event - - - - - - - - - - - ); -} \ No newline at end of file diff --git a/app/admin/events/edit/[id]/positions/[positionId]/not-found.tsx b/app/admin/events/edit/[id]/positions/[positionId]/not-found.tsx deleted file mode 100644 index e265ba4..0000000 --- a/app/admin/events/edit/[id]/positions/[positionId]/not-found.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import {Card, CardContent, Stack, Typography} from "@mui/material"; -import {Info} from "@mui/icons-material"; - -function NotFound() { - return ( - - - - - Event position not found. - - - - ); -} - -export default NotFound; \ No newline at end of file diff --git a/app/admin/events/edit/[id]/positions/[positionId]/page.tsx b/app/admin/events/edit/[id]/positions/[positionId]/page.tsx deleted file mode 100644 index 91b2121..0000000 --- a/app/admin/events/edit/[id]/positions/[positionId]/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import prisma from "@/lib/db"; -import {notFound} from "next/navigation"; -import {Card, CardContent, IconButton, Stack, Tooltip, Typography} from "@mui/material"; -import Link from "next/link"; -import {ArrowBack} from "@mui/icons-material"; -import EventPositionForm from "@/components/EventPosition/EventPositionForm"; - -export default async function Page(props: { params: Promise<{ positionId: string, }> }) { - const params = await props.params; - - const {positionId} = params; - - const eventPosition = await prisma.eventPosition.findUnique({ - where: { - id: positionId, - }, - include: { - event: true, - }, - }); - - if (!eventPosition) { - notFound(); - } - - return ( - - - - - - - - - - - {eventPosition.position} - {eventPosition.event.name} - - - - - ); -} \ No newline at end of file diff --git a/app/admin/events/edit/[id]/positions/loading.tsx b/app/admin/events/edit/[id]/positions/loading.tsx deleted file mode 100644 index 3c1602d..0000000 --- a/app/admin/events/edit/[id]/positions/loading.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import {CircularProgress} from "@mui/material"; - -export default function Loading() { - return ( - - ); -} \ No newline at end of file diff --git a/app/admin/events/edit/[id]/positions/page.tsx b/app/admin/events/edit/[id]/positions/page.tsx deleted file mode 100644 index e69d794..0000000 --- a/app/admin/events/edit/[id]/positions/page.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import {notFound} from "next/navigation"; -import prisma from "@/lib/db"; -import { - Box, - Card, - CardContent, - IconButton, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Tooltip, - Typography -} from "@mui/material"; -import Link from "next/link"; -import {ArrowBack, Edit} from "@mui/icons-material"; -import EventPositionDeleteButton from "@/components/EventPosition/EventPositionDeleteButton"; -import EventPositionForm from "@/components/EventPosition/EventPositionForm"; -import {getRating} from "@/lib/vatsim"; -import EventPositionsLockButton from "@/components/EventPosition/EventPositionsLockButton"; -import EventControllerRemoveForm from "@/components/EventPosition/EventControllerRemoveForm"; -import {User} from "next-auth"; -import ControllerManualAddForm from "@/components/EventPosition/ControllerManualAddForm"; - -export default async function Page(props: { params: Promise<{ id: string, }> }) { - const params = await props.params; - - const {id} = params; - - const event = await prisma.event.findUnique({ - where: { - id, - }, - include: { - positions: { - include: { - controllers: true, - }, - orderBy: { - position: 'asc', - } - }, - }, - }); - - if (!event) { - notFound(); - } - - const users = await prisma.user.findMany({ - where: { - controllerStatus: { - not: "NONE", - }, - }, - orderBy: { - lastName: 'asc', - } - }); - - return ( - - - - - - - - - - - Positions - {event.name} - - - - Add Controller - - - - - - Position - Signup Cap - Minimum Rating - Controllers (click each to open profile) - Actions - - - - {event.positions.map((position) => ( - - {position.position} - {position.signupCap} - {getRating(position.minRating || -1) || 'N/A'} - - {position.controllers.map((controller) => ( - - - {controller.firstName} {controller.lastName} - {getRating(controller.rating)} - - - - - ))} - - - - - - - - - - - ))} - -
-
-
- New Event Position - -
-
- ); -} \ No newline at end of file diff --git a/app/admin/events/new/page.tsx b/app/admin/events/new/page.tsx deleted file mode 100644 index 583856c..0000000 --- a/app/admin/events/new/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import {Box, Card, CardContent, IconButton, Stack, Tooltip, Typography} from "@mui/material"; -import Link from "next/link"; -import {ArrowBack} from "@mui/icons-material"; -import EventForm from "@/components/Events/EventForm"; - -export default function Page() { - - return ( - - - - - - - - - - - New Event - - - - - - - ); -} diff --git a/app/admin/events/page.tsx b/app/admin/events/page.tsx deleted file mode 100644 index 240b03f..0000000 --- a/app/admin/events/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import {Button, Card, CardContent, Stack, Typography} from "@mui/material"; -import Link from "next/link"; -import {Add} from "@mui/icons-material"; -import {deleteStaleEvents} from "@/actions/event"; -import EventTable from "@/components/Events/EventTable"; - -export default async function Page() { - - await deleteStaleEvents(); - - return ( - - - - - Events - Events are automatically deleted one week from the end date - - - - - - - - - ); - -} \ No newline at end of file diff --git a/app/admin/oi-matrix/page.tsx b/app/admin/oi-matrix/page.tsx index 43a1373..ec5fee5 100644 --- a/app/admin/oi-matrix/page.tsx +++ b/app/admin/oi-matrix/page.tsx @@ -10,6 +10,9 @@ export default async function Page() { operatingInitials: { not: null, }, + controllerStatus: { + not: 'NONE', + }, }, select: { operatingInitials: true, @@ -31,11 +34,11 @@ export default async function Page() { Operating Initials Matrix - + In Use - HOME (hover/click to inspect controller) - + In Use - VISITOR (hover/click to inspect controller) @@ -64,7 +67,7 @@ export default async function Page() { {initials} diff --git a/app/admin/staffing-requests/[id]/loading.tsx b/app/admin/staffing-requests/[id]/loading.tsx deleted file mode 100644 index 3c1602d..0000000 --- a/app/admin/staffing-requests/[id]/loading.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import {CircularProgress} from "@mui/material"; - -export default function Loading() { - return ( - - ); -} \ No newline at end of file diff --git a/app/api/update/events/route.ts b/app/api/update/events/route.ts index 03aeaf3..f8607c8 100644 --- a/app/api/update/events/route.ts +++ b/app/api/update/events/route.ts @@ -1,12 +1,57 @@ -import {deleteStaleEvents, lockUpcomingEvents} from "@/actions/event"; import {revalidatePath} from "next/cache"; import {updateSyncTime} from "@/actions/lib/sync"; +import prisma from "@/lib/db"; +import { UTApi } from "uploadthing/server"; export const dynamic = 'force-dynamic'; +const ut = new UTApi(); + export async function GET() { - await lockUpcomingEvents(); - await deleteStaleEvents(); + + const in24Hours = new Date(); + in24Hours.setHours(in24Hours.getHours() + 24); + + const oneDayAgo = new Date(); + oneDayAgo.setHours(oneDayAgo.getHours() - 24); + + await prisma.event.updateMany({ + where: { + manualPositionsOpen: false, + start: { + lte: in24Hours, + }, + positionsLocked: false, + }, + data: { + positionsLocked: true, + }, + }); + + const archivedEvents = await prisma.event.findMany({ + where: { + end: { + lte: oneDayAgo, + }, + }, + }); + + for (const event of archivedEvents) { + await ut.deleteFiles(event.bannerKey || ''); + + await prisma.event.update({ + where: { + id: event.id, + }, + data: { + archived: new Date(), + hidden: true, + positionsLocked: true, + manualPositionsOpen: false, + bannerKey: null, + }, + }); + }; await updateSyncTime({events: new Date(),}); diff --git a/app/api/update/stats/route.ts b/app/api/update/stats/route.ts index fa76fd5..277d898 100644 --- a/app/api/update/stats/route.ts +++ b/app/api/update/stats/route.ts @@ -41,7 +41,7 @@ export async function GET() { }, }); - if (!vatsimUser || !prefixes?.prefixes.some((prefix) => vatsimUser.callsign.startsWith(prefix))) { + if (!vatsimUser || !prefixes?.prefixes.some((prefix) => vatsimUser.callsign.startsWith(prefix + "_"))) { // The controller is offline if (activePosition) { // The controller was active on a position, mark it as inactive diff --git a/app/events/[id]/page.tsx b/app/events/[id]/page.tsx index cb9fdad..80aae9f 100644 --- a/app/events/[id]/page.tsx +++ b/app/events/[id]/page.tsx @@ -8,26 +8,17 @@ import { Container, Grid2, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Tooltip, Typography } from "@mui/material"; import Image from "next/image"; import {UTApi} from "uploadthing/server"; -import {format} from "date-fns"; import Markdown from "react-markdown"; -import {getServerSession, User} from "next-auth"; -import {authOptions} from "@/auth/auth"; -import {getRating} from "@/lib/vatsim"; -import {EventPosition} from "@prisma/client"; -import {Lock} from "@mui/icons-material"; -import EventPositionSignupForm from "@/components/EventPosition/EventPositionSignupForm"; -import Placeholder from "../../../public/img/logo_large.png"; +import Placeholder from "@/public/img/logo_large.png"; +import { formatZuluDate } from '@/lib/date'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/auth/auth'; +import EventPositionRequestForm from '@/components/EventPosition/EventPositionRequestForm'; +import { User } from '@prisma/client'; const ut = new UTApi(); @@ -39,13 +30,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { const event = await prisma.event.findUnique({ where: { id, - }, - include: { - positions: { - include: { - controllers: true, - }, - }, + hidden: false, }, }); @@ -53,21 +38,21 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { notFound(); } - const urls = await ut.getFileUrls([event.bannerKey]); - const imageUrl = urls.data[0].url || ''; - - const isSignedUpForAnotherPosition = (currentPosition: EventPosition) => { - return event.positions - .filter((position) => position.id !== currentPosition.id) - .map((position) => position.controllers) - .flat() - .map((controller) => controller.id) - .includes(session?.user.id || ''); - } const session = await getServerSession(authOptions); + const imageUrl = event.bannerKey && `https://utfs.io/f/${event.bannerKey}`; + + const eventPosition = await prisma.eventPosition.findUnique({ + where: { + eventId_userId: { + eventId: event.id, + userId: session?.user.id || '', + }, + }, + }); + return ( - ( + @@ -75,7 +60,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { -1 ? imageUrl : Placeholder} + src={imageUrl || Placeholder} alt={event.name} priority fill style={{objectFit: 'contain'}}/> @@ -83,8 +68,8 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { {event.name} - {format(new Date(event.start), 'M/d/yy HHmm')}z - - {format(new Date(event.end), 'M/d/yy HHmm')}z + {formatZuluDate(event.start)} + - {formatZuluDate(event.end)} {event.featuredFields.join(" • ") || 'No fields'} @@ -94,54 +79,23 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { - {session && session.user && - - - Event Positions - {event.positions.length === 0 && No positions available} - {event.positions.length > 0 && - - - - Position - Controllers - Actions - - - - {event.positions.map((position) => ( - - {position.position} {position.minRating && `(${getRating(position.minRating) || getRating(2)}+)`} - - {position.controllers.length === 0 && "N/A"} - {position.controllers.map((controller) => ( - - {controller.firstName} {controller.lastName} - {getRating(controller.rating)} - - ))} - - - {event.positionsLocked && - - - - } - {!event.positionsLocked && !isSignedUpForAnotherPosition(position) && - } - - - ))} - -
-
} -
-
- } - + { session?.user && session.user.controllerStatus !== 'NONE' && !session.user.noEventSignup && !eventPosition?.published && + + Request Position + + + } + { session?.user && session.user.controllerStatus !== 'NONE' && !session.user.noEventSignup && eventPosition?.published && + + Your Position Assignment + {eventPosition.finalPosition} + {formatZuluDate(eventPosition.finalStartTime || event.start)} - {formatZuluDate(eventPosition.finalEndTime || event.end)} + {eventPosition.finalNotes} + Contact the events team if you have any questions. + + }
-
) +
); } diff --git a/app/events/admin/event-presets/[id]/page.tsx b/app/events/admin/event-presets/[id]/page.tsx new file mode 100644 index 0000000..32f89ca --- /dev/null +++ b/app/events/admin/event-presets/[id]/page.tsx @@ -0,0 +1,28 @@ +import EventPositionPresetForm from "@/components/EventPositionPreset/EventPositionPresetForm"; +import prisma from "@/lib/db"; +import { Card, CardContent, Typography } from "@mui/material"; +import { notFound } from "next/navigation"; + +export default async function Page({ params }: { params: Promise<{ id: string, }>}) { + + const { id } = await params; + + const positionPreset = await prisma.eventPositionPreset.findUnique({ + where: { + id, + }, + }); + + if (!positionPreset) { + notFound(); + } + + return ( + + + Edit - {positionPreset.name} + + + + ); +} \ No newline at end of file diff --git a/app/events/admin/event-presets/new/page.tsx b/app/events/admin/event-presets/new/page.tsx new file mode 100644 index 0000000..da917b3 --- /dev/null +++ b/app/events/admin/event-presets/new/page.tsx @@ -0,0 +1,13 @@ +import EventPositionPresetForm from "@/components/EventPositionPreset/EventPositionPresetForm"; +import { Card, CardContent, Typography } from "@mui/material"; + +export default async function Page() { + return ( + + + New Event Position Preset + + + + ); +} \ No newline at end of file diff --git a/app/events/admin/event-presets/page.tsx b/app/events/admin/event-presets/page.tsx new file mode 100644 index 0000000..855e6ca --- /dev/null +++ b/app/events/admin/event-presets/page.tsx @@ -0,0 +1,19 @@ +import { EventPositionPresetTable } from "@/components/EventPositionPreset/EventPositionPresetTable"; +import { Add } from "@mui/icons-material"; +import { Button, Card, CardContent, Link, Stack, Typography } from "@mui/material"; + +export default function Page() { + return ( + + + + Event Position Presets + + + + + + + + ); +} \ No newline at end of file diff --git a/app/events/admin/events/[id]/manager/page.tsx b/app/events/admin/events/[id]/manager/page.tsx new file mode 100644 index 0000000..6e049c8 --- /dev/null +++ b/app/events/admin/events/[id]/manager/page.tsx @@ -0,0 +1,90 @@ +import { authOptions } from "@/auth/auth"; +import ArchivedAlert from "@/components/EventManager/ArchivedAlert"; +import EventControls from "@/components/EventManager/EventControls"; +import EventPositionsTable from "@/components/EventManager/EventPositionsTable"; +import EventPresetSelector from "@/components/EventManager/EventPresetSelector"; +import HiddenAlert from "@/components/EventManager/HiddenAlert"; +import ManualControllerAddForm from "@/components/EventManager/ManualControllerAddForm"; +import EventPositionRequestForm from "@/components/EventPosition/EventPositionRequestForm"; +import prisma from "@/lib/db"; +import { ExpandMore } from "@mui/icons-material"; +import { Accordion, AccordionDetails, AccordionSummary, Alert, Card, CardContent, Stack, Typography } from "@mui/material"; +import { EventPosition, SoloCertification, User } from "@prisma/client"; +import { getServerSession } from "next-auth"; +import { notFound } from "next/navigation"; + +export type EventPositionWithSolo = EventPosition & { + soloCert: SoloCertification | null | undefined, + user: User | null | undefined, +}; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + + const { id } = await params; + + const event = await prisma.event.findUnique({ + where: { + id, + }, + include: { + positions: { + include: { + user: true, + }, + orderBy: { + published: 'asc', + } + }, + }, + }); + + if (!event) { + notFound(); + } + + const session = await getServerSession(authOptions); + + const positions: EventPositionWithSolo[] = await Promise.all(event.positions.map(async (position) => { + if (!position.user) { + return { ...position, soloCert: undefined, user: undefined }; + } + const soloCert = await getSoloCertification(position.user); + return { ...position, soloCert }; + })); + + return session?.user && ( + + { event.archived && } + { event.hidden && !event.archived && } + + + + }> + Preset Positions + + + + + + + + }> + Manually Assign Controller + + + + + + + + + ); +} + +const getSoloCertification = async (user: User) => { + return prisma.soloCertification.findFirst({ + where: { + userId: user.id, + }, + }); +} \ No newline at end of file diff --git a/app/admin/events/edit/[id]/not-found.tsx b/app/events/admin/events/[id]/not-found.tsx similarity index 90% rename from app/admin/events/edit/[id]/not-found.tsx rename to app/events/admin/events/[id]/not-found.tsx index 3d554ad..67ef82b 100644 --- a/app/admin/events/edit/[id]/not-found.tsx +++ b/app/events/admin/events/[id]/not-found.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Card, CardContent, Stack, Typography} from "@mui/material"; import {Info} from "@mui/icons-material"; -function NotFound() { +export default function NotFound() { return ( @@ -13,6 +13,4 @@ function NotFound() { ); -} - -export default NotFound; \ No newline at end of file +} \ No newline at end of file diff --git a/app/events/admin/events/[id]/page.tsx b/app/events/admin/events/[id]/page.tsx new file mode 100644 index 0000000..a1b1ece --- /dev/null +++ b/app/events/admin/events/[id]/page.tsx @@ -0,0 +1,46 @@ +import EventForm from "@/components/Event/EventForm"; +import ArchivedAlert from "@/components/EventManager/ArchivedAlert"; +import ArchiveToggleButton from "@/components/EventManager/ArchiveToggleButton"; +import HiddenAlert from "@/components/EventManager/HiddenAlert"; +import ToggleVisibilityButton from "@/components/EventManager/ToggleVisibilityButton"; +import prisma from "@/lib/db"; +import { OpenInNew } from "@mui/icons-material"; +import { Button, Card, CardContent, Stack, Typography } from "@mui/material"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + + const { id } = await params; + + const event = await prisma.event.findUnique({ + where: { + id, + }, + }); + + if (!event) { + notFound(); + } + + return ( + <> + { event.archived && } + { event.hidden && !event.archived && } + + + Edit - {event.name} + + + + + + + + + + + + ); + +} \ No newline at end of file diff --git a/app/events/admin/events/new/page.tsx b/app/events/admin/events/new/page.tsx new file mode 100644 index 0000000..31cd132 --- /dev/null +++ b/app/events/admin/events/new/page.tsx @@ -0,0 +1,13 @@ +import EventForm from "@/components/Event/EventForm"; +import { Card, CardContent, Typography } from "@mui/material"; + +export default async function Page() { + return ( + + + New Event + + + + ); +} \ No newline at end of file diff --git a/app/events/admin/events/page.tsx b/app/events/admin/events/page.tsx new file mode 100644 index 0000000..f3e75af --- /dev/null +++ b/app/events/admin/events/page.tsx @@ -0,0 +1,28 @@ +import EventTable from "@/components/Event/EventTable"; +import EventTableToggleSwitch from "@/components/Event/EventTableToggleSwitch"; +import { Add } from "@mui/icons-material"; +import { Box, Button, Card, CardContent, Stack, Typography } from "@mui/material"; +import Link from "next/link"; + +export default async function EventsPage({ searchParams }: { searchParams: Promise<{ archived?: string, }> }) { + + const { archived } = await searchParams; + + return ( + + + + + Events + Events are archived 24 hours after the published end time. + + + + + + + + + + ); +} \ No newline at end of file diff --git a/app/events/admin/layout.tsx b/app/events/admin/layout.tsx new file mode 100644 index 0000000..580a4c1 --- /dev/null +++ b/app/events/admin/layout.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {Grid2, Typography} from "@mui/material"; +import {getServerSession} from "next-auth"; +import {authOptions} from "@/auth/auth"; +import {Metadata} from "next"; +import EventsMenu from '@/components/Admin/EventsMenu'; + +export const metadata: Metadata = { + title: 'Events | vZDC', + description: 'vZDC events admin page', +}; + +export default async function Layout({children}: { children: React.ReactNode }) { + + const session = await getServerSession(authOptions); + + if (!session || !session.user.roles.some(r => ["EVENT_STAFF", "STAFF"].includes(r))) { + return ( + You do not have access to this page. + ); + } + + return ( + ( + + + + + {children} + + ) + ); +} \ No newline at end of file diff --git a/app/events/admin/loading.tsx b/app/events/admin/loading.tsx new file mode 100644 index 0000000..252715a --- /dev/null +++ b/app/events/admin/loading.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import {Skeleton} from "@mui/material"; + +export default function Loading() { + + return ( + + ); + +} \ No newline at end of file diff --git a/app/events/admin/logs/page.tsx b/app/events/admin/logs/page.tsx new file mode 100644 index 0000000..c4b3e49 --- /dev/null +++ b/app/events/admin/logs/page.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import {Card, CardContent, Typography} from "@mui/material"; +import LogTable from "@/components/Logs/LogTable"; +import {EVENT_ONLY_LOG_MODELS} from "@/lib/log"; + +export default async function Page() { + + return ( + + + Logs + + + + ); +} \ No newline at end of file diff --git a/app/events/admin/overview/page.tsx b/app/events/admin/overview/page.tsx new file mode 100644 index 0000000..d549943 --- /dev/null +++ b/app/events/admin/overview/page.tsx @@ -0,0 +1,112 @@ +import prisma from "@/lib/db"; +import Link from "next/link"; +import { Card, CardContent, Grid2, IconButton, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material"; +import { Checklist, Edit, Info } from "@mui/icons-material"; +import { eventGetDuration, getDuration, getTimeAgo } from "@/lib/date"; +import { EVENT_ONLY_LOG_MODELS } from "@/lib/log"; + +export default async function Page() { + + const upcomingEvents = await prisma.event.findMany({ + where: { + start: { + gte: new Date(), + lte: new Date(new Date().getTime() + 30 * 24 * 60 * 60 * 1000), // 30 days + }, + archived: null, + }, + orderBy: { + start: 'asc', + }, + }); + + const recentLogs = await prisma.log.findMany({ + take: 10, + where: { + model: { + in: EVENT_ONLY_LOG_MODELS, + } + }, + orderBy: { + timestamp: 'desc' + }, + include: { + user: true + }, + }); + + return ( + + + + + Upcoming Unarchived Events (30 days) + {upcomingEvents.length} + + + + + + + Next Event + { upcomingEvents[0] && + <> + + {upcomingEvents[0].name || 'N/A'} + + + + + + + + + + + + + + + + + In {eventGetDuration(new Date(), upcomingEvents[0].start, true).toFixed(0)} days + } + { !upcomingEvents[0] && N/A } + + + + + + + Recent Events Activity + {recentLogs.length === 0 && No recent events activity} + {recentLogs.length > 0 && + + + + Time + User + Type + Model + Message + + + + {recentLogs.map((log) => ( + + {getTimeAgo(log.timestamp)} + {log.user.cid} ({log.user.fullName}) + {log.type} + {log.model} + {log.message} + + ))} + +
+
} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/admin/events/edit/[id]/loading.tsx b/app/events/admin/staffing-requests/[id]/loading.tsx similarity index 100% rename from app/admin/events/edit/[id]/loading.tsx rename to app/events/admin/staffing-requests/[id]/loading.tsx diff --git a/app/admin/staffing-requests/[id]/not-found.tsx b/app/events/admin/staffing-requests/[id]/not-found.tsx similarity index 100% rename from app/admin/staffing-requests/[id]/not-found.tsx rename to app/events/admin/staffing-requests/[id]/not-found.tsx diff --git a/app/admin/staffing-requests/[id]/page.tsx b/app/events/admin/staffing-requests/[id]/page.tsx similarity index 100% rename from app/admin/staffing-requests/[id]/page.tsx rename to app/events/admin/staffing-requests/[id]/page.tsx diff --git a/app/admin/staffing-requests/page.tsx b/app/events/admin/staffing-requests/page.tsx similarity index 100% rename from app/admin/staffing-requests/page.tsx rename to app/events/admin/staffing-requests/page.tsx diff --git a/app/events/page.tsx b/app/events/page.tsx index f0178f4..68bdb6f 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -1,16 +1,13 @@ import React from 'react'; -import {Box, Card, CardContent, Container, Stack, Typography} from "@mui/material"; -import prisma from "@/lib/db"; -import EventCalendar from "@/components/Events/EventCalendar"; -import {formatZuluDate} from "@/lib/date"; -import Image from "next/image"; -import {UTApi} from "uploadthing/server"; -import Link from "next/link"; +import Image from 'next/image'; +import {Accordion, AccordionDetails, AccordionSummary, Box, Card, CardContent, Container, Link, Stack, Typography} from "@mui/material"; import {Metadata} from "next"; -import Placeholder from "../../public/img/logo_large.png"; +import EventCalendar from '@/components/Events/EventCalendar'; +import prisma from '@/lib/db'; +import { formatZuluDate } from '@/lib/date'; +import Placeholder from '@/public/img/logo_large.png'; +import { ExpandMore } from '@mui/icons-material'; -const ut = new UTApi(); -const VATUSA_FACILITY = process.env.VATUSA_FACILITY; export const metadata: Metadata = { title: 'Events | vZDC', description: 'vZDC charts page', @@ -19,27 +16,36 @@ export const metadata: Metadata = { export default async function Page() { const events = await prisma.event.findMany({ + where: { + hidden: false, + }, orderBy: { start: 'asc', }, }); - + return ( - - + + }> Legend + + Home Support/Optional Support/Required + Friday Night Operations + Saturday Night Operations Group Flight - Training + Training - - + + @@ -52,14 +58,13 @@ export default async function Page() { - -1?(await ut.getFileUrls([event.bannerKey])).data[0].url:Placeholder} + {event.name} {event.name} {formatZuluDate(event.start)} - {formatZuluDate(event.end).substring(9)} - Hosted by {event.host || VATUSA_FACILITY} {event.featuredFields.join(', ')} diff --git a/app/page.tsx b/app/page.tsx index 20c3963..892271a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,6 +20,7 @@ export default async function Home() { start: { gt: new Date(), }, + hidden: false, }, orderBy: { start: 'asc', @@ -31,6 +32,7 @@ export default async function Home() { return [event.id, event.bannerKey ? `https://utfs.io/f/${event.bannerKey}` : '/img/logo_large.png']; })); + const onlineAtc = await prisma.controllerPosition.findMany({ where: { active: true, @@ -71,8 +73,6 @@ export default async function Home() { const top3Controllers = getTop3Controllers(top3Logs); - // return - return ( ( diff --git a/app/training/layout.tsx b/app/training/layout.tsx index f0700ba..c951dbd 100644 --- a/app/training/layout.tsx +++ b/app/training/layout.tsx @@ -7,7 +7,7 @@ import {Metadata} from "next"; export const metadata: Metadata = { title: 'Training | vZDC', - description: 'vZDC training page', + description: 'vZDC training admin page', }; export default async function Layout({children}: { children: React.ReactNode }) { diff --git a/auth/vatsimProvider.ts b/auth/vatsimProvider.ts index 53b29a2..eb9e287 100644 --- a/auth/vatsimProvider.ts +++ b/auth/vatsimProvider.ts @@ -75,7 +75,7 @@ export const getVatusaData = async (data: Profile | User, allUsers?: User[]): Pr return { controllerStatus: "HOME", roles: [ - "CONTROLLER", "MENTOR", "INSTRUCTOR", "STAFF" + "CONTROLLER", "EVENT_STAFF", "MENTOR", "INSTRUCTOR", "STAFF", ], staffPositions: [ "ATM" diff --git a/components/Admin/AdminMenu.tsx b/components/Admin/AdminMenu.tsx index a57820d..8941837 100644 --- a/components/Admin/AdminMenu.tsx +++ b/components/Admin/AdminMenu.tsx @@ -15,12 +15,14 @@ import { ListAlt, MilitaryTech, QuestionAnswer, + RecentActors, Report, Send, Task, ViewCompact } from "@mui/icons-material"; import prisma from "@/lib/db"; +import MenuWrapper from './MenuWrapper'; export default async function AdminMenu() { @@ -66,165 +68,147 @@ export default async function AdminMenu() { }, }); + const atmName = atm ? `${atm.firstName} ${atm.lastName || 'N/A'}` : 'N/A'; + const datmName = datm ? `${datm.firstName} ${datm.lastName || 'N/A'}` : 'N/A'; + return ( - - - Facility Administration - ATM: {atm?.firstName} {atm?.lastName || 'N/A'} - DATM: {datm?.firstName} {datm?.lastName || 'N/A'} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } \ No newline at end of file diff --git a/components/Admin/EventsMenu.tsx b/components/Admin/EventsMenu.tsx new file mode 100644 index 0000000..10ed59c --- /dev/null +++ b/components/Admin/EventsMenu.tsx @@ -0,0 +1,73 @@ +import { Home, ListAlt, QuestionAnswer, RecentActors } from "@mui/icons-material"; +import { Badge, Card } from "@mui/material"; + +import { CalendarMonth } from "@mui/icons-material"; +import { CardContent, Link, List, ListItemButton, ListItemIcon, ListItemText, Typography } from "@mui/material"; +import prisma from "@/lib/db"; +import MenuWrapper from "./MenuWrapper"; + +export default async function EventMenu() { + + const staffingRequests = await prisma.staffingRequest.count(); + + const ec = await prisma.user.findFirst({ + where: { + staffPositions: { + has: "EC" + }, + }, + select: { + firstName: true, + lastName: true + } + }); + + const ecName = ec ? `${ec.firstName} ${ec.lastName || 'N/A'}` : 'N/A'; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/components/Admin/MenuWrapper.tsx b/components/Admin/MenuWrapper.tsx new file mode 100644 index 0000000..7d323bb --- /dev/null +++ b/components/Admin/MenuWrapper.tsx @@ -0,0 +1,40 @@ +import { ExpandMore } from "@mui/icons-material"; +import { Accordion, AccordionDetails, AccordionSummary, Box, Card, CardContent, List, Typography } from "@mui/material"; + +export default function MenuWrapper({ title, subheadings, children }: { title: string, subheadings: string[], children: React.ReactNode }) { + return ( + <> + + + + {title} + { subheadings.map((subheading, idx) => ( + {subheading} + ))} + + {children} + + + + + + + }> + + {title} + { subheadings.map((subheading, idx) => ( + {subheading} + ))} + + + + + + {children} + + + + + + ); +} \ No newline at end of file diff --git a/components/Admin/TrainingMenu.tsx b/components/Admin/TrainingMenu.tsx index 44c9112..1f4356c 100644 --- a/components/Admin/TrainingMenu.tsx +++ b/components/Admin/TrainingMenu.tsx @@ -18,6 +18,7 @@ import { WorkspacePremium, } from "@mui/icons-material"; import prisma from "@/lib/db"; +import MenuWrapper from './MenuWrapper'; export default async function TrainingMenu() { @@ -35,134 +36,129 @@ export default async function TrainingMenu() { }, }); + const taName = ta ? `${ta.firstName} ${ta.lastName || 'N/A'}` : 'N/A'; + return ( - - - Training Administration - TA: {ta?.firstName} {ta?.lastName || 'N/A'} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/components/Event/EventDeleteButton.tsx b/components/Event/EventDeleteButton.tsx new file mode 100644 index 0000000..a4c46f8 --- /dev/null +++ b/components/Event/EventDeleteButton.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { deleteEvent } from "@/actions/event"; +import { Delete } from "@mui/icons-material"; +import { Tooltip } from "@mui/material"; +import { GridActionsCellItem } from "@mui/x-data-grid"; +import { Event } from "@prisma/client"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +export default function EventDeleteButton({ event }: { event: Event }) { + const [clicked, setClicked] = useState(false); + + const handleClick = async () => { + if (clicked) { + await deleteEvent(event.id); + toast(`Event '${event.name}' deleted successfully!`, {type: 'success'}); + } else { + toast.warn(`Deleting this event will remove all positions and signups associated with it. Click again to confirm.`); + setClicked(true); + } + + } + + return ( + + } + label="Delete Event" + onClick={handleClick} + /> + + ); +} \ No newline at end of file diff --git a/components/Event/EventForm.tsx b/components/Event/EventForm.tsx new file mode 100644 index 0000000..5431c13 --- /dev/null +++ b/components/Event/EventForm.tsx @@ -0,0 +1,359 @@ +'use client'; + +import { useTheme } from "@emotion/react"; +import { CheckCircle, ExpandMore, Info, Pending, Visibility } from "@mui/icons-material"; +import { Accordion, AccordionDetails, AccordionSummary, Autocomplete, Box, Button, Chip, CircularProgress, FormControl, FormControlLabel, FormLabel, Grid2, Radio, RadioGroup, Stack, TextField, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; +import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { Event, EventType } from "@prisma/client"; +import MarkdownEditor from "@uiw/react-markdown-editor"; +import dayjs, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import Form from "next/form"; +import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import FormSaveButton from "../Form/FormSaveButton"; +import { upsertEvent, validateEvent } from "@/actions/event"; +import { toast } from "react-toastify"; +import { useRouter } from "next/navigation"; +import { SafeParseReturnType, ZodIssue } from "zod"; +import Markdown from "react-markdown"; +import { ZodErrorSlimResponse } from "@/types"; + +export default function EventForm({ event }: { event?: Event, }) { + + dayjs.extend(utc); + + const theme = useTheme(); + const router = useRouter(); + + const [open, setOpen] = useState(0); + const [bannerUploadType, setBannerUploadType] = useState<'file' | 'url'>('file'); + + const [name, setName] = useState(event?.name || ''); + const [start, setStart] = useState(dayjs.utc(event?.start || new Date())); + const [end, setEnd] = useState(dayjs.utc(event?.end || new Date())); + const [type, setType] = useState(event?.type); + const [bannerUrl, setBannerUrl] = useState(''); + const [description, setDescription] = useState(event?.description || ''); + const [featuredFields, setFeaturedFields] = useState(event?.featuredFields || []); + + const [status, setStatus] = useState([ + , + , + , + , + , + ]); + + const debounce = (func: Function, wait: number) => { + let timeout: NodeJS.Timeout; + return (...args: any[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + }; + + const debouncedUpdateStatus = useMemo( + () => debounce(async () => { + const res = await validateEvent({ + id: event?.id, + name, + start: start?.toDate(), + end: end?.toDate(), + type: type?.toString() || '', + description, + bannerUrl, + featuredFields, + }) as ZodErrorSlimResponse; + + const firstStep = await getStepStatus(res, { name, start: start?.toDate(), end: end?.toDate(), }); + + if (event?.archived) { + setStatus([, , , , ]); + return; + } + + const secondStep = await getStepStatus(res, { type: type?.toString() || '' }); + const thirdStep = await getStepStatus(res, { description }); + const fourthStep = await getStepStatus(res, { bannerUrl }); + const fifthStep = await getStepStatus(res, { featuredFields }); + setStatus([firstStep, secondStep, thirdStep, fourthStep, fifthStep]); + }, 500), + [event?.archived, event?.id, name, start, end, type, description, bannerUrl, featuredFields] + ); + + const handleSubmit = async (formData: FormData) => { + + formData.set('name', name); + formData.set('start', start?.toISOString() || ''); + formData.set('end', end?.toISOString() || ''); + formData.set('type', type || EventType.HOME); + formData.set('bannerUrl', bannerUrl); + formData.set('description', description); + formData.set('featuredFields', JSON.stringify(featuredFields)); + + const {event: newEvent, errors} = await upsertEvent(formData); + + if (errors) { + toast.error(errors.map((error) => error.message).join('. ')); + return false; + } + + if (event) { + toast.success('Event updated successfully.'); + } else { + toast.success('Event created successfully.'); + router.push(`/events/admin/events/${newEvent.id}/manager`); + + setDescription(''); + setFeaturedFields([]); + setBannerUploadType('file'); + } + } + + useEffect(() => { + debouncedUpdateStatus(); + }, [debouncedUpdateStatus]); + + const handleOpen = (panel: number) => (event: React.SyntheticEvent, isExpanded: boolean) => { + setOpen(isExpanded ? panel : -1); + } + + const back = () => { + setOpen((prev) => prev - 1); + debouncedUpdateStatus(); + } + + const forward = () => { + setOpen((prev) => prev + 1); + debouncedUpdateStatus(); + } + + const NextButton = + + + + + + return ( + +
+ + + + }> + + Basic Information + {status[0]} + + + + + + setName(e.target.value)} disabled={!!event?.archived} /> + + + + + + + + + All times are in UTC. Event must be at least 30 minutes long and cannot be before today. + + + {NextButton} + + + + + + + }> + + Event Type + {status[1]} + + + + + setType(v as EventType)}> + {Object.keys(EventType).map((type) => ( + } label={( + <> + {type} + {getDescription(type as EventType)} + + )} /> + ))} + + + {NextButton} + + + + + }> + + Description + {status[2]} + + + + { event?.archived && {event.description} } + { !event?.archived && + setDescription(d)} + /> + } + {NextButton} + + + + + }> + + Banner Image or URL + {!!event?.archived ? : bannerUploadType === 'url' ? status[3] : } + + + + setBannerUploadType(value)} + > + File + URL + + + {bannerUploadType === "file" ? + : + setBannerUrl(e.target.value)} disabled={!!event?.archived} />} + + {NextButton} + + + + + }> + + Featured Fields + {status[4]} + + + + + value.map((option: string, index: number) => { + const {key, ...tagProps} = getTagProps({index}); + return ( + + ); + }) + } + onChange={(event, value) => { + setFeaturedFields(value.map((v) => v.toUpperCase())); + }} + renderInput={(params) => ( + + )} + /> + {NextButton} + + + + + }> + + Important Event Information + {event?.archived ? : } + + + +
    +
  • + By default, when an event is created, it is hidden from the calendar or list view. +
  • +
  • + This event will be archived, not deleted, 24 hours after the published end date. This can be reverted through the event manager. +
  • +
  • + Create, publish, modify, and delete positions through the Event Manager, which you will be redirected to after submission. +
  • +
  • + Editing this event later on will have no impact on the Event Manager, since it only deals with positions. +
  • +
  • + You can un-hide this event from the events manager among other common tasks. +
  • +
  • + You will be notified of any errors in your submission after you submit the form. All changes will be saved as long as you dont leave this page or refresh. +
  • +
+ {NextButton} +
+
+
+ + +
+ ); +} + +const getDescription = (type: EventType) => { + switch (type) { + case EventType.HOME: + return 'Events that are planned and executed by the ARTCC with a the ARTCC facility being the primary event airport(s). These events have assigned positions based on pre-event signups.'; + case EventType.SUPPORT_REQUIRED: + return 'Events that the ARTCC is expected to provide supporting staffing for are classed as required support events. These events are coordinated with adjacent facilities and VATUSA. These events have assigned positions based on pre-event signups.'; + case EventType.SUPPORT_OPTIONAL: + return 'Events that the ARTCC has been requested to support, or that the events team is aware of, that are tracked but not coordinated by the events team. These events may have assigned positions or be staffed first come first serve at the discretion of the Events Coordinator.'; + case EventType.GROUP_FLIGHT: + return 'Organizations that have requested, or notified the ARTCC, staffing may be posted. Controllers may staff during these requested periods but the ARTCC has made no commitment to making staffing available for the activity.'; + case EventType.FRIDAY_NIGHT_OPERATIONS: + return 'Any event between 2100z and 0600z on a Friday. FNOs are “owned” by VATUSA but may be delegated to subdivisions for planning, coordination, and execution.'; + case EventType.SATURDAY_NIGHT_OPERATIONS: + return 'Any event between the hours of 2100z and 0600z on a Saturday. SNOs must receive approval from VATUSA prior to being publicly advertised.'; + case EventType.TRAINING: + return 'A training event or session involving one or more students.'; + default: + return ''; + } +} + +const getStepStatus = async (parse: ZodErrorSlimResponse, input: { [key: string]: any }) => { + + if (parse.success || parse.errors.length === 0) { + return ; + } + + if (parse.errors.filter((error) => Object.keys(input).includes(error.path)).length > 0) { + return ; + } else { + return ; + } +} \ No newline at end of file diff --git a/components/Event/EventTable.tsx b/components/Event/EventTable.tsx new file mode 100644 index 0000000..79dfda9 --- /dev/null +++ b/components/Event/EventTable.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { Checklist, Edit, OpenInNew } from "@mui/icons-material"; +import { getGridSingleSelectOperators, GridActionsCellItem, GridColDef } from "@mui/x-data-grid"; +import { EventType } from "@prisma/client"; +import Link from "next/link"; +import EventDeleteButton from "./EventDeleteButton"; +import { Tooltip } from "@mui/material"; +import { useRouter } from "next/navigation"; +import DataTable, { containsOnlyFilterOperator, equalsOnlyFilterOperator } from "../DataTable/DataTable"; +import { fetchEvents } from "@/actions/event"; +import { formatZuluDate } from "@/lib/date"; + +export default function EventTable({ archived }: { archived?: boolean, }) { + + const router = useRouter(); + + const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + filterOperators: [...equalsOnlyFilterOperator, ...containsOnlyFilterOperator], + flex: 1, + }, + { + field: 'type', + type: 'singleSelect', + headerName: 'Type', + flex: 1, + sortable: false, + valueOptions: Object.keys(EventType).map((model) => ({value: model, label: model})), + filterOperators: getGridSingleSelectOperators().filter((operator) => operator.value === 'is'), + }, + { + field: 'start', + type: 'dateTime', + headerName: 'Start (GMT)', + valueFormatter: formatZuluDate, + flex: 1, + filterable: false, + }, + { + field: 'end', + type: 'dateTime', + headerName: 'End (GMT)', + valueFormatter: formatZuluDate, + flex: 1, + filterable: false, + }, + { + field: 'bannerKey', + type: 'actions', + headerName: 'Banner', + flex: 1, + renderCell: (params) => { + return params.row.bannerKey ? : 'N/A'; + }, + }, + { + field: 'hidden', + type: 'boolean', + headerName: 'Hidden', + flex: 1, + sortable: false, + }, + { + field: 'actions', + type: 'actions', + headerName: 'Actions', + flex: 1, + getActions: (params) => [ + + } + label="Event Manager" + onClick={() => router.push(`/events/admin/events/${params.row.id}/manager`)} + /> + , + + } + label="Edit Event" + onClick={() => router.push(`/events/admin/events/${params.row.id}`)} + /> + , + , + ], + } + ]; + + return ( + { + const events = await fetchEvents(pagination, sortModel, filter, archived); + return { + data: events[1], + rowCount: events[0], + }; + }}/> + ) +} \ No newline at end of file diff --git a/components/Event/EventTableToggleSwitch.tsx b/components/Event/EventTableToggleSwitch.tsx new file mode 100644 index 0000000..523f199 --- /dev/null +++ b/components/Event/EventTableToggleSwitch.tsx @@ -0,0 +1,33 @@ +'use client'; +import { Switch } from "@mui/material"; + +import { FormControlLabel } from "@mui/material"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +export default function EventTableToggleSwitch({ archived, }: { archived: boolean }) { + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + return ( + { + const newSP = new URLSearchParams(searchParams); + if (c) { + newSP.set('archived', 'true'); + } else { + newSP.set('archived', 'false'); + } + router.push(`${pathname}?${newSP.toString()}`); + }} + /> + } + label="Show ONLY Archived Events" + /> + ); +} \ No newline at end of file diff --git a/components/EventManager/ArchiveToggleButton.tsx b/components/EventManager/ArchiveToggleButton.tsx new file mode 100644 index 0000000..e039c66 --- /dev/null +++ b/components/EventManager/ArchiveToggleButton.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { toggleEventArchived } from "@/actions/eventManagement"; +import { Button } from "@mui/material"; +import { Event } from "@prisma/client"; +import { toast } from "react-toastify"; + +export default function ArchiveToggleButton({ event }: { event: Event,}) { + + const handleClick = () => { + if (event.archived && event.end < new Date()) { + toast.error('Cannot unarchive an event that has ended. If you really want to unarchive this event, please change the start and end times.'); + return; + } + toggleEventArchived(event); + } + + return ( + + ); +} \ No newline at end of file diff --git a/components/EventManager/ArchivedAlert.tsx b/components/EventManager/ArchivedAlert.tsx new file mode 100644 index 0000000..786f476 --- /dev/null +++ b/components/EventManager/ArchivedAlert.tsx @@ -0,0 +1,7 @@ +import { Alert } from "@mui/material"; + +export default function ArchivedAlert() { + return ( + This event is archived and hidden. In order to unarchive this event, change the start and end dates to the future (if they are not). + ); +} \ No newline at end of file diff --git a/components/EventManager/EventControls.tsx b/components/EventManager/EventControls.tsx new file mode 100644 index 0000000..54ab75e --- /dev/null +++ b/components/EventManager/EventControls.tsx @@ -0,0 +1,48 @@ +import { ButtonGroup, IconButton, Tooltip, Typography } from "@mui/material"; +import { Divider } from "@mui/material"; +import { Event } from "@prisma/client"; +import ToggleVisibilityButton from "./ToggleVisibilityButton"; +import { Info } from "@mui/icons-material"; +import { Card, CardContent, Stack } from "@mui/material"; +import ArchiveToggleButton from "./ArchiveToggleButton"; +import { Edit } from "@mui/icons-material"; +import { eventGetDuration, formatZuluDate } from "@/lib/date"; +import Link from "next/link"; + +export default async function EventControls({ event }: { event: Event, }) { + + + + return ( + + + Event Manager - {event.type} + {event.name} + START  {formatZuluDate(event.start)} (IN {eventGetDuration(new Date(), event.start, true).toFixed(2)} days) + END      {formatZuluDate(event.end)} (+{eventGetDuration(event.start, event.end).toFixed(2)} hours) + + + + + + + + + + + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/components/EventManager/EventPositionDeleteButton.tsx b/components/EventManager/EventPositionDeleteButton.tsx new file mode 100644 index 0000000..aa76a2d --- /dev/null +++ b/components/EventManager/EventPositionDeleteButton.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { deleteEventPosition } from "@/actions/eventPosition"; +import { Delete } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; +import { Event, EventPosition } from "@prisma/client"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +export default function EventPositionDeleteButton({ event, position, }: { event: Event, position: EventPosition, }) { + + const [clicked, setClicked] = useState(false); + + const handleClick = async () => { + if (clicked) { + await deleteEventPosition(event, position.id, true); + toast(`Position '${position.requestedPosition}' deleted successfully!`, {type: 'success'}); + } else { + toast.warn(`Deleting this position will remove all signups associated with it. Click again to confirm.`); + setClicked(true); + } + + } + + return ( + + + + + + ); + +} \ No newline at end of file diff --git a/components/EventManager/EventPositionEditButton.tsx b/components/EventManager/EventPositionEditButton.tsx new file mode 100644 index 0000000..7ca2363 --- /dev/null +++ b/components/EventManager/EventPositionEditButton.tsx @@ -0,0 +1,102 @@ +'use client'; +import { adminSaveEventPosition, publishEventPosition } from "@/actions/eventPosition"; +import { EventPositionWithSolo } from "@/app/events/admin/events/[id]/manager/page"; +import { formatZuluDate } from "@/lib/date"; +import { Edit } from "@mui/icons-material"; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton, Stack, TextField, Tooltip } from "@mui/material"; +import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { Event } from "@prisma/client"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +export default function EventPositionEditButton({ event, position, }: { event: Event, position: EventPositionWithSolo, }) { + + dayjs.extend(utc); + + const eventStart = dayjs.utc(event.start); + const eventEnd = dayjs.utc(event.end); + + const reqStart = dayjs.utc(position.requestedStartTime); + const reqEnd = dayjs.utc(position.requestedEndTime); + + const finalStart = position.finalStartTime ? dayjs.utc(position.finalStartTime) : reqStart; + const finalEnd = position.finalEndTime ? dayjs.utc(position.finalEndTime) : reqEnd; + + const [open, setOpen] = useState(false); + const [finalPosition, setFinalPosition] = useState(position.finalPosition || (event.presetPositions.includes(position.requestedPosition) ? position.requestedPosition : '')); + const [finalStartTime, setFinalStartTime] = useState(finalStart); + const [finalEndTime, setFinalEndTime] = useState(finalEnd); + const [finalNotes, setFinalNotes] = useState(position.finalNotes || ''); + + const handleClick = () => { + setOpen(true); + } + + const save = async (publish?: boolean) => { + + const formData = new FormData(); + formData.set('requestedPosition', position.requestedPosition); + formData.set('finalPosition', finalPosition); + formData.set('finalStartTime', finalStartTime!.toISOString()); + formData.set('finalEndTime', finalEndTime!.toISOString()); + formData.set('finalNotes', finalNotes); + + const { eventPosition, errors } = await adminSaveEventPosition(event, position, formData); + + if (errors) { + toast.error(errors.map((error) => error.message).join('. ')); + return; + } + + toast.success('Position saved successfully!'); + + if (publish) { + const {error} = await publishEventPosition(event, eventPosition); + + if (error) { + toast.error(error.errors.map((error) => error.message).join('. ')); + return; + } + + toast.success('Position published successfully!'); + } + + setOpen(false); + } + + return ( + + + + + + + setOpen(false)}> + Position - {position.user?.firstName} {position.user?.lastName} + + REQUESTED '{position.requestedPosition}' + {eventStart.isSame(reqStart) && eventEnd.isSame(reqEnd) ? 'FULL EVENT' : `${formatZuluDate(position.requestedStartTime)} - ${formatZuluDate(position.requestedEndTime)}`} +
+ Notes: + {position.notes} +
+ + setFinalPosition(e.target.value)} /> + + + setFinalNotes(e.target.value)} /> + +
+ + + + + +
+ + ); + +} \ No newline at end of file diff --git a/components/EventManager/EventPositionPublishAllButton.tsx b/components/EventManager/EventPositionPublishAllButton.tsx new file mode 100644 index 0000000..2010298 --- /dev/null +++ b/components/EventManager/EventPositionPublishAllButton.tsx @@ -0,0 +1,137 @@ +'use client'; +import { publishEventPosition, unpublishEventPosition, validateFinalEventPosition } from "@/actions/eventPosition"; +import { EventPositionWithSolo } from "@/app/events/admin/events/[id]/manager/page"; +import { ZodErrorSlimResponse } from "@/types"; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; +import { Event, } from "@prisma/client"; +import { User } from "next-auth"; +import { useState } from "react"; +import { toast } from "react-toastify"; +import { ZodIssue } from "zod"; + +export default function EventPositionPublishAllButton({ event, positions, }: { event: Event, positions: EventPositionWithSolo[], }) { + + const [errorDialogOpen, setErrorDialogOpen] = useState(false); + const [errors, setErrors] = useState([]); + + const allPublished = positions.length > 0 && positions.every((position) => position.published); + + const handleClick = async () => { + if (allPublished) { + await Promise.all(positions.map(async (position) => { + await unpublishEventPosition(event, position); + })); + toast.success('All positions unpublished successfully!'); + return; + } + + const errors = await getErrors(event, positions); + + if (errors.length === 0) { + await Promise.all(positions.map(async (position) => { + if (!position.published) { + await publishEventPosition(event, { + ...position, + finalPosition: position.finalPosition || position.requestedPosition || 'ERR - CONTACT EVENT STAFF', + }); + } + })); + toast.success('All positions published successfully!'); + } else { + setErrors(errors); + setErrorDialogOpen(true); + } + } + + return ( + <> + + setErrorDialogOpen(false)}> + Could Not Auto-Publish + +
    + {errors.map((error) => ( +
  • + {error.user.firstName} {error.user.lastName} +
      + {error.errors.map((error, index) => ( +
    • {error}
    • + ))} +
    +
  • + ))} +
+ Fix these conflicts and try again. +
+ + + +
+ + ) +} + +type EventPositionError = { + user: User; + errors: string[]; +} + +const getErrors = async (event: Event, positions: EventPositionWithSolo[]): Promise => { + + if (positions.length === 0) { + return []; + } + + const errors: EventPositionError[] = []; + + for (const position of positions) { + + // check for duplicate positions + const duplicate = positions.find((p) => p !== position && p.requestedPosition === position.requestedPosition); + if (duplicate && position.user) { + if (errors.find((error) => error.user === position.user)) { + errors.find((error) => error.user === position.user)?.errors.push(`Duplicate position ${position.requestedPosition}`); + } else { + errors.push({ user: position.user as User, errors: [`Duplicate position ${position.requestedPosition}`] }); + } + } + + const modPosition = position; + + if (!position.finalPosition && event.presetPositions.includes(position.requestedPosition)) { + modPosition.finalPosition = position.requestedPosition; + } else if (!position.finalPosition && position.user) { + if (errors.find((error) => error.user === position.user)) { + errors.find((error) => error.user === position.user)?.errors.push('Final Position is required and cannot be autofilled because it is not one of the presets.'); + } else { + errors.push({ user: position.user as User, errors: ['Final Position is required and cannot be autofilled because it is not one of the presets.'] }); + } + } + + modPosition.finalStartTime = position.finalStartTime || position.requestedStartTime; + modPosition.finalEndTime = position.finalEndTime || position.requestedEndTime; + modPosition.finalNotes = position.finalNotes || ''; + + const formData = new FormData(); + formData.set('finalPosition', modPosition.finalPosition || ''); + formData.set('finalStartTime', modPosition.finalStartTime.toISOString()); + formData.set('finalEndTime', modPosition.finalEndTime.toISOString()); + formData.set('finalNotes', modPosition.finalNotes); + + const parse = await validateFinalEventPosition(event, formData) as ZodErrorSlimResponse; + + if (parse.success) { + continue; + } + + for (const error of parse.errors) { + if (errors.find((error) => error.user === position.user)) { + errors.find((error) => error.user === position.user)?.errors.push(error.message); + } else { + errors.push({ user: position.user as User, errors: [error.message] }); + } + } + } + + return errors; +} diff --git a/components/EventManager/EventPositionPublishButton.tsx b/components/EventManager/EventPositionPublishButton.tsx new file mode 100644 index 0000000..f33d47e --- /dev/null +++ b/components/EventManager/EventPositionPublishButton.tsx @@ -0,0 +1,37 @@ +'use client'; +import { publishEventPosition, unpublishEventPosition } from "@/actions/eventPosition"; +import { Publish, Unpublished } from "@mui/icons-material"; +import { IconButton } from "@mui/material"; +import { Tooltip } from "@mui/material"; +import { Event, EventPosition } from "@prisma/client"; +import { toast } from "react-toastify"; + +export default function EventPositionPublishButton({ event, position, }: { event: Event, position: EventPosition, }) { + + const handleClick = async () => { + + if (position.published) { + await unpublishEventPosition(event, position); + toast.success('Position unpublished successfully!'); + return; + } + + const {error} = await publishEventPosition(event, position); + + if (error) { + toast.error(error.errors.map((error) => error.message).join('. ')); + return; + } + + toast.success('Position published successfully!'); + } + + return ( + + + { position.published ? : } + + + ); +} + \ No newline at end of file diff --git a/components/EventManager/EventPositionsTable.tsx b/components/EventManager/EventPositionsTable.tsx new file mode 100644 index 0000000..41f360a --- /dev/null +++ b/components/EventManager/EventPositionsTable.tsx @@ -0,0 +1,121 @@ +import { EventPositionWithSolo } from "@/app/events/admin/events/[id]/manager/page";; +import { Box, Chip, Stack } from "@mui/material"; +import { ButtonGroup } from "@mui/material"; +import { formatZuluDate } from "@/lib/date"; +import { getRating } from "@/lib/vatsim"; +import { TableBody, Tooltip } from "@mui/material"; +import { CardContent, Table, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material"; +import { Card } from "@mui/material"; +import Link from "next/link"; +import { Event, EventPosition } from "@prisma/client"; +import TogglePositionsLockButton from "./TogglePositionsLockButton"; +import ForcePositionsToggleSwitch from "./ForcePositionsToggleSwitch"; +import EventPositionDeleteButton from "./EventPositionDeleteButton"; +import EventPositionEditButton from "./EventPositionEditButton"; +import EventPositionPublishButton from "./EventPositionPublishButton"; +import EventPositionPublishAllButton from "./EventPositionPublishAllButton"; + +export default async function EventPositionsTable({ event, positions }: { event: Event, positions: EventPositionWithSolo[] }) { + + const getTimeRectangle = (position: EventPosition, eventStart: Date, start: Date, eventEnd: Date, end: Date) => { + + if (eventStart.getTime() > start.getTime() || eventEnd.getTime() < end.getTime()) { + return <> +
+ INVALID (hover) + ; + } + + const totalDuration = eventEnd.getTime() - eventStart.getTime(); + const startOffset = start.getTime() - eventStart.getTime(); + const endOffset = end.getTime() - eventStart.getTime(); + + const startPercentage = (startOffset / totalDuration) * 100; + const endPercentage = (endOffset / totalDuration) * 100; + + return
+
+
; + } + + return ( + + + Controller Positions + + + + + + + + + + + + + + + + + + + Controller + Solo? + Requested Position + Requested Time + Notes + Final Position + Final Time + Final Notes + { !event.archived && Actions } + + + + {positions.map((position) => position.user && ( + + + + + + + {position.soloCert ? + + {position.soloCert.position} + {formatZuluDate(position.soloCert.expires)} + + : ''} + {position.requestedPosition} + + + {getTimeRectangle(position, new Date(event.start), new Date(position.requestedStartTime), new Date(event.end), new Date(position.requestedEndTime))} + + + {position.notes} + {position.finalPosition} + + + {getTimeRectangle(position, new Date(event.start), new Date(position.finalStartTime || position.requestedStartTime), new Date(event.end), new Date(position.finalEndTime || position.requestedEndTime))} + + + {position.finalNotes} + { !event.archived && + + + + + + } + + ))} + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/EventManager/EventPresetSelector.tsx b/components/EventManager/EventPresetSelector.tsx new file mode 100644 index 0000000..7d31f7d --- /dev/null +++ b/components/EventManager/EventPresetSelector.tsx @@ -0,0 +1,18 @@ +import prisma from "@/lib/db"; +import { Card, CardContent, Typography } from "@mui/material"; +import { Event } from "@prisma/client"; +import PresetSelectorForm from "./PresetSelectorForm"; + +export default async function EventPresetSelector({ event }: { event: Event, }) { + + const positionPresets = await prisma.eventPositionPreset.findMany({ + orderBy: { + name: 'asc', + }, + }); + + return ( + + ) + +} \ No newline at end of file diff --git a/components/EventManager/ForcePositionsToggleSwitch.tsx b/components/EventManager/ForcePositionsToggleSwitch.tsx new file mode 100644 index 0000000..0566841 --- /dev/null +++ b/components/EventManager/ForcePositionsToggleSwitch.tsx @@ -0,0 +1,21 @@ +'use client'; +import { toggleManualPositionOpen } from "@/actions/eventPosition"; +import { FormControlLabel } from "@mui/material"; + +import { Switch } from "@mui/material"; +import { Event } from "@prisma/client"; + +export default function ForcePositionsToggleSwitch({ event }: { event: Event }) { + return ( + toggleManualPositionOpen(event)} + /> + } + label="Force Positions Lock Setting? (no auto close)" + /> + ); +} \ No newline at end of file diff --git a/components/EventManager/HiddenAlert.tsx b/components/EventManager/HiddenAlert.tsx new file mode 100644 index 0000000..8bc0803 --- /dev/null +++ b/components/EventManager/HiddenAlert.tsx @@ -0,0 +1,7 @@ +import { Alert } from "@mui/material"; + +export default function HiddenAlert() { + return ( + This event is hidden. In order to show this event, change the visibility. + ); +} \ No newline at end of file diff --git a/components/EventManager/ManualControllerAddForm.tsx b/components/EventManager/ManualControllerAddForm.tsx new file mode 100644 index 0000000..ffe41b7 --- /dev/null +++ b/components/EventManager/ManualControllerAddForm.tsx @@ -0,0 +1,18 @@ +import { Accordion, AccordionDetails, AccordionSummary, Card, CardContent, Typography } from "@mui/material"; +import { Event, User } from "@prisma/client"; +import EventPositionRequestForm from "../EventPosition/EventPositionRequestForm"; +import { ExpandMore } from "@mui/icons-material"; + +export default function ManualControllerAddForm({ event, user }: { event: Event, user: User}) { + + return ( + + }> + Manually Assign Controller + + + + + + ) +} \ No newline at end of file diff --git a/components/EventManager/PresetSelectorForm.tsx b/components/EventManager/PresetSelectorForm.tsx new file mode 100644 index 0000000..5f68ff4 --- /dev/null +++ b/components/EventManager/PresetSelectorForm.tsx @@ -0,0 +1,75 @@ +'use client'; +import { updateEventPresetPositions } from "@/actions/event"; +import { Box, Stack, TextField } from "@mui/material"; +import { Autocomplete } from "@mui/material"; +import { Chip } from "@mui/material"; +import { Event, EventPositionPreset } from "@prisma/client"; +import Form from "next/form"; +import { useState } from "react"; +import FormSaveButton from "../Form/FormSaveButton"; +import { toast } from "react-toastify"; + +export default function EventPresetSelector({ event, presetPositions }: { event: Event, presetPositions: EventPositionPreset[] }) { + + const [positions, setPositions] = useState(event.presetPositions); + + const handleSubmit = async () => { + await updateEventPresetPositions(event.id, positions); + + toast.success('Preset positions updated successfully!'); + } + + return ( +
+ + + value.map((option: string, index: number) => { + const {key, ...tagProps} = getTagProps({index}); + return ( + + ); + }) + } + onChange={(event, value) => { + setPositions(value); + }} + renderInput={(params) => ( + + )} + /> + + `${option.name} (${option.positions.length} positions)`} + onChange={(event, value) => { + setPositions(value?.positions || []); + }} + renderInput={(params) => ( + + )} + /> + + + + +
+ ) + +} \ No newline at end of file diff --git a/components/EventManager/TogglePositionsLockButton.tsx b/components/EventManager/TogglePositionsLockButton.tsx new file mode 100644 index 0000000..d24f42d --- /dev/null +++ b/components/EventManager/TogglePositionsLockButton.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { togglePositionsLocked } from "@/actions/eventPosition"; +import { Button } from "@mui/material"; +import { Event } from "@prisma/client"; + +export default function TogglePositionsLockButton({ event }: { event: Event, }) { + return ( + + ); +} \ No newline at end of file diff --git a/components/EventManager/ToggleVisibilityButton.tsx b/components/EventManager/ToggleVisibilityButton.tsx new file mode 100644 index 0000000..918c048 --- /dev/null +++ b/components/EventManager/ToggleVisibilityButton.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { toggleEventHidden } from "@/actions/eventManagement"; +import { Button } from "@mui/material"; +import { Event } from "@prisma/client"; + +export default function ToggleVisibilityButton({ event }: { event: Event, }) { + return ( + + ); +} \ No newline at end of file diff --git a/components/EventPosition/ControllerManualAddForm.tsx b/components/EventPosition/ControllerManualAddForm.tsx deleted file mode 100644 index 1b1e1bd..0000000 --- a/components/EventPosition/ControllerManualAddForm.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; -import React, {useState} from 'react'; -import {EventPosition} from "@prisma/client"; -import {Autocomplete, Box, Stack, TextField} from "@mui/material"; -import {User} from "next-auth"; -import {getRating} from "@/lib/vatsim"; -import {toast} from "react-toastify"; -import {forceAssignPosition} from "@/actions/eventPosition"; -import FormSaveButton from "@/components/Form/FormSaveButton"; - -export default function ControllerManualAddForm({eventPositions, users}: { - eventPositions: EventPosition[], - users: User[] -}) { - - const [controller, setController] = useState(''); - const [position, setPosition] = useState(''); - - const handleSubmit = async (formData: FormData) => { - - const {eventPosition, controller, errors} = await forceAssignPosition(formData); - - if (errors) { - toast(errors[0].message, {type: 'error'}); - return; - } - - toast(`Assigned ${eventPosition.position} to ${controller.firstName} ${controller.lastName}`, {type: 'success'}); - } - - return ( -
- - - - `${option.position}`} - value={eventPositions.find((p) => p.id === position) || null} - onChange={(event, newValue) => { - setPosition(newValue ? newValue.id : ''); - }} - renderInput={(params) => } - /> - `${option.firstName} ${option.lastName} (${option.cid}) - ${getRating(option.rating)}`} - value={users.find((u) => u.id === controller) || null} - onChange={(event, newValue) => { - setController(newValue ? newValue.id : ''); - }} - renderInput={(params) => } - /> - - - - -
- ); -} \ No newline at end of file diff --git a/components/EventPosition/ControllerSignupDeleteButton.tsx b/components/EventPosition/ControllerSignupDeleteButton.tsx deleted file mode 100644 index 56bd013..0000000 --- a/components/EventPosition/ControllerSignupDeleteButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; -import React from 'react'; -import {useFormStatus} from "react-dom"; -import {Button, IconButton} from "@mui/material"; -import {Close, Delete} from "@mui/icons-material"; - -function ControllerSignupDeleteButton({ iconOnly }: { iconOnly?: boolean}) { - const { pending } = useFormStatus(); - - if (iconOnly) { - return ( - - - - ); - } - return ( - - ); -} - -export default ControllerSignupDeleteButton; \ No newline at end of file diff --git a/components/EventPosition/EventControllerRemoveForm.tsx b/components/EventPosition/EventControllerRemoveForm.tsx deleted file mode 100644 index 70f4f76..0000000 --- a/components/EventPosition/EventControllerRemoveForm.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; -import React from 'react'; -import {Event, EventPosition} from "@prisma/client"; -import {User} from "next-auth"; -import ControllerSignupDeleteButton from "@/components/EventPosition/ControllerSignupDeleteButton"; -import {unassignEventPosition} from "@/actions/eventPosition"; -import {log} from "@/actions/log"; -import {toast} from "react-toastify"; - -function EventControllerRemoveForm({ event, position, controller }: { event: Event, position: EventPosition, controller: User }) { - - const handleSubmit = async () => { - await unassignEventPosition(event, position, controller, true); - await log("UPDATE", "EVENT_POSITION", `Removed controller ${controller.cid} from position ${position.position} in event ${event.name}`); - toast(`Controller removed from ${position.position}`, { type: "success" }); - } - - return ( -
- - - ); -} - -export default EventControllerRemoveForm; \ No newline at end of file diff --git a/components/EventPosition/EventPositionDeleteButton.tsx b/components/EventPosition/EventPositionDeleteButton.tsx deleted file mode 100644 index 41f06d3..0000000 --- a/components/EventPosition/EventPositionDeleteButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; -import React, {useState} from 'react'; -import {EventPosition} from "@prisma/client"; -import {toast} from "react-toastify"; -import {IconButton} from "@mui/material"; -import {Delete} from "@mui/icons-material"; -import {deleteEventPosition} from "@/actions/eventPosition"; - -function EventPositionDeleteButton({eventPosition}: { eventPosition: EventPosition }) { - const [clicked, setClicked] = useState(false); - - const handleClick = async () => { - if (clicked) { - await deleteEventPosition(eventPosition.id); - toast(`'${eventPosition.position}' deleted successfully!`, {type: 'success'}); - } else { - toast(`Deleting '${eventPosition.position}' will remove all controllers signed up for it. Click again to confirm.`, {type: 'warning'}); - setClicked(true); - } - - } - - return ( - - {clicked ? : } - - ); -} - -export default EventPositionDeleteButton; \ No newline at end of file diff --git a/components/EventPosition/EventPositionForm.tsx b/components/EventPosition/EventPositionForm.tsx deleted file mode 100644 index 83207c2..0000000 --- a/components/EventPosition/EventPositionForm.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; -import React from 'react'; -import {Event, EventPosition} from "@prisma/client"; -import {Grid2, MenuItem, TextField} from "@mui/material"; -import {toast} from "react-toastify"; -import {createOrUpdateEventPosition} from "@/actions/eventPosition"; -import FormSaveButton from "@/components/Form/FormSaveButton"; -import {useRouter} from "next/navigation"; - -function EventPositionForm({event, eventPosition}: { event: Event, eventPosition?: EventPosition }) { - - const router = useRouter(); - - const handleSubmit = async (formData: FormData) => { - const {eventPosition, errors} = await createOrUpdateEventPosition(formData); - if (errors) { - toast(errors.map(e => e.message).join('. '), {type: 'error'}); - return; - } - - toast(`Position ${eventPosition.position} saved successfully!`, {type: 'success'}); - router.push(`/admin/events/edit/${event.id}/positions`); - } - - return ( - (
- - - - - - - - - - - - All Ratings - OBS - S1 - S2 - S3 - C1 - C2 - C3 - I1 - I2 - I3 - - - - - - -
) - ); - -} - -export default EventPositionForm; \ No newline at end of file diff --git a/components/EventPosition/EventPositionRequestForm.tsx b/components/EventPosition/EventPositionRequestForm.tsx new file mode 100644 index 0000000..80b6491 --- /dev/null +++ b/components/EventPosition/EventPositionRequestForm.tsx @@ -0,0 +1,105 @@ +'use client'; +import { Autocomplete, Button, Grid2, TextField, Typography } from "@mui/material"; +import { LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import { Event, EventPosition, User } from "@prisma/client"; +import dayjs, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import Form from "next/form"; +import { useEffect, useState } from "react"; +import FormSaveButton from "../Form/FormSaveButton"; +import { Add, Delete } from "@mui/icons-material"; +import { deleteEventPosition, fetchAllUsers, saveEventPosition } from "@/actions/eventPosition"; +import { toast } from "react-toastify"; +import { getRating } from "@/lib/vatsim"; + +export default function EventPositionRequestForm({ admin, currentUser, event, eventPosition }: { admin?: boolean, currentUser: User, event: Event, eventPosition?: EventPosition | null, }) { + + dayjs.extend(utc); + + const [allUsers, setAllUsers] = useState([]); + const [user, setUser] = useState(currentUser.id || ''); + const [position, setPosition] = useState(eventPosition?.requestedPosition || ''); + const [start, setStart] = useState(dayjs.utc(eventPosition?.requestedStartTime || event.start)); + const [end, setEnd] = useState(dayjs.utc(eventPosition?.requestedEndTime || event.end)); + const [notes, setNotes] = useState(eventPosition?.notes || ''); + + const handleSubmit = async (formData: FormData) => { + + if (!admin && (eventPosition || event.positionsLocked)) return; + + formData.set('userId', user); + formData.set('eventId', event.id); + formData.set('requestedPosition', position); + formData.set('requestedStartTime', start!.toISOString()); + formData.set('requestedEndTime', end!.toISOString()); + formData.set('notes', notes); + + const { errors } = await saveEventPosition(event, formData, admin); + + if (errors) { + toast.error(errors.map((error) => error.message).join('. ')); + return; + } + + toast.success(`Position ${admin ? 'added' : 'requested'} successfully!`); + + } + + useEffect(() => { + + if(admin) { + fetchAllUsers().then((users) => setAllUsers(users as User[])); + } + + }, [admin]); + + return ( + +
+ + { admin && + `${option.firstName} ${option.lastName} - ${getRating(option.rating)} (${option.cid})`} + value={allUsers.find((u) => u.id === user) || null} + onChange={(event, newValue) => { + setUser(newValue ? newValue.id : ''); + }} + renderInput={(params) => } + /> + } + + } + inputValue={position} + onInputChange={(e, value) => setPosition(value)} + /> + + + + + + + + + setNotes(e.target.value)} /> + + + { admin && } /> } + { !admin && !eventPosition && !event.positionsLocked && } /> } + { !admin && eventPosition && !event.positionsLocked && } + { !admin && event.positionsLocked && Positions are locked for this event. } + { !admin && You will recieve an email once your final position and time has been published. } + { admin && The position will be unpublished after being added. } + + +
+
+ ) +} \ No newline at end of file diff --git a/components/EventPosition/EventPositionSignupButton.tsx b/components/EventPosition/EventPositionSignupButton.tsx deleted file mode 100644 index cb960d9..0000000 --- a/components/EventPosition/EventPositionSignupButton.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; -import React from 'react'; -import {Button} from "@mui/material"; -import {Add} from "@mui/icons-material"; -import {useFormStatus} from "react-dom"; - -function EventPositionSignupButton() { - - const { pending } = useFormStatus(); - - return ( - - ); -} - -export default EventPositionSignupButton; \ No newline at end of file diff --git a/components/EventPosition/EventPositionSignupForm.tsx b/components/EventPosition/EventPositionSignupForm.tsx deleted file mode 100644 index 10f5b87..0000000 --- a/components/EventPosition/EventPositionSignupForm.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; -import React from 'react'; -import {User} from "next-auth"; -import {Event, EventPosition} from "@prisma/client"; -import {assignEventPosition, unassignEventPosition} from "@/actions/eventPosition"; -import EventPositionSignupButton from "@/components/EventPosition/EventPositionSignupButton"; -import ControllerSignupDeleteButton from "@/components/EventPosition/ControllerSignupDeleteButton"; -import {toast} from "react-toastify"; -import {Block} from "@mui/icons-material"; - -function EventPositionSignupForm({ user, event, position, controllers }: { user: User, event: Event, position: EventPosition, controllers: User[] }) { - - if (user.noEventSignup) { - return ; - } - - const isSignedUp = (controllers: User[]) => controllers.map((controller) => controller.id).includes(user.id || ''); - - const formSubmit = async (position: EventPosition, controllers: User[]) => { - if (isSignedUp(controllers)) { - await unassignEventPosition(event, position, user); - toast("Signup deleted", { type: "success" }); - } else if (isAbleToSignup(position, controllers, user)) { - await assignEventPosition(event, position, controllers, user); - toast("Signup added", { type: "success" }); - } - } - - - - return ( -
{ - await formSubmit(position, controllers) - }}> - {isSignedUp(controllers) && - - } - {!isSignedUp(controllers) && isAbleToSignup(position, controllers as User[], user) && - - } - - ); -} - -const isAbleToSignup = (eventPosition: EventPosition, controllersSignedUp: User[], controller: User) => { - if (controller.controllerStatus === "NONE") { - return false; - } - if (eventPosition.signupCap && controllersSignedUp.length >= eventPosition.signupCap) { - return false; - } - return !(eventPosition.minRating && eventPosition.minRating > controller.rating); - -} - -export default EventPositionSignupForm; \ No newline at end of file diff --git a/components/EventPosition/EventPositionsLockButton.tsx b/components/EventPosition/EventPositionsLockButton.tsx deleted file mode 100644 index 416638d..0000000 --- a/components/EventPosition/EventPositionsLockButton.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; -import React from 'react'; -import {Event} from '@prisma/client'; -import {FormControlLabel, Switch, Tooltip} from "@mui/material"; -import {setPositionsLock} from "@/actions/event"; -import {toast} from "react-toastify"; - -export default function EventPositionsLockButton({event}: { event: Event, }) { - - const [positionsLocked, setPositionsLocked] = React.useState(event.positionsLocked); - - const changeLock = async (newLock: boolean) => { - setPositionsLocked(newLock); - await setPositionsLock(event, newLock); - toast(`Signups ${newLock ? 'locked!' : 'unlocked!'}`, {type: 'success',}); - } - - const eventIsWithin48Hours = new Date(event.start).getTime() - Date.now() < 48 * 60 * 60 * 1000; - - return ( - - changeLock(b)} - control={} - label="Lock Signups"/> - - ); - -} \ No newline at end of file diff --git a/components/EventPositionPreset/EventPositionPresetDeleteButton.tsx b/components/EventPositionPreset/EventPositionPresetDeleteButton.tsx new file mode 100644 index 0000000..cc04075 --- /dev/null +++ b/components/EventPositionPreset/EventPositionPresetDeleteButton.tsx @@ -0,0 +1,31 @@ +import { deleteEventPreset } from "@/actions/eventPreset"; +import { Delete } from "@mui/icons-material"; +import { Tooltip } from "@mui/material"; +import { GridActionsCellItem } from "@mui/x-data-grid"; +import { EventPositionPreset } from "@prisma/client"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +export function EventPositionPresetDeleteButton({ positionPreset }: { positionPreset: EventPositionPreset }) { + const [clicked, setClicked] = useState(false); + + const handleClick = async () => { + if (clicked) { + await deleteEventPreset(positionPreset.id); + toast(`Position preset '${positionPreset.name}' deleted successfully!`, {type: 'success'}); + } else { + toast.warn(`Deleting this preset is not reversable. Click again to confirm.`); + setClicked(true); + } + + } + + return ( + + } + label="Delete Event Position Preset" + onClick={handleClick} + /> + + );} \ No newline at end of file diff --git a/components/EventPositionPreset/EventPositionPresetForm.tsx b/components/EventPositionPreset/EventPositionPresetForm.tsx new file mode 100644 index 0000000..43e769a --- /dev/null +++ b/components/EventPositionPreset/EventPositionPresetForm.tsx @@ -0,0 +1,71 @@ +'use client'; +import { Autocomplete, Box, Chip, Stack, TextField } from "@mui/material"; +import { EventPositionPreset } from "@prisma/client"; +import Form from "next/form"; +import { useState } from "react"; +import FormSaveButton from "../Form/FormSaveButton"; +import { createOrUpdateEventPreset } from "@/actions/eventPreset"; +import { toast } from "react-toastify"; +import { useRouter } from "next/navigation"; + +export default function EventPositionPresetForm({ positionPreset }: { positionPreset?: EventPositionPreset }) { + + const router = useRouter(); + const [positions, setPositions] = useState(positionPreset?.positions || []); + + const handleSubmit = async (formData: FormData) => { + formData.set('positions', positions.join(',')); + + const {errors} = await createOrUpdateEventPreset(formData); + + if (errors) { + toast.error(errors.map((e) => e.message).join('. ')); + return; + } + + if (!positionPreset) { + toast.success('Event position preset created successfully!'); + router.push('/events/admin/event-presets'); + } else { + toast.success('Event position preset updated successfully!'); + } + } + + return ( +
+ + + + + value.map((option: string, index: number) => { + const {key, ...tagProps} = getTagProps({index}); + return ( + + ); + }) + } + onChange={(event, value) => { + setPositions(value); + }} + renderInput={(params) => ( + + )} + /> + + + + +
+ ); +} \ No newline at end of file diff --git a/components/EventPositionPreset/EventPositionPresetTable.tsx b/components/EventPositionPreset/EventPositionPresetTable.tsx new file mode 100644 index 0000000..a83cf63 --- /dev/null +++ b/components/EventPositionPreset/EventPositionPresetTable.tsx @@ -0,0 +1,64 @@ +'use client'; +import { GridActionsCellItem, GridColDef } from "@mui/x-data-grid"; +import DataTable, { containsOnlyFilterOperator, equalsOnlyFilterOperator } from "../DataTable/DataTable"; +import { fetchEventPresets } from "@/actions/eventPreset"; +import { EventPositionPreset } from "@prisma/client"; +import { EventPositionPresetDeleteButton } from "./EventPositionPresetDeleteButton"; +import { Tooltip } from "@mui/material"; +import { Edit } from "@mui/icons-material"; +import { useRouter } from "next/navigation"; + +export function EventPositionPresetTable() { + + const router = useRouter(); + + const columns: GridColDef[] = [ + { + field: 'name', + headerName: 'Name', + flex: 1, + filterOperators: [...equalsOnlyFilterOperator, ...containsOnlyFilterOperator] + }, + { + field: 'positions', + headerName: 'Positions', + flex: 2, + renderCell: (params) => { + const positions = params.row.positions; + if (positions.length <= 4) { + return positions.join(', '); + } + const firstFour = positions.slice(0, 4).join(', '); + return `${firstFour}, ${positions.length - 4} more`; + }, + filterOperators: [...equalsOnlyFilterOperator, ...containsOnlyFilterOperator] + }, + { + field: 'actions', + headerName: 'Actions', + type: 'actions', + flex: 1, + getActions: (params) => [ + + } + label="Edit Event Position Preset" + onClick={() => router.push(`/events/admin/event-presets/${params.row.id}`)} + /> + , + , + ], + } + ]; + + return ( + { + const eventPresets = await fetchEventPresets(pagination, sortModel, filter); + return { + data: eventPresets[1], + rowCount: eventPresets[0], + }; + }}/> + ); +} \ No newline at end of file diff --git a/components/Events/EventCalendar.tsx b/components/Events/EventCalendar.tsx index f3953d7..f4e9972 100644 --- a/components/Events/EventCalendar.tsx +++ b/components/Events/EventCalendar.tsx @@ -1,11 +1,11 @@ 'use client'; import React from 'react'; -import {Event, EventType} from '@prisma/client'; import dayGridPlugin from "@fullcalendar/daygrid"; import FullCalendar from "@fullcalendar/react"; import {useRouter} from "next/navigation"; +import { EventType } from '@prisma/client'; -export default function EventCalendar({events}: { events: Event[], }) { +export default function EventCalendar({events}: { events: any[], }) { const router = useRouter(); @@ -39,11 +39,15 @@ const getEventColor = (eventType: EventType) => { return '#834091'; case EventType.SUPPORT_OPTIONAL: return '#cd8dd8'; + case EventType.FRIDAY_NIGHT_OPERATIONS: + return '#36d1e7'; + case EventType.SATURDAY_NIGHT_OPERATIONS: + return '#e6af34'; case EventType.GROUP_FLIGHT: return '#66bb6a'; case EventType.TRAINING: - return '#ffa726'; + return 'darkgray'; default: - return 'gray'; + return 'darkgray'; } } \ No newline at end of file diff --git a/components/Events/EventDeleteButton.tsx b/components/Events/EventDeleteButton.tsx deleted file mode 100644 index 9c971dd..0000000 --- a/components/Events/EventDeleteButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; -import React, {useState} from 'react'; -import {Event} from "@prisma/client"; -import {toast} from "react-toastify"; -import {GridActionsCellItem} from "@mui/x-data-grid"; -import {Delete} from "@mui/icons-material"; -import {deleteEvent} from "@/actions/event"; - -export default function EventDeleteButton({event}: { event: Event }) { - const [clicked, setClicked] = useState(false); - - const handleDelete = async () => { - if (clicked) { - await deleteEvent(event.id); - toast(`'${event.name}' deleted successfully!`, {type: 'success'}); - } else { - toast(`Deleting '${event.name}' will remove this event and all positions associated with it. Click again to confirm.`, {type: 'warning'}); - setClicked(true); - } - } - - return ( - } - label="Delete Event" - onClick={handleDelete} - /> - ); -} \ No newline at end of file diff --git a/components/Events/EventForm.tsx b/components/Events/EventForm.tsx deleted file mode 100644 index c977892..0000000 --- a/components/Events/EventForm.tsx +++ /dev/null @@ -1,172 +0,0 @@ -'use client'; -import React from 'react'; -import {Event, EventType} from "@prisma/client"; -import { - Box, - Grid2, - MenuItem, - Stack, - TextField, - ToggleButton, - ToggleButtonGroup, - Typography, - useTheme -} from "@mui/material"; -import {DateTimePicker, LocalizationProvider} from "@mui/x-date-pickers"; -import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; -import dayjs from "dayjs"; -import utc from 'dayjs/plugin/utc'; -import {toast} from "react-toastify"; -import {createOrUpdateEvent} from "@/actions/event"; -import {useRouter} from "next/navigation"; -import dynamic from "next/dynamic"; -import Image from "next/image"; -import Link from "next/link"; -import FormSaveButton from "@/components/Form/FormSaveButton"; -import {StaticImageData} from '@/node_modules/next/image'; - -const MarkdownEditor = dynamic( - () => import("@uiw/react-markdown-editor").then((mod) => mod.default), - { ssr: false } -); - -export default function EventForm({event, imageUrl, }: { event?: Event, imageUrl?: string | StaticImageData}) { - - const theme = useTheme(); - const [description, setDescription] = React.useState(event?.description || ''); - const [bannerUploadType, setBannerUploadType] = React.useState('file'); - const router = useRouter(); - dayjs.extend(utc); - - const handleSubmit = async (formData: FormData) => { - toast("Saving event. This might take a couple seconds.", {type: 'info'}) - const {event, errors} = await createOrUpdateEvent(formData); - if (errors) { - toast(errors.map((e) => e.message).join('. '), {type: 'error'}); - return; - } - router.push('/admin/events'); - toast(`Event '${event.name}' saved successfully!`, {type: 'success'}); - } - - const handleChange = ( - event: React.MouseEvent, - newAlignment: string, - ) => { - setBannerUploadType(newAlignment); - }; - - return ( - ( -
- - - - - - - - - - - - - - - {Object.keys(EventType).map((type) => ( - {type} - ))} - - - - - - - - - - - Description - setDescription(d)} - /> - - - - - Upload Banner Image - - - File - URL - - - {bannerUploadType === "file" ? - : - } - - - {imageUrl && - - Active Banner Image - Click to open in a new tab. - - - {event?.name - - - - } - - - - -
-
) - ); - -} \ No newline at end of file diff --git a/components/Events/EventTable.tsx b/components/Events/EventTable.tsx deleted file mode 100644 index 7753a00..0000000 --- a/components/Events/EventTable.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client'; -import React from 'react'; -import {getGridSingleSelectOperators, GridActionsCellItem, GridColDef} from "@mui/x-data-grid"; -import DataTable, {containsOnlyFilterOperator, equalsOnlyFilterOperator} from "@/components/DataTable/DataTable"; -import {fetchEvents} from "@/actions/event"; -import {Tooltip} from "@mui/material"; -import {Checklist, Edit} from "@mui/icons-material"; -import EventDeleteButton from "@/components/Events/EventDeleteButton"; -import {EventType} from "@prisma/client"; -import {useRouter} from "next/navigation"; -import {formatZuluDate} from "@/lib/date"; - -export default function EventTable() { - const eventTypes = Object.keys(EventType).map((type) => ({value: type, label: type})); - - const router = useRouter(); - - const columns: GridColDef[] = [ - { - field: 'name', - headerName: 'Name', - flex: 1, - filterOperators: [...equalsOnlyFilterOperator, ...containsOnlyFilterOperator], - }, - { - field: 'type', - type: 'singleSelect', - headerName: 'Type', - flex: 1, - filterOperators: getGridSingleSelectOperators().filter((operator) => operator.value === 'is'), - valueOptions: eventTypes - }, - { - field: 'start', - headerName: 'Start', - flex: 1, - filterable: false, - valueFormatter: (params) => formatZuluDate(params), - }, - { - field: 'end', - headerName: 'End', - flex: 1, - filterable: false, - valueFormatter: (params) => formatZuluDate(params), - }, - { - field: 'host', - headerName: 'Host', - flex: 1, - filterOperators: [...equalsOnlyFilterOperator, ...containsOnlyFilterOperator], - }, - { - field: 'actions', - headerName: 'Actions', - type: 'actions', - flex: 1, - getActions: (params) => [ - - } - label="View Positions" - onClick={() => router.push(`/admin/events/edit/${params.row.id}/positions`)} - /> - , - - } - label="Edit Event" - onClick={() => router.push(`/admin/events/edit/${params.row.id}`)} - /> - , - - ], - }, - ]; - - return ( - { - const events = await fetchEvents(pagination, sortModel, filter); - return { - data: events[1], - rowCount: events[0], - }; - }}/> - ); -} \ No newline at end of file diff --git a/components/Feedback/FeedbackForm.tsx b/components/Feedback/FeedbackForm.tsx index e59ba26..4a77b95 100644 --- a/components/Feedback/FeedbackForm.tsx +++ b/components/Feedback/FeedbackForm.tsx @@ -53,7 +53,7 @@ const groupedPositions = [ 'BWI_GND', 'ORF_GND', 'RIC_GND', - 'RDG_GND', + 'RDU_GND', ] }, { diff --git a/components/Footer/Footer.tsx b/components/Footer/Footer.tsx index 72ee710..50bcc95 100644 --- a/components/Footer/Footer.tsx +++ b/components/Footer/Footer.tsx @@ -9,10 +9,9 @@ import getConfig from "next/config"; const DEV_MODE = process.env['DEV_MODE'] === 'true'; - export default function Footer() { - const {publicRuntimeConfig} = getConfig(); + const { publicRuntimeConfig } = getConfig(); return ( diff --git a/components/Logo/Logo.tsx b/components/Logo/Logo.tsx index 31dbc32..65627b5 100644 --- a/components/Logo/Logo.tsx +++ b/components/Logo/Logo.tsx @@ -2,11 +2,21 @@ import React from 'react'; import logo from '@/public/img/logo.png'; import Image from "next/image"; import Link from "next/link"; +import { Box } from '@mui/material'; export default function Logo() { return ( - - {"Washington - + <> + + + {"Washington + + + + + {"Washington + + + ); } \ No newline at end of file diff --git a/components/Navbar/LoginButton.tsx b/components/Navbar/LoginButton.tsx index 1c38f89..fcaceb5 100644 --- a/components/Navbar/LoginButton.tsx +++ b/components/Navbar/LoginButton.tsx @@ -17,7 +17,7 @@ import { Typography } from "@mui/material"; import {Session} from "next-auth"; -import {AdminPanelSettings, Cancel, Class, Login, Logout, Person, Refresh, Settings} from "@mui/icons-material"; +import {AdminPanelSettings, CalendarMonth, Cancel, Class, Login, Logout, Person, Refresh, Settings} from "@mui/icons-material"; import NavDropdown from "@/components/Navbar/NavDropdown"; import Link from "next/link"; import {getRating} from "@/lib/vatsim"; @@ -116,6 +116,10 @@ export default function LoginButton({session, sidebar, sidebarButtonClicked,}: { } text="Training Administration"/> } + {session?.user.roles.some((r) => ["EVENT_STAFF", "STAFF"].includes(r)) && + + } text="Events Administration"/> + } } text="Refresh VATUSA Account Information" onClick={handleClick}/> } text="Logout" onClick={logout}/> @@ -152,6 +156,15 @@ export default function LoginButton({session, sidebar, sidebarButtonClicked,}: { Training Administration } + {session?.user.roles.some((r) => ["EVENT_STAFF", "STAFF"].includes(r)) && + + + + + + Events Administration + + } @@ -172,17 +185,15 @@ export default function LoginButton({session, sidebar, sidebarButtonClicked,}: { > Confirm Sign In - - The information contained on all pages of this website is to be used for flight simulation purposes only on the VATSIM - network. It is not intended nor should it be used for real world navigation. This site is not affiliated with the FAA, NATCA, - the actual Washington ARTCC, or any governing aviation body. All content contained herein is approved only for use on the VATSIM network. - -
- } label={ - I understand that we are not the real world FAA nor do we have any - affiliation with them. - }/> -
+ The information contained on all pages of this website is to be used for flight simulation purposes only on the VATSIM + network. It is not intended nor should it be used for real world navigation. This site is not affiliated with the FAA, NATCA, + the actual Washington ARTCC, or any governing aviation body. All content contained herein is approved only for use on the VATSIM network. + +
+ } label={ + I understand that we are a virtual organization and do NOT have any + affiliation with the FAA, ZDC, or any government agency. + }/>