Ajouter un proche depuis la fiche : créer depuis la recherche d'un lien, frère ou sœur, « + » en lecture, panneau prérempli, alerte de doublon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 20:20:27 +01:00
1 parent 6e42a1f12d
commit 5ebfa8337d
8 files changed
+216 -6

No files matched your search

+19
View File
@@ -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) */}
<ContributionSessionModal
@@ -3390,6 +3408,7 @@ function App() {
onRemoveRelation={handleRemoveRelation}
onSetParentage={handleSetParentage}
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
onCreateRelative={tree.canEditCurrentTree || tree.canSubmitContribution || isDemoMode ? handleCreateRelative : null}
proposal={ghosts?.patches.get(String(tree.selectedPerson.id)) || null}
proposalReview={userRole.isAdmin && ghosts?.patches.get(String(tree.selectedPerson.id))?.ghost?.source === 'pending'
? reviewFromTree(ghosts.patches.get(String(tree.selectedPerson.id)).ghost.sessionId)
@@ -120,3 +120,8 @@
/* Titre de section en casse normale (pas de sur-titre en capitales dans le panneau d'ajout) */
.apn-section-title { font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); margin: 0 0 10px; }
/* Doublon probable (1.5.1) : une personne du même nom est déjà dans l'arbre */
.apn-duplicate { display: grid; gap: 6px; padding: 10px 12px; border: 1px solid var(--pz-a200); border-radius: 10px; background: var(--pz-a50); }
.apn-duplicate-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0; font: 500 13.5px var(--pz-sans); color: var(--pz-ink); }
.apn-duplicate-hint { margin: 0; font: 400 12.5px var(--pz-sans); color: var(--pz-muted); }
+38 -1
View File
@@ -7,6 +7,7 @@ import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import { SEX_CHOICES } from '../utils/personSex.js'
import { initialOf } from '../utils/initial.js'
import { findLikelyDuplicates } from '../utils/duplicates.js'
// Titre traduit d'un bloc : le passage entre <em> et </em> 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
<p className="apn-error-text">{t('Il faut au moins un prénom.')}</p>
)}
{lastNameMissing && <p className="apn-error-text">{t('Il faut au moins un nom si le prénom est inconnu.')}</p>}
{duplicates.length > 0 && (
<div className="apn-duplicate" role="status">
{duplicates.map((p) => (
<p key={p.id} className="apn-duplicate-line">
<span>{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() })}</span>
{onOpenPerson && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onOpenPerson(p)}>{t('Ouvrir sa fiche')}</button>
)}
</p>
))}
<p className="apn-duplicate-hint">{t('Si c’est une autre personne, ajoutez-la quand même.')}</p>
</div>
)}
<div className="pz-field">
<label htmlFor="ap-birthName">{t('Nom de naissance')} <span className="apn-optional">{t('si différent')}</span></label>
+7
View File
@@ -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; }
+47 -5
View File
@@ -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) => (
<div key={`${relationType}-${target?.id}`} className="pc-rel-row">
<span className="pc-rel-avatar" aria-hidden="true">
{target?.photo ? <img src={target.photo} alt="" /> : <span>{target?.firstName?.[0] || '?'}</span>}
</span>
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
{meta && <span className="pc-rel-meta">{meta}</span>}
<button
{removable && <button
type="button"
className="pz-btn pz-btn--ghost pz-btn--icon pc-rel-remove"
aria-label={t('Retirer {name}', { name: target ? fullNameLabel(target) : '' })}
@@ -632,7 +635,7 @@ const PersonCard = ({
disabled={relationActionLoading}
>
<X size={15} strokeWidth={2} />
</button>
</button>}
</div>
)
@@ -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')}
/>
) : (
<button type="button" className="pc-rel-add" onClick={() => setActiveRelationSearch(relationType)} disabled={relationActionLoading}>
@@ -1046,6 +1052,18 @@ const PersonCard = ({
</section>
)}
{/* Ajouter un proche sans passer par « Modifier » puis l'onglet Relations (1.5.1) */}
{!editMode && onCreateRelative && (
<div className="pc-quick-add" role="group" aria-label={t('Ajouter un proche')}>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('parent', '')}><Plus size={14} strokeWidth={2} />{t('Parent')}</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('spouse', '')}><Plus size={14} strokeWidth={2} />{t('Conjoint·e')}</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('child', '')}><Plus size={14} strokeWidth={2} />{t('Enfant')}</button>
{parentsList.length > 0 && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('sibling', '')}><Plus size={14} strokeWidth={2} />{t('Frère ou sœur')}</button>
)}
</div>
)}
{(editMode ? notesShown : person.note) && (
<section className="pc-section" data-pc-field="note">
<h3 className="pz-eyebrow">{t('Notes')}</h3>
@@ -1107,6 +1125,24 @@ const PersonCard = ({
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
{renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}
</section>
{onCreateRelative && (
<section className="pc-section">
<h3 className="pz-eyebrow">{t('Frères et sœurs')}</h3>
{siblingsList.length > 0
? siblingsList.map(c => renderRelationRow(c, 'sibling', null, false))
: <p className="pc-rel-empty">{t('Aucun frère ni sœur')}</p>}
{/* 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 ? (
<button type="button" className="pc-rel-add" onClick={() => onCreateRelative('sibling', '')}>
<Plus size={15} strokeWidth={2} />
{t('Ajouter un frère ou une sœur')}
</button>
) : (
<p className="pc-rel-empty">{t("Ajoutez d'abord un parent pour lui rattacher un frère ou une sœur.")}</p>
)}
</section>
)}
</div>
)}
</div>
@@ -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(
</li>
))}
</ul>
) : query ? (
) : query && !onCreate ? (
<p className="pc-rel-empty">{t('Aucune personne ne correspond')}</p>
) : null}
{onCreate && query.trim() && (
<button type="button" className="pc-search-item pc-search-create" onClick={() => onCreate(query.trim())}>
<span className="pc-rel-avatar" aria-hidden="true"><Plus size={14} strokeWidth={2.2} /></span>
<span className="pc-rel-name">{t('Créer « {name} »', { name: query.trim() })} <em>{createLabel}</em></span>
</button>
)}
</div>
)
})
@@ -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(<ProposalCard person={ghostPerson('pending')} summary="1 personne, 1 lien" review={review} onClose={noop} />)
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(<ProposalCard person={ghostPerson('sent')} review={review} onClose={noop} />)
expect(html).toContain('en attente de relecture')
expect(html).not.toContain('Accepter')
expect(renderToStaticMarkup(<ProposalCard person={ghostPerson('draft')} onClose={noop} />)).toContain('Pas encore envoyé')
})
it('le bandeau d’une fiche existante liste les champs proposés', () => {
const html = renderToStaticMarkup(<ProposalBanner patch={{ fields: { birthPlace: 'Lille', deathDate: '1950-01-01' }, ghost: { source: 'pending', label: 'Cousine' } }} review={review} />)
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(<PersonCard person={person} onSave={noop} onCreateRelative={noop} />)
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(<PersonCard person={person} onSave={noop} />)).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([])
})
})
+16
View File
@@ -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}”',
}
+14
View File
@@ -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)
}