Chercher quelqu'un : créer la personne trouvée, rattachée à un proche

« Ajouter à l'arbre » s'ouvre sur Nouvelle personne : lien (parent, enfant, conjoint,
frère ou sœur) et proche dans l'arbre, par le même chemin qu'« Ajouter quelqu'un ».
Dates, lieux, sexe, source en note, à vérifier. « Déjà dans l'arbre » complète une fiche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 18:15:03 +01:00
1 parent f9fefd83e1
commit 7c9ea3c95e
6 files changed
+134 -12

No files matched your search

+9
View File
@@ -1213,6 +1213,11 @@ function App() {
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
// Personne venue d'une source publique (panneau « Chercher quelqu'un ») : lieux, « à vérifier » et source
...(formData.birthPlace ? { birthPlace: formData.birthPlace } : {}),
...(formData.deathPlace ? { deathPlace: formData.deathPlace } : {}),
...(formData.toVerify ? { toVerify: true } : {}),
...(formData.notes ? { notes: formData.notes } : {}),
},
auth.userAuth.token
)
@@ -1264,11 +1269,13 @@ function App() {
})
await tree.refreshCurrentTreeGraph()
return newPerson
} catch (err) {
setAddPersonError(err.message || t("Erreur lors de l'ajout de la personne"))
} finally {
setAddPersonLoading(false)
}
return null
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.treeId, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps
// Modal session contribution (contributeurs)
@@ -3342,6 +3349,8 @@ function App() {
persons={persons}
canApply={tree.canEditCurrentTree}
onApply={handlePersonSave}
onCreate={handleAddPersonSubmit}
createError={addPersonError}
onClose={() => setSearchPanelVisible(false)}
/>
<AccountPanel
@@ -66,6 +66,9 @@
.sp-picker-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
.sp-picker-list .apn-dropdown-item:disabled { opacity: .5; cursor: default; }
.sp-picker-list .sp-year-off { color: var(--pz-a700); }
.sp-picker-list .apn-dropdown-item.is-selected { background: var(--pz-a50); box-shadow: inset 0 0 0 1px var(--pz-a300); }
.sp-picker .apn-types .pz-tab { font-size: 12.5px; padding: 0 4px; white-space: nowrap; }
.sp-create { display: flex; justify-content: flex-end; }
.sp-links { gap: 10px; }
.sp-links > .pz-label { margin: 0; }
+73 -11
View File
@@ -6,7 +6,7 @@ import PzBusy from './PzBusy.jsx'
import { t, dateLocale } from '../i18n/index.js'
import { fetchSearchSources, searchSource } from '../api/searchSourcesApi.js'
import { SEARCH_LINKS, SEARCH_LINK_REGIONS } from '../data/searchLinks.js'
import { changesFromResult } from '../utils/searchResultChanges.js'
import { changesFromResult, personFromResult } from '../utils/searchResultChanges.js'
import { fullNameLabel } from '../utils/personName.js'
// Ce que chaque source interrogée couvre et ce qui part vers elle : affiché avant qu'on coche
@@ -90,9 +90,23 @@ function withEmphasis(text) {
return <>{before}<em>{emphasized}</em>{after}</>
}
// Mêmes libellés que « Ajouter quelqu'un » : la nouvelle personne est parent, enfant… de quelqu'un de l'arbre
const RELATION_TABS = {
parent: t('Parent'),
child: t('Enfant'),
spouse: t('Conjoint·e'),
sibling: t('Frère·Sœur'),
}
const RELATION_LABELS = {
parent: t('Parent de'),
child: t('Enfant de'),
spouse: t('Conjoint·e de'),
sibling: t('Frère·Sœur de'),
}
const plain = (value) => String(value || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim()
function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = false, onApply, onClose }) {
function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = false, onApply, onCreate, createError = '', onClose }) {
const [lastName, setLastName] = useState('')
const [firstName, setFirstName] = useState('')
const [birthYear, setBirthYear] = useState('')
@@ -179,7 +193,25 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
.slice(0, 6)
}, [picker, persons])
// Le plus souvent, la personne trouvée n'est pas encore dans l'arbre : on la crée, rattachée à un proche
const handleCreate = async () => {
const { key, result, sourceId, relationType, relative } = picker
if (!relative) return
setPicker((prev) => ({ ...prev, saving: true, failed: false }))
const created = await onCreate?.(personFromResult(result, SOURCE_INFO[sourceId].name, { personId: relative.id, type: relationType }))
if (created) {
setApplied((prev) => ({ ...prev, [key]: { name: `${result.firstName} ${result.lastName}`.trim(), created: true } }))
setPicker(null)
} else {
setPicker((prev) => ({ ...prev, saving: false, failed: true }))
}
}
const handlePick = async (person) => {
if (picker.mode === 'new') {
setPicker((prev) => ({ ...prev, relative: person }))
return
}
const { key, result, sourceId } = picker
const changes = changesFromResult(person, result, SOURCE_INFO[sourceId].name)
if (!changes) {
@@ -189,7 +221,7 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
setPicker((prev) => ({ ...prev, saving: true }))
try {
await onApply?.(person.id, changes)
setApplied((prev) => ({ ...prev, [key]: fullNameLabel(person) }))
setApplied((prev) => ({ ...prev, [key]: { name: fullNameLabel(person), created: false } }))
setPicker(null)
} catch {
setPicker((prev) => ({ ...prev, saving: false, failed: true }))
@@ -314,16 +346,33 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
{t('Voir la source')}<ArrowUpRight size={13} aria-hidden="true" />
</a>
{canApply && !applied[key] && (
<button type="button" className={`pz-btn pz-btn--sm ${result.match === 'good' ? 'pz-btn--primary' : 'pz-btn--secondary'}`} onClick={() => setPicker(isPicking ? null : { key, result, sourceId, filter: result.lastName })} aria-expanded={isPicking}>
<button type="button" className={`pz-btn pz-btn--sm ${result.match === 'good' ? 'pz-btn--primary' : 'pz-btn--secondary'}`} onClick={() => setPicker(isPicking ? null : { key, result, sourceId, mode: 'new', relationType: 'parent', relative: null, filter: result.lastName })} aria-expanded={isPicking}>
{t("Ajouter à l'arbre")}
</button>
)}
</div>
{applied[key] && <p className="sp-result-applied">{t('Ajouté à {name}, à vérifier', { name: applied[key] })}</p>}
{applied[key] && (
<p className="sp-result-applied">
{applied[key].created ? t("{name} est dans l'arbre, à vérifier", { name: applied[key].name }) : t('Ajouté à {name}, à vérifier', { name: applied[key].name })}
</p>
)}
{isPicking && (
<div className="sp-picker">
<div className="pz-tabs" role="tablist">
<button type="button" role="tab" aria-selected={picker.mode === 'new'} className={`pz-tab${picker.mode === 'new' ? ' is-active' : ''}`} onClick={() => setPicker((prev) => ({ ...prev, mode: 'new', nothingNew: null, failed: false }))}>{t('Nouvelle personne')}</button>
<button type="button" role="tab" aria-selected={picker.mode === 'existing'} className={`pz-tab${picker.mode === 'existing' ? ' is-active' : ''}`} onClick={() => setPicker((prev) => ({ ...prev, mode: 'existing', failed: false }))}>{t("Déjà dans l'arbre")}</button>
</div>
{picker.mode === 'new' && (
<div className="pz-tabs apn-types" role="radiogroup" aria-label={t('Type de lien')}>
{Object.entries(RELATION_TABS).map(([type, label]) => (
<button key={type} type="button" role="radio" aria-checked={picker.relationType === type} className={`pz-tab${picker.relationType === type ? ' is-active' : ''}`} onClick={() => setPicker((prev) => ({ ...prev, relationType: type }))}>
{label}
</button>
))}
</div>
)}
<div className="pz-field">
<label htmlFor={`sp-pick-${key}`}>{t('À quelle personne de l’arbre ?')}</label>
<label htmlFor={`sp-pick-${key}`}>{picker.mode === 'new' ? RELATION_LABELS[picker.relationType] : t('À quelle personne de l’arbre ?')}</label>
<div className="apn-search">
<Search size={16} className="apn-search-icon" aria-hidden="true" />
<input id={`sp-pick-${key}`} type="text" value={picker.filter} onChange={(e) => setPicker((prev) => ({ ...prev, filter: e.target.value, nothingNew: null, failed: false }))} autoComplete="off" autoFocus />
@@ -333,18 +382,31 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
{candidates.length === 0 && <li className="pz-hint">{t('Personne ne correspond à cette recherche.')}</li>}
{candidates.map(({ person, yearOff }) => (
<li key={person.id}>
<button type="button" className="apn-dropdown-item" onClick={() => handlePick(person)} disabled={picker.saving}>
<button type="button" className={`apn-dropdown-item${picker.relative?.id === person.id ? ' is-selected' : ''}`} aria-pressed={picker.mode === 'new' ? picker.relative?.id === person.id : undefined} onClick={() => handlePick(person)} disabled={picker.saving}>
<span className="apn-avatar" aria-hidden="true">{person.photo ? <img src={person.photo} alt="" /> : (person.firstName || '?').charAt(0)}</span>
<span className="apn-dropdown-name">{person.firstName} <em>{person.lastName}</em></span>
<span className={`apn-dropdown-date${yearOff ? ' sp-year-off' : ''}`} title={yearOff ? t("Pas la même année de naissance que ce résultat") : undefined}>{personYears(person)}</span>
<span className={`apn-dropdown-date${yearOff && picker.mode === 'existing' ? ' sp-year-off' : ''}`} title={yearOff && picker.mode === 'existing' ? t("Pas la même année de naissance que ce résultat") : undefined}>{personYears(person)}</span>
</button>
</li>
))}
</ul>
{candidates.some((candidate) => candidate.yearOff) && <p className="pz-hint">{t("Une année en couleur n'est pas celle de ce résultat.")}</p>}
{picker.mode === 'existing' && candidates.some((candidate) => candidate.yearOff) && <p className="pz-hint">{t("Une année en couleur n'est pas celle de ce résultat.")}</p>}
{picker.nothingNew && <p className="pz-hint">{t('Rien de nouveau pour {name} : ses dates et ses lieux sont déjà remplis.', { name: picker.nothingNew })}</p>}
{picker.failed && <p className="pz-error">{t("L'ajout n'a pas marché. Réessayez.")}</p>}
<p className="pz-hint">{t('Seuls les champs vides sont remplis. La personne passe « à vérifier », et la source va dans sa note.')}</p>
{picker.failed && <p className="pz-error">{(picker.mode === 'new' && createError) || t("L'ajout n'a pas marché. Réessayez.")}</p>}
{picker.mode === 'new' ? (
<>
<div className="sp-create">
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" disabled={!picker.relative || picker.saving} onClick={handleCreate}>
<PzBusy busy={picker.saving}>{t('Créer {name}', { name: result.firstName.split(' ')[0] || result.lastName })}</PzBusy>
</button>
</div>
<p className="pz-hint">{picker.relative
? t('{name}, {link} {relative}. Ses dates, ses lieux et la source sont repris, et la fiche passe « à vérifier ».', { name: `${result.firstName} ${result.lastName}`.trim(), link: RELATION_LABELS[picker.relationType].toLowerCase(), relative: fullNameLabel(picker.relative) })
: t('Choisissez à qui la rattacher dans l’arbre.')}</p>
</>
) : (
<p className="pz-hint">{t('Seuls les champs vides sont remplis. La personne passe « à vérifier », et la source va dans sa note.')}</p>
)}
</div>
)}
</article>
+6
View File
@@ -43,6 +43,12 @@ export default {
'seuls le nom et le prénom sont comparés': 'only the names are compared',
'né(e) vers {year}': 'born around {year}',
'Modifier': 'Edit',
'Nouvelle personne': 'New person',
"Déjà dans l'arbre": 'Already in the tree',
"{name} est dans l'arbre, à vérifier": '{name} is in the tree, needs checking',
'Créer {name}': 'Create {name}',
'{name}, {link} {relative}. Ses dates, ses lieux et la source sont repris, et la fiche passe « à vérifier ».': '{name}, {link} {relative}. Their dates, places and source are copied, and the profile is marked "needs checking".',
'Choisissez à qui la rattacher dans l’arbre.': 'Choose who to link them to in the tree.',
'À comparer': 'Worth comparing',
'Voir la source': 'See the source',
"Ajouter à l'arbre": 'Add to the tree',
+25
View File
@@ -20,3 +20,28 @@ export function changesFromResult(person, result, sourceLabel) {
changes.note = note.includes(result.url) ? note : [note, sourceLine].filter(Boolean).join('\n')
return changes
}
const SEX = { M: 'male', F: 'female' }
// « 1898 » ou « 1898-03 » : la base garde une date complète, l'année seule s'écrit au 1er janvier (comme la fiche)
function toStoredDate(value) {
if (!value) return null
return value.length === 10 ? value : `${value.slice(0, 4)}-01-01`
}
// Nouvelle personne créée depuis un résultat, rattachée à quelqu'un de l'arbre (même forme que « Ajouter quelqu'un »)
export function personFromResult(result, sourceLabel, relation) {
return {
firstName: result.firstName || '',
lastName: result.lastName || '',
sex: SEX[result.sex] || null,
birthDate: toStoredDate(result.birth?.date),
deathDate: toStoredDate(result.death?.date),
birthPlace: result.birth?.place || null,
deathPlace: result.death?.place || null,
isDeceased: false,
toVerify: true,
notes: `${sourceLabel} : ${result.url}`,
relations: relation ? [{ personId: relation.personId, type: relation.type }] : [],
}
}
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { changesFromResult } from './searchResultChanges.js'
import { changesFromResult, personFromResult } from './searchResultChanges.js'
const RESULT = {
id: 'abc123',
@@ -24,6 +24,23 @@ describe('changesFromResult', () => {
expect(changesFromResult(complete, RESULT, 'Décès')).toBeNull()
})
it('crée une nouvelle personne rattachée, à vérifier, avec sa source', () => {
const result = { ...RESULT, firstName: 'Jean Louis', lastName: 'Dupont', sex: 'M', birth: { date: '1898', place: 'Tulle (19)' } }
expect(personFromResult(result, 'Décès', { personId: 'p1', type: 'parent' })).toEqual({
firstName: 'Jean Louis',
lastName: 'Dupont',
sex: 'male',
birthDate: '1898-01-01',
deathDate: '1987-11-08',
birthPlace: 'Tulle (19)',
deathPlace: 'Limoges (87)',
isDeceased: false,
toVerify: true,
notes: 'Décès : https://deces.matchid.io/id/abc123',
relations: [{ personId: 'p1', type: 'parent' }],
})
})
it("n'ajoute pas deux fois la même source à la note", () => {
const person = { note: 'Décès : https://deces.matchid.io/id/abc123' }
expect(changesFromResult(person, RESULT, 'Décès').note).toBe('Décès : https://deces.matchid.io/id/abc123')