// 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]) =>
)}
{/* 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;
})();