diff --git a/backend/src/lib/person-names.ts b/backend/src/lib/person-names.ts new file mode 100644 index 0000000..679cf9f --- /dev/null +++ b/backend/src/lib/person-names.ts @@ -0,0 +1,4 @@ +// Nom de famille facultatif, prénom facultatif (prénom inconnu = vide) : une personne garde au moins un des deux +export function hasAnyName(firstName: string, lastName: string): boolean { + return Boolean(firstName.trim() || lastName.trim()) +} diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index c95b3bd..928c2b3 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -13,6 +13,7 @@ import type { Actor, MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' import { nextSiblingOrder } from '../lib/sibling-order.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' +import { hasAnyName } from '../lib/person-names.js' const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ @@ -69,7 +70,7 @@ const reviewSessionSchema = z.object({ const personCreateSchema = z.object({ firstName: z.string().trim().max(120), - lastName: z.string().min(1).max(120), + lastName: z.string().trim().max(120), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -81,12 +82,12 @@ const personCreateSchema = z.object({ nationality: z.string().max(120).optional().nullable(), sex: z.string().max(20).optional().nullable(), notes: z.string().max(2000).optional().nullable(), -}) +}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) const personUpdateSchema = z .object({ firstName: z.string().trim().max(120).optional(), - lastName: z.string().min(1).max(120).optional(), + lastName: z.string().trim().max(120).optional(), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -427,6 +428,16 @@ async function applyContributionChange( }) : null + if (parsed.data.firstName !== undefined || parsed.data.lastName !== undefined) { + const before = await tx.person.findFirst({ + where: { id: change.entityId, treeId, deletedAt: null }, + select: { firstName: true, lastName: true }, + }) + if (before && !hasAnyName(parsed.data.firstName ?? before.firstName, parsed.data.lastName ?? before.lastName)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + } + const result = await tx.person.updateMany({ where: { id: change.entityId, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index b82ae7b..c95ab95 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -16,6 +16,7 @@ import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock- import type { MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' +import { hasAnyName } from '../lib/person-names.js' import { ContributionRouteError, resolveSubmissionContext } from './contributions.js' const MAX_TREES_PER_ACCOUNT = 10 @@ -85,7 +86,7 @@ const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ const createPersonSchema = z.object({ firstName: z.string().trim().max(120), - lastName: z.string().min(1).max(120), + lastName: z.string().trim().max(120), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -97,12 +98,12 @@ const createPersonSchema = z.object({ nationality: z.string().max(120).optional().nullable(), sex: z.string().max(20).optional().nullable(), notes: z.string().max(2000).optional().nullable(), -}) +}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) const patchPersonSchema = z .object({ firstName: z.string().trim().max(120).optional(), - lastName: z.string().min(1).max(120).optional(), + lastName: z.string().trim().max(120).optional(), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -1367,6 +1368,17 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } + // Au moins un nom : on regarde le résultat de la modification, pas seulement ce qu'elle envoie + if (payload.data.firstName !== undefined || payload.data.lastName !== undefined) { + const before = await app.prisma.person.findFirst({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + select: { firstName: true, lastName: true }, + }) + if (before && !hasAnyName(payload.data.firstName ?? before.firstName, payload.data.lastName ?? before.lastName)) { + return reply.code(400).send({ error: 'invalid_payload', details: 'name_required' }) + } + } + const data: { firstName?: string lastName?: string diff --git a/backend/test/person-names.test.ts b/backend/test/person-names.test.ts new file mode 100644 index 0000000..3d96e2b --- /dev/null +++ b/backend/test/person-names.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { hasAnyName } from '../src/lib/person-names' +import { gedcomNameValue } from '../src/routes/gedcom' + +// Nom de famille facultatif : une personne garde au moins un prénom ou un nom +describe('au moins un nom', () => { + it('un prénom ou un nom suffit', () => { + expect(hasAnyName('Léa', '')).toBe(true) + expect(hasAnyName('', 'Lambert')).toBe(true) + expect(hasAnyName(' ', ' Lambert ')).toBe(true) + }) + + it('ni prénom ni nom : refusé', () => { + expect(hasAnyName('', '')).toBe(false) + expect(hasAnyName(' ', ' ')).toBe(false) + }) + + it('export GEDCOM sans nom de famille : « Léa // », valide', () => { + expect(gedcomNameValue('Léa', '')).toBe('Léa //') + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3dfd6b7..de7d4be 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -228,7 +228,8 @@ function buildContributionChangesFromDraft(draft = {}) { for (const person of addedPersons) { if (!person || typeof person !== 'object') continue // Prénom inconnu (chaîne vide) : la personne part quand même, nom de famille obligatoire comme avant - if (!person.lastName) continue + // Nom de famille facultatif : seule une personne sans aucun nom est ignorée + if (!person.firstName && !person.lastName) continue // Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null diff --git a/frontend/src/components/AddPersonPanel.css b/frontend/src/components/AddPersonPanel.css index 007f39c..3d1689e 100644 --- a/frontend/src/components/AddPersonPanel.css +++ b/frontend/src/components/AddPersonPanel.css @@ -111,3 +111,6 @@ /* 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; } + +/* Titre de section en casse normale (pas de sur-titre en capitales dans le panneau d'ajout) */ +.apn-section-title { font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); margin: 0 0 10px; } diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index cd62fc0..b949392 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -49,6 +49,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const [relationDropdownOpen, setRelationDropdownOpen] = useState(false) const [selectedRelationType, setSelectedRelationType] = useState('parent') const [touched, setTouched] = useState({}) + // Prénom inconnu sans nom de famille : la personne n'aurait aucun nom + const [lastNameMissing, setLastNameMissing] = useState(false) const [justAdded, setJustAdded] = useState(false) const firstNameRef = useRef(null) @@ -103,6 +105,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on setDateFieldsKey((k) => k + 1) setRelations([]) setTouched({}) + setLastNameMissing(false) setJustAdded(false) firstNameRef.current?.focus() } @@ -139,6 +142,11 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on return } + if (unknownFirstName && !lastName.trim()) { + setLastNameMissing(true) + return + } + setJustAdded(true) onSubmit({ @@ -176,7 +184,6 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on >
-

