Create basic couple route

This commit is contained in:
Pierre HUBERT 2023-08-16 12:17:04 +02:00
parent 0652fbadc8
commit 328eada9ec
6 changed files with 668 additions and 0 deletions

View File

@ -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 {
<Route path="profile" element={<ProfileRoute />} />
<Route path="family/:familyId/*" element={<BaseFamilyRoute />}>
<Route path="" element={<FamilyHomeRoute />} />
<Route path="members" element={<FamilyMembersListRoute />} />
<Route
path="member/create"
@ -68,6 +74,17 @@ export function App(): React.ReactElement {
path="member/:memberId/edit"
element={<FamilyEditMemberRoute />}
/>
<Route
path="couple/create"
element={<FamilyCreateCoupleRoute />}
/>
<Route path="couple/:coupleId" element={<FamilyCoupleRoute />} />
<Route
path="couple/:coupleId/edit"
element={<FamilyEditCoupleRoute />}
/>
<Route path="settings" element={<FamilySettingsRoute />} />
<Route path="users" element={<FamilyUsersListRoute />} />
<Route path="*" element={<NotFoundRoute />} />

View File

@ -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<number, Couple>;
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<Couple> {
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<Couple> {
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<CouplesList> {
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<void> {
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<void> {
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<void> {
await APIClient.exec({
uri: `/family/${m.family_id}/couple/${m.id}/photo`,
method: "DELETE",
});
}
/**
* Delete a family couple
*/
static async Delete(m: Couple): Promise<void> {
await APIClient.exec({
uri: `/family/${m.family_id}/couple/${m.id}`,
method: "DELETE",
});
}
}

View File

@ -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 {

View File

@ -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 (
<CouplePage
couple={Couple.New(family.family.family_id)}
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 { 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 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 (
<AsyncWidget
loadKey={`${coupleId}-${count.current}`}
load={load}
ready={couple !== undefined}
errMsg="Echec du chargement des informations du couple !"
build={() => (
<CouplePage
couple={couple!}
children={[]} // TODO
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 family = useFamily();
const [couple, setCouple] = React.useState<Couple>();
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 (
<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) => 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 (
<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}
/>
</PropertiesBox>
</Grid>
{/* Photo */}
<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}
/>{" "}
{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>
))
)}
</PropertiesBox>
</Grid>
)}
</Grid>
</div>
);
}

View File

@ -38,6 +38,7 @@ interface FamilyContext {
familyId: number;
reloadFamilyInfo: () => void;
reloadMembersList: () => Promise<void>;
reloadCouplesList: () => Promise<void>;
}
const FamilyContextK = React.createContext<FamilyContext | null>(null);
@ -116,6 +117,7 @@ export function BaseFamilyRoute(): React.ReactElement {
familyId: family!.family_id,
reloadFamilyInfo: onReload,
reloadMembersList: onReload,
reloadCouplesList: onReload,
}}
>
<Box

View File

@ -0,0 +1,19 @@
import { Avatar } from "@mui/material";
import { Couple } from "../api/CoupleApi";
export function CouplePhoto(p: {
couple: Couple;
width?: number;
}): React.ReactElement {
return (
<Avatar
sx={
p.width
? { width: `${p.width}px`, height: "auto", display: "inline-block" }
: undefined
}
variant="rounded"
src={p.couple.thumbnailURL ?? undefined}
/>
);
}