Genealogy as a feature (#175)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Start our journey into turning GeneIT as afully featured family intranet by making genealogy a feature that can be disabled by family admins Reviewed-on: #175
This commit is contained in:
506
geneit_app/src/routes/family/genealogy/FamilyCoupleRoute.tsx
Normal file
506
geneit_app/src/routes/family/genealogy/FamilyCoupleRoute.tsx
Normal file
@ -0,0 +1,506 @@
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import FileDownloadIcon from "@mui/icons-material/FileDownload";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import { Button, Grid, Stack } from "@mui/material";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ServerApi } from "../../../api/ServerApi";
|
||||
import { Couple, CoupleApi } from "../../../api/genealogy/CoupleApi";
|
||||
import { Member } from "../../../api/genealogy/MemberApi";
|
||||
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 { useQuery } from "../../../hooks/useQuery";
|
||||
import { AsyncWidget } from "../../../widgets/AsyncWidget";
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { ConfirmLeaveWithoutSaveDialog } from "../../../widgets/ConfirmLeaveWithoutSaveDialog";
|
||||
import { CouplePhoto } from "../../../widgets/CouplePhoto";
|
||||
import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle";
|
||||
import { MemberItem } from "../../../widgets/MemberItem";
|
||||
import { PropertiesBox } from "../../../widgets/PropertiesBox";
|
||||
import { RouterLink } from "../../../widgets/RouterLink";
|
||||
import { DateInput } from "../../../widgets/forms/DateInput";
|
||||
import { MemberInput } from "../../../widgets/forms/MemberInput";
|
||||
import { PropSelect } from "../../../widgets/forms/PropSelect";
|
||||
import { UploadPhotoButton } from "../../../widgets/forms/UploadPhotoButton";
|
||||
import { useGenealogy } from "../../../widgets/genealogy/BaseGenealogyRoute";
|
||||
|
||||
/**
|
||||
* Create a new couple route
|
||||
*/
|
||||
export function FamilyCreateCoupleRoute(): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [shouldQuit, setShouldQuit] = React.useState(false);
|
||||
const n = useNavigate();
|
||||
const genealogy = useGenealogy();
|
||||
const family = useFamily();
|
||||
|
||||
const params = useQuery();
|
||||
const couple = Couple.New(family.family.family_id);
|
||||
const wife = Number(params.get("wife"));
|
||||
const husband = Number(params.get("husband"));
|
||||
if (wife) couple.wife = wife;
|
||||
if (husband) couple.husband = husband;
|
||||
|
||||
const create = async (m: Couple) => {
|
||||
try {
|
||||
const r = await CoupleApi.Create(m);
|
||||
|
||||
await genealogy.reloadCouplesList();
|
||||
|
||||
setShouldQuit(true);
|
||||
n(family.family.coupleURL(r));
|
||||
snackbar(`La fiche pour le couple a été créée avec succès !`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Echec de la création du couple !");
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setShouldQuit(true);
|
||||
n(family.family.URL("genealogy/couples"));
|
||||
};
|
||||
|
||||
return (
|
||||
<CouplePage
|
||||
couple={couple}
|
||||
creating={true}
|
||||
editing={true}
|
||||
onCancel={cancel}
|
||||
onSave={create}
|
||||
shouldAllowLeaving={shouldQuit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing couple route
|
||||
*/
|
||||
export function FamilyCoupleRoute(): React.ReactElement {
|
||||
const count = React.useRef(1);
|
||||
|
||||
const n = useNavigate();
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const family = useFamily();
|
||||
const genealogy = useGenealogy();
|
||||
const { coupleId } = useParams();
|
||||
|
||||
const [couple, setCouple] = React.useState<Couple>();
|
||||
const load = async () => {
|
||||
setCouple(await CoupleApi.GetSingle(family.familyId, Number(coupleId)));
|
||||
};
|
||||
|
||||
const forceReload = async () => {
|
||||
count.current += 1;
|
||||
setCouple(undefined);
|
||||
|
||||
await genealogy.reloadCouplesList();
|
||||
};
|
||||
|
||||
const deleteCouple = async () => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
"Voulez-vous vraiment supprimer cette fiche de couple ? L'opération n'est pas réversible !"
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
await CoupleApi.Delete(couple!);
|
||||
|
||||
snackbar("La fiche du couple a été supprimée avec succès !");
|
||||
n(family.family.URL("genealogy/couples"));
|
||||
|
||||
await genealogy.reloadCouplesList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Échec de la suppression du couple !");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={`${coupleId}-${count.current}`}
|
||||
load={load}
|
||||
ready={couple !== undefined}
|
||||
errMsg="Echec du chargement des informations du couple !"
|
||||
build={() => (
|
||||
<CouplePage
|
||||
couple={couple!}
|
||||
children={genealogy.members.childrenOfCouple(couple!)}
|
||||
creating={false}
|
||||
editing={false}
|
||||
onRequestDelete={deleteCouple}
|
||||
onRequestEdit={() => n(family.family.coupleURL(couple!, true))}
|
||||
onForceReload={forceReload}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit existing couple route
|
||||
*/
|
||||
export function FamilyEditCoupleRoute(): React.ReactElement {
|
||||
const n = useNavigate();
|
||||
const { coupleId } = useParams();
|
||||
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [shouldQuit, setShouldQuit] = React.useState(false);
|
||||
|
||||
const genealogy = useGenealogy();
|
||||
const family = useFamily();
|
||||
|
||||
const [couple, setCouple] = React.useState<Couple>();
|
||||
const load = async () => {
|
||||
setCouple(await CoupleApi.GetSingle(family.familyId, Number(coupleId)));
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setShouldQuit(true);
|
||||
n(family.family.coupleURL(couple!));
|
||||
//n(-1);
|
||||
};
|
||||
|
||||
const save = async (c: Couple) => {
|
||||
try {
|
||||
await CoupleApi.Update(c);
|
||||
|
||||
snackbar("Les informations du couple ont été mises à jour avec succès !");
|
||||
|
||||
await genealogy.reloadCouplesList();
|
||||
|
||||
setShouldQuit(true);
|
||||
n(family.family.coupleURL(c, false));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Échec de la mise à jour des informations du couple !");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={coupleId}
|
||||
load={load}
|
||||
errMsg="Échec du chargement des informations du couple !"
|
||||
build={() => (
|
||||
<CouplePage
|
||||
couple={couple!}
|
||||
creating={false}
|
||||
editing={true}
|
||||
onCancel={cancel}
|
||||
onSave={save}
|
||||
shouldAllowLeaving={shouldQuit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CouplePage(p: {
|
||||
couple: Couple;
|
||||
editing: boolean;
|
||||
creating: boolean;
|
||||
shouldAllowLeaving?: boolean;
|
||||
children?: Member[];
|
||||
onCancel?: () => void;
|
||||
onSave?: (m: Couple) => Promise<void>;
|
||||
onRequestEdit?: () => void;
|
||||
onRequestDelete?: () => void;
|
||||
onForceReload?: () => void;
|
||||
}): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const family = useFamily();
|
||||
|
||||
const [changed, setChanged] = React.useState(false);
|
||||
const [couple, setCouple] = React.useState(
|
||||
new Couple(structuredClone(p.couple))
|
||||
);
|
||||
|
||||
const updatedCouple = () => {
|
||||
setChanged(true);
|
||||
setCouple(new Couple(structuredClone(couple)));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
loadingMessage.show(
|
||||
"Enregistrement des informations du couple en cours..."
|
||||
);
|
||||
await p.onSave!(couple);
|
||||
loadingMessage.hide();
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (
|
||||
changed &&
|
||||
!(await confirm(
|
||||
"Voulez-vous vraiment retirer les modifications apportées ? Celles-ci seront perdues !"
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
p.onCancel!();
|
||||
};
|
||||
|
||||
const uploadNewPhoto = async (b: Blob) => {
|
||||
await CoupleApi.SetCouplePhoto(couple, b);
|
||||
snackbar("La photo du couple a été mise à jour avec succès !");
|
||||
p.onForceReload?.();
|
||||
};
|
||||
|
||||
const deletePhoto = async () => {
|
||||
try {
|
||||
if (!(await confirm("Voulez-vous supprimer cette photo ?"))) return;
|
||||
|
||||
await CoupleApi.RemoveCouplePhoto(couple);
|
||||
|
||||
snackbar("La photo du couple a été supprimée avec succès !");
|
||||
p.onForceReload?.();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Échec de la suppresion de la photo !");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "2000px", margin: "auto" }}>
|
||||
<ConfirmLeaveWithoutSaveDialog
|
||||
shouldBlock={changed && p.shouldAllowLeaving !== true}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FamilyPageTitle
|
||||
title={
|
||||
(p.editing
|
||||
? p.creating
|
||||
? "Création"
|
||||
: "Édition"
|
||||
: "Visualisation") + " d'une fiche de couple"
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{/* Edit button */}
|
||||
{p.onRequestEdit && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={p.onRequestEdit}
|
||||
size="large"
|
||||
>
|
||||
Editer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
{p.onRequestDelete && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={p.onRequestDelete}
|
||||
size="large"
|
||||
color="error"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
{p.editing && changed && (
|
||||
<Button
|
||||
variant={"contained"}
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={save}
|
||||
size="large"
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{p.editing && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ClearIcon />}
|
||||
onClick={cancel}
|
||||
size="small"
|
||||
>
|
||||
Annuler les modifications
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* General info */}
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Informations générales">
|
||||
{/* Husband */}
|
||||
<br />
|
||||
<MemberInput
|
||||
editable={p.editing}
|
||||
label="Époux"
|
||||
onValueChange={(m) => {
|
||||
couple.husband = m;
|
||||
updatedCouple();
|
||||
}}
|
||||
filter={(m) => m.sex === "M" || m.sex === undefined}
|
||||
current={couple.husband}
|
||||
/>
|
||||
|
||||
{/* Wife */}
|
||||
<br />
|
||||
<MemberInput
|
||||
editable={p.editing}
|
||||
label="Épouse"
|
||||
onValueChange={(m) => {
|
||||
couple.wife = m;
|
||||
updatedCouple();
|
||||
}}
|
||||
filter={(m) => m.sex === "F" || m.sex === undefined}
|
||||
current={couple.wife}
|
||||
/>
|
||||
<br />
|
||||
|
||||
{/* State */}
|
||||
<PropSelect
|
||||
editing={p.editing}
|
||||
label="Status"
|
||||
value={couple.state}
|
||||
onValueChange={(s) => {
|
||||
couple.state = s;
|
||||
updatedCouple();
|
||||
}}
|
||||
options={ServerApi.Config.couples_states.map((s) => {
|
||||
return { label: s.fr, value: s.code };
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Wedding day */}
|
||||
<DateInput
|
||||
label="Date du mariage"
|
||||
editable={p.editing}
|
||||
id="dow"
|
||||
value={couple.dateOfWedding}
|
||||
onValueChange={(d) => {
|
||||
couple.wedding_year = d.year;
|
||||
couple.wedding_month = d.month;
|
||||
couple.wedding_day = d.day;
|
||||
updatedCouple();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Divorce day */}
|
||||
<DateInput
|
||||
label="Date du divorce"
|
||||
editable={p.editing}
|
||||
id="dod"
|
||||
value={couple.dateOfDivorce}
|
||||
onValueChange={(d) => {
|
||||
couple.divorce_year = d.year;
|
||||
couple.divorce_month = d.month;
|
||||
couple.divorce_day = d.day;
|
||||
updatedCouple();
|
||||
}}
|
||||
/>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
|
||||
{
|
||||
/* Photo */ !family.family.disable_couple_photos && (
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Photo">
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<CouplePhoto couple={couple} width={150} />
|
||||
<br />
|
||||
{p.editing ? (
|
||||
<p>
|
||||
Veuillez enregistrer / annuler les modifications apportées
|
||||
à la fiche avant de changer la photo du couple.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<UploadPhotoButton
|
||||
label={couple.hasPhoto ? "Remplacer" : "Ajouter"}
|
||||
onPhotoSelected={uploadNewPhoto}
|
||||
aspect={5 / 4}
|
||||
/>{" "}
|
||||
{couple.hasPhoto && (
|
||||
<RouterLink to={couple.photoURL!} target="_blank">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileDownloadIcon />}
|
||||
>
|
||||
Télécharger
|
||||
</Button>
|
||||
</RouterLink>
|
||||
)}{" "}
|
||||
{couple.hasPhoto && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteIcon />}
|
||||
color="error"
|
||||
onClick={deletePhoto}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}{" "}
|
||||
</div>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Children */}
|
||||
{p.children && (
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Enfants">
|
||||
{p.children.length === 0 ? (
|
||||
<>Aucun enfant</>
|
||||
) : (
|
||||
p.children.map((c) => (
|
||||
<RouterLink key={c.id} to={family.family.memberURL(c)}>
|
||||
<MemberItem member={c} />
|
||||
</RouterLink>
|
||||
))
|
||||
)}
|
||||
|
||||
{couple.wife && couple.husband && (
|
||||
<div style={{ display: "flex", justifyContent: "end" }}>
|
||||
<RouterLink
|
||||
to={family.family.URL(
|
||||
`genealogy/member/create?mother=${couple.wife}&father=${couple.husband}`
|
||||
)}
|
||||
>
|
||||
<Button>Nouveau</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
)}
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,280 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import { Button, TextField, Tooltip } from "@mui/material";
|
||||
import { DataGrid, GridActionsCellItem, GridColDef } from "@mui/x-data-grid";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Couple, CoupleApi } from "../../../api/genealogy/CoupleApi";
|
||||
import { dateTimestamp, fmtDate } from "../../../api/genealogy/MemberApi";
|
||||
import { ServerApi } from "../../../api/ServerApi";
|
||||
import { useAlert } from "../../../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useSnackbar } from "../../../hooks/context_providers/SnackbarProvider";
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { CouplePhoto } from "../../../widgets/CouplePhoto";
|
||||
import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle";
|
||||
import { MemberPhoto } from "../../../widgets/MemberPhoto";
|
||||
import { RouterLink } from "../../../widgets/RouterLink";
|
||||
import { useGenealogy } from "../../../widgets/genealogy/BaseGenealogyRoute";
|
||||
|
||||
export function FamilyCouplesListRoute(): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const family = useFamily();
|
||||
const genealogy = useGenealogy();
|
||||
|
||||
const [filter, setFilter] = React.useState("");
|
||||
|
||||
const processDeletion = async (c: Couple) => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Voulez-vous vraiment supprimer cette fiche de couple ?`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
await CoupleApi.Delete(c);
|
||||
await genealogy.reloadCouplesList();
|
||||
|
||||
snackbar("La fiche du couple a été supprimée avec succès !");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Echec de la suppression de la fiche ! ");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FamilyPageTitle title="Couples de la famille" />
|
||||
<RouterLink to={family.family.URL("couple/create")}>
|
||||
<Tooltip title="Créer la fiche d'un nouveau couple">
|
||||
<Button startIcon={<AddIcon />}>Nouveau</Button>
|
||||
</Tooltip>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
{genealogy.couples.isEmpty ? (
|
||||
<p>
|
||||
Votre famille n'a aucun couple enregistré pour le moment ! Utilisez le
|
||||
bouton situé en haut à droite pour créer le premier !
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label={"Rechercher un couple..."}
|
||||
variant="standard"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
style={{ maxWidth: "500px", margin: "10px" }}
|
||||
/>
|
||||
|
||||
<CouplesTable
|
||||
couples={
|
||||
filter === ""
|
||||
? genealogy.couples.fullList
|
||||
: genealogy.couples.filter(
|
||||
(m) =>
|
||||
(m.wife &&
|
||||
genealogy.members
|
||||
.get(m.wife)!
|
||||
.fullName.toLocaleLowerCase()
|
||||
.includes(filter.toLocaleLowerCase())) ||
|
||||
(m.husband &&
|
||||
genealogy.members
|
||||
.get(m.husband)!
|
||||
.fullName.toLocaleLowerCase()
|
||||
.includes(filter.toLocaleLowerCase())) === true
|
||||
)
|
||||
}
|
||||
onDelete={processDeletion}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CouplesTable(p: {
|
||||
couples: Couple[];
|
||||
onDelete: (m: Couple) => void;
|
||||
}): React.ReactElement {
|
||||
const family = useFamily();
|
||||
const genealogy = useGenealogy();
|
||||
|
||||
const n = useNavigate();
|
||||
|
||||
const compareInvertedMembersNames = (
|
||||
v1: number | undefined,
|
||||
v2: number | undefined
|
||||
) => {
|
||||
const n1 =
|
||||
((v1 && genealogy.members.get(v1)?.invertedFullName) ?? "") || "";
|
||||
const n2 =
|
||||
((v2 && genealogy.members.get(v2)?.invertedFullName) ?? "") || "";
|
||||
|
||||
return n1?.localeCompare(n2, undefined, {
|
||||
ignorePunctuation: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
};
|
||||
|
||||
const columns: GridColDef<Couple>[] = [
|
||||
{
|
||||
field: "signed_photo_id",
|
||||
headerName: "",
|
||||
disableColumnMenu: true,
|
||||
sortable: false,
|
||||
width: 60,
|
||||
renderCell(params) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", height: "100%" }}
|
||||
>
|
||||
<CouplePhoto couple={params.row} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "husband",
|
||||
headerName: "Époux",
|
||||
flex: 5,
|
||||
sortComparator: compareInvertedMembersNames,
|
||||
renderCell(params) {
|
||||
return <MemberCell id={params.row.husband} />;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "wife",
|
||||
headerName: "Épouse",
|
||||
flex: 5,
|
||||
sortComparator: compareInvertedMembersNames,
|
||||
renderCell(params) {
|
||||
return <MemberCell id={params.row.wife} />;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "state",
|
||||
headerName: "État",
|
||||
flex: 3,
|
||||
renderCell(params) {
|
||||
return ServerApi.Config.couples_states.find(
|
||||
(c) => c.code === params.row.state
|
||||
)?.fr;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "dateOfWedding",
|
||||
headerName: "Date de mariage",
|
||||
flex: 3,
|
||||
sortComparator(v1, v2) {
|
||||
const d1 = dateTimestamp(v1);
|
||||
const d2 = dateTimestamp(v2);
|
||||
return d1 - d2;
|
||||
},
|
||||
renderCell(params) {
|
||||
return fmtDate(params.row.dateOfWedding);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "dateOfDivorce",
|
||||
headerName: "Date de divorce",
|
||||
flex: 3,
|
||||
sortComparator(v1, v2) {
|
||||
const d1 = dateTimestamp(v1);
|
||||
const d2 = dateTimestamp(v2);
|
||||
return d1 - d2;
|
||||
},
|
||||
renderCell(params) {
|
||||
return fmtDate(params.row.dateOfDivorce);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
type: "actions",
|
||||
sortable: false,
|
||||
width: 120,
|
||||
disableColumnMenu: true,
|
||||
getActions(params) {
|
||||
return [
|
||||
<GridActionsCellItem
|
||||
icon={<VisibilityIcon />}
|
||||
label="Consulter la fiche du couple"
|
||||
className="textPrimary"
|
||||
onClick={() => n(family.family.coupleURL(params.row))}
|
||||
color="inherit"
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<EditIcon />}
|
||||
label="Modifier la fiche du couple"
|
||||
className="textPrimary"
|
||||
onClick={() => n(family.family.coupleURL(params.row, true))}
|
||||
color="inherit"
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<DeleteIcon />}
|
||||
label="Supprimer la fiche du couple"
|
||||
onClick={() => p.onDelete(params.row)}
|
||||
color="inherit"
|
||||
/>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// If couple photos are hidden, remove their column
|
||||
if (family.family.disable_couple_photos) columns.splice(0, 1);
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
style={{ flex: "1" }}
|
||||
rows={p.couples}
|
||||
columns={columns}
|
||||
autoPageSize
|
||||
getRowId={(c) => c.id}
|
||||
onCellDoubleClick={(p) => {
|
||||
/*let member;
|
||||
if (p.field === "wife") member = family.members.get(p.row.wife);
|
||||
else if (p.field === "husband")
|
||||
member = family.members.get(p.row.husband);
|
||||
|
||||
if (member) {
|
||||
n(family.family.memberURL(member));
|
||||
return;
|
||||
}*/
|
||||
|
||||
n(family.family.coupleURL(p.row));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberCell(p: { id?: number }): React.ReactElement {
|
||||
const genealogy = useGenealogy();
|
||||
if (!p.id) return <></>;
|
||||
|
||||
const member = genealogy.members.get(p.id!)!;
|
||||
|
||||
return (
|
||||
<Tooltip title="Double-cliquez ici pour accéder à la fiche du membre">
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<MemberPhoto member={member} width={25} /> {member.fullName}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
17
geneit_app/src/routes/family/genealogy/FamilyHomeRoute.tsx
Normal file
17
geneit_app/src/routes/family/genealogy/FamilyHomeRoute.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle";
|
||||
|
||||
export function FamilyHomeRoute(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<FamilyPageTitle title="Votre famille" />
|
||||
<div style={{ margin: "20px" }}>
|
||||
<p>
|
||||
Bienvenue sur l'espace informatique dédié à la vie de votre famille !
|
||||
Veuillez utiliser le menu situé à gauche pour accéder aux différentes
|
||||
sections de l'application.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
784
geneit_app/src/routes/family/genealogy/FamilyMemberRoute.tsx
Normal file
784
geneit_app/src/routes/family/genealogy/FamilyMemberRoute.tsx
Normal file
@ -0,0 +1,784 @@
|
||||
import { mdiFamilyTree } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import FileDownloadIcon from "@mui/icons-material/FileDownload";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import {
|
||||
Button,
|
||||
Grid,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import * as EmailValidator from "email-validator";
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ServerApi } from "../../../api/ServerApi";
|
||||
import { Couple } from "../../../api/genealogy/CoupleApi";
|
||||
import { Member, MemberApi, fmtDate } from "../../../api/genealogy/MemberApi";
|
||||
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 { useQuery } from "../../../hooks/useQuery";
|
||||
import { AsyncWidget } from "../../../widgets/AsyncWidget";
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { ConfirmLeaveWithoutSaveDialog } from "../../../widgets/ConfirmLeaveWithoutSaveDialog";
|
||||
import { CouplePhoto } from "../../../widgets/CouplePhoto";
|
||||
import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle";
|
||||
import { MemberItem } from "../../../widgets/MemberItem";
|
||||
import { MemberPhoto } from "../../../widgets/MemberPhoto";
|
||||
import { PropertiesBox } from "../../../widgets/PropertiesBox";
|
||||
import { RouterLink } from "../../../widgets/RouterLink";
|
||||
import { DateInput } from "../../../widgets/forms/DateInput";
|
||||
import { MemberInput } from "../../../widgets/forms/MemberInput";
|
||||
import { PropCheckbox } from "../../../widgets/forms/PropCheckbox";
|
||||
import { PropEdit } from "../../../widgets/forms/PropEdit";
|
||||
import { PropSelect } from "../../../widgets/forms/PropSelect";
|
||||
import { SexSelection } from "../../../widgets/forms/SexSelection";
|
||||
import { UploadPhotoButton } from "../../../widgets/forms/UploadPhotoButton";
|
||||
import { useGenealogy } from "../../../widgets/genealogy/BaseGenealogyRoute";
|
||||
|
||||
/**
|
||||
* Create a new member route
|
||||
*/
|
||||
export function FamilyCreateMemberRoute(): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [shouldQuit, setShouldQuit] = React.useState(false);
|
||||
const n = useNavigate();
|
||||
const genealogy = useGenealogy();
|
||||
const family = useFamily();
|
||||
|
||||
const parameters = useQuery();
|
||||
const mother = Number(parameters.get("mother"));
|
||||
const father = Number(parameters.get("father"));
|
||||
|
||||
const create = async (m: Member) => {
|
||||
try {
|
||||
const r = await MemberApi.Create(m);
|
||||
|
||||
await genealogy.reloadMembersList();
|
||||
|
||||
setShouldQuit(true);
|
||||
n(family.family.URL(`genealogy/member/${r.id}`));
|
||||
snackbar(`La fiche pour ${r.fullName} a été créée avec succès !`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Echec de la création de la personne !");
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setShouldQuit(true);
|
||||
n(family.family.URL("genealogy/members"));
|
||||
};
|
||||
|
||||
const member = Member.New(family.family.family_id);
|
||||
if (mother) member.mother = mother;
|
||||
if (father) member.father = father;
|
||||
|
||||
return (
|
||||
<MemberPage
|
||||
member={member}
|
||||
creating={true}
|
||||
editing={true}
|
||||
onCancel={cancel}
|
||||
onSave={create}
|
||||
shouldAllowLeaving={shouldQuit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing member route
|
||||
*/
|
||||
export function FamilyMemberRoute(): React.ReactElement {
|
||||
const count = React.useRef(1);
|
||||
|
||||
const n = useNavigate();
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const family = useFamily();
|
||||
const genealogy = useGenealogy();
|
||||
const { memberId } = useParams();
|
||||
|
||||
const [member, setMember] = React.useState<Member>();
|
||||
const load = async () => {
|
||||
setMember(await MemberApi.GetSingle(family.familyId, Number(memberId)));
|
||||
};
|
||||
|
||||
const forceReload = async () => {
|
||||
count.current += 1;
|
||||
setMember(undefined);
|
||||
|
||||
await genealogy.reloadMembersList();
|
||||
};
|
||||
|
||||
const deleteMember = async () => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
"Voulez-vous vraiment supprimer cette fiche membre ? L'opération n'est pas réversible !"
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
await MemberApi.Delete(member!);
|
||||
|
||||
snackbar("La fiche de membre a été supprimée avec succès !");
|
||||
n(family.family.URL("genealogy/members"));
|
||||
|
||||
await genealogy.reloadMembersList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Échec de la suppression du membre !");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={`${memberId}-${count.current}`}
|
||||
load={load}
|
||||
ready={member !== undefined}
|
||||
errMsg="Echec du chargement des informations du membre"
|
||||
build={() => (
|
||||
<MemberPage
|
||||
member={member!}
|
||||
children={genealogy.members.children(member!.id)}
|
||||
siblings={genealogy.members.siblings(member!.id)}
|
||||
couples={genealogy.couples.getAllOf(member!)}
|
||||
creating={false}
|
||||
editing={false}
|
||||
onrequestOpenTree={() => n(family.family.familyTreeURL(member!))}
|
||||
onRequestDelete={deleteMember}
|
||||
onRequestEdit={() => n(family.family.memberURL(member!, true))}
|
||||
onForceReload={forceReload}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit existing member route
|
||||
*/
|
||||
export function FamilyEditMemberRoute(): React.ReactElement {
|
||||
const n = useNavigate();
|
||||
const { memberId } = useParams();
|
||||
|
||||
const alert = useAlert();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const [shouldQuit, setShouldQuit] = React.useState(false);
|
||||
|
||||
const family = useFamily();
|
||||
const genealogy = useGenealogy();
|
||||
|
||||
const [member, setMember] = React.useState<Member>();
|
||||
const load = async () => {
|
||||
setMember(await MemberApi.GetSingle(family.familyId, Number(memberId)));
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setShouldQuit(true);
|
||||
n(family.family.memberURL(member!));
|
||||
//n(-1);
|
||||
};
|
||||
|
||||
const save = async (m: Member) => {
|
||||
try {
|
||||
await MemberApi.Update(m);
|
||||
|
||||
snackbar("Les informations du membre ont été mises à jour avec succès !");
|
||||
|
||||
await genealogy.reloadMembersList();
|
||||
|
||||
setShouldQuit(true);
|
||||
n(family.family.memberURL(member!));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Échec de la mise à jour des informations du membre !");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncWidget
|
||||
loadKey={memberId}
|
||||
load={load}
|
||||
errMsg="Échec du chargement des informations du membre"
|
||||
build={() => (
|
||||
<MemberPage
|
||||
member={member!}
|
||||
creating={false}
|
||||
editing={true}
|
||||
onCancel={cancel}
|
||||
onSave={save}
|
||||
shouldAllowLeaving={shouldQuit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberPage(p: {
|
||||
member: Member;
|
||||
editing: boolean;
|
||||
creating: boolean;
|
||||
shouldAllowLeaving?: boolean;
|
||||
children?: Member[];
|
||||
siblings?: Member[];
|
||||
couples?: Couple[];
|
||||
onCancel?: () => void;
|
||||
onSave?: (m: Member) => Promise<void>;
|
||||
onRequestEdit?: () => void;
|
||||
onRequestDelete?: () => void;
|
||||
onForceReload?: () => void;
|
||||
onrequestOpenTree?: () => void;
|
||||
}): React.ReactElement {
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
const loadingMessage = useLoadingMessage();
|
||||
|
||||
const family = useFamily();
|
||||
|
||||
const [changed, setChanged] = React.useState(false);
|
||||
const [member, setMember] = React.useState(
|
||||
new Member(structuredClone(p.member))
|
||||
);
|
||||
|
||||
const updatedMember = () => {
|
||||
setChanged(true);
|
||||
setMember(new Member(structuredClone(member)));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
loadingMessage.show(
|
||||
"Enregistrement des informations du membre en cours..."
|
||||
);
|
||||
await p.onSave!(member);
|
||||
loadingMessage.hide();
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (
|
||||
changed &&
|
||||
!(await confirm(
|
||||
"Voulez-vous vraiment retirer les modifications apportées ? Celles-ci seront perdues !"
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
p.onCancel!();
|
||||
};
|
||||
|
||||
const uploadNewPhoto = async (b: Blob) => {
|
||||
await MemberApi.SetMemberPhoto(member, b);
|
||||
snackbar("La photo du membre a été mise à jour avec succès !");
|
||||
p.onForceReload?.();
|
||||
};
|
||||
|
||||
const deletePhoto = async () => {
|
||||
try {
|
||||
if (!(await confirm("Voulez-vous supprimer cette photo ?"))) return;
|
||||
|
||||
await MemberApi.RemoveMemberPhoto(member);
|
||||
|
||||
snackbar("La photo du membre a été supprimée avec succès !");
|
||||
p.onForceReload?.();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Échec de la suppresion de la photo !");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "2000px", margin: "auto" }}>
|
||||
<ConfirmLeaveWithoutSaveDialog
|
||||
shouldBlock={changed && p.shouldAllowLeaving !== true}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FamilyPageTitle
|
||||
title={
|
||||
(p.editing
|
||||
? p.creating
|
||||
? "Création"
|
||||
: "Édition"
|
||||
: "Visualisation") + " d'une fiche de membre"
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{/* Family tree button */}
|
||||
{p.onrequestOpenTree && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Icon path={mdiFamilyTree} size={1} />}
|
||||
onClick={p.onrequestOpenTree}
|
||||
size="large"
|
||||
>
|
||||
Arbre
|
||||
</Button>
|
||||
)}
|
||||
{/* Edit button */}
|
||||
{p.onRequestEdit && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={p.onRequestEdit}
|
||||
size="large"
|
||||
>
|
||||
Editer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
{p.onRequestDelete && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={p.onRequestDelete}
|
||||
size="large"
|
||||
color="error"
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
{p.editing && changed && (
|
||||
<Button
|
||||
variant={"contained"}
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={save}
|
||||
size="large"
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{p.editing && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ClearIcon />}
|
||||
onClick={cancel}
|
||||
size="small"
|
||||
>
|
||||
Annuler les modifications
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* General info */}
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Informations générales">
|
||||
{/* Sex */}
|
||||
<SexSelection
|
||||
readonly={!p.editing}
|
||||
current={member.sex}
|
||||
onChange={(v) => {
|
||||
member.sex = v;
|
||||
updatedMember();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* First name */}
|
||||
<PropEdit
|
||||
label="Prénom"
|
||||
editable={p.editing}
|
||||
value={member.first_name}
|
||||
onValueChange={(v) => {
|
||||
member.first_name = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_first_name}
|
||||
/>
|
||||
|
||||
{/* Last name */}
|
||||
<PropEdit
|
||||
label="Nom"
|
||||
editable={p.editing}
|
||||
value={member.last_name}
|
||||
onValueChange={(v) => {
|
||||
member.last_name = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_last_name}
|
||||
/>
|
||||
|
||||
{/* Birth last name */}
|
||||
<PropEdit
|
||||
label="Nom de naissance"
|
||||
editable={p.editing}
|
||||
value={member.birth_last_name}
|
||||
onValueChange={(v) => {
|
||||
member.birth_last_name = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_birth_last_name}
|
||||
/>
|
||||
|
||||
{/* Birth day */}
|
||||
<DateInput
|
||||
label="Date de naissance"
|
||||
editable={p.editing}
|
||||
id="dob"
|
||||
value={member.dateOfBirth}
|
||||
onValueChange={(d) => {
|
||||
member.birth_year = d.year;
|
||||
member.birth_month = d.month;
|
||||
member.birth_day = d.day;
|
||||
updatedMember();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Is dead */}
|
||||
<PropCheckbox
|
||||
checked={member.dead}
|
||||
editable={p.editing}
|
||||
label={member.sex === "F" ? "Décédée" : "Décédé"}
|
||||
onValueChange={(v) => {
|
||||
member.dead = v;
|
||||
updatedMember();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Death day */}
|
||||
<DateInput
|
||||
label="Date de décès"
|
||||
editable={p.editing}
|
||||
id="dod"
|
||||
value={member.dateOfDeath}
|
||||
onValueChange={(d) => {
|
||||
member.death_year = d.year;
|
||||
member.death_month = d.month;
|
||||
member.death_day = d.day;
|
||||
updatedMember();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Father */}
|
||||
<br />
|
||||
<MemberInput
|
||||
editable={p.editing}
|
||||
label="Père"
|
||||
onValueChange={(m) => {
|
||||
member.father = m;
|
||||
updatedMember();
|
||||
}}
|
||||
filter={(m) =>
|
||||
(m.sex === "M" || m.sex === undefined) && m.id !== member.id
|
||||
}
|
||||
current={member.father}
|
||||
/>
|
||||
|
||||
{/* Mother */}
|
||||
<br />
|
||||
<MemberInput
|
||||
editable={p.editing}
|
||||
label="Mère"
|
||||
onValueChange={(m) => {
|
||||
member.mother = m;
|
||||
updatedMember();
|
||||
}}
|
||||
filter={(m) =>
|
||||
(m.sex === "F" || m.sex === undefined) && m.id !== member.id
|
||||
}
|
||||
current={member.mother}
|
||||
/>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
|
||||
{/* Photo */}
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Photo">
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<MemberPhoto member={member} width={150} />
|
||||
<br />
|
||||
{p.editing ? (
|
||||
<p>
|
||||
Veuillez enregistrer / annuler les modifications apportées à
|
||||
la fiche avant de changer la photo du membre.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<UploadPhotoButton
|
||||
label={member.hasPhoto ? "Remplacer" : "Ajouter"}
|
||||
onPhotoSelected={uploadNewPhoto}
|
||||
aspect={4 / 5}
|
||||
/>{" "}
|
||||
{member.hasPhoto && (
|
||||
<RouterLink to={member.photoURL!} target="_blank">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileDownloadIcon />}
|
||||
>
|
||||
Télécharger
|
||||
</Button>
|
||||
</RouterLink>
|
||||
)}{" "}
|
||||
{member.hasPhoto && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteIcon />}
|
||||
color="error"
|
||||
onClick={deletePhoto}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}{" "}
|
||||
</div>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
|
||||
{/* Contact */}
|
||||
{(p.editing || member.hasContactInfo) && (
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Contact">
|
||||
{/* Email */}
|
||||
<PropEdit
|
||||
label="Adresse mail"
|
||||
editable={p.editing}
|
||||
value={member.email}
|
||||
onValueChange={(v) => {
|
||||
member.email = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_email}
|
||||
checkValue={(e) => EmailValidator.validate(e)}
|
||||
/>
|
||||
|
||||
{/* Phone number */}
|
||||
<PropEdit
|
||||
label="Téléphone"
|
||||
editable={p.editing}
|
||||
value={member.phone}
|
||||
onValueChange={(v) => {
|
||||
member.phone = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_phone}
|
||||
/>
|
||||
|
||||
{/* Country */}
|
||||
<PropSelect
|
||||
label="Pays"
|
||||
editing={p.editing}
|
||||
onValueChange={(o) => {
|
||||
member.country = o;
|
||||
updatedMember();
|
||||
}}
|
||||
value={member.country}
|
||||
options={ServerApi.Config.countries.map((c) => {
|
||||
return { label: c.fr, value: c.code };
|
||||
})}
|
||||
/>
|
||||
{/* Address */}
|
||||
<PropEdit
|
||||
label="Adresse"
|
||||
editable={p.editing}
|
||||
value={member.address}
|
||||
onValueChange={(v) => {
|
||||
member.address = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_address}
|
||||
/>
|
||||
|
||||
{/* Postal code */}
|
||||
<PropEdit
|
||||
label="Code postal"
|
||||
editable={p.editing}
|
||||
value={member.postal_code}
|
||||
onValueChange={(v) => {
|
||||
member.postal_code = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_postal_code}
|
||||
/>
|
||||
|
||||
{/* City */}
|
||||
<PropEdit
|
||||
label="Ville"
|
||||
editable={p.editing}
|
||||
value={member.city}
|
||||
onValueChange={(v) => {
|
||||
member.city = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_city}
|
||||
/>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Bio */}
|
||||
{(p.editing || member.hasNote) && (
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Biographie">
|
||||
<PropEdit
|
||||
label="Biographie"
|
||||
editable={p.editing}
|
||||
multiline={true}
|
||||
minRows={5}
|
||||
maxRows={20}
|
||||
value={member.note}
|
||||
onValueChange={(v) => {
|
||||
member.note = v;
|
||||
updatedMember();
|
||||
}}
|
||||
size={ServerApi.Config.constraints.member_note}
|
||||
/>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Couples */}
|
||||
{p.couples && (
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title={member.sex === "F" ? "Époux" : "Épouse"}>
|
||||
{p.couples!.length === 0 ? (
|
||||
<>{member.sex === "F" ? "Aucun époux" : "Aucune épouse"}</>
|
||||
) : (
|
||||
p.couples.map((c) => (
|
||||
<CoupleItem key={c.id} currMemberId={member.id} couple={c} />
|
||||
))
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "end" }}>
|
||||
<RouterLink
|
||||
to={family.family.URL(
|
||||
`genealogy/couple/create?${
|
||||
member.sex === "F" ? "wife" : "husband"
|
||||
}=${member.id}`
|
||||
)}
|
||||
>
|
||||
<Button>Nouveau</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Children */}
|
||||
{p.children && (
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Enfants">
|
||||
{p.children.length === 0 ? (
|
||||
<>Aucun enfant</>
|
||||
) : (
|
||||
p.children.map((c) => (
|
||||
<RouterLink key={c.id} to={family.family.memberURL(c)}>
|
||||
<MemberItem member={c} />
|
||||
</RouterLink>
|
||||
))
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "end" }}>
|
||||
<RouterLink
|
||||
to={family.family.URL(
|
||||
`genealogy/member/create?${
|
||||
member.sex === "F" ? "mother" : "father"
|
||||
}=${member.id}`
|
||||
)}
|
||||
>
|
||||
<Button>Nouveau</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Siblings */}
|
||||
{p.siblings && (
|
||||
<Grid item sm={12} md={6}>
|
||||
<PropertiesBox title="Frères et sœurs">
|
||||
{p.siblings.length === 0 ? (
|
||||
<>Aucun frère ou sœur</>
|
||||
) : (
|
||||
p.siblings.map((c) => (
|
||||
<RouterLink key={c.id} to={family.family.memberURL(c)}>
|
||||
<MemberItem member={c} />
|
||||
</RouterLink>
|
||||
))
|
||||
)}
|
||||
|
||||
{(member.mother || member.father) && (
|
||||
<div style={{ display: "flex", justifyContent: "end" }}>
|
||||
<RouterLink
|
||||
to={family.family.URL(
|
||||
`genealogy/member/create?mother=${member.mother}&father=${member.father}`
|
||||
)}
|
||||
>
|
||||
<Button>Nouveau</Button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
)}
|
||||
</PropertiesBox>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoupleItem(p: {
|
||||
currMemberId: number;
|
||||
couple: Couple;
|
||||
}): React.ReactElement {
|
||||
const n = useNavigate();
|
||||
|
||||
const family = useFamily();
|
||||
const genealogy = useGenealogy();
|
||||
|
||||
const statusStr = ServerApi.Config.couples_states.find(
|
||||
(c) => c.code === p.couple.state
|
||||
)?.fr;
|
||||
|
||||
const status = [];
|
||||
if (statusStr) status.push(statusStr);
|
||||
|
||||
if (p.couple.dateOfWedding)
|
||||
status.push(`Mariage : ${fmtDate(p.couple.dateOfWedding)}`);
|
||||
|
||||
if (p.couple.dateOfDivorce)
|
||||
status.push(`Divorce : ${fmtDate(p.couple.dateOfDivorce)}`);
|
||||
|
||||
const otherSpouseID =
|
||||
p.couple.wife === p.currMemberId ? p.couple.husband : p.couple.wife;
|
||||
const otherSpouse = otherSpouseID
|
||||
? genealogy.members.get(otherSpouseID)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ListItemButton onClick={() => n(family.family.coupleURL(p.couple))}>
|
||||
<ListItemAvatar>
|
||||
{p.couple.hasPhoto ? (
|
||||
<CouplePhoto couple={p.couple!} />
|
||||
) : (
|
||||
<MemberPhoto member={otherSpouse} />
|
||||
)}
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={otherSpouse ? otherSpouse.fullName : "___ ___"}
|
||||
secondary={status.join(" - ")}
|
||||
></ListItemText>
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
165
geneit_app/src/routes/family/genealogy/FamilyMemberTreeRoute.tsx
Normal file
165
geneit_app/src/routes/family/genealogy/FamilyMemberTreeRoute.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import {
|
||||
Alert,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
FamilyTreeNode,
|
||||
buildAscendingTree,
|
||||
buildDescendingTree,
|
||||
treeHeight,
|
||||
} from "../../../utils/family_tree";
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { BasicFamilyTree } from "../../../widgets/BasicFamilyTree";
|
||||
import { MemberItem } from "../../../widgets/MemberItem";
|
||||
import { RouterLink } from "../../../widgets/RouterLink";
|
||||
import { useGenealogy } from "../../../widgets/genealogy/BaseGenealogyRoute";
|
||||
import { SimpleFamilyTree } from "../../../widgets/simple_family_tree/SimpleFamilyTree";
|
||||
|
||||
enum CurrTab {
|
||||
BasicTree,
|
||||
SimpleTree,
|
||||
}
|
||||
|
||||
enum TreeMode {
|
||||
Ascending,
|
||||
Descending,
|
||||
}
|
||||
|
||||
export function FamilyMemberTreeRoute(): React.ReactElement {
|
||||
const { memberId } = useParams();
|
||||
|
||||
const genealogy = useGenealogy();
|
||||
const family = useFamily();
|
||||
|
||||
const [currTab, setCurrTab] = React.useState(CurrTab.SimpleTree);
|
||||
const [currMode, setCurrMode] = React.useState(TreeMode.Descending);
|
||||
|
||||
const member = genealogy.members.get(Number(memberId));
|
||||
|
||||
const memo: [FamilyTreeNode, number] | null = React.useMemo(() => {
|
||||
if (!member) return null;
|
||||
const tree =
|
||||
currMode === TreeMode.Ascending
|
||||
? buildAscendingTree(member.id, genealogy.members, genealogy.couples)
|
||||
: buildDescendingTree(member.id, genealogy.members, genealogy.couples);
|
||||
|
||||
return [tree, treeHeight(tree)];
|
||||
}, [member, currMode, genealogy.members, genealogy.couples]);
|
||||
|
||||
const [currDepth, setCurrDepth] = React.useState(0);
|
||||
|
||||
if (!member) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
L'arbre ne peut pas être constuit : le membre n'existe pas !
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const [tree, maxDepth] = memo!;
|
||||
|
||||
if (currDepth === 0) setCurrDepth(maxDepth);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: "1",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
{/* parent bar */}
|
||||
<div style={{ display: "flex" }}>
|
||||
<MemberItem
|
||||
dense
|
||||
member={member}
|
||||
secondary={
|
||||
<RouterLink to={family.family.URL("genealogy/tree")}>
|
||||
<IconButton>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</RouterLink>
|
||||
}
|
||||
/>
|
||||
<div style={{ flex: "10" }}></div>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Arbre</FormLabel>
|
||||
<RadioGroup
|
||||
row
|
||||
value={currMode}
|
||||
onChange={(_e, v) => {
|
||||
setCurrDepth(0);
|
||||
setCurrMode(Number(v));
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
value={TreeMode.Descending}
|
||||
control={<Radio />}
|
||||
label="Descendant"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={TreeMode.Ascending}
|
||||
control={<Radio />}
|
||||
label="Ascendant"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<div style={{ flex: "1" }}></div>
|
||||
|
||||
<FormControl variant="standard" sx={{ m: 1, minWidth: 120 }}>
|
||||
<InputLabel>Profondeur</InputLabel>
|
||||
<Select
|
||||
value={currDepth}
|
||||
onChange={(v) => setCurrDepth(Number(v.target.value))}
|
||||
label="Profondeur"
|
||||
>
|
||||
{Array(maxDepth)
|
||||
.fill(0)
|
||||
.map((_v, index) => (
|
||||
<MenuItem key={index} value={index + 1}>
|
||||
{index + 1}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<div style={{ flex: "1" }}></div>
|
||||
|
||||
<Tabs
|
||||
value={currTab}
|
||||
onChange={(_e, v) => setCurrTab(v)}
|
||||
aria-label="basic tabs example"
|
||||
>
|
||||
<Tab tabIndex={CurrTab.BasicTree} label="Basique" />
|
||||
<Tab tabIndex={CurrTab.SimpleTree} label="Simple" />
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* the tree itself */}
|
||||
<Paper style={{ flex: "1", display: "flex", flexDirection: "column" }}>
|
||||
{currTab === CurrTab.BasicTree ? (
|
||||
<BasicFamilyTree tree={tree!} depth={currDepth} />
|
||||
) : (
|
||||
<SimpleFamilyTree tree={tree!} depth={currDepth} />
|
||||
)}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,248 @@
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import FemaleIcon from "@mui/icons-material/Female";
|
||||
import MaleIcon from "@mui/icons-material/Male";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import { Button, TextField, Tooltip, Typography } from "@mui/material";
|
||||
import { DataGrid, GridActionsCellItem, GridColDef } from "@mui/x-data-grid";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Member,
|
||||
MemberApi,
|
||||
dateTimestamp,
|
||||
fmtDate,
|
||||
} from "../../../api/genealogy/MemberApi";
|
||||
import { useAlert } from "../../../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useSnackbar } from "../../../hooks/context_providers/SnackbarProvider";
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle";
|
||||
import { MemberPhoto } from "../../../widgets/MemberPhoto";
|
||||
import { RouterLink } from "../../../widgets/RouterLink";
|
||||
import { useGenealogy } from "../../../widgets/genealogy/BaseGenealogyRoute";
|
||||
|
||||
export function FamilyMembersListRoute(): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
const confirm = useConfirm();
|
||||
const snackbar = useSnackbar();
|
||||
|
||||
const genealogy = useGenealogy();
|
||||
const family = useFamily();
|
||||
|
||||
const [filter, setFilter] = React.useState("");
|
||||
|
||||
const processDeletion = async (m: Member) => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
`Voulez-vous vraiment supprimer la fiche de ${m.fullName} ?`
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
await MemberApi.Delete(m);
|
||||
await genealogy.reloadMembersList();
|
||||
|
||||
snackbar("La fiche du membre a été supprimée avec succès !");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Echec de la suppression de la fiche ! ");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FamilyPageTitle title="Membres de la famille" />
|
||||
<RouterLink to={family.family.URL("genealogy/member/create")}>
|
||||
<Tooltip title="Créer la fiche d'un nouveau membre">
|
||||
<Button startIcon={<AddIcon />}>Nouveau</Button>
|
||||
</Tooltip>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
{genealogy.members.isEmpty ? (
|
||||
<p>
|
||||
Votre famille n'a aucun membre pour le moment ! Utilisez le bouton
|
||||
situé en haut à droite pour créer le premier !
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
label={"Rechercher un membre..."}
|
||||
variant="standard"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
style={{ maxWidth: "500px", margin: "10px" }}
|
||||
/>
|
||||
|
||||
<MembersTable
|
||||
members={
|
||||
filter === ""
|
||||
? genealogy.members.fullList
|
||||
: genealogy.members.filter((m) =>
|
||||
m.fullName.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
}
|
||||
onDelete={processDeletion}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersTable(p: {
|
||||
members: Member[];
|
||||
onDelete: (m: Member) => void;
|
||||
}): React.ReactElement {
|
||||
const genealogy = useGenealogy();
|
||||
const family = useFamily();
|
||||
const n = useNavigate();
|
||||
|
||||
const columns: GridColDef<Member>[] = [
|
||||
{
|
||||
field: "signed_photo_id",
|
||||
headerName: "",
|
||||
disableColumnMenu: true,
|
||||
sortable: false,
|
||||
width: 60,
|
||||
renderCell(params) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", height: "100%" }}
|
||||
>
|
||||
<MemberPhoto member={params.row} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
field: "last_name",
|
||||
headerName: "Nom",
|
||||
flex: 3,
|
||||
},
|
||||
{
|
||||
field: "first_name",
|
||||
headerName: "Prénom",
|
||||
flex: 3,
|
||||
},
|
||||
|
||||
{
|
||||
field: "sex",
|
||||
headerName: "",
|
||||
disableColumnMenu: true,
|
||||
width: 20,
|
||||
renderCell(params) {
|
||||
if (params.row.sex === "F")
|
||||
return <FemaleIcon fontSize="small" htmlColor="pink" />;
|
||||
else return <MaleIcon fontSize="small" htmlColor="lightBlue" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "dateOfBirth",
|
||||
headerName: "Date de naissance",
|
||||
flex: 3,
|
||||
sortComparator(v1, v2) {
|
||||
const d1 = dateTimestamp(v1);
|
||||
const d2 = dateTimestamp(v2);
|
||||
return d1 - d2;
|
||||
},
|
||||
renderCell(params) {
|
||||
return fmtDate(params.row.dateOfBirth);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "dateOfDeath",
|
||||
headerName: "Date de décès",
|
||||
flex: 3,
|
||||
sortComparator(v1, v2) {
|
||||
const d1 = dateTimestamp(v1);
|
||||
const d2 = dateTimestamp(v2);
|
||||
return d1 - d2;
|
||||
},
|
||||
renderCell(params) {
|
||||
return fmtDate(params.row.dateOfDeath);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "father",
|
||||
headerName: "Père",
|
||||
flex: 5,
|
||||
renderCell(params) {
|
||||
if (!params.row.father)
|
||||
return (
|
||||
<Typography color="red" component="span" variant="body2">
|
||||
Non renseigné
|
||||
</Typography>
|
||||
);
|
||||
return genealogy.members.get(params.row.father)!.fullName;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "mother",
|
||||
headerName: "Mère",
|
||||
flex: 5,
|
||||
renderCell(params) {
|
||||
if (!params.row.mother)
|
||||
return (
|
||||
<Typography color="red" component="span" variant="body2">
|
||||
Non renseignée
|
||||
</Typography>
|
||||
);
|
||||
return genealogy.members.get(params.row.mother)!.fullName;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
type: "actions",
|
||||
sortable: false,
|
||||
width: 120,
|
||||
disableColumnMenu: true,
|
||||
getActions(params) {
|
||||
return [
|
||||
<GridActionsCellItem
|
||||
icon={<VisibilityIcon />}
|
||||
label="Consulter le membre"
|
||||
className="textPrimary"
|
||||
onClick={() => n(family.family.memberURL(params.row))}
|
||||
color="inherit"
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<EditIcon />}
|
||||
label="Modifier le membre"
|
||||
className="textPrimary"
|
||||
onClick={() => n(family.family.memberURL(params.row, true))}
|
||||
color="inherit"
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<DeleteIcon />}
|
||||
label="Supprimer le membre"
|
||||
onClick={() => p.onDelete(params.row)}
|
||||
color="inherit"
|
||||
/>,
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
style={{ flex: "1" }}
|
||||
rows={p.members}
|
||||
columns={columns}
|
||||
autoPageSize
|
||||
getRowId={(c) => c.id}
|
||||
onRowDoubleClick={(p) => n(family.family.memberURL(p.row))}
|
||||
/>
|
||||
);
|
||||
}
|
39
geneit_app/src/routes/family/genealogy/FamilyTreeRoute.tsx
Normal file
39
geneit_app/src/routes/family/genealogy/FamilyTreeRoute.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { MemberInput } from "../../../widgets/forms/MemberInput";
|
||||
|
||||
export function FamilyTreeRoute(): React.ReactElement {
|
||||
const n = useNavigate();
|
||||
|
||||
const family = useFamily();
|
||||
|
||||
const onMemberSelected = (id: number | undefined) => {
|
||||
if (id) n(family.family.familyTreeURL(id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: "1",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: "450px", textAlign: "center" }}>
|
||||
<p>
|
||||
Veuillez sélectionner la personne à partir de laquelle vous souhaitez
|
||||
constuire l'arbre généalogique de votre famille :
|
||||
</p>
|
||||
|
||||
<MemberInput
|
||||
editable={true}
|
||||
onValueChange={onMemberSelected}
|
||||
label={"Membre"}
|
||||
filter={() => true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
221
geneit_app/src/routes/family/genealogy/GenalogySettingsRoute.tsx
Normal file
221
geneit_app/src/routes/family/genealogy/GenalogySettingsRoute.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import UploadIcon from "@mui/icons-material/Upload";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CardActions,
|
||||
CardContent,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import { FamilyApi } from "../../../api/FamilyApi";
|
||||
import { DataApi } from "../../../api/genealogy/DataApi";
|
||||
import { useAlert } from "../../../hooks/context_providers/AlertDialogProvider";
|
||||
import { useConfirm } from "../../../hooks/context_providers/ConfirmDialogProvider";
|
||||
import { useLoadingMessage } from "../../../hooks/context_providers/LoadingMessageProvider";
|
||||
import { downloadBlob, selectFileToUpload } from "../../../utils/files_utils";
|
||||
import { useFamily } from "../../../widgets/BaseFamilyRoute";
|
||||
import { FamilyCard } from "../../../widgets/FamilyCard";
|
||||
|
||||
export function GenalogySettingsRoute(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<GenealogySettingsCard />
|
||||
<GenealogyExportCard />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GenealogySettingsCard(): React.ReactElement {
|
||||
const alert = useAlert();
|
||||
|
||||
const family = useFamily();
|
||||
|
||||
const [disableCouplePhotos, setDisableCouplePhotos] = React.useState(
|
||||
family.family.disable_couple_photos
|
||||
);
|
||||
|
||||
const canEdit = family.family.is_admin;
|
||||
|
||||
const [error, setError] = React.useState<string>();
|
||||
const [success, setSuccess] = React.useState<string>();
|
||||
|
||||
const updateFamily = async () => {
|
||||
try {
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
|
||||
await FamilyApi.UpdateFamily({
|
||||
id: family.family.family_id,
|
||||
disable_couple_photos: disableCouplePhotos,
|
||||
});
|
||||
|
||||
family.reloadFamilyInfo();
|
||||
|
||||
alert("Les paramètres de la famille ont été mis à jour avec succès !");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Echec de la mise à jour des paramètres de la famille !");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FamilyCard error={error} success={success}>
|
||||
<CardContent>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
Paramètres du module de généalogie
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
component="form"
|
||||
sx={{
|
||||
"& .MuiTextField-root": { my: 1 },
|
||||
}}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
>
|
||||
<Tooltip
|
||||
title="Les photos de couple ne sont pas utilisées en pratique dans les arbres généalogiques. Il est possible de masquer les formulaires d'édition de photos de couple pour limiter le risque de confusion."
|
||||
arrow
|
||||
>
|
||||
<FormControlLabel
|
||||
disabled={!canEdit}
|
||||
control={
|
||||
<Switch
|
||||
checked={disableCouplePhotos}
|
||||
onChange={(_e, c) => setDisableCouplePhotos(c)}
|
||||
/>
|
||||
}
|
||||
label="Désactiver les photos de couple"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</CardContent>
|
||||
<CardActions>
|
||||
<Button
|
||||
onClick={updateFamily}
|
||||
disabled={!canEdit}
|
||||
style={{ marginLeft: "auto" }}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</CardActions>
|
||||
</FamilyCard>
|
||||
);
|
||||
}
|
||||
|
||||
function GenealogyExportCard(): React.ReactElement {
|
||||
const loading = useLoadingMessage();
|
||||
const confirm = useConfirm();
|
||||
const alert = useAlert();
|
||||
|
||||
const family = useFamily();
|
||||
|
||||
const [error, setError] = React.useState<string>();
|
||||
const [success, setSuccess] = React.useState<string>();
|
||||
|
||||
const exportData = async () => {
|
||||
loading.show("Export des données");
|
||||
try {
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
|
||||
const blob = await DataApi.ExportData(family.familyId);
|
||||
downloadBlob(blob, `Export-${new Date().getTime()}.zip`);
|
||||
|
||||
setSuccess("Export des données effectué avec succès !");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError("Echec de l'export des données de la famille !");
|
||||
}
|
||||
loading.hide();
|
||||
};
|
||||
|
||||
const importData = async () => {
|
||||
try {
|
||||
if (
|
||||
!(await confirm(
|
||||
"Attention ! Cette opération a pour effet d'effacer toutes les données existantes en base ! Voulez-vous vraiment poursuivre l'opération ?"
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
const file = await selectFileToUpload({
|
||||
allowedTypes: ["application/zip"],
|
||||
});
|
||||
if (file === null) return;
|
||||
|
||||
setError(undefined);
|
||||
setSuccess(undefined);
|
||||
|
||||
loading.show(
|
||||
"Restauration des données de généalogie de la famille en cours..."
|
||||
);
|
||||
|
||||
await DataApi.ImportData(family.familyId, file);
|
||||
|
||||
family.reloadFamilyInfo();
|
||||
|
||||
alert(
|
||||
"Import des données de généalogie de la famille effectué avec succès !"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(
|
||||
`Echec de l'import des données de généalogie de la famille ! (${e})`
|
||||
);
|
||||
}
|
||||
|
||||
loading.hide();
|
||||
};
|
||||
|
||||
return (
|
||||
<FamilyCard error={error} success={success}>
|
||||
<CardContent>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
Export / import des données de généalogie
|
||||
</Typography>
|
||||
<p>
|
||||
Vous pouvez, à des fins de sauvegardes ou de transfert, exporter et
|
||||
importer l'ensemble des données des membres et des couples de cette
|
||||
famille, sous format ZIP.
|
||||
</p>
|
||||
|
||||
<Alert severity="warning">
|
||||
Attention ! La restauration des données de généalogie de la famille
|
||||
provoque préalablement l'effacement de toutes les données enregistrées
|
||||
dans la famille ! Par ailleurs, la restauration n'est pas réversible !
|
||||
</Alert>
|
||||
|
||||
<p> </p>
|
||||
|
||||
<Button
|
||||
startIcon={<DownloadIcon />}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
onClick={exportData}
|
||||
size={"large"}
|
||||
style={{ marginBottom: "10px" }}
|
||||
>
|
||||
Exporter les données de généalogie
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
startIcon={<UploadIcon />}
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
fullWidth
|
||||
onClick={importData}
|
||||
disabled={!family.family.is_admin}
|
||||
size={"large"}
|
||||
>
|
||||
Importer les données de généalogie
|
||||
</Button>
|
||||
</CardContent>
|
||||
</FamilyCard>
|
||||
);
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import { FamilyPageTitle } from "../../../widgets/FamilyPageTitle";
|
||||
import { useGenealogy } from "../../../widgets/genealogy/BaseGenealogyRoute";
|
||||
|
||||
export function GenealogyHomeRoute(): React.ReactElement {
|
||||
const genealogy = useGenealogy();
|
||||
return (
|
||||
<>
|
||||
<FamilyPageTitle title="Généalogie de votre famille" />
|
||||
<div style={{ margin: "20px" }}>
|
||||
<p>
|
||||
Depuis cette section de l'application, vous pouvez afficher et
|
||||
compléter l'abre généalogique de votre famille.
|
||||
</p>
|
||||
<p> </p>
|
||||
<p>Nombre de fiches de membres: {genealogy.members.size}</p>
|
||||
<p>Nombre de fiches de couples: {genealogy.couples.size}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user