diff --git a/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.scss b/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.scss index 3e80d0f..f55af8c 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.scss +++ b/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.scss @@ -1,4 +1,10 @@ .recurrence-editor { + &__label { + font-weight: 500; + color: #333; + font-size: 0.9375rem; + } + &__weekday-button { width: 32px; height: 32px; @@ -23,6 +29,20 @@ } } } + + &__warning { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + margin-top: 0.5rem; + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + color: #856404; + font-size: 0.875rem; + line-height: 1.4; + } } // Utility classes for layout @@ -38,6 +58,10 @@ align-items: center; } + &--flex-wrap { + flex-wrap: wrap; + } + &--gap-1rem { gap: 1rem; } diff --git a/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.tsx b/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.tsx index eaa869c..6df98e1 100644 --- a/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/components/RecurrenceEditor.tsx @@ -1,41 +1,110 @@ -import { Select, Input } from '@openfun/cunningham-react'; -import { useState } from 'react'; -import type { IcsRecurrenceRule } from 'ts-ics'; +import { Select, Input } from '@gouvfr-lasuite/cunningham-react'; +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { IcsRecurrenceRule, IcsWeekDay } from 'ts-ics'; -const WEEKDAYS = [ - { value: 'MO', label: 'L' }, - { value: 'TU', label: 'M' }, - { value: 'WE', label: 'M' }, - { value: 'TH', label: 'J' }, - { value: 'FR', label: 'V' }, - { value: 'SA', label: 'S' }, - { value: 'SU', label: 'D' }, -] as const; +type RecurrenceFrequency = IcsRecurrenceRule['frequency']; -const RECURRENCE_OPTIONS = [ - { value: 'NONE', label: 'Non' }, - { value: 'DAILY', label: 'Tous les jours' }, - { value: 'WEEKLY', label: 'Toutes les semaines' }, - { value: 'MONTHLY', label: 'Tous les mois' }, - { value: 'YEARLY', label: 'Tous les ans' }, - { value: 'CUSTOM', label: 'Personnalisé...' }, -] as const; +const WEEKDAY_KEYS: IcsWeekDay[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']; + +const MONTHS = [ + { value: 1, key: 'january' }, + { value: 2, key: 'february' }, + { value: 3, key: 'march' }, + { value: 4, key: 'april' }, + { value: 5, key: 'may' }, + { value: 6, key: 'june' }, + { value: 7, key: 'july' }, + { value: 8, key: 'august' }, + { value: 9, key: 'september' }, + { value: 10, key: 'october' }, + { value: 11, key: 'november' }, + { value: 12, key: 'december' }, +]; interface RecurrenceEditorProps { value?: IcsRecurrenceRule; onChange: (rule: IcsRecurrenceRule | undefined) => void; } +/** + * Validate day of month based on month (handles leap years and month lengths) + */ +function isValidDayForMonth(day: number, month?: number): boolean { + if (day < 1 || day > 31) return false; + + if (!month) return true; // No month specified, allow any valid day + + // Days per month (non-leap year) + const daysInMonth: Record = { + 1: 31, 2: 29, 3: 31, 4: 30, 5: 31, 6: 30, + 7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31 + }; + + return day <= (daysInMonth[month] || 31); +} + +/** + * Get warning message for invalid dates + */ +function getDateWarning(t: (key: string) => string, day: number, month?: number): string | null { + if (!month) return null; + + if (month === 2 && day > 29) { + return t('calendar.recurrence.warnings.februaryMax'); + } + if (month === 2 && day === 29) { + return t('calendar.recurrence.warnings.leapYear'); + } + if ([4, 6, 9, 11].includes(month) && day > 30) { + return t('calendar.recurrence.warnings.monthMax30'); + } + if (day > 31) { + return t('calendar.recurrence.warnings.dayMax31'); + } + + return null; +} + export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) { + const { t } = useTranslation(); + const [isCustom, setIsCustom] = useState(() => { if (!value) return false; - return value.interval !== 1 || value.byDay?.length || value.count || value.until; + return value.interval !== 1 || value.byDay?.length || value.byMonthday?.length || value.byMonth?.length || value.count || value.until; }); - const getSimpleValue = () => { + // Generate options from translations + const recurrenceOptions = useMemo(() => [ + { value: 'NONE', label: t('calendar.recurrence.none') }, + { value: 'DAILY', label: t('calendar.recurrence.daily') }, + { value: 'WEEKLY', label: t('calendar.recurrence.weekly') }, + { value: 'MONTHLY', label: t('calendar.recurrence.monthly') }, + { value: 'YEARLY', label: t('calendar.recurrence.yearly') }, + { value: 'CUSTOM', label: t('calendar.recurrence.custom') }, + ], [t]); + + const frequencyOptions = useMemo(() => [ + { value: 'DAILY', label: t('calendar.recurrence.days') }, + { value: 'WEEKLY', label: t('calendar.recurrence.weeks') }, + { value: 'MONTHLY', label: t('calendar.recurrence.months') }, + { value: 'YEARLY', label: t('calendar.recurrence.years') }, + ], [t]); + + const weekdays = useMemo(() => WEEKDAY_KEYS.map(key => ({ + value: key, + label: t(`calendar.recurrence.weekdays.${key.toLowerCase()}`), + })), [t]); + + const monthOptions = useMemo(() => MONTHS.map(month => ({ + value: String(month.value), + label: t(`calendar.recurrence.months.${month.key}`), + })), [t]); + + const getSimpleValue = (): string => { if (!value) return 'NONE'; if (isCustom) return 'CUSTOM'; - return value.freq; + return value.frequency; }; const handleSimpleChange = (newValue: string) => { @@ -44,21 +113,23 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) { onChange(undefined); return; } - + if (newValue === 'CUSTOM') { setIsCustom(true); onChange({ - freq: 'WEEKLY', + frequency: 'WEEKLY', interval: 1 }); return; } - + setIsCustom(false); onChange({ - freq: newValue as IcsRecurrenceRule['freq'], + frequency: newValue as RecurrenceFrequency, interval: 1, byDay: undefined, + byMonthday: undefined, + byMonth: undefined, count: undefined, until: undefined }); @@ -66,63 +137,100 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) { const handleChange = (updates: Partial) => { onChange({ - freq: 'WEEKLY', + frequency: 'WEEKLY', interval: 1, ...value, ...updates }); }; + // Get selected day strings from byDay array + const getSelectedDays = (): IcsWeekDay[] => { + if (!value?.byDay) return []; + return value.byDay.map(d => typeof d === 'string' ? d as IcsWeekDay : d.day); + }; + + const isDaySelected = (day: IcsWeekDay): boolean => { + return getSelectedDays().includes(day); + }; + + const toggleDay = (day: IcsWeekDay) => { + const currentDays = getSelectedDays(); + const newDays = currentDays.includes(day) + ? currentDays.filter(d => d !== day) + : [...currentDays, day]; + handleChange({ byDay: newDays.map(d => ({ day: d })) }); + }; + + // Get selected month day + const getMonthDay = (): number => { + return value?.byMonthday?.[0] ?? 1; + }; + + // Get selected month for yearly recurrence + const getMonth = (): string => { + return String(value?.byMonth?.[0] ?? 1); + }; + + const handleMonthDayChange = (day: number) => { + handleChange({ byMonthday: [day] }); + }; + + const handleMonthChange = (month: number) => { + handleChange({ byMonth: [month] }); + }; + + // Calculate date warning + const monthDay = getMonthDay(); + const selectedMonth = value?.frequency === 'YEARLY' ? parseInt(getMonth()) : undefined; + const dateWarning = isCustom && (value?.frequency === 'MONTHLY' || value?.frequency === 'YEARLY') + ? getDateWarning(t, monthDay, selectedMonth) + : null; + return (
handleChange({ interval: parseInt(e.target.value) })} - style={{ width: '80px' }} + onChange={(e) => handleChange({ interval: parseInt(e.target.value) || 1 })} /> { + const day = parseInt(e.target.value) || 1; + if (day >= 1 && day <= 31) { + handleMonthDayChange(day); + } + }} + /> +
+ {dateWarning && ( +
+ ⚠️ {dateWarning} +
+ )} + + )} + + {/* YEARLY: Month + Day selection */} + {value?.frequency === 'YEARLY' && ( +
+ {t('calendar.recurrence.repeatOnDate')} +
+ { + const day = parseInt(e.target.value) || 1; + if (day >= 1 && day <= 31) { + handleMonthDayChange(day); + } + }} + /> +
+ {dateWarning && ( +
+ ⚠️ {dateWarning} +
+ )} +
+ )} + + {/* End conditions */}
- Se termine + {t('calendar.recurrence.endsLabel')}
handleChange({ count: undefined, until: undefined })} /> - Jamais +
handleChange({ until: new Date(), count: undefined })} + onChange={() => handleChange({ until: { type: 'DATE', date: new Date() }, count: undefined })} /> - Le + {value?.until && ( handleChange({ + value={value.until.date instanceof Date ? value.until.date.toISOString().split('T')[0] : ''} + onChange={(e) => handleChange({ until: { - type: 'DATE-TIME', + type: 'DATE', date: new Date(e.target.value), - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, } })} /> @@ -172,21 +344,22 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
handleChange({ count: 1, until: undefined })} + onChange={() => handleChange({ count: 10, until: undefined })} /> - Après + {value?.count !== undefined && ( <> handleChange({ count: parseInt(e.target.value) })} - style={{ width: '80px' }} - /> - occurrences + onChange={(e) => handleChange({ count: parseInt(e.target.value) || 1 })} + /> + {t('calendar.recurrence.occurrences')} )}
@@ -196,4 +369,4 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) { )}
); -} \ No newline at end of file +}