diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx index de43fb2..c0e5b6a 100644 --- a/src/components/Event/components/DateTimeFields.tsx +++ b/src/components/Event/components/DateTimeFields.tsx @@ -89,7 +89,7 @@ export const DateTimeFields: React.FC = ({ onEndDateChange, onEndTimeChange, }) => { - const { t } = useI18n(); + const { t, lang } = useI18n(); const initialDurationRef = React.useRef(null); const isUserActionRef = React.useRef(false); @@ -341,8 +341,9 @@ export const DateTimeFields: React.FC = ({ return ( (null); + const [openCounterModal, setOpenCounterModal] = useState(false); - // Check if we should show RSVP buttons const hasNoAttendeesOrOrganizer = !(contextualizedEvent.event?.attendee?.length > 0) && !contextualizedEvent.event?.organizer; @@ -68,16 +69,30 @@ export function AttendanceValidation({ return ( <> - + {calendar.owner?.resource ? t("eventPreview.authorizeQuestion") : t("eventPreview.attendingQuestion")} - + - + + {!contextualizedEvent.isOrganizer && ( + setOpenCounterModal(!openCounterModal)} + sx={{ marginLeft: 1, cursor: "pointer" }} + > + {t("eventPreview.proposeNewTime")} + + )} + ); } diff --git a/src/features/Events/AttendanceValidation/EventCounterModal.tsx b/src/features/Events/AttendanceValidation/EventCounterModal.tsx new file mode 100644 index 0000000..9e6060c --- /dev/null +++ b/src/features/Events/AttendanceValidation/EventCounterModal.tsx @@ -0,0 +1,243 @@ +import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils"; +import { ResponsiveDialog } from "@/components/Dialog"; +import { DateTimeFields } from "@/components/Event/components/DateTimeFields"; +import { FieldWithLabel } from "@/components/Event/components/FieldWithLabel"; +import { splitDateTime } from "@/components/Event/utils/dateTimeHelpers"; +import { Box, Button, TextField, Typography } from "@linagora/twake-mui"; +import moment from "moment-timezone"; +import { useEffect, useState } from "react"; +import { useI18n } from "twake-i18n"; +import { postCounterProposal } from "../api/sendCounterProposal"; +import { EventTimeSubtitle } from "../EventPreview/EventTimeSubtitle"; +import { ContextualizedEvent } from "../EventsTypes"; + +export function EventCounterModal({ + open, + setOpen, + contextualizedEvent, +}: { + open: boolean; + setOpen: (b: boolean) => void; + contextualizedEvent: ContextualizedEvent; +}) { + const { t } = useI18n(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const allday = contextualizedEvent.event.allday ?? false; + + const timezone = contextualizedEvent.event.timezone; + + const startSplit = splitDateTime( + moment + .tz(contextualizedEvent.event.start, timezone) + .format("YYYY-MM-DDTHH:mm") + ); + const endSplit = splitDateTime( + moment + .tz( + contextualizedEvent.event.end ?? contextualizedEvent.event.start, + timezone + ) + .format("YYYY-MM-DDTHH:mm") + ); + + const [startDate, setStartDate] = useState(startSplit.date); + const [startTime, setStartTime] = useState(startSplit.time); + const [endDate, setEndDate] = useState(endSplit.date); + const [endTime, setEndTime] = useState(endSplit.time); + const [showMore, setShowMore] = useState(false); + const [hasEndDateChanged, setHasEndDateChanged] = useState(false); + const [message, setMessage] = useState(""); + const [validation, setValidation] = useState<{ + errors: { dateTime: string }; + }>({ + errors: { dateTime: "" }, + }); + + const handleStartDateChange = (value: string) => { + setStartDate(value); + if (value > endDate) { + setEndDate(value); + setHasEndDateChanged(true); + } + setValidation({ errors: { dateTime: "" } }); + }; + + const handleStartTimeChange = (value: string) => { + setStartTime(value); + setValidation({ errors: { dateTime: "" } }); + }; + + const handleEndDateChange = (value: string) => { + setEndDate(value); + setHasEndDateChanged(true); + setValidation({ errors: { dateTime: "" } }); + }; + + const handleEndTimeChange = (value: string) => { + setEndTime(value); + setValidation({ errors: { dateTime: "" } }); + }; + + const validate = (): boolean => { + if (!startDate || !endDate) { + setValidation({ + errors: { dateTime: t("event.validation.startRequired") }, + }); + return false; + } + if (!allday && (!startTime || !endTime)) { + setValidation({ + errors: { dateTime: t("event.validation.startRequired") }, + }); + return false; + } + if ( + endDate < startDate || + (!allday && endDate === startDate && endTime <= startTime) + ) { + setValidation({ + errors: { dateTime: t("event.validation.endAfterStart") }, + }); + return false; + } + setValidation({ errors: { dateTime: "" } }); + return true; + }; + + const handleSubmit = async () => { + if (!validate()) return; + if ( + !contextualizedEvent.currentUserAttendee?.cal_address || + !contextualizedEvent.event.organizer?.cal_address + ) { + setValidation({ errors: { dateTime: t("error.unknown") } }); + return; + } + setIsSubmitting(true); + try { + await postCounterProposal({ + event: contextualizedEvent.event, + senderEmail: contextualizedEvent.currentUserAttendee.cal_address, + recipientEmail: contextualizedEvent.event.organizer.cal_address, + proposedStart: contextualizedEvent.event.allday + ? startDate + : `${startDate}T${startTime}`, + proposedEnd: contextualizedEvent.event.allday + ? endDate + : `${endDate}T${endTime}`, + message, + }); + setOpen(false); + } catch (error) { + console.error(error); + setValidation({ errors: { dateTime: t("error.unknown") } }); + } finally { + setIsSubmitting(false); + } + }; + + useEffect(() => { + if (!open) return; + setStartDate(startSplit.date); + setStartTime(startSplit.time); + setEndDate(endSplit.date); + setEndTime(endSplit.time); + setShowMore(false); + setHasEndDateChanged(false); + setMessage(""); + setValidation({ errors: { dateTime: "" } }); + }, [open, startSplit.date, startSplit.time, endSplit.date, endSplit.time]); + + return ( + setOpen(false)} + title={t("eventPreview.proposeNewTime")} + > + {/* Event title */} + + + {formatEventChipTitle(contextualizedEvent.event, t)} + + + + {/* Current event time */} + + + + {/* Your proposal label */} + + setShowMore((prev) => !prev)} + /> + + {/* Optional message */} + + setMessage(e.target.value)} + sx={{ + mt: 2, + "& .MuiInputBase-root": { + overflowY: "auto", + padding: 0, + }, + "& textarea": { + resize: "vertical", + }, + }} + /> + + + {/* Actions */} + + + + + + ); +} diff --git a/src/features/Events/EventPreview/EventPreviewModal.tsx b/src/features/Events/EventPreview/EventPreviewModal.tsx index 1834fbf..a670afb 100644 --- a/src/features/Events/EventPreview/EventPreviewModal.tsx +++ b/src/features/Events/EventPreview/EventPreviewModal.tsx @@ -2,7 +2,6 @@ import { CalendarName } from "@/components/Calendar/CalendarName"; import { formatEventChipTitle } from "@/components/Calendar/utils/calendarUtils"; import ResponsiveDialog from "@/components/Dialog/ResponsiveDialog"; import { EditModeDialog } from "@/components/Event/EditModeDialog"; -import { getTimezoneOffset } from "@/utils/timezone"; import { DateSelectArg } from "@fullcalendar/core"; import { Box, Chip, Tooltip, Typography } from "@linagora/twake-mui"; import CircleIcon from "@mui/icons-material/Circle"; @@ -16,8 +15,7 @@ import { EventPreviewDetails } from "../EventPreview/EventPreviewDetails"; import { EventPreviewHeader } from "../EventPreview/EventPreviewHeader"; import { useEventPreviewState } from "../EventPreview/useEventPreviewState"; import EventUpdateModal from "../EventUpdateModal"; -import { formatDate } from "./utils/formatDate"; -import { formatEnd } from "./utils/formatEnd"; +import { EventTimeSubtitle } from "./EventTimeSubtitle"; export default function EventPreviewModal({ eventId, @@ -160,12 +158,7 @@ export default function EventPreviewModal({ )} - - {formatDate(event.start, t, timezone, event.allday)} - {event.end && - formatEnd(event.start, event.end, t, timezone, event.allday) && - ` – ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`} - + {/* Event details (attendees, location, description, etc.) */} diff --git a/src/features/Events/EventPreview/EventTimeSubtitle.tsx b/src/features/Events/EventPreview/EventTimeSubtitle.tsx new file mode 100644 index 0000000..53bceff --- /dev/null +++ b/src/features/Events/EventPreview/EventTimeSubtitle.tsx @@ -0,0 +1,27 @@ +import { getTimezoneOffset } from "@/utils/timezone"; +import { Typography } from "@linagora/twake-mui"; +import { CalendarEvent } from "../EventsTypes"; +import { formatDate } from "./utils/formatDate"; +import { formatEnd } from "./utils/formatEnd"; + +export function EventTimeSubtitle({ + event, + t, + timezone, +}: { + event: CalendarEvent; + t: (k: string, p?: string | object | undefined) => string; + timezone: string; +}) { + const formattedEnd = event.end + ? formatEnd(event.start, event.end, t, timezone, event.allday) + : null; + + return ( + + {formatDate(event.start, t, timezone, event.allday)} + {formattedEnd && + ` – ${formattedEnd} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`} + + ); +} diff --git a/src/features/Events/api/sendCounterProposal.ts b/src/features/Events/api/sendCounterProposal.ts new file mode 100644 index 0000000..9f7c488 --- /dev/null +++ b/src/features/Events/api/sendCounterProposal.ts @@ -0,0 +1,84 @@ +import { api } from "@/utils/apiUtils"; +import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; +import { TIMEZONES } from "@/utils/timezone-data"; +import ICAL from "ical.js"; +import { CalendarEvent } from "../EventsTypes"; +import { makeTimezone, makeVevent } from "../utils"; + +export async function postCounterProposal({ + event, + senderEmail, + recipientEmail, + proposedStart, + proposedEnd, + message, +}: { + event: CalendarEvent; + senderEmail: string; + recipientEmail: string; + proposedStart: string; + proposedEnd: string; + message?: string; +}): Promise { + // Build the counter event with proposed dates + const counterEvent: CalendarEvent = { + ...event, + start: proposedStart, + end: proposedEnd, + sequence: event.sequence ?? 0, + }; + + // Build vevent jCal + const vevent = makeVevent( + counterEvent, + counterEvent.timezone, + senderEmail, + !event.recurrenceId + ); + if (message) { + vevent?.[1]?.push(["comment", {}, "text", message]); + } + // Build vtimezone + const timezoneData = TIMEZONES.zones[counterEvent.timezone]; + const vtimezone = makeTimezone(timezoneData, counterEvent); + + // Assemble full vcalendar with METHOD:COUNTER + const jcal = [ + "vcalendar", + [ + ["version", {}, "text", "2.0"], + ["prodid", {}, "text", "-//OpenPaaS//OpenPaaS//EN"], + ["method", {}, "text", "COUNTER"], + ], + [vevent, vtimezone.component.jCal], + ]; + + // Serialize to raw ICS + const counterICS = new ICAL.Component(jcal).toString(); + + const postResponse = await api(`dav${event.URL}`, { + method: "POST", + body: JSON.stringify({ + ical: counterICS, + sender: senderEmail, + recipient: recipientEmail, + uid: extractEventBaseUuid(event.uid), + sequence: counterEvent.sequence, + method: "COUNTER", + }), + headers: { + accept: "application/json,text/plain,*/*", + "content-type": "application/calendar+json", + Prefer: "return=representation", + "X-Http-Method-Override": "ITIP", + }, + }); + + if (!postResponse.ok) { + throw new Error( + `postCounterProposal failed with status ${postResponse.status}` + ); + } + + return postResponse; +} diff --git a/src/features/Events/createEventContext.ts b/src/features/Events/createEventContext.ts index 6d8e0cd..ee06647 100644 --- a/src/features/Events/createEventContext.ts +++ b/src/features/Events/createEventContext.ts @@ -9,9 +9,6 @@ export function createEventContext( ): ContextualizedEvent { const isOwn = calendar.owner?.emails?.includes(user.email) ?? false; const isRecurring = event?.uid?.includes("/") ?? false; - const isOrganizer = event.organizer - ? user?.email === event.organizer.cal_address - : isOwn; const attendeeEmail = calendar.delegated ? calendar.owner?.emails?.[0] : user.email; @@ -20,6 +17,10 @@ export function createEventContext( (person) => person.cutype === "RESOURCE" && person.cn === calendar.name ) : event.attendee?.find((person) => person.cal_address === attendeeEmail); + const isOrganizer = event.organizer + ? attendeeEmail === event.organizer.cal_address + : isOwn; + return { event, calendar, diff --git a/src/locales/en.json b/src/locales/en.json index 5c262e1..9cfb78d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -282,9 +282,13 @@ }, "attendingQuestion": "Attending?", "authorizeQuestion": "Authorize?", - "ACCEPTED": "Accept", + "proposeNewTime": "New time", + "yourProposal": "Your proposal", + "optionalMessage": "Add optional message to the organizer", + "sendProposal": "Send proposal", + "ACCEPTED": "Yes", "TENTATIVE": "Maybe", - "DECLINED": "Decline", + "DECLINED": "No", "NEEDS-ACTION": "Pending", "attendingStatus": { "ACCEPTED": "Accepted", diff --git a/src/locales/fr.json b/src/locales/fr.json index ffed79c..2b2cb2d 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -283,9 +283,13 @@ }, "attendingQuestion": "Participer ?", "authorizeQuestion": "Autoriser ?", - "ACCEPTED": "Accepter", + "proposeNewTime": "Nouvel horaire", + "yourProposal": "Votre proposition", + "optionalMessage": "Ajouter un message optionnel à l'organisateur", + "sendProposal": "Envoyer la proposition", + "ACCEPTED": "Oui", "TENTATIVE": "Peut-être", - "DECLINED": "Décliner", + "DECLINED": "Non", "NEEDS-ACTION": "En attente", "attendingStatus": { "ACCEPTED": "Accepté", diff --git a/src/locales/ru.json b/src/locales/ru.json index 122c6ac..efe581b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -283,6 +283,10 @@ }, "attendingQuestion": "Присоединитесь?", "authorizeQuestion": "Авторизовать?", + "proposeNewTime": "Предложить новое время", + "yourProposal": "Ваше предложение", + "optionalMessage": "Добавить необязательное сообщение организатору", + "sendProposal": "Отправить предложение", "ACCEPTED": "Да", "TENTATIVE": "Возможно", "DECLINED": "Нет", diff --git a/src/locales/vi.json b/src/locales/vi.json index 54ebb48..707256e 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -279,11 +279,15 @@ "label": "Rảnh", "tooltip": "Người khác sẽ thấy bạn rảnh trong khoảng thời gian này." }, - "attendingQuestion": "Tham gia?", + "attendingQuestion": "Tham dự?", "authorizeQuestion": "Cho phép?", - "ACCEPTED": "Chấp nhận", + "proposeNewTime": "Hẹn lại", + "yourProposal": "Đề xuất của bạn", + "optionalMessage": "Thêm tin nhắn tùy chọn cho người tổ chức", + "sendProposal": "Gửi đề xuất", + "ACCEPTED": "Có", "TENTATIVE": "Có thể", - "DECLINED": "Từ chối", + "DECLINED": "Không", "NEEDS-ACTION": "Đang chờ", "attendingStatus": { "ACCEPTED": "Đã chấp nhận",