// 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.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}
{active &&
}
;
})}
)}
;
})}
{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 &&
setDeclineId(invitation.id)}>Refuser
onAccept(invitation.id)}>Accepter
}
{pending && confirmingDecline &&
Refuser cette invitation ? Aucun accès ne sera créé.
setDeclineId(null)}>Annuler
onDecline(invitation.id)}>Confirmer le refus
}
})}
}
;
}
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.
: <>
RPPS du praticien
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é'}
{selected &&
}
;
})}
{error && {error}
}
void submit()}
style={{ width: '100%', marginTop: 16 }}>
Créer l’invitation
>}
;
}
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) &&
setAction({
invitationId: invitation.id, kind: pending ? 'revoke' : 'renew',
})}
style={{ width: '100%', marginTop: 9 }}>
{pending ? 'Annuler l’invitation' : 'Renvoyer l’invitation'}
}
;
})}
: 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} ?`}
setAction(null)}>Retour
void confirm()}>
{action.kind === 'revoke' ? 'Confirmer l’annulation' : 'Confirmer le renvoi'}
}
;
}
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' : ''}
begin(assignment)}>Quitter
;
})}
}
{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.
}
setTargetId(null)}>Annuler
void confirm()}>Quitter ce service
}
;
}
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'}
setTargetId(assignment.assignmentId)}>Retirer
)}
: Aucune affectation d’un autre praticien.
)}
{target &&
Retirer {target.practitionerName} du service {target.departmentName} ?
setTargetId(null)}>Annuler
void confirm()}>Retirer l’affectation
}
;
}
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;