feat: add calendar events module

This commit is contained in:
Arthur Bassi 2026-06-12 11:54:21 -03:00
parent f7adadffbe
commit 09a2468ab0
13 changed files with 660 additions and 0 deletions

View file

@ -0,0 +1,279 @@
import { useEffect } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
FormControl,
FormControlLabel,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
CALENDAR_REMINDER_OPTIONS,
CALENDAR_TIME_OPTIONS,
} from "@/components/common/calendar/calendar-utils";
import {
calendarEventSchema,
type CalendarEventFormValues,
} from "@/domain/calendar/schemas/calendar-event-schema";
import { useCalendarEventDetail } from "@/domain/calendar/use-cases/use-calendar-event-detail";
import { useCreateCalendarEvent } from "@/domain/calendar/use-cases/use-create-calendar-event";
import { useUpdateCalendarEvent } from "@/domain/calendar/use-cases/use-update-calendar-event";
import { toDateInputValue } from "@/lib/time-utils";
const COLORS = ["primary", "success", "info", "warning", "danger"] as const;
const defaultValues: CalendarEventFormValues = {
title: "",
description: "",
location: "",
recordId: null,
recordName: "",
recordTargetType: "",
reminderMinutes: 15,
eventColor: "primary",
startDate: new Date().toISOString().split("T")[0],
startTime: "08:00 AM",
endDate: new Date().toISOString().split("T")[0],
endTime: "08:30 AM",
allDay: false,
};
export default function EventFormPage() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const { data: event, isLoading, error } = useCalendarEventDetail(id);
const createEvent = useCreateCalendarEvent();
const updateEvent = useUpdateCalendarEvent();
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<CalendarEventFormValues>({
resolver: zodResolver(calendarEventSchema),
defaultValues: {
...defaultValues,
startDate: searchParams.get("date") ?? defaultValues.startDate,
endDate: searchParams.get("date") ?? defaultValues.endDate,
},
});
const allDay = useWatch({ control, name: "allDay" });
useEffect(() => {
if (event && isEdit) {
reset({
title: event.title,
description: event.description,
location: event.location,
recordId: event.recordId,
recordName: event.recordName,
recordTargetType: event.recordTargetType,
reminderMinutes: event.reminderMinutes,
eventColor: event.eventColor,
startDate: toDateInputValue(event.startDate),
startTime: event.startTime || "08:00 AM",
endDate: toDateInputValue(event.endDate),
endTime: event.endTime || "08:30 AM",
allDay: event.allDay,
});
}
}, [event, isEdit, reset]);
const isSaving = createEvent.isPending || updateEvent.isPending;
const onSubmit = (values: CalendarEventFormValues) => {
if (isEdit && id) {
updateEvent.mutate({ id, values });
return;
}
createEvent.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-3xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={3}>
<Typography variant="h5">{isEdit ? "Edit Event" : "Create Event"}</Typography>
{error && <Alert severity="error">Failed to load event</Alert>}
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<TextField
label="Title"
required
fullWidth
inputProps={{ maxLength: 100 }}
error={Boolean(errors.title)}
helperText={errors.title?.message}
{...register("title")}
/>
<TextField
label="Description"
fullWidth
multiline
rows={3}
{...register("description")}
/>
<TextField
label="Location"
fullWidth
inputProps={{ maxLength: 150 }}
{...register("location")}
/>
<Controller
name="reminderMinutes"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Remind Attendees</InputLabel>
<Select
label="Remind Attendees"
value={field.value}
onChange={(event) => field.onChange(Number(event.target.value))}
>
{CALENDAR_REMINDER_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="eventColor"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Calendar Color</InputLabel>
<Select label="Calendar Color" value={field.value} onChange={field.onChange}>
{COLORS.map((color) => (
<MenuItem key={color} value={color}>
{color}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
{!allDay && (
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="Start Date"
type="date"
fullWidth
InputLabelProps={{ shrink: true }}
error={Boolean(errors.startDate)}
helperText={errors.startDate?.message}
{...register("startDate")}
/>
<Controller
name="startTime"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>Start Time</InputLabel>
<Select label="Start Time" value={field.value} onChange={field.onChange}>
{CALENDAR_TIME_OPTIONS.map((time) => (
<MenuItem key={time} value={time}>
{time}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
)}
{!allDay && (
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
label="End Date"
type="date"
fullWidth
InputLabelProps={{ shrink: true }}
error={Boolean(errors.endDate)}
helperText={errors.endDate?.message}
{...register("endDate")}
/>
<Controller
name="endTime"
control={control}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>End Time</InputLabel>
<Select label="End Time" value={field.value} onChange={field.onChange}>
{CALENDAR_TIME_OPTIONS.map((time) => (
<MenuItem key={time} value={time}>
{time}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
)}
<Controller
name="allDay"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox checked={field.value} onChange={field.onChange} />}
label="All day event"
/>
)}
/>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button
variant="outlined"
onClick={() => navigate("/calendar")}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "../_components/event-form";

View file

@ -0,0 +1,59 @@
import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import type { DatesSetArg, EventClickArg } from "@fullcalendar/core";
import type { DateClickArg } from "@fullcalendar/interaction";
import FullCalendar from "@fullcalendar/react";
import { Box } from "@mui/material";
import { CalendarActionBar } from "@/components/common/calendar/calendar-action-bar";
import { EventCalendar } from "@/components/common/calendar/event-calendar";
import { mapEventToFullCalendar } from "@/components/common/calendar/calendar-utils";
import { useCalendarEvents } from "@/domain/calendar/use-cases/use-calendar-events";
export default function CalendarPage() {
const navigate = useNavigate();
const calendarRef = useRef<FullCalendar>(null);
const [range, setRange] = useState<{ start: Date; end: Date } | null>(null);
const [title, setTitle] = useState("");
const { data: events = [], isFetching } = useCalendarEvents(
range?.start ?? null,
range?.end ?? null,
);
const fullCalendarEvents = useMemo(() => events.map(mapEventToFullCalendar), [events]);
const handleDatesSet = (info: DatesSetArg) => {
setRange({ start: info.start, end: info.end });
setTitle(info.view.title);
};
const handleEventClick = (clickInfo: EventClickArg) => {
navigate(`/calendar/edit/${clickInfo.event.id}`);
};
const handleDateClick = (dateInfo: DateClickArg) => {
navigate(`/calendar/new?date=${dateInfo.dateStr}`);
};
const calendarApi = () => calendarRef.current?.getApi();
return (
<Box className="p-4">
<CalendarActionBar
title={title}
onTodayClick={() => calendarApi()?.today()}
onPrevClick={() => calendarApi()?.prev()}
onNextClick={() => calendarApi()?.next()}
onViewChange={(view) => calendarApi()?.changeView(view)}
/>
<EventCalendar
ref={calendarRef}
events={fullCalendarEvents}
loading={isFetching}
onDatesSet={handleDatesSet}
onEventClick={handleEventClick}
onDateClick={handleDateClick}
/>
</Box>
);
}

View file

@ -0,0 +1 @@
export { default } from "./_components/event-form";

View file

@ -0,0 +1,70 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
mapCalendarEvent,
mapCalendarEvents,
mapCalendarEventToBackend,
} from "@/domain/calendar/mappers/calendar-event-mapper";
import type {
CalendarEvent,
CalendarEventsListParams,
CalendarEventsListResult,
} from "@/domain/calendar/types/calendar-event";
export const calendarApi = {
getEventsForCalendar: async (start: Date, end: Date): Promise<CalendarEvent[]> => {
const data = await apiGet<unknown>(API_PATHS.calendar.getEventsForCalendar, {
searchParams: {
start: start.toISOString(),
end: end.toISOString(),
},
});
return mapCalendarEvents(handleApiResponse<unknown[]>(data));
},
getList: async (params: CalendarEventsListParams = {}): Promise<CalendarEventsListResult> => {
const data = await apiGet<unknown>(API_PATHS.calendar.getEventList, {
searchParams: {
search: params.search ?? "",
page: params.page ?? 1,
pageSize: params.pageSize ?? 12,
},
});
const nested = handleApiResponse<unknown>(data);
const paginated = handlePaginatedResponse<unknown>(nested, {
page: params.page,
pageSize: params.pageSize,
});
return {
items: paginated.items.map(mapCalendarEvent),
totalCount: paginated.totalCount,
};
},
getById: async (id: string | number): Promise<CalendarEvent> => {
const data = await apiGet<unknown>(`${API_PATHS.calendar.getEventById}/${id}`);
return mapCalendarEvent(handleApiResponse(data));
},
create: async (payload: unknown): Promise<CalendarEvent> => {
const data = await apiPost<unknown>(
API_PATHS.calendar.addEvent,
mapCalendarEventToBackend(payload as CalendarEvent),
);
return mapCalendarEvent(handleApiResponse(data));
},
update: async (id: string | number, payload: unknown): Promise<CalendarEvent> => {
const data = await apiPost<unknown>(API_PATHS.calendar.editEvent, {
...mapCalendarEventToBackend(payload as CalendarEvent),
Id: Number(id),
});
return mapCalendarEvent(handleApiResponse(data));
},
delete: async (id: string | number): Promise<void> => {
await apiDelete<unknown>(`${API_PATHS.calendar.deleteEvent}/${id}`);
},
};

View file

@ -0,0 +1,88 @@
import type { CalendarEvent, CalendarEventColor } from "@/domain/calendar/types/calendar-event";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string") {
return value;
}
}
return "";
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "number") {
return value;
}
}
return null;
}
function readBoolean(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = record[key];
if (typeof value === "boolean") {
return value;
}
}
return false;
}
const COLORS: CalendarEventColor[] = ["primary", "success", "info", "warning", "danger"];
function normalizeColor(value: string): CalendarEventColor {
return COLORS.includes(value as CalendarEventColor) ? (value as CalendarEventColor) : "primary";
}
export function mapCalendarEvent(raw: unknown): CalendarEvent {
const record = isRecord(raw) ? raw : {};
return {
id: readNumber(record, "id", "Id") ?? 0,
title: readString(record, "title", "Title"),
description: readString(record, "description", "Description"),
location: readString(record, "location", "Location"),
recordId: readNumber(record, "recordID", "RecordID", "recordId", "RecordId"),
recordName: readString(record, "recordName", "RecordName"),
recordTargetType: readString(record, "recordTargetType", "RecordTargetType"),
reminderMinutes: readNumber(record, "reminderMinutes", "ReminderMinutes") ?? 15,
eventColor: normalizeColor(readString(record, "eventColor", "EventColor") || "primary"),
startDate: readString(record, "startDate", "StartDate"),
startTime: readString(record, "startTime", "StartTime"),
endDate: readString(record, "endDate", "EndDate"),
endTime: readString(record, "endTime", "EndTime"),
allDay: readBoolean(record, "allDay", "AllDay"),
};
}
export function mapCalendarEvents(raw: unknown): CalendarEvent[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.map(mapCalendarEvent);
}
export function mapCalendarEventToBackend(event: Partial<CalendarEvent> & { id?: number }) {
return {
...(event.id ? { Id: event.id } : {}),
Title: event.title ?? "",
Description: event.description ?? "",
Location: event.location ?? "",
RecordID: event.recordId ?? null,
RecordName: event.recordName ?? "",
RecordTargetType: event.recordTargetType ?? "",
ReminderMinutes: event.reminderMinutes ?? 15,
EventColor: event.eventColor ?? "primary",
StartDate: event.startDate ?? "",
StartTime: event.startTime ?? "",
EndDate: event.endDate ?? "",
EndTime: event.endTime ?? "",
AllDay: event.allDay ?? false,
};
}

View file

@ -0,0 +1,19 @@
import { z } from "zod";
export const calendarEventSchema = z.object({
title: z.string().min(1, "Title is required").max(100),
description: z.string().optional(),
location: z.string().max(150).optional(),
recordId: z.number().nullable().optional(),
recordName: z.string().optional(),
recordTargetType: z.string().optional(),
reminderMinutes: z.number().int().min(0),
eventColor: z.enum(["primary", "success", "info", "warning", "danger"]),
startDate: z.string().min(1, "Start date is required"),
startTime: z.string().optional(),
endDate: z.string().min(1, "End date is required"),
endTime: z.string().optional(),
allDay: z.boolean(),
});
export type CalendarEventFormValues = z.infer<typeof calendarEventSchema>;

View file

@ -0,0 +1,29 @@
export type CalendarEventColor = "primary" | "success" | "info" | "warning" | "danger";
export interface CalendarEvent {
id: number;
title: string;
description: string;
location: string;
recordId: number | null;
recordName: string;
recordTargetType: string;
reminderMinutes: number;
eventColor: CalendarEventColor;
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allDay: boolean;
}
export interface CalendarEventsListParams {
search?: string;
page?: number;
pageSize?: number;
}
export interface CalendarEventsListResult {
items: CalendarEvent[];
totalCount: number;
}

View file

@ -0,0 +1,13 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEvent } from "@/domain/calendar/types/calendar-event";
import { queryKeys } from "@/infra/query-key/query-key";
import { requireQueryParam } from "@/lib/query/require-query-param";
export function useCalendarEventDetail(id?: string): UseQueryResult<CalendarEvent, Error> {
return useQuery({
queryKey: queryKeys.calendar.detail(id ?? ""),
queryFn: () => calendarApi.getById(requireQueryParam(id, "Calendar event id")),
enabled: Boolean(id),
});
}

