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 && (
{t('Aucun enfant')}
} + {childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) :{t('Aucun enfant')}
} {renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}