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/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
+
+
+
;
+
+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({
{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'}
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 (