feat(blocked_slots): add blocked slots

This commit is contained in:
Dominik 2025-06-30 20:13:56 +02:00
parent ce27923118
commit 016b4371c2
Signed by: dominik
GPG key ID: 06A4003FC5049644
17 changed files with 1038 additions and 36 deletions

View file

@ -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 <BlockedSlotForm existingBlockedSlotId={resolvedParams.slotId} />;
}

View file

@ -0,0 +1,5 @@
import BlockedSlotForm from '@/components/forms/blocked-slot-form';
export default function NewBlockedSlotPage() {
return <BlockedSlotForm />;
}

View file

@ -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 <div className='text-center mt-10'>Loading...</div>;
if (error)
return (
<div className='text-center mt-10 text-red-500'>
Error loading blocked slots
</div>
);
const blockedSlots = blockedSlotsData?.data?.blocked_slots || [];
return (
<div className='relative h-full flex flex-col items-center'>
{/* Heading */}
<h1 className='text-3xl font-bold mt-8 mb-4 text-center z-10'>
My Blocked Slots
</h1>
{/* Scrollable blocked slot list */}
<div className='w-full flex justify-center overflow-hidden'>
<div className='grid gap-8 w-[max(90%, 500px)] p-6 overflow-y-auto'>
{blockedSlots.length > 0 ? (
blockedSlots.map((slot) => (
<BlockedSlotListEntry
key={slot.id}
{...slot}
updated_at={new Date(slot.updated_at)}
created_at={new Date(slot.created_at)}
/>
))
) : (
<div className='flex flex-1 flex-col items-center justify-center min-h-[300px]'>
<Label size='large' className='justify-center text-center'>
You don&#39;t have any blocked slots right now
</Label>
<RedirectButton
redirectUrl='/blocked_slots/new'
buttonText='create Blocked Slot'
className='mt-4'
/>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -155,7 +155,11 @@ export default function ShowEvent() {
</Label>{' '}
<div className='grid grid-cols-1 mt-3 sm:max-h-60 sm:grid-cols-2 sm:overflow-y-auto sm:mb-0'>
{event.participants?.map((user) => (
<ParticipantListEntry key={user.user.id} {...user} />
<ParticipantListEntry
key={user.user.id}
{...user}
eventID={event.id}
/>
))}
</div>
</div>

View file

@ -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,
},
);
});

View file

@ -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'],
});
}

View file

@ -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<string, string | string[]> = {};
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,
});
});

View file

@ -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'],
});
}

View file

@ -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(),
});

View file

@ -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',

View file

@ -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',
}),
);

View file

@ -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 (
<DaDRBCalendar
@ -218,11 +227,19 @@ function CalendarWithUserEvents({
end: new Date(event.end_time),
type: event.type,
userId: event.users[0],
organizer: event.type === 'event' ? event.organizer_id : undefined,
})) ?? []),
...(additionalEvents ?? []),
]}
onSelectEvent={(event) => {
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);
},
},
);
}
}}
/>
);

View file

@ -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<typeof BlockedSlotsSchema>;
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 (
<Link href={`/blocked_slots/${slot.id}`} className='block'>
<Card className='w-full'>
<div className='grid grid-cols-1 gap-2 mx-auto md:mx-4 md:grid-cols-[80px_1fr_250px]'>
<div className='w-full items-center justify-center grid'>
<Logo colorType='monochrome' logoType='submark' width={50} />
</div>
<div className='w-full items-center justify-center grid my-3 md:my-0'>
<h2 className='text-center'>{slot.reason}</h2>
</div>
<div className='grid gap-4'>
<div className='grid grid-cols-[80px_auto] gap-2'>
<Label className='text-[var(--color-neutral-300)] justify-end'>
start
</Label>
<Label>
{formatDate(slot.start_time)} {formatTime(slot.start_time)}
</Label>
</div>
<div className='grid grid-cols-[80px_auto] gap-2'>
<Label className='text-[var(--color-neutral-300)] justify-end'>
end
</Label>
<Label>
{formatDate(slot.end_time)} {formatTime(slot.end_time)}
</Label>
</div>
</div>
</div>
</Card>
</Link>
);
}

View file

