diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3224581..17c0139 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1104,6 +1104,22 @@ function App() { const [addPersonLoading, setAddPersonLoading] = useState(false) const [addPersonError, setAddPersonError] = useState('') + // Ajouter un proche depuis une fiche (1.5.1) : le panneau s'ouvre avec le lien déjà posé, le nom tapé dans la + // recherche, et le nom de famille proposé pour un enfant ou un frère ou une sœur + const [addPersonPrefill, setAddPersonPrefill] = useState(null) + const handleCreateRelative = useCallback(async (type, query = '') => { + const person = tree.selectedPerson + if (!person) return + const [first = '', ...rest] = String(query).trim().split(/\s+/) + const inheritsName = type === 'child' || type === 'sibling' + const lastName = rest.join(' ') || (inheritsName ? person.lastName || '' : '') + const personName = [person.firstName, person.lastName].filter(Boolean).join(' ') + if (!(await leaveSelectedPerson())) return + await chooseSelectedPerson(null) + setAddPersonPrefill({ nonce: Date.now(), firstName: first, lastName, relation: { type, personId: person.id, personName } }) + handleOpenAddPerson() + }, [tree.selectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps + const handleOpenAddPerson = useCallback(() => { setAddPersonError('') // Le panneau d'ajout vit dans le mode édition : la famille y entre sans passer par le crayon @@ -3257,6 +3273,8 @@ function App() { error={addPersonError} onSubmit={handleAddPersonSubmit} onClose={handleCloseAddPerson} + prefill={addPersonPrefill} + onOpenPerson={(person) => { setAddPersonPanelVisible(false); handlePersonSelect(person) }} /> {/* Modal Session Contribution (contributeurs) */} et garde son italique function withEmphasis(text) { @@ -33,7 +34,11 @@ function withEmphasis(text) { * @param {Function} props.onSubmit - Handler soumission (formData) * @param {Function} props.onClose - Handler fermeture */ -function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) { +/** + * @param {Object} props.prefill - ouverture depuis une fiche (1.5.1) : { nonce, firstName, lastName, relation: { type, personId, personName } } + * @param {Function} props.onOpenPerson - « Ouvrir sa fiche » sous l'alerte de doublon + */ +function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose, prefill = null, onOpenPerson = null }) { const [firstName, setFirstName] = useState('') // Prénom inconnu : enregistré vide, affiché « Prénom inconnu » dans l'arbre et la fiche const [unknownFirstName, setUnknownFirstName] = useState(false) @@ -61,6 +66,23 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const firstNameRef = useRef(null) const panelRef = useRef(null) + // Ouvert depuis une fiche : le lien avec la personne est déjà là, et le nom de famille proposé pour un enfant + // ou un frère ou une sœur (modifiable). Avant, il fallait rechercher à nouveau la personne qu'on venait de quitter. + useEffect(() => { + if (!prefill?.nonce) return + setFirstName(prefill.firstName || '') + setUnknownFirstName(false) + setLastName(prefill.lastName || '') + setTouched({}) + if (prefill.relation?.personId != null) { + setSelectedRelationType(prefill.relation.type) + setRelations([{ personId: prefill.relation.personId, personName: prefill.relation.personName || '', type: prefill.relation.type }]) + } + }, [prefill?.nonce]) // eslint-disable-line react-hooks/exhaustive-deps + + // Doublon probable : même prénom et même nom qu'une personne de l'arbre + const duplicates = unknownFirstName ? [] : findLikelyDuplicates(persons, firstName, lastName) + // Focus sur Prénom à l'ouverture useEffect(() => { if (visible) { @@ -251,6 +273,21 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on

{t('Il faut au moins un prénom.')}

)} {lastNameMissing &&

{t('Il faut au moins un nom si le prénom est inconnu.')}

} + {duplicates.length > 0 && ( +
+ {duplicates.map((p) => ( +

+ {p.birthYear + ? t('{name} ({year}) est déjà dans l’arbre.', { name: `${firstNameLabel(p)} ${p.lastName || ''}`.trim(), year: p.birthYear }) + : t('{name} est déjà dans l’arbre.', { name: `${firstNameLabel(p)} ${p.lastName || ''}`.trim() })} + {onOpenPerson && ( + + )} +

+ ))} +

{t('Si c’est une autre personne, ajoutez-la quand même.')}

+
+ )}
diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index 20e6a81..bbd24b4 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -304,3 +304,10 @@ } .pc-quick-edit:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; } @media (hover: hover) and (pointer: fine) { .pc-quick-edit:hover { background: var(--pz-line-soft); } } + +/* Ajouter un proche depuis la fiche en lecture (1.5.1) */ +.pc-quick-add { display: flex; flex-wrap: wrap; gap: 6px; } +.pc-quick-add .pz-btn { gap: 4px; } +/* « Créer « Jeanne » comme parent » sous la recherche d'un proche */ +.pc-search-create .pc-rel-avatar { color: var(--pz-a700); background: var(--pz-a50); } +.pc-search-create em { color: var(--pz-muted); font-style: normal; } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 176fa23..a848160 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -70,6 +70,8 @@ const PersonCard = ({ // Modification proposée et pas encore relue (fantôme, 1.5.1) : { fields, deleted, ghost }, et sa relecture proposal = null, proposalReview = null, + // Créer un proche qui n'est pas encore dans l'arbre (1.5.1) : (type, nom tapé) ouvre l'ajout déjà rempli + onCreateRelative = null, relationActionLoading = false, relationActionSuccess = '', relationActionError = '', @@ -617,14 +619,15 @@ const PersonCard = ({ ageLabel && { key: 'age', node: ageLabel, nowrap: true }, ].filter(Boolean) - const renderRelationRow = (target, relationType, meta) => ( + // removable : un frère ou une sœur passe par ses parents, il ne se retire pas d'ici + const renderRelationRow = (target, relationType, meta, removable = true) => (
{target ? fullNameLabel(target) : ''} {meta && {meta}} - + }
) @@ -646,6 +649,9 @@ const PersonCard = ({ onSelect={(p) => handleRelationAdd(p, relationType)} onClose={() => { setActiveRelationSearch(null); setRelationSearchQuery('') }} placeholder={placeholder} + // La recherche ne trouvait que les personnes déjà dans l'arbre : sans résultat, on s'arrêtait là + onCreate={onCreateRelative ? (query) => { setActiveRelationSearch(null); setRelationSearchQuery(''); onCreateRelative(relationType, query) } : null} + createLabel={relationType === 'parent' ? t('comme parent') : relationType === 'child' ? t('comme enfant') : t('comme conjoint·e')} /> ) : ( + + + {parentsList.length > 0 && ( + + )} +
+ )} + {(editMode ? notesShown : person.note) && (

{t('Notes')}

@@ -1107,6 +1125,24 @@ const PersonCard = ({ {childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) :

{t('Aucun enfant')}

} {renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}
+ + {onCreateRelative && ( +
+

{t('Frères et sœurs')}

+ {siblingsList.length > 0 + ? siblingsList.map(c => renderRelationRow(c, 'sibling', null, false)) + :

{t('Aucun frère ni sœur')}

} + {/* Un frère ou une sœur reçoit les mêmes parents : sans parent, on le dit avant d'ouvrir l'ajout */} + {parentsList.length > 0 ? ( + + ) : ( +

{t("Ajoutez d'abord un parent pour lui rattacher un frère ou une sœur.")}

+ )} +
+ )} )} @@ -1244,7 +1280,7 @@ const PersonCard = ({ * Recherche en ligne pour ajouter un lien (parent, conjoint·e, enfant) */ const RelationSearchInline = forwardRef(function RelationSearchInline( - { query, onQueryChange, results, onSelect, onClose, placeholder }, + { query, onQueryChange, results, onSelect, onClose, placeholder, onCreate = null, createLabel = '' }, ref ) { const filtered = results.slice(0, 6) @@ -1286,9 +1322,15 @@ const RelationSearchInline = forwardRef(function RelationSearchInline( ))} - ) : query ? ( + ) : query && !onCreate ? (

{t('Aucune personne ne correspond')}

) : null} + {onCreate && query.trim() && ( + + )} ) }) diff --git a/frontend/src/components/propositions.test.jsx b/frontend/src/components/propositions.test.jsx new file mode 100644 index 0000000..54de48b --- /dev/null +++ b/frontend/src/components/propositions.test.jsx @@ -0,0 +1,70 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import PersonCard from './PersonCard.jsx' +import ProposalCard, { ProposalBanner } from './ProposalCard.jsx' +import { findLikelyDuplicates } from '../utils/duplicates.js' +import { summarizeChanges } from '../utils/proposalSummary.js' + +// 1.5.1 : propositions sur l'arbre (fantômes) et ajout d'un proche depuis la fiche. Les droits de relecture sont +// tenus par le serveur (backend/test/propositions-fantomes.db.test.ts) ; ici, ce que chacun voit. +const noop = () => {} +const ghostPerson = (source) => ({ id: 'ghost:s1:tmp:a', firstName: 'Lucie', lastName: 'Martin', birthYear: 1990, ghost: { source, sessionId: 's1', label: 'Cousine', createdAt: '2026-10-25T10:00:00Z' } }) +const review = { busy: false, error: '', onAccept: noop, onReject: noop, onOpenDetails: noop } + +describe('carte d’une proposition', () => { + it('qui gère l’arbre peut accepter, refuser ou voir le détail', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Proposé par Cousine') + expect(html).toContain('Accepter') + expect(html).toContain('Refuser') + // t() pose une espace fine insécable devant les deux-points + expect(html).toMatch(/Dans cette proposition\s:\s1 personne, 1 lien\./) + }) + + it('la famille lit seulement qu’elle attend, sans bouton de relecture', () => { + const html = renderToStaticMarkup() + expect(html).toContain('en attente de relecture') + expect(html).not.toContain('Accepter') + expect(renderToStaticMarkup()).toContain('Pas encore envoyé') + }) + + it('le bandeau d’une fiche existante liste les champs proposés', () => { + const html = renderToStaticMarkup() + expect(html).toContain('Lieu de naissance') + expect(html).toContain('Lille') + expect(html).toContain('1950') + expect(html).toContain('Accepter') + }) + + it('le résumé compte ce que contient la proposition', () => { + expect(summarizeChanges([ + { entityType: 'person', action: 'create' }, + { entityType: 'parent_child_link', action: 'create' }, + { entityType: 'union', action: 'create' }, + { entityType: 'annotation', action: 'create' }, + ])).toBe("1 personne, 2 liens, 1 ajout sur l'arbre") + }) +}) + +describe('ajouter un proche depuis la fiche', () => { + const person = { id: 'p1', firstName: 'Marie', lastName: 'Martin', isAlive: true } + it('la fiche en lecture propose parent, conjoint·e et enfant quand App le permet ; sinon rien', () => { + const html = renderToStaticMarkup() + expect(html).toContain('pc-quick-add') + expect(html).toContain('Conjoint·e') + // Sans parent connu, pas de frère ou sœur à rattacher + expect(html).not.toContain('Frère ou sœur') + expect(renderToStaticMarkup()).not.toContain('pc-quick-add') + }) + + it('le doublon probable ignore accents, casse et espaces, et regarde aussi le nom de naissance', () => { + const persons = [ + { id: 'a', firstName: 'Hélène', lastName: 'Dupont' }, + { id: 'b', firstName: 'Helene', lastName: 'Martin', birthName: 'Dupont' }, + { id: 'c', firstName: 'Hélène', lastName: 'Durand' }, + { id: 'g', firstName: 'Hélène', lastName: 'Dupont', ghost: { source: 'pending' } }, + ] + expect(findLikelyDuplicates(persons, ' helene ', 'DUPONT').map((p) => p.id)).toEqual(['a', 'b']) + expect(findLikelyDuplicates(persons, 'Hélène', '')).toEqual([]) + }) +}) diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 09a970f..6875eca 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -495,4 +495,20 @@ export default { 'Chargement de la photo': 'Loading photo', 'Impossible d’afficher cette photo.': 'Unable to display this photo.', 'Les photos venues d’Immich ne se recadrent pas pour l’instant.': 'Photos from Immich cannot be cropped for now.', + + // ─── Ajouter un proche depuis la fiche (1.5.1) ─── + '{name} ({year}) est déjà dans l’arbre.': '{name} ({year}) is already in the tree.', + '{name} est déjà dans l’arbre.': '{name} is already in the tree.', + 'Ouvrir sa fiche': 'Open their profile', + 'Si c’est une autre personne, ajoutez-la quand même.': "If it's someone else, add them anyway.", + 'comme parent': 'as a parent', + 'comme enfant': 'as a child', + 'comme conjoint·e': 'as a partner', + 'Ajouter un proche': 'Add a relative', + 'Frère ou sœur': 'Sibling', + 'Frères et sœurs': 'Siblings', + 'Aucun frère ni sœur': 'No siblings', + 'Ajouter un frère ou une sœur': 'Add a sibling', + "Ajoutez d'abord un parent pour lui rattacher un frère ou une sœur.": 'Add a parent first to link a sibling.', + 'Créer « {name} »': 'Create “{name}”', } diff --git a/frontend/src/utils/duplicates.js b/frontend/src/utils/duplicates.js new file mode 100644 index 0000000..e877eae --- /dev/null +++ b/frontend/src/utils/duplicates.js @@ -0,0 +1,14 @@ +// Doublon probable à l'ajout d'une personne (1.5.1) : même prénom, et même nom ou nom de naissance, sans tenir +// compte des accents, de la casse ni des espaces en trop. Trois au plus. +function norm(value) { + return String(value || '').normalize('NFD').replace(/\p{M}/gu, '').trim().toLowerCase().replace(/\s+/g, ' ') +} + +export function findLikelyDuplicates(persons = [], firstName = '', lastName = '') { + const first = norm(firstName) + const last = norm(lastName) + if (!first || !last) return [] + return persons + .filter((p) => !p.ghost && norm(p.firstName) === first && (norm(p.lastName) === last || norm(p.birthName) === last)) + .slice(0, 3) +}