diff --git a/backend/src/lib/person-sex.ts b/backend/src/lib/person-sex.ts index 632ea9e..20a9a45 100644 --- a/backend/src/lib/person-sex.ts +++ b/backend/src/lib/person-sex.ts @@ -1,12 +1,13 @@ import { z } from 'zod' -// Sexe d'une personne : 'male', 'female', ou null (non précisé). L'import GEDCOM écrit 'male'/'female', -// d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est ramené à ces deux valeurs, à l'écriture -// comme à la lecture, sans migration des lignes existantes. -export type PersonSex = 'male' | 'female' +// Sexe d'une personne : 'male', 'female', 'other' (autre), ou null (non précisé). L'import GEDCOM écrit +// 'male'/'female'/'other' (SEX X de GEDCOM 7), d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est +// ramené à ces valeurs, à l'écriture comme à la lecture, sans migration des lignes existantes. +export type PersonSex = 'male' | 'female' | 'other' const MALE = new Set(['male', 'm', 'homme', 'h']) const FEMALE = new Set(['female', 'f', 'femme']) +const OTHER = new Set(['other', 'x', 'autre']) const UNSPECIFIED = new Set(['', 'u', 'unknown', 'unspecified']) // undefined : valeur que l'on ne sait pas lire (refusée à l'écriture) @@ -15,6 +16,7 @@ export function parseSex(value: string | null): PersonSex | null | undefined { const key = value.trim().toLowerCase() if (MALE.has(key)) return 'male' if (FEMALE.has(key)) return 'female' + if (OTHER.has(key)) return 'other' if (UNSPECIFIED.has(key)) return null return undefined } @@ -24,7 +26,7 @@ export function sexOrNull(value: string | null | undefined): PersonSex | null { return parseSex(value ?? null) ?? null } -// Champ « sex » des schémas d'écriture : facultatif, null pour « non précisé », valeur ramenée à male/female +// Champ « sex » des schémas d'écriture : facultatif, null pour « non précisé », valeur ramenée à male/female/other export const sexSchema = z .string() .max(20) diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 21111ee..02c691d 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -459,6 +459,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const sex = sexOrNull(person.sex) if (sex === 'male') lines.push('1 SEX M') else if (sex === 'female') lines.push('1 SEX F') + else if (sex === 'other') lines.push('1 SEX X') const personEvents = eventsByPerson.get(person.id) ?? [] const birthDetails = personEvents.find((event) => event.type === 'birth') diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 3ec27e2..02ea53f 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -893,6 +893,61 @@ describe('Import GEDCOM', () => { }) }) +describe('Sexe dans le GEDCOM (issue #114)', () => { + const importer = async (app: FastifyInstance, content: string) => { + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + prismaMock.person.findMany.mockResolvedValue([]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` })) + prismaMock.person.create.mockClear() + const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } }) + expect(response.statusCode).toBe(200) + return Object.fromEntries(prismaMock.person.create.mock.calls.map((call: [{ data: { firstName: string; sex: string | null } }]) => [call[0].data.firstName, call[0].data.sex])) + } + + it('import : M, F, X (GEDCOM 7), U et absent donnent male, female, other, null, null', async () => { + const app = createApp() + const content = [ + '0 HEAD', + '0 @I1@ INDI', '1 NAME Jean /Martin/', '1 SEX M', + '0 @I2@ INDI', '1 NAME Marie /Martin/', '1 SEX F', + '0 @I3@ INDI', '1 NAME Camille /Martin/', '1 SEX X', + '0 @I4@ INDI', '1 NAME Dominique /Martin/', '1 SEX U', + '0 @I5@ INDI', '1 NAME Claude /Martin/', + '0 TRLR', + ].join('\n') + expect(await importer(app, content)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Dominique: null, Claude: null }) + await app.close() + }) + + it('aller-retour : other sort en SEX X, non précisé sans ligne SEX, et tout se relit à l’identique', async () => { + const app = createApp() + await app.ready() + const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() }) + const base = { lastName: 'Martin', birthDate: null, birthDatePrecision: null, birthPlace: null, birthDateEnd: null, deathDateEnd: null, deathDatePrecision: null, deathPlace: null, nickname: null, notes: null, profession: null, deathDate: null } + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Martin', rootPersonId: null }) + prismaMock.person.findMany.mockResolvedValue([ + { ...base, id: 'p-1', firstName: 'Jean', sex: 'male' }, + { ...base, id: 'p-2', firstName: 'Marie', sex: 'female' }, + { ...base, id: 'p-3', firstName: 'Camille', sex: 'other' }, + { ...base, id: 'p-4', firstName: 'Claude', sex: null }, + ]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.lifeEvent.findMany.mockResolvedValue([]) + + const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + const text = response.body.replace(/\r\n/g, '\n') + expect(text).toContain('1 NAME Camille /Martin/\n1 SEX X\n') + expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/) + + expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Claude: null }) + await app.close() + }) +}) + describe('Visit log', () => { it('refuses the visit log to a non-admin member', async () => { const app = createApp() @@ -1150,7 +1205,7 @@ describe('Surnom (nickname) en écriture directe', () => { }) describe('Sexe d’une personne en écriture directe (issue #114)', () => { - it('PATCH ramène le sexe à male / female, null = non précisé, refuse une valeur inconnue', async () => { + it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', 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 }) @@ -1161,6 +1216,9 @@ describe('Sexe d’une personne en écriture directe (issue #114)', () => { expect((await patch({ sex: 'male' })).statusCode).toBe(200) expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' }) + expect((await patch({ sex: 'other' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'other' }) + expect((await patch({ sex: null })).statusCode).toBe(200) expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: null }) diff --git a/backend/test/person-sex.test.ts b/backend/test/person-sex.test.ts index 1efcaa0..51c27bd 100644 --- a/backend/test/person-sex.test.ts +++ b/backend/test/person-sex.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { parseSex, sexOrNull, sexSchema } from '../src/lib/person-sex' -// Sexe d'une personne (issue #114) : male, female ou null, anciennes valeurs M/F relues +// Sexe d'une personne (issue #114) : male, female, other ou null, anciennes valeurs M/F relues describe('sexe d’une personne', () => { it('ramène les écritures connues à male / female / null', () => { expect(parseSex('male')).toBe('male') @@ -14,6 +14,9 @@ describe('sexe d’une personne', () => { expect(parseSex(null)).toBeNull() expect(parseSex('')).toBeNull() expect(parseSex('U')).toBeNull() + expect(parseSex('other')).toBe('other') + expect(parseSex('X')).toBe('other') + expect(parseSex('Autre')).toBe('other') expect(parseSex('robot')).toBeUndefined() }) diff --git a/frontend/src/components/AddPersonPanel.css b/frontend/src/components/AddPersonPanel.css index 3d1689e..1bf4f76 100644 --- a/frontend/src/components/AddPersonPanel.css +++ b/frontend/src/components/AddPersonPanel.css @@ -43,6 +43,8 @@ .apn-section { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--pz-line-soft); } .apn-types .pz-tab { font-size: 13px; padding: 0 6px; } +/* Sexe : quatre choix, chacun sur une ligne même dans la colonne étroite du panneau */ +.apn-sex .pz-tab { white-space: nowrap; padding: 0 4px; } .apn-relation { position: relative; } .apn-search { position: relative; display: flex; align-items: center; } @@ -108,6 +110,9 @@ /* 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); } +/* « Je ne connais pas son prénom » : une ligne, sous Prénom et Nom (ordinateur), sous Prénom (téléphone, ordre du DOM) */ +.apn-name-row > .apn-unknown { grid-column: 1 / -1; grid-row: 2; margin-top: -6px; white-space: nowrap; justify-self: start; } +@media (max-width: 480px) { .apn-name-row > .apn-unknown { grid-row: auto; } } /* 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 ada74cf..b446079 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -200,7 +200,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
-
+
- {/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher) */} - {(!firstName.trim() || unknownFirstName) && ( - - )}
+ {/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher). + Hors de la colonne Prénom pour tenir sur une ligne : sous les deux champs sur ordinateur, sous Prénom sur téléphone */} + {(!firstName.trim() || unknownFirstName) && ( + + )}
{t('Sexe')} {t('facultatif')} -
+
{SEX_CHOICES.map(({ value, label }) => (