diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e6ba106..df28b4a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import Galaxy from './components/Galaxy' import PersonCard from './components/PersonCard.jsx' import { FamilyWelcome, SouvenirPicker } from './components/FamilyOnboarding.jsx' import { HELP_URL } from './utils/siteLinks.js' +import { firstNameLabel } from './utils/personName.js' import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome' import MediaViewer from './components/MediaViewer' import MediaManagerPanel from './components/MediaManagerPanel' @@ -563,6 +564,10 @@ function App() { const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false) const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false) const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null) + // Message bref (toast) : enregistrement automatique d'une fiche, erreurs d'ordre des fratries + const [draftNotice, setDraftNotice] = useState('') + // Fiche en modification : elle enregistre ses changements avant qu'on la quitte (PersonCard, saveOnLeaveRef) + const personSaveOnLeaveRef = useRef(null) useEffect(() => { if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) { @@ -811,29 +816,55 @@ function App() { updateDraft: editMode.updateDraft, }) - const handlePersonSelect = useCallback((personLike) => { + // Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un + // souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte + // avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas. + const leaveSelectedPerson = useCallback(async () => { + const current = tree.selectedPerson + const saveOnLeave = personSaveOnLeaveRef.current + if (!current || !saveOnLeave || isDemoMode) return true + const outcome = await saveOnLeave() + if (outcome === 'saved') setDraftNotice(t('Modifications de {name} enregistrées', { name: firstNameLabel(current) })) + return outcome !== 'blocked' + }, [tree.selectedPerson, isDemoMode]) + + const chooseSelectedPerson = useCallback(async (next) => { + if (!(await leaveSelectedPerson())) return false + tree.setSelectedPerson(next) + return true + }, [leaveSelectedPerson, tree.setSelectedPerson]) + + const deactivateEditMode = useCallback(async () => { + if (!(await leaveSelectedPerson())) return + editMode.deactivate() + }, [leaveSelectedPerson, editMode.deactivate]) + + const handlePersonSelect = useCallback(async (personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike if (personId === null || personId === undefined) { - tree.setSelectedPerson(null) + await chooseSelectedPerson(null) return } + const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null + if (!(await leaveSelectedPerson())) return + // Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ; // sa saisie reste en mémoire et reprend quand on rouvre « Ajouter » setAddPersonPanelVisible(false) - const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null tree.setSelectedPerson(freshPerson) trackTreeInteraction('text') trackDemoInteraction('person') - }, [tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + }, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps - const handleMediaSelectFresh = useCallback((media, personLike) => { + const handleMediaSelectFresh = useCallback(async (media, personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike const freshPerson = personId !== null && personId !== undefined ? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null : personLike || null + if (!(await leaveSelectedPerson())) return tree.handleMediaSelect(media, freshPerson) trackTreeInteraction(toInteractionMediaType(media?.type)) trackDemoInteraction('media') @@ -895,22 +926,26 @@ function App() { const handleEditToggle = useCallback(() => { if (editMode.isActive && editMode.hasDraft) { setShowConfirmModal(true) + } else if (editMode.isActive) { + deactivateEditMode() } else { - if (!editMode.isActive) trackDemoInteraction('edit') + trackDemoInteraction('edit') editMode.toggle() } - }, [editMode.isActive, editMode.hasDraft, editMode.toggle, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + }, [editMode.isActive, editMode.hasDraft, editMode.toggle, deactivateEditMode, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps // Actions de la modal de confirmation const handleConfirmContinue = useCallback(() => { setShowConfirmModal(false) }, []) - const handleConfirmSaveAndQuit = useCallback(() => { + const handleConfirmSaveAndQuit = useCallback(async () => { + const left = await leaveSelectedPerson() + setShowConfirmModal(false) + if (!left) return editMode.saveDraft() editMode.deactivate() - setShowConfirmModal(false) - }, [editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps + }, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps const handleConfirmDiscard = useCallback(() => { editMode.clearDraft() @@ -966,11 +1001,11 @@ function App() { setSouvenirPickerVisible(true) }, [tree.treeContext.treeId]) - const handleSouvenirPick = useCallback((person) => { + const handleSouvenirPick = useCallback(async (person) => { + if (!(await chooseSelectedPerson(person))) return setSouvenirPickerVisible(false) - tree.setSelectedPerson(person) setPendingMediaPersonId(person.id) - }, [tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps + }, [chooseSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps const handleSouvenirPickerAddPerson = useCallback(() => { setSouvenirPickerVisible(false) @@ -984,6 +1019,8 @@ function App() { const handleAddPersonSubmit = useCallback(async (formData) => { if (!tree.treeContext.treeId && !isDemoMode) return + // La fiche ouverte est enregistrée avant que la nouvelle personne ne la remplace + if (!(await leaveSelectedPerson())) return setAddPersonLoading(true) setAddPersonError('') @@ -1193,7 +1230,6 @@ function App() { const [contribSubmitLoading, setContribSubmitLoading] = useState(false) const [contribSubmitError, setContribSubmitError] = useState('') const [contribSuccess, setContribSuccess] = useState(false) - const [draftNotice, setDraftNotice] = useState('') // Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite. // L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit. @@ -1233,6 +1269,8 @@ function App() { // Soumission des modifications const handleEditSubmit = useCallback(async () => { + // Les changements de la fiche ouverte doivent être dans le brouillon avant l'envoi + if (!(await leaveSelectedPerson())) return if (isDemoMode) { // Démo: édition locale uniquement, jamais de persistance serveur. editMode.saveDraft() @@ -2967,11 +3005,11 @@ function App() { hasResults={search.hasResults} onSearch={search.handleSearch} onClear={search.clearSearch} - onResultClick={(person) => { + onResultClick={async (person) => { const fullPerson = persons.find(p => p.id === person.id) if (!fullPerson) return trackDemoInteraction('search') - tree.setSelectedPerson(fullPerson) + if (!(await chooseSelectedPerson(fullPerson))) return galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id) }} /> @@ -2988,7 +3026,7 @@ function App() { hasDraft={editMode.hasDraft} draftTimestamp={editMode.draftTimestamp} role={userRole.isAdmin ? 'admin' : 'contributor'} - onDeactivate={editMode.deactivate} + onDeactivate={deactivateEditMode} onSubmit={handleEditSubmit} /> {/* Confirmation Modal (quitter mode édition avec draft) */} @@ -3104,8 +3142,9 @@ function App() { {tree.selectedPerson && ( tree.setSelectedPerson(null)} + onClose={() => { chooseSelectedPerson(null) }} onMediaSelect={handleMediaSelectFresh} + saveOnLeaveRef={personSaveOnLeaveRef} editMode={editMode.isActive} canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree} onManageMedia={media.handleOpenMediaManager} diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index d1754b6..547b951 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -270,6 +270,7 @@ .pc-unknown-first { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin: -4px 0 0; font: 400 12.5px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; white-space: nowrap; } .pc-unknown-first input { width: 15px; height: 15px; accent-color: var(--pz-a500); } .pc-name-error { margin: 6px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-danger); } +.pc-save-error { margin: 0; font: 500 12.5px/1.4 var(--pz-sans); color: var(--pz-danger); } /* Modification de l'onglet Infos : pastilles des champs vides (DA terracotta, sans nouvelle couleur) */ .pc-fill-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 1b8e4ba..22c26f7 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -17,6 +17,7 @@ import { formatPlace } from '../utils/place.js' import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js' import { isYearOnly } from '../utils/dateInput.js' import DateField from './DateField.jsx' +import { runOnce } from '../utils/saveOnLeave.js' // Aucune modification en cours (référence stable) const NO_EDITS = Object.freeze({}) @@ -70,6 +71,7 @@ const PersonCard = ({ onMoveSibling, onUploadMomentFile, onMomentSaved, + saveOnLeaveRef = null, }) => { // Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos')) @@ -114,6 +116,9 @@ const PersonCard = ({ const [lastNameMissing, setLastNameMissing] = useState(false) const [saving, setSaving] = useState(false) const [saveSuccess, setSaveSuccess] = useState(false) + const [saveError, setSaveError] = useState('') + // Enregistrement en cours : un second appel (bouton, puis changement de fiche) reçoit la même promesse + const saveInFlight = useRef(null) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showImmichPanel, setShowImmichPanel] = useState(false) // null : connexion Immich de l'arbre inconnue, puis booléen. Le menu de source ne se propose @@ -149,6 +154,7 @@ const PersonCard = ({ setActiveRelationSearch(null) setRelationSearchQuery('') setSaveSuccess(false) + setSaveError('') setShowSourceMenu(false) setMomentSheet(null) setLifeNotice('') @@ -313,29 +319,53 @@ const PersonCard = ({ ] const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : [] - const handleSave = async () => { - if (!hasChanges || !onSave) return + // Enregistre les modifications de cette fiche (bouton « Enregistrer » et départ de la fiche). + // Résultat : 'saved', 'unchanged' (rien à enregistrer) ou 'blocked' (validation, ou échec réseau : + // le message reste sur la fiche, la saisie n'est pas jetée). + const persistEdits = () => runOnce(saveInFlight, async () => { + if (!hasChanges || !onSave) return 'unchanged' if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) { setFirstNameMissing(true) - return + return 'blocked' } // Prénom inconnu : il faut alors un nom de famille if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) { setLastNameMissing(true) - return + return 'blocked' } + const savedFor = person.id setSaving(true) + setSaveError('') try { - const savedFor = person.id await onSave(savedFor, edits) setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev)) setOpenedFields([]) setSaveSuccess(true) setTimeout(() => setSaveSuccess(false), 2000) + return 'saved' + } catch (error) { + // Serveur injoignable (status 0, message traduit par le client) ; sinon un texte clair, jamais un message technique + setSaveError(error?.status === 0 ? error.message : t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez.")) + return 'blocked' } finally { setSaving(false) } - } + }) + + // App enregistre la fiche avant de la quitter (changement de personne, fermeture, sortie du mode + // modification) : la référence suit toujours la dernière version de persistEdits + useEffect(() => { + if (!saveOnLeaveRef) return undefined + saveOnLeaveRef.current = persistEdits + return () => { if (saveOnLeaveRef.current === persistEdits) saveOnLeaveRef.current = null } + }) + + // Sortie du mode modification : les modifications restantes (abandonnées) ne reviennent pas à la prochaine entrée + useEffect(() => { + if (!editMode) setEditState({ personId: person.id, values: {}, changed: false }) + }, [editMode]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleSave = () => { persistEdits() } const handleDelete = () => { if (!onDelete) return @@ -1000,6 +1030,7 @@ const PersonCard = ({ {/* ── Actions d'édition ── */} + {editMode && saveError &&

{saveError}

} {editMode && (