View file

@ -0,0 +1,20 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEvent } from "@/domain/calendar/types/calendar-event";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCalendarEvents(
start: Date | null,
end: Date | null,
): UseQueryResult<CalendarEvent[], Error> {
return useQuery({
queryKey: queryKeys.calendar.range(start?.toISOString() ?? "", end?.toISOString() ?? ""),
queryFn: () => {
if (!start || !end) {
return Promise.resolve([]);
}
return calendarApi.getEventsForCalendar(start, end);
},
enabled: Boolean(start && end),
});
}

View file

@ -0,0 +1,28 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema";
import type { CalendarEvent } from "@/domain/calendar/types/calendar-event";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateCalendarEvent(): UseMutationResult<
CalendarEvent,
Error,
CalendarEventFormValues
> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: CalendarEventFormValues) => calendarApi.create(values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.calendar.all });
toast.success("Event created successfully!");
navigate("/calendar");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create event");
},
});
}

View file

@ -0,0 +1,19 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { calendarApi } from "@/domain/calendar/api/calendar-api";
import { queryKeys } from "@/infra/query-key/query-key";
export function useDeleteCalendarEvent(): UseMutationResult<void, Error, number> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => calendarApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.calendar.all });
toast.success("Event deleted successfully!");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete event");
},
});
}

View file

@ -0,0 +1,34 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { calendarApi } from "@/domain/calendar/api/calendar-api";
import type { CalendarEventFormValues } from "@/domain/calendar/schemas/calendar-event-schema";
import type { CalendarEvent } from "@/domain/calendar/types/calendar-event";
import { queryKeys } from "@/infra/query-key/query-key";
interface UpdateCalendarEventInput {
id: string;
values: CalendarEventFormValues;
}
export function useUpdateCalendarEvent(): UseMutationResult<
CalendarEvent,
Error,
UpdateCalendarEventInput
> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, values }: UpdateCalendarEventInput) => calendarApi.update(id, values),
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.calendar.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.calendar.detail(variables.id) });
toast.success("Event updated successfully!");
navigate("/calendar");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update event");
},
});
}