diff --git a/geneit_app/src/App.tsx b/geneit_app/src/App.tsx index a75fe33..ffffa01 100644 --- a/geneit_app/src/App.tsx +++ b/geneit_app/src/App.tsx @@ -28,6 +28,11 @@ import { BaseAuthenticatedPage } from "./widgets/BaseAuthenticatedPage"; import { BaseFamilyRoute } from "./widgets/BaseFamilyRoute"; import { BaseLoginPage } from "./widgets/BaseLoginpage"; import { FamilyMembersListRoute } from "./routes/family/FamilyMembersListRoute"; +import { + FamilyCoupleRoute, + FamilyCreateCoupleRoute, + FamilyEditCoupleRoute, +} from "./routes/family/FamilyCoupleRoute"; interface AuthContext { signedIn: boolean; @@ -58,6 +63,7 @@ export function App(): React.ReactElement { } /> }> } /> + } /> } /> + + } + /> + } /> + } + /> + } /> } /> } /> diff --git a/geneit_app/src/api/CoupleApi.ts b/geneit_app/src/api/CoupleApi.ts new file mode 100644 index 0000000..acbede1 --- /dev/null +++ b/geneit_app/src/api/CoupleApi.ts @@ -0,0 +1,195 @@ +import { APIClient } from "./ApiClient"; + +interface CoupleApiInterface { + id: number; + family_id: number; + wife?: number; + husband?: number; + state?: string; + photo_id?: string; + signed_photo_id?: string; + time_create?: number; + time_update?: number; + wedding_year?: number; + wedding_month?: number; + wedding_day?: number; + divorce_year?: number; + divorce_month?: number; + divorce_day?: number; +} + +export class Couple implements CoupleApiInterface { + id: number; + family_id: number; + wife?: number; + husband?: number; + state?: string; + photo_id?: string; + signed_photo_id?: string; + time_create?: number; + time_update?: number; + wedding_year?: number; + wedding_month?: number; + wedding_day?: number; + divorce_year?: number; + divorce_month?: number; + divorce_day?: number; + + constructor(int: CoupleApiInterface) { + this.id = int.id; + this.family_id = int.family_id; + this.wife = int.wife; + this.husband = int.husband; + this.state = int.state; + this.photo_id = int.photo_id; + this.signed_photo_id = int.signed_photo_id; + this.time_create = int.time_create; + this.time_update = int.time_update; + this.wedding_year = int.wedding_year; + this.wedding_month = int.wedding_month; + this.wedding_day = int.wedding_day; + this.divorce_year = int.divorce_year; + this.divorce_month = int.divorce_month; + this.divorce_day = int.divorce_day; + } + + /** + * Create an empty couple object + */ + static New(family_id: number): Couple { + return new Couple({ + id: 0, + family_id: family_id, + }); + } + + get hasPhoto(): boolean { + return this.photo_id !== null; + } + + get photoURL(): string | null { + if (!this.signed_photo_id) return null; + return `${APIClient.backendURL()}/photo/${this.signed_photo_id}`; + } + + get thumbnailURL(): string | null { + if (!this.signed_photo_id) return null; + return `${APIClient.backendURL()}/photo/${this.signed_photo_id}/thumbnail`; + } +} + +export class CouplesList { + private list: Couple[]; + private map: Map; + + constructor(list: Couple[]) { + this.list = list; + this.map = new Map(); + + for (const m of list) { + this.map.set(m.id, m); + } + } + + public get isEmpty(): boolean { + return this.list.length === 0; + } + + public get fullList(): Couple[] { + return this.list; + } + + filter(predicate: (m: Couple) => boolean): Couple[] { + return this.list.filter(predicate); + } + + get(id: number): Couple | undefined { + return this.map.get(id); + } +} + +export class CoupleApi { + /** + * Create a new couple + */ + static async Create(m: Couple): Promise { + const res = await APIClient.exec({ + uri: `/family/${m.family_id}/couple/create`, + method: "POST", + jsonData: m, + }); + + return new Couple(res.data); + } + + /** + * Get the information about a single couple + */ + static async GetSingle( + family_id: number, + couple_id: number + ): Promise { + const res = await APIClient.exec({ + uri: `/family/${family_id}/couple/${couple_id}`, + method: "GET", + }); + + return new Couple(res.data); + } + + /** + * Get the entire list of couples of a family + */ + static async GetEntireList(family_id: number): Promise { + const res = await APIClient.exec({ + uri: `/family/${family_id}/couples`, + method: "GET", + }); + + return new CouplesList(res.data.map((d: any) => new Couple(d))); + } + + /** + * Update a couple information + */ + static async Update(m: Couple): Promise { + await APIClient.exec({ + uri: `/family/${m.family_id}/couple/${m.id}`, + method: "PUT", + jsonData: m, + }); + } + + /** + * Set a new photo for a couple + */ + static async SetCouplePhoto(m: Couple, b: Blob): Promise { + const fd = new FormData(); + fd.append("photo", b); + await APIClient.exec({ + uri: `/family/${m.family_id}/couple/${m.id}/photo`, + method: "PUT", + formData: fd, + }); + } + + /** + * Remove the photo of a couple + */ + static async RemoveCouplePhoto(m: Couple): Promise { + await APIClient.exec({ + uri: `/family/${m.family_id}/couple/${m.id}/photo`, + method: "DELETE", + }); + } + + /** + * Delete a family couple + */ + static async Delete(m: Couple): Promise { + await APIClient.exec({ + uri: `/family/${m.family_id}/couple/${m.id}`, + method: "DELETE", + }); + } +} diff --git a/geneit_app/src/api/FamilyApi.ts b/geneit_app/src/api/FamilyApi.ts index 2a2129a..1b93ce3 100644 --- a/geneit_app/src/api/FamilyApi.ts +++ b/geneit_app/src/api/FamilyApi.ts @@ -1,4 +1,5 @@ import { APIClient } from "./ApiClient"; +import { Couple } from "./CoupleApi"; import { Member } from "./MemberApi"; interface FamilyAPI { @@ -62,6 +63,15 @@ export class Family implements FamilyAPI { `/family/${this.family_id}/member/${member.id}` + (edit ? "/edit" : "") ); } + + /** + * Get application URL for couple page + */ + coupleURL(member: Couple, edit?: boolean): string { + return ( + `/family/${this.family_id}/couple/${member.id}` + (edit ? "/edit" : "") + ); + } } export enum JoinFamilyResult { diff --git a/geneit_app/src/routes/family/FamilyCoupleRoute.tsx b/geneit_app/src/routes/family/FamilyCoupleRoute.tsx new file mode 100644 index 0000000..d2fae50 --- /dev/null +++ b/geneit_app/src/routes/family/FamilyCoupleRoute.tsx @@ -0,0 +1,425 @@ +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 { Couple, CoupleApi } from "../../api/CoupleApi"; +import { Member } from "../../api/MemberApi"; +import { useAlert } from "../../hooks/context_providers/AlertDialogProvider"; +import { useConfirm } from "../../hooks/context_providers/ConfirmDialogProvider"; +import { useSnackbar } from "../../hooks/context_providers/SnackbarProvider"; +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 { MemberInput } from "../../widgets/forms/MemberInput"; +import { UploadPhotoButton } from "../../widgets/forms/UploadPhotoButton"; + +/** + * 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 family = useFamily(); + + const create = async (m: Couple) => { + try { + const r = await CoupleApi.Create(m); + + await family.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("couples")); + }; + + return ( + + ); +} + +/** + * 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 { coupleId } = useParams(); + + const [couple, setCouple] = React.useState(); + const load = async () => { + setCouple(await CoupleApi.GetSingle(family.familyId, Number(coupleId))); + }; + + const forceReload = async () => { + count.current += 1; + setCouple(undefined); + + await family.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("couples")); + + await family.reloadCouplesList(); + } catch (e) { + console.error(e); + alert("Échec de la suppression du couple !"); + } + }; + + return ( + ( + 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 family = useFamily(); + + const [couple, setCouple] = React.useState(); + const load = async () => { + setCouple(await CoupleApi.GetSingle(family.familyId, Number(coupleId))); + }; + + const cancel = () => { + setShouldQuit(true); + 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 family.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 ( + ( + + )} + /> + ); +} + +export function CouplePage(p: { + couple: Couple; + editing: boolean; + creating: boolean; + shouldAllowLeaving?: boolean; + children?: Member[]; + onCancel?: () => void; + onSave?: (m: Couple) => void; + onRequestEdit?: () => void; + onRequestDelete?: () => void; + onForceReload?: () => void; +}): React.ReactElement { + const confirm = useConfirm(); + const snackbar = useSnackbar(); + + 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 = () => { + p.onSave!(couple); + }; + + 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 ( +
+ +
+ + + {/* Edit button */} + {p.onRequestEdit && ( + + )} + + {/* Delete button */} + {p.onRequestDelete && ( + + )} + + {/* Save button */} + {p.editing && changed && ( + + )} + + {/* Cancel button */} + {p.editing && ( + + )} + +
+ + + {/* General info */} + + + {/* Husband */} +
+ { + couple.husband = m; + updatedCouple(); + }} + filter={(m) => m.sex === "M" || m.sex === undefined} + current={couple.husband} + /> + + {/* Wife */} +
+ { + couple.wife = m; + updatedCouple(); + }} + filter={(m) => m.sex === "F" || m.sex === undefined} + current={couple.wife} + /> +
+
+ + {/* Photo */} + + +
+ +
+ {p.editing ? ( +

+ Veuillez enregistrer / annuler les modifications apportées à + la fiche avant de changer la photo du couple. +

+ ) : ( + <> + {" "} + {couple.hasPhoto && ( + + + + )}{" "} + {couple.hasPhoto && ( + + )} + + )}{" "} +
+
+
+ + {/* Children */} + {p.children && ( + + + {p.children.length === 0 ? ( + <>Aucun enfant + ) : ( + p.children.map((c) => ( + + + + )) + )} + + + )} +
+
+ ); +} diff --git a/geneit_app/src/widgets/BaseFamilyRoute.tsx b/geneit_app/src/widgets/BaseFamilyRoute.tsx index ae14b5c..a59538d 100644 --- a/geneit_app/src/widgets/BaseFamilyRoute.tsx +++ b/geneit_app/src/widgets/BaseFamilyRoute.tsx @@ -38,6 +38,7 @@ interface FamilyContext { familyId: number; reloadFamilyInfo: () => void; reloadMembersList: () => Promise; + reloadCouplesList: () => Promise; } const FamilyContextK = React.createContext(null); @@ -116,6 +117,7 @@ export function BaseFamilyRoute(): React.ReactElement { familyId: family!.family_id, reloadFamilyInfo: onReload, reloadMembersList: onReload, + reloadCouplesList: onReload, }} > + ); +}