diff --git a/src/frontend/apps/calendars/src/features/calendar/components/AttendeesInput.scss b/src/frontend/apps/calendars/src/features/calendar/components/AttendeesInput.scss new file mode 100644 index 0000000..28c5f45 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/AttendeesInput.scss @@ -0,0 +1,155 @@ +.attendees-input { + display: flex; + flex-direction: column; + gap: 1rem; + + &__field { + width: 100%; + } + + &__participants { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + &__header { + display: flex; + align-items: center; + gap: 0.5rem; + } + + &__title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #6a6a6a; + } + + &__badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + padding: 0 0.375rem; + background-color: #6a6a6a; + color: white; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 600; + } + + &__list { + display: flex; + flex-direction: column; + gap: 0; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + overflow: hidden; + } + + &__item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + background: white; + border-bottom: 1px solid #f0f0f0; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: #fafafa; + } + } + + &__status-icon { + font-size: 1.5rem; + flex-shrink: 0; + + &--accepted { + color: #4caf50; + } + + &--pending { + color: #9e9e9e; + } + } + + &__item-content { + display: flex; + flex-direction: column; + gap: 0.125rem; + flex: 1; + min-width: 0; + } + + &__item-name { + font-size: 0.9375rem; + font-weight: 500; + color: #161616; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__item-email { + font-size: 0.875rem; + color: #6a6a6a; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__organizer-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + color: #6a6a6a; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 500; + text-transform: capitalize; + } + + &__action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: #6a6a6a; + border-radius: 4px; + transition: all 0.2s; + flex-shrink: 0; + + &:hover:not(:disabled) { + background-color: #f0f0f0; + color: #161616; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + &--remove:hover:not(:disabled) { + background-color: #fef0f0; + color: #d32f2f; + } + + .material-icons { + font-size: 1.25rem; + } + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/AttendeesInput.tsx b/src/frontend/apps/calendars/src/features/calendar/components/AttendeesInput.tsx new file mode 100644 index 0000000..b177f51 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/AttendeesInput.tsx @@ -0,0 +1,211 @@ +import { useState, useCallback, type KeyboardEvent } from 'react'; +import { Input } from '@gouvfr-lasuite/cunningham-react'; +import { useTranslation } from 'react-i18next'; +import type { IcsAttendee, IcsOrganizer } from 'ts-ics'; + +interface AttendeesInputProps { + attendees: IcsAttendee[]; + onChange: (attendees: IcsAttendee[]) => void; + organizerEmail?: string; + organizer?: IcsOrganizer; +} + +// Validate email format +const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +// Get partstat display style +const getPartstatStyle = (partstat?: string): { bgColor: string; textColor: string } => { + switch (partstat) { + case 'ACCEPTED': + return { bgColor: '#d4edda', textColor: '#155724' }; + case 'DECLINED': + return { bgColor: '#f8d7da', textColor: '#721c24' }; + case 'TENTATIVE': + return { bgColor: '#fff3cd', textColor: '#856404' }; + default: // NEEDS-ACTION or undefined + return { bgColor: '#e9ecef', textColor: '#495057' }; + } +}; + +// Get partstat icon +const getPartstatIcon = (partstat?: string): string => { + switch (partstat) { + case 'ACCEPTED': + return 'check_circle'; + case 'DECLINED': + return 'cancel'; + case 'TENTATIVE': + return 'help'; + default: + return 'schedule'; + } +}; + +export function AttendeesInput({ attendees, onChange, organizerEmail, organizer }: AttendeesInputProps) { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(null); + + // Calculate total participants (organizer + attendees) + const totalParticipants = (organizer ? 1 : 0) + attendees.length; + + const addAttendee = useCallback(() => { + const email = inputValue.trim().toLowerCase(); + + if (!email) { + return; + } + + if (!isValidEmail(email)) { + setError(t('calendar.attendees.invalidEmail')); + return; + } + + // Check if already in list + if (attendees.some(a => a.email.toLowerCase() === email)) { + setError(t('calendar.attendees.alreadyAdded')); + return; + } + + // Check if it's the organizer + if (organizerEmail && email === organizerEmail.toLowerCase()) { + setError(t('calendar.attendees.cannotAddOrganizer')); + return; + } + + const newAttendee: IcsAttendee = { + email, + partstat: 'NEEDS-ACTION', + rsvp: true, + role: 'REQ-PARTICIPANT', + }; + + onChange([...attendees, newAttendee]); + setInputValue(''); + setError(null); + }, [inputValue, attendees, onChange, organizerEmail, t]); + + const removeAttendee = useCallback((emailToRemove: string) => { + onChange(attendees.filter(a => a.email !== emailToRemove)); + }, [attendees, onChange]); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + addAttendee(); + } + }, [addAttendee]); + + return ( +
+
+ { + setInputValue(e.target.value); + if (error) setError(null); + }} + onKeyDown={handleKeyDown} + state={error ? 'error' : 'default'} + text={error || undefined} + icon={person_add} + /> +
+ + {totalParticipants > 0 && ( +
+
+

+ {t('calendar.attendees.participants')} + {totalParticipants} +

+
+ +
+ {/* Organizer */} + {organizer && ( +
+ + check_circle + +
+ + {organizer.name || organizer.email} + + + {t('calendar.attendees.organizer')} + +
+ + +
+ )} + + {/* Attendees */} + {attendees.map((attendee) => { + const icon = getPartstatIcon(attendee.partstat); + const isAccepted = attendee.partstat === 'ACCEPTED'; + + return ( +
+ + {icon} + +
+ + {attendee.name || attendee.email} + + {attendee.name && ( + + <{attendee.email}> + + )} +
+ + +
+ ); + })} +
+
+ )} +
+ ); +}