// Le contexte actif – quelle clinique, quel service – posé sur l'écran Compte. // // L'écran Compte est le seul endroit qui parlait déjà de l'organisation : c'est // donc là que se lit, et se change, la clinique et le service que la session // tient. Le contexte vit sur la session côté serveur ; l'écran ne fait que // l'afficher et demander à en changer, il ne l'annonce jamais dans une requête // métier. // // Trois états, tous dits : // – un service actif : il s'affiche, et la liste des affectations permet d'en // changer ; // – aucune affectation : le contrôle est inerte et porte sa raison, comme le // reste des capacités du compte ; // – la lecture échoue : rien n'est inventé, le contrôle disparaît plutôt que // d'afficher un contexte faux. const ClinicContextBaseAccountScreen = AccountScreen; const CC_INK = (typeof AC_INK !== 'undefined' && AC_INK) || '#0a0a0a'; const CC_SUB = (typeof AC_SUB !== 'undefined' && AC_SUB) || '#6b6b6b'; const CC_HAIR = (typeof AC_HAIR !== 'undefined' && AC_HAIR) || '#e8e8e8'; const CC_ACCENT = '#3d6b8f'; const CC_ACCENT_SOFT = 'rgba(61,107,143,.09)'; // La raison est un code fermé servi par le serveur : on le traduit, on ne le // paraphrase pas et on n'en invente pas d'autre. const CC_REASONS = { organization_admin_required: 'Aucun service ne vous est affecté. Contactez un administrateur de votre organisation.', }; function clinicContextSearchValue(value) { return String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); } function ClinicContextSheet({ open, context, busy, error, onChoose, onAdd, onClose }) { const { BottomSheet, InteractiveRow } = window.VascoUI; const [query, setQuery] = React.useState(''); React.useEffect(() => { if (!open) setQuery(''); }, [open]); const rows = context.assignments; const normalizedQuery = clinicContextSearchValue(query.trim()); const visibleRows = normalizedQuery ? rows.filter((entry) => clinicContextSearchValue( `${entry.organizationName} ${entry.clinicName} ${entry.departmentName}`, ).includes(normalizedQuery)) : rows; const organizations = []; const organizationsById = new Map(); visibleRows.forEach((entry) => { let organization = organizationsById.get(entry.organizationId); if (!organization) { organization = { organizationId: entry.organizationId, organizationName: entry.organizationName || 'Organisation sans nom renseigné', clinics: [], clinicsById: new Map(), }; organizationsById.set(entry.organizationId, organization); organizations.push(organization); } let clinic = organization.clinicsById.get(entry.clinicId); if (!clinic) { clinic = { clinicId: entry.clinicId, clinicName: entry.clinicName || 'Établissement sans nom renseigné', rows: [], }; organization.clinicsById.set(entry.clinicId, clinic); organization.clinics.push(clinic); } clinic.rows.push(entry); }); return ( {error &&
{error}
} {rows.length === 0 && (
{CC_REASONS[context.reasonCode] || 'Aucun service ne vous est affecté.'}
)} {rows.length > 5 &&
setQuery(event.target.value)} placeholder="Rechercher une organisation, un établissement ou un service" style={{ width: '100%', height: 42, boxSizing: 'border-box', borderRadius: 13, border: `1px solid ${CC_HAIR}`, background: '#fff', color: CC_INK, padding: '0 12px 0 38px', fontFamily: 'inherit', fontSize: 12.5, outline: 'none' }} />
} {rows.length > 0 && visibleRows.length === 0 &&
Aucune affectation ne correspond à cette recherche.
} {organizations.map((organization) => { const organizationActive = context.organization?.id === organization.organizationId; return
{organization.organizationName}
{organization.clinics.map((clinic, clinicIndex) =>
{clinic.clinicName}
{clinic.rows.map((entry) => { const active = context.organization?.id === entry.organizationId && context.department?.id === entry.departmentId; return { if (!active) onChoose(entry.assignmentId); }} style={{ justifyContent: 'space-between', gap: 10, minHeight: 44, padding: '9px 10px 9px 12px', borderRadius: 12, border: `1px solid ${active ? 'rgba(61,107,143,.32)' : 'rgba(10,10,10,.07)'}`, background: active ? CC_ACCENT_SOFT : '#fff', }}> {entry.departmentName} ; })}
)}
; })} {context.canAcceptInvitations && Invitations reçues {context.invitations.length ? `${context.invitations.length} invitation${context.invitations.length > 1 ? 's' : ''} en attente` : 'Voir l’historique'} }
); } const CC_INVITATION_STATUS = { pending: { label: 'En attente', color: CC_ACCENT, background: CC_ACCENT_SOFT }, accepted: { label: 'Acceptée', color: '#397153', background: 'rgba(57,113,83,.10)' }, declined: { label: 'Refusée', color: '#9f382f', background: 'rgba(159,56,47,.08)' }, expired: { label: 'Expirée', color: CC_SUB, background: '#eeeeeb' }, revoked: { label: 'Annulée', color: CC_SUB, background: '#eeeeeb' }, }; function clinicInvitationDate(value) { if (!value) return ''; const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; return new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', }).format(date); } function AssignmentInvitationsSheet({ context, busy, error, onAccept, onDecline, onClose }) { const { BottomSheet, Button } = window.VascoUI; const [declineId, setDeclineId] = React.useState(null); const rows = context.receivedInvitations || context.invitations; return {error &&
{error}
} {rows.length === 0 ?
Aucune invitation en attente. L’administrateur de la structure doit choisir le service et vous inviter avec votre RPPS.
:
{rows.map((invitation) => { const status = CC_INVITATION_STATUS[invitation.status] || CC_INVITATION_STATUS.expired; const pending = invitation.status === 'pending'; const confirmingDecline = declineId === invitation.id; return
{invitation.organizationName}
{status.label}
{invitation.clinicName || 'Établissement non renseigné'}
{invitation.departmentName}
{pending ? `Valable jusqu’au ${clinicInvitationDate(invitation.expiresAt)}` : `Reçue le ${clinicInvitationDate(invitation.createdAt)}`}
{pending && !confirmingDecline &&
} {pending && confirmingDecline &&
Refuser cette invitation ? Aucun accès ne sera créé.
}
})}
}
; } function PractitionerInvitationSheet({ context, busy, error, onCreate, onClose }) { const { BottomSheet, Button, InteractiveRow } = window.VascoUI; const [rpps, setRpps] = React.useState(''); const [departmentId, setDepartmentId] = React.useState( context.manageableDepartments[0]?.departmentId || '', ); const [created, setCreated] = React.useState(false); const validRpps = /^\d{11}$/.test(rpps); const submit = async () => { if (!validRpps || !departmentId || busy) return; try { await onCreate(rpps, departmentId); setCreated(true); } catch { // Le parent expose déjà le message serveur dans la feuille. } }; return {created ?
Invitation créée. Elle apparaîtra dans le compte lié à ce RPPS.
: <> setRpps(event.target.value.replace(/\D/g, '').slice(0, 11))} placeholder="11 chiffres" disabled={busy} style={{ width: '100%', height: 44, boxSizing: 'border-box', marginTop: 7, padding: '0 12px', borderRadius: 12, border: `1px solid ${CC_HAIR}`, background: '#fff', color: CC_INK, fontFamily: 'inherit', fontSize: 13, outline: 'none' }} />
Service autorisé
{context.manageableDepartments.map((department) => { const selected = department.departmentId === departmentId; return setDepartmentId(department.departmentId)} style={{ minHeight: 48, padding: '8px 10px 8px 12px', borderRadius: 12, border: `1px solid ${selected ? 'rgba(61,107,143,.32)' : CC_HAIR}`, background: selected ? CC_ACCENT_SOFT : '#fff', color: CC_INK, textAlign: 'left', gap: 10 }}> {department.departmentName} {department.clinicName || 'Établissement non renseigné'} ; })}
{error &&
{error}
} }
; } function AssignmentManagementShell({ title, subtitle, busy, error, onClose, children }) { const { BottomSheet } = window.VascoUI; return {error &&
{error}
} {children}
; } function ManagedInvitationsSheet({ context, busy, error, onRevoke, onRenew, onClose }) { const { Button } = window.VascoUI; const [action, setAction] = React.useState(null); const rows = context.managedInvitations || []; const target = action ? rows.find((invitation) => invitation.id === action.invitationId) : null; const confirm = async () => { if (!target || busy) return; const completed = action.kind === 'revoke' ? await onRevoke(target.id) : await onRenew(target); if (completed) setAction(null); }; const outcomeDate = (invitation) => { if (invitation.status === 'accepted') return invitation.acceptedAt; if (invitation.status === 'declined') return invitation.declinedAt; if (invitation.status === 'revoked') return invitation.revokedAt; return invitation.expiresAt; }; return {!target && (rows.length ?
{rows.map((invitation) => { const status = CC_INVITATION_STATUS[invitation.status] || CC_INVITATION_STATUS.expired; const pending = invitation.status === 'pending'; const renewable = ['declined', 'expired', 'revoked'].includes(invitation.status); const terminalDate = clinicInvitationDate(outcomeDate(invitation)); return
RPPS {invitation.recipientRpps}
{status.label}
{invitation.clinicName || 'Établissement non renseigné'} · {invitation.departmentName}
{pending ? `Valable jusqu’au ${clinicInvitationDate(invitation.expiresAt)}` : `${status.label}${terminalDate ? ` le ${terminalDate}` : ''}`}
{(pending || renewable) && }
; })}
:
Aucune invitation envoyée par cette organisation.
)} {target &&
{action.kind === 'revoke' ? `Annuler l’invitation du RPPS ${target.recipientRpps} pour ${target.departmentName} ?` : `Renvoyer une invitation au RPPS ${target.recipientRpps} pour ${target.departmentName} ?`}
}
; } function OwnAssignmentsSheet({ context, busy, error, onLeave, onClose }) { const { Button, InteractiveRow } = window.VascoUI; const [targetId, setTargetId] = React.useState(null); const [replacementId, setReplacementId] = React.useState(null); const target = context.assignments.find((item) => item.assignmentId === targetId) || null; const remaining = target ? context.assignments.filter((item) => item.assignmentId !== target.assignmentId) : []; const targetIsCurrent = Boolean(target && target.organizationId === context.organization?.id && target.departmentId === context.department?.id); const begin = (assignment) => { const alternatives = context.assignments.filter( (item) => item.assignmentId !== assignment.assignmentId, ); setTargetId(assignment.assignmentId); setReplacementId( assignment.organizationId === context.organization?.id && assignment.departmentId === context.department?.id ? alternatives[0]?.assignmentId || null : null, ); }; const confirm = async () => { if (!target || busy || (targetIsCurrent && remaining.length && !replacementId)) return; if (await onLeave(target.assignmentId, targetIsCurrent ? replacementId : null)) { setTargetId(null); setReplacementId(null); } }; return {!target &&
{context.assignments.map((assignment) => { const current = assignment.organizationId === context.organization?.id && assignment.departmentId === context.department?.id; return
{assignment.departmentName} {[assignment.organizationName, assignment.clinicName].filter(Boolean).join(' · ')} {current ? ' · Actif' : ''}
; })}
} {target &&
Quitter {target.departmentName} ?
Vous n’accéderez plus aux patients et rendez-vous rattachés à ce service.
{targetIsCurrent && remaining.length > 0 && <>
Ouvrir ensuite
{remaining.map((assignment) => setReplacementId(assignment.assignmentId)} disabled={busy} style={{ minHeight: 44, padding: '7px 10px', borderRadius: 11, textAlign: 'left', border: `1px solid ${replacementId === assignment.assignmentId ? 'rgba(61,107,143,.32)' : CC_HAIR}`, background: replacementId === assignment.assignmentId ? CC_ACCENT_SOFT : '#fff', color: CC_INK }}> {assignment.departmentName} {[assignment.organizationName, assignment.clinicName].filter(Boolean).join(' · ')} )}
} {targetIsCurrent && remaining.length === 0 &&
C’est votre dernière affectation : votre compte restera ouvert, sans accès médical.
}
}
; } function ManagedPractitionersSheet({ context, busy, error, onRevoke, onClose }) { const { Button } = window.VascoUI; const [targetId, setTargetId] = React.useState(null); const target = context.manageableAssignments.find( (item) => item.assignmentId === targetId, ) || null; const confirm = async () => { if (!target || busy) return; if (await onRevoke(target.assignmentId)) setTargetId(null); }; return {!target && (context.manageableAssignments.length ?
{context.manageableAssignments.map((assignment) =>
{assignment.practitionerName} {assignment.departmentName} · {assignment.clinicName || 'Établissement'}
)}
:
Aucune affectation d’un autre praticien.
)} {target &&
Retirer {target.practitionerName} du service {target.departmentName} ?
}
; } function ClinicContextAccountScreen(props) { const { InteractiveRow } = window.VascoUI; const [context, setContext] = React.useState(null); const [open, setOpen] = React.useState(null); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); React.useEffect(() => { let alive = true; const api = window.VascoApi && window.VascoApi.account && window.VascoApi.account.context; if (!api) return undefined; api.load() .then((value) => { if (alive) setContext(value); }) // Une lecture qui échoue ne doit pas afficher un contexte inventé : le // contrôle disparaît, et le reste de l'écran Compte fonctionne. .catch(() => { if (alive) setContext(null); }); return () => { alive = false; }; }, []); const choose = async (assignmentId) => { setBusy(true); setError(null); try { setContext(await window.VascoApi.account.context.switchTo(assignmentId)); setOpen(null); } catch (failure) { setError((failure && failure.message) || 'Changement de contexte impossible.'); } finally { setBusy(false); } }; const acceptInvitation = async (invitationId) => { setBusy(true); setError(null); try { setContext(await window.VascoApi.account.context.acceptInvitation(invitationId)); setOpen('context'); } catch (failure) { setError((failure && failure.message) || 'Ajout de l’affectation impossible.'); } finally { setBusy(false); } }; const declineInvitation = async (invitationId) => { setBusy(true); setError(null); try { setContext(await window.VascoApi.account.context.declineInvitation(invitationId)); } catch (failure) { setError((failure && failure.message) || 'Refus de l’invitation impossible.'); } finally { setBusy(false); } }; const createInvitation = async (rpps, departmentId) => { setBusy(true); setError(null); try { const created = await window.VascoApi.account.context.createInvitation(rpps, departmentId); setContext(await window.VascoApi.account.context.load()); return created; } catch (failure) { setError((failure && failure.message) || 'Création de l’invitation impossible.'); throw failure; } finally { setBusy(false); } }; const revokeInvitation = async (invitationId) => { setBusy(true); setError(null); try { setContext(await window.VascoApi.account.context.revokeInvitation(invitationId)); return true; } catch (failure) { setError((failure && failure.message) || 'Annulation de l’invitation impossible.'); return false; } finally { setBusy(false); } }; const renewInvitation = async (invitation) => { setBusy(true); setError(null); try { await window.VascoApi.account.context.createInvitation( invitation.recipientRpps, invitation.departmentId, ); setContext(await window.VascoApi.account.context.load()); return true; } catch (failure) { setError((failure && failure.message) || 'Renvoi de l’invitation impossible.'); return false; } finally { setBusy(false); } }; const leaveAssignment = async (assignmentId, replacementAssignmentId) => { setBusy(true); setError(null); try { setContext(await window.VascoApi.account.context.leaveAssignment( assignmentId, replacementAssignmentId, )); return true; } catch (failure) { setError((failure && failure.message) || 'Retrait de l’affectation impossible.'); return false; } finally { setBusy(false); } }; const revokeAssignment = async (assignmentId) => { setBusy(true); setError(null); try { setContext(await window.VascoApi.account.context.revokeAssignment(assignmentId)); return true; } catch (failure) { setError((failure && failure.message) || 'Retrait du praticien impossible.'); return false; } finally { setBusy(false); } }; const label = context && context.department ? `${context.organization?.name || 'Organisation'} · ${context.clinic?.name || 'Établissement'} · ${context.department.name}` : 'Aucun service actif'; const integrated = Boolean(ClinicContextBaseAccountScreen.acceptsClinicContext); return (
{integrated ? { setError(null); setOpen('context'); }} onOpenClinicInvitation={() => { setError(null); setOpen('invite-admin'); }} onManageOwnAssignments={() => { setError(null); setOpen('manage-own'); }} onManageInvitations={() => { setError(null); setOpen('manage-invitations'); }} onManagePractitioners={() => { setError(null); setOpen('manage-admin'); }} /> : } {!integrated && context && ( setOpen('context')} style={{ position: 'absolute', zIndex: 90, left: 18, right: 18, bottom: 126, height: 46, borderRadius: 16, border: `1px solid ${CC_HAIR}`, background: '#fff', boxShadow: '0 5px 18px rgba(10,10,10,.12)', color: CC_INK, fontWeight: 750, fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 14px', gap: 10, }}> {label} {context.canSwitch ? 'Changer' : 'Voir'} )} {context && { setError(null); setOpen('accept'); }} onClose={() => { if (!busy) { setOpen(null); setError(null); } }} />} {open === 'accept' && context && { if (!busy) { setOpen('context'); setError(null); } }} />} {open === 'invite-admin' && context && { if (!busy) { setOpen(null); setError(null); } }} />} {open === 'manage-own' && context && { if (!busy) { setOpen(null); setError(null); } }} />} {open === 'manage-invitations' && context && { if (!busy) { setOpen(null); setError(null); } }} />} {open === 'manage-admin' && context && { if (!busy) { setOpen(null); setError(null); } }} />}
); } ClinicContextAccountScreen.acceptsClinicContext = Boolean(ClinicContextBaseAccountScreen.acceptsClinicContext); ClinicContextAccountScreen.acceptsCorrespondents = Boolean(ClinicContextBaseAccountScreen.acceptsCorrespondents); AccountScreen = ClinicContextAccountScreen; window.AccountScreen = ClinicContextAccountScreen;