Merge pull request 'feat(blocked_slots): add blocked slots' (#122)
Reviewed-on: #122 Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
This commit is contained in:
commit
a0965e1b8d
19 changed files with 1055 additions and 42 deletions
10
src/app/(main)/blocker/[slotId]/page.tsx
Normal file
10
src/app/(main)/blocker/[slotId]/page.tsx
Normal 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} />;
|
||||
}
|
5
src/app/(main)/blocker/new/page.tsx
Normal file
5
src/app/(main)/blocker/new/page.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import BlockedSlotForm from '@/components/forms/blocked-slot-form';
|
||||
|
||||
export default function NewBlockedSlotPage() {
|
||||
return <BlockedSlotForm />;
|
||||
}
|
56
src/app/(main)/blocker/page.tsx
Normal file
56
src/app/(main)/blocker/page.tsx
Normal 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 Blockers
|
||||
</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't have any blockers right now
|
||||
</Label>
|
||||
<RedirectButton
|
||||
redirectUrl='/blocker/new'
|
||||
buttonText='Create New Blocker'
|
||||
className='mt-4'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
165
src/app/api/blocked_slots/[slotID]/route.ts
Normal file
165
src/app/api/blocked_slots/[slotID]/route.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
});
|
90
src/app/api/blocked_slots/[slotID]/swagger.ts
Normal file
90
src/app/api/blocked_slots/[slotID]/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
127
src/app/api/blocked_slots/route.ts
Normal file
127
src/app/api/blocked_slots/route.ts
Normal 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,
|
||||
});
|
||||
});
|
66
src/app/api/blocked_slots/swagger.ts
Normal file
66
src/app/api/blocked_slots/swagger.ts
Normal 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'],
|
||||
});
|
||||
}
|
52
src/app/api/blocked_slots/validation.ts
Normal file
52
src/app/api/blocked_slots/validation.ts
Normal 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(),
|
||||
});
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -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(`/blocker/${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);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
SidebarMenuItem,
|
||||
} from '@/components/custom-ui/sidebar';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { CalendarMinus, CalendarMinus2, 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: 'Blockers',
|
||||
url: '/blocker',
|
||||
icon: CalendarMinus,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
|
@ -123,6 +128,17 @@ export function AppSidebar() {
|
|||
</span>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem className='pl-[8px]'>
|
||||
<Link
|
||||
href='/blocker/new'
|
||||
className='flex items-center gap-2 text-xl font-label'
|
||||
>
|
||||
<CalendarMinus2 className='size-8' />
|
||||
<span className='group-data-[collapsible=icon]:hidden text-nowrap whitespace-nowrap'>
|
||||
New Blocker
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
</SidebarFooter>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
|
|
56
src/components/custom-ui/blocked-slot-list-entry.tsx
Normal file
56
src/components/custom-ui/blocked-slot-list-entry.tsx
Normal 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={`/blocker/${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>
|
||||
);
|
||||
}
|
|
@ -12,7 +12,6 @@ export default function LabeledInput({
|
|||
error,
|
||||
...rest
|
||||
}: {
|
||||
type: 'text' | 'email' | 'password';
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
244
src/components/forms/blocked-slot-form.tsx
Normal file
244
src/components/forms/blocked-slot-form.tsx
Normal file
|
@ -0,0 +1,244 @@
|
|||
'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('/blocker');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createBlockedSlot } = usePostApiBlockedSlots({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
resetCreate();
|
||||
router.push('/blocker');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 Blocker' : 'Create Blocker'}
|
||||
</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 Blocker' : 'Create Blocker'}
|
||||
</Button>
|
||||
{existingBlockedSlotId && (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={onDeleteSubmit}
|
||||
>
|
||||
Delete Blocker
|
||||
</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>
|
||||
);
|
||||
}
|
|
@ -175,7 +175,6 @@ const EventForm: React.FC<EventFormProps> = (props) => {
|
|||
console.log('Creating event');
|
||||
const mutationResult = await createEvent({ data });
|
||||
eventID = mutationResult.data.event.id;
|
||||
createEvent({ data });
|
||||
}
|
||||
|
||||
toast.custom((t) => (
|
||||
|
|
|
@ -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 (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue