Files
parenthese/frontend/src/components/AddPersonPanel.jsx
T
IliesAllaliandClaude Opus 5.5 6f28918dde Interface en anglais : traduction, bascule FR · EN, accents corrigés
Module frontend/src/i18n : le texte français sert de clé, dictionnaires anglais
par zone, langue du navigateur puis choix mémorisé, ?lang=en pour forcer.
Bascule dans le menu du compte, la pastille visiteur, le menu Plus, l'écran
d'accès et la connexion. Démo traduite (noms et lieux gardés). Liens d'aide et
de vie privée selon la langue. Textes français sans accents corrigés.
Test : chaque texte passé à t() a sa traduction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:01:15 +02:00

325 lines
12 KiB
React

import { useState, useEffect, useRef } from 'react'
import { UserPlus, X, Search, ChevronDown } from 'lucide-react'
import './AddPersonPanel.css'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
/**
* Panneau slide-in gauche pour ajouter une personne
* Visible uniquement en mode édition
*
* Features:
* - Slide-in depuis la gauche (400px desktop, 90% mobile)
* - Focus auto sur Prénom à l'ouverture
* - Validation temps-réel (required)
* - Section Relations avec dropdown searchable
* - Reste ouvert après ajout réussi (reset form)
*
* @param {Object} props
* @param {boolean} props.visible - Afficher le panneau
* @param {Array} props.persons - Liste des personnes existantes pour les relations
* @param {boolean} props.loading - Soumission en cours
* @param {string} props.error - Message d'erreur
* @param {Function} props.onSubmit - Handler soumission (formData)
* @param {Function} props.onClose - Handler fermeture
*/
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [birthName, setBirthName] = useState('')
const [birthDate, setBirthDate] = useState('')
const [deathDate, setDeathDate] = useState('')
const [relations, setRelations] = useState([])
const [relationSearch, setRelationSearch] = useState('')
const [relationDropdownOpen, setRelationDropdownOpen] = useState(false)
const [selectedRelationType, setSelectedRelationType] = useState('parent')
const [touched, setTouched] = useState({})
const [justAdded, setJustAdded] = useState(false)
const firstNameRef = useRef(null)
const panelRef = useRef(null)
// Focus sur Prénom à l'ouverture
useEffect(() => {
if (visible) {
setTimeout(() => firstNameRef.current?.focus(), 300)
}
}, [visible])
// ESC pour fermer
useEffect(() => {
if (!visible) return
function handleKeyDown(e) {
if (e.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [visible, onClose])
// Fermer dropdown relation au clic extérieur
useEffect(() => {
if (!relationDropdownOpen) return
function handleClick(e) {
if (!e.target.closest('.relation-dropdown-wrapper')) {
setRelationDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [relationDropdownOpen])
// Reset form après ajout réussi
useEffect(() => {
if (justAdded && !loading && !error) {
setFirstName('')
setLastName('')
setBirthName('')
setBirthDate('')
setDeathDate('')
setRelations([])
setTouched({})
setJustAdded(false)
firstNameRef.current?.focus()
}
}, [justAdded, loading, error])
const filteredPersons = persons.filter((p) => {
if (!relationSearch) return true
const fullName = `${p.firstName || ''} ${p.lastName || ''}`.toLowerCase()
return fullName.includes(relationSearch.toLowerCase())
})
const handleAddRelation = (person) => {
// Éviter les doublons
if (relations.some((r) => r.personId === person.id)) return
setRelations((prev) => [
...prev,
{ personId: person.id, personName: `${person.firstName} ${person.lastName || ''}`.trim(), type: selectedRelationType },
])
setRelationSearch('')
setRelationDropdownOpen(false)
}
const handleRemoveRelation = (personId) => {
setRelations((prev) => prev.filter((r) => r.personId !== personId))
}
const handleSubmit = (e) => {
e.preventDefault()
if (!firstName.trim()) {
setTouched({ firstName: true })
firstNameRef.current?.focus()
return
}
setJustAdded(true)
onSubmit({
firstName: firstName.trim(),
lastName: lastName.trim(),
birthName: birthName.trim() || null,
birthDate: birthDate || null,
deathDate: deathDate || null,
relations,
})
}
const isValid = firstName.trim().length > 0
const relationTypeLabels = {
parent: t('Parent de'),
child: t('Enfant de'),
spouse: t('Conjoint·e de'),
sibling: t('Frère·Sœur de'),
}
const relationTypeTabs = {
parent: t('Parent'),
child: t('Enfant'),
spouse: t('Conjoint·e'),
sibling: t('Frère·Sœur'),
}
return (
<div
className={`add-person-panel ${visible ? 'add-person-panel--open' : ''}`}
ref={panelRef}
role="dialog"
aria-labelledby="addPersonTitle"
aria-hidden={!visible}
>
<div className="apn-head">
<div className="pz-modal-head">
<p className="pz-eyebrow">{t('Nouvelle fiche')}</p>
<h2 id="addPersonTitle" className="pz-title">{withEmphasis(t("Ajouter <em>quelqu'un</em>"))}</h2>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon apn-close" onClick={onClose} aria-label={t('Fermer')}>
<X size={18} strokeWidth={2} />
</button>
</div>
<form className="apn-form" onSubmit={handleSubmit}>
<div className="apn-scroll">
<div className="pz-row2">
<div className="pz-field">
<label htmlFor="ap-firstName">{t('Prénom')}</label>
<input
ref={firstNameRef}
id="ap-firstName"
type="text"
className={touched.firstName && !firstName.trim() ? 'apn-input--error' : ''}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, firstName: true }))}
placeholder={t('Marie')}
autoComplete="off"
aria-invalid={touched.firstName && !firstName.trim()}
/>
</div>
<div className="pz-field">
<label htmlFor="ap-lastName">{t('Nom')}</label>
<input
id="ap-lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder={t('Dupont')}
autoComplete="off"
/>
</div>
</div>
{touched.firstName && !firstName.trim() && (
<p className="apn-error-text">{t('Il faut au moins un prénom.')}</p>
)}
<div className="pz-field">
<label htmlFor="ap-birthName">{t('Nom de naissance')} <span className="apn-optional">{t('si différent')}</span></label>
<input
id="ap-birthName"
type="text"
value={birthName}
onChange={(e) => setBirthName(e.target.value)}
placeholder={t('Martin')}
autoComplete="off"
/>
</div>
<div className="pz-row2">
<div className="pz-field">
<label htmlFor="ap-birthDate">{t('Naissance')}</label>
<input id="ap-birthDate" type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} />
</div>
<div className="pz-field">
<label htmlFor="ap-deathDate">{t('Décès')}</label>
<input id="ap-deathDate" type="date" value={deathDate} onChange={(e) => setDeathDate(e.target.value)} />
</div>
</div>
<section className="apn-section">
<h3 className="pz-eyebrow">{t('Place dans la famille')}</h3>
<div className="pz-tabs apn-types" role="radiogroup" aria-label={t('Type de lien')}>
{Object.entries(relationTypeTabs).map(([type, label]) => (
<button
key={type}
type="button"
role="radio"
aria-checked={selectedRelationType === type}
className={`pz-tab ${selectedRelationType === type ? 'is-active' : ''}`}
onClick={() => setSelectedRelationType(type)}
>
{label}
</button>
))}
</div>
<div className="pz-field relation-dropdown-wrapper apn-relation">
<label htmlFor="ap-relationSearch">{relationTypeLabels[selectedRelationType]}</label>
<div className="apn-search">
<Search size={15} strokeWidth={2} className="apn-search-icon" />
<input
id="ap-relationSearch"
type="text"
value={relationSearch}
onChange={(e) => {
setRelationSearch(e.target.value)
setRelationDropdownOpen(true)
}}
onFocus={() => setRelationDropdownOpen(true)}
placeholder={t("Chercher dans l'arbre")}
autoComplete="off"
/>
<ChevronDown size={15} strokeWidth={2} className="apn-search-chevron" />
</div>
{relationDropdownOpen && filteredPersons.length > 0 && (
<ul className="apn-dropdown">
{filteredPersons.slice(0, 8).map((p) => (
<li key={p.id}>
<button type="button" className="apn-dropdown-item" onClick={() => handleAddRelation(p)}>
<span className="apn-avatar" aria-hidden="true">
{p.photo ? <img src={p.photo} alt="" /> : (p.firstName || '?').charAt(0)}
</span>
<span className="apn-dropdown-name">{p.firstName} <em>{p.lastName || ''}</em></span>
{(p.birthYear || p.birthDate) && (
<span className="apn-dropdown-date">{p.birthYear || String(p.birthDate).slice(0, 4)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
{relations.length > 0 && (
<div className="apn-links">
{relations.map((rel) => (
<div key={rel.personId} className="apn-link">
<span className="apn-link-type">{relationTypeLabels[rel.type]}</span>
<span className="apn-link-name">{rel.personName}</span>
<button
type="button"
className="pz-btn pz-btn--ghost pz-btn--icon apn-link-remove"
onClick={() => handleRemoveRelation(rel.personId)}
aria-label={t('Retirer {name}', { name: rel.personName })}
>
<X size={14} strokeWidth={2.2} />
</button>
</div>
))}
</div>
)}
</section>
{error && <div className="pz-error" role="alert">{error}</div>}
</div>
<div className="apn-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose}>
{t('Annuler')}
</button>
<button type="submit" className="pz-btn pz-btn--primary" disabled={!isValid || loading}>
<UserPlus size={16} strokeWidth={2} />
<PzBusy busy={loading} busyLabel={t('Ajout en cours')}>{t('Ajouter cette personne')}</PzBusy>
</button>
</div>
</form>
</div>
)
}
export default AddPersonPanel