feat(blocked_slots): add blocked slots
This commit is contained in:
parent
ce27923118
commit
016b4371c2
17 changed files with 1038 additions and 36 deletions
|
@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
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={`/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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
248
src/components/forms/blocked-slot-form.tsx
Normal file
248
src/components/forms/blocked-slot-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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