const PATIENT_VIEW_MOTION = window.VascoUI?.tokens?.motion || Object.freeze({ quick: 150 });
// La maquette V5 conserve sa palette d'origine. La couche produit remplace les
// tons très saturés par cinq familles plus sourdes, tout en gardant la même
// couleur pour un patient dans la pile, la liste et la fiche dépliée.
const PATIENT_FOLDER_PALETTE = [
{ id: 'sand', body: '#F2E9D8', tab: '#E8DBC3', spine: '#D5C09C', deep: '#BFA77D' },
{ id: 'sage', body: '#E2EBE4', tab: '#D2E0D5', spine: '#B4C9BA', deep: '#91AB99' },
{ id: 'dusty-rose', body: '#F0E3E5', tab: '#E4D2D6', spine: '#CFAEB7', deep: '#B88996' },
{ id: 'mist-blue', body: '#DFE8ED', tab: '#CDDCE3', spine: '#AFC4CF', deep: '#8DAAB8' },
{ id: 'soft-plum', body: '#E8E2EB', tab: '#D9CFDE', spine: '#BCAEC5', deep: '#9B899F' },
];
FOLDER_COLORS.splice(0, FOLDER_COLORS.length, ...PATIENT_FOLDER_PALETTE);
// Une icône par sexe : mêmes couleurs et tracés, indépendamment du patient.
const PATIENT_PORTRAIT_PALETTE = Object.freeze({ skin: '#E3B894', hair: '#3D2817', bg: '#9B7355' });
function PatientSexAwarePortrait({ patient, rounded = false }) {
const palette = PATIENT_PORTRAIT_PALETTE;
const hair = window.VascoPatientViewFilter.portraitHairForSex(patient.sex);
return (
);
}
Portrait = PatientSexAwarePortrait;
// L'écran Patients actif conserve les deux présentations utiles de la maquette :
// la pile détaillée et la liste compacte. La grille d'icônes répétait les mêmes
// patients avec moins d'informations, donc elle ne fait plus partie du sélecteur.
function PatientViewToggle({ mode, onChange }) {
const { IconButton } = window.VascoUI;
const modes = [
{
id: 'stack',
label: 'Cartes superposées',
icon: <>>,
},
{
id: 'list',
label: 'Liste compacte',
icon: <>>,
},
];
return (
{modes.map((m) => {
const selected = mode === m.id;
return (
onChange(m.id)} size={30} style={{
width: 30, height: 28, borderRadius: 8,
background: selected ? PF_SURF : 'transparent',
border: selected ? `1px solid ${PF_HAIR}` : '1px solid transparent',
boxShadow: selected ? '0 1px 3px rgba(10,10,10,0.08)' : 'none',
cursor: 'pointer', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: `all ${PATIENT_VIEW_MOTION.quick}ms ease`,
}}>
);
})}
);
}
ViewToggle = PatientViewToggle;
// La V5 rassemble toutes les lignes de la fiche dépliée dans une seule carte.
// Le rendez-vous relève de l'agenda : il devient une carte autonome, tandis que
// les informations du patient conservent leur carte et leur ordre d'origine.
const PatientFocusDetailsBase = FocusDetails;
function patientViewContainsText(node, text) {
if (typeof node === 'string') return node.includes(text);
if (Array.isArray(node)) return node.some((child) => patientViewContainsText(child, text));
if (!React.isValidElement(node)) return false;
return patientViewContainsText(node.props?.children, text);
}
function patientViewNormalizeRows(rows) {
return rows.map((row, index) => React.cloneElement(row, {
...row.props,
style: {
...row.props.style,
borderTop: index === 0 ? 'none' : `1px solid ${PF_HAIR}`,
},
}));
}
function PatientSectionedFocusDetails(props) {
const rendered = PatientFocusDetailsBase(props);
if (!props.patient?.nextRdv || !React.isValidElement(rendered)) return rendered;
const sections = React.Children.toArray(rendered.props.children);
const infoIndex = sections.findIndex((child) => (
React.isValidElement(child)
&& child.type === 'div'
&& patientViewContainsText(child, 'Prochain RDV')
));
if (infoIndex < 0) return rendered;
const infoCard = sections[infoIndex];
const rows = React.Children.toArray(infoCard.props.children);
const appointmentRows = rows.filter((row) => patientViewContainsText(row, 'Prochain RDV'));
const patientRows = rows.filter((row) => !patientViewContainsText(row, 'Prochain RDV'));
if (!appointmentRows.length) return rendered;
const sharedStyle = { ...infoCard.props.style };
const replacements = [];
if (patientRows.length) {
replacements.push(React.cloneElement(infoCard, {
...infoCard.props,
key: 'patient-focus-information',
role: 'group',
'aria-label': 'Informations patient',
style: { ...sharedStyle, margin: '0 8px 8px' },
}, patientViewNormalizeRows(patientRows)));
}
replacements.push(
,
);
sections.splice(
infoIndex,
1,
...replacements,
);
return React.cloneElement(rendered, rendered.props, sections);
}
FocusDetails = PatientSectionedFocusDetails;
// Le filtre s'insère à côté du tri sans recopier l'écran Patients gelé. Un
// contexte porte le choix, puis les deux vues actives reçoivent la même liste.
const LegacyPatientFoldersForFilter = PatientFolders;
const LegacyPatientSortPickerForFilter = SortPicker;
const LegacyPatientWalletCardsForFilter = WalletCards;
const LegacyPatientListViewForFilter = PatientListView;
const PatientFilterContext = React.createContext({
criteria: { status: 'all', sex: 'all' }, setCriteria: () => {},
});
function PatientFilterPicker() {
const { Button, IconButton, InteractiveRow } = window.VascoUI;
const { criteria, setCriteria } = React.useContext(PatientFilterContext);
const [open, setOpen] = React.useState(false);
const { FILTERS: statusFilters, SEX_FILTERS: sexFilters } = window.VascoPatientViewFilter;
const active = criteria.status !== 'all' || criteria.sex !== 'all';
const activeLabels = [
criteria.status === 'all' ? null : statusFilters.find((item) => item.id === criteria.status)?.label,
criteria.sex === 'all' ? null : sexFilters.find((item) => item.id === criteria.sex)?.label,
].filter(Boolean);
const change = (key, value) => setCriteria((current) => ({ ...current, [key]: value }));
const choices = (items, key) => items.map((item) => {
const checked = item.id === criteria[key];
return (
change(key, item.id)} style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 9,
padding: '7px 10px', borderRadius: 9, border: 'none',
background: checked ? PF_MED_TINT : 'transparent',
color: checked ? PF_MED : PF_INK, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 12.5, fontWeight: 600,
textAlign: 'left',
}}>
{item.label}
);
});
return (
setOpen((value) => !value)} style={{
height: 32, borderRadius: 12,
background: active ? PF_MED_TINT : 'rgba(10,10,10,0.04)',
border: 'none', cursor: 'pointer', position: 'relative',
}}>
{active && }
{open && <>
setOpen(false)} style={{
position: 'fixed', inset: 0, zIndex: 30,
}} />
Statut clinique
{choices(statusFilters, 'status')}
Sexe
{choices(sexFilters, 'sex')}
>}
);
}
function PatientSortAndFilter(props) {
return <>
>;
}
function FilteredPatientWalletCards(props) {
const { criteria } = React.useContext(PatientFilterContext);
const patients = window.VascoPatientViewFilter.filterPatients(props.patients, criteria);
return
;
}
function FilteredPatientListView(props) {
const { criteria } = React.useContext(PatientFilterContext);
const patients = window.VascoPatientViewFilter.filterPatients(props.patients, criteria);
return
;
}
function FilterablePatientFolders(props) {
const [criteria, setCriteria] = React.useState({ status: 'all', sex: 'all' });
const state = React.useMemo(() => ({ criteria, setCriteria }), [criteria]);
return (
);
}
SortPicker = PatientSortAndFilter;
WalletCards = FilteredPatientWalletCards;
PatientListView = FilteredPatientListView;
PatientFolders = FilterablePatientFolders;