diff --git a/src/app/(protected)/calendar/_components/event-form.tsx b/src/app/(protected)/calendar/_components/event-form.tsx new file mode 100644 index 00000000..92a21854 --- /dev/null +++ b/src/app/(protected)/calendar/_components/event-form.tsx @@ -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({ + 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 ( + + + + ); + } + + return ( + + + + {isEdit ? "Edit Event" : "Create Event"} + + {error && Failed to load event} + + + + + + + + + + ( + + Remind Attendees + + + )} + /> + + ( + + Calendar Color + + + )} + /> + + {!allDay && ( + + + ( + + Start Time + + + )} + /> + + )} + + {!allDay && ( + + + ( + + End Time + + + )} + /> + + )} + + ( + } + label="All day event" + /> + )} + /> + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/calendar/edit/[id].tsx b/src/app/(protected)/calendar/edit/[id].tsx new file mode 100644 index 00000000..751eb3ca --- /dev/null +++ b/src/app/(protected)/calendar/edit/[id].tsx @@ -0,0 +1 @@ +export { default } from "../_components/event-form"; diff --git a/src/app/(protected)/calendar/index.tsx b/src/app/(protected)/calendar/index.tsx new file mode 100644 index 00000000..93da0024 --- /dev/null +++ b/src/app/(protected)/calendar/index.tsx @@ -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(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 ( + + calendarApi()?.today()} + onPrevClick={() => calendarApi()?.prev()} + onNextClick={() => calendarApi()?.next()} + onViewChange={(view) => calendarApi()?.changeView(view)} + /> + + + ); +} diff --git a/src/app/(protected)/calendar/new.tsx b/src/app/(protected)/calendar/new.tsx new file mode 100644 index 00000000..1aef5075 --- /dev/null +++ b/src/app/(protected)/calendar/new.tsx @@ -0,0 +1 @@ +export { default } from "./_components/event-form"; diff --git a/src/domain/calendar/api/calendar-api.ts b/src/domain/calendar/api/calendar-api.ts new file mode 100644 index 00000000..2edf9694 --- /dev/null +++ b/src/domain/calendar/api/calendar-api.ts @@ -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 => { + const data = await apiGet(API_PATHS.calendar.getEventsForCalendar, { + searchParams: { + start: start.toISOString(), + end: end.toISOString(), + }, + }); + return mapCalendarEvents(handleApiResponse(data)); + }, + + getList: async (params: CalendarEventsListParams = {}): Promise => { + const data = await apiGet(API_PATHS.calendar.getEventList, { + searchParams: { + search: params.search ?? "", + page: params.page ?? 1, + pageSize: params.pageSize ?? 12, + }, + }); + const nested = handleApiResponse(data); + const paginated = handlePaginatedResponse(nested, { + page: params.page, + pageSize: params.pageSize, + }); + + return { + items: paginated.items.map(mapCalendarEvent), + totalCount: paginated.totalCount, + }; + }, + + getById: async (id: string | number): Promise => { + const data = await apiGet(`${API_PATHS.calendar.getEventById}/${id}`); + return mapCalendarEvent(handleApiResponse(data)); + }, + + create: async (payload: unknown): Promise => { + const data = await apiPost( + API_PATHS.calendar.addEvent, + mapCalendarEventToBackend(payload as CalendarEvent), + ); + return mapCalendarEvent(handleApiResponse(data)); + }, + + update: async (id: string | number, payload: unknown): Promise => { + const data = await apiPost(API_PATHS.calendar.editEvent, { + ...mapCalendarEventToBackend(payload as CalendarEvent), + Id: Number(id), + }); + return mapCalendarEvent(handleApiResponse(data)); + }, + + delete: async (id: string | number): Promise => { + await apiDelete(`${API_PATHS.calendar.deleteEvent}/${id}`); + }, +}; diff --git a/src/domain/calendar/mappers/calendar-event-mapper.ts b/src/domain/calendar/mappers/calendar-event-mapper.ts new file mode 100644 index 00000000..788d0b5b --- /dev/null +++ b/src/domain/calendar/mappers/calendar-event-mapper.ts @@ -0,0 +1,88 @@ +import type { CalendarEvent, CalendarEventColor } from "@/domain/calendar/types/calendar-event"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(record: Record, ...keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return ""; +} + +function readNumber(record: Record, ...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, ...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 & { 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, + }; +} diff --git a/src/domain/calendar/schemas/calendar-event-schema.ts b/src/domain/calendar/schemas/calendar-event-schema.ts new file mode 100644 index 00000000..adc6545f --- /dev/null +++ b/src/domain/calendar/schemas/calendar-event-schema.ts @@ -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; diff --git a/src/domain/calendar/types/calendar-event.ts b/src/domain/calendar/types/calendar-event.ts new file mode 100644 index 00000000..8c82456f --- /dev/null +++ b/src/domain/calendar/types/calendar-event.ts @@ -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; +} diff --git a/src/domain/calendar/use-cases/use-calendar-event-detail.ts b/src/domain/calendar/use-cases/use-calendar-event-detail.ts new file mode 100644 index 00000000..55ed6e9c --- /dev/null +++ b/src/domain/calendar/use-cases/use-calendar-event-detail.ts @@ -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 { + return useQuery({ + queryKey: queryKeys.calendar.detail(id ?? ""), + queryFn: () => calendarApi.getById(requireQueryParam(id, "Calendar event id")), + enabled: Boolean(id), + }); +} diff --git a/src/domain/calendar/use-cases/use-calendar-events.ts b/src/domain/calendar/use-cases/use-calendar-events.ts new file mode 100644 index 00000000..a0de7576 --- /dev/null +++ b/src/domain/calendar/use-cases/use-calendar-events.ts @@ -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 { + 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), + }); +} diff --git a/src/domain/calendar/use-cases/use-create-calendar-event.ts b/src/domain/calendar/use-cases/use-create-calendar-event.ts new file mode 100644 index 00000000..b9fa46e5 --- /dev/null +++ b/src/domain/calendar/use-cases/use-create-calendar-event.ts @@ -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"); + }, + }); +} diff --git a/src/domain/calendar/use-cases/use-delete-calendar-event.ts b/src/domain/calendar/use-cases/use-delete-calendar-event.ts new file mode 100644 index 00000000..0f05604d --- /dev/null +++ b/src/domain/calendar/use-cases/use-delete-calendar-event.ts @@ -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 { + 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"); + }, + }); +} diff --git a/src/domain/calendar/use-cases/use-update-calendar-event.ts b/src/domain/calendar/use-cases/use-update-calendar-event.ts new file mode 100644 index 00000000..3d6430ef --- /dev/null +++ b/src/domain/calendar/use-cases/use-update-calendar-event.ts @@ -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"); + }, + }); +}