diff --git a/src/app/(app)/settings/backoffice/events/page.tsx b/src/app/(app)/settings/backoffice/events/page.tsx new file mode 100644 index 0000000..1e157a6 --- /dev/null +++ b/src/app/(app)/settings/backoffice/events/page.tsx @@ -0,0 +1,869 @@ +"use client"; + +import { AuthCheck } from "@/components/auth-check"; +import CustomCombobox from "@/components/combobox"; +import Input from "@/components/input"; +import Label from "@/components/label"; +import Modal from "@/components/modal"; +import SettingsWrapper from "@/components/settings-wrapper"; +import Table, { + HeaderElement, + TableCell, + TableContent, + TableHeader, + TableItemWrapper, +} from "@/components/table"; +import { + useCreateCategory, + useCreateEvent, + useDeleteCategory, + useDeleteEvent, + useEditCategory, + useEditEvent, +} from "@/lib/mutations/events"; +import { useGetAllCourses } from "@/lib/queries/courses"; +import { useGetCategories, useGetEvents } from "@/lib/queries/events"; +import { ICourse, IEventCategory, IEventResponse } from "@/lib/types"; +import { isAllDay, isAllDayEvent } from "@/lib/utils"; +import { Switch } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import clsx from "clsx"; +import moment from "moment"; +import Link from "next/link"; +import { createContext, useContext, useRef, useState } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { twMerge } from "tailwind-merge"; +import z from "zod"; + +interface IModalStateProps { + isOpen: boolean; + type: + | "editCategory" + | "editEvent" + | "newCategory" + | "newEvent" + | "delete" + | null; + data: IEventCategory | IEventResponse | null; +} + +interface IEManagementProvider { + onClose: () => void; + onOpen: ( + type: + | "editCategory" + | "editEvent" + | "newCategory" + | "newEvent" + | "delete" + | null, + data: IEventCategory | IEventResponse | null, + ) => void; + modalState: IModalStateProps; + categories: IEventCategory[]; +} + +interface IInputLineProps extends React.InputHTMLAttributes { + label: string; + placeholder?: string; + isColor?: boolean; + colorValue?: string; + disabled?: boolean; + className?: string; + errorMessage?: string; +} + +function formatItems(items: ICourse[] | IEventCategory[] | undefined) { + if (!items) return []; + + return items.map((item) => { + return { id: item.id, name: item.name }; + }); +} + +function InputLine({ + label, + placeholder, + isColor, + colorValue, + disabled = false, + className, + errorMessage, + onChange, + ...rest +}: IInputLineProps) { + return ( +
+ + +
+ {isColor && ( +
+ )} + + input]:!text-black [&>input:invalid]:!text-black" + : "", + )} + {...rest} + /> +
+ + {errorMessage} +
+ ); +} + +function DeleteModalLayout() { + const deleteCategory = useDeleteCategory(); + const deleteEvent = useDeleteEvent(); + const { onClose, modalState } = useContext(EventsManagementContext); + + if (!modalState.data) return null; + + const isEvent = "category" in modalState.data; + + const handleDelete = () => { + if (!isEvent && modalState.data?.id) { + deleteCategory.mutate(modalState.data.id); + } else if (modalState.data?.id) { + deleteEvent.mutate(modalState.data?.id); + } + onClose(); + }; + + const name = isEvent + ? (modalState.data as IEventResponse).title + : (modalState.data as IEventCategory).name; + + return ( +
+
+

+ Are you sure you want to delete this {isEvent ? "event" : "category"}? +

+
+ {isEvent ? ( + <> + Event: + {name} + + ) : ( +
+ error +

{`Deleting category ${name} will remove all events linked to it`}

+
+ )} +
+
+
+ + +
+
+ ); +} + +function CategoryModalLayout() { + const { data: courses } = useGetAllCourses(); + const editCategory = useEditCategory(); + const createCategory = useCreateCategory(); + + const { onClose, modalState } = useContext(EventsManagementContext); + + const isNew = modalState.type === "newCategory"; + const category = modalState.data as IEventCategory; + + const formSchema = z.object({ + name: z.string().max(72, { + message: "The name should be smaller than 72 characters", + }), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/, { + message: "Invalid color format!(e.g., #RRGGBB)", + }), + }); + + type FormSchema = z.infer; + + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: isNew ? "" : category?.name || "", + color: isNew ? "#EE7749" : category?.color || "#EE7749", + }, + }); + + const colorValue = watch("color"); + + const handleSave: SubmitHandler = (data) => { + if (!isNew && modalState.data?.id) { + editCategory.mutate({ + id: modalState.data?.id, + category: { + name: data.name, + color: data.color, + course_id: selectedCourse?.id || "", + type: enabled ? "mandatory" : "optional", + }, + }); + } else { + createCategory.mutate({ + name: data.name, + color: data.color, + course_id: selectedCourse?.id || "", + type: enabled ? "mandatory" : "optional", + }); + } + onClose(); + }; + + const [selectedCourse, setSelectedCourse] = useState<{ + id: string; + name: string; + } | null>( + modalState.data && category?.course + ? { id: category.course.id, name: category.course.name } + : { id: "", name: "None" }, + ); + + const [enabled, setEnabled] = useState( + modalState.data && category?.type === "mandatory" ? true : false, + ); + + return ( +
+
+

+ {isNew + ? "Creating a new event category" + : "Currently editing a category"} +

+ {modalState.data && !isNew && ( +

+ Category: {category?.name} +

+ )} +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+ +
+ + +
+ + + +
+
+
+
+ +
+ + +
+
+ ); +} + +function EventModalLayout() { + const editEvent = useEditEvent(); + const createEvent = useCreateEvent(); + const formRef = useRef(null); + + const { onClose, modalState, categories } = useContext( + EventsManagementContext, + ); + + const isNew = modalState.type === "newEvent"; + + const event = modalState.data as IEventResponse; + const start = !isNew ? moment.utc(event?.start) : null; + const end = !isNew ? moment.utc(event?.end) : null; + + const formSchema = z + .object({ + name: z.string().max(72, { + message: "The name should be smaller than 72 characters", + }), + start: z.date(), + end: z.date(), + place: z.string().optional(), + link: z + .string() + .optional() + .refine((val) => { + if (!val) return true; + try { + new URL(val); + return true; + } catch { + return false; + } + }, "Must be a valid URL starting with http:// or https://") + .refine( + (val) => !val || val.length <= 72, + "The link should be smaller than 72 characters", + ), + }) + .refine((data) => data.end >= data.start, { + message: "End date must be equal or after start date", + path: ["end"], + }); + + type FormSchema = z.infer; + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: isNew ? "" : event?.title || "", + place: isNew ? "" : event?.place || "", + link: isNew ? "" : event?.link || "", + }, + }); + + const handleSave: SubmitHandler = (data) => { + if (!isNew && modalState.data?.id) { + editEvent.mutate({ + id: modalState.data?.id, + event: { + title: data.name, + start: moment.utc(data.start).format("YYYY-MM-DDTHH:mm:ss[Z]"), + end: moment.utc(data.end).format("YYYY-MM-DDTHH:mm:ss[Z]"), + place: data.place || "", + link: data.link || "", + category_id: selectedCategory!.id, + }, + }); + } else { + createEvent.mutate({ + title: data.name, + start: moment.utc(data.start).format("YYYY-MM-DDTHH:mm:ss[Z]"), + end: moment.utc(data.end).format("YYYY-MM-DDTHH:mm:ss[Z]"), + place: data.place || "", + link: data.link || "", + category_id: selectedCategory!.id, + }); + } + onClose(); + }; + + const [selectedCategory, setSelectedCategory] = useState<{ + id: string; + name: string; + } | null>( + modalState.data && event?.category + ? { id: event.category.id, name: event.category.name } + : null, + ); + + const [enabled, setEnabled] = useState(isAllDayEvent(start, end)); + + return ( +
+
+

+ {isNew ? "Creating a new Event" : "Currently editing an event"} +

+ {modalState.data && !isNew && ( +

+ Event: {event?.title} +

+ )} +
+ +
+
+
+ + +
+ + + +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ + + +
+
+
+ +
+ + +
+
+
+ ); +} + +function ActionsBox({ + type, + data, +}: { + type: "category" | "event"; + data: IModalStateProps["data"]; +}) { + const { onOpen } = useContext(EventsManagementContext); + + return ( +
+ + +
+ ); +} + +function ColorTagCard({ color }: { color: string }) { + return ( +
+ {color} +
+ ); +} + +function DateCard({ start, end }: { start: string; end: string }) { + const startFormatted = moment.utc(start); + const endFormatted = moment.utc(end); + + return ( +
+

{startFormatted.format("DD/MM/YYYY")}

+

{endFormatted.format("DD/MM/YYYY")}

+
+ ); +} + +function TimeCard({ start, end }: { start: string; end: string }) { + const startFormatted = moment.utc(start); + const endFormatted = moment.utc(end); + + return ( +
+ {isAllDay(startFormatted, endFormatted) ? ( + "All Day" + ) : ( +

{`${startFormatted.format("HH:mm")} - ${endFormatted.format("HH:mm")}`}

+ )} +
+ ); +} + +function CategoryItem({ category }: { category: IEventCategory }) { + return ( + + {category.name} + + + + {category.course?.name} + + {category.type === "mandatory" ? "Yes" : "No"} + + + + + + ); +} + +function EventItem({ event }: { event: IEventResponse }) { + return ( + + +
+ {event.title} + {event.link && ( + + {event.link} + + )} +
+
+ {event.category?.name} + + + + + + + {`${event.place ? event.place : "none"}`} + + + +
+ ); +} + +const EventsManagementContext = createContext({ + onClose: () => {}, + onOpen: () => {}, + modalState: { isOpen: false, type: null, data: null }, + categories: [], +}); + +export default function EventsManagement() { + const { data: categories = [] } = useGetCategories(); + const { data: events = [] } = useGetEvents(); + + const [modalState, setModalState] = useState({ + isOpen: false, + type: null, + data: null, + }); + + const onClose = () => { + setModalState({ + isOpen: false, + type: modalState.type, + data: modalState.data, + }); + }; + + const onOpen = ( + type: IModalStateProps["type"], + data: IModalStateProps["data"], + ) => { + setModalState({ isOpen: true, type: type, data: data }); + }; + + return ( + <> + Events & Categories | Pombo + + + +
+
+
+
+

Event Categories

+

Manage categories for your calendar events

+
+ + +
+ + + + + + + + + + + + {categories && categories.length > 0 ? ( + categories.map((category) => ( + + )) + ) : ( + + + + )} + +
+

+ No categories +

+
+
+ +
+
+
+

Calendar Events

+

Create and manage events for your calendar

+
+ + +
+ + + + + + + + + + + + + {events && events.length > 0 ? ( + events.map((event) => ( + + )) + ) : ( + + + + )} + +
+

No events

+
+
+
+ + + setModalState({ + isOpen: false, + type: modalState.type, + data: modalState.data, + }) + } + className={ + modalState.type && + [ + "newCategory", + "editCategory", + "newEvent", + "editEvent", + ].includes(modalState.type) + ? "max-w-2xl" + : "" + } + > + {modalState.type == "delete" && } + {(modalState.type == "editCategory" || + modalState.type == "newCategory") && } + {(modalState.type == "editEvent" || + modalState.type == "newEvent") && } + +
+
+
+ + ); +} diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx index 195c8a7..d7fa305 100644 --- a/src/components/combobox.tsx +++ b/src/components/combobox.tsx @@ -17,6 +17,8 @@ interface ICustomCombobox { setSelectedItem: (item: IItemProps | null) => void; className?: string; placeholder?: string; + disableFlip?: boolean; + required?: boolean; } export default function CustomCombobox({ @@ -25,6 +27,8 @@ export default function CustomCombobox({ setSelectedItem, className, placeholder, + disableFlip = false, + required = false, }: ICustomCombobox) { const [query, setQuery] = useState(""); @@ -47,8 +51,9 @@ export default function CustomCombobox({ placeholder={placeholder} displayValue={(item: IItemProps) => item?.name || ""} onChange={(event) => setQuery(event.target.value)} + required={required} className={clsx( - "bg-muted border-dark/10 w-full rounded-lg border py-1.5 pr-8 pl-3 text-sm/6", + "bg-muted border-dark/10 w-full rounded-xl border py-1.5 pr-8 pl-3 text-sm/6", "focus:not-data-focus:outline-none data-focus:outline-2 data-focus:-outline-offset-2 data-focus:outline-white/25", )} /> @@ -60,11 +65,15 @@ export default function CustomCombobox({
{filteredItems.map((item) => ( diff --git a/src/components/input.tsx b/src/components/input.tsx index 9dc4847..5f2f57e 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -10,7 +10,6 @@ interface IInputProps extends React.InputHTMLAttributes { export default function Input({ type, className, - value, center_text, min, max, @@ -27,18 +26,16 @@ export default function Input({ clsx( className, textAlignment, - "flex items-center rounded-xl border border-black/10 px-2 py-1.5 text-black outline-none md:px-3 md:py-2.5", + "flex w-full items-center rounded-xl border border-black/10 px-2 py-1.5 text-black outline-none md:px-3 md:py-2.5", ), )} > void; + children: React.ReactNode; + className?: string; +} + +export default function Modal({ + modalState, + onClose, + children, + className, +}: IModalProps) { + return ( + + + +
+ +
+ + +
+ {children} +
+
+
+
+
+
+ ); +} diff --git a/src/components/sidebar-settings.tsx b/src/components/sidebar-settings.tsx index d62abc0..df377c3 100644 --- a/src/components/sidebar-settings.tsx +++ b/src/components/sidebar-settings.tsx @@ -34,50 +34,74 @@ export default function SidebarSettings() { - {user.data && ["admin", "professor"].includes(user.data.type) && ( - <> - Backoffice + {user.data && + ["admin", "professor", "department"].includes(user.data.type) && ( + <> + Backoffice - - - - + + {["admin", "professor"].includes(user.data.type) && ( + <> + + + - - - + + + - - - + + + - - - + + + + + )} - {user.data && user.data.type === "admin" && ( - <> - - - + {user.data && user.data.type === "admin" && ( + <> + + + - - - - - )} - - - )} + + + + + )} + + + + + + + )} ); diff --git a/src/components/table.tsx b/src/components/table.tsx new file mode 100644 index 0000000..c9f00ee --- /dev/null +++ b/src/components/table.tsx @@ -0,0 +1,61 @@ +import clsx from "clsx"; + +interface IParentProps { + children: React.ReactNode; + className?: string; +} + +export function HeaderElement({ + title, + className, +}: { + title: string; + className?: string; +}) { + return ( + + {title} + + ); +} + +export function TableHeader({ children, className }: IParentProps) { + return ( + + {children} + + ); +} + +export function TableContent({ children, className }: IParentProps) { + return ( + + {children} + + ); +} + +export function TableItemWrapper({ children, className }: IParentProps) { + return {children}; +} + +export function TableCell({ children, className }: IParentProps) { + return {children}; +} + +export default function Table({ children, className }: IParentProps) { + return ( +
+ {children}
+
+ ); +} diff --git a/src/contexts/events-provider.tsx b/src/contexts/events-provider.tsx index d09a9b5..4c46f45 100644 --- a/src/contexts/events-provider.tsx +++ b/src/contexts/events-provider.tsx @@ -12,7 +12,7 @@ import { IEventCategory, IEventResponse, } from "@/lib/types"; -import { getContrastColor } from "@/lib/utils"; +import { getContrastColor, isAllDayEvent } from "@/lib/utils"; import moment from "moment"; import { createContext, useEffect, useState } from "react"; @@ -106,14 +106,6 @@ function formatEvents(events: IEventResponse[]) { const start = moment.utc(event.start); const end = moment.utc(event.end); - const startsAtMidnight = - start.hours() === 0 && start.minutes() === 0 && start.seconds() === 0; - const endsAtMidnight = - end.hours() === 0 && end.minutes() === 0 && end.seconds() === 0; - const allday = - (startsAtMidnight && endsAtMidnight) || - end.diff(start, "days", true) >= 1; - return { id: event.id, title: event.title, @@ -124,7 +116,7 @@ function formatEvents(events: IEventResponse[]) { link: event.link, eventColor: event.category.color, textColor: getContrastColor(event.category.color, 5), - allDay: allday, + allDay: isAllDayEvent(start, end), }; }); } diff --git a/src/lib/events.ts b/src/lib/events.ts index f81ed46..d47e1e0 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,5 +1,10 @@ import { api } from "./api"; -import { IEventResponse } from "./types"; +import { + IEventCategory, + IEventCategoryRequest, + IEventRequest, + IEventResponse, +} from "./types"; export async function getEvents() { try { @@ -10,6 +15,32 @@ export async function getEvents() { } } +export async function deleteEvent(id: string) { + try { + await api.delete(`/events/${id}`); + } catch { + throw new Error(`Failed to delete event with id: ${id}.`); + } +} + +export async function editEvent(id: string, event: IEventRequest) { + try { + const res = await api.patch(`/events/${id}`, { event }); + return res.data.event; + } catch { + throw new Error(`Failed to edit event with id: ${id}`); + } +} + +export async function createEvent(event: IEventRequest) { + try { + const res = await api.post("/events", { event }); + return res.data.event; + } catch { + throw new Error("Failed to create new event."); + } +} + export async function getSelectedEvents() { try { const res = await api.get<{ events: IEventResponse[] }>("/events/selected"); @@ -32,7 +63,9 @@ export async function getEventById(id: string) { export async function getCategories() { try { - const res = await api.get("/event_categories"); + const res = await api.get<{ event_categories: IEventCategory[] }>( + "/event_categories", + ); return res.data.event_categories; } catch { throw new Error(`Failed to fetch categories. Please try again later.`); @@ -61,6 +94,35 @@ export async function getSelectedCateries() { } } +export async function deleteCategory(id: string) { + try { + await api.delete(`/event_categories/${id}`); + } catch { + throw new Error(`Failed to delete category with id: ${id}.`); + } +} + +export async function editCategory( + id: string, + event_category: IEventCategoryRequest, +) { + try { + const res = await api.patch(`/event_categories/${id}`, { event_category }); + return res.data.event_category; + } catch { + throw new Error(`Failed to edit category with id: ${id}`); + } +} + +export async function createCategory(event_category: IEventCategoryRequest) { + try { + const res = await api.post("/event_categories", { event_category }); + return res.data.event_category; + } catch { + throw new Error("Failed to create new category."); + } +} + export async function updateStudentCategories({ event_categories, }: { diff --git a/src/lib/mutations/events.ts b/src/lib/mutations/events.ts index 1308a75..6dac5fc 100644 --- a/src/lib/mutations/events.ts +++ b/src/lib/mutations/events.ts @@ -1,5 +1,19 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { updateStudentCategories } from "../events"; +import { + createCategory, + createEvent, + deleteCategory, + deleteEvent, + editCategory, + editEvent, + updateStudentCategories, +} from "../events"; +import { + IEventCategory, + IEventCategoryRequest, + IEventRequest, + IEventResponse, +} from "../types"; export function useUpdateStudentCategories() { const qc = useQueryClient(); @@ -12,3 +26,83 @@ export function useUpdateStudentCategories() { }, }); } + +export function useDeleteCategory() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: deleteCategory, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["categories"] }); + qc.invalidateQueries({ queryKey: ["events"] }); + }, + }); +} + +export function useEditCategory() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + category, + }: { + id: string; + category: IEventCategoryRequest; + }) => editCategory(id, category), + onSuccess: (data) => { + qc.setQueryData(["categories"], (old) => { + if (!old) return [data]; + return old.map((cat) => (cat.id === data.id ? data : cat)); + }); + }, + }); +} + +export function useCreateCategory() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: createCategory, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["categories"] }); + }, + }); +} + +export function useDeleteEvent() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: deleteEvent, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["events"] }); + }, + }); +} + +export function useEditEvent() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, event }: { id: string; event: IEventRequest }) => + editEvent(id, event), + onSuccess: (data) => { + qc.setQueryData(["events"], (old) => { + if (!old) return [data]; + return old.map((event) => (event.id === data.id ? data : event)); + }); + }, + }); +} + +export function useCreateEvent() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: createEvent, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["events"] }); + }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6118d05..0804539 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -102,6 +102,10 @@ export interface IEventResponse { link?: string; } +export type IEventRequest = Omit & { + category_id: string; +}; + export interface IJobProps { id: number; type: string; @@ -115,11 +119,15 @@ export interface IJobProps { export interface IEventCategory { id: string; name: string; + type: "optional" | "mandatory"; color: string; course?: ICourse; - type: "optional" | "mandatory"; } +export type IEventCategoryRequest = Omit & { + course_id: string; +}; + export type IEventCategoriesSorted = { year?: number; categories: IEventCategory[]; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 1b90d0e..bea39ca 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -50,3 +50,33 @@ export function getContrastColor(baseColor: string, targetRatio: number = 5) { return contrastColor || (direction === "darken" ? "#000" : "#fff"); } + +export function isAllDay( + start: moment.Moment | null, + end: moment.Moment | null, +): boolean { + if (!start || !end) return false; + + const startsAtMidnight = + start.hours() === 0 && start.minutes() === 0 && start.seconds() === 0; + const endsAtMidnight = + end.hours() === 0 && end.minutes() === 0 && end.seconds() === 0; + + return startsAtMidnight && endsAtMidnight; +} + +export function isMultipleDay( + start: moment.Moment | null, + end: moment.Moment | null, +): boolean { + if (!start || !end) return false; + + return end.diff(start, "days", true) >= 1; +} + +export function isAllDayEvent( + start: moment.Moment | null, + end: moment.Moment | null, +): boolean { + return isAllDay(start, end) || isMultipleDay(start, end); +}