diff --git a/backend/cmd/migrate/migrations/20250210084555_add_events_columns.go b/backend/cmd/migrate/migrations/20250210084555_add_events_columns.go new file mode 100644 index 0000000..35ea0c7 --- /dev/null +++ b/backend/cmd/migrate/migrations/20250210084555_add_events_columns.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "context" + "fmt" + + "fr.latosa-escrima/core/models" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [up migration] ") + _, err := db.NewAddColumn(). + Model((*models.Event)(nil)). + ColumnExpr("full_day BOOLEAN NOT NULL DEFAULT FALSE"). + Exec(ctx) + if err != nil { + return err + } + + // Add "is_visible" column + _, err = db.NewAddColumn(). + Model((*models.Event)(nil)). + ColumnExpr("is_visible BOOLEAN NOT NULL DEFAULT TRUE"). + Exec(ctx) + if err != nil { + return err + } + + // Add "rrule" column + _, err = db.NewAddColumn(). + Model((*models.Event)(nil)). + ColumnExpr("rrule TEXT"). + Exec(ctx) + return err + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] ") + _, err := db.NewDropColumn(). + Model((*models.Event)(nil)). + Column("full_day"). + Exec(ctx) + if err != nil { + return err + } + + _, err = db.NewDropColumn(). + Model((*models.Event)(nil)). + Column("is_visible"). + Exec(ctx) + if err != nil { + return err + } + + _, err = db.NewDropColumn(). + Model((*models.Event)(nil)). + Column("rrule"). + Exec(ctx) + return err + }) +} diff --git a/backend/core/models/events.go b/backend/core/models/events.go index 7c5a5c7..ad3054b 100644 --- a/backend/core/models/events.go +++ b/backend/core/models/events.go @@ -7,13 +7,6 @@ import ( "github.com/uptrace/bun" ) -type Status string - -const ( - Active Status = "Active" - Inactive Status = "Inactive" -) - type Event struct { bun.BaseModel `bun:"table:events"` @@ -22,5 +15,7 @@ type Event struct { CreationDate time.Time `bun:"creation_date,notnull,default:current_timestamp" json:"creationDate"` ScheduleStart time.Time `bun:"schedule_start,notnull" json:"start"` ScheduleEnd time.Time `bun:"schedule_end,notnull" json:"end"` - Status Status `bun:"status,notnull,default:'Inactive'" json:"status"` + FullDay bool `bun:"full_day,notnull,default:false" json:"fullDay"` + IsVisible bool `bun:"is_visible,notnull,default:true" json:"isVisible"` + Rrule string `bun:"rrule" json:"rrule"` } diff --git a/frontend/components/date-time-picker.tsx b/frontend/components/date-time-picker.tsx deleted file mode 100644 index dabcc6d..0000000 --- a/frontend/components/date-time-picker.tsx +++ /dev/null @@ -1,114 +0,0 @@ -"use client" - -import * as React from "react"; -import { format } from "date-fns"; - -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; - -interface DateTimePickerProps { - onDateSelectChange: (selectedDate: Date | undefined) => void; -} - -export function DateTimePicker({ - onDateSelectChange -}: DateTimePickerProps) { - const [date, setDate] = React.useState(); - const [isOpen, setIsOpen] = React.useState(false); - // TODO : this is buggy as hell - - const hours = Array.from({ length: 24 }, (_, i) => i); - const handleDateSelect = (selectedDate: Date | undefined) => { - if (selectedDate) { - setDate(selectedDate); - onDateSelectChange(selectedDate) - } - }; - - const handleTimeChange = ( - type: "hour" | "minute", - value: string - ) => { - if (date) { - const newDate = new Date(date); - if (type === "hour") { - newDate.setHours(parseInt(value)); - } else if (type === "minute") { - newDate.setMinutes(parseInt(value)); - } - setDate(newDate); - } - }; - - return ( - - - - - -
- -
- -
- {hours.reverse().map((hour) => ( - - ))} -
- -
- -
- {Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => ( - - ))} -
- -
-
-
-
-
- ); -} diff --git a/frontend/components/event-dialog.tsx b/frontend/components/event-dialog.tsx new file mode 100644 index 0000000..5e132e3 --- /dev/null +++ b/frontend/components/event-dialog.tsx @@ -0,0 +1,286 @@ +"use client" + +import * as React from "react" +import { CalendarIcon } from "lucide-react" +import { format } from "date-fns" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Checkbox } from "@/components/ui/checkbox" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { useForm } from "react-hook-form" +import { + CalendarEventExternal, +} from "@schedule-x/calendar"; +import ICalendarEvent from "@/interfaces/ICalendarEvent" + +export const eventFormSchema = z.object({ + title: z.string().min(1, "Titre requis"), + startDate: z.date({ + required_error: "Date de début requise", + }), + startTime: z.string(), + endDate: z.date({ + required_error: "Date finale requise", + }), + endTime: z.string(), + fullDay: z.boolean().default(false), + frequency: z.enum(["unique", "quotidien", "hebdomadaire", "mensuel"]), + frequencyEndDate: z.date().optional(), + isVisible: z.boolean().default(true), +}) + +export type EventFormValues = z.infer + +const frequencies = [ + { label: "Unique", value: "unique" }, + { label: "Quotidien", value: "quotidien" }, + { label: "Hebdomadaire", value: "hebdomadaire" }, + { label: "Mensuel", value: "mensuel" }, +] + +const isCalendarEventExternal = (event: CalendarEventExternal | Omit): event is CalendarEventExternal => { + return (event as CalendarEventExternal).id !== undefined; +}; + +export const EventForm: React.FC< + { + event: ICalendarEvent | Omit; + onSubmitEvent: (eventFormValues: EventFormValues) => void; + } +> = ({ + event, + onSubmitEvent, +}) => { + + const form = useForm({ + resolver: zodResolver(eventFormSchema), + defaultValues: { + title: isCalendarEventExternal(event) ? event.title : "", + startDate: isCalendarEventExternal(event) ? new Date(event.start) : new Date(), + startTime: isCalendarEventExternal(event) ? `${new Date(event.start).getHours()}:${new Date(event.start).getMinutes()}` : "10:00", + endDate: isCalendarEventExternal(event) ? new Date(event.end) : new Date(), + endTime: isCalendarEventExternal(event) ? `${new Date(event.end).getHours()}:${new Date(event.end).getMinutes()}` : "11:00", + fullDay: isCalendarEventExternal(event) ? event.fullday : false, + frequency: isCalendarEventExternal(event) ? event.rrule : "unique", + isVisible: isCalendarEventExternal(event) ? event.visibility : true, + }, + }) + + const frequency = form.watch("frequency") + + async function onSubmit(data: EventFormValues) { + try { + const validatedData = eventFormSchema.parse(data) + onSubmitEvent(validatedData) + } catch (error) { + console.error("On submit error : ", error) + } + } + + return ( +
+ + ( + + Titre + + + + + + )} + /> + +
+ ( + + Début + + + + + + + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + Until + + ( + + Fin + + + + + + + + + + + + + )} + /> + + ( + + + + + + + )} + /> +
+ + ( + + + + + Journée complète + + + )} + /> + +
+ ( + + Fréquence + + + + )} + /> + + {frequency !== "unique" && ( + ( + + Jusqu'au + + + + + + + + + + + + + )} + /> + )} +
+ + ( + + Evènement visible ? + + + + + + )} + /> +
+ + +
+ + + ) + } + + diff --git a/frontend/components/planning.tsx b/frontend/components/planning.tsx index 8b9f845..bef9c4a 100644 --- a/frontend/components/planning.tsx +++ b/frontend/components/planning.tsx @@ -8,11 +8,7 @@ import { createEventsServicePlugin } from "@schedule-x/events-service"; import { createDragAndDropPlugin } from "@schedule-x/drag-and-drop"; import { createResizePlugin } from "@schedule-x/resize"; import { createEventRecurrencePlugin } from "@schedule-x/event-recurrence"; -import { - CalendarEventExternal, - createViewDay, - createViewWeek, -} from "@schedule-x/calendar"; +import { createViewDay, createViewWeek } from "@schedule-x/calendar"; import { useEffect, useState } from "react"; import { format } from "date-fns"; import { Dialog, DialogProps } from "@radix-ui/react-dialog"; @@ -23,25 +19,49 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { KeyedMutator } from "swr"; import { getCookie } from "cookies-next"; import { useTheme } from "next-themes"; -import { Checkbox } from "@/components/ui/checkbox"; -import { eventNames } from "process"; -import { useSearchParams } from "next/navigation"; -import { CheckedState } from "@radix-ui/react-checkbox"; -import { DateTimePicker } from "./date-time-picker"; +import { EventForm, EventFormValues } from "./event-dialog"; +import ICalendarEvent from "@/interfaces/ICalendarEvent"; -interface CalendarEventExternalDB extends CalendarEventExternal { - status: "Active" | "Inactive"; -} +const mapFrequencyToRrule = ( + frequency: "unique" | "quotidien" | "hebdomadaire" | "mensuel", + frequencyEndDate?: Date, +): string => { + let rrule = ""; + + switch (frequency) { + case "quotidien": + rrule = "FREQ=DAILY"; + break; + case "hebdomadaire": + rrule = "FREQ=WEEKLY"; + break; + case "mensuel": + rrule = "FREQ=MONTHLY"; + break; + default: + return ""; + } + + if (frequencyEndDate) { + const until = frequencyEndDate.getTime(); + const untilDate = new Date(until); + const epochDateString = untilDate + .toISOString() + .replace(/[-:]/g, "") + .split(".")[0]; // Format as YYYYMMDDTHHmmss + rrule += `;UNTIL=${epochDateString}`; + } + + return rrule; +}; const Planning: React.FC<{ - events: CalendarEventExternal[]; - mutate?: KeyedMutator>; + events: ICalendarEvent[]; + mutate?: KeyedMutator>; }> = ({ events, mutate }) => { const { resolvedTheme } = useTheme(); console.log(resolvedTheme); @@ -54,18 +74,15 @@ const Planning: React.FC<{ createEventRecurrencePlugin(), ] : []; - const [eventSelected, setEventSelected] = - useState(null); - const [eventStatus, setEventStatus] = useState<"Active" | "Inactive">( - "Active", + const [eventSelected, setEventSelected] = useState( + null, + ); + const [newEvent, setNewEvent] = useState | null>( + null, ); - const [newEvent, setNewEvent] = useState | null>(null); - const handleEventUpdate = async (eventSelected: CalendarEventExternal) => { - const event: CalendarEventExternal = { + const handleEventUpdate = async (eventSelected: ICalendarEvent) => { + const event: ICalendarEvent = { ...eventSelected, start: `${new Date(eventSelected.start).toISOString()}`, end: `${new Date(eventSelected.end).toISOString()}`, @@ -104,14 +121,10 @@ const Planning: React.FC<{ })), callbacks: { onEventClick(event, e) { - setEventSelected(event); + setEventSelected(event as ICalendarEvent); }, - // async onBeforeEventUpdate(oldEvent, newEvent) { - // await request("/api/me/has-permissions") - // }, async onEventUpdate(newEvent) { - // console.log(event); - await handleEventUpdate(newEvent); + await handleEventUpdate(newEvent as ICalendarEvent); }, }, }, @@ -145,72 +158,20 @@ const Planning: React.FC<{ setNewEvent((e) => (open ? e : null)); }} event={newEvent} - //onStartChange={(e) => { - // const val = e.currentTarget.value; - // setNewEvent((ev) => { - // if (ev) - // return { - // ...ev, - // start: val, - // }; - // return ev; - // }); - //}} - onStartDateChange={(date) => { - setNewEvent((ev) => { - if (ev) - return { - ...ev, - start: date, - }; - return ev; - }); - }} - onEndDateChange={(date) => { - setNewEvent((ev) => { - if (ev) - return { - ...ev, - end: date, - }; - return ev; - }); - }} - onTitleChange={(e) => { - const val = e.currentTarget.value; - setNewEvent((ev) => { - if (ev) - return { - ...ev, - title: val, - }; - return ev; - }); - }} - onActiveStateChange={(e) => { - e - ? setEventStatus("Active") - : setEventStatus("Inactive"); - }} - //onEndChange={(e) => { - // const val = e.currentTarget.value; - // setNewEvent((ev) => { - // if (ev) - // return { - // ...ev, - // end: val, - // }; - // return ev; - // }); - //}} - onAdd={async () => { + onSubmitEvent={async (eventFormValues) => { + const rrule = mapFrequencyToRrule( + eventFormValues.frequency, + eventFormValues.frequencyEndDate, + ); try { - const event: Omit = { + const event: Omit = { ...newEvent, - start: `${new Date(newEvent.start).toISOString()}`, - end: `${new Date(newEvent.end).toISOString()}`, - title: newEvent.title, - status: eventStatus, + start: `${eventFormValues.startDate} ${eventFormValues.startTime}`, + end: `${eventFormValues.endDate} ${eventFormValues.endTime}`, + title: `${eventFormValues.title}`, + fullDay: eventFormValues.fullDay, + rrule: rrule, + isVisible: eventFormValues.isVisible, }; const res = await request( `/events/new`, @@ -241,25 +202,8 @@ const Planning: React.FC<{ setEventSelected((e) => (open ? e : null)); }} event={eventSelected} - onStartDateChange={(date) => { - setEventSelected((ev) => { - if (ev && date) - return { - ...ev, - start: format(date, "YYYY-MM-DD HH:MM"), - }; - return ev; - }); - }} - onEndDateChange={(date) => { - setEventSelected((ev) => { - if (ev && date) - return { - ...ev, - end: format(date, "YYYY-MM-DD HH:MM"), - }; - return ev; - }); + onSubmitEvent={(eventForm) => { + console.log("Event form: " + eventForm); }} onDelete={async () => { calendar?.events?.remove(eventSelected.id); @@ -296,35 +240,21 @@ const Planning: React.FC<{ const EventDialog: React.FC< { - // onEndChange: React.ChangeEventHandler; - // onStartChange: React.ChangeEventHandler; - onStartDateChange: (selectedDate: Date | undefined) => void; - onEndDateChange: (selectedDate: Date | undefined) => void; + onSubmitEvent: (eventFormValues: EventFormValues) => void; onDelete?: () => void; onUpdate?: () => void; onAdd?: () => void; - onTitleChange?: React.ChangeEventHandler; - onActiveStateChange?: (status: boolean) => void; - event: CalendarEventExternal | Omit; + event: ICalendarEvent | Omit; } & DialogProps > = ({ open, onOpenChange, - // onEndChange, - // onStartChange, - onStartDateChange, - onEndDateChange, + onSubmitEvent, onDelete, onUpdate, onAdd, - onTitleChange, - onActiveStateChange, event, }) => { - const [checked, setChecked] = useState( - event.status === "Active", - ); - return ( @@ -332,67 +262,7 @@ const EventDialog: React.FC< {event.title} {event.description} - -
-
- - -
- -
- - - onStartDateChange(date) - } - /> -
-
- - {/* */} - onEndDateChange(date)} - /> -
-
- - { - const booleanCheck = !!e; - setChecked((prev) => { - return !prev; - }); - if (onActiveStateChange) { - onActiveStateChange(booleanCheck); - } - }} - /> -
-
+ {onUpdate && (