// Extension tactile du widget CCAM. La V5 de référence reste gelée : ce // composant remplace son symbole global avant le montage de la coque. (function installCcamTactile() { 'use strict'; const contract = window.VascoCcamTactile; function money(minor) { if (minor === null || minor === undefined) return 'Tarif non publié'; return `${(minor / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} €`; } function billed(act) { if (!contract.isPriced(act)) return 'Tarif non publié'; const rate = act.billing_rate_basis_points; if (rate === null || rate === undefined) return money(act.amount_minor); return money(Math.floor((act.amount_minor * rate + 5000) / 10000)); } function rateLabel(act) { const percent = contract.billingRatePercent(act); return percent === null ? '– %' : `${percent.toLocaleString('fr-FR', { maximumFractionDigits: 2, })} %`; } function positionLabel(index) { return index === 0 ? '1re' : `${index + 1}e`; } function positionActionLabel(acts, targetIndex) { return `Passer en ${positionLabel(targetIndex)} position · ${rateLabel(acts[targetIndex])}`; } function capability(data, action) { return (data._vasco_widget_capabilities || []).find((entry) => entry.action === action) || null; } function TactileCheckbox({ act, disabled, onChange }) { const checked = contract.isSelected(act); return ( ); } function CcamActionSheet({ open, data, act, busy, editable, onClose, onCommand }) { const acts = contract.allActs(data); const sheetAct = act || { code: '', label: '', amount_minor: null, selected: false }; const index = acts.findIndex((item) => item.code === sheetAct.code); const selected = contract.isSelected(sheetAct); const { BottomSheet, Button, tokens } = window.VascoUI; const moveActionCount = Number(selected && index > 0) + Number(selected && index >= 0 && index < acts.length - 1); const positionButtonStyle = { fontSize: 12 }; const metrics = [ ['Catalogue', money(sheetAct.amount_minor)], ['Coefficient', rateLabel(sheetAct)], ['Facturé', selected ? billed(sheetAct) : 'Non retenu'], ]; return
{metrics.map(([label, value]) =>
{label}
{value}
)}
{/* Même composition que les actions de la fiche rendez-vous : les actions sont des surfaces jumelles, pas des liens ni des pilules isolées. */}
{selected && index > 0 && } {selected && index >= 0 && index < acts.length - 1 && }
; } function TactileCcamWidget({ data: incoming, onAdd, patientId, onWidgetUpdated = null, allowCroSelection = true, refreshKey = null }) { const { InlineAction, SimpleSelect } = window.VascoUI; const [data, setData] = React.useState(incoming); const [openedCode, setOpenedCode] = React.useState(null); const [busy, setBusy] = React.useState(null); const [error, setError] = React.useState(null); const [croDocuments, setCroDocuments] = React.useState([]); const [croLoadState, setCroLoadState] = React.useState('idle'); const attempts = React.useRef({}); const rowRefs = React.useRef({}); React.useEffect(() => { if ((incoming?._vasco_widget_version || 0) > (data?._vasco_widget_version || 0)) setData(incoming); }, [incoming?._vasco_widget_version]); React.useEffect(() => { if (!allowCroSelection || !patientId || !window.VascoApi?.patient?.documents) return undefined; const controller = new AbortController(); setCroLoadState('loading'); window.VascoApi.patient.documents(patientId, 'CRO', { signal: controller.signal }) .then((page) => { setCroDocuments((page.items || []).filter((document) => document.status !== 'archived')); setCroLoadState('ready'); }) .catch((failure) => { if (failure?.name !== 'AbortError') setCroLoadState('error'); }); return () => controller.abort(); }, [patientId, allowCroSelection, refreshKey, incoming?._vasco_widget_version]); const randomKey = () => window.crypto?.randomUUID?.() || `vasco-ccam-${Date.now()}-${Math.random()}`; const widgetRef = () => ({ id: data._vasco_widget_id, version: data._vasco_widget_version, type: 'ccam', }); const reReadAfterConflict = async () => { const conversationId = data._vasco_conversation_id; if (!conversationId) return false; const conversation = await window.VascoApi.assistant.getConversation(conversationId); const fresh = conversation.messages.find((message) => message.widgetId === data._vasco_widget_id); if (!fresh?.data) return false; setData(fresh.data); return true; }; const run = async (action, options, signature) => { if (!data._vasco_widget_id || busy) return; const stable = attempts.current[signature] || randomKey(); attempts.current[signature] = stable; setBusy(signature); setError(null); try { const result = await window.VascoApi.assistant.widgetAction(widgetRef(), action, { ...(options || {}), idempotencyKey: stable, }); const next = { ...result.widget.data, _vasco_conversation_id: data._vasco_conversation_id, _vasco_widget_id: result.widget.id, _vasco_widget_version: result.widget.version, _vasco_widget_status: result.widget.status, _vasco_widget_capabilities: result.widget.capabilities || [], _vasco_cro_document_id: result.widget.type === 'ccam' ? (result.widget.cro_document_id || null) : null, }; setData(next); // Le composant est aussi consommé depuis le CRO. Remonter toute mutation // d'état garde la fiche documentaire et le fil Assistant synchronisés avec // la version exacte renvoyée par le serveur. Copier ne change pas la fiche. if (action !== 'copy') onWidgetUpdated?.(result.widget); delete attempts.current[signature]; return result; } catch (failure) { if (failure?.status === 409 && await reReadAfterConflict()) { delete attempts.current[signature]; setError('La fiche a changé ailleurs. Elle a été actualisée ; votre action reste à refaire.'); } else { // Une perte réseau conserve la même clé : le second appui rejoue la // même commande. Les autres refus peuvent repartir sur une commande neuve. if (failure?.code !== 'network_error' && failure?.code !== 'request_timeout') { delete attempts.current[signature]; } setError(contract.errorMessage(failure)); } return null; } finally { setBusy(null); } }; const edit = async (command) => { const replacement = contract.replacement(data, command); if (!replacement) return; const result = await run('edit', { replacement }, `edit:${JSON.stringify(command)}`); if (result) setOpenedCode(null); }; const add = async (code) => { const selected = contract.allActs(data).filter(contract.isSelected).map((act) => act.code); const result = await run('edit', { replacement: { selected_codes: selected, added_codes: [code] }, }, `add:${code}`); if (result && onAdd) { /* la recherche se referme dans CCAMAddAct */ } }; const copy = async () => { const result = await run('copy', null, 'copy'); if (!result || !navigator.clipboard?.writeText) return; const text = contract.allActs(data).map((act) => `${act.code} – ${act.label}`).join('\n'); try { await navigator.clipboard.writeText(text); } catch { /* reçu serveur acquis */ } }; const selectCro = async (croDocumentId) => { const selected = contract.allActs(data).filter(contract.isSelected).map((act) => act.code); await run('edit', { replacement: { selected_codes: selected, cro_document_id: croDocumentId || null, }, }, `cro:${croDocumentId || 'none'}`); }; const acts = contract.allActs(data); const opened = acts.find((act) => act.code === openedCode) || null; const closeSheet = () => { const code = openedCode; setOpenedCode(null); window.setTimeout(() => rowRefs.current[code]?.focus({ preventScroll: true }), window.VascoUI.tokens.motion.sheetExit); }; const discarded = Boolean(data.discarded_at); const confirmed = Boolean(data.confirmed_at); const editCapability = capability(data, 'edit'); const confirmCapability = capability(data, 'confirm'); const canEdit = Boolean(data._vasco_widget_id) && (!editCapability || editCapability.enabled !== false); const hasSelectedActs = acts.some(contract.isSelected); const canConfirm = (!confirmCapability || confirmCapability.enabled !== false) && hasSelectedActs; const croDocumentId = data._vasco_cro_document_id || null; const selectedCro = croDocuments.find((document) => document.id === croDocumentId) || null; const croOptions = croDocuments.map((document) => [ document.id, `${document.label}${document.d ? ` · ${document.d}` : ''}`, ]); return (
Codage CCAM – {data.intervention} {data._vasco_widget_id && ( void copy()} disabled={Boolean(busy)} ariaLabel="Copier le codage">Copier )}
CRO rattaché
{allowCroSelection ? void selectCro(value)} /> :
{croDocumentId ? data.intervention : 'CRO non sélectionné'}
} {!croDocumentId && croLoadState !== 'loading' && (
Sélection requise avant confirmation.
)} {croLoadState === 'error' && (
Les CRO du dossier n’ont pas pu être chargés.
)}
{error &&
{error}
}
{acts.map((act) => { const selected = contract.isSelected(act); const nonBillable = contract.nonBillableRank(act); const amount = !selected ? 'Non retenu' : contract.contributes(act) ? billed(act) : nonBillable ? 'Non facturable à ce rang' : money(act.amount_minor); return (
{ rowRefs.current[act.code] = node; }} className="ccam-tactile-row" role="button" tabIndex={0} aria-label={`Ouvrir les actions de ${act.code}`} onClick={() => setOpenedCode(act.code)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setOpenedCode(act.code); } }} style={{ minHeight: 56, display: 'flex', alignItems: 'center', gap: 8, borderBottom: `1px solid ${CH_HAIR}`, cursor: 'pointer', }}> void edit({ type: 'toggle', code: act.code })} />
{act.code} {money(act.amount_minor)} · {rateLabel(act)}
{act.label}
{amount}
); })}
{!hasSelectedActs && !discarded && (
Sélectionnez au moins un acte avant de confirmer.
)} {data._vasco_widget_id && canEdit && ( void add(code)} existing={acts.map((act) => act.code)} /> )}
Total estimé après coefficient {money(data.total_minor)}
{data._vasco_widget_id && (
{discarded ? ( <> Codage écarté void run('discard', { discarded: false }, 'restore')} style={{ color: CH_SUB }}>Rétablir ) : ( <> void run('discard', { discarded: true }, 'discard')}>Rejeter void run('confirm', null, 'confirm')} style={{ color: confirmed ? CH_OK : CH_SUB }}> {confirmed ? 'Confirmé' : 'Confirmer'} )}
)} void edit(command)} />
); } window.WidgetCCAM = TactileCcamWidget; })();