From 016b4371c2fed41655eadc5d57e79886f13be8eb Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Mon, 30 Jun 2025 20:13:56 +0200 Subject: [PATCH 1/2] feat(blocked_slots): add blocked slots --- .../(main)/blocked_slots/[slotId]/page.tsx | 10 + src/app/(main)/blocked_slots/new/page.tsx | 5 + src/app/(main)/blocked_slots/page.tsx | 56 ++++ src/app/(main)/events/[eventID]/page.tsx | 6 +- src/app/api/blocked_slots/[slotID]/route.ts | 165 ++++++++++++ src/app/api/blocked_slots/[slotID]/swagger.ts | 90 +++++++ src/app/api/blocked_slots/route.ts | 127 +++++++++ src/app/api/blocked_slots/swagger.ts | 66 +++++ src/app/api/blocked_slots/validation.ts | 52 ++++ src/app/api/calendar/validation.ts | 1 + src/app/api/validation.ts | 11 + src/components/calendar.tsx | 131 ++++++--- .../custom-ui/blocked-slot-list-entry.tsx | 56 ++++ src/components/custom-ui/labeled-input.tsx | 1 - .../custom-ui/participant-list-entry.tsx | 47 +++- src/components/forms/blocked-slot-form.tsx | 248 ++++++++++++++++++ src/components/wrappers/query-provider.tsx | 2 +- 17 files changed, 1038 insertions(+), 36 deletions(-) create mode 100644 src/app/(main)/blocked_slots/[slotId]/page.tsx create mode 100644 src/app/(main)/blocked_slots/new/page.tsx create mode 100644 src/app/(main)/blocked_slots/page.tsx create mode 100644 src/app/api/blocked_slots/[slotID]/route.ts create mode 100644 src/app/api/blocked_slots/[slotID]/swagger.ts create mode 100644 src/app/api/blocked_slots/route.ts create mode 100644 src/app/api/blocked_slots/swagger.ts create mode 100644 src/app/api/blocked_slots/validation.ts create mode 100644 src/components/custom-ui/blocked-slot-list-entry.tsx create mode 100644 src/components/forms/blocked-slot-form.tsx diff --git a/src/app/(main)/blocked_slots/[slotId]/page.tsx b/src/app/(main)/blocked_slots/[slotId]/page.tsx new file mode 100644 index 0000000..893253c --- /dev/null +++ b/src/app/(main)/blocked_slots/[slotId]/page.tsx @@ -0,0 +1,10 @@ +import BlockedSlotForm from '@/components/forms/blocked-slot-form'; + +export default async function NewBlockedSlotPage({ + params, +}: { + params: Promise<{ slotId?: string }>; +}) { + const resolvedParams = await params; + return ; +} diff --git a/src/app/(main)/blocked_slots/new/page.tsx b/src/app/(main)/blocked_slots/new/page.tsx new file mode 100644 index 0000000..a7c1bc7 --- /dev/null +++ b/src/app/(main)/blocked_slots/new/page.tsx @@ -0,0 +1,5 @@ +import BlockedSlotForm from '@/components/forms/blocked-slot-form'; + +export default function NewBlockedSlotPage() { + return ; +} diff --git a/src/app/(main)/blocked_slots/page.tsx b/src/app/(main)/blocked_slots/page.tsx new file mode 100644 index 0000000..2331737 --- /dev/null +++ b/src/app/(main)/blocked_slots/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { RedirectButton } from '@/components/buttons/redirect-button'; +import BlockedSlotListEntry from '@/components/custom-ui/blocked-slot-list-entry'; +import { Label } from '@/components/ui/label'; +import { useGetApiBlockedSlots } from '@/generated/api/blocked-slots/blocked-slots'; + +export default function BlockedSlots() { + const { data: blockedSlotsData, isLoading, error } = useGetApiBlockedSlots(); + + if (isLoading) return
Loading...
; + if (error) + return ( +
+ Error loading blocked slots +
+ ); + + const blockedSlots = blockedSlotsData?.data?.blocked_slots || []; + + return ( +
+ {/* Heading */} +

+ My Blocked Slots +

+ + {/* Scrollable blocked slot list */} +
+
+ {blockedSlots.length > 0 ? ( + blockedSlots.map((slot) => ( + + )) + ) : ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/src/app/(main)/events/[eventID]/page.tsx b/src/app/(main)/events/[eventID]/page.tsx index c11e028..ce39d19 100644 --- a/src/app/(main)/events/[eventID]/page.tsx +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -155,7 +155,11 @@ export default function ShowEvent() { {' '}
{event.participants?.map((user) => ( - + ))}
diff --git a/src/app/api/blocked_slots/[slotID]/route.ts b/src/app/api/blocked_slots/[slotID]/route.ts new file mode 100644 index 0000000..908324e --- /dev/null +++ b/src/app/api/blocked_slots/[slotID]/route.ts @@ -0,0 +1,165 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + updateBlockedSlotSchema, + BlockedSlotResponseSchema, +} from '@/app/api/blocked_slots/validation'; +import { + ErrorResponseSchema, + SuccessResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const GET = auth(async function GET(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const blockedSlot = await prisma.blockedSlot.findUnique({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + }, + }); + + if (!blockedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + BlockedSlotResponseSchema, + { + blocked_slot: blockedSlot, + }, + { + status: 200, + }, + ); +}); + +export const PATCH = auth(async function PATCH(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const dataRaw = await req.json(); + const data = await updateBlockedSlotSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + + const blockedSlot = await prisma.blockedSlot.update({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + data: data.data, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + }, + }); + + if (!blockedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + BlockedSlotResponseSchema, + { success: true, blocked_slot: blockedSlot }, + { status: 200 }, + ); +}); + +export const DELETE = auth(async function DELETE(req, { params }) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const slotID = (await params).slotID; + + const deletedSlot = await prisma.blockedSlot.delete({ + where: { + id: slotID, + user_id: authCheck.user.id, + }, + }); + + if (!deletedSlot) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Blocked slot not found or not owned by user', + }, + { status: 404 }, + ); + } + + return returnZodTypeCheckedResponse( + SuccessResponseSchema, + { success: true }, + { + status: 200, + }, + ); +}); diff --git a/src/app/api/blocked_slots/[slotID]/swagger.ts b/src/app/api/blocked_slots/[slotID]/swagger.ts new file mode 100644 index 0000000..16f2637 --- /dev/null +++ b/src/app/api/blocked_slots/[slotID]/swagger.ts @@ -0,0 +1,90 @@ +import { + updateBlockedSlotSchema, + BlockedSlotResponseSchema, +} from '@/app/api/blocked_slots/validation'; +import { + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, + invalidRequestDataResponse, +} from '@/lib/defaultApiResponses'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { SlotIdParamSchema } from '@/app/api/validation'; +import zod from 'zod/v4'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + }, + responses: { + 200: { + description: 'Blocked slot retrieved successfully', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'delete', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + }, + responses: { + 204: { + description: 'Blocked slot deleted successfully', + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'patch', + path: '/api/blocked_slots/{slotID}', + request: { + params: zod.object({ + slotID: SlotIdParamSchema, + }), + body: { + content: { + 'application/json': { + schema: updateBlockedSlotSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Blocked slot updated successfully', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...userNotFoundResponse, + ...notAuthenticatedResponse, + ...serverReturnedDataValidationErrorResponse, + ...invalidRequestDataResponse, + }, + tags: ['Blocked Slots'], + }); +} diff --git a/src/app/api/blocked_slots/route.ts b/src/app/api/blocked_slots/route.ts new file mode 100644 index 0000000..afd4f87 --- /dev/null +++ b/src/app/api/blocked_slots/route.ts @@ -0,0 +1,127 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { + blockedSlotsQuerySchema, + BlockedSlotsResponseSchema, + BlockedSlotsSchema, + createBlockedSlotSchema, +} from './validation'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; + +export const GET = auth(async function GET(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw: Record = {}; + for (const [key, value] of req.nextUrl.searchParams.entries()) { + if (key.endsWith('[]')) { + const cleanKey = key.slice(0, -2); + if (!dataRaw[cleanKey]) { + dataRaw[cleanKey] = []; + } + if (Array.isArray(dataRaw[cleanKey])) { + (dataRaw[cleanKey] as string[]).push(value); + } else { + dataRaw[cleanKey] = [dataRaw[cleanKey] as string, value]; + } + } else { + dataRaw[key] = value; + } + } + const data = await blockedSlotsQuerySchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + const { start, end } = data.data; + + const requestUserId = authCheck.user.id; + + const blockedSlots = await prisma.blockedSlot.findMany({ + where: { + user_id: requestUserId, + start_time: { gte: start }, + end_time: { lte: end }, + }, + orderBy: { start_time: 'asc' }, + select: { + id: true, + start_time: true, + end_time: true, + reason: true, + created_at: true, + updated_at: true, + }, + }); + + return returnZodTypeCheckedResponse( + BlockedSlotsResponseSchema, + { success: true, blocked_slots: blockedSlots }, + { status: 200 }, + ); +}); + +export const POST = auth(async function POST(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dataRaw = await req.json(); + const data = await createBlockedSlotSchema.safeParseAsync(dataRaw); + if (!data.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: data.error.issues, + }, + { status: 400 }, + ); + + const requestUserId = authCheck.user.id; + + if (!requestUserId) { + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not authenticated', + }, + { status: 401 }, + ); + } + + const blockedSlot = await prisma.blockedSlot.create({ + data: { + ...data.data, + user_id: requestUserId, + }, + }); + + return returnZodTypeCheckedResponse(BlockedSlotsSchema, blockedSlot, { + status: 201, + }); +}); diff --git a/src/app/api/blocked_slots/swagger.ts b/src/app/api/blocked_slots/swagger.ts new file mode 100644 index 0000000..4be89a9 --- /dev/null +++ b/src/app/api/blocked_slots/swagger.ts @@ -0,0 +1,66 @@ +import { + BlockedSlotResponseSchema, + BlockedSlotsResponseSchema, + blockedSlotsQuerySchema, + createBlockedSlotSchema, +} from './validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'get', + path: '/api/blocked_slots', + request: { + query: blockedSlotsQuerySchema, + }, + responses: { + 200: { + description: 'Blocked slots retrieved successfully.', + content: { + 'application/json': { + schema: BlockedSlotsResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['Blocked Slots'], + }); + + registry.registerPath({ + method: 'post', + path: '/api/blocked_slots', + request: { + body: { + content: { + 'application/json': { + schema: createBlockedSlotSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Blocked slot created successfully.', + content: { + 'application/json': { + schema: BlockedSlotResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + ...invalidRequestDataResponse, + }, + tags: ['Blocked Slots'], + }); +} diff --git a/src/app/api/blocked_slots/validation.ts b/src/app/api/blocked_slots/validation.ts new file mode 100644 index 0000000..d761700 --- /dev/null +++ b/src/app/api/blocked_slots/validation.ts @@ -0,0 +1,52 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import zod from 'zod/v4'; +import { + eventEndTimeSchema, + eventStartTimeSchema, +} from '@/app/api/event/validation'; + +extendZodWithOpenApi(zod); + +export const blockedSlotsQuerySchema = zod.object({ + start: eventStartTimeSchema.optional(), + end: eventEndTimeSchema.optional(), +}); + +export const blockedSlotRecurrenceEndDateSchema = zod.iso + .datetime() + .or(zod.date().transform((date) => date.toISOString())); + +export const BlockedSlotsSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + id: zod.string(), + reason: zod.string().nullish(), + created_at: zod.date(), + updated_at: zod.date(), + }) + .openapi('BlockedSlotsSchema', { + description: 'Blocked time slot in the user calendar', + }); + +export const BlockedSlotsResponseSchema = zod.object({ + success: zod.boolean().default(true), + blocked_slots: zod.array(BlockedSlotsSchema), +}); + +export const BlockedSlotResponseSchema = zod.object({ + success: zod.boolean().default(true), + blocked_slot: BlockedSlotsSchema, +}); + +export const createBlockedSlotSchema = BlockedSlotsSchema.omit({ + id: true, + created_at: true, + updated_at: true, +}); + +export const updateBlockedSlotSchema = zod.object({ + start_time: eventStartTimeSchema.optional(), + end_time: eventEndTimeSchema.optional(), + reason: zod.string().optional(), +}); diff --git a/src/app/api/calendar/validation.ts b/src/app/api/calendar/validation.ts index 5bf45a6..bc51489 100644 --- a/src/app/api/calendar/validation.ts +++ b/src/app/api/calendar/validation.ts @@ -48,6 +48,7 @@ export const VisibleSlotSchema = EventSchema.omit({ type: zod.literal('event'), users: zod.string().array(), user_id: zod.string().optional(), + organizer_id: zod.string().optional(), }) .openapi('VisibleSlotSchema', { description: 'Visible time slot in the user calendar', diff --git a/src/app/api/validation.ts b/src/app/api/validation.ts index 38b95bd..518121d 100644 --- a/src/app/api/validation.ts +++ b/src/app/api/validation.ts @@ -85,3 +85,14 @@ export const EventIdParamSchema = registry.registerParameter( example: '67890', }), ); + +export const SlotIdParamSchema = registry.registerParameter( + 'SlotIdParam', + zod.string().openapi({ + param: { + name: 'slotID', + in: 'path', + }, + example: 'abcde12345', + }), +); diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index ab23c35..56885e0 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button'; import { fromZodIssue } from 'zod-validation-error/v4'; import type { $ZodIssue } from 'zod/v4/core'; import { useGetApiCalendar } from '@/generated/api/calendar/calendar'; +import { usePatchApiBlockedSlotsSlotID } from '@/generated/api/blocked-slots/blocked-slots'; moment.updateLocale('en', { week: { @@ -47,6 +48,7 @@ const DaDRBCalendar = withDragAndDrop< end: Date; type: UserCalendarSchemaItem['type']; userId?: string; + organizer?: string; }, { id: string; @@ -190,6 +192,13 @@ function CalendarWithUserEvents({ }, }, }); + const { mutate: patchBlockedSlot } = usePatchApiBlockedSlotsSlotID({ + mutation: { + throwOnError(error) { + throw error.response?.data || 'Failed to update blocked slot'; + }, + }, + }); return ( { - router.push(`/events/${event.id}`); + if (event.type === 'blocked_private') return; + if (event.type === 'blocked_owned') { + router.push(`/blocked_slots/${event.id}`); + return; + } + if (event.type === 'event') { + router.push(`/events/${event.id}`); + } }} onSelectSlot={(slotInfo) => { router.push( @@ -236,53 +253,105 @@ function CalendarWithUserEvents({ selectable={sesstion.data?.user?.id === userId} onEventDrop={(event) => { const { start, end, event: droppedEvent } = event; - if (droppedEvent.type === 'blocked_private') return; + if ( + droppedEvent.type === 'blocked_private' || + (droppedEvent.organizer && + droppedEvent.organizer !== sesstion.data?.user?.id) + ) + return; const startISO = new Date(start).toISOString(); const endISO = new Date(end).toISOString(); - patchEvent( - { - eventID: droppedEvent.id, - data: { - start_time: startISO, - end_time: endISO, + if (droppedEvent.type === 'blocked_owned') { + patchBlockedSlot( + { + slotID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - { - onSuccess: () => { - refetch(); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error updating blocked slot:', error); + }, }, - onError: (error) => { - console.error('Error updating event:', error); + ); + return; + } else if (droppedEvent.type === 'event') { + patchEvent( + { + eventID: droppedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - ); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error updating event:', error); + }, + }, + ); + } }} onEventResize={(event) => { const { start, end, event: resizedEvent } = event; - if (resizedEvent.type === 'blocked_private') return; + if ( + resizedEvent.type === 'blocked_private' || + (resizedEvent.organizer && + resizedEvent.organizer !== sesstion.data?.user?.id) + ) + return; const startISO = new Date(start).toISOString(); const endISO = new Date(end).toISOString(); if (startISO === endISO) { console.warn('Start and end times are the same, skipping resize.'); return; } - patchEvent( - { - eventID: resizedEvent.id, - data: { - start_time: startISO, - end_time: endISO, + if (resizedEvent.type === 'blocked_owned') { + patchBlockedSlot( + { + slotID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - { - onSuccess: () => { - refetch(); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error resizing blocked slot:', error); + }, }, - onError: (error) => { - console.error('Error resizing event:', error); + ); + return; + } else if (resizedEvent.type === 'event') { + patchEvent( + { + eventID: resizedEvent.id, + data: { + start_time: startISO, + end_time: endISO, + }, }, - }, - ); + { + onSuccess: () => { + refetch(); + }, + onError: (error) => { + console.error('Error resizing event:', error); + }, + }, + ); + } }} /> ); diff --git a/src/components/custom-ui/blocked-slot-list-entry.tsx b/src/components/custom-ui/blocked-slot-list-entry.tsx new file mode 100644 index 0000000..4690eea --- /dev/null +++ b/src/components/custom-ui/blocked-slot-list-entry.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import Logo from '@/components/misc/logo'; +import { Label } from '@/components/ui/label'; +import Link from 'next/link'; +import zod from 'zod/v4'; +import { BlockedSlotsSchema } from '@/app/api/blocked_slots/validation'; + +type BlockedSlotListEntryProps = zod.output; + +export default function BlockedSlotListEntry(slot: BlockedSlotListEntryProps) { + const formatDate = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleDateString(); + }; + const formatTime = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + }; + return ( + + +
+
+ +
+
+

{slot.reason}

+
+
+
+ + +
+
+ + +
+
+
+
+ + ); +} diff --git a/src/components/custom-ui/labeled-input.tsx b/src/components/custom-ui/labeled-input.tsx index 4746a31..8adb8a5 100644 --- a/src/components/custom-ui/labeled-input.tsx +++ b/src/components/custom-ui/labeled-input.tsx @@ -12,7 +12,6 @@ export default function LabeledInput({ error, ...rest }: { - type: 'text' | 'email' | 'password'; label: string; placeholder?: string; value?: string; diff --git a/src/components/custom-ui/participant-list-entry.tsx b/src/components/custom-ui/participant-list-entry.tsx index 2ec9c02..6f21ee2 100644 --- a/src/components/custom-ui/participant-list-entry.tsx +++ b/src/components/custom-ui/participant-list-entry.tsx @@ -5,16 +5,28 @@ import { user_default_light } from '@/assets/usericon/default/defaultusericon-ex import { useTheme } from 'next-themes'; import zod from 'zod/v4'; import { ParticipantSchema } from '@/app/api/event/[eventID]/participant/validation'; +import { usePatchApiEventEventIDParticipantUser } from '@/generated/api/event-participant/event-participant'; +import { useSession } from 'next-auth/react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; type ParticipantListEntryProps = zod.output; export default function ParticipantListEntry({ user, status, -}: ParticipantListEntryProps) { + eventID, +}: ParticipantListEntryProps & { eventID?: string }) { + const session = useSession(); const { resolvedTheme } = useTheme(); const defaultImage = resolvedTheme === 'dark' ? user_default_dark : user_default_light; + const updateAttendance = usePatchApiEventEventIDParticipantUser(); const finalImageSrc = user.image ?? defaultImage; @@ -22,7 +34,38 @@ export default function ParticipantListEntry({
Avatar {user.name} - {status} + {user.id === session.data?.user?.id && eventID ? ( + + ) : ( + {status} + )}
); } diff --git a/src/components/forms/blocked-slot-form.tsx b/src/components/forms/blocked-slot-form.tsx new file mode 100644 index 0000000..c6ed109 --- /dev/null +++ b/src/components/forms/blocked-slot-form.tsx @@ -0,0 +1,248 @@ +'use client'; + +import useZodForm from '@/lib/hooks/useZodForm'; +import { + updateBlockedSlotSchema, + createBlockedSlotSchema, +} from '@/app/api/blocked_slots/validation'; +import { + useGetApiBlockedSlotsSlotID, + usePatchApiBlockedSlotsSlotID, + useDeleteApiBlockedSlotsSlotID, + usePostApiBlockedSlots, +} from '@/generated/api/blocked-slots/blocked-slots'; +import { useRouter } from 'next/navigation'; +import React from 'react'; +import LabeledInput from '../custom-ui/labeled-input'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader } from '../ui/card'; +import Logo from '../misc/logo'; +import { eventStartTimeSchema } from '@/app/api/event/validation'; +import zod from 'zod/v4'; + +const dateForDateTimeInputValue = (date: Date) => + new Date(date.getTime() + new Date().getTimezoneOffset() * -60 * 1000) + .toISOString() + .slice(0, 19); + +export default function BlockedSlotForm({ + existingBlockedSlotId, +}: { + existingBlockedSlotId?: string; +}) { + const router = useRouter(); + + const { data: existingBlockedSlot, isLoading: isLoadingExisting } = + useGetApiBlockedSlotsSlotID(existingBlockedSlotId || ''); + + const { + register: registerCreate, + handleSubmit: handleCreateSubmit, + formState: formStateCreate, + reset: resetCreate, + } = useZodForm( + createBlockedSlotSchema.extend({ + start_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + end_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + }), + ); + + const { + register: registerUpdate, + handleSubmit: handleUpdateSubmit, + formState: formStateUpdate, + reset: resetUpdate, + setValue: setValueUpdate, + } = useZodForm( + updateBlockedSlotSchema.extend({ + start_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + end_time: eventStartTimeSchema.or(zod.iso.datetime({ local: true })), + }), + ); + + const { mutateAsync: updateBlockedSlot } = usePatchApiBlockedSlotsSlotID({ + mutation: { + onSuccess: () => { + resetUpdate(); + }, + }, + }); + + const { mutateAsync: deleteBlockedSlot } = useDeleteApiBlockedSlotsSlotID({ + mutation: { + onSuccess: () => { + router.push('/blocked_slots'); + }, + }, + }); + + const { mutateAsync: createBlockedSlot } = usePostApiBlockedSlots({ + mutation: { + onSuccess: () => { + resetCreate(); + router.push('/blocked_slots'); + }, + }, + }); + + React.useEffect(() => { + if (existingBlockedSlot?.data) { + setValueUpdate( + 'start_time', + dateForDateTimeInputValue( + new Date(existingBlockedSlot?.data.blocked_slot.start_time), + ), + ); + setValueUpdate( + 'end_time', + dateForDateTimeInputValue( + new Date(existingBlockedSlot?.data.blocked_slot.end_time), + ), + ); + setValueUpdate( + 'reason', + existingBlockedSlot?.data.blocked_slot.reason || '', + ); + } + }, [ + existingBlockedSlot?.data, + resetUpdate, + setValueUpdate, + isLoadingExisting, + ]); + + const onUpdateSubmit = handleUpdateSubmit(async (data) => { + await updateBlockedSlot( + { + data: { + ...data, + start_time: new Date(data.start_time).toISOString(), + end_time: new Date(data.end_time).toISOString(), + }, + slotID: existingBlockedSlotId || '', + }, + { + onSuccess: () => { + router.back(); + }, + }, + ); + }); + + const onDeleteSubmit = async () => { + if (existingBlockedSlotId) { + await deleteBlockedSlot({ slotID: existingBlockedSlotId }); + } + }; + + const onCreateSubmit = handleCreateSubmit(async (data) => { + await createBlockedSlot({ + data: { + ...data, + start_time: new Date(data.start_time).toISOString(), + end_time: new Date(data.end_time).toISOString(), + }, + }); + }); + + return ( +
+ + +
+
+ +
+
+

+ {existingBlockedSlotId + ? 'Update Blocked Slot' + : 'Create Blocked Slot'} +

+
+
+
+
+ +
+
+ + + +
+
+ + {existingBlockedSlotId && ( + + )} +
+ {formStateCreate.errors.root && ( +

+ {formStateCreate.errors.root.message} +

+ )} + {formStateUpdate.errors.root && ( +

+ {formStateUpdate.errors.root.message} +

+ )} +
+
+
+
+ ); +} diff --git a/src/components/wrappers/query-provider.tsx b/src/components/wrappers/query-provider.tsx index 4d05c6c..0120caf 100644 --- a/src/components/wrappers/query-provider.tsx +++ b/src/components/wrappers/query-provider.tsx @@ -3,7 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as React from 'react'; -const queryClient = new QueryClient(); +export const queryClient = new QueryClient(); export function QueryProvider({ children }: { children: React.ReactNode }) { return ( From ffd052da54a35071b06cb7c5067d6ce16df13669 Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Mon, 30 Jun 2025 20:45:29 +0200 Subject: [PATCH 2/2] feat(slot blocking): add buttons to sidebar --- src/components/custom-ui/app-sidebar.tsx | 26 +++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/components/custom-ui/app-sidebar.tsx b/src/components/custom-ui/app-sidebar.tsx index 50e88c2..ec3c77f 100644 --- a/src/components/custom-ui/app-sidebar.tsx +++ b/src/components/custom-ui/app-sidebar.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from '@/components/custom-ui/sidebar'; -import { ChevronDown } from 'lucide-react'; +import { CalendarMinus, ChevronDown } from 'lucide-react'; import { Collapsible, CollapsibleContent, @@ -28,8 +28,8 @@ import Link from 'next/link'; import { Star, CalendarDays, - User, - Users, + //User, + //Users, CalendarClock, CalendarPlus, } from 'lucide-react'; @@ -40,7 +40,7 @@ const items = [ url: '/home', icon: CalendarDays, }, - { + /*{ title: 'Friends', url: '#', icon: User, @@ -49,12 +49,17 @@ const items = [ title: 'Groups', url: '#', icon: Users, - }, + },*/ { title: 'Events', url: '/events', icon: CalendarClock, }, + { + title: 'Blocked Slots', + url: '/blocked_slots', + icon: CalendarMinus, + }, ]; export function AppSidebar() { @@ -112,6 +117,17 @@ export function AppSidebar() { + + + + + New Blocked Slot + + +