diff --git a/backend/prisma/migrations/20261017_004_deces_sans_date/migration.sql b/backend/prisma/migrations/20261017_004_deces_sans_date/migration.sql new file mode 100644 index 0000000..14f6e2c --- /dev/null +++ b/backend/prisma/migrations/20261017_004_deces_sans_date/migration.sql @@ -0,0 +1,2 @@ +-- Personne décédée sans date connue (issue #77) +ALTER TABLE "persons" ADD COLUMN "isDeceased" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4ce8cf7..098b07e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -214,6 +214,8 @@ model Person { birthName String? birthDate DateTime? deathDate DateTime? + // Décédé(e) sans date connue (issue #77) : vrai seulement quand deathDate est vide + isDeceased Boolean @default(false) birthPlace String? // Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … » birthDatePrecision String? diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 4facfe3..126c63f 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -73,6 +73,7 @@ const personCreateSchema = z.object({ birthName: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), + isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), @@ -88,6 +89,7 @@ const personUpdateSchema = z birthName: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), + isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), @@ -378,6 +380,7 @@ async function applyContributionChange( birthName: toTrimmedStringOrNull(parsed.data.birthName), birthDate: toDateOrNull(parsed.data.birthDate), deathDate: toDateOrNull(parsed.data.deathDate), + isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased), birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace), profession: toTrimmedStringOrNull(parsed.data.profession), region: toTrimmedStringOrNull(parsed.data.region), @@ -434,6 +437,7 @@ async function applyContributionChange( birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined, birthDate: nextDates.birthDate, deathDate: nextDates.deathDate, + isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased, birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined, profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined, region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined, @@ -830,6 +834,7 @@ async function snapshotEntity( birthName: true, birthDate: true, deathDate: true, + isDeceased: true, birthPlace: true, profession: true, region: true, diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 4a33083..ed675a9 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -477,6 +477,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { if (deathDate) lines.push(`2 DATE ${deathDate}`) if (person.deathPlace) lines.push(`2 PLAC ${oneLine(person.deathPlace)}`) pushNotesAndSources(lines, 2, deathDetails) + } else if (person.isDeceased) { + // Décédé(e) sans rien d'autre : la forme courte de la norme + lines.push('1 DEAT Y') } // Le champ « profession » s'écrit seulement s'il n'est pas déjà un des métiers datés @@ -692,6 +695,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { sex, birthDate, deathDate, + // « 1 DEAT Y » ou un décès sans date : décédé(e), date inconnue + isDeceased: Boolean(deatNode) && !deathDate, birthPlace, profession, notes, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index dce079b..de1805b 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -89,6 +89,7 @@ const createPersonSchema = z.object({ birthName: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), + isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), @@ -104,6 +105,7 @@ const patchPersonSchema = z birthName: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), + isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), @@ -1310,6 +1312,8 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { birthName: toTrimmedStringOrNull(payload.data.birthName), birthDate: toDateOrNull(payload.data.birthDate), deathDate: toDateOrNull(payload.data.deathDate), + // 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), profession: toTrimmedStringOrNull(payload.data.profession), region: toTrimmedStringOrNull(payload.data.region), @@ -1366,6 +1370,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { birthName?: string | null birthDate?: Date | null deathDate?: Date | null + isDeceased?: boolean birthPlace?: string | null profession?: string | null region?: string | null @@ -1395,6 +1400,14 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.deathDate = toDateOrNull(payload.data.deathDate) } + if (payload.data.isDeceased !== undefined) { + data.isDeceased = payload.data.isDeceased + } + // Avec une date de décès, le drapeau « sans date » n'a plus lieu d'être + if (data.deathDate) { + data.isDeceased = false + } + if (payload.data.birthPlace !== undefined) { data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace) } diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 4ae8370..78a4a04 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -11,6 +11,7 @@ export type GraphPayload = { birthName: string | null birthDate: string | null deathDate: string | null + isDeceased: boolean birthPlace: string | null profession: string | null region: string | null @@ -85,6 +86,7 @@ export function buildGraphPayload( birthName: person.birthName, birthDate: formatDate(person.birthDate), deathDate: formatDate(person.deathDate), + isDeceased: Boolean(person.isDeceased) && !person.deathDate, birthPlace: person.birthPlace, profession: person.profession, region: person.region, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index d603079..5f9b606 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -487,7 +487,7 @@ describe('Graph routes', () => { prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Ragot', rootPersonId: null }) prismaMock.person.findMany.mockResolvedValue([ { ...base, id: 'p-1', firstName: 'Louis', lastName: 'Ragot', sex: 'male', birthDate: new Date('1879-01-01T00:00:00Z'), birthDatePrecision: 'about', birthPlace: 'La Roche-sur-Yon', deathDate: new Date('1950-05-02T00:00:00Z'), deathPlace: 'Nantes', nickname: 'Loulou', profession: 'Instituteur' }, - { ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null }, + { ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null, isDeceased: true }, { ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'male', birthDate: null, birthDatePrecision: null, birthPlace: null }, ]) prismaMock.union.findMany.mockResolvedValue([ @@ -513,6 +513,8 @@ describe('Graph routes', () => { expect(text).toContain('1 OCCU Instituteur\n2 DATE FROM 1900 TO 1935') expect(text.match(/1 OCCU/g)).toHaveLength(1) expect(text).toContain('2 PEDI adopted') + // Décédée sans date (issue #77) : la forme courte de la norme + expect(text).toContain('1 NAME Marie /Bodin/\n1 SEX F\n1 DEAT Y\n') const roots = parseGedcom(text) const records = indexGedcomRecords(roots) @@ -631,6 +633,24 @@ describe('Graph routes', () => { }) }) +describe('Décès sans date (issue #77)', () => { + it('marque décédé sans date, et une date de décès retire le drapeau', 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({ isDeceased: true })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: true }) + + expect((await patch({ deathDate: '1950-01-01', isDeceased: true })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: false, deathDate: new Date('1950-01-01T00:00:00.000Z') }) + + expect((await patch({ isDeceased: false, deathDate: null })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ isDeceased: false, deathDate: null }) + await app.close() + }) +}) + describe('Moments de l’onglet Vie', () => { it('un admin ajoute un moment avec deux souvenirs, le modifie sans changer son type, le supprime', async () => { const app = createApp() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 44334dd..07f2d45 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -241,6 +241,7 @@ function buildContributionChangesFromDraft(draft = {}) { birthName: person.birthName ? String(person.birthName).trim() : null, birthDate: person.birthDate || null, deathDate: person.deathDate || null, + isDeceased: !person.deathDate && Boolean(person.isDeceased), }, }) @@ -289,7 +290,10 @@ function buildContributionChangesFromDraft(draft = {}) { 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 if (Object.prototype.hasOwnProperty.call(source, 'note')) after.notes = source.note || null - if (Object.prototype.hasOwnProperty.call(source, 'isAlive') && source.isAlive === true) after.deathDate = null + if (Object.prototype.hasOwnProperty.call(source, 'isAlive')) { + if (source.isAlive === true) after.deathDate = null + after.isDeceased = source.isAlive === false + } if (Object.keys(after).length > 0) { changes.push({ @@ -1073,6 +1077,7 @@ function App() { birthName: formData.birthName || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, + isDeceased: Boolean(formData.isDeceased), relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })), siblingParentLinks, }, @@ -1098,6 +1103,7 @@ function App() { birthName: formData.birthName, birthDate: formData.birthDate, deathDate: formData.deathDate, + isDeceased: Boolean(formData.isDeceased), }, auth.userAuth.token ) @@ -1230,7 +1236,10 @@ function App() { 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 - if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive') && rawChanges.isAlive === true) payload.deathDate = null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'isAlive')) { + if (rawChanges.isAlive === true) payload.deathDate = null + payload.isDeceased = rawChanges.isAlive === false + } if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null if (Object.keys(payload).length > 0) { @@ -1510,8 +1519,13 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null if (Object.prototype.hasOwnProperty.call(changes, 'note')) localPerson.note = changes.note || null - if (Object.prototype.hasOwnProperty.call(changes, 'isAlive') && changes.isAlive === true) { - localPerson.deathYear = null + if (Object.prototype.hasOwnProperty.call(changes, 'isAlive')) { + if (changes.isAlive === true) { + localPerson.deathYear = null + localPerson.deathDate = null + } + localPerson.isAlive = changes.isAlive === true + localPerson.isDeceased = changes.isAlive === false && !localPerson.deathYear } tree.setSelectedPerson({ ...localPerson }) } @@ -1532,7 +1546,10 @@ function App() { 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 - if (Object.prototype.hasOwnProperty.call(changes, 'isAlive') && changes.isAlive === true) payload.deathDate = null + if (Object.prototype.hasOwnProperty.call(changes, 'isAlive')) { + if (changes.isAlive === true) payload.deathDate = null + payload.isDeceased = changes.isAlive === false + } if (Object.prototype.hasOwnProperty.call(changes, 'note')) payload.notes = changes.note || null if (Object.keys(payload).length > 0) { diff --git a/frontend/src/components/AddPersonPanel.css b/frontend/src/components/AddPersonPanel.css index 9db624c..007f39c 100644 --- a/frontend/src/components/AddPersonPanel.css +++ b/frontend/src/components/AddPersonPanel.css @@ -104,3 +104,10 @@ .add-person-panel, .add-person-panel--open { transform: none; transition: opacity 180ms ease, visibility 0s linear 180ms; } .add-person-panel--open { transition: opacity 180ms ease, visibility 0s; } } + +/* Décédé(e) sans date connue (issue #77) */ +.apn-deceased { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; } +.apn-deceased input { width: 15px; height: 15px; accent-color: var(--pz-a500); } + +/* Colonnes alignées en haut : la case « décédé(e) » sous le décès ne décale pas la naissance */ +.apn-row { align-items: start; } diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index f0f3ef1..b23dc40 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import DateField from './DateField.jsx' import { UserPlus, X, Search, ChevronDown } from 'lucide-react' import './AddPersonPanel.css' import PzBusy from './PzBusy.jsx' @@ -35,6 +36,10 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const [birthName, setBirthName] = useState('') const [birthDate, setBirthDate] = useState('') const [deathDate, setDeathDate] = useState('') + // Décédé(e) sans date connue (issue #77) + const [isDeceased, setIsDeceased] = 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([]) const [relationSearch, setRelationSearch] = useState('') const [relationDropdownOpen, setRelationDropdownOpen] = useState(false) @@ -88,6 +93,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on setBirthName('') setBirthDate('') setDeathDate('') + setIsDeceased(false) + setDateFieldsKey((k) => k + 1) setRelations([]) setTouched({}) setJustAdded(false) @@ -134,6 +141,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on birthName: birthName.trim() || null, birthDate: birthDate || null, deathDate: deathDate || null, + isDeceased: !deathDate && isDeceased, relations, }) } @@ -173,7 +181,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on