{t('Nouvelle fiche')}

{withEmphasis(t("Ajouter quelqu'un"))}

@@ -208,26 +229,19 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on id="ap-lastName" type="text" value={lastName} - onChange={(e) => setLastName(e.target.value)} + onChange={(e) => { + setLastName(e.target.value) + setLastNameMissing(false) + }} placeholder={t('Dupont')} autoComplete="off" />
- {touched.firstName && !unknownFirstName && !firstName.trim() && (

{t('Il faut au moins un prénom.')}

)} + {lastNameMissing &&

{t('Il faut au moins un nom si le prénom est inconnu.')}

}
@@ -272,7 +286,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
-

{t('Place dans la famille')}

+

{t('Place dans la famille')}

{Object.entries(relationTypeTabs).map(([type, label]) => ( diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index ec25519..64740b5 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -98,6 +98,7 @@ const PersonCard = ({ // Prénom inconnu (case cochée) et saisie de prénom vide sans case : le prénom est demandé avant l'enregistrement const [unknownFirstName, setUnknownFirstName] = useState(() => isFirstNameUnknown(person)) const [firstNameMissing, setFirstNameMissing] = useState(false) + const [lastNameMissing, setLastNameMissing] = useState(false) const [hasChanges, setHasChanges] = useState(false) const [saving, setSaving] = useState(false) const [saveSuccess, setSaveSuccess] = useState(false) @@ -122,6 +123,7 @@ const PersonCard = ({ setEdits({}) setUnknownFirstName(isFirstNameUnknown(person)) setFirstNameMissing(false) + setLastNameMissing(false) setHasChanges(false) setShowDeleteConfirm(false) setActiveRelationSearch(null) @@ -209,6 +211,11 @@ const PersonCard = ({ setFirstNameMissing(true) return } + // Prénom inconnu : il faut alors un nom de famille + if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) { + setLastNameMissing(true) + return + } setSaving(true) try { await onSave(person.id, edits) @@ -556,27 +563,36 @@ const PersonCard = ({ type="text" className="pc-input pc-input--name pc-input--last" value={getEditValue('lastName', person.lastName)} - onChange={(e) => handleFieldChange('lastName', e.target.value)} + onChange={(e) => { + setLastNameMissing(false) + handleFieldChange('lastName', e.target.value) + }} placeholder={t('Nom')} aria-label={t('Nom')} /> - + {/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher) */} + {(unknownFirstName || !String(getEditValue('firstName', person.firstName) ?? '').trim()) && ( + + )} {firstNameMissing &&

{t('Il faut au moins un prénom.')}

} + {lastNameMissing &&

{t('Il faut au moins un nom si le prénom est inconnu.')}

}
) : (

- {isFirstNameUnknown(person) ? {firstNameLabel(person)} : person.firstName} {person.lastName} + {isFirstNameUnknown(person) ? {firstNameLabel(person)} : person.firstName} + {person.lastName && <>{' '}{person.lastName}}

)} {!editMode && metaItems.length > 0 && ( diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index ef4b1ed..a3bd2f8 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -102,7 +102,7 @@ function mapRemotePersonToLocal(person, current = null) { id: person?.id ?? current?.id, // Prénom inconnu = chaîne vide renvoyée par l'API (pas de repli sur l'ancien nom lors d'un rafraîchissement) firstName: person ? (person.firstName ?? '') : (current?.firstName ?? ''), - lastName: person?.lastName || current?.lastName || '', + lastName: person ? (person.lastName ?? '') : (current?.lastName ?? ''), birthName: person?.birthName ?? current?.birthName ?? null, nickname: person?.nickname ?? null, birthYear, diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 62bd65a..94ea9aa 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -407,4 +407,5 @@ export default { 'Décédé(e), date inconnue': 'Deceased, date unknown', 'Prénom inconnu': 'Unknown first name', 'Je ne connais pas son prénom': "I don't know their first name", + 'Il faut au moins un nom si le prénom est inconnu.': 'A last name is required when the first name is unknown.', }