Complete family planning application with: - React frontend with TypeScript - Node.js/Express backend with TypeScript - Python ingestion service for document processing - Planning ingestion service with LLM integration - Shared UI components and type definitions - OAuth integration for calendar synchronization - Comprehensive documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import styled from "styled-components";
|
|
import { uploadAvatar, listAvatars, createParent, updateParent, createGrandParent, updateGrandParent } from "../services/api-client";
|
|
const Panel = styled.aside `
|
|
flex: 1;
|
|
padding: 24px;
|
|
border-radius: 18px;
|
|
background: rgba(29, 36, 66, 0.92);
|
|
border: 1px solid rgba(126, 136, 180, 0.22);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
`;
|
|
const Title = styled.h2 `
|
|
margin: 0;
|
|
`;
|
|
const Description = styled.p `
|
|
margin: 0;
|
|
color: var(--text-muted);
|
|
`;
|
|
const Form = styled.form `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
`;
|
|
const Label = styled.label `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
font-weight: 600;
|
|
`;
|
|
const Row = styled.div `
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
`;
|
|
const BaseInput = styled.input `
|
|
padding: 12px 14px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(126, 136, 180, 0.28);
|
|
background: rgba(16, 22, 52, 0.9);
|
|
color: #ffffff;
|
|
`;
|
|
const TextArea = styled.textarea `
|
|
padding: 12px 14px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(126, 136, 180, 0.28);
|
|
min-height: 96px;
|
|
background: rgba(16, 22, 52, 0.9);
|
|
color: #ffffff;
|
|
`;
|
|
const SubmitButton = styled.button `
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
border: none;
|
|
background: ${({ $loading }) => $loading ? "rgba(85, 98, 255, 0.25)" : "linear-gradient(135deg, #5562ff, #7d6cff)"};
|
|
color: #ffffff;
|
|
font-weight: 600;
|
|
cursor: ${({ $loading }) => ($loading ? "progress" : "pointer")};
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
transition: opacity 0.2s ease;
|
|
opacity: ${({ $loading }) => ($loading ? 0.7 : 1)};
|
|
`;
|
|
const SecondaryButton = styled.button `
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(126, 136, 180, 0.4);
|
|
background: rgba(16, 22, 52, 0.9);
|
|
color: #d9dcff;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: transform 0.2s ease;
|
|
|
|
&:hover {
|
|
transform: translateY(-1px);
|
|
}
|
|
`;
|
|
const AvatarSection = styled.section `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
`;
|
|
const AvatarHeader = styled.div `
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
`;
|
|
const AvatarPreview = styled.div `
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
`;
|
|
const AvatarImage = styled.img `
|
|
width: 72px;
|
|
height: 72px;
|
|
border-radius: 18px;
|
|
object-fit: cover;
|
|
border: 2px solid rgba(126, 136, 180, 0.28);
|
|
`;
|
|
const AvatarFallback = styled.div `
|
|
width: 72px;
|
|
height: 72px;
|
|
border-radius: 18px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(9, 13, 28, 0.8);
|
|
border: 2px solid ${({ $color }) => $color};
|
|
color: #f4f5ff;
|
|
font-weight: 700;
|
|
`;
|
|
const AvatarInfo = styled.div `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
`;
|
|
const Helper = styled.span `
|
|
color: var(--text-muted);
|
|
font-size: 0.9rem;
|
|
`;
|
|
const AvatarPicker = styled.div `
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
`;
|
|
const StatusMessage = styled.div `
|
|
padding: 12px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(126, 136, 180, 0.3);
|
|
background: rgba(16, 22, 52, 0.7);
|
|
color: var(--text-muted);
|
|
`;
|
|
const GalleryGrid = styled.div `
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
|
gap: 12px;
|
|
`;
|
|
const GalleryItem = styled.button `
|
|
border: 2px solid ${({ $selected }) => ($selected ? "#5562ff" : "transparent")};
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
background: rgba(9, 13, 28, 0.8);
|
|
cursor: pointer;
|
|
`;
|
|
const GalleryThumbnail = styled.img `
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
`;
|
|
const ErrorText = styled.div `
|
|
color: #ffb3bd;
|
|
`;
|
|
const DEFAULT_COLOR = "#7d6cff";
|
|
export const ParentProfilePanel = ({ mode = "create", parent, onCancel, kind = "parent" }) => {
|
|
const isEdit = mode === "edit" && !!parent;
|
|
const resolvedKind = kind;
|
|
const createEntity = resolvedKind === 'grandparent' ? createGrandParent : createParent;
|
|
const updateEntity = resolvedKind === 'grandparent' ? updateGrandParent : updateParent;
|
|
const displayLabel = resolvedKind === 'grandparent' ? 'grand-parent' : 'parent';
|
|
const [fullName, setFullName] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [colorHex, setColorHex] = useState(DEFAULT_COLOR);
|
|
const [notes, setNotes] = useState("");
|
|
const [avatarSelection, setAvatarSelection] = useState(null);
|
|
const [removeExistingAvatar, setRemoveExistingAvatar] = useState(false);
|
|
const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
|
|
const [gallery, setGallery] = useState([]);
|
|
const [galleryLoading, setGalleryLoading] = useState(false);
|
|
const [galleryError, setGalleryError] = useState(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const fileInputRef = useRef(null);
|
|
useEffect(() => {
|
|
if (avatarPickerOpen && gallery.length === 0 && !galleryLoading) {
|
|
setGalleryLoading(true);
|
|
listAvatars()
|
|
.then((list) => setGallery(list.map((g) => ({ filename: g.filename, url: g.url }))))
|
|
.catch(() => setGalleryError("Impossible de charger la galerie locale."))
|
|
.finally(() => setGalleryLoading(false));
|
|
}
|
|
}, [avatarPickerOpen, gallery.length, galleryLoading]);
|
|
useEffect(() => {
|
|
if (isEdit && parent) {
|
|
setFullName(parent.fullName);
|
|
setColorHex(parent.colorHex);
|
|
setEmail(parent.email ?? "");
|
|
setNotes(parent.notes ?? "");
|
|
setAvatarSelection(null);
|
|
setRemoveExistingAvatar(false);
|
|
if (fileInputRef.current)
|
|
fileInputRef.current.value = "";
|
|
setError(null);
|
|
}
|
|
else if (!isEdit) {
|
|
setFullName("");
|
|
setColorHex(DEFAULT_COLOR);
|
|
setEmail("");
|
|
setNotes("");
|
|
setAvatarSelection(null);
|
|
setRemoveExistingAvatar(false);
|
|
if (fileInputRef.current)
|
|
fileInputRef.current.value = "";
|
|
setError(null);
|
|
}
|
|
}, [isEdit, parent]);
|
|
const initials = useMemo(() => {
|
|
return fullName
|
|
.split(" ")
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0))
|
|
.join("")
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
}, [fullName]);
|
|
const currentAvatarUrl = useMemo(() => {
|
|
if (avatarSelection) {
|
|
return avatarSelection.source === "upload" ? avatarSelection.previewUrl : avatarSelection.url;
|
|
}
|
|
if (removeExistingAvatar)
|
|
return null;
|
|
return parent?.avatar?.url ?? null;
|
|
}, [avatarSelection, parent?.avatar, removeExistingAvatar]);
|
|
const currentAvatarLabel = useMemo(() => {
|
|
if (avatarSelection) {
|
|
return avatarSelection.source === "upload" ? avatarSelection.name : avatarSelection.name ?? "Avatar local";
|
|
}
|
|
if (removeExistingAvatar)
|
|
return "Aucun avatar selectionne";
|
|
return parent?.avatar?.name ?? "Aucun avatar selectionne";
|
|
}, [avatarSelection, parent?.avatar, removeExistingAvatar]);
|
|
const handleFileChange = (event) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file)
|
|
return;
|
|
if (!file.type.startsWith("image/")) {
|
|
setError("Le fichier doit etre une image (png, jpg, svg...).");
|
|
return;
|
|
}
|
|
setAvatarSelection({ source: "upload", file, previewUrl: URL.createObjectURL(file), name: file.name });
|
|
setRemoveExistingAvatar(false);
|
|
setError(null);
|
|
};
|
|
const handleSelectGallery = (item) => {
|
|
setAvatarSelection({ source: "gallery", url: item.url, name: item.filename });
|
|
setRemoveExistingAvatar(false);
|
|
if (fileInputRef.current)
|
|
fileInputRef.current.value = "";
|
|
};
|
|
const handleClearAvatar = () => {
|
|
if (avatarSelection?.source === "upload")
|
|
URL.revokeObjectURL(avatarSelection.previewUrl);
|
|
setAvatarSelection(null);
|
|
if (fileInputRef.current)
|
|
fileInputRef.current.value = "";
|
|
setRemoveExistingAvatar(isEdit);
|
|
};
|
|
const handleSubmit = async (event) => {
|
|
event.preventDefault();
|
|
if (isSubmitting)
|
|
return;
|
|
if (!fullName.trim()) {
|
|
setError("Merci de saisir le nom complet.");
|
|
return;
|
|
}
|
|
const payload = {
|
|
fullName: fullName.trim(),
|
|
colorHex,
|
|
email: email.trim() ? email.trim() : undefined,
|
|
notes: notes.trim() ? notes.trim() : undefined
|
|
};
|
|
setIsSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
let avatarPayload = undefined;
|
|
if (avatarSelection?.source === "upload") {
|
|
const uploaded = await uploadAvatar(avatarSelection.file);
|
|
avatarPayload = { kind: "custom", url: uploaded.url, name: avatarSelection.name };
|
|
URL.revokeObjectURL(avatarSelection.previewUrl);
|
|
setAvatarSelection({ source: "gallery", url: uploaded.url, name: avatarSelection.name });
|
|
}
|
|
else if (avatarSelection?.source === "gallery") {
|
|
avatarPayload = { kind: "custom", url: avatarSelection.url, name: avatarSelection.name };
|
|
}
|
|
else if (isEdit && parent?.avatar && !removeExistingAvatar) {
|
|
avatarPayload = parent.avatar;
|
|
}
|
|
else if (removeExistingAvatar) {
|
|
avatarPayload = null;
|
|
}
|
|
if (avatarPayload !== undefined) {
|
|
payload.avatar = avatarPayload ?? undefined;
|
|
}
|
|
if (isEdit && parent) {
|
|
await updateEntity(parent.id, payload);
|
|
onCancel?.();
|
|
}
|
|
else {
|
|
await createEntity(payload);
|
|
setFullName("");
|
|
setColorHex(DEFAULT_COLOR);
|
|
setEmail("");
|
|
setNotes("");
|
|
setAvatarSelection(null);
|
|
setRemoveExistingAvatar(false);
|
|
if (fileInputRef.current)
|
|
fileInputRef.current.value = "";
|
|
}
|
|
}
|
|
catch (err) {
|
|
setError("Impossible d enregistrer pour le moment. Merci de reessayer.");
|
|
}
|
|
finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
return (_jsxs(Panel, { children: [_jsx(Title, { children: isEdit ? "Modifier le parent" : "Ajouter un parent" }), _jsx(Description, { children: isEdit
|
|
? "Ajuste le profil, la couleur ou l avatar. Les modifications sont visibles partout."
|
|
: "Cree rapidement un nouveau profil en renseignant email et avatar." }), _jsxs(Form, { onSubmit: handleSubmit, children: [_jsxs(Label, { children: ["Prenom et nom", _jsx(BaseInput, { type: "text", placeholder: "Ex: Jean Dupont", value: fullName, onChange: (event) => setFullName(event.target.value) })] }), _jsxs(Row, { children: [_jsxs(Label, { style: { flex: "1 1 180px" }, children: ["Adresse email", _jsx(BaseInput, { type: "email", placeholder: "prenom@exemple.com", value: email, onChange: (event) => setEmail(event.target.value) })] }), _jsxs(Label, { style: { width: "120px" }, children: ["Couleur", _jsx(BaseInput, { type: "color", value: colorHex, onChange: (event) => setColorHex(event.target.value) })] })] }), _jsxs(Label, { children: ["Notes", _jsx(TextArea, { placeholder: "Infos importantes, telephone...", value: notes, onChange: (event) => setNotes(event.target.value) })] }), _jsxs(AvatarSection, { children: [_jsxs(AvatarHeader, { children: [_jsx("strong", { children: "Avatar" }), _jsxs(Row, { children: [_jsx(SecondaryButton, { type: "button", onClick: () => setAvatarPickerOpen((o) => !o), children: avatarPickerOpen ? "Fermer" : "Choisir un avatar" }), (avatarSelection || (isEdit && parent?.avatar && !removeExistingAvatar)) && (_jsx(SecondaryButton, { type: "button", onClick: handleClearAvatar, children: "Retirer l avatar" }))] })] }), _jsxs(AvatarPreview, { children: [currentAvatarUrl ? (_jsx(AvatarImage, { src: currentAvatarUrl, alt: currentAvatarLabel ?? "Avatar" })) : (_jsx(AvatarFallback, { "$color": colorHex, children: initials || "?" })), _jsxs(AvatarInfo, { children: [_jsx("span", { children: currentAvatarLabel }), _jsx(Helper, { children: "Les avatars importes sont stockes dans `backend/public/avatars/`." })] })] }), avatarPickerOpen ? (_jsxs(AvatarPicker, { children: [_jsxs("div", { children: [_jsx("strong", { children: "Importer un nouvel avatar" }), _jsx(Label, { children: _jsx(BaseInput, { ref: fileInputRef, type: "file", accept: "image/*", onChange: handleFileChange }) }), _jsx(Helper, { children: "Formats acceptes: png, jpg, svg. Taille conseillee 512x512. Les images importees sont stockees localement." })] }), _jsxs("div", { children: [_jsx("strong", { children: "Galerie locale" }), galleryLoading ? (_jsx(StatusMessage, { children: "Chargement de la galerie..." })) : galleryError ? (_jsx(StatusMessage, { children: galleryError })) : gallery.length === 0 ? (_jsx(StatusMessage, { children: "Aucune image dans `backend/public/avatars/`. Ajoute des fichiers pour les proposer ici." })) : (_jsx(GalleryGrid, { children: gallery.map((item) => (_jsx(GalleryItem, { "$selected": avatarSelection?.source === "gallery" && avatarSelection.url === item.url, type: "button", onClick: () => handleSelectGallery(item), children: _jsx(GalleryThumbnail, { src: item.url, alt: item.filename }) }, item.filename))) }))] })] })) : null] }), error ? _jsx(ErrorText, { children: error }) : null, _jsxs(Row, { children: [_jsx(SubmitButton, { type: "submit", disabled: isSubmitting, "$loading": isSubmitting, children: isSubmitting ? "Enregistrement..." : isEdit ? "Mettre a jour le profil" : "Enregistrer le profil" }), isEdit ? (_jsx(SecondaryButton, { type: "button", onClick: () => onCancel?.(), children: "Annuler" })) : null] })] })] }));
|
|
};
|