Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -89,7 +89,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
onEndDateChange,
|
||||
onEndTimeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
const initialDurationRef = React.useRef<number | null>(null);
|
||||
const isUserActionRef = React.useRef(false);
|
||||
@@ -341,8 +341,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
|
||||
return (
|
||||
<LocalizationProvider
|
||||
key={lang}
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale={t("locale") ?? "en"}
|
||||
adapterLocale={lang ?? "en"}
|
||||
localeText={{
|
||||
okButtonLabel: t("common.ok"),
|
||||
cancelButtonLabel: t("common.cancel"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Box, Typography } from "@linagora/twake-mui";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { useI18n } from "twake-i18n";
|
||||
import { ContextualizedEvent } from "../EventsTypes";
|
||||
import { EventCounterModal } from "./EventCounterModal";
|
||||
import { RSVPButton } from "./RSVPButton";
|
||||
|
||||
interface AttendanceValidationProps {
|
||||
@@ -25,8 +26,8 @@ export function AttendanceValidation({
|
||||
const { t } = useI18n();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingValue, setLoadingValue] = useState<PartStat | null>(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 (
|
||||
<>
|
||||
<Typography sx={{ marginRight: 2 }}>
|
||||
<Typography variant="body2" sx={{ marginRight: 1 }}>
|
||||
{calendar.owner?.resource
|
||||
? t("eventPreview.authorizeQuestion")
|
||||
: t("eventPreview.attendingQuestion")}
|
||||
</Typography>
|
||||
<Box display="flex" gap="15px" alignItems="center">
|
||||
<Box display="flex" gap={1} mx={1} alignItems="center">
|
||||
<RSVPButton rsvpValue="ACCEPTED" {...commonButtonProps} />
|
||||
<RSVPButton rsvpValue="TENTATIVE" {...commonButtonProps} />
|
||||
<RSVPButton rsvpValue="DECLINED" {...commonButtonProps} />
|
||||
<RSVPButton rsvpValue="TENTATIVE" {...commonButtonProps} />
|
||||
</Box>
|
||||
{!contextualizedEvent.isOrganizer && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
onClick={() => setOpenCounterModal(!openCounterModal)}
|
||||
sx={{ marginLeft: 1, cursor: "pointer" }}
|
||||
>
|
||||
{t("eventPreview.proposeNewTime")}
|
||||
</Typography>
|
||||
)}
|
||||
<EventCounterModal
|
||||
open={openCounterModal}
|
||||
setOpen={setOpenCounterModal}
|
||||
contextualizedEvent={contextualizedEvent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={t("eventPreview.proposeNewTime")}
|
||||
>
|
||||
{/* Event title */}
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{formatEventChipTitle(contextualizedEvent.event, t)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Current event time */}
|
||||
|
||||
<EventTimeSubtitle
|
||||
event={contextualizedEvent.event}
|
||||
t={t}
|
||||
timezone={contextualizedEvent.event.timezone}
|
||||
/>
|
||||
|
||||
{/* Your proposal label */}
|
||||
<FieldWithLabel label={t("eventPreview.yourProposal")} isExpanded={false}>
|
||||
<DateTimeFields
|
||||
startDate={startDate}
|
||||
startTime={startTime}
|
||||
endDate={endDate}
|
||||
endTime={endTime}
|
||||
allday={allday}
|
||||
showMore={showMore}
|
||||
hasEndDateChanged={hasEndDateChanged}
|
||||
validation={validation}
|
||||
onStartDateChange={handleStartDateChange}
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
onEndDateChange={handleEndDateChange}
|
||||
onEndTimeChange={handleEndTimeChange}
|
||||
showEndDate={
|
||||
showMore ||
|
||||
allday ||
|
||||
(hasEndDateChanged && startDate !== endDate) ||
|
||||
(!showMore && !allday && startDate !== endDate)
|
||||
}
|
||||
onToggleEndDate={() => setShowMore((prev) => !prev)}
|
||||
/>
|
||||
</FieldWithLabel>
|
||||
{/* Optional message */}
|
||||
<Box mt={2}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
multiline
|
||||
size="small"
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
fullWidth
|
||||
placeholder={t("eventPreview.optionalMessage")}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
sx={{
|
||||
mt: 2,
|
||||
"& .MuiInputBase-root": {
|
||||
overflowY: "auto",
|
||||
padding: 0,
|
||||
},
|
||||
"& textarea": {
|
||||
resize: "vertical",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box display="flex" justifyContent="flex-end" gap={2} mt={3}>
|
||||
<Button variant="text" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("eventPreview.sendProposal")}
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<Typography color="text.secondaryContainer">
|
||||
{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)) : ""}`}
|
||||
</Typography>
|
||||
<EventTimeSubtitle event={event} t={t} timezone={timezone} />
|
||||
</Box>
|
||||
|
||||
{/* Event details (attendees, location, description, etc.) */}
|
||||
|
||||
@@ -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 (
|
||||
<Typography color="text.secondaryContainer">
|
||||
{formatDate(event.start, t, timezone, event.allday)}
|
||||
{formattedEnd &&
|
||||
` – ${formattedEnd} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@@ -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<Response> {
|
||||
// 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+6
-2
@@ -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",
|
||||
|
||||
+6
-2
@@ -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é",
|
||||
|
||||
@@ -283,6 +283,10 @@
|
||||
},
|
||||
"attendingQuestion": "Присоединитесь?",
|
||||
"authorizeQuestion": "Авторизовать?",
|
||||
"proposeNewTime": "Предложить новое время",
|
||||
"yourProposal": "Ваше предложение",
|
||||
"optionalMessage": "Добавить необязательное сообщение организатору",
|
||||
"sendProposal": "Отправить предложение",
|
||||
"ACCEPTED": "Да",
|
||||
"TENTATIVE": "Возможно",
|
||||
"DECLINED": "Нет",
|
||||
|
||||
+7
-3
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user