// Account — Profil, paramètres, connecteurs, contenus personnels. // Chaque ligne est pilotée par `/api/v1/account` : le serveur décide de ce qui // est actif et, quand il refuse, il fournit la raison. Aucun état n'est inventé // ici, faute de quoi l'écran promettrait des fonctions qui n'existent pas. const AC_VT = window.VT || {}; const AC_INK = AC_VT.ink || '#0a0a0a'; const AC_SUB = AC_VT.sub || '#6b7280'; const AC_BG = AC_VT.paper || '#f6f5f1'; const AC_SURF = AC_VT.surface || '#ffffff'; const AC_HAIR = AC_VT.hair || 'rgba(10,10,10,0.06)'; const AC_HAIR2 = AC_VT.hair2 || 'rgba(10,10,10,0.10)'; const AC_OK = AC_VT.success || '#059669'; const AC_MED = AC_VT.medBlue || '#3d6b8f'; const AC_DANGER = AC_VT.danger || '#b91c1c'; const AC_MED_TINT = AC_VT.medBlueTint || 'rgba(61,107,143,0.10)'; const AC_OK_TINT = AC_VT.successTint || 'rgba(5,150,105,0.10)'; // Libellés alignés mot pour mot sur l'application native // (`mobile/src/features/account/account-screen.tsx`) : deux formulations // différentes pour la même raison serveur finiraient par diverger. const AC_REASONS = { organization_admin_required: 'Réservé à un futur administrateur de l’organisation.', identity_provider_managed: 'Cette action est gérée par le fournisseur d’identité.', simulated_identity: 'Indisponible avec une identité de démonstration.', step_up_unavailable: 'Une vérification d’identité renforcée est indisponible.', provider_unavailable: 'Aucun fournisseur réel n’est configuré.', source_unavailable: 'Aucune source approuvée n’alimente cette information.', single_option_supported: 'Une seule option est prise en charge dans cette version.', feature_not_in_j6a: 'Cette action ne fait pas partie du périmètre J6a.', secure_export_storage_unavailable: 'Aucun stockage chiffré qualifié n’est disponible pour l’export.', retention_policy_unapproved: 'La politique de rétention et de fermeture n’est pas approuvée.', feature_not_in_j6b1: 'Cette action ne fait pas partie du périmètre J6b1.', role_not_supported: 'Votre accès ne permet pas cette action.', }; function acReason(capability) { if (!capability || capability.enabled) return null; return AC_REASONS[capability.reason_code] || 'Action indisponible.'; } const AC_ZONES = [ 'Europe/Paris', 'Europe/Brussels', 'Europe/Zurich', 'Europe/London', 'America/Montreal', 'America/Guadeloupe', 'Indian/Reunion', ]; const AC_VERIFICATION = { verified: 'vérifié', synthetic: 'synthétique', unverified: 'non vérifié' }; const AC_SALUTATIONS = { doctor: 'Dr.', professor: 'Pr.', none: '' }; const AC_DOC_TYPES = ['CRO', 'CRC', 'CRH', 'NOTE', 'LETTRE']; function acPlural(count, singular, plural) { return `${count} ${count > 1 ? (plural || `${singular}s`) : singular}`; } function acDate(value) { if (!value) return '—'; return new Intl.DateTimeFormat('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) .format(new Date(value)); } // ── Section row ── function SettingRow({ icon, label, sub, right, onClick, danger = false, reason = null, role, checked }) { const actionable = Boolean(onClick) && !reason; const body = ( <>
{icon}
{label}
{sub &&
{sub}
} {reason && (
{reason}
)}
{right || (actionable ? ( ) : null)} ); const shared = { width: '100%', display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px', background: 'none', border: 'none', fontFamily: 'inherit', textAlign: 'left', }; if (!actionable) { return (
{body}
); } return ( ); } // ── Section group ── function SettingGroup({ title, children }) { return (
{title}
{React.Children.map(children, (child, i) => ( {i > 0 &&
} {child} ))}
); } // ── Interrupteur purement visuel ── // Il vit à l'intérieur du bouton de la ligne : en faire un `
{children}
{footer &&
{footer}
} ); } function AcField({ label, hint, children }) { return ( ); } const acInputStyle = { width: '100%', height: 44, padding: '0 12px', borderRadius: AC_VT.rMd || 12, border: `1px solid ${AC_HAIR2}`, background: AC_SURF, outline: 'none', fontFamily: 'inherit', fontSize: 14, fontWeight: 500, color: AC_INK, }; function AcButton({ children, onClick, variant = 'ghost', disabled = false, busy = false }) { const solid = variant === 'solid'; const danger = variant === 'danger'; return ( ); } // ═══════════════════════════════════════════════════════════ // Édition du profil — PATCH /api/v1/account/profile // ═══════════════════════════════════════════════════════════ function ProfileEditorSheet({ profile, busy, onClose, onSave }) { const [salutation, setSalutation] = React.useState(profile.salutation); const [givenName, setGivenName] = React.useState(profile.given_name); const [familyName, setFamilyName] = React.useState(profile.family_name); const [phone, setPhone] = React.useState(profile.phone || ''); const [specialty, setSpecialty] = React.useState(profile.specialty || ''); const [timeZone, setTimeZone] = React.useState(profile.time_zone); const zones = AC_ZONES.includes(profile.time_zone) ? AC_ZONES : [profile.time_zone].concat(AC_ZONES); const trimmedPhone = phone.trim(); // Le serveur impose E.164 : autant refuser avant l'appel plutôt que // d'afficher un 422 générique. const phoneValid = trimmedPhone === '' || /^\+[1-9][0-9]{7,14}$/.test(trimmedPhone); // Ce que la feuille modifierait réellement. Calculé au rendu plutôt qu'à // l'envoi : un formulaire inchangé garde son bouton inerte, au lieu de // l'accepter puis d'annoncer en bas de l'écran qu'il n'y avait rien à faire. const changes = {}; if (salutation !== profile.salutation) changes.salutation = salutation; if (givenName.trim() !== profile.given_name) changes.given_name = givenName.trim(); if (familyName.trim() !== profile.family_name) changes.family_name = familyName.trim(); if (trimmedPhone !== (profile.phone || '')) changes.phone = trimmedPhone; if (specialty.trim() !== (profile.specialty || '')) changes.specialty = specialty.trim(); if (timeZone !== profile.time_zone) changes.time_zone = timeZone; const valid = Boolean(givenName.trim() && familyName.trim() && phoneValid) && Object.keys(changes).length > 0; const submit = () => { if (!valid || busy) return; onSave(changes); }; return ( Annuler {busy ? 'Enregistrement…' : 'Enregistrer'} }> setGivenName(e.target.value)} maxLength={120} style={acInputStyle} /> setFamilyName(e.target.value)} maxLength={120} style={acInputStyle} /> setPhone(e.target.value)} inputMode="tel" style={{ ...acInputStyle, borderColor: phoneValid ? AC_HAIR2 : AC_DANGER, }} /> setSpecialty(e.target.value)} maxLength={120} style={acInputStyle} /> ); } // ═══════════════════════════════════════════════════════════ // Fiche du cabinet — GET / PUT /api/v1/clinic // ═══════════════════════════════════════════════════════════ // La feuille est montée sur la fiche déjà lue : `version` vaut 0 tant qu'aucune // n'existe, et l'enregistrement la crée. Le parent la remonte (via `key`) sur // chaque version reçue, pour que les champs repartent des valeurs du serveur // après un conflit. function ClinicSheet({ clinic, organizationName, organizationReason, busy, error, onClose, onSave }) { const [name, setName] = React.useState(clinic.name || ''); const [addressLine, setAddressLine] = React.useState(clinic.address_line || ''); const [postalCode, setPostalCode] = React.useState(clinic.postal_code || ''); const [city, setCity] = React.useState(clinic.city || ''); const [phone, setPhone] = React.useState(clinic.phone || ''); const [email, setEmail] = React.useState(clinic.email || ''); const fields = { name: name.trim(), address_line: addressLine.trim(), postal_code: postalCode.trim(), city: city.trim(), phone: phone.trim(), email: email.trim(), }; // Un PUT remplace la fiche entière : le bouton reste inerte tant que rien ne // change, plutôt que d'accepter un envoi qui n'écrirait rien de nouveau. const changed = Object.keys(fields).some((key) => fields[key] !== (clinic[key] || '')); const submit = () => { if (!changed || busy) return; onSave(fields); }; return ( Annuler {busy ? 'Enregistrement…' : 'Enregistrer'} }> {error && (
{error}
)} setName(e.target.value)} maxLength={160} style={acInputStyle} /> setAddressLine(e.target.value)} maxLength={200} style={acInputStyle} /> setPostalCode(e.target.value)} maxLength={16} style={acInputStyle} /> setCity(e.target.value)} maxLength={120} style={acInputStyle} /> setPhone(e.target.value)} inputMode="tel" maxLength={24} style={acInputStyle} /> setEmail(e.target.value)} inputMode="email" maxLength={254} style={acInputStyle} /> {/* L'organisation reste ce que le serveur en dit : la fiche du cabinet ne la renomme pas, et la raison du refus est celle de `edit_clinic`. */} {/* La sous-ligne d'origine annonce un logo. Il n'est pas construit : le dire ici vaut mieux qu'un bouton qui ne téléverserait rien. */}
Logo
Indisponible : téléversement d’image non construit.
); } // ═══════════════════════════════════════════════════════════ // Déconnexion de tous les appareils — POST /api/v1/auth/logout-all // ═══════════════════════════════════════════════════════════ function LogoutAllSheet({ busy, onCancel, onConfirm }) { return ( Annuler {busy ? 'Révocation…' : 'Tout déconnecter'} }>
Cette action est immédiate et ne peut pas être annulée. Elle n'efface aucune donnée clinique : elle met seulement fin aux sessions ouvertes.
); } // ═══════════════════════════════════════════════════════════ // Contenus personnels — prompts et modèles de documents // ═══════════════════════════════════════════════════════════ function ContentEditorSheet({ kind, item, busy, onClose, onSave }) { const isPrompt = kind === 'prompts'; const [name, setName] = React.useState(item?.name || ''); const [description, setDescription] = React.useState(item?.description || ''); const [documentType, setDocumentType] = React.useState(item?.document_type || 'CRO'); const [content, setContent] = React.useState(item?.content || ''); const maxContent = isPrompt ? 8000 : 20000; const valid = name.trim() && content.trim() && content.length <= maxContent; const submit = () => { if (!valid || busy) return; if (isPrompt) { onSave({ name: name.trim(), description: description.trim() || null, content }); return; } onSave({ name: name.trim(), document_type: documentType, content }); }; return ( Annuler {busy ? 'Enregistrement…' : 'Enregistrer'} }> setName(e.target.value)} maxLength={120} style={acInputStyle} /> {isPrompt ? ( setDescription(e.target.value)} maxLength={500} style={acInputStyle} /> ) : ( )}