@ -12,7 +12,6 @@ export default function LabeledInput({
error,
...rest
}: {
type: 'text' | 'email' | 'password';
label: string;
placeholder?: string;
value?: string;

View file

@ -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<typeof ParticipantSchema>;
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({
<div className='flex items-center gap-2 py-1 ml-5'>
<Image src={finalImageSrc} alt='Avatar' width={30} height={30} />
<span>{user.name}</span>
<span className='text-sm text-gray-500'>{status}</span>
{user.id === session.data?.user?.id && eventID ? (
<Select
defaultValue={status}
onValueChange={(value) => {
updateAttendance.mutate({
eventID: eventID,
user: session.data?.user?.id || '',
data: {
status: value as
| 'ACCEPTED'
| 'TENTATIVE'
| 'DECLINED'
| 'PENDING',
},
});
}}
>
<SelectTrigger id='language'>
<SelectValue placeholder='Select status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='ACCEPTED'>Attending</SelectItem>
<SelectItem value='TENTATIVE'>Maybe Attending</SelectItem>
<SelectItem value='DECLINED'>Not Attending</SelectItem>
<SelectItem value='PENDING' disabled>
Pending Response
</SelectItem>
</SelectContent>
</Select>
) : (
<span className='text-sm text-gray-500'>{status}</span>
)}
</div>
);
}

View file

@ -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 (
<div className='flex items-center justify-center h-full'>
<Card className='w-[max(80%, 500px)] max-w-screen p-0 gap-0 max-xl:w-[95%] mx-auto'>
<CardHeader className='p-0 m-0 gap-0 px-6'>
<div className='h-full mt-0 ml-2 mb-16 flex items-center justify-between max-sm:grid max-sm:grid-row-start:auto max-sm:mb-6 max-sm:mt-10 max-sm:ml-0'>
<div className='w-[100px] max-sm:w-full max-sm:flex max-sm:justify-center'>
<Logo colorType='monochrome' logoType='submark' width={50} />
</div>
<div className='items-center ml-auto mr-auto max-sm:mb-6 max-sm:w-full max-sm:flex max-sm:justify-center'>
<h1 className='text-center'>
{existingBlockedSlotId
? 'Update Blocked Slot'
: 'Create Blocked Slot'}
</h1>
</div>
<div className='w-0 sm:w-[100px]'></div>
</div>
</CardHeader>
<CardContent>
<form
onSubmit={existingBlockedSlotId ? onUpdateSubmit : onCreateSubmit}
>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3'>
<LabeledInput
label='Start Time'
type='datetime-local'
id='start_time'
{...(existingBlockedSlotId
? registerUpdate('start_time')
: registerCreate('start_time'))}
error={
formStateCreate.errors.start_time?.message ||
formStateUpdate.errors.start_time?.message
}
required
/>
<LabeledInput
label='End Time'
type='datetime-local'
id='end_time'
{...(existingBlockedSlotId
? registerUpdate('end_time')
: registerCreate('end_time'))}
error={
formStateCreate.errors.end_time?.message ||
formStateUpdate.errors.end_time?.message
}
required
/>
<LabeledInput
label='Reason'
type='text'
id='reason'
{...(existingBlockedSlotId
? registerUpdate('reason')
: registerCreate('reason'))}
error={
formStateCreate.errors.reason?.message ||
formStateUpdate.errors.reason?.message
}
placeholder='Optional reason for blocking this slot'
/>
</div>
<div className='flex justify-end gap-2 p-4'>
<Button
type='submit'
variant='primary'
disabled={
formStateCreate.isSubmitting || formStateUpdate.isSubmitting
}
>
{existingBlockedSlotId
? 'Update Blocked Slot'
: 'Create Blocked Slot'}
</Button>
{existingBlockedSlotId && (
<Button
type='button'
variant='destructive'
onClick={onDeleteSubmit}
>
Delete Blocked Slot
</Button>
)}
</div>
{formStateCreate.errors.root && (
<p className='text-red-500 text-sm mt-1'>
{formStateCreate.errors.root.message}
</p>
)}
{formStateUpdate.errors.root && (
<p className='text-red-500 text-sm mt-1'>
{formStateUpdate.errors.root.message}
</p>
)}
</form>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (