Genealogy as a feature (#175)
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:
2024-05-16 19:15:15 +00:00
parent 0442538bd5
commit c8ee881b2c
34 changed files with 726 additions and 443 deletions

View 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>
);
}

View File

@ -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} /> &nbsp; {member.fullName}
</div>
</Tooltip>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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))}
/>
);
}

View 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>
);
}

View 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>&nbsp;</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>
);
}

View File

@ -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>&nbsp;</p>
<p>Nombre de fiches de membres: {genealogy.members.size}</p>
<p>Nombre de fiches de couples: {genealogy.couples.size}</p>
</div>
</>
);
}