// Tableau de bord en disposition bureau.
//
// Cette couche ne crée aucune donnée : elle redistribue en grille ce que le
// tableau de bord téléphone affiche déjà, depuis les mêmes projections
// (`VASCO_RUNTIME.dashboard`, `TODO_REPORTS`, `AGENDA_TODAY`, `ACTIVITE`) et les
// mêmes chargeurs (`VascoApi`). Chaque geste appelle le même rappel et ouvre la
// même feuille que sur le téléphone.
//
// Sous le seuil de bureau, le composant rend exactement la version enveloppée
// précédente : le rendu téléphone ne change pas d'un pixel. Le fichier est donc
// chargé après toutes les couches qui remplacent `Dashboard`, et enveloppe la
// dernière version, jamais la référence figée.
const VASCO_DESKTOP_LEGACY_DASHBOARD = Dashboard;
function vascoDesktopPlural(count, one, many) {
return count > 1 ? many : one;
}
function vascoDesktopCapability(capabilities, name) {
return (capabilities && capabilities[name]) || null;
}
// Refus serveur : le motif vient du serveur, jamais d'un texte inventé ici.
function vascoDesktopRefusal(capability) {
return typeof dbReason === 'function' ? dbReason(capability) : 'Action indisponible';
}
function vascoDesktopBlocked(capability) {
return Boolean(capability && capability.enabled === false);
}
function VascoDesktopTypeBadge({ type }) {
const tokens = window.VascoUI.tokens;
return {type} ;
}
// Toutes les lignes de panneau partagent la même hauteur, la même séparation et
// le même geste clavier : `InteractiveRow` du socle porte le bouton, le focus et
// l'étiquette ; la teinte de survol vient de la feuille de style de la scène.
function VascoDesktopRowList({ children }) {
const tokens = window.VascoUI.tokens;
return
{React.Children.map(children, (child, index) =>
{index > 0 &&
}
{child}
)}
;
}
function VascoDesktopRow({ ariaLabel, onClick = null, leading = null, trailing = null,
primary, secondary = null }) {
const tokens = window.VascoUI.tokens;
const { InteractiveRow } = window.VascoUI;
const text =
{primary}
{secondary && {secondary} }
;
const shared = {
width: '100%', minHeight: tokens.layout.panel.rowHeight,
padding: `0 ${tokens.space.xs}px`, borderRadius: 0, border: 'none',
background: 'transparent', gap: tokens.space.md,
};
if (!onClick) {
return {leading}{text}{trailing}
;
}
return {text} ;
}
// ── À finaliser ─────────────────────────────────────────────────────────────
// Source : `TODO_REPORTS`, la file de /api/v1/dashboard/reports projetée par
// api-client.js. Geste : ouvrir la feuille de validation du téléphone, centrée
// sur le document touché comme le fait déjà une notification.
function VascoDesktopFinalizePanel({ reports, capability, onOpenAll, onOpenReport }) {
const tokens = window.VascoUI.tokens;
const { InlineAction, Panel } = window.VascoUI;
const counts = reports.reduce((acc, report) => {
acc[report.type] = (acc[report.type] || 0) + 1;
return acc;
}, {});
const spread = Object.keys(counts).sort().map((type) => `${counts[type]} ${type}`).join(' · ');
const blocked = vascoDesktopBlocked(capability);
return Tout ouvrir
: null}
footer={!blocked && spread ? {spread} : null}>
{reports.map((report) => onOpenReport(report)}
leading={ }
primary={report.patient}
secondary={report.procedure}
trailing={{report.started} } />)}
;
}
// ── Aujourd'hui ─────────────────────────────────────────────────────────────
// Source : `AGENDA_TODAY`, rempli par `VascoApi.loadAgenda()` depuis la même
// fenêtre que l'onglet Agenda. La charge du jour vient de `day_load`, comme la
// carte « Charge du jour » du téléphone. Geste : ouvrir l'agenda, le seul que la
// carte « Prochain RDV » propose sur le téléphone.
function VascoDesktopTodayPanel({ dayLoad, nextTime, onOpenAgenda, capability }) {
const tokens = window.VascoUI.tokens;
const { InlineAction, Panel } = window.VascoUI;
const [state, setState] = React.useState('loading');
const [rows, setRows] = React.useState([]);
const [failure, setFailure] = React.useState(null);
const load = React.useCallback(async () => {
const api = window.VascoApi;
if (!api || typeof api.loadAgenda !== 'function') {
setState('error');
setFailure('L’agenda du jour n’est pas raccordé.');
return;
}
setState('loading');
setFailure(null);
try {
await api.loadAgenda();
setRows((window.AGENDA_TODAY || []).slice());
setState('ready');
} catch (error) {
setRows([]);
setState('error');
setFailure(error?.message || 'Agenda indisponible');
}
}, []);
React.useEffect(() => { void load(); }, [load]);
// `day_load` est le décompte du serveur, pas la liste chargée : les deux
// projections peuvent différer. On nomme donc la mesure au lieu de poser deux
// nombres contradictoires côte à côte sans dire d'où ils viennent.
const spread = dayLoad ? `Charge du jour : ${[
`${dayLoad.consultations} ${vascoDesktopPlural(dayLoad.consultations, 'consultation et suivi', 'consultations et suivis')}`,
`${dayLoad.procedures} ${vascoDesktopPlural(dayLoad.procedures, 'intervention', 'interventions')}`,
`${dayLoad.emergencies} ${vascoDesktopPlural(dayLoad.emergencies, 'urgence', 'urgences')}`,
].join(' · ')}` : null;
const blocked = vascoDesktopBlocked(capability);
return Ouvrir l’agenda
: null}
footer={spread ? {spread} : null}>
{rows.map((row, index) => {row.time}}
primary={row.label}
secondary={row.sub} />)}
;
}
// ── Suivis en retard ────────────────────────────────────────────────────────
// Source et feuille : celles de `postop-followup.jsx`, sans les recopier. La
// ligne ouvre le dossier à l'onglet post-opératoire, comme la feuille mobile.
function VascoDesktopFollowUpPanel({ accent, onOpenPatient }) {
const tokens = window.VascoUI.tokens;
const { InlineAction, Panel } = window.VascoUI;
const runtime = window.VascoApi?.dashboard?.overdueFollowUps;
const [state, setState] = React.useState('loading');
const [payload, setPayload] = React.useState(null);
const [failure, setFailure] = React.useState(null);
const [open, setOpen] = React.useState(false);
const load = React.useCallback(async () => {
if (!runtime) {
setState('error');
setFailure('Les surveillances en retard ne sont pas raccordées.');
return;
}
setState('loading');
setFailure(null);
try {
setPayload(await runtime.load());
setState('ready');
} catch (error) {
setPayload(null);
setState('error');
setFailure(error?.message || 'Chargement impossible');
}
}, [runtime]);
React.useEffect(() => { void load(); }, [load]);
const items = state === 'ready' ? (payload?.items || []) : [];
const visible = items.slice(0, 5);
return <>
setOpen(true)} ariaLabel="Ouvrir les surveillances en retard">
Tout ouvrir
: null}
footer={visible.length < items.length ? {items.length - visible.length} de plus : null}>
{visible.map((item) => {
const destination = window.VascoPostopFollowUp?.dashboardTarget(item) || null;
return onOpenPatient && onOpenPatient(destination.patientId, destination.tab)
: null}
primary={item.patient.displayName}
secondary={item.patient.archived ? 'Patient archivé' : item.interventionLabel}
trailing={{item.daysOverdue} J } />;
})}
{open && setOpen(false)}
onReload={load} onOpenPatient={onOpenPatient || (() => {})} />}
>;
}
// ── Assistant ───────────────────────────────────────────────────────────────
// Source : la route d'historique de l'écran Assistant. Geste : ouvrir la
// conversation choisie, par l'aiguillage d'onglet qui sert déjà au Compte.
function VascoDesktopAssistantPanel({ onOpenConversation, onOpenHistory }) {
const tokens = window.VascoUI.tokens;
const { InlineAction, Panel } = window.VascoUI;
const runtime = window.VascoApi?.assistant;
const [state, setState] = React.useState('loading');
const [items, setItems] = React.useState([]);
const [failure, setFailure] = React.useState(null);
const load = React.useCallback(async () => {
if (!runtime || typeof runtime.listConversations !== 'function') {
setState('error');
setFailure('L’historique de l’assistant n’est pas raccordé.');
return;
}
setState('loading');
setFailure(null);
try {
setItems(await runtime.listConversations());
setState('ready');
} catch (error) {
setItems([]);
setState('error');
setFailure(error?.message || 'Historique indisponible');
}
}, [runtime]);
React.useEffect(() => { void load(); }, [load]);
return
Ouvrir l’assistant
: null}>
{items.slice(0, 4).map((conversation) => onOpenConversation(conversation)}
primary={conversation.title}
secondary={conversation.preview || null}
trailing={{conversation.date} } />)}
;
}
// ── Mon activité ────────────────────────────────────────────────────────────
// Source : `ACTIVITE`, projeté depuis `dashboard.activity`. Même geste que la
// carte du téléphone : ouvrir les statistiques du Compte.
function VascoDesktopActivityPanel({ onOpenStats, capability }) {
const tokens = window.VascoUI.tokens;
const { InlineAction, Panel } = window.VascoUI;
const activity = window.ACTIVITE || null;
const kpis = (activity?.kpis || []).filter((kpi) => kpi.key === 'crs' || kpi.key === 'rdvs');
const blocked = vascoDesktopBlocked(capability);
return Détail
: null}
footer={!blocked && activity?.periode ? {activity.periode} : null}>
{kpis.map((kpi) =>
{kpi.value}
{kpi.delta}
{kpi.label.toLowerCase()}
)}
;
}
// ── Recherche CCAM ──────────────────────────────────────────────────────────
// Le référentiel n'est pas raccordé sur cet environnement. Le panneau ne promet
// rien : il affiche le motif renvoyé par le serveur, comme la carte du téléphone.
function VascoDesktopCcamPanel({ capability, onOpen }) {
const tokens = window.VascoUI.tokens;
const { Panel } = window.VascoUI;
const blocked = vascoDesktopBlocked(capability);
return
}
primary="Code, acte, catégorie…" />
;
}
// ── La page ─────────────────────────────────────────────────────────────────
function VascoDesktopDashboardPage({ accent, onChangeTab, onOpenPatient, onSlideToChat,
onOpenStats, runtime = null, dictationRuntime = null }) {
const tokens = window.VascoUI.tokens;
const { Button, PageHeader, PageGrid, PageColumn, icons } = window.VascoUI;
const payload = window.VASCO_RUNTIME && window.VASCO_RUNTIME.dashboard;
const capabilities = payload?.capabilities || null;
const practitionerName = payload?.practitioner?.display_name || 'Dr.X';
const patientsRemaining = payload?.summary?.patients_remaining ?? 0;
const timeZone = payload?.time_zone || 'Europe/Paris';
const [reports, setReports] = React.useState(() => (window.TODO_REPORTS || []).slice());
const [crSheet, setCrSheet] = React.useState(null);
const [focusReportId, setFocusReportId] = React.useState(null);
const [dictating, setDictating] = React.useState(false);
const [pickingDictationPatient, setPickingDictationPatient] = React.useState(false);
const [ccamOpen, setCcamOpen] = React.useState(false);
const nextTime = payload?.next_appointment
? new Intl.DateTimeFormat('fr-FR', {
hour: '2-digit', minute: '2-digit', hour12: false, timeZone,
}).format(new Date(payload.next_appointment.starts_at))
: null;
// « Avec un patient » promettait un patient qu'il ne donnait jamais : au
// téléphone c'est le micro d'accueil qui arme le dossier avant d'ouvrir le
// parcours, et cette page de bureau ne monte pas ce micro. On arme donc par
// la feuille de choix du parcours lui-même, avant d'ouvrir, plutôt que de
// laisser un bouton ouvrir une dictée sans patient.
const startDictation = (mode, name) => {
const capability = vascoDesktopCapability(capabilities, name);
if (vascoDesktopBlocked(capability)) {
showToast(vascoDesktopRefusal(capability), 'error');
return;
}
if (mode !== 'full') { setDictating(mode); return; }
if (!dictationRuntime?.patients) {
showToast('La recherche patient est indisponible', 'error');
return;
}
setPickingDictationPatient(true);
};
const openAgenda = () => onChangeTab && onChangeTab('AGE');
const actions = <>
{/* Le micro de l'accueil devient l'action principale de l'en-tête : même
tracé que l'onglet Dictée, à la moitié d'un petit contrôle. */}
}
onClick={() => startDictation('quick', 'start_quick_dictation')}>Dicter
startDictation('full', 'start_dictation')}>Avec un patient
onChangeTab && onChangeTab(route)}
onOpenPatient={(id) => onOpenPatient && onOpenPatient(id)}
onOpenReport={(documentId) => { setFocusReportId(documentId); setCrSheet('all'); }} />
>;
const subtitle = <>
{patientsRemaining} {vascoDesktopPlural(patientsRemaining, 'patient restant', 'patients restants')} aujourd’hui
·
{reports.length} CR à finaliser
>;
return
Bonjour, {practitionerName} >}
subtitle={subtitle}
actions={actions} />
{ setFocusReportId(null); setCrSheet('all'); }}
onOpenReport={(report) => { setFocusReportId(report.id); setCrSheet('all'); }} />
onChangeTab && onChangeTab('IA', { openHistory: true })}
onOpenConversation={(conversation) => {
if (onChangeTab) onChangeTab('IA', { conversationId: conversation.id });
else if (onSlideToChat) onSlideToChat();
}} />
setCcamOpen(true)} />
{crSheet && { setFocusReportId(null); setCrSheet(filter); }}
onValidate={(document) => setReports((previous) => previous.filter((item) => item.id !== document.id))}
onClose={() => { setFocusReportId(null); setCrSheet(null); }} />}
{ccamOpen && setCcamOpen(false)} />}
{pickingDictationPatient && {
window.VascoDictationArmedPatients.arm(picked);
setPickingDictationPatient(false);
setDictating('full');
}}
onClose={() => setPickingDictationPatient(false)} />}
{dictating && setDictating(false)}
onSent={(conversation) => {
void runtime?.refresh().catch(() => {});
if (onChangeTab) onChangeTab('IA', { conversationId: conversation.id });
}} />}
;
}
Dashboard = function VascoDashboardForLayout(props) {
if (window.VascoUI.useLayout() !== 'desktop') {
return ;
}
return ;
};
window.Dashboard = Dashboard;