diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 928c2b3..b79eab4 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -77,6 +77,7 @@ const personCreateSchema = z.object({ deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -94,6 +95,7 @@ const personUpdateSchema = z deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -386,6 +388,7 @@ async function applyContributionChange( deathDate: toDateOrNull(parsed.data.deathDate), isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased), birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace), + deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace), profession: toTrimmedStringOrNull(parsed.data.profession), region: toTrimmedStringOrNull(parsed.data.region), nationality: toTrimmedStringOrNull(parsed.data.nationality), @@ -454,6 +457,7 @@ async function applyContributionChange( deathDate: nextDates.deathDate, isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased, birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined, + deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined, profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined, region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined, nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined, @@ -852,6 +856,7 @@ async function snapshotEntity( deathDate: true, isDeceased: true, birthPlace: true, + deathPlace: true, profession: true, region: true, nationality: true, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index c95ab95..a549262 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -93,6 +93,7 @@ const createPersonSchema = z.object({ deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -110,6 +111,7 @@ const patchPersonSchema = z deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), @@ -1319,6 +1321,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { // Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased), birthPlace: toTrimmedStringOrNull(payload.data.birthPlace), + deathPlace: toTrimmedStringOrNull(payload.data.deathPlace), profession: toTrimmedStringOrNull(payload.data.profession), region: toTrimmedStringOrNull(payload.data.region), nationality: toTrimmedStringOrNull(payload.data.nationality), @@ -1388,6 +1391,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { deathDate?: Date | null isDeceased?: boolean birthPlace?: string | null + deathPlace?: string | null profession?: string | null region?: string | null nationality?: string | null @@ -1432,6 +1436,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace) } + if (payload.data.deathPlace !== undefined) { + data.deathPlace = toTrimmedStringOrNull(payload.data.deathPlace) + } + if (payload.data.profession !== undefined) { data.profession = toTrimmedStringOrNull(payload.data.profession) } diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 86c83bb..baf6a97 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -14,6 +14,7 @@ export type GraphPayload = { deathDate: string | null isDeceased: boolean birthPlace: string | null + deathPlace: string | null profession: string | null region: string | null nationality: string | null @@ -90,6 +91,7 @@ export function buildGraphPayload( deathDate: formatDate(person.deathDate), isDeceased: Boolean(person.isDeceased) && !person.deathDate, birthPlace: person.birthPlace, + deathPlace: person.deathPlace, profession: person.profession, region: person.region, nationality: person.nationality, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index e1e58ab..8fa3317 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -1008,3 +1008,29 @@ describe('Surnom (nickname) en écriture directe', () => { await app.close() }) }) + +describe('Lieu de décès et filiation adoptive (écriture)', () => { + it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => { + const app = createApp() + const token = await createUserToken(app) + const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload }) + + expect((await patch({ deathPlace: ' Nantes, France ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: 'Nantes, France' }) + + expect((await patch({ deathPlace: ' ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: null }) + await app.close() + }) + + it('PATCH d’un lien accepte le type de filiation (naissance ou adoption)', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: null, parentageType: 'biologique' }) + prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 1 }) + const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/links/link-1', headers: { authorization: `Bearer ${token}` }, payload: { parentageType: 'adoption' } }) + expect(res.statusCode).toBe(200) + expect(prismaMock.parentChildLink.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ parentageType: 'adoption' }) + await app.close() + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0023e65..263706d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -26,6 +26,7 @@ import { ExpandableSearch } from './components/search' import { t, dateLocale } from './i18n/index.js' import { createParentChildLink, + updateParentChildLink, deleteParentChildLink, deletePerson, deleteUnion, @@ -257,9 +258,9 @@ function buildContributionChangesFromDraft(draft = {}) { const relatedId = String(rel.personId) if (rel.type === 'child') { linkedParentIds.add(relatedId) - changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } }) + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: rel.adopted ? 'adoption' : 'biologique' } }) } else if (rel.type === 'parent') { - changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } }) + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: rel.adopted ? 'adoption' : 'biologique' } }) } else if (rel.type === 'spouse') { changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } }) } @@ -276,6 +277,16 @@ function buildContributionChangesFromDraft(draft = {}) { } } + // Lien avec ses parents basculé (naissance / adoption) sur un lien existant + const parentageChanges = draft.parentageChanges && typeof draft.parentageChanges === 'object' ? draft.parentageChanges : {} + for (const [childId, parentageType] of Object.entries(parentageChanges)) { + for (const filiation of filiations) { + if (String(filiation.childId) !== String(childId) || String(filiation.id).startsWith('local-')) continue + if ((filiation.parentageType || 'biologique') === parentageType) continue + changes.push({ entityType: 'parent_child_link', action: 'update', entityId: String(filiation.id), after: { parentageType } }) + } + } + const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object' ? draft.modifiedPersons : {} @@ -291,6 +302,7 @@ function buildContributionChangesFromDraft(draft = {}) { if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear) if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear) if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'deathPlace')) after.deathPlace = (source.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null @@ -1040,7 +1052,7 @@ function App() { childId: rel.personId, parentId: personId, unionId: null, - parentageType: 'biologique', + parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: filiations.length + 1, }) } else if (rel.type === 'child') { @@ -1049,7 +1061,7 @@ function App() { childId: personId, parentId: rel.personId, unionId: null, - parentageType: 'biologique', + parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: filiations.length + 1, }) } @@ -1089,7 +1101,7 @@ function App() { birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, isDeceased: Boolean(formData.isDeceased), - relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })), + relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })), siblingParentLinks, }, ] @@ -1127,13 +1139,13 @@ function App() { linkedParentIds.add(String(rel.personId)) await createParentChildLink( tree.treeContext.treeId, - { parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 }, + { parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 }, auth.userAuth.token ) } else if (rel.type === 'parent') { await createParentChildLink( tree.treeContext.treeId, - { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: 'biologique', displayOrder: 1 }, + { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 }, auth.userAuth.token ) } else if (rel.type === 'spouse') { @@ -1246,6 +1258,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathPlace')) payload.deathPlace = (rawChanges.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null @@ -1529,6 +1542,7 @@ function App() { localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null } if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null + if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) localPerson.deathPlace = changes.deathPlace || null if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null @@ -1558,6 +1572,7 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear) if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear) if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) payload.deathPlace = (changes.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null @@ -1807,6 +1822,48 @@ function App() { } }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps + // Choix en attente d'un contributeur (brouillon), affiché sur la fiche avant l'envoi + const [pendingParentage, setPendingParentage] = useState({}) + useEffect(() => { if (!editMode.isActive) setPendingParentage({}) }, [editMode.isActive]) // eslint-disable-line react-hooks/exhaustive-deps + + // Lien avec ses parents : naissance ou adoption, pour tous les liens parent de la personne + const handleSetParentage = useCallback(async (childPersonId, parentageType) => { + const childLinks = filiations.filter((candidate) => String(candidate.childId) === String(childPersonId)) + if (childLinks.length === 0) return + + if (isDemoMode) { + for (const link of childLinks) link.parentageType = parentageType + tree.bumpGraphRevision() + setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' }) + return + } + + if (!tree.canEditCurrentTree) { + const draft = editMode.restoreDraft() + const parentageChanges = { ...(draft.parentageChanges || {}), [String(childPersonId)]: parentageType } + editMode.updateDraft({ parentageChanges }) + setPendingParentage((prev) => ({ ...prev, [String(childPersonId)]: parentageType })) + setRelationFeedback({ loading: false, success: t('Modification ajoutée à la contribution'), error: '' }) + return + } + + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + setRelationFeedback({ loading: true, success: '', error: '' }) + try { + for (const link of childLinks) { + if (String(link.id).startsWith('local-') || (link.parentageType || 'biologique') === parentageType) continue + await updateParentChildLink(tree.treeContext.treeId, link.id, { parentageType }, accessToken) + } + await refreshTreeAndKeepSelection(childPersonId, { relayout: true }) + setRelationFeedback({ loading: false, success: t('Lien modifié'), error: '' }) + } catch (err) { + setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) }) + console.error('Failed to update parentage:', err) + } + }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps + // Suppression d'une relation (mode édition) const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => { if (isDemoMode) { @@ -3038,6 +3095,8 @@ function App() { onDelete={handlePersonDelete} onAddRelation={handleAddRelation} onRemoveRelation={handleRemoveRelation} + onSetParentage={handleSetParentage} + pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null} relationActionLoading={relationFeedback.loading} relationActionSuccess={relationFeedback.success} relationActionError={relationFeedback.error} diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 84a7b8e..873028d 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -235,6 +235,19 @@ export async function reorderSiblings(treeId, order, token) { }) } +// Lien parent-enfant modifié (type de filiation : naissance ou adoption) +export async function updateParentChildLink(treeId, linkId, payload, token) { + const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }) + + return data.link +} + export async function deleteParentChildLink(treeId, linkId, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, { method: 'DELETE', diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index b949392..36e5293 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -42,6 +42,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const [deathDate, setDeathDate] = useState('') // Décédé(e) sans date connue (issue #77) const [isDeceased, setIsDeceased] = useState(false) + // Lien d'enfant ou de parent : la personne qui est l'enfant du lien a été adoptée + const [adopted, setAdopted] = useState(false) // Les champs de date gardent leur texte : on les recrée après chaque ajout const [dateFieldsKey, setDateFieldsKey] = useState(0) const [relations, setRelations] = useState([]) @@ -157,7 +159,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on birthDate: birthDate || null, deathDate: deathDate || null, isDeceased: !deathDate && isDeceased, - relations, + // Adopté(e) : s'applique aux liens d'enfant ou de parent de cette fiche + relations: relations.map((rel) => ({ ...rel, adopted: adopted && (rel.type === 'child' || rel.type === 'parent') })), }) } @@ -341,6 +344,13 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on )} + {(selectedRelationType === 'child' || selectedRelationType === 'parent') && ( + + )} + {relations.length > 0 && (
{relations.map((rel) => ( diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index bae4c0c..a13a8b9 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -76,6 +76,7 @@ const PERSON_FIELD_LABELS = { nickname: t('Surnom'), birthDate: t('Date de naissance'), deathDate: t('Date de décès'), + deathPlace: t('Lieu de décès'), birthPlace: t('Lieu de naissance'), profession: t('Métier'), nationality: t('Nationalité'), diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index 2e495b1..aff0ddf 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -256,3 +256,19 @@ .pc-unknown-first { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; } .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); } + +/* 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; } +.pc-fill-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px dashed var(--pz-a300); background: var(--pz-a50); color: var(--pz-a700); font: 500 13px var(--pz-sans); cursor: pointer; } +@media (hover: hover) and (pointer: fine) { .pc-fill-chip:hover { background: var(--pz-a100); border-color: var(--pz-a500); } } +.pc-fill-chip:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; } + +/* Liens : parents adoptifs, réglage de la filiation */ +.pc-section-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px; } +.pc-adopt-tag { font: 500 12px var(--pz-sans); color: var(--pz-a700); background: var(--pz-a50); border: 1px solid var(--pz-a300); border-radius: 999px; padding: 1px 9px; } +.pc-parentage { display: grid; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--pz-line-soft); } +.pc-seg { width: 100%; } +.pc-seg .pz-tab { flex: 1; } + +/* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */ +.pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); } diff --git a/frontend/src/components/PersonCard.infos.test.jsx b/frontend/src/components/PersonCard.infos.test.jsx new file mode 100644 index 0000000..ce4a5b0 --- /dev/null +++ b/frontend/src/components/PersonCard.infos.test.jsx @@ -0,0 +1,55 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import PersonCard from './PersonCard.jsx' + +// Onglet Infos : en modification, seuls les champs remplis s'affichent ; les vides sont des pastilles +const sparse = { + id: 'p-sparse', + firstName: 'Marie', + lastName: 'Martin', + nickname: 'Mamie Mimi', + isAlive: false, + birthDate: null, + deathDate: null, +} + +const render = (person, editMode) => renderToStaticMarkup( + {}} />, +) + +describe('PersonCard, onglet Infos', () => { + it('en modification, une pastille par champ vide et le champ rempli reste affiché', () => { + const html = render(sparse, true) + expect(html).toContain('+ Date de naissance') + expect(html).toContain('+ Lieu de naissance') + expect(html).toContain('+ Nom de naissance') + expect(html).toContain('+ Date de décès') + expect(html).toContain('+ Lieu de décès') + expect(html).toContain('+ Métier') + expect(html).toContain('+ Région') + expect(html).toContain('+ Nationalité') + expect(html).toContain('+ Notes') + // Le surnom est rempli : il n'a pas de pastille, il a son champ + expect(html).not.toContain('+ Surnom') + expect(html).toContain('Mamie Mimi') + }) + + it('une personne vivante n’a ni pastille de décès ni champ de décès', () => { + const html = render({ ...sparse, isAlive: true, nickname: null }, true) + expect(html).not.toContain('+ Date de décès') + expect(html).not.toContain('+ Lieu de décès') + expect(html).toContain('+ Surnom') + }) + + it('en lecture, pas de pastille, et le lieu de décès s’affiche quand il existe', () => { + const html = render({ ...sparse, deathPlace: 'Nantes, France', deathDate: '1970-03-02' }, false) + expect(html).not.toContain('pc-fill-chip') + expect(html).toContain('Lieu de décès') + expect(html).toContain('Nantes') + }) + + it('en lecture, le lieu de décès absent n’a pas de ligne', () => { + const html = render(sparse, false) + expect(html).not.toContain('Lieu de décès') + }) +}) diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 09df9d4..7f3aeaa 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -58,6 +58,8 @@ const PersonCard = ({ onDelete, onAddRelation, onRemoveRelation, + onSetParentage, + pendingParentage = null, relationActionLoading = false, relationActionSuccess = '', relationActionError = '', @@ -124,6 +126,10 @@ const PersonCard = ({ const [relationSearchQuery, setRelationSearchQuery] = useState('') const relationSearchRef = useRef(null) const avatarInputRef = useRef(null) + // Infos en modification : champs ouverts depuis une pastille (et leur focus) + const [openedFields, setOpenedFields] = useState([]) + const [focusField, setFocusField] = useState(null) + const bodyRef = useRef(null) // Reset edits quand la personne change useEffect(() => { @@ -138,8 +144,18 @@ const PersonCard = ({ setShowSourceMenu(false) setMomentSheet(null) setLifeNotice('') + setOpenedFields([]) + setFocusField(null) }, [person.id]) + // Pastille cliquée : le champ s'ouvre à sa place et prend le focus + useEffect(() => { + if (!focusField) return + const target = bodyRef.current?.querySelector(`[data-pc-field="${focusField}"] input, [data-pc-field="${focusField}"] textarea`) + target?.focus() + setFocusField(null) + }, [focusField]) + // Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se // traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source. // Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur. @@ -215,6 +231,42 @@ const PersonCard = ({ })) } + // Onglet Infos en modification : seuls les champs remplis (ou ouverts) s'affichent, le reste est en pastilles + const isFilled = (value) => value !== null && value !== undefined && String(value).trim() !== '' + const openedHas = (field) => openedFields.includes(field) + const openField = (field) => { + setOpenedFields((prev) => (prev.includes(field) ? prev : [...prev, field])) + setFocusField(field) + } + const birthFilled = isFilled(getEditValue('birthYear', person.birthDate || person.birthYear)) + const deathFilled = isFilled(getEditValue('deathYear', person.deathDate || person.deathYear)) + const deceasedNow = editMode ? !getEditValue('isAlive', person.isAlive) : !person.isAlive + const notesShown = editMode && (isFilled(getEditValue('note', person.note)) || openedHas('note')) + // En lecture : toujours visible si demandé (naissance, décès d'une personne décédée), sinon si rempli + const infoRow = (field, label, filled, content, always = false) => { + const shown = editMode ? (filled || openedHas(field)) : (always || filled) + if (!shown) return null + return ( +
+ {label} + {content} +
+ ) + } + const chipCandidates = [ + { field: 'nickname', label: t('Surnom'), filled: isFilled(getEditValue('nickname', person.nickname)), relevant: true }, + { field: 'birthYear', label: t('Date de naissance'), filled: birthFilled, relevant: true }, + { field: 'birthPlace', label: t('Lieu de naissance'), filled: isFilled(getEditValue('birthPlace', person.birthPlace)), relevant: true }, + { field: 'deathYear', label: t('Date de décès'), filled: deathFilled, relevant: deceasedNow }, + { field: 'deathPlace', label: t('Lieu de décès'), filled: isFilled(getEditValue('deathPlace', person.deathPlace)), relevant: deceasedNow }, + { field: 'birthName', label: t('Nom de naissance'), filled: isFilled(getEditValue('birthName', person.birthName)), relevant: true }, + { field: 'profession', label: t('Métier'), filled: isFilled(getEditValue('profession', person.profession)), relevant: true }, + { field: 'region', label: t('Région'), filled: isFilled(getEditValue('region', person.region)), relevant: true }, + { field: 'nationality', label: t('Nationalité'), filled: isFilled(getEditValue('nationality', person.nationality)), relevant: true }, + { field: 'note', label: t('Notes'), filled: isFilled(getEditValue('note', person.note)), relevant: true }, + ] + const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : [] + const handleSave = async () => { if (!hasChanges || !onSave) return if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) { @@ -231,6 +283,7 @@ const PersonCard = ({ const savedFor = person.id await onSave(savedFor, edits) setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev)) + setOpenedFields([]) setSaveSuccess(true) setTimeout(() => setSaveSuccess(false), 2000) } finally { @@ -302,6 +355,18 @@ const PersonCard = ({ return childFiliations[0] })() const personMedias = getPersonMedias(person.id) + // Filiation d'adoption entre un enfant et un parent (lien direct ou via une union) + const parentageAdopted = (childId, parentId) => filiations.some((f) => ( + String(f.childId) === String(childId) + && f.parentageType === 'adoption' + && (String(f.parentId) === String(parentId) || (f.unionId && personUnions.some((u) => String(u.id) === String(f.unionId)))) + )) + // Un contributeur voit son choix en attente avant l'envoi ; sinon le lien enregistré + const parentsAdopted = pendingParentage ? pendingParentage === 'adoption' : filiations.some((f) => String(f.childId) === String(person.id) && f.parentageType === 'adoption') + const adoptedChildLabel = (child) => { + if (!parentageAdopted(child.id, person.id)) return null + return child.sex === 'female' ? t('adoptée') : t('adopté') + } // Frise de vie : la famille vient de l'arbre déjà chargé, les événements de la route /life, // demandée seulement à l'ouverture de l'onglet (la démo a ses propres données) @@ -733,32 +798,14 @@ const PersonCard = ({ {/* ── Infos ── */} {tab === 'infos' && ( -
+

{t('État civil')}

-
- {t('Naissance')} - {renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' })} -
- {(person.birthPlace || editMode) && ( -
- {t('Lieu')} - {renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace })} -
- )} - {(person.birthName || editMode) && ( -
- {t('Nom de naissance')} - {renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') })} -
- )} - {(person.nickname || editMode) && ( -
- {t('Surnom')} - {renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') })} -
- )} + {infoRow('birthYear', t('Naissance'), birthFilled, renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' }), true)} + {infoRow('birthPlace', t('Lieu'), isFilled(getEditValue('birthPlace', person.birthPlace)), renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace }))} + {infoRow('birthName', t('Nom de naissance'), isFilled(getEditValue('birthName', person.birthName)), renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') }))} + {infoRow('nickname', t('Surnom'), isFilled(getEditValue('nickname', person.nickname)), renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') }))} {editMode && (
{t('Statut')} @@ -772,37 +819,28 @@ const PersonCard = ({
)} - {(!getEditValue('isAlive', person.isAlive) || (!editMode && !person.isAlive)) && ( -
- {t('Décès')} - {renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') })} -
- )} + {infoRow('deathYear', t('Décès'), deathFilled, renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') }), deceasedNow)} + {infoRow('deathPlace', t('Lieu de décès'), isFilled(getEditValue('deathPlace', person.deathPlace)), renderEditableValue({ field: 'deathPlace', value: person.deathPlace || '', placeholder: t('Lieu de décès'), format: formatPlace }))} {!editMode && (
{t('Âge')} {ageLabel || '-'}
)} - {(person.region || editMode) && ( -
- {t('Région')} - {renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') })} -
- )} - {(person.nationality || editMode) && ( -
- {t('Nationalité')} - {renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') })} -
- )} - {(person.profession || editMode) && ( -
- {t('Métier')} - {renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') })} -
- )} + {infoRow('region', t('Région'), isFilled(getEditValue('region', person.region)), renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') }))} + {infoRow('nationality', t('Nationalité'), isFilled(getEditValue('nationality', person.nationality)), renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') }))} + {infoRow('profession', t('Métier'), isFilled(getEditValue('profession', person.profession)), renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') }))}
+ {/* Modification : une pastille par champ vide, qui ouvre le champ à sa place */} + {editMode && emptyChips.length > 0 && ( +
+ {emptyChips.map((chip) => ( + + ))} +
+ )}
{!editMode && (parentsList.length > 0 || personUnions.length > 0 || childrenList.length > 0) && ( @@ -844,8 +882,8 @@ const PersonCard = ({ )} - {(person.note || editMode) && ( -
+ {(editMode ? notesShown : person.note) && ( +

{t('Notes')}

{editMode ? (