585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
import { DateSelectArg, EventClickArg } from "@fullcalendar/core";
|
|
import frLocale from "@fullcalendar/core/locales/fr";
|
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
import interactionPlugin from "@fullcalendar/interaction";
|
|
import listPlugin from "@fullcalendar/list";
|
|
import FullCalendar from "@fullcalendar/react";
|
|
import DeleteIcon from "@mui/icons-material/Delete";
|
|
import EditIcon from "@mui/icons-material/Edit";
|
|
import RuleIcon from "@mui/icons-material/Rule";
|
|
import {
|
|
Alert,
|
|
Avatar,
|
|
Card,
|
|
CardActions,
|
|
CardContent,
|
|
CardHeader,
|
|
Checkbox,
|
|
FormControl,
|
|
FormControlLabel,
|
|
FormGroup,
|
|
FormLabel,
|
|
IconButton,
|
|
Menu,
|
|
MenuItem,
|
|
Popover,
|
|
Tooltip,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import { red } from "@mui/material/colors";
|
|
import React from "react";
|
|
import { FamilyApi, FamilyUser } from "../../../api/FamilyApi";
|
|
import { Accommodation } from "../../../api/accommodations/AccommodationListApi";
|
|
import {
|
|
AccommodationReservation,
|
|
AccommodationsReservationsApi,
|
|
AccommodationsReservationsList,
|
|
} from "../../../api/accommodations/AccommodationsReservationsApi";
|
|
import { useAlert } from "../../../hooks/context_providers/AlertDialogProvider";
|
|
import { useConfirm } from "../../../hooks/context_providers/ConfirmDialogProvider";
|
|
import { useLoadingMessage } from "../../../hooks/context_providers/LoadingMessageProvider";
|
|
import { useSnackbar } from "../../../hooks/context_providers/SnackbarProvider";
|
|
import { useUpdateAccommodationReservation } from "../../../hooks/context_providers/accommodations/UpdateReservationDialogProvider";
|
|
import {
|
|
fmtUnixDate,
|
|
fmtUnixDateFullCalendar,
|
|
} from "../../../utils/time_utils";
|
|
import { AsyncWidget } from "../../../widgets/AsyncWidget";
|
|
import { useUser } from "../../../widgets/BaseAuthenticatedPage";
|
|
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
|
import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle";
|
|
import { useAccommodations } from "../../../widgets/accommodations/BaseAccommodationsRoute";
|
|
|
|
export function AccommodationsReservationsRoute(): React.ReactElement {
|
|
const snackbar = useSnackbar();
|
|
const alert = useAlert();
|
|
const confirm = useConfirm();
|
|
const loadingMessage = useLoadingMessage();
|
|
|
|
const loadKey = React.useRef(1);
|
|
|
|
const user = useUser();
|
|
const family = useFamily();
|
|
const accommodations = useAccommodations();
|
|
const updateReservation = useUpdateAccommodationReservation();
|
|
|
|
const [reservations, setReservations] = React.useState<
|
|
AccommodationsReservationsList | undefined
|
|
>();
|
|
const [users, setUsers] = React.useState<FamilyUser[] | null>(null);
|
|
|
|
const [showValidated, setShowValidated] = React.useState(true);
|
|
const [showRejected, setShowRejected] = React.useState(true);
|
|
const [showPending, setShowPending] = React.useState(true);
|
|
|
|
const [hiddenPeople, setHiddenPeople] = React.useState<Set<number>>(
|
|
new Set()
|
|
);
|
|
const [hiddenAccommodations, setHiddenAccommodations] = React.useState<
|
|
Set<number>
|
|
>(new Set());
|
|
|
|
const eventPopupAnchor = React.useRef<HTMLDivElement>(null);
|
|
const [activeEvent, setActiveEvent] = React.useState<
|
|
| undefined
|
|
| {
|
|
user: FamilyUser;
|
|
accommodation: Accommodation;
|
|
reservation: AccommodationReservation;
|
|
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
>();
|
|
|
|
const [validateResaAnchorEl, setValidateResaAnchorEl] =
|
|
React.useState<null | HTMLElement>(null);
|
|
|
|
const load = async () => {
|
|
setReservations(
|
|
await AccommodationsReservationsApi.FullListOfFamily(family.family)
|
|
);
|
|
setUsers(await FamilyApi.GetUsersList(family.family.family_id));
|
|
};
|
|
|
|
const reload = async () => {
|
|
loadKey.current += 1;
|
|
setUsers(null);
|
|
};
|
|
|
|
const visibleReservations = React.useMemo(() => {
|
|
return reservations?.filter((r) => {
|
|
if (!showValidated && r.validated === true) return false;
|
|
if (!showPending && r.validated === null) return false;
|
|
if (!showRejected && r.validated === false) return false;
|
|
if (hiddenPeople.has(r.user_id)) return false;
|
|
if (hiddenAccommodations.has(r.accommodation_id)) return false;
|
|
return true;
|
|
});
|
|
}, [
|
|
showValidated,
|
|
showRejected,
|
|
showPending,
|
|
hiddenPeople,
|
|
hiddenAccommodations,
|
|
reservations,
|
|
]);
|
|
|
|
const onSelect = async (d: DateSelectArg) => {
|
|
try {
|
|
const resa = await updateReservation(
|
|
{
|
|
accommodation_id: -1,
|
|
start: Math.floor(d.start.getTime() / 1000),
|
|
end: Math.floor(d.end.getTime() / 1000),
|
|
},
|
|
true
|
|
);
|
|
|
|
if (!resa) return;
|
|
|
|
loadingMessage.show("Création de la réservation en cours...");
|
|
|
|
await AccommodationsReservationsApi.Create(family.family, resa);
|
|
|
|
reload();
|
|
snackbar("La réservation a été créée avec succès !");
|
|
} catch (e) {
|
|
console.error("Failed to create a reservation!", e);
|
|
alert("Échec de la création de la réservation!");
|
|
} finally {
|
|
loadingMessage.hide();
|
|
}
|
|
};
|
|
|
|
const onEventClick = (ev: EventClickArg) => {
|
|
const id: number = ev.event.extendedProps.id;
|
|
const resa = reservations?.get(id)!;
|
|
const acc = accommodations.accommodations.get(resa.accommodation_id)!;
|
|
|
|
const user = users?.find((u) => u.user_id === resa.user_id);
|
|
|
|
if (!user) {
|
|
console.error(`User ${resa.user_id} not found!`);
|
|
return;
|
|
}
|
|
|
|
const loc = ev.el.getBoundingClientRect();
|
|
setActiveEvent({
|
|
reservation: resa,
|
|
accommodation: acc,
|
|
user: user,
|
|
|
|
x: loc.left,
|
|
y: loc.top,
|
|
w: loc.width,
|
|
h: loc.height,
|
|
});
|
|
};
|
|
|
|
const respondToResaRequest = async (
|
|
r: AccommodationReservation,
|
|
validate: boolean
|
|
) => {
|
|
try {
|
|
loadingMessage.show("Validation de la réservation en cours...");
|
|
|
|
setActiveEvent(undefined);
|
|
|
|
await AccommodationsReservationsApi.Validate(r, validate);
|
|
|
|
reload();
|
|
snackbar("La réservation a été mise à jour avec succès !");
|
|
} catch (e) {
|
|
console.error("Failed to respond to reservation request!", e);
|
|
alert(`Echec de l'enregistrement de la réponse à la réservation ! ${e}`);
|
|
} finally {
|
|
loadingMessage.hide();
|
|
}
|
|
};
|
|
|
|
const validateReservation = async (r: AccommodationReservation) => {
|
|
respondToResaRequest(r, true);
|
|
};
|
|
|
|
const rejectReservation = async (r: AccommodationReservation) => {
|
|
if (
|
|
!(await confirm(
|
|
"Voulez-vous vraiment rejeter cette demande de réservation ?"
|
|
))
|
|
)
|
|
return;
|
|
respondToResaRequest(r, false);
|
|
};
|
|
|
|
const changeReservation = async (r: AccommodationReservation) => {
|
|
try {
|
|
const ac = accommodations.accommodations.get(r.accommodation_id);
|
|
if (
|
|
ac?.need_validation &&
|
|
!(await confirm(
|
|
"Voulez-vous vraiment changer cette réservation ? Celle-ci devra être de nouveau validée !"
|
|
))
|
|
)
|
|
return;
|
|
|
|
const newResa = await updateReservation(
|
|
{
|
|
reservation_id: r.id,
|
|
accommodation_id: r.accommodation_id,
|
|
start: r.reservation_start,
|
|
end: r.reservation_end,
|
|
},
|
|
false
|
|
);
|
|
|
|
if (!newResa) return;
|
|
|
|
setActiveEvent(undefined);
|
|
loadingMessage.show("Mise à jour de la réservation en cours...");
|
|
|
|
await AccommodationsReservationsApi.Update(family.family, newResa);
|
|
|
|
reload();
|
|
snackbar("La réservation a été mise à jour avec succès !");
|
|
} catch (e) {
|
|
console.error("Failed to update a reservation!", e);
|
|
alert("Échec de la mise à jour de la réservation!");
|
|
} finally {
|
|
loadingMessage.hide();
|
|
}
|
|
};
|
|
|
|
const deleteReservation = async (r: AccommodationReservation) => {
|
|
try {
|
|
if (
|
|
!(await confirm(
|
|
"Voulez-vous vraiment supprimer cette réservation ? L'opération n'est pas réversible !"
|
|
))
|
|
)
|
|
return;
|
|
|
|
setActiveEvent(undefined);
|
|
loadingMessage.show("Suppression de la réservation en cours...");
|
|
|
|
await AccommodationsReservationsApi.Delete(r);
|
|
|
|
reload();
|
|
snackbar("La réservation a été supprimée avec succès !");
|
|
} catch (e) {
|
|
console.error("Failed to delete a reservation!", e);
|
|
alert("Échec de la suppression de la réservation!");
|
|
} finally {
|
|
loadingMessage.hide();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<FamilyPageTitle title="Réservations" />
|
|
<AsyncWidget
|
|
loadKey={loadKey.current}
|
|
load={load}
|
|
errMsg="Echec du chargement de la liste des réservations !"
|
|
build={() => (
|
|
<div style={{ display: "flex", flexDirection: "row" }}>
|
|
<div style={{ flex: 1, maxWidth: "250px", marginRight: "20px" }}>
|
|
<Alert severity="info">
|
|
Cliquez sur le calendrier pour créer une réservation.
|
|
</Alert>
|
|
|
|
{/* Invitation status */}
|
|
<FormControl
|
|
sx={{ m: 3 }}
|
|
component="fieldset"
|
|
variant="standard"
|
|
>
|
|
<FormLabel component="legend">Status</FormLabel>
|
|
<FormGroup>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={showValidated}
|
|
onChange={(_ev, v) => setShowValidated(v)}
|
|
color="success"
|
|
/>
|
|
}
|
|
label="Validées"
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={showRejected}
|
|
onChange={(_ev, v) => setShowRejected(v)}
|
|
color="error"
|
|
/>
|
|
}
|
|
label="Rejetées"
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={showPending}
|
|
onChange={(_ev, v) => setShowPending(v)}
|
|
color="info"
|
|
/>
|
|
}
|
|
label="En attente de validation"
|
|
/>
|
|
</FormGroup>
|
|
</FormControl>
|
|
|
|
{/* Accommodations */}
|
|
<FormControl
|
|
sx={{ m: 3 }}
|
|
component="fieldset"
|
|
variant="standard"
|
|
>
|
|
<FormLabel component="legend">Logements</FormLabel>
|
|
<FormGroup>
|
|
{accommodations.accommodations.fullList.map((a) => (
|
|
<FormControlLabel
|
|
key={a.id}
|
|
control={
|
|
<Checkbox
|
|
sx={{
|
|
color: "#" + a.color,
|
|
"&.Mui-checked": {
|
|
color: "#" + a.color,
|
|
},
|
|
}}
|
|
checked={!hiddenAccommodations.has(a.id)}
|
|
onChange={(_ev, v) => {
|
|
if (v) hiddenAccommodations.delete(a.id);
|
|
else hiddenAccommodations.add(a.id);
|
|
setHiddenAccommodations(
|
|
new Set(hiddenAccommodations)
|
|
);
|
|
}}
|
|
/>
|
|
}
|
|
label={a.name}
|
|
/>
|
|
))}
|
|
</FormGroup>
|
|
</FormControl>
|
|
|
|
{/* People */}
|
|
<FormControl
|
|
sx={{ m: 3 }}
|
|
component="fieldset"
|
|
variant="standard"
|
|
>
|
|
<FormLabel component="legend">Personnes</FormLabel>
|
|
<FormGroup>
|
|
{users?.map((u) => (
|
|
<FormControlLabel
|
|
key={u.user_id}
|
|
control={
|
|
<Checkbox
|
|
checked={!hiddenPeople.has(u.user_id)}
|
|
onChange={(_ev, v) => {
|
|
if (v) hiddenPeople.delete(u.user_id);
|
|
else hiddenPeople.add(u.user_id);
|
|
setHiddenPeople(new Set(hiddenPeople));
|
|
}}
|
|
/>
|
|
}
|
|
label={u.user_name}
|
|
/>
|
|
))}
|
|
</FormGroup>
|
|
</FormControl>
|
|
</div>
|
|
|
|
{/* The calendar */}
|
|
<div style={{ flex: 5 }}>
|
|
<FullCalendar
|
|
editable={true}
|
|
selectable={true}
|
|
plugins={[dayGridPlugin, listPlugin, interactionPlugin]}
|
|
initialView="dayGridMonth"
|
|
height="700px"
|
|
locale={frLocale}
|
|
headerToolbar={{
|
|
left: "prev,next today",
|
|
center: "title",
|
|
right: "dayGridMonth,dayGridWeek,dayGridDay,listWeek",
|
|
}}
|
|
select={onSelect}
|
|
eventClick={onEventClick}
|
|
events={visibleReservations?.map((r) => {
|
|
const a = accommodations.accommodations.get(
|
|
r.accommodation_id
|
|
)!;
|
|
const u = users?.find((u) => u.user_id === r.user_id);
|
|
return {
|
|
title: `${u?.user_name} - ${a.name}`,
|
|
start: fmtUnixDateFullCalendar(r.reservation_start, false),
|
|
end: fmtUnixDateFullCalendar(r.reservation_end, true),
|
|
allDay: true,
|
|
color: a.color ? "#" + a.color : undefined,
|
|
borderColor:
|
|
r.validated === true
|
|
? "green"
|
|
: r.validated === false
|
|
? "red dotted"
|
|
: "grey dotted",
|
|
extendedProps: {
|
|
id: r.id,
|
|
},
|
|
};
|
|
})}
|
|
/>
|
|
</div>
|
|
|
|
{/* Calendar event popover */}
|
|
<div
|
|
ref={eventPopupAnchor}
|
|
id="active-event-anchor"
|
|
style={{
|
|
position: "fixed",
|
|
top: activeEvent?.y + "px",
|
|
left: activeEvent?.x + "px",
|
|
width: activeEvent?.w + "px",
|
|
height: activeEvent?.h + "px",
|
|
backgroundColor: "pink",
|
|
zIndex: 0,
|
|
}}
|
|
></div>
|
|
<Popover
|
|
open={activeEvent !== undefined}
|
|
anchorEl={eventPopupAnchor.current}
|
|
onClose={() => {
|
|
setActiveEvent(undefined);
|
|
}}
|
|
anchorOrigin={{
|
|
vertical: "bottom",
|
|
horizontal: "left",
|
|
}}
|
|
>
|
|
<Card sx={{ maxWidth: 345 }} elevation={6}>
|
|
<CardHeader
|
|
avatar={
|
|
<Avatar sx={{ bgcolor: red[500] }}>
|
|
{activeEvent?.user.user_name
|
|
.substring(0, 1)
|
|
.toLocaleUpperCase()}
|
|
</Avatar>
|
|
}
|
|
title={activeEvent?.user.user_name}
|
|
subheader={activeEvent?.user.user_mail}
|
|
/>
|
|
|
|
<CardContent>
|
|
<Typography variant="body2" color="text.secondary">
|
|
<p>
|
|
Réservation de {activeEvent?.accommodation.name}
|
|
<br />
|
|
<em>{activeEvent?.accommodation.description}</em>
|
|
</p>
|
|
<p>
|
|
Du{" "}
|
|
{fmtUnixDate(
|
|
activeEvent?.reservation.reservation_start ?? 0
|
|
)}{" "}
|
|
<br />
|
|
Au{" "}
|
|
{fmtUnixDate(
|
|
activeEvent?.reservation.reservation_end ?? 0
|
|
)}
|
|
</p>
|
|
<p>
|
|
<strong>
|
|
{activeEvent?.reservation.validated === false ? (
|
|
<span style={{ color: "#f44336" }}>Refusée</span>
|
|
) : activeEvent?.reservation.validated === true ? (
|
|
<span style={{ color: "#66bb6a" }}>Validée</span>
|
|
) : (
|
|
<span style={{ color: "#29b6f6" }}>
|
|
En attente de validation
|
|
</span>
|
|
)}
|
|
</strong>
|
|
</p>
|
|
</Typography>
|
|
</CardContent>
|
|
<CardActions disableSpacing>
|
|
{activeEvent?.accommodation.need_validation &&
|
|
family.family.is_admin && (
|
|
<>
|
|
<Tooltip
|
|
title="Valider (ou rejeter) la réservation"
|
|
arrow
|
|
>
|
|
<IconButton
|
|
onClick={(e) =>
|
|
setValidateResaAnchorEl(e.currentTarget)
|
|
}
|
|
>
|
|
<RuleIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Menu
|
|
anchorEl={validateResaAnchorEl}
|
|
open={!!validateResaAnchorEl && !!activeEvent}
|
|
onClose={() => setValidateResaAnchorEl(null)}
|
|
>
|
|
<MenuItem
|
|
disabled={
|
|
activeEvent.reservation.validated === true
|
|
}
|
|
onClick={() =>
|
|
validateReservation(activeEvent.reservation)
|
|
}
|
|
>
|
|
Valider
|
|
</MenuItem>
|
|
<MenuItem
|
|
disabled={
|
|
activeEvent.reservation.validated === false
|
|
}
|
|
onClick={() =>
|
|
rejectReservation(activeEvent.reservation)
|
|
}
|
|
>
|
|
Rejeter
|
|
</MenuItem>
|
|
</Menu>
|
|
</>
|
|
)}
|
|
{user.user.id === activeEvent?.reservation.user_id && (
|
|
<>
|
|
<Tooltip title="Modifier les dates de réservation" arrow>
|
|
<IconButton
|
|
onClick={() =>
|
|
changeReservation(activeEvent?.reservation)
|
|
}
|
|
>
|
|
<EditIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Supprimer la réservation" arrow>
|
|
<IconButton
|
|
color="error"
|
|
onClick={() =>
|
|
deleteReservation(activeEvent?.reservation)
|
|
}
|
|
>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</CardActions>
|
|
</Card>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|