Files
workavia-calendar-front/src/features/Events/EventDisplayPreview.tsx
T
2025-11-13 10:43:06 +01:00

846 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DateSelectArg } from "@fullcalendar/core";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import CircleIcon from "@mui/icons-material/Circle";
import CloseIcon from "@mui/icons-material/Close";
import EditIcon from "@mui/icons-material/Edit";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import RepeatIcon from "@mui/icons-material/Repeat";
import SubjectIcon from "@mui/icons-material/Subject";
import VideocamIcon from "@mui/icons-material/Videocam";
import { Box, Typography } from "@mui/material";
import EventPopover from "./EventModal";
import {
Button,
Chip,
DialogActions,
IconButton,
Menu,
MenuItem,
Tooltip,
} from "@mui/material";
import AvatarGroup from "@mui/material/AvatarGroup";
import { useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { CalendarName } from "../../components/Calendar/CalendarName";
import { getTimezoneOffset } from "../../components/Calendar/TimezoneSelector";
import {
formatEventChipTitle,
updateTempCalendar,
} from "../../components/Calendar/utils/calendarUtils";
import ResponsiveDialog from "../../components/Dialog/ResponsiveDialog";
import { EditModeDialog } from "../../components/Event/EditModeDialog";
import EventDuplication from "../../components/Event/EventDuplicate";
import {
handleDelete,
handleRSVP,
} from "../../components/Event/eventHandlers/eventHandlers";
import { InfoRow } from "../../components/Event/InfoRow";
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
import { getCalendarRange } from "../../utils/dateUtils";
import { deleteEventAsync } from "../Calendars/CalendarSlice";
import { dlEvent } from "./EventApi";
import { CalendarEvent } from "./EventsTypes";
import EventUpdateModal from "./EventUpdateModal";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
export default function EventPreviewModal({
eventId,
calId,
tempEvent,
open,
onClose,
}: {
eventId: string;
calId: string;
tempEvent?: boolean;
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const timezone =
useAppSelector((state) => state.calendars.timeZone) ??
Intl.DateTimeFormat().resolvedOptions().timeZone;
const calendarList = Object.values(
useAppSelector((state) => state.calendars.list)
);
const calendar = tempEvent
? calendars.templist[calId]
: calendars.list[calId];
const event = calendar.events[eventId];
const user = useAppSelector((state) => state.user);
const isRecurring = event?.uid?.includes("/");
const isOwn = calendar.ownerEmails?.includes(user.userData.email);
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const [openDuplicateModal, setOpenDuplicateModal] = useState(false);
const [hidePreview, setHidePreview] = useState(false);
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
undefined
);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<Function>();
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
null
);
const mailSpaUrl = (window as any).MAIL_SPA_URL ?? null;
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
if (!event || !calendar) return null;
const attendeeDisplayLimit = 3;
const attendees =
event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const visibleAttendees = showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit);
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.userData.email
);
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
const updateTempList = async () => {
if (calendars.templist) {
const calendarRange = getCalendarRange(new Date(event.start));
await updateTempCalendar(
calendars.templist,
event,
dispatch,
calendarRange
);
}
};
return (
<>
<ResponsiveDialog
open={open && !hidePreview}
onClose={() => onClose({}, "backdropClick")}
showHeaderActions={false}
actionsBorderTop={currentUserAttendee && isOwn}
actionsJustifyContent="center"
style={{ overflow: "auto" }}
title={
<>
<DialogActions>
<Box>
{(window as any).DEBUG && (
<IconButton
onClick={async () => {
const icsContent = await dlEvent(event);
const blob = new Blob([icsContent], {
type: "text/calendar",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${eventId}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}}
>
<FileDownloadOutlinedIcon />
</IconButton>
)}
{user.userData.email === event.organizer?.cal_address &&
calendar.ownerEmails?.includes(user.userData.email) && (
<IconButton
size="small"
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(() => () => {
setHidePreview(true);
setOpenUpdateModal(true);
});
setOpenEditModePopup("edit");
} else {
setHidePreview(true);
setOpenUpdateModal(true);
}
}}
>
<EditIcon />
</IconButton>
)}
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
<IconButton
size="small"
onClick={(e) => setToggleActionMenu(e.currentTarget)}
>
<MoreVertIcon />
</IconButton>
)}
<Menu
open={Boolean(toggleActionMenu)}
onClose={() => setToggleActionMenu(null)}
anchorEl={toggleActionMenu}
>
{mailSpaUrl && attendees.length > 0 && (
<MenuItem
onClick={() =>
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
.map((a) => a.cal_address)
.filter((mail) => mail !== user.userData.email)
.join(",")}?subject=${event.title}`
)
}
>
{t("eventPreview.emailAttendees")}
</MenuItem>
)}
<EventDuplication
event={event}
onClose={() => setToggleActionMenu(null)}
onOpenDuplicate={() => {
setToggleActionMenu(null);
setHidePreview(true);
setOpenDuplicateModal(true);
}}
/>
{user.userData.email === event.organizer?.cal_address && (
<MenuItem
onClick={async () => {
if (isRecurring) {
setAfterChoiceFunc(
() => (typeOfAction?: "solo" | "all" | undefined) =>
handleDelete(
isRecurring,
typeOfAction,
onClose,
dispatch,
calendar,
event,
calId,
eventId
)
);
setOpenEditModePopup("edit");
} else {
onClose({}, "backdropClick");
await dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
}
updateTempList();
}}
>
{t("eventPreview.deleteEvent")}
</MenuItem>
)}
</Menu>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon />
</IconButton>
</Box>
</DialogActions>
<Box display="flex" flexDirection="row">
{event.class === "PRIVATE" &&
(isOwn ? (
<Tooltip
title={t("eventPreview.privateEvent.tooltipOwn")}
placement="top"
>
<LockOutlineIcon />
</Tooltip>
) : (
<LockOutlineIcon />
))}
<Typography
variant="h5"
sx={{
fontSize: "24px",
fontWeight: 600,
wordBreak: "break-word",
fontFamily: "Inter, sans-serif",
}}
gutterBottom
>
{formatEventChipTitle(event, t)}
</Typography>
{event.transp === "TRANSPARENT" && (
<Tooltip title={t("eventPreview.free.tooltip")} placement="top">
<Chip
icon={<CircleIcon color="success" fontSize="small" />}
label={t("eventPreview.free.label")}
/>
</Tooltip>
)}
</Box>
<Typography color="text.secondaryContainer" gutterBottom>
{formatDate(event.start, t, event.allday)}
{event.end &&
formatEnd(event.start, event.end, t, event.allday) &&
` ${formatEnd(event.start, event.end, t, event.allday)} ${!event.allday ? getTimezoneOffset(timezone) : ""}`}
</Typography>
</>
}
actions={
currentUserAttendee &&
isOwn && (
<>
<>
<Typography sx={{ marginRight: 2 }}>
{t("eventPreview.attendingQuestion")}
</Typography>
<Box display="flex" gap="15px" alignItems="center">
<Button
variant={
currentUserAttendee?.partstat === "ACCEPTED"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "ACCEPTED"
? "success"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"ACCEPTED",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"ACCEPTED",
onClose
);
}
}}
>
{t("eventPreview.accept")}
</Button>
<Button
variant={
currentUserAttendee?.partstat === "TENTATIVE"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "TENTATIVE"
? "warning"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"TENTATIVE",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"TENTATIVE",
onClose
);
}
}}
>
{t("eventPreview.maybe")}
</Button>
<Button
variant={
currentUserAttendee?.partstat === "DECLINED"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "DECLINED"
? "error"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"DECLINED",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"DECLINED",
onClose
);
}
}}
>
{t("eventPreview.decline")}
</Button>
</Box>
</>
</>
)
}
>
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
<>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<VideocamIcon />
</Box>
}
content={
<Button
variant="contained"
onClick={() =>
window.open(event.x_openpass_videoconference)
}
>
{t("eventPreview.joinVideo")}
</Button>
}
/>
)}
{/* Attendees */}
{attendees?.length > 0 && (
<>
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<PeopleAltOutlinedIcon />
</Box>
}
content={
<Box
style={{
marginBottom: 1,
display: "flex",
flexDirection: "row",
}}
>
<Box sx={{ marginRight: 2 }}>
<Typography>
{t("eventPreview.guests", {
count: attendees.length,
})}
</Typography>
<Typography
sx={{ fontSize: "13px", color: "text.secondary" }}
>
{t("eventPreview.yesCount", {
count: attendees.filter(
(a) => a.partstat === "ACCEPTED"
).length,
})}
,{" "}
{t("eventPreview.noCount", {
count: attendees.filter(
(a) => a.partstat === "DECLINED"
).length,
})}
</Typography>
</Box>
{!showAllAttendees && (
<AvatarGroup max={attendeeDisplayLimit}>
{organizer &&
renderAttendeeBadge(
organizer,
"org",
t,
showAllAttendees,
true
)}
{visibleAttendees.map((a, idx) =>
renderAttendeeBadge(
a,
idx.toString(),
t,
showAllAttendees
)
)}
</AvatarGroup>
)}
<Typography
sx={{
cursor: "pointer",
marginLeft: 2,
fontSize: "14px",
color: "text.secondary",
alignSelf: "center",
}}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees
? t("eventPreview.showLess")
: t("eventPreview.showMore")}
</Typography>
</Box>
}
/>
</>
)}
{showAllAttendees &&
organizer &&
renderAttendeeBadge(organizer, "org", t, showAllAttendees, true)}
{showAllAttendees &&
visibleAttendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), t, showAllAttendees)
)}
{/* Location */}
{event.location && (
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<LocationOnOutlinedIcon />
</Box>
}
text={event.location}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
{/* Description */}
{event.description && (
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<SubjectIcon />
</Box>
}
text={event.description}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
{/* ALARM */}
{event.alarm && (
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<NotificationsNoneIcon />
</Box>
}
text={t("eventPreview.alarmText", {
trigger: t(`event.form.notifications.${event.alarm.trigger}`),
action: t(`event.form.notifications.${event.alarm.action}`),
})}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
{/* Repetition */}
{event.repetition && (
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<RepeatIcon />
</Box>
}
text={makeRecurrenceString(event, t)}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
</>
)}
{event.class === "PRIVATE" && !isOwn && (
<Box
sx={{
backgroundColor: "#F3F4F6",
height: 48,
borderRadius: "8px",
gap: "16px",
paddingTop: "16px",
paddingBottom: " 16px",
}}
>
<Typography
fontFamily={"Inter"}
fontWeight={500}
fontSize={"12px"}
lineHeight={"16px"}
letterSpacing={"0.5px"}
textAlign={"center"}
>
{t("eventPreview.privateEvent.hiddenDetails")}
</Typography>
</Box>
)}
{/* Error */}
{event.error && (
<InfoRow
icon={
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<ErrorOutlineIcon color="error" />
</Box>
}
text={event.error}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
error
/>
)}
{/* Calendar color dot */}
<Box
style={{
display: "flex",
alignItems: "center",
gap: 1,
marginBottom: 2,
}}
>
<Box sx={{ minWidth: "25px", marginRight: 2 }}>
<CalendarTodayIcon />
</Box>
<CalendarName calendar={calendar} />
</Box>
</ResponsiveDialog>
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
event={event}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
afterChoiceFunc && afterChoiceFunc(type);
}}
/>
<EventUpdateModal
open={openUpdateModal}
onClose={() => {
setOpenUpdateModal(false);
setHidePreview(false);
}}
onCloseAll={() => {
setOpenUpdateModal(false);
onClose({}, "backdropClick");
}}
eventId={eventId}
calId={calId}
typeOfAction={typeOfAction}
/>
<EventPopover
anchorEl={null}
open={openDuplicateModal}
selectedRange={
{
start: new Date(event.start),
startStr: event.start,
end: new Date(event.end ?? ""),
endStr: event.end ?? "",
allDay: event.allday ?? false,
} as DateSelectArg
}
setSelectedRange={() => {}}
calendarRef={{ current: null }}
onClose={() => {
setOpenDuplicateModal(false);
onClose({}, "backdropClick"); // Đóng cả preview modal
}}
event={event}
/>
</>
);
}
function makeRecurrenceString(
event: CalendarEvent,
t: Function
): string | undefined {
if (!event.repetition) return;
const recur: string[] = [
`${t("eventPreview.recurrentEvent")} · ${t(
`eventPreview.freq.${event.repetition.freq}`,
event.repetition.freq
)}`,
];
const recurType: Record<string, string> = {
daily: t("eventPreview.days"),
monthly: t("eventPreview.months"),
yearly: t("eventPreview.years"),
};
if (event.repetition.byday) {
recur.push(
t("eventPreview.recurrenceOnDays", {
days: event.repetition.byday
.map((s) => t(`eventPreview.onDays.${s}`))
.join(", "),
})
);
}
if (event.repetition.interval && event.repetition.interval > 1) {
recur.push(
t("eventPreview.everyInterval", {
interval: event.repetition.interval,
unit: recurType[event.repetition.freq],
})
);
}
if (event.repetition.occurrences) {
recur.push(
t("eventPreview.forOccurrences", {
count: event.repetition.occurrences,
})
);
}
if (event.repetition.endDate) {
recur.push(
t("eventPreview.until", {
date: new Date(event.repetition.endDate).toLocaleDateString(
t("locale"),
{
year: "numeric",
month: "long",
day: "numeric",
}
),
})
);
}
return recur.join(", ");
}
function formatDate(date: Date | string, t: Function, allday?: boolean) {
if (allday) {
return new Date(date).toLocaleDateString(t("locale"), {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
});
} else {
return new Date(date).toLocaleString(t("locale"), {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}
function formatEnd(
start: Date | string,
end: Date | string,
t: Function,
allday?: boolean
) {
const startDate = new Date(start);
const endDate = new Date(end);
const sameDay =
startDate.getFullYear() === endDate.getFullYear() &&
startDate.getMonth() === endDate.getMonth() &&
startDate.getDate() === endDate.getDate();
if (allday) {
return sameDay
? null
: endDate.toLocaleDateString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
});
} else {
if (sameDay) {
return endDate.toLocaleTimeString(t("locale"), {
hour: "2-digit",
minute: "2-digit",
});
}
return endDate.toLocaleString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}