Refactor EventModal into section components (#7)
* 🙈(tools) remove OpenSpec skills and commands from Git tracking
Remove .claude/skills/openspec-*/ and .claude/commands/opsx/ from
Git tracking while keeping them locally. Extends the previous
cleanup (d24eed9) that handled the openspec/ directory.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ✨(front) add EventModal section components and useEventForm hook
Extract modal sections into dedicated components (DateTimeSection,
RecurrenceSection, LocationSection, VideoConferenceSection,
AttendeesSection, DescriptionSection, InvitationResponseSection,
RemindersSection, StatusSection, AttachmentsSection) with shared
SectionRow and SectionPill layout components.
Add useEventForm hook to centralize form state management.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ♻️(front) refactor EventModal to use extracted sections
Simplify EventModal by delegating to section components and
useEventForm hook. Replace alert() with addToast() for error
feedback, convert useCallback to useMemo for buildSummary in
RecurrenceEditor, and add missing organizer dependency in
useEventForm useEffect.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* 💄(front) update scheduler styles for new modal design
Rework SCSS for EventModal, AttendeesInput, RecurrenceEditor,
and Scheduler. Move inline styles to CSS classes. Update
globals and frontend dependencies.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@event-calendar/core": "^5.2.3",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.1.0",
|
||||
"@gouvfr-lasuite/ui-kit": "0.18.7",
|
||||
"@gouvfr-lasuite/cunningham-react": "4.2.0",
|
||||
"@gouvfr-lasuite/ui-kit": "0.19.6",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@viselect/react": "3.9.0",
|
||||
|
||||
@@ -1,155 +1,83 @@
|
||||
.attendees-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__participants {
|
||||
&__add-btn {
|
||||
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;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
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;
|
||||
}
|
||||
background: transparent;
|
||||
color: var(--c--theme--colors--greyscale-600);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
|
||||
&: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;
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
color: var(--c--theme--colors--greyscale-900);
|
||||
border-color: var(--c--theme--colors--greyscale-400);
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: default;
|
||||
|
||||
.material-icons {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&__organizer-label {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__pill-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-left: 0.125rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
border-radius: 50%;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import { useState, useCallback, type KeyboardEvent } from "react";
|
||||
import { Input } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { Badge } from "@gouvfr-lasuite/ui-kit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
|
||||
|
||||
interface AttendeesInputProps {
|
||||
attendees: IcsAttendee[];
|
||||
@@ -10,48 +11,55 @@ interface AttendeesInputProps {
|
||||
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 } => {
|
||||
type BadgeType =
|
||||
| "accent"
|
||||
| "neutral"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "info";
|
||||
|
||||
const getBadgeType = (partstat?: string): BadgeType => {
|
||||
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' };
|
||||
case "ACCEPTED":
|
||||
return "success";
|
||||
case "DECLINED":
|
||||
return "danger";
|
||||
case "TENTATIVE":
|
||||
return "warning";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
};
|
||||
|
||||
// Get partstat icon
|
||||
const getPartstatIcon = (partstat?: string): string => {
|
||||
switch (partstat) {
|
||||
case 'ACCEPTED':
|
||||
return 'check_circle';
|
||||
case 'DECLINED':
|
||||
return 'cancel';
|
||||
case 'TENTATIVE':
|
||||
return 'help';
|
||||
case "ACCEPTED":
|
||||
return "check_circle";
|
||||
case "DECLINED":
|
||||
return "cancel";
|
||||
case "TENTATIVE":
|
||||
return "help";
|
||||
default:
|
||||
return 'schedule';
|
||||
return "schedule";
|
||||
}
|
||||
};
|
||||
|
||||
export function AttendeesInput({ attendees, onChange, organizerEmail, organizer }: AttendeesInputProps) {
|
||||
export function AttendeesInput({
|
||||
attendees,
|
||||
onChange,
|
||||
organizerEmail,
|
||||
organizer,
|
||||
}: AttendeesInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Calculate total participants (organizer + attendees)
|
||||
const totalParticipants = (organizer ? 1 : 0) + attendees.length;
|
||||
|
||||
const addAttendee = useCallback(() => {
|
||||
const email = inputValue.trim().toLowerCase();
|
||||
|
||||
@@ -60,50 +68,57 @@ export function AttendeesInput({ attendees, onChange, organizerEmail, organizer
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
setError(t('calendar.attendees.invalidEmail'));
|
||||
setError(t("calendar.attendees.invalidEmail"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already in list
|
||||
if (attendees.some(a => a.email.toLowerCase() === email)) {
|
||||
setError(t('calendar.attendees.alreadyAdded'));
|
||||
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'));
|
||||
setError(t("calendar.attendees.cannotAddOrganizer"));
|
||||
return;
|
||||
}
|
||||
|
||||
const newAttendee: IcsAttendee = {
|
||||
email,
|
||||
partstat: 'NEEDS-ACTION',
|
||||
partstat: "NEEDS-ACTION",
|
||||
rsvp: true,
|
||||
role: 'REQ-PARTICIPANT',
|
||||
role: "REQ-PARTICIPANT",
|
||||
};
|
||||
|
||||
onChange([...attendees, newAttendee]);
|
||||
setInputValue('');
|
||||
setInputValue("");
|
||||
setError(null);
|
||||
}, [inputValue, attendees, onChange, organizerEmail, t]);
|
||||
|
||||
const removeAttendee = useCallback((emailToRemove: string) => {
|
||||
onChange(attendees.filter(a => a.email !== emailToRemove));
|
||||
}, [attendees, onChange]);
|
||||
const removeAttendee = useCallback(
|
||||
(emailToRemove: string) => {
|
||||
onChange(attendees.filter((a) => a.email !== emailToRemove));
|
||||
},
|
||||
[attendees, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addAttendee();
|
||||
}
|
||||
}, [addAttendee]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addAttendee();
|
||||
}
|
||||
},
|
||||
[addAttendee],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="attendees-input">
|
||||
<div className="attendees-input__field">
|
||||
<Input
|
||||
label={t('calendar.attendees.label')}
|
||||
label={t("calendar.attendees.label")}
|
||||
hideLabel
|
||||
placeholder={t("calendar.attendees.placeholder")}
|
||||
variant="classic"
|
||||
fullWidth
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
@@ -111,101 +126,42 @@ export function AttendeesInput({ attendees, onChange, organizerEmail, organizer
|
||||
if (error) setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
state={error ? 'error' : 'default'}
|
||||
state={error ? "error" : "default"}
|
||||
text={error || undefined}
|
||||
icon={<span className="material-icons">person_add</span>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{totalParticipants > 0 && (
|
||||
<div className="attendees-input__participants">
|
||||
<div className="attendees-input__header">
|
||||
<h3 className="attendees-input__title">
|
||||
{t('calendar.attendees.participants')}
|
||||
<span className="attendees-input__badge">{totalParticipants}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="attendees-input__list">
|
||||
{/* Organizer */}
|
||||
{organizer && (
|
||||
<div className="attendees-input__item">
|
||||
<span className="material-icons attendees-input__status-icon attendees-input__status-icon--accepted">
|
||||
check_circle
|
||||
</span>
|
||||
<div className="attendees-input__item-content">
|
||||
<span className="attendees-input__item-name">
|
||||
{organizer.name || organizer.email}
|
||||
</span>
|
||||
<span className="attendees-input__organizer-badge">
|
||||
{t('calendar.attendees.organizer')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="attendees-input__action-btn"
|
||||
aria-label={t('calendar.attendees.viewProfile')}
|
||||
>
|
||||
<span className="material-icons">person</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="attendees-input__action-btn attendees-input__action-btn--remove"
|
||||
disabled
|
||||
aria-label={t('calendar.attendees.cannotRemoveOrganizer')}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{attendees.map((attendee) => {
|
||||
const icon = getPartstatIcon(attendee.partstat);
|
||||
const isAccepted = attendee.partstat === 'ACCEPTED';
|
||||
|
||||
return (
|
||||
<div key={attendee.email} className="attendees-input__item">
|
||||
<span
|
||||
className={`material-icons attendees-input__status-icon ${
|
||||
isAccepted
|
||||
? 'attendees-input__status-icon--accepted'
|
||||
: 'attendees-input__status-icon--pending'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="attendees-input__item-content">
|
||||
<span className="attendees-input__item-name">
|
||||
{attendee.name || attendee.email}
|
||||
</span>
|
||||
{attendee.name && (
|
||||
<span className="attendees-input__item-email">
|
||||
<{attendee.email}>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="attendees-input__action-btn"
|
||||
aria-label={t('calendar.attendees.viewProfile')}
|
||||
>
|
||||
<span className="material-icons">person</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="attendees-input__action-btn attendees-input__action-btn--remove"
|
||||
onClick={() => removeAttendee(attendee.email)}
|
||||
aria-label={t('calendar.attendees.remove')}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="attendees-input__pills">
|
||||
{organizer && attendees.length > 0 && (
|
||||
<Badge type={"success"} className="attendees-input__pill">
|
||||
<span className="material-icons">check_circle</span>
|
||||
{organizer.email}
|
||||
<span className="attendees-input__organizer-label">
|
||||
({t("calendar.attendees.organizer")})
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{attendees.map((attendee) => (
|
||||
<Badge
|
||||
key={attendee.email}
|
||||
type={getBadgeType(attendee.partstat)}
|
||||
className="attendees-input__pill"
|
||||
>
|
||||
<span className="material-icons">
|
||||
{getPartstatIcon(attendee.partstat)}
|
||||
</span>
|
||||
{attendee.email}
|
||||
<button
|
||||
type="button"
|
||||
className="attendees-input__pill-remove"
|
||||
onClick={() => removeAttendee(attendee.email)}
|
||||
aria-label={t("calendar.attendees.remove")}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +1,212 @@
|
||||
.event-modal {
|
||||
&__feature-tag {
|
||||
display: inline-flex;
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__more-options {
|
||||
border-top: 1px solid #f1f3f4;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
&__more-options-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 2rem;
|
||||
border: none;
|
||||
background: #f1f3f4;
|
||||
color: #5f6368;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
color: #5f6368;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #e8eaeb;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
|
||||
&:hover {
|
||||
background: #d2e3fc;
|
||||
}
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 1.125rem;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&__more-options-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #e8eaeb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// DateTimeSection layout
|
||||
.datetime-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__inputs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
align-self: center;
|
||||
margin: 0 8px;
|
||||
font-size: 20px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
&__allday {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #5f6368;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// VideoConferenceSection layout
|
||||
.video-conference-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: #5f6368;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #f1f3f4;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utility classes for layout
|
||||
.event-modal-layout {
|
||||
// RemindersSection layout
|
||||
.reminders-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&--row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&--wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&--justify-space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&--align-center {
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&--flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&--gap-2rem {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
&--gap-1rem {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&--gap-0-5rem {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&--margin-bottom-1rem {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
&__remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: #5f6368;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--margin-top-2rem {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
&:hover {
|
||||
background-color: #f1f3f4;
|
||||
}
|
||||
|
||||
&--margin-left-auto {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&--margin-left-2rem {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
&--margin-top-0-5rem {
|
||||
margin-top: 0.5rem;
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AttachmentsSection layout
|
||||
.attachments-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
&__size {
|
||||
color: #5f6368;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: #5f6368;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #e8eaeb;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StatusSection layout
|
||||
.status-section {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,28 @@
|
||||
/**
|
||||
* EventModal component.
|
||||
* Handles creation and editing of calendar events.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IcsEvent,
|
||||
IcsAttendee,
|
||||
IcsOrganizer,
|
||||
IcsRecurrenceRule,
|
||||
} from "ts-ics";
|
||||
import type { IcsEvent, IcsOrganizer } from "ts-ics";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalSize,
|
||||
Select,
|
||||
TextArea,
|
||||
} from "@gouvfr-lasuite/cunningham-react";
|
||||
|
||||
import { useAuth } from "@/features/auth/Auth";
|
||||
import { AttendeesInput } from "./AttendeesInput";
|
||||
import { RecurrenceEditor } from "./RecurrenceEditor";
|
||||
import { addToast, ToasterItem } from "@/features/ui/components/toaster/Toaster";
|
||||
import { DeleteEventModal } from "./DeleteEventModal";
|
||||
import { useEventForm } from "./hooks/useEventForm";
|
||||
import { DateTimeSection } from "./event-modal-sections/DateTimeSection";
|
||||
import { RecurrenceSection } from "./event-modal-sections/RecurrenceSection";
|
||||
import { LocationSection } from "./event-modal-sections/LocationSection";
|
||||
import { VideoConferenceSection } from "./event-modal-sections/VideoConferenceSection";
|
||||
import { AttendeesSection } from "./event-modal-sections/AttendeesSection";
|
||||
import { DescriptionSection } from "./event-modal-sections/DescriptionSection";
|
||||
import { InvitationResponseSection } from "./event-modal-sections/InvitationResponseSection";
|
||||
import { SectionPills } from "./event-modal-sections/SectionPills";
|
||||
import type { EventModalProps, RecurringDeleteOption } from "./types";
|
||||
import {
|
||||
formatDateTimeLocal,
|
||||
formatDateLocal,
|
||||
parseDateTimeLocal,
|
||||
parseDateLocal,
|
||||
} from "./utils/dateFormatters";
|
||||
|
||||
// Get browser timezone
|
||||
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
import { SectionRow } from "./event-modal-sections/SectionRow";
|
||||
|
||||
export const EventModal = ({
|
||||
isOpen,
|
||||
@@ -49,36 +38,21 @@ export const EventModal = ({
|
||||
}: EventModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [title, setTitle] = useState(event?.summary || "");
|
||||
const [description, setDescription] = useState(event?.description || "");
|
||||
const [location, setLocation] = useState(event?.location || "");
|
||||
const [startDateTime, setStartDateTime] = useState("");
|
||||
const [endDateTime, setEndDateTime] = useState("");
|
||||
const [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [attendees, setAttendees] = useState<IcsAttendee[]>([]);
|
||||
const [showAttendees, setShowAttendees] = useState(false);
|
||||
const [recurrence, setRecurrence] = useState<IcsRecurrenceRule | undefined>(
|
||||
event?.recurrenceRule
|
||||
);
|
||||
const [showRecurrence, setShowRecurrence] = useState(() => {
|
||||
return !!event?.recurrenceRule;
|
||||
});
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isAllDay, setIsAllDay] = useState(() => {
|
||||
return event?.start?.type === 'DATE';
|
||||
});
|
||||
|
||||
// Calculate organizer
|
||||
const organizer: IcsOrganizer | undefined =
|
||||
event?.organizer ||
|
||||
(user?.email
|
||||
? { email: user.email, name: user.email.split('@')[0] }
|
||||
? { email: user.email, name: user.email.split("@")[0] }
|
||||
: undefined);
|
||||
|
||||
// Check if current user is an attendee (invited to this event)
|
||||
const form = useEventForm({ event, calendarUrl, adapter, organizer, mode });
|
||||
|
||||
// Check if current user is invited
|
||||
const currentUserAttendee = event?.attendees?.find(
|
||||
(att) => user?.email && att.email.toLowerCase() === user.email.toLowerCase()
|
||||
(att) =>
|
||||
user?.email && att.email.toLowerCase() === user.email.toLowerCase(),
|
||||
);
|
||||
const isInvited = !!(
|
||||
event?.organizer &&
|
||||
@@ -86,304 +60,95 @@ export const EventModal = ({
|
||||
event.organizer.email !== user?.email
|
||||
);
|
||||
const currentParticipationStatus =
|
||||
currentUserAttendee?.partstat || 'NEEDS-ACTION';
|
||||
currentUserAttendee?.partstat || "NEEDS-ACTION";
|
||||
|
||||
// Reset form when event changes
|
||||
useEffect(() => {
|
||||
setTitle(event?.summary || "");
|
||||
setDescription(event?.description || "");
|
||||
setLocation(event?.location || "");
|
||||
setSelectedCalendarUrl(calendarUrl);
|
||||
|
||||
// Initialize attendees from event
|
||||
if (event?.attendees && event.attendees.length > 0) {
|
||||
setAttendees(event.attendees);
|
||||
setShowAttendees(true);
|
||||
} else {
|
||||
setAttendees([]);
|
||||
setShowAttendees(false);
|
||||
}
|
||||
|
||||
// Initialize recurrence from event
|
||||
if (event?.recurrenceRule) {
|
||||
setRecurrence(event.recurrenceRule);
|
||||
setShowRecurrence(true);
|
||||
} else {
|
||||
setRecurrence(undefined);
|
||||
setShowRecurrence(false);
|
||||
}
|
||||
|
||||
// Initialize all-day from event
|
||||
const eventIsAllDay = event?.start?.type === 'DATE';
|
||||
setIsAllDay(eventIsAllDay);
|
||||
|
||||
// Parse start/end dates
|
||||
// Dates from adapter have timezone info and are "fake UTC" - use getUTC* methods
|
||||
if (event?.start?.date) {
|
||||
const startDate =
|
||||
event.start.date instanceof Date
|
||||
? event.start.date
|
||||
: new Date(event.start.date);
|
||||
// If there's timezone info, the date is "fake UTC"
|
||||
const isFakeUtc = Boolean(event.start.local?.timezone);
|
||||
|
||||
if (eventIsAllDay) {
|
||||
setStartDateTime(formatDateLocal(startDate, isFakeUtc));
|
||||
} else {
|
||||
setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc));
|
||||
}
|
||||
} else {
|
||||
if (eventIsAllDay) {
|
||||
setStartDateTime(formatDateLocal(new Date()));
|
||||
} else {
|
||||
setStartDateTime(formatDateTimeLocal(new Date()));
|
||||
}
|
||||
}
|
||||
|
||||
if (event?.end?.date) {
|
||||
const endDate =
|
||||
event.end.date instanceof Date
|
||||
? event.end.date
|
||||
: new Date(event.end.date);
|
||||
const isFakeUtc = Boolean(event.end.local?.timezone);
|
||||
|
||||
if (eventIsAllDay) {
|
||||
// For all-day events, the end date in ICS is exclusive (next day)
|
||||
// But in the UI we want to show the inclusive end date (same day for 1-day event)
|
||||
// So subtract 1 day when displaying
|
||||
const displayEndDate = new Date(endDate);
|
||||
displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1);
|
||||
setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc));
|
||||
} else {
|
||||
setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc));
|
||||
}
|
||||
} else {
|
||||
if (eventIsAllDay) {
|
||||
// Default: same day
|
||||
setEndDateTime(formatDateLocal(new Date()));
|
||||
} else {
|
||||
// Default: 1 hour after start
|
||||
const defaultEnd = new Date();
|
||||
defaultEnd.setHours(defaultEnd.getHours() + 1);
|
||||
setEndDateTime(formatDateTimeLocal(defaultEnd));
|
||||
}
|
||||
}
|
||||
}, [event, calendarUrl]);
|
||||
const showError = (message: string) => {
|
||||
addToast(
|
||||
<ToasterItem type="error" closeButton>{message}</ToasterItem>,
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Remove duration to avoid union type conflict
|
||||
// (IcsEvent is either end or duration, not both)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { duration: _duration, ...eventWithoutDuration } = event ?? {};
|
||||
|
||||
let icsEvent: IcsEvent;
|
||||
|
||||
if (isAllDay) {
|
||||
// All-day event
|
||||
const startDate = parseDateLocal(startDateTime);
|
||||
const endDate = parseDateLocal(endDateTime);
|
||||
|
||||
// Create UTC dates for all-day events
|
||||
const utcStart = new Date(
|
||||
Date.UTC(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate()
|
||||
)
|
||||
);
|
||||
// For all-day events, the end date in ICS is exclusive (next day)
|
||||
// The user enters the inclusive end date, so we add 1 day for ICS
|
||||
const utcEnd = new Date(
|
||||
Date.UTC(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate() + 1 // Add 1 day for exclusive end
|
||||
)
|
||||
);
|
||||
|
||||
icsEvent = {
|
||||
...eventWithoutDuration,
|
||||
uid: event?.uid || crypto.randomUUID(),
|
||||
summary: title,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
stamp: event?.stamp || { date: new Date() },
|
||||
start: {
|
||||
date: utcStart,
|
||||
type: "DATE",
|
||||
},
|
||||
end: {
|
||||
date: utcEnd,
|
||||
type: "DATE",
|
||||
},
|
||||
organizer: organizer,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
recurrenceRule: recurrence,
|
||||
};
|
||||
} else {
|
||||
// Timed event
|
||||
const startDate = parseDateTimeLocal(startDateTime);
|
||||
const endDate = parseDateTimeLocal(endDateTime);
|
||||
|
||||
// Create "fake UTC" dates where getUTCHours() = local hours
|
||||
// This is required because ts-ics uses getUTCHours() to generate ICS
|
||||
const fakeUtcStart = new Date(
|
||||
Date.UTC(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
startDate.getHours(),
|
||||
startDate.getMinutes(),
|
||||
startDate.getSeconds()
|
||||
)
|
||||
);
|
||||
const fakeUtcEnd = new Date(
|
||||
Date.UTC(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate(),
|
||||
endDate.getHours(),
|
||||
endDate.getMinutes(),
|
||||
endDate.getSeconds()
|
||||
)
|
||||
);
|
||||
|
||||
icsEvent = {
|
||||
...eventWithoutDuration,
|
||||
uid: event?.uid || crypto.randomUUID(),
|
||||
summary: title,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
stamp: event?.stamp || { date: new Date() },
|
||||
start: {
|
||||
date: fakeUtcStart,
|
||||
type: "DATE-TIME",
|
||||
local: {
|
||||
date: fakeUtcStart,
|
||||
timezone: BROWSER_TIMEZONE,
|
||||
tzoffset: adapter.getTimezoneOffset(startDate, BROWSER_TIMEZONE),
|
||||
},
|
||||
},
|
||||
end: {
|
||||
date: fakeUtcEnd,
|
||||
type: "DATE-TIME",
|
||||
local: {
|
||||
date: fakeUtcEnd,
|
||||
timezone: BROWSER_TIMEZONE,
|
||||
tzoffset: adapter.getTimezoneOffset(endDate, BROWSER_TIMEZONE),
|
||||
},
|
||||
},
|
||||
organizer: organizer,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
recurrenceRule: recurrence,
|
||||
};
|
||||
}
|
||||
|
||||
await onSave(icsEvent, selectedCalendarUrl);
|
||||
const icsEvent = form.toIcsEvent();
|
||||
await onSave(icsEvent, form.selectedCalendarUrl);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save event:", error);
|
||||
alert(t('api.error.unexpected'));
|
||||
showError(t("api.error.unexpected"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async (option?: RecurringDeleteOption) => {
|
||||
if (!onDelete || !event?.uid) return;
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onDelete(event as IcsEvent, selectedCalendarUrl, option);
|
||||
await onDelete(event as IcsEvent, form.selectedCalendarUrl, option);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete event:", error);
|
||||
alert(t('api.error.unexpected'));
|
||||
showError(t("api.error.unexpected"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
const handleRespondToInvitation = async (
|
||||
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED'
|
||||
status: "ACCEPTED" | "TENTATIVE" | "DECLINED",
|
||||
) => {
|
||||
if (!onRespondToInvitation || !event) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onRespondToInvitation(event as IcsEvent, status);
|
||||
// Update local state to reflect new status
|
||||
setAttendees((prev) =>
|
||||
form.setAttendees((prev) =>
|
||||
prev.map((att) =>
|
||||
user?.email && att.email.toLowerCase() === user.email.toLowerCase()
|
||||
? { ...att, partstat: status }
|
||||
: att
|
||||
)
|
||||
: att,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to respond to invitation:", error);
|
||||
alert(t('api.error.unexpected'));
|
||||
showError(t("api.error.unexpected"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newStartValue = e.target.value;
|
||||
|
||||
if (isAllDay) {
|
||||
const oldStart = parseDateLocal(startDateTime);
|
||||
const oldEnd = parseDateLocal(endDateTime);
|
||||
const duration = oldEnd.getTime() - oldStart.getTime();
|
||||
|
||||
const newStart = parseDateLocal(newStartValue);
|
||||
const newEnd = new Date(newStart.getTime() + duration);
|
||||
|
||||
setStartDateTime(newStartValue);
|
||||
setEndDateTime(formatDateLocal(newEnd));
|
||||
} else {
|
||||
const oldStart = parseDateTimeLocal(startDateTime);
|
||||
const oldEnd = parseDateTimeLocal(endDateTime);
|
||||
const duration = oldEnd.getTime() - oldStart.getTime();
|
||||
|
||||
const newStart = parseDateTimeLocal(newStartValue);
|
||||
const newEnd = new Date(newStart.getTime() + duration);
|
||||
|
||||
setStartDateTime(newStartValue);
|
||||
setEndDateTime(formatDateTimeLocal(newEnd));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllDayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newIsAllDay = e.target.checked;
|
||||
setIsAllDay(newIsAllDay);
|
||||
|
||||
// Convert dates when switching modes
|
||||
if (newIsAllDay) {
|
||||
// Convert to date-only format
|
||||
const start = parseDateTimeLocal(startDateTime);
|
||||
const end = parseDateTimeLocal(endDateTime);
|
||||
setStartDateTime(formatDateLocal(start));
|
||||
setEndDateTime(formatDateLocal(end));
|
||||
} else {
|
||||
// Convert to datetime format
|
||||
const start = parseDateLocal(startDateTime);
|
||||
const end = parseDateLocal(endDateTime);
|
||||
setStartDateTime(formatDateTimeLocal(start));
|
||||
setEndDateTime(formatDateTimeLocal(end));
|
||||
}
|
||||
};
|
||||
const pills = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "videoConference" as const,
|
||||
icon: "videocam",
|
||||
label: t("calendar.event.sections.addVideoConference"),
|
||||
},
|
||||
{
|
||||
id: "location" as const,
|
||||
icon: "place",
|
||||
label: t("calendar.event.location"),
|
||||
},
|
||||
{
|
||||
id: "description" as const,
|
||||
icon: "notes",
|
||||
label: t("calendar.event.description"),
|
||||
},
|
||||
{
|
||||
id: "recurrence" as const,
|
||||
icon: "repeat",
|
||||
label: t("calendar.recurrence.label"),
|
||||
},
|
||||
{
|
||||
id: "attendees" as const,
|
||||
icon: "group",
|
||||
label: t("calendar.event.attendees"),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -393,227 +158,142 @@ export const EventModal = ({
|
||||
size={ModalSize.MEDIUM}
|
||||
title={
|
||||
mode === "create"
|
||||
? t('calendar.event.createTitle')
|
||||
: t('calendar.event.editTitle')
|
||||
? t("calendar.event.createTitle")
|
||||
: t("calendar.event.editTitle")
|
||||
}
|
||||
leftActions={
|
||||
mode === "edit" && onDelete ? (
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleDeleteClick}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('calendar.event.delete')}
|
||||
{t("calendar.event.delete")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<>
|
||||
<Button color="neutral" onClick={onClose} disabled={isLoading}>
|
||||
{t('calendar.event.cancel')}
|
||||
{t("calendar.event.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="brand"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !title.trim()}
|
||||
disabled={isLoading || !form.title.trim()}
|
||||
>
|
||||
{isLoading ? "..." : t('calendar.event.save')}
|
||||
{isLoading ? "..." : t("calendar.event.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="event-modal__content">
|
||||
<Input
|
||||
label={t('calendar.event.title')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{/* Invitation Response Section */}
|
||||
{isInvited && mode === 'edit' && onRespondToInvitation && (
|
||||
<div className="event-modal__invitation">
|
||||
<div className="event-modal__invitation-header">
|
||||
<span className="event-modal__invitation-label">
|
||||
{t('calendar.event.invitation', { defaultValue: 'Invitation' })}
|
||||
</span>
|
||||
<span className="event-modal__invitation-organizer">
|
||||
{t('calendar.event.organizedBy', {
|
||||
defaultValue: 'Organisé par',
|
||||
})}{' '}
|
||||
{event?.organizer?.name || event?.organizer?.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="event-modal__invitation-actions">
|
||||
<Button
|
||||
size="small"
|
||||
color={
|
||||
currentParticipationStatus === 'ACCEPTED'
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
onClick={() => handleRespondToInvitation('ACCEPTED')}
|
||||
disabled={
|
||||
isLoading || currentParticipationStatus === 'ACCEPTED'
|
||||
}
|
||||
>
|
||||
✓ {t('calendar.event.accept', { defaultValue: 'Accepter' })}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color={
|
||||
currentParticipationStatus === 'TENTATIVE'
|
||||
? 'warning'
|
||||
: 'neutral'
|
||||
}
|
||||
onClick={() => handleRespondToInvitation('TENTATIVE')}
|
||||
disabled={
|
||||
isLoading || currentParticipationStatus === 'TENTATIVE'
|
||||
}
|
||||
>
|
||||
? {t('calendar.event.maybe', { defaultValue: 'Peut-être' })}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color={
|
||||
currentParticipationStatus === 'DECLINED'
|
||||
? 'error'
|
||||
: 'neutral'
|
||||
}
|
||||
onClick={() => handleRespondToInvitation('DECLINED')}
|
||||
disabled={
|
||||
isLoading || currentParticipationStatus === 'DECLINED'
|
||||
}
|
||||
>
|
||||
✗ {t('calendar.event.decline', { defaultValue: 'Refuser' })}
|
||||
</Button>
|
||||
</div>
|
||||
{currentParticipationStatus && (
|
||||
<div className="event-modal__invitation-status">
|
||||
{t('calendar.event.yourResponse', {
|
||||
defaultValue: 'Votre réponse',
|
||||
})}
|
||||
:{' '}
|
||||
<strong>
|
||||
{currentParticipationStatus === 'ACCEPTED' &&
|
||||
t('calendar.event.accepted', { defaultValue: 'Accepté' })}
|
||||
{currentParticipationStatus === 'TENTATIVE' &&
|
||||
t('calendar.event.tentative', {
|
||||
defaultValue: 'Peut-être',
|
||||
})}
|
||||
{currentParticipationStatus === 'DECLINED' &&
|
||||
t('calendar.event.declined', { defaultValue: 'Refusé' })}
|
||||
{currentParticipationStatus === 'NEEDS-ACTION' &&
|
||||
t('calendar.event.needsAction', {
|
||||
defaultValue: 'En attente',
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label={t('calendar.event.calendar', { defaultValue: 'Calendrier' })}
|
||||
value={selectedCalendarUrl}
|
||||
onChange={(e) => setSelectedCalendarUrl(String(e.target.value))}
|
||||
options={calendars.map((cal) => ({
|
||||
value: cal.url,
|
||||
label: cal.displayName || cal.url,
|
||||
}))}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<label className="event-modal__checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllDay}
|
||||
onChange={handleAllDayChange}
|
||||
/>
|
||||
<span>{t('calendar.event.allDay')}</span>
|
||||
</label>
|
||||
|
||||
<div className="event-modal__datetime-row">
|
||||
<SectionRow
|
||||
icon="edit"
|
||||
label={t("calendar.event.calendar")}
|
||||
alwaysOpen={true}
|
||||
>
|
||||
<Input
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
label={t('calendar.event.start')}
|
||||
value={startDateTime}
|
||||
onChange={handleStartDateChange}
|
||||
label={t("calendar.event.title")}
|
||||
hideLabel
|
||||
autoFocus={mode === "create"}
|
||||
value={form.title}
|
||||
onChange={(e) => form.setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.preventDefault();
|
||||
}}
|
||||
fullWidth
|
||||
placeholder={t("calendar.event.titlePlaceholder")}
|
||||
variant="classic"
|
||||
/>
|
||||
</SectionRow>
|
||||
<SectionRow
|
||||
icon="event"
|
||||
label={t("calendar.event.calendar")}
|
||||
alwaysOpen={true}
|
||||
>
|
||||
<Select
|
||||
label={t("calendar.event.calendar")}
|
||||
hideLabel
|
||||
value={form.selectedCalendarUrl}
|
||||
onChange={(e) =>
|
||||
form.setSelectedCalendarUrl(String(e.target.value))
|
||||
}
|
||||
options={calendars.map((cal) => ({
|
||||
value: cal.url,
|
||||
label: cal.displayName || cal.url,
|
||||
}))}
|
||||
variant="classic"
|
||||
fullWidth
|
||||
/>
|
||||
<Input
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
label={t('calendar.event.end')}
|
||||
value={endDateTime}
|
||||
onChange={(e) => setEndDateTime(e.target.value)}
|
||||
fullWidth
|
||||
</SectionRow>
|
||||
<DateTimeSection
|
||||
startDateTime={form.startDateTime}
|
||||
endDateTime={form.endDateTime}
|
||||
isAllDay={form.isAllDay}
|
||||
onStartChange={form.handleStartDateChange}
|
||||
onEndChange={form.setEndDateTime}
|
||||
onAllDayChange={form.handleAllDayChange}
|
||||
/>
|
||||
{form.isSectionExpanded("recurrence") && (
|
||||
<RecurrenceSection
|
||||
recurrence={form.recurrence}
|
||||
onChange={form.setRecurrence}
|
||||
alwaysOpen
|
||||
/>
|
||||
)}
|
||||
{isInvited && mode === "edit" && onRespondToInvitation && (
|
||||
<InvitationResponseSection
|
||||
organizer={event?.organizer}
|
||||
currentStatus={currentParticipationStatus}
|
||||
isLoading={isLoading}
|
||||
onRespond={handleRespondToInvitation}
|
||||
/>
|
||||
)}
|
||||
{form.isSectionExpanded("videoConference") && (
|
||||
<VideoConferenceSection
|
||||
url={form.videoConferenceUrl}
|
||||
onChange={form.setVideoConferenceUrl}
|
||||
alwaysOpen
|
||||
/>
|
||||
)}
|
||||
{form.isSectionExpanded("location") && (
|
||||
<LocationSection
|
||||
location={form.location}
|
||||
onChange={form.setLocation}
|
||||
alwaysOpen
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={t('calendar.event.location')}
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label={t('calendar.event.description')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<div className="event-modal__features">
|
||||
<button
|
||||
type="button"
|
||||
className={`event-modal__feature-tag ${
|
||||
showAttendees ? 'event-modal__feature-tag--active' : ''
|
||||
}`}
|
||||
onClick={() => setShowAttendees(!showAttendees)}
|
||||
>
|
||||
<span className="material-icons">people</span>
|
||||
{t('calendar.event.attendees')}
|
||||
{attendees.length > 0 && ` (${attendees.length})`}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`event-modal__feature-tag ${
|
||||
showRecurrence ? 'event-modal__feature-tag--active' : ''
|
||||
}`}
|
||||
onClick={() => setShowRecurrence(!showRecurrence)}
|
||||
>
|
||||
<span className="material-icons">repeat</span>
|
||||
{t('calendar.recurrence.label')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAttendees && (
|
||||
<div className="event-modal__attendees-input">
|
||||
<AttendeesInput
|
||||
attendees={attendees}
|
||||
onChange={setAttendees}
|
||||
organizerEmail={user?.email}
|
||||
organizer={organizer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRecurrence && (
|
||||
<div className="event-modal__recurrence-editor">
|
||||
<RecurrenceEditor value={recurrence} onChange={setRecurrence} />
|
||||
</div>
|
||||
{form.isSectionExpanded("attendees") && (
|
||||
<AttendeesSection
|
||||
attendees={form.attendees}
|
||||
onChange={form.setAttendees}
|
||||
organizerEmail={user?.email}
|
||||
organizer={organizer}
|
||||
alwaysOpen
|
||||
/>
|
||||
)}
|
||||
{form.isSectionExpanded("description") && (
|
||||
<DescriptionSection
|
||||
description={form.description}
|
||||
onChange={form.setDescription}
|
||||
alwaysOpen
|
||||
/>
|
||||
)}
|
||||
<SectionPills
|
||||
pills={pills}
|
||||
isSectionExpanded={form.isSectionExpanded}
|
||||
onToggle={form.toggleSection}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<DeleteEventModal
|
||||
isOpen={showDeleteModal}
|
||||
isRecurring={!!recurrence}
|
||||
isRecurring={!!form.recurrence}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
.recurrence-editor {
|
||||
&__label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
&__card {
|
||||
background: var(--c--theme--colors--greyscale-50, #f8f9fa);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
background: var(--c--theme--colors--primary-100, #e8f0fe);
|
||||
color: var(--c--theme--colors--primary-700, #1a56db);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__interval {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__weekdays {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__weekday-button {
|
||||
@@ -10,32 +40,94 @@
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #f1f3f4;
|
||||
background: var(--c--theme--colors--greyscale-100, #f1f3f4);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #e8eaeb;
|
||||
background: var(--c--theme--colors--greyscale-200, #e8eaeb);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: #1a73e8;
|
||||
background: var(--c--theme--colors--primary-600, #1a73e8);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #1557b0;
|
||||
background: var(--c--theme--colors--primary-700, #1557b0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__day-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
&__section-label {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--c--theme--colors--greyscale-500, #6b7280);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__end {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__end-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__end-btn {
|
||||
border: 1px solid var(--c--theme--colors--greyscale-300, #d1d5db);
|
||||
border-radius: 20px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.15s;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c--theme--colors--greyscale-400, #9ca3af);
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: var(--c--theme--colors--primary-600, #1a73e8);
|
||||
color: var(--c--theme--colors--primary-600, #1a73e8);
|
||||
background: var(--c--theme--colors--primary-50, #eff6ff);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c--theme--colors--primary-700, #1557b0);
|
||||
color: var(--c--theme--colors--primary-700, #1557b0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__end-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__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;
|
||||
@@ -44,37 +136,3 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility classes for layout
|
||||
.recurrence-editor-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&--align-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&--flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&--gap-1rem {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&--gap-0-5rem {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&--margin-left-2rem {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
&--margin-top-0-5rem {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
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';
|
||||
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";
|
||||
|
||||
type RecurrenceFrequency = IcsRecurrenceRule['frequency'];
|
||||
type RecurrenceFrequency = IcsRecurrenceRule["frequency"];
|
||||
type EndType = "never" | "count" | "date";
|
||||
|
||||
const WEEKDAY_KEYS: IcsWeekDay[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
|
||||
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' },
|
||||
{ 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 {
|
||||
@@ -27,98 +28,140 @@ interface RecurrenceEditorProps {
|
||||
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<number, number> = {
|
||||
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 {
|
||||
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');
|
||||
return t("calendar.recurrence.warnings.februaryMax");
|
||||
}
|
||||
if (month === 2 && day === 29) {
|
||||
return t('calendar.recurrence.warnings.leapYear');
|
||||
return t("calendar.recurrence.warnings.leapYear");
|
||||
}
|
||||
if ([4, 6, 9, 11].includes(month) && day > 30) {
|
||||
return t('calendar.recurrence.warnings.monthMax30');
|
||||
return t("calendar.recurrence.warnings.monthMax30");
|
||||
}
|
||||
if (day > 31) {
|
||||
return t('calendar.recurrence.warnings.dayMax31');
|
||||
return t("calendar.recurrence.warnings.dayMax31");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEndType(value?: IcsRecurrenceRule): EndType {
|
||||
if (value?.count) return "count";
|
||||
if (value?.until) return "date";
|
||||
return "never";
|
||||
}
|
||||
|
||||
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.byMonthday?.length || value.byMonth?.length || value.count || value.until;
|
||||
return !!(
|
||||
value.interval !== 1 ||
|
||||
value.byDay?.length ||
|
||||
value.byMonthday?.length ||
|
||||
value.byMonth?.length ||
|
||||
value.count ||
|
||||
value.until
|
||||
);
|
||||
});
|
||||
|
||||
// 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 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 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 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 monthOptions = useMemo(
|
||||
() =>
|
||||
MONTHS.map((month) => ({
|
||||
value: String(month.value),
|
||||
label: t(`calendar.recurrence.months.${month.key}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const summary = useMemo((): string => {
|
||||
if (!isCustom || !value) return "";
|
||||
|
||||
const interval = value.interval || 1;
|
||||
const freq = value.frequency;
|
||||
const freqLabel = t(`calendar.recurrence.${freq === "DAILY" ? "days" : freq === "WEEKLY" ? "weeks" : freq === "MONTHLY" ? "months" : "years"}`);
|
||||
|
||||
let result = `${t("calendar.recurrence.everyLabel")} ${interval > 1 ? `${interval} ` : ""}${freqLabel}`;
|
||||
|
||||
if (freq === "WEEKLY" && value.byDay?.length) {
|
||||
const dayLabels = value.byDay.map((d) => {
|
||||
const dayKey = typeof d === "string" ? d : d.day;
|
||||
return t(
|
||||
`calendar.recurrence.weekdays.${dayKey.toLowerCase()}`,
|
||||
);
|
||||
});
|
||||
result += ` · ${dayLabels.join(", ")}`;
|
||||
}
|
||||
|
||||
if (value.until) {
|
||||
const dateStr =
|
||||
value.until.date instanceof Date
|
||||
? value.until.date.toISOString().split("T")[0]
|
||||
: "";
|
||||
result += ` · ${t("calendar.recurrence.on")} ${dateStr}`;
|
||||
} else if (value.count) {
|
||||
result += ` · ${value.count} ${t("calendar.recurrence.occurrences")}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [isCustom, value, t]);
|
||||
|
||||
const getSimpleValue = (): string => {
|
||||
if (!value) return 'NONE';
|
||||
if (isCustom) return 'CUSTOM';
|
||||
if (!value) return "NONE";
|
||||
if (isCustom) return "CUSTOM";
|
||||
return value.frequency;
|
||||
};
|
||||
|
||||
const handleSimpleChange = (newValue: string) => {
|
||||
if (newValue === 'NONE') {
|
||||
if (newValue === "NONE") {
|
||||
setIsCustom(false);
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === 'CUSTOM') {
|
||||
if (newValue === "CUSTOM") {
|
||||
setIsCustom(true);
|
||||
onChange({
|
||||
frequency: 'WEEKLY',
|
||||
interval: 1
|
||||
frequency: "WEEKLY",
|
||||
interval: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -131,23 +174,24 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
byMonthday: undefined,
|
||||
byMonth: undefined,
|
||||
count: undefined,
|
||||
until: undefined
|
||||
until: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (updates: Partial<IcsRecurrenceRule>) => {
|
||||
onChange({
|
||||
frequency: 'WEEKLY',
|
||||
frequency: "WEEKLY",
|
||||
interval: 1,
|
||||
...value,
|
||||
...updates
|
||||
...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);
|
||||
return value.byDay.map((d) =>
|
||||
typeof d === "string" ? (d as IcsWeekDay) : d.day,
|
||||
);
|
||||
};
|
||||
|
||||
const isDaySelected = (day: IcsWeekDay): boolean => {
|
||||
@@ -157,17 +201,15 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
const toggleDay = (day: IcsWeekDay) => {
|
||||
const currentDays = getSelectedDays();
|
||||
const newDays = currentDays.includes(day)
|
||||
? currentDays.filter(d => d !== day)
|
||||
? currentDays.filter((d) => d !== day)
|
||||
: [...currentDays, day];
|
||||
handleChange({ byDay: newDays.map(d => ({ day: d })) });
|
||||
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);
|
||||
};
|
||||
@@ -180,78 +222,97 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
handleChange({ byMonth: [month] });
|
||||
};
|
||||
|
||||
// Calculate date warning
|
||||
const endType = getEndType(value);
|
||||
|
||||
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;
|
||||
const selectedMonth =
|
||||
value?.frequency === "YEARLY" ? parseInt(getMonth()) : undefined;
|
||||
const dateWarning =
|
||||
isCustom &&
|
||||
(value?.frequency === "MONTHLY" || value?.frequency === "YEARLY")
|
||||
? getDateWarning(t, monthDay, selectedMonth)
|
||||
: null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--gap-1rem">
|
||||
<div className="recurrence-editor">
|
||||
<Select
|
||||
label={t('calendar.recurrence.label')}
|
||||
label={t("calendar.recurrence.label")}
|
||||
hideLabel
|
||||
value={getSimpleValue()}
|
||||
options={recurrenceOptions}
|
||||
onChange={(e) => handleSimpleChange(String(e.target.value ?? ''))}
|
||||
onChange={(e) => handleSimpleChange(String(e.target.value ?? ""))}
|
||||
variant="classic"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{isCustom && (
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--gap-1rem recurrence-editor-layout--margin-left-2rem">
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-1rem">
|
||||
<span>{t('calendar.recurrence.everyLabel')}</span>
|
||||
<div className="recurrence-editor__card">
|
||||
{summary && (
|
||||
<div className="recurrence-editor__summary">{summary}</div>
|
||||
)}
|
||||
|
||||
<div className="recurrence-editor__interval">
|
||||
<span>{t("calendar.recurrence.everyLabel")}</span>
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
variant="classic"
|
||||
min={1}
|
||||
value={value?.interval || 1}
|
||||
onChange={(e) => handleChange({ interval: parseInt(e.target.value) || 1 })}
|
||||
onChange={(e) =>
|
||||
handleChange({ interval: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label=""
|
||||
value={value?.frequency || 'WEEKLY'}
|
||||
variant="classic"
|
||||
value={value?.frequency || "WEEKLY"}
|
||||
options={frequencyOptions}
|
||||
onChange={(e) => handleChange({ frequency: String(e.target.value ?? '') as RecurrenceFrequency })}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
frequency: String(
|
||||
e.target.value ?? "",
|
||||
) as RecurrenceFrequency,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* WEEKLY: Day selection */}
|
||||
{value?.frequency === 'WEEKLY' && (
|
||||
<div className="recurrence-editor-layout">
|
||||
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOn')}</span>
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem recurrence-editor-layout--flex-wrap">
|
||||
{weekdays.map(day => {
|
||||
const isSelected = isDaySelected(day.value);
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
className={`recurrence-editor__weekday-button ${isSelected ? 'recurrence-editor__weekday-button--selected' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleDay(day.value);
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{value?.frequency === "WEEKLY" && (
|
||||
<div className="recurrence-editor__weekdays">
|
||||
{weekdays.map((day) => {
|
||||
const isSelected = isDaySelected(day.value);
|
||||
return (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
className={`recurrence-editor__weekday-button ${isSelected ? "recurrence-editor__weekday-button--selected" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleDay(day.value);
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MONTHLY: Day of month selection */}
|
||||
{value?.frequency === 'MONTHLY' && (
|
||||
<div className="recurrence-editor-layout">
|
||||
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOnDay')}</span>
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
|
||||
<span>{t('calendar.recurrence.dayOfMonth')}</span>
|
||||
{value?.frequency === "MONTHLY" && (
|
||||
<div className="recurrence-editor__day-select">
|
||||
<span className="recurrence-editor__label">
|
||||
{t("calendar.recurrence.repeatOnDay")}
|
||||
</span>
|
||||
<div className="recurrence-editor__interval">
|
||||
<span>{t("calendar.recurrence.dayOfMonth")}</span>
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
variant="classic"
|
||||
value={getMonthDay()}
|
||||
onChange={(e) => {
|
||||
const day = parseInt(e.target.value) || 1;
|
||||
@@ -259,30 +320,35 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
handleMonthDayChange(day);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
{dateWarning && (
|
||||
<div className="recurrence-editor__warning">
|
||||
⚠️ {dateWarning}
|
||||
{dateWarning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YEARLY: Month + Day selection */}
|
||||
{value?.frequency === 'YEARLY' && (
|
||||
<div className="recurrence-editor-layout">
|
||||
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOnDate')}</span>
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
|
||||
{value?.frequency === "YEARLY" && (
|
||||
<div className="recurrence-editor__day-select">
|
||||
<span className="recurrence-editor__label">
|
||||
{t("calendar.recurrence.repeatOnDate")}
|
||||
</span>
|
||||
<div className="recurrence-editor__interval">
|
||||
<Select
|
||||
label=""
|
||||
value={getMonth()}
|
||||
variant="classic"
|
||||
options={monthOptions}
|
||||
onChange={(e) => handleMonthChange(parseInt(String(e.target.value)) || 1)}
|
||||
onChange={(e) =>
|
||||
handleMonthChange(parseInt(String(e.target.value)) || 1)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
variant="classic"
|
||||
min={1}
|
||||
max={31}
|
||||
value={getMonthDay()}
|
||||
@@ -292,78 +358,91 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
|
||||
handleMonthDayChange(day);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
{dateWarning && (
|
||||
<div className="recurrence-editor__warning">
|
||||
⚠️ {dateWarning}
|
||||
{dateWarning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End conditions */}
|
||||
<div className="recurrence-editor-layout">
|
||||
<span className="recurrence-editor__label">{t('calendar.recurrence.endsLabel')}</span>
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
|
||||
<input
|
||||
type="radio"
|
||||
id="end-never"
|
||||
name="end-type"
|
||||
checked={!value?.count && !value?.until}
|
||||
onChange={() => handleChange({ count: undefined, until: undefined })}
|
||||
/>
|
||||
<label htmlFor="end-never">{t('calendar.recurrence.never')}</label>
|
||||
</div>
|
||||
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
|
||||
<input
|
||||
type="radio"
|
||||
id="end-date"
|
||||
name="end-type"
|
||||
checked={!!value?.until}
|
||||
onChange={() => handleChange({ until: { type: 'DATE', date: new Date() }, count: undefined })}
|
||||
/>
|
||||
<label htmlFor="end-date">{t('calendar.recurrence.on')}</label>
|
||||
{value?.until && (
|
||||
<Input
|
||||
label=""
|
||||
type="date"
|
||||
value={value.until.date instanceof Date ? value.until.date.toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => handleChange({
|
||||
until: {
|
||||
type: 'DATE',
|
||||
date: new Date(e.target.value),
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
|
||||
<input
|
||||
type="radio"
|
||||
id="end-count"
|
||||
name="end-type"
|
||||
checked={!!value?.count}
|
||||
onChange={() => handleChange({ count: 10, until: undefined })}
|
||||
/>
|
||||
<label htmlFor="end-count">{t('calendar.recurrence.after')}</label>
|
||||
{value?.count !== undefined && (
|
||||
<>
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.count}
|
||||
onChange={(e) => handleChange({ count: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
<span>{t('calendar.recurrence.occurrences')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="recurrence-editor__end">
|
||||
<span className="recurrence-editor__section-label">
|
||||
{t("calendar.recurrence.endsLabel")}
|
||||
</span>
|
||||
<div className="recurrence-editor__end-options">
|
||||
<button
|
||||
type="button"
|
||||
className={`recurrence-editor__end-btn ${endType === "never" ? "recurrence-editor__end-btn--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleChange({ count: undefined, until: undefined })
|
||||
}
|
||||
>
|
||||
{t("calendar.recurrence.never")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`recurrence-editor__end-btn ${endType === "count" ? "recurrence-editor__end-btn--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleChange({ count: 10, until: undefined })
|
||||
}
|
||||
>
|
||||
{t("calendar.recurrence.after")}...
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`recurrence-editor__end-btn ${endType === "date" ? "recurrence-editor__end-btn--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleChange({
|
||||
until: { type: "DATE", date: new Date() },
|
||||
count: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("calendar.recurrence.on")}...
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{endType === "count" && (
|
||||
<div className="recurrence-editor__end-input">
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
variant="classic"
|
||||
min={1}
|
||||
value={value?.count ?? 10}
|
||||
onChange={(e) =>
|
||||
handleChange({ count: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
<span>{t("calendar.recurrence.occurrences")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{endType === "date" && (
|
||||
<div className="recurrence-editor__end-input">
|
||||
<Input
|
||||
label=""
|
||||
type="date"
|
||||
variant="classic"
|
||||
value={
|
||||
value?.until?.date instanceof Date
|
||||
? value.until.date.toISOString().split("T")[0]
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
until: {
|
||||
type: "DATE",
|
||||
date: new Date(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,145 +11,145 @@
|
||||
// Event Modal Styles (for use with Cunningham Modal)
|
||||
// ============================================================================
|
||||
|
||||
.event-modal {
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
// .event-modal {
|
||||
// &__content {
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// gap: 1rem;
|
||||
// }
|
||||
|
||||
// Date/Time row with two columns
|
||||
&__datetime-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
// // Date/Time row with two columns
|
||||
// &__datetime-row {
|
||||
// display: flex;
|
||||
// gap: 1rem;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
// > * {
|
||||
// flex: 1;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Features section (buttons + inputs)
|
||||
&__features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
// // Features section (buttons + inputs)
|
||||
// &__features {
|
||||
// display: flex;
|
||||
// flex-wrap: wrap;
|
||||
// gap: 0.5rem;
|
||||
// margin-top: 0.5rem;
|
||||
// }
|
||||
|
||||
&__attendees-input {
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
// &__attendees-input {
|
||||
// padding: 0.75rem;
|
||||
// background-color: #f8f9fa;
|
||||
// border-radius: 4px;
|
||||
// margin-top: 0.75rem;
|
||||
// }
|
||||
|
||||
&__recurrence-editor {
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
// &__recurrence-editor {
|
||||
// padding: 0.75rem;
|
||||
// background-color: #f8f9fa;
|
||||
// border-radius: 4px;
|
||||
// margin-top: 0.75rem;
|
||||
// }
|
||||
|
||||
// Checkbox for all-day
|
||||
&__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
// // Checkbox for all-day
|
||||
// &__checkbox {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// gap: 0.5rem;
|
||||
// cursor: pointer;
|
||||
// user-select: none;
|
||||
|
||||
input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
// input[type="checkbox"] {
|
||||
// cursor: pointer;
|
||||
// width: 1.25rem;
|
||||
// height: 1.25rem;
|
||||
// margin: 0;
|
||||
// }
|
||||
|
||||
span {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
// span {
|
||||
// font-size: 0.9375rem;
|
||||
// line-height: 1.5;
|
||||
// color: #212529;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Feature tag button
|
||||
&__feature-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 1rem;
|
||||
background-color: #fff;
|
||||
color: #495057;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease-in-out;
|
||||
// // Feature tag button
|
||||
// &__feature-tag {
|
||||
// display: inline-flex;
|
||||
// align-items: center;
|
||||
// gap: 0.375rem;
|
||||
// padding: 0.375rem 0.75rem;
|
||||
// border: 1px solid #dee2e6;
|
||||
// border-radius: 1rem;
|
||||
// background-color: #fff;
|
||||
// color: #495057;
|
||||
// font-size: 0.875rem;
|
||||
// cursor: pointer;
|
||||
// transition: all 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
// &:hover {
|
||||
// background-color: #f8f9fa;
|
||||
// border-color: #adb5bd;
|
||||
// }
|
||||
|
||||
&--active {
|
||||
background-color: #e7f5ff;
|
||||
border-color: #339af0;
|
||||
color: #1971c2;
|
||||
}
|
||||
// &--active {
|
||||
// background-color: #e7f5ff;
|
||||
// border-color: #339af0;
|
||||
// color: #1971c2;
|
||||
// }
|
||||
|
||||
.material-icons {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
// .material-icons {
|
||||
// font-size: 1.125rem;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Invitation section
|
||||
&__invitation {
|
||||
padding: 1rem;
|
||||
background-color: #e7f5ff;
|
||||
border: 1px solid #339af0;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
// // Invitation section
|
||||
// &__invitation {
|
||||
// padding: 1rem;
|
||||
// background-color: #e7f5ff;
|
||||
// border: 1px solid #339af0;
|
||||
// border-radius: 0.5rem;
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// gap: 0.75rem;
|
||||
// }
|
||||
|
||||
&__invitation-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
// &__invitation-header {
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// gap: 0.25rem;
|
||||
// }
|
||||
|
||||
&__invitation-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #1971c2;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
// &__invitation-label {
|
||||
// font-size: 0.875rem;
|
||||
// font-weight: 600;
|
||||
// color: #1971c2;
|
||||
// text-transform: uppercase;
|
||||
// letter-spacing: 0.05em;
|
||||
// }
|
||||
|
||||
&__invitation-organizer {
|
||||
font-size: 0.9375rem;
|
||||
color: #495057;
|
||||
}
|
||||
// &__invitation-organizer {
|
||||
// font-size: 0.9375rem;
|
||||
// color: #495057;
|
||||
// }
|
||||
|
||||
&__invitation-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
// &__invitation-actions {
|
||||
// display: flex;
|
||||
// gap: 0.5rem;
|
||||
// flex-wrap: wrap;
|
||||
// }
|
||||
|
||||
&__invitation-status {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
// &__invitation-status {
|
||||
// padding: 0.5rem 0.75rem;
|
||||
// background-color: #fff;
|
||||
// border-radius: 0.375rem;
|
||||
// font-size: 0.875rem;
|
||||
// color: #495057;
|
||||
|
||||
strong {
|
||||
color: #1971c2;
|
||||
}
|
||||
}
|
||||
}
|
||||
// strong {
|
||||
// color: #1971c2;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// ============================================================================
|
||||
// Delete Event Modal Styles
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import type { AttachmentMeta } from "../types";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface AttachmentsSectionProps {
|
||||
attachments: AttachmentMeta[];
|
||||
onChange: (attachments: AttachmentMeta[]) => void;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
export const AttachmentsSection = ({
|
||||
attachments,
|
||||
onChange,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: AttachmentsSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const summary =
|
||||
attachments.length > 0
|
||||
? `${attachments.length} ${t("calendar.event.sections.attachment", { count: attachments.length })}`
|
||||
: undefined;
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const newAttachments: AttachmentMeta[] = Array.from(files).map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}));
|
||||
|
||||
onChange([...attachments, ...newAttachments]);
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(attachments.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="attach_file"
|
||||
label={t("calendar.event.sections.addAttachment")}
|
||||
summary={summary}
|
||||
isEmpty={attachments.length === 0}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="attachments-section">
|
||||
{attachments.map((attachment) => (
|
||||
<div key={attachment.id} className="attachments-section__item">
|
||||
<span className="material-icons" style={{ fontSize: 18 }}>
|
||||
description
|
||||
</span>
|
||||
<div className="attachments-section__info">
|
||||
<span className="attachments-section__name">
|
||||
{attachment.name}
|
||||
</span>
|
||||
<span className="attachments-section__size">
|
||||
{formatFileSize(attachment.size)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="attachments-section__remove"
|
||||
onClick={() => handleRemove(attachment.id)}
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="neutral"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>
|
||||
add
|
||||
</span>
|
||||
{t("calendar.event.sections.addAttachment")}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
|
||||
import { AttendeesInput } from "../AttendeesInput";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface AttendeesSectionProps {
|
||||
attendees: IcsAttendee[];
|
||||
onChange: (attendees: IcsAttendee[]) => void;
|
||||
organizerEmail?: string;
|
||||
organizer?: IcsOrganizer;
|
||||
alwaysOpen?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const AttendeesSection = ({
|
||||
attendees,
|
||||
onChange,
|
||||
organizerEmail,
|
||||
organizer,
|
||||
alwaysOpen,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: AttendeesSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="group"
|
||||
label={t("calendar.event.sections.addAttendees")}
|
||||
isEmpty={attendees.length === 0}
|
||||
alwaysOpen={alwaysOpen}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
iconAlign="flex-start"
|
||||
>
|
||||
<AttendeesInput
|
||||
attendees={attendees}
|
||||
onChange={onChange}
|
||||
organizerEmail={organizerEmail}
|
||||
organizer={organizer}
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface DateTimeSectionProps {
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
isAllDay: boolean;
|
||||
onStartChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onEndChange: (value: string) => void;
|
||||
onAllDayChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const DateTimeSection = ({
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
isAllDay,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
onAllDayChange,
|
||||
}: DateTimeSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionRow
|
||||
icon="access_time"
|
||||
label={t("calendar.event.sections.addDateTime")}
|
||||
isEmpty={!startDateTime || !endDateTime}
|
||||
alwaysOpen={true}
|
||||
iconAlign="flex-start"
|
||||
>
|
||||
<div className="datetime-section">
|
||||
<div className="datetime-section__inputs">
|
||||
<Input
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
label={t("calendar.event.start")}
|
||||
value={startDateTime}
|
||||
onChange={onStartChange}
|
||||
fullWidth
|
||||
hideLabel
|
||||
variant="classic"
|
||||
/>
|
||||
<span
|
||||
className="material-icons datetime-section__arrow"
|
||||
aria-hidden="true"
|
||||
>
|
||||
arrow_forward
|
||||
</span>
|
||||
<Input
|
||||
type={isAllDay ? "date" : "datetime-local"}
|
||||
label={t("calendar.event.end")}
|
||||
value={endDateTime}
|
||||
onChange={(e) => onEndChange(e.target.value)}
|
||||
fullWidth
|
||||
hideLabel
|
||||
variant="classic"
|
||||
/>
|
||||
</div>
|
||||
<label className="datetime-section__allday">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllDay}
|
||||
onChange={onAllDayChange}
|
||||
/>
|
||||
<span>{t("calendar.event.allDay")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</SectionRow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TextArea } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface DescriptionSectionProps {
|
||||
description: string;
|
||||
onChange: (value: string) => void;
|
||||
alwaysOpen?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const DescriptionSection = ({
|
||||
description,
|
||||
onChange,
|
||||
alwaysOpen,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: DescriptionSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="notes"
|
||||
label={t("calendar.event.sections.addDescription")}
|
||||
isEmpty={!description}
|
||||
alwaysOpen={alwaysOpen}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
iconAlign="flex-start"
|
||||
>
|
||||
<TextArea
|
||||
label={t("calendar.event.description")}
|
||||
placeholder={t("calendar.event.descriptionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
fullWidth
|
||||
variant="classic"
|
||||
hideLabel
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
.invitation-response {
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #202124;
|
||||
}
|
||||
|
||||
&__organizer {
|
||||
font-size: 0.8125rem;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 0.8125rem;
|
||||
color: #5f6368;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import type { IcsOrganizer } from "ts-ics";
|
||||
|
||||
interface InvitationResponseSectionProps {
|
||||
organizer?: IcsOrganizer;
|
||||
currentStatus: string;
|
||||
isLoading: boolean;
|
||||
onRespond: (status: "ACCEPTED" | "TENTATIVE" | "DECLINED") => void;
|
||||
}
|
||||
|
||||
export const InvitationResponseSection = ({
|
||||
organizer,
|
||||
currentStatus,
|
||||
isLoading,
|
||||
onRespond,
|
||||
}: InvitationResponseSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="invitation-response">
|
||||
<div className="invitation-response__header">
|
||||
<span className="invitation-response__label">
|
||||
{t("calendar.event.invitation", { defaultValue: "Invitation" })}
|
||||
</span>
|
||||
<span className="invitation-response__organizer">
|
||||
{t("calendar.event.organizedBy", {
|
||||
defaultValue: "Organisé par",
|
||||
})}{" "}
|
||||
{organizer?.name || organizer?.email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="invitation-response__actions">
|
||||
<Button
|
||||
size="small"
|
||||
color={currentStatus === "ACCEPTED" ? "success" : "neutral"}
|
||||
onClick={() => onRespond("ACCEPTED")}
|
||||
disabled={isLoading || currentStatus === "ACCEPTED"}
|
||||
>
|
||||
✓ {t("calendar.event.accept", { defaultValue: "Accepter" })}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color={currentStatus === "TENTATIVE" ? "warning" : "neutral"}
|
||||
onClick={() => onRespond("TENTATIVE")}
|
||||
disabled={isLoading || currentStatus === "TENTATIVE"}
|
||||
>
|
||||
? {t("calendar.event.maybe", { defaultValue: "Peut-être" })}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color={currentStatus === "DECLINED" ? "error" : "neutral"}
|
||||
onClick={() => onRespond("DECLINED")}
|
||||
disabled={isLoading || currentStatus === "DECLINED"}
|
||||
>
|
||||
✗ {t("calendar.event.decline", { defaultValue: "Refuser" })}
|
||||
</Button>
|
||||
</div>
|
||||
{currentStatus && (
|
||||
<div className="invitation-response__status">
|
||||
{t("calendar.event.yourResponse", {
|
||||
defaultValue: "Votre réponse",
|
||||
})}
|
||||
:{" "}
|
||||
<strong>
|
||||
{currentStatus === "ACCEPTED" &&
|
||||
t("calendar.event.accepted", { defaultValue: "Accepté" })}
|
||||
{currentStatus === "TENTATIVE" &&
|
||||
t("calendar.event.tentative", { defaultValue: "Peut-être" })}
|
||||
{currentStatus === "DECLINED" &&
|
||||
t("calendar.event.declined", { defaultValue: "Refusé" })}
|
||||
{currentStatus === "NEEDS-ACTION" &&
|
||||
t("calendar.event.needsAction", {
|
||||
defaultValue: "En attente",
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface LocationSectionProps {
|
||||
location: string;
|
||||
onChange: (value: string) => void;
|
||||
alwaysOpen?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const LocationSection = ({
|
||||
location,
|
||||
onChange,
|
||||
alwaysOpen,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: LocationSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="place"
|
||||
label={t("calendar.event.sections.addLocation")}
|
||||
isEmpty={!location}
|
||||
alwaysOpen={alwaysOpen}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<Input
|
||||
label={t("calendar.event.location")}
|
||||
hideLabel
|
||||
value={location}
|
||||
placeholder={t("calendar.event.locationPlaceholder")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
variant="classic"
|
||||
fullWidth
|
||||
/>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { IcsRecurrenceRule } from "ts-ics";
|
||||
import { RecurrenceEditor } from "../RecurrenceEditor";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface RecurrenceSectionProps {
|
||||
recurrence: IcsRecurrenceRule | undefined;
|
||||
onChange: (value: IcsRecurrenceRule | undefined) => void;
|
||||
alwaysOpen?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const RecurrenceSection = ({
|
||||
recurrence,
|
||||
onChange,
|
||||
alwaysOpen,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: RecurrenceSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="repeat"
|
||||
label={t("calendar.event.sections.addRecurrence")}
|
||||
isEmpty={!recurrence}
|
||||
alwaysOpen={alwaysOpen}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
iconAlign="flex-start"
|
||||
>
|
||||
<RecurrenceEditor value={recurrence} onChange={onChange} />
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Select } from "@gouvfr-lasuite/cunningham-react";
|
||||
import type { IcsAlarm } from "ts-ics";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface RemindersSectionProps {
|
||||
alarms: IcsAlarm[];
|
||||
onChange: (alarms: IcsAlarm[]) => void;
|
||||
alwaysOpen?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
const REMINDER_PRESETS = [
|
||||
{ minutes: 5, key: "5min" },
|
||||
{ minutes: 15, key: "15min" },
|
||||
{ minutes: 30, key: "30min" },
|
||||
{ minutes: 60, key: "1hour" },
|
||||
{ minutes: 1440, key: "1day" },
|
||||
{ minutes: 10080, key: "1week" },
|
||||
];
|
||||
|
||||
const alarmToMinutes = (alarm: IcsAlarm): number => {
|
||||
const trigger = alarm.trigger;
|
||||
if (trigger.type !== "relative") return 15;
|
||||
const d = trigger.value;
|
||||
return (
|
||||
(d.weeks || 0) * 10080 +
|
||||
(d.days || 0) * 1440 +
|
||||
(d.hours || 0) * 60 +
|
||||
(d.minutes || 0)
|
||||
);
|
||||
};
|
||||
|
||||
const minutesToAlarm = (minutes: number): IcsAlarm => ({
|
||||
action: "DISPLAY",
|
||||
trigger: {
|
||||
type: "relative",
|
||||
value: {
|
||||
before: true,
|
||||
...(minutes >= 10080 && minutes % 10080 === 0
|
||||
? { weeks: minutes / 10080 }
|
||||
: minutes >= 1440 && minutes % 1440 === 0
|
||||
? { days: minutes / 1440 }
|
||||
: minutes >= 60 && minutes % 60 === 0
|
||||
? { hours: minutes / 60 }
|
||||
: { minutes }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const RemindersSection = ({
|
||||
alarms,
|
||||
onChange,
|
||||
alwaysOpen,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: RemindersSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAdd = () => {
|
||||
onChange([...alarms, minutesToAlarm(15)]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(alarms.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (index: number, minutes: number) => {
|
||||
const updated = [...alarms];
|
||||
updated[index] = minutesToAlarm(minutes);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const presetOptions = REMINDER_PRESETS.map((p) => ({
|
||||
value: String(p.minutes),
|
||||
label: t(`calendar.event.reminders.${p.key}`),
|
||||
}));
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="notifications"
|
||||
label={t("calendar.event.sections.addReminder")}
|
||||
isEmpty={alarms.length === 0}
|
||||
alwaysOpen={alwaysOpen}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="reminders-section">
|
||||
{alarms.map((alarm, index) => (
|
||||
<div key={index} className="reminders-section__item">
|
||||
<Select
|
||||
label=""
|
||||
value={String(alarmToMinutes(alarm))}
|
||||
onChange={(e) => handleChange(index, Number(e.target.value))}
|
||||
options={presetOptions}
|
||||
fullWidth
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="reminders-section__remove"
|
||||
onClick={() => handleRemove(index)}
|
||||
aria-label={t("common.cancel")}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="small" color="neutral" onClick={handleAdd}>
|
||||
<span className="material-icons" style={{ fontSize: 16 }}>
|
||||
add
|
||||
</span>
|
||||
{t("calendar.event.sections.addReminder")}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
.section-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
border: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
background-color: var(--c--contextuals--background--surface--primary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
|
||||
&--active {
|
||||
border: 1px solid
|
||||
var(--c--contextuals--border--semantic--contextual--primary);
|
||||
background-color: var(
|
||||
--c--contextuals--background--semantic--neutral--tertiary
|
||||
);
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.section-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
interface SectionPillProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const SectionPill = ({
|
||||
icon,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
}: SectionPillProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`section-pill ${isActive ? "section-pill--active" : ""}`}
|
||||
onClick={onClick}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<span className="material-icons section-pill__icon">{icon}</span>
|
||||
<span className="section-pill__label">{label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SectionPill } from "./SectionPill";
|
||||
import type { EventFormSectionId } from "../types";
|
||||
|
||||
interface PillConfig {
|
||||
id: EventFormSectionId;
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SectionPillsProps {
|
||||
pills: PillConfig[];
|
||||
isSectionExpanded: (id: EventFormSectionId) => boolean;
|
||||
onToggle: (id: EventFormSectionId) => void;
|
||||
}
|
||||
|
||||
export const SectionPills = ({
|
||||
pills,
|
||||
isSectionExpanded,
|
||||
onToggle,
|
||||
}: SectionPillsProps) => {
|
||||
return (
|
||||
<div className="section-pills">
|
||||
{pills.map((pill) => (
|
||||
<SectionPill
|
||||
key={pill.id}
|
||||
icon={pill.icon}
|
||||
label={pill.label}
|
||||
isActive={isSectionExpanded(pill.id)}
|
||||
onClick={() => onToggle(pill.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
.section-row {
|
||||
border-bottom: 1px solid #f1f3f4;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--always-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--icon-start {
|
||||
align-items: flex-start;
|
||||
|
||||
.section-row__icon {
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&--icon-start &__header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&--always-open &__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.625rem 0.5rem;
|
||||
min-height: 44px;
|
||||
gap: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.material-icons {
|
||||
font-size: 20px;
|
||||
color: #5f6368;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: #202124;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--empty &__label {
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
&__right-action {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.material-icons {
|
||||
font-size: 20px;
|
||||
color: #5f6368;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 0 0.5rem 0.75rem calc(36px + 0.25rem);
|
||||
animation: sectionSlideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
&--expanded &__header--clickable:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sectionSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface SectionRowProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
summary?: string;
|
||||
isEmpty?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
rightAction?: ReactNode;
|
||||
children?: ReactNode;
|
||||
alwaysOpen?: boolean;
|
||||
iconAlign?: "center" | "flex-start";
|
||||
}
|
||||
|
||||
export const SectionRow = ({
|
||||
icon,
|
||||
label,
|
||||
summary,
|
||||
isEmpty = false,
|
||||
isExpanded = false,
|
||||
onToggle,
|
||||
rightAction,
|
||||
children,
|
||||
alwaysOpen = false,
|
||||
iconAlign = "center",
|
||||
}: SectionRowProps) => {
|
||||
const iconAlignClass =
|
||||
iconAlign === "flex-start" ? "section-row--icon-start" : "";
|
||||
|
||||
if (alwaysOpen) {
|
||||
return (
|
||||
<div className={`section-row section-row--always-open ${iconAlignClass}`}>
|
||||
<div className="section-row__icon">
|
||||
<span className="material-icons">{icon}</span>
|
||||
</div>
|
||||
<div className="section-row__body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isClickable = !!onToggle;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`section-row ${isExpanded ? "section-row--expanded" : ""} ${
|
||||
isEmpty ? "section-row--empty" : ""
|
||||
} ${iconAlignClass}`}
|
||||
>
|
||||
<div
|
||||
className={`section-row__header ${
|
||||
isClickable ? "section-row__header--clickable" : ""
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (isClickable && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onToggle?.();
|
||||
}
|
||||
}}
|
||||
role={isClickable ? "button" : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
aria-expanded={isClickable ? isExpanded : undefined}
|
||||
>
|
||||
<div className="section-row__icon">
|
||||
<span className="material-icons">{icon}</span>
|
||||
</div>
|
||||
<div className="section-row__label">
|
||||
{isEmpty ? label : summary || label}
|
||||
</div>
|
||||
{rightAction && (
|
||||
<div className="section-row__right-action">{rightAction}</div>
|
||||
)}
|
||||
{isClickable && (
|
||||
<div className="section-row__chevron">
|
||||
<span className="material-icons">
|
||||
{isExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && children && (
|
||||
<div className="section-row__content">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select } from "@gouvfr-lasuite/cunningham-react";
|
||||
import type {
|
||||
IcsClassType,
|
||||
IcsEventStatusType,
|
||||
IcsTimeTransparentType,
|
||||
} from "ts-ics";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface StatusSectionProps {
|
||||
status: IcsEventStatusType;
|
||||
visibility: IcsClassType;
|
||||
availability: IcsTimeTransparentType;
|
||||
onStatusChange: (value: IcsEventStatusType) => void;
|
||||
onVisibilityChange: (value: IcsClassType) => void;
|
||||
onAvailabilityChange: (value: IcsTimeTransparentType) => void;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const StatusSection = ({
|
||||
status,
|
||||
visibility,
|
||||
availability,
|
||||
onStatusChange,
|
||||
onVisibilityChange,
|
||||
onAvailabilityChange,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: StatusSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const statusLabel = t(`calendar.event.status.${status.toLowerCase()}`);
|
||||
const visibilityLabel = t(
|
||||
`calendar.event.visibility.${visibility.toLowerCase()}`,
|
||||
);
|
||||
const availabilityLabel =
|
||||
availability === "OPAQUE"
|
||||
? t("calendar.event.availability.busy")
|
||||
: t("calendar.event.availability.free");
|
||||
|
||||
const summary = `${statusLabel} · ${visibilityLabel} · ${availabilityLabel}`;
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="info_outline"
|
||||
label={t("calendar.event.sections.moreOptions")}
|
||||
summary={summary}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="status-section">
|
||||
<Select
|
||||
label={t("calendar.event.status.label")}
|
||||
value={status}
|
||||
onChange={(e) =>
|
||||
onStatusChange(e.target.value as IcsEventStatusType)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: "CONFIRMED",
|
||||
label: t("calendar.event.status.confirmed"),
|
||||
},
|
||||
{
|
||||
value: "TENTATIVE",
|
||||
label: t("calendar.event.status.tentative"),
|
||||
},
|
||||
{
|
||||
value: "CANCELLED",
|
||||
label: t("calendar.event.status.cancelled"),
|
||||
},
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
<Select
|
||||
label={t("calendar.event.visibility.label")}
|
||||
value={visibility}
|
||||
onChange={(e) => onVisibilityChange(e.target.value as IcsClassType)}
|
||||
options={[
|
||||
{ value: "PUBLIC", label: t("calendar.event.visibility.public") },
|
||||
{
|
||||
value: "PRIVATE",
|
||||
label: t("calendar.event.visibility.private"),
|
||||
},
|
||||
{
|
||||
value: "CONFIDENTIAL",
|
||||
label: t("calendar.event.visibility.confidential"),
|
||||
},
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
<Select
|
||||
label={t("calendar.event.availability.label")}
|
||||
value={availability}
|
||||
onChange={(e) =>
|
||||
onAvailabilityChange(e.target.value as IcsTimeTransparentType)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: "OPAQUE",
|
||||
label: t("calendar.event.availability.busy"),
|
||||
},
|
||||
{
|
||||
value: "TRANSPARENT",
|
||||
label: t("calendar.event.availability.free"),
|
||||
},
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
|
||||
interface VideoConferenceSectionProps {
|
||||
url: string;
|
||||
onChange: (url: string) => void;
|
||||
alwaysOpen?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const VideoConferenceSection = ({
|
||||
url,
|
||||
onChange,
|
||||
alwaysOpen,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: VideoConferenceSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleCreateVisio = () => {
|
||||
// Inert for now - will integrate with La Suite API in the future
|
||||
setIsCreating(true);
|
||||
setTimeout(() => {
|
||||
setIsCreating(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
icon="videocam"
|
||||
label={t("calendar.event.sections.addVideoConference")}
|
||||
isEmpty={!url}
|
||||
alwaysOpen={alwaysOpen}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
onClick={handleCreateVisio}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{t("calendar.event.sections.createVisio")}
|
||||
</Button>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
IcsEvent,
|
||||
IcsAttendee,
|
||||
IcsAlarm,
|
||||
IcsRecurrenceRule,
|
||||
IcsClassType,
|
||||
IcsEventStatusType,
|
||||
IcsTimeTransparentType,
|
||||
IcsOrganizer,
|
||||
} from "ts-ics";
|
||||
import type { EventCalendarAdapter } from "../../../services/dav/EventCalendarAdapter";
|
||||
import type { AttachmentMeta, EventFormSectionId } from "../types";
|
||||
import {
|
||||
formatDateTimeLocal,
|
||||
formatDateLocal,
|
||||
parseDateTimeLocal,
|
||||
parseDateLocal,
|
||||
} from "../utils/dateFormatters";
|
||||
|
||||
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
interface UseEventFormParams {
|
||||
event: Partial<IcsEvent> | null;
|
||||
calendarUrl: string;
|
||||
adapter: EventCalendarAdapter;
|
||||
organizer: IcsOrganizer | undefined;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
export const useEventForm = ({
|
||||
event,
|
||||
calendarUrl,
|
||||
adapter,
|
||||
organizer,
|
||||
mode,
|
||||
}: UseEventFormParams) => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [startDateTime, setStartDateTime] = useState("");
|
||||
const [endDateTime, setEndDateTime] = useState("");
|
||||
const [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl);
|
||||
const [isAllDay, setIsAllDay] = useState(false);
|
||||
const [attendees, setAttendees] = useState<IcsAttendee[]>([]);
|
||||
const [recurrence, setRecurrence] = useState<IcsRecurrenceRule | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [alarms, setAlarms] = useState<IcsAlarm[]>([]);
|
||||
const [videoConferenceUrl, setVideoConferenceUrl] = useState("");
|
||||
const [attachments, setAttachments] = useState<AttachmentMeta[]>([]);
|
||||
const [status, setStatus] = useState<IcsEventStatusType>("CONFIRMED");
|
||||
const [visibility, setVisibility] = useState<IcsClassType>("PUBLIC");
|
||||
const [availability, setAvailability] =
|
||||
useState<IcsTimeTransparentType>("OPAQUE");
|
||||
const [expandedSections, setExpandedSections] = useState<
|
||||
Set<EventFormSectionId>
|
||||
>(new Set());
|
||||
|
||||
// Reset form when event changes
|
||||
useEffect(() => {
|
||||
setTitle(event?.summary || "");
|
||||
setDescription(event?.description || "");
|
||||
setLocation(event?.location || "");
|
||||
setSelectedCalendarUrl(calendarUrl);
|
||||
setStatus(event?.status || "CONFIRMED");
|
||||
setVisibility(event?.class || "PUBLIC");
|
||||
setAvailability(event?.timeTransparent || "OPAQUE");
|
||||
setVideoConferenceUrl(event?.url || "");
|
||||
setAlarms(event?.alarms || []);
|
||||
setAttachments([]);
|
||||
|
||||
// Initialize attendees
|
||||
if (event?.attendees && event.attendees.length > 0) {
|
||||
setAttendees(event.attendees);
|
||||
} else {
|
||||
setAttendees([]);
|
||||
}
|
||||
|
||||
// Initialize recurrence
|
||||
if (event?.recurrenceRule) {
|
||||
setRecurrence(event.recurrenceRule);
|
||||
} else {
|
||||
setRecurrence(undefined);
|
||||
}
|
||||
|
||||
// Initialize all-day
|
||||
const eventIsAllDay = event?.start?.type === "DATE";
|
||||
setIsAllDay(eventIsAllDay);
|
||||
|
||||
const initialExpanded = new Set<EventFormSectionId>();
|
||||
if (event?.location) initialExpanded.add("location");
|
||||
if (event?.description) initialExpanded.add("description");
|
||||
if (event?.recurrenceRule) initialExpanded.add("recurrence");
|
||||
if (event?.url) initialExpanded.add("videoConference");
|
||||
|
||||
if (mode === "create") {
|
||||
initialExpanded.add("attendees");
|
||||
} else if (
|
||||
event?.attendees?.some(
|
||||
(att) =>
|
||||
att.email.toLowerCase() !== organizer?.email?.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
initialExpanded.add("attendees");
|
||||
}
|
||||
|
||||
setExpandedSections(initialExpanded);
|
||||
|
||||
// Parse start/end dates
|
||||
if (event?.start?.date) {
|
||||
const startDate =
|
||||
event.start.date instanceof Date
|
||||
? event.start.date
|
||||
: new Date(event.start.date);
|
||||
const isFakeUtc = Boolean(event.start.local?.timezone);
|
||||
|
||||
if (eventIsAllDay) {
|
||||
setStartDateTime(formatDateLocal(startDate, isFakeUtc));
|
||||
} else {
|
||||
setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc));
|
||||
}
|
||||
} else {
|
||||
if (eventIsAllDay) {
|
||||
setStartDateTime(formatDateLocal(new Date()));
|
||||
} else {
|
||||
setStartDateTime(formatDateTimeLocal(new Date()));
|
||||
}
|
||||
}
|
||||
|
||||
if (event?.end?.date) {
|
||||
const endDate =
|
||||
event.end.date instanceof Date
|
||||
? event.end.date
|
||||
: new Date(event.end.date);
|
||||
const isFakeUtc = Boolean(event.end.local?.timezone);
|
||||
|
||||
if (eventIsAllDay) {
|
||||
const displayEndDate = new Date(endDate);
|
||||
displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1);
|
||||
setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc));
|
||||
} else {
|
||||
setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc));
|
||||
}
|
||||
} else {
|
||||
if (eventIsAllDay) {
|
||||
setEndDateTime(formatDateLocal(new Date()));
|
||||
} else {
|
||||
const defaultEnd = new Date();
|
||||
defaultEnd.setHours(defaultEnd.getHours() + 1);
|
||||
setEndDateTime(formatDateTimeLocal(defaultEnd));
|
||||
}
|
||||
}
|
||||
}, [event, calendarUrl, mode, organizer?.email]);
|
||||
|
||||
const toggleSection = useCallback((sectionId: EventFormSectionId) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sectionId)) {
|
||||
next.delete(sectionId);
|
||||
} else {
|
||||
next.add(sectionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isSectionExpanded = useCallback(
|
||||
(sectionId: EventFormSectionId) => expandedSections.has(sectionId),
|
||||
[expandedSections],
|
||||
);
|
||||
|
||||
const handleStartDateChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newStartValue = e.target.value;
|
||||
|
||||
if (isAllDay) {
|
||||
const oldStart = parseDateLocal(startDateTime);
|
||||
const oldEnd = parseDateLocal(endDateTime);
|
||||
const duration = oldEnd.getTime() - oldStart.getTime();
|
||||
const newStart = parseDateLocal(newStartValue);
|
||||
const newEnd = new Date(newStart.getTime() + duration);
|
||||
setStartDateTime(newStartValue);
|
||||
setEndDateTime(formatDateLocal(newEnd));
|
||||
} else {
|
||||
const oldStart = parseDateTimeLocal(startDateTime);
|
||||
const oldEnd = parseDateTimeLocal(endDateTime);
|
||||
const duration = oldEnd.getTime() - oldStart.getTime();
|
||||
const newStart = parseDateTimeLocal(newStartValue);
|
||||
const newEnd = new Date(newStart.getTime() + duration);
|
||||
setStartDateTime(newStartValue);
|
||||
setEndDateTime(formatDateTimeLocal(newEnd));
|
||||
}
|
||||
},
|
||||
[isAllDay, startDateTime, endDateTime],
|
||||
);
|
||||
|
||||
const handleAllDayChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newIsAllDay = e.target.checked;
|
||||
setIsAllDay(newIsAllDay);
|
||||
|
||||
if (newIsAllDay) {
|
||||
const start = parseDateTimeLocal(startDateTime);
|
||||
const end = parseDateTimeLocal(endDateTime);
|
||||
setStartDateTime(formatDateLocal(start));
|
||||
setEndDateTime(formatDateLocal(end));
|
||||
} else {
|
||||
const start = parseDateLocal(startDateTime);
|
||||
const end = parseDateLocal(endDateTime);
|
||||
setStartDateTime(formatDateTimeLocal(start));
|
||||
setEndDateTime(formatDateTimeLocal(end));
|
||||
}
|
||||
},
|
||||
[startDateTime, endDateTime],
|
||||
);
|
||||
|
||||
const toIcsEvent = useCallback((): IcsEvent => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { duration: _duration, ...eventWithoutDuration } = event ?? {};
|
||||
|
||||
if (isAllDay) {
|
||||
const startDate = parseDateLocal(startDateTime);
|
||||
const endDate = parseDateLocal(endDateTime);
|
||||
const utcStart = new Date(
|
||||
Date.UTC(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
),
|
||||
);
|
||||
const utcEnd = new Date(
|
||||
Date.UTC(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate() + 1,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...eventWithoutDuration,
|
||||
uid: event?.uid || crypto.randomUUID(),
|
||||
summary: title,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
stamp: event?.stamp || { date: new Date() },
|
||||
start: { date: utcStart, type: "DATE" },
|
||||
end: { date: utcEnd, type: "DATE" },
|
||||
organizer,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
recurrenceRule: recurrence,
|
||||
alarms: alarms.length > 0 ? alarms : undefined,
|
||||
url: videoConferenceUrl || undefined,
|
||||
status,
|
||||
class: visibility,
|
||||
timeTransparent: availability,
|
||||
} as IcsEvent;
|
||||
}
|
||||
|
||||
const startDate = parseDateTimeLocal(startDateTime);
|
||||
const endDate = parseDateTimeLocal(endDateTime);
|
||||
const fakeUtcStart = new Date(
|
||||
Date.UTC(
|
||||
startDate.getFullYear(),
|
||||
startDate.getMonth(),
|
||||
startDate.getDate(),
|
||||
startDate.getHours(),
|
||||
startDate.getMinutes(),
|
||||
startDate.getSeconds(),
|
||||
),
|
||||
);
|
||||
const fakeUtcEnd = new Date(
|
||||
Date.UTC(
|
||||
endDate.getFullYear(),
|
||||
endDate.getMonth(),
|
||||
endDate.getDate(),
|
||||
endDate.getHours(),
|
||||
endDate.getMinutes(),
|
||||
endDate.getSeconds(),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...eventWithoutDuration,
|
||||
uid: event?.uid || crypto.randomUUID(),
|
||||
summary: title,
|
||||
description: description || undefined,
|
||||
location: location || undefined,
|
||||
stamp: event?.stamp || { date: new Date() },
|
||||
start: {
|
||||
date: fakeUtcStart,
|
||||
type: "DATE-TIME",
|
||||
local: {
|
||||
date: fakeUtcStart,
|
||||
timezone: BROWSER_TIMEZONE,
|
||||
tzoffset: adapter.getTimezoneOffset(startDate, BROWSER_TIMEZONE),
|
||||
},
|
||||
},
|
||||
end: {
|
||||
date: fakeUtcEnd,
|
||||
type: "DATE-TIME",
|
||||
local: {
|
||||
date: fakeUtcEnd,
|
||||
timezone: BROWSER_TIMEZONE,
|
||||
tzoffset: adapter.getTimezoneOffset(endDate, BROWSER_TIMEZONE),
|
||||
},
|
||||
},
|
||||
organizer,
|
||||
attendees: attendees.length > 0 ? attendees : undefined,
|
||||
recurrenceRule: recurrence,
|
||||
alarms: alarms.length > 0 ? alarms : undefined,
|
||||
url: videoConferenceUrl || undefined,
|
||||
status,
|
||||
class: visibility,
|
||||
timeTransparent: availability,
|
||||
} as IcsEvent;
|
||||
}, [
|
||||
event,
|
||||
isAllDay,
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
organizer,
|
||||
attendees,
|
||||
recurrence,
|
||||
alarms,
|
||||
videoConferenceUrl,
|
||||
status,
|
||||
visibility,
|
||||
availability,
|
||||
adapter,
|
||||
]);
|
||||
|
||||
return {
|
||||
// Basic fields
|
||||
title,
|
||||
setTitle,
|
||||
description,
|
||||
setDescription,
|
||||
location,
|
||||
setLocation,
|
||||
startDateTime,
|
||||
endDateTime,
|
||||
setEndDateTime,
|
||||
selectedCalendarUrl,
|
||||
setSelectedCalendarUrl,
|
||||
isAllDay,
|
||||
|
||||
// Complex fields
|
||||
attendees,
|
||||
setAttendees,
|
||||
recurrence,
|
||||
setRecurrence,
|
||||
alarms,
|
||||
setAlarms,
|
||||
videoConferenceUrl,
|
||||
setVideoConferenceUrl,
|
||||
attachments,
|
||||
setAttachments,
|
||||
status,
|
||||
setStatus,
|
||||
visibility,
|
||||
setVisibility,
|
||||
availability,
|
||||
setAvailability,
|
||||
|
||||
// Section management
|
||||
toggleSection,
|
||||
isSectionExpanded,
|
||||
|
||||
// Handlers
|
||||
handleStartDateChange,
|
||||
handleAllDayChange,
|
||||
|
||||
// Conversion
|
||||
toIcsEvent,
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,15 @@
|
||||
* Type definitions for Scheduler components.
|
||||
*/
|
||||
|
||||
import type { IcsEvent, IcsRecurrenceRule } from "ts-ics";
|
||||
import type {
|
||||
IcsEvent,
|
||||
IcsRecurrenceRule,
|
||||
IcsAlarm,
|
||||
IcsAttendee,
|
||||
IcsClassType,
|
||||
IcsEventStatusType,
|
||||
IcsTimeTransparentType,
|
||||
} from "ts-ics";
|
||||
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
||||
import type { EventCalendarAdapter } from "../../services/dav/EventCalendarAdapter";
|
||||
|
||||
@@ -63,6 +71,26 @@ export interface EventModalState {
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections toggled via pills in the event modal.
|
||||
*/
|
||||
export type EventFormSectionId =
|
||||
| "location"
|
||||
| "description"
|
||||
| "recurrence"
|
||||
| "attendees"
|
||||
| "videoConference";
|
||||
|
||||
/**
|
||||
* Attachment metadata (UI only, no actual file upload).
|
||||
*/
|
||||
export interface AttachmentMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form state for the event modal.
|
||||
*/
|
||||
@@ -74,9 +102,15 @@ export interface EventFormState {
|
||||
endDateTime: string;
|
||||
selectedCalendarUrl: string;
|
||||
isAllDay: boolean;
|
||||
attendees: IcsAttendee[];
|
||||
recurrence: IcsRecurrenceRule | undefined;
|
||||
showRecurrence: boolean;
|
||||
showAttendees: boolean;
|
||||
alarms: IcsAlarm[];
|
||||
videoConferenceUrl: string;
|
||||
attachments: AttachmentMeta[];
|
||||
status: IcsEventStatusType;
|
||||
visibility: IcsClassType;
|
||||
availability: IcsTimeTransparentType;
|
||||
expandedSections: Set<EventFormSectionId>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -131,7 +131,48 @@
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"create": "Create"
|
||||
"create": "Create",
|
||||
"sections": {
|
||||
"addRecurrence": "Add recurrence",
|
||||
"addLocation": "Add location",
|
||||
"addVideoConference": "Visio",
|
||||
"createVisio": "Add video conference",
|
||||
"videoLink": "Video conference link",
|
||||
"addAttendees": "Add participants",
|
||||
"addDescription": "Add description",
|
||||
"addReminder": "Add reminder",
|
||||
"addAttachment": "Add attachment",
|
||||
"moreOptions": "More options",
|
||||
"reminder_one": "reminder",
|
||||
"reminder_other": "reminders",
|
||||
"attachment_one": "attachment",
|
||||
"attachment_other": "attachments"
|
||||
},
|
||||
"reminders": {
|
||||
"5min": "5 minutes before",
|
||||
"15min": "15 minutes before",
|
||||
"30min": "30 minutes before",
|
||||
"1hour": "1 hour before",
|
||||
"1day": "1 day before",
|
||||
"1week": "1 week before"
|
||||
},
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"confirmed": "Confirmed",
|
||||
"tentative": "Tentative",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"visibility": {
|
||||
"label": "Visibility",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"confidential": "Confidential"
|
||||
},
|
||||
"availability": {
|
||||
"label": "Availability",
|
||||
"busy": "Busy",
|
||||
"free": "Free"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"myCalendars": "My calendars",
|
||||
@@ -170,7 +211,7 @@
|
||||
},
|
||||
"recurrence": {
|
||||
"label": "Repeat",
|
||||
"none": "No",
|
||||
"none": "Does not repeat",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
@@ -700,7 +741,48 @@
|
||||
"delete": "Supprimer",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"create": "Créer"
|
||||
"create": "Créer",
|
||||
"sections": {
|
||||
"addRecurrence": "Ajouter une récurrence",
|
||||
"addLocation": "Ajouter un lieu",
|
||||
"addVideoConference": "Visio",
|
||||
"createVisio": "Ajouter une visioconférence",
|
||||
"videoLink": "Lien de visioconférence",
|
||||
"addAttendees": "Ajouter des participants",
|
||||
"addDescription": "Ajouter une description",
|
||||
"addReminder": "Ajouter un rappel",
|
||||
"addAttachment": "Ajouter une pièce jointe",
|
||||
"moreOptions": "Plus d'options",
|
||||
"reminder_one": "rappel",
|
||||
"reminder_other": "rappels",
|
||||
"attachment_one": "pièce jointe",
|
||||
"attachment_other": "pièces jointes"
|
||||
},
|
||||
"reminders": {
|
||||
"5min": "5 minutes avant",
|
||||
"15min": "15 minutes avant",
|
||||
"30min": "30 minutes avant",
|
||||
"1hour": "1 heure avant",
|
||||
"1day": "1 jour avant",
|
||||
"1week": "1 semaine avant"
|
||||
},
|
||||
"status": {
|
||||
"label": "Statut",
|
||||
"confirmed": "Confirmé",
|
||||
"tentative": "Provisoire",
|
||||
"cancelled": "Annulé"
|
||||
},
|
||||
"visibility": {
|
||||
"label": "Visibilité",
|
||||
"public": "Public",
|
||||
"private": "Privé",
|
||||
"confidential": "Confidentiel"
|
||||
},
|
||||
"availability": {
|
||||
"label": "Disponibilité",
|
||||
"busy": "Occupé",
|
||||
"free": "Libre"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"myCalendars": "Mes agendas",
|
||||
@@ -739,7 +821,7 @@
|
||||
},
|
||||
"recurrence": {
|
||||
"label": "Répéter",
|
||||
"none": "Non",
|
||||
"none": "Ne se répète pas",
|
||||
"daily": "Tous les jours",
|
||||
"weekly": "Toutes les semaines",
|
||||
"monthly": "Tous les mois",
|
||||
@@ -1016,7 +1098,48 @@
|
||||
"delete": "Verwijderen",
|
||||
"cancel": "Annuleren",
|
||||
"save": "Opslaan",
|
||||
"create": "Aanmaken"
|
||||
"create": "Aanmaken",
|
||||
"sections": {
|
||||
"addRecurrence": "Herhaling toevoegen",
|
||||
"addLocation": "Locatie toevoegen",
|
||||
"addVideoConference": "Visio",
|
||||
"createVisio": "Add video conference",
|
||||
"videoLink": "Videoconferentie link",
|
||||
"addAttendees": "Deelnemers toevoegen",
|
||||
"addDescription": "Beschrijving toevoegen",
|
||||
"addReminder": "Herinnering toevoegen",
|
||||
"addAttachment": "Bijlage toevoegen",
|
||||
"moreOptions": "Meer opties",
|
||||
"reminder_one": "herinnering",
|
||||
"reminder_other": "herinneringen",
|
||||
"attachment_one": "bijlage",
|
||||
"attachment_other": "bijlagen"
|
||||
},
|
||||
"reminders": {
|
||||
"5min": "5 minuten voor",
|
||||
"15min": "15 minuten voor",
|
||||
"30min": "30 minuten voor",
|
||||
"1hour": "1 uur voor",
|
||||
"1day": "1 dag voor",
|
||||
"1week": "1 week voor"
|
||||
},
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"confirmed": "Bevestigd",
|
||||
"tentative": "Voorlopig",
|
||||
"cancelled": "Geannuleerd"
|
||||
},
|
||||
"visibility": {
|
||||
"label": "Zichtbaarheid",
|
||||
"public": "Openbaar",
|
||||
"private": "Privé",
|
||||
"confidential": "Vertrouwelijk"
|
||||
},
|
||||
"availability": {
|
||||
"label": "Beschikbaarheid",
|
||||
"busy": "Bezet",
|
||||
"free": "Vrij"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"myCalendars": "Mijn agenda's",
|
||||
@@ -1055,7 +1178,7 @@
|
||||
},
|
||||
"recurrence": {
|
||||
"label": "Herhalen",
|
||||
"none": "Nee",
|
||||
"none": "Niet herhalen",
|
||||
"daily": "Dagelijks",
|
||||
"weekly": "Wekelijks",
|
||||
"monthly": "Maandelijks",
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
@use "./../features/calendar/components/scheduler/Scheduler.scss";
|
||||
@use "./../features/calendar/components/scheduler/scheduler-theme.scss";
|
||||
@use "./../features/calendar/components/scheduler/SchedulerToolbar.scss";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow.scss";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection.scss";
|
||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill.scss";
|
||||
@use "./../pages/index.scss" as *;
|
||||
@use "./../pages/calendar.scss" as *;
|
||||
html,
|
||||
@@ -77,5 +80,26 @@ body {
|
||||
|
||||
.c__datagrid--empty,
|
||||
.c__datagrid thead {
|
||||
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.c__modal__title {
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.c__modal__close button {
|
||||
padding: 0;
|
||||
font-size: 88px;
|
||||
top: -5px !important;
|
||||
width: 28px !important;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.c__modal__scroller {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -489,10 +489,10 @@
|
||||
dependencies:
|
||||
tslib "^2.8.0"
|
||||
|
||||
"@gouvfr-lasuite/cunningham-react@4.1.0":
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.1.0.tgz#3a1347a7e05f9add18462d5b1ded3e26635bdd37"
|
||||
integrity sha512-r6QKi4qcPEG4Vxe7rKt89d7I2KYD9gvOKxkzlDAVBjo9DYnQ4ZM9T5tG/W0/oBbcURTFqxINfFhaF8CyviiOjw==
|
||||
"@gouvfr-lasuite/cunningham-react@4.2.0":
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.2.0.tgz#19bc8a658cc069d7b9397cba735b194e578b9b1f"
|
||||
integrity sha512-97eTA+v/ySsl6d5NCn6D2mVoNqrJnHWG+PWCY1L/Fz8MEx18sPwqUsqdaIuSj0C+iug0SCJ6Ufx57MURHCf0oA==
|
||||
dependencies:
|
||||
"@fontsource-variable/roboto-flex" "5.2.5"
|
||||
"@fontsource/material-icons-outlined" "5.2.5"
|
||||
@@ -529,16 +529,16 @@
|
||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234"
|
||||
integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg==
|
||||
|
||||
"@gouvfr-lasuite/ui-kit@0.18.7":
|
||||
version "0.18.7"
|
||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.18.7.tgz#1c3912f2b366ece09616937c2a5901300fe25a35"
|
||||
integrity sha512-1Bw1FSol9+mxztiaPEyfwVf9/1Vns7OJVEzvwLTlChbo6cS8FjuP/ZYG57RMvschsN4kfMzC03YmxcVdS0L9Nw==
|
||||
"@gouvfr-lasuite/ui-kit@0.19.6":
|
||||
version "0.19.6"
|
||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.19.6.tgz#bc9e32f5b575147bd92e19172aaac794522c4772"
|
||||
integrity sha512-PY0Jh2Wno/+dBVy89OdAook3h9GfrJXXVyOeNLA5mJ9NlNQBq0XKxSB87iqzDn/CBe1aUSLJY47QWzRWQQ0wXQ==
|
||||
dependencies:
|
||||
"@dnd-kit/core" "6.3.1"
|
||||
"@dnd-kit/modifiers" "9.0.0"
|
||||
"@dnd-kit/sortable" "10.0.0"
|
||||
"@fontsource/material-icons" "5.2.5"
|
||||
"@gouvfr-lasuite/cunningham-react" "4.1.0"
|
||||
"@gouvfr-lasuite/cunningham-react" "4.2.0"
|
||||
"@gouvfr-lasuite/cunningham-tokens" "3.1.0"
|
||||
"@gouvfr-lasuite/integration" "1.0.2"
|
||||
"@types/node" "22.10.7"
|
||||
|
||||
Reference in New Issue
Block a user