Files
project.linkedcorp.net/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx
T
Milton Gonzalez 00aaf1ebef fix: version 4.8.1 -- Agenda medica abre en vista Mes como el Calendario
Barra igual a la del Calendario (Hoy, anterior/siguiente, mes y ano; vista a la
derecha), vista Mes por defecto y aviso cuando no hay medicos configurados (antes
la vista Dia quedaba vacia).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:45:58 -05:00

779 lines
41 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Stethoscope, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { DatePicker } from '../../components/DatePicker.jsx';
import { MonthGrid, monthGridDays } from '../../components/MonthGrid.jsx';
import * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js';
import './clinic.css';
const dayKey = d => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
const today = () => {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Guayaquil',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date()).map(p => [p.type, p.value]));
return `${parts.year}-${parts.month}-${parts.day}`;
};
const move = (day, n) => dayKey(new Date(new Date(`${day}T12:00:00Z`).getTime() + n * 86400000));
const at = (day, hour = '09:00') => `${day} ${hour}:00`;
const plus = (s, n) => {
const d = new Date(`${s.replace(' ', 'T')}Z`);
return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
};
const initialAppointment = (day, doctorId, slot) => ({
doctorId: doctorId || '',
patientId: '',
typeId: '',
startsAt: slot || at(day),
endsAt: plus(slot || at(day), 30),
channel: 'phone',
notes: ''
});
const initialPatient = () => ({
firstNames: '',
lastNames: '',
identification: '',
phone: '',
email: '',
birthDate: '',
notes: ''
});
const initialBlock = (day, doctorId) => ({
doctorId: doctorId || '',
kind: 'surgery',
title: '',
startsAt: at(day),
endsAt: plus(at(day), 60),
recurrence: null
});
const formAppointment = a => ({
doctorId: a.doctor_user_id,
patientId: a.patient_id,
typeId: a.type_id,
startsAt: a.starts_at,
endsAt: a.ends_at,
channel: a.channel,
notes: a.notes || '',
editVersion: a.edit_version
});
const formPatient = p => ({
firstNames: p.first_names,
lastNames: p.last_names,
identification: p.identification || '',
phone: p.phone,
email: p.email || '',
birthDate: p.birth_date || '',
notes: p.notes || '',
editVersion: p.edit_version
});
const formBlock = b => ({
doctorId: b.doctor_user_id,
kind: b.kind,
title: b.title,
startsAt: b.starts_at,
endsAt: b.ends_at,
recurrence: b.recurrence_json ? JSON.parse(b.recurrence_json) : null,
editVersion: b.edit_version
});
const readStored = (key, fallback) => {
try {
return JSON.parse(localStorage.getItem(key)) ?? fallback;
} catch {
return fallback;
}
};
export default function AgendaMedicaPage() {
const {
t,
lang,
openDialog,
pushToast
} = useUi();
const {
user
} = useAuth();
const guardedClose = useGuardedClose();
const [me, setMe] = useState(null),
[error, setError] = useState(''),
[doctors, setDoctors] = useState([]),
[types, setTypes] = useState([]),
[allTypes, setAllTypes] = useState([]);
const [tab, setTab] = useState('agenda'),
[view, setView] = useState(() => { const v = readStored('plc_clinic_view_v2', 'month'); return ['day', 'week', 'month', 'list'].includes(v) ? v : 'month'; }),
[day, setDay] = useState(today);
const [doctorFilter, setDoctorFilter] = useState(() => {
const value = readStored('plc_clinic_doctors', []);
return Array.isArray(value) ? value : [];
});
const [statusFilter, setStatusFilter] = useState(() => {
const value = readStored('plc_clinic_status', ['scheduled', 'confirmed', 'attended', 'no_show']);
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
});
const [appointments, setAppointments] = useState([]),
[blocks, setBlocks] = useState([]),
[patients, setPatients] = useState([]),
[search, setSearch] = useState(''),
[page, setPage] = useState(1),
[patientTotal, setPatientTotal] = useState(0);
const [popup, setPopup] = useState(null),
[editing, setEditing] = useState(false),
[conflict, setConflict] = useState(''),
[affected, setAffected] = useState([]),
[slots, setSlots] = useState([]),
[cancelReason, setCancelReason] = useState(''),
[cancelMode, setCancelMode] = useState(false),
[patientHistory, setPatientHistory] = useState([]),
[quickPatientId, setQuickPatientId] = useState(null),
[reprogram, setReprogram] = useState([]);
const [allUsers, setAllUsers] = useState([]),
[staffRows, setStaffRows] = useState([]),
[staffUser, setStaffUser] = useState(''),
[staffInitial, setStaffInitial] = useState({
scope: 'own',
isDoctor: true,
specialty: '',
color: '#0078d4',
isActive: true
});
const [typeInitial, setTypeInitial] = useState({
name: '',
durationMinutes: 30,
color: '#0078d4',
isActive: true
});
const [scheduleDoctor, setScheduleDoctor] = useState(''),
[scheduleInitial, setScheduleInitial] = useState({
rows: []
});
const staffDraft = useDraftForm({
formId: 'clinic-staff',
entityId: staffUser || null,
initial: staffInitial,
label: t('clinic_staff'),
enabled: tab === 'config'
});
const typeDraft = useDraftForm({
formId: 'clinic-type',
entityId: typeInitial.id || null,
initial: typeInitial,
label: t('clinic_types'),
enabled: tab === 'config'
});
const scheduleDraft = useDraftForm({
formId: 'clinic-schedule',
entityId: scheduleDoctor || null,
initial: scheduleInitial,
label: t('clinic_schedules'),
enabled: tab === 'config'
});
const staffForm = staffDraft.values,
typeForm = typeDraft.values,
scheduleRows = scheduleDraft.values.rows || [];
const setStaffForm = staffDraft.setValues,
setTypeForm = typeDraft.setValues;
const setScheduleRows = rows => scheduleDraft.setField('rows', rows);
const isReception = me?.scope === 'all',
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
const period = useMemo(() => {
let from = day,
to = move(day, 1);
if (view === 'week') {
const d = new Date(`${day}T12:00:00Z`),
offset = (d.getUTCDay() + 6) % 7;
from = move(day, -offset);
to = move(from, 7);
}
if (view === 'month') {
const cells = monthGridDays(day);
from = cells[0];
to = move(cells.at(-1), 1);
}
if (view === 'list') to = move(day, 30);
return {
from: at(from, '00:00'),
to: at(to, '00:00')
};
}, [day, view]);
async function load() {
if (!me) return;
try {
const [a, b] = await Promise.all([api.clinicAppointments(period.from, period.to), api.clinicBlocks(period.from, period.to)]);
setAppointments(a);
setBlocks(b);
setError('');
} catch (e) {
setError(e.message);
}
}
useEffect(() => {
if (!user?.modules?.includes('clinic')) return;
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
api.clinicMe().then(async staff => {
setMe(staff);
const data = await api.clinicDoctors();
setDoctors(data.doctors);
setTypes(data.types);
}).catch(e => {
if (isAdmin) setTab('config');
else setError(e.message);
});
}, [user?.id]);
useEffect(() => {
void load();
}, [me?.user_id, period.from, period.to]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_view_v2', JSON.stringify(view));
} catch {}
}, [view]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
} catch {}
}, [doctorFilter]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter));
} catch {}
}, [statusFilter]);
useEffect(() => {
if (tab === 'patients' && isReception) api.clinicPatients(search, page).then(x => {
setPatients(x.items);
setPatientTotal(x.total);
}).catch(e => setError(e.message));
}, [tab, search, page, isReception]);
useEffect(() => {
if (tab === 'config' && isAdmin) Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]).then(([rows, users, kindRows]) => {
setStaffRows(rows);
setAllUsers(users);
setAllTypes(kindRows);
}).catch(e => setError(e.message));
}, [tab, isAdmin]);
const formId = popup?.kind === 'appointment' ? 'clinic-appointment' : popup?.kind === 'patient' ? 'clinic-patient' : 'clinic-block';
const initial = popup?.kind === 'appointment' ? popup.item ? formAppointment(popup.item) : initialAppointment(day, popup.doctorId, popup.slot) : popup?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient() : popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
const draft = useDraftForm({
formId,
entityId: popup?.item?.id,
initial,
label: t(`clinic_${popup?.kind || 'appointment'}`),
enabled: !!popup && editing,
onDiscard: () => setEditing(false)
});
const v = draft.values;
useEffect(() => {
if (quickPatientId && popup?.kind === 'appointment' && editing) {
draft.setField('patientId', quickPatientId);
setQuickPatientId(null);
}
}, [quickPatientId, popup?.kind, editing]);
useEffect(() => {
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return;
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([]));
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
function open(kind, item = null, extra = {}) {
setPopup({
kind,
item,
...extra
});
setEditing(!item);
setConflict('');
setAffected([]);
setCancelMode(false);
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {});
}
function close() {
void guardedClose(editing ? draft : null, () => {
setPopup(null);
setEditing(false);
setConflict('');
});
}
async function save(confirmAffected = false) {
if (popup.item && !draft.isDirty) return;
try {
let result;
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
if (popup.kind === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
if (popup.kind === 'block') result = await api.clinicSaveBlock({
...v,
confirmAffected
}, popup.item?.id);
if (result?.requiresConfirmation) {
setAffected(result.affected);
return;
}
draft.clear();
if (popup.returnTo) {
setQuickPatientId(result.id);
setPopup(popup.returnTo);
setEditing(true);
} else {
setPopup(null);
setEditing(false);
}
setConflict('');
setAffected([]);
if (result?.affected?.length) setReprogram(result.affected);
await load();
if (tab === 'patients') {
const list = await api.clinicPatients(search, page);
setPatients(list.items);
}
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setConflict(e.message);
}
}
async function actionStatus(status) {
try {
await api.clinicAppointmentStatus(popup.item.id, {
status,
editVersion: popup.item.edit_version
});
setPopup(null);
await load();
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setConflict(e.message);
}
}
function cancelAppointment() {
if (!cancelReason.trim()) return;
openDialog({
tone: 'warning',
title: t('clinic_cancel'),
message: t('clinic_cancel_confirm'),
actions: [{
label: t('common_cancel')
}, {
label: t('clinic_cancel'),
variant: 'danger',
onClick: async () => {
try {
await api.clinicCancelAppointment(popup.item.id, {
reason: cancelReason,
editVersion: popup.item.edit_version
});
setPopup(null);
setCancelMode(false);
await load();
} catch (e) {
setConflict(e.message);
}
}
}]
});
}
function appointmentMenu(item) {
const actions = [{
label: t('common_close')
}];
if (isReception) {
actions.push({
label: t('clinic_confirm'),
onClick: () => api.clinicAppointmentStatus(item.id, {
status: 'confirmed',
editVersion: item.edit_version
}).then(load).catch(e => setError(e.message))
});
actions.push({
label: t('clinic_reprogram'),
onClick: () => {
open('appointment', item);
setEditing(true);
}
});
actions.push({
label: t('clinic_cancel'),
variant: 'danger',
onClick: () => {
open('appointment', item);
setCancelMode(true);
}
});
}
for (const status of ['attended', 'no_show']) actions.push({
label: t(`clinic_status_${status}`),
onClick: () => api.clinicAppointmentStatus(item.id, {
status,
editVersion: item.edit_version
}).then(load).catch(e => setError(e.message))
});
openDialog({
title: `${item.first_names} ${item.last_names}`,
message: item.starts_at,
actions
});
}
const periodTitle = (() => {
const d = new Date(`${day}T12:00:00`);
if (view === 'month' || view === 'list') return d.toLocaleDateString(lang, { month: 'long', year: 'numeric' });
if (view === 'day') return d.toLocaleDateString(lang, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const a = new Date(`${period.from.slice(0, 10)}T12:00:00`), b = new Date(`${move(period.to.slice(0, 10), -1)}T12:00:00`);
return `${a.toLocaleDateString(lang, { day: 'numeric', month: 'short' })} – ${b.toLocaleDateString(lang, { day: 'numeric', month: 'short', year: 'numeric' })}`;
})();
const doctorColor = id => doctors.find(d => d.id === id)?.color || 'rgb(var(--accent))';
const doctorShort = id => (doctors.find(d => d.id === id)?.name || '').split(' ').slice(0, 2).join(' ');
const weekLabels = [t('calendar_mon'), t('calendar_tue'), t('calendar_wed'), t('calendar_thu'), t('calendar_fri'), t('calendar_sat'), t('calendar_sun')];
// Anterior/siguiente: un dia, una semana o un mes segun la vista.
const step = n => {
if (view === 'week') return move(day, 7 * n);
if (view !== 'month') return move(day, n);
const [y, m] = day.split('-').map(Number), d = new Date(Date.UTC(y, m - 1 + n, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-01`;
};
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status));
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id));
const renderEvent = (a, byDoctor = false) => <button key={`a-${a.id}`} className="clinic-event appointment" style={{
borderLeftColor: byDoctor ? doctorColor(a.doctor_user_id) : a.type_color
}} onClick={() => open('appointment', a)} onContextMenu={e => {
e.preventDefault();
appointmentMenu(a);
}} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${a.status}`)}`}>{byDoctor ? `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names.slice(0, 1)}. · ${a.type_name}` : `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${a.status}`)}`}</button>;
const renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={() => open('block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title}</button>;
const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_notes'), popup.item.notes]] : popup.kind === 'patient' ? [[t('clinic_first_names'), popup.item.first_names], [t('clinic_last_names'), popup.item.last_names], [t('clinic_identification'), popup.item.identification], [t('clinic_phone'), popup.item.phone], [t('clinic_email'), popup.item.email], [t('clinic_birth_date'), popup.item.birth_date], [t('clinic_notes'), popup.item.notes]] : [[t('clinic_doctor'), doctors.find(d => d.id === popup.item.doctor_user_id)?.name], [t('clinic_kind'), t(`clinic_kind_${popup.item.kind}`)], [t('clinic_title'), popup.item.title], [t('calendar_start'), popup.item.occurrence_start], [t('calendar_end'), popup.item.occurrence_end]] : [];
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
const hourRange = (() => {
let lo = 24, hi = 0;
for (const d of selectedDoctors) for (const seg of d.schedules || []) {
lo = Math.min(lo, Number(String(seg.starts_time).slice(0, 2)));
hi = Math.max(hi, Math.ceil(Number(String(seg.ends_time).slice(0, 2)) + Number(String(seg.ends_time).slice(3, 5)) / 60));
}
for (const x of [...visible.map(a => [a.starts_at, a.ends_at]), ...visibleBlocks.map(b => [b.occurrence_start, b.occurrence_end])]) {
lo = Math.min(lo, Number(String(x[0]).slice(11, 13)));
hi = Math.max(hi, Math.min(24, Math.ceil(Number(String(x[1]).slice(11, 13)) + Number(String(x[1]).slice(14, 16)) / 60)));
}
if (lo >= hi) [lo, hi] = [7, 19];
return Array.from({ length: hi - lo }, (_, i) => lo + i);
})();
return <div className="clinic-page"><div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div><div className="toolbar-left">{isReception && tab === 'agenda' && <Button variant="accent" icon={Plus} onClick={() => open('appointment', null, {
doctorId: singleDoctor
})}>{t('clinic_new_appointment')}</Button>}{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, {
doctorId: singleDoctor
})}>{t('clinic_new_block')}</Button>}</div></div>
{error && <InfoBar tone="error">{error}</InfoBar>}{reprogram.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}</InfoBar>}
<Tabs tabs={[...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])].map(([key, label]) => ({ key, label: t(label) }))} active={tab} onChange={key => {
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null;
void guardedClose(current, () => setTab(key));
}} />
{tab === 'agenda' && <><div className="toolbar clinic-toolbar"><div className="toolbar-left"><Button onClick={() => setDay(today())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => setDay(step(-1))} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => setDay(step(1))} aria-label={t('calendar_next')} /><strong className="clinic-period">{periodTitle}</strong></div><div className="toolbar-left clinic-controls"><ComboBox value={view} onChange={setView} options={['month', 'week', 'day', 'list'].map(x => ({
value: x,
label: t(`clinic_view_${x}`)
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} values={doctorFilter} onChange={setDoctorFilter} /><CheckListDropdown label={t('clinic_status')} options={['scheduled', 'confirmed', 'attended', 'no_show'].map(x => ({
value: x,
label: t(`clinic_status_${x}`)
}))} values={statusFilter} onChange={setStatusFilter} /><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /></div></div>
{me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>}
{(view === 'week' || view === 'month') && <div className="clinic-legend">{selectedDoctors.map(d => <span key={d.id}><span className="clinic-swatch" style={{ background: d.color || 'rgb(var(--accent))' }} />{d.name}</span>)}<span><span className="clinic-swatch block" />{t('clinic_blocks')}</span></div>}
{view === 'day' ? <div className="clinic-day-grid">{selectedDoctors.map(d => <div className="clinic-doctor" key={d.id}><h3 style={{
borderTop: `3px solid ${d.color || 'rgb(var(--accent))'}`
}}>{d.name}</h3>{hourRange.map(h => {
const hour = String(h).padStart(2, '0'),
start = at(day, `${hour}:00`),
end = plus(start, 60),
weekday = (new Date(`${day}T12:00:00Z`).getUTCDay() + 6) % 7,
working = d.schedules?.some(s => s.weekday === weekday && `${day} ${s.starts_time}` <= start && `${day} ${s.ends_time}` >= end);
const blocked = visibleBlocks.some(b => b.doctor_user_id === d.id && b.occurrence_start < end && b.occurrence_end > start);
return <div key={hour} className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} onClick={() => working && !blocked && isReception && open('appointment', null, {
doctorId: d.id,
slot: start
})}><small>{hour}:00</small>{visible.filter(a => a.doctor_user_id === d.id && a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.doctor_user_id === d.id && b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
})}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
length: 7
}, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{weekLabels[(new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7]} {Number(d.slice(8, 10))}</h3>{hourRange.map(h => {
const start = at(d, `${String(h).padStart(2, '0')}:00`),
end = plus(start, 60),
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
// Con varios medicos: la hora es "de atencion" si alguno atiende; el sombreado de bloqueo
// solo aplica cuando se mira un medico (con varios, cada bloqueo se ve como entrada).
working = selectedDoctors.some(doc => doc.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end)),
blocked = selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start);
return <div className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} key={h} onClick={() => working && !blocked && isReception && open('appointment', null, {
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
slot: start
})}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(a => renderEvent(a, true))}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
})}</div>)}</div> : view === 'month' ? <MonthGrid day={day} weekLabels={weekLabels} todayKey={today()} onDayClick={d => { setDay(d); setView('day'); }} renderDay={d => {
const items = [...visibleBlocks.filter(b => b.occurrence_start.slice(0, 10) <= d && b.occurrence_end.slice(0, 10) >= d).map(b => ({ start: b.occurrence_start, node: <div key={`b-${b.id}-${b.occurrence_start}`} className="clinic-chip block" style={{ borderLeftColor: doctorColor(b.doctor_user_id) }} title={b.title}>{doctorShort(b.doctor_user_id)} · {b.title}</div> })),
...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: <div key={`a-${a.id}`} className="clinic-chip" style={{ borderLeftColor: doctorColor(a.doctor_user_id) }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names.slice(0, 1)}.</div> }))].sort((x, y) => x.start.localeCompare(y.start));
return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && <div className="text-muted" style={{ fontSize: '.66rem' }}>+{items.length - 4} {t('clinic_more')}</div>}</>;
}} /> : <div className="clinic-list">{[...visible.map(a => ({
start: a.starts_at,
node: renderEvent(a)
})), ...visibleBlocks.map(b => ({
start: b.occurrence_start,
node: renderBlock(b)
}))].sort((a, b) => a.start.localeCompare(b.start)).map((x, i) => <div key={i}><strong>{x.start.slice(0, 10)}</strong>{x.node}</div>)}</div>}</>}
{tab === 'patients' && isReception && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value);
setPage(1);
}} /></Field><div className="clinic-list">{patients.map(p => <button key={p.id} className="clinic-row" onClick={() => open('patient', p)}>{p.first_names} {p.last_names} · {p.phone}</button>)}</div><div className="toolbar-left"><Button disabled={page <= 1} onClick={() => setPage(page - 1)}>{t('calendar_previous')}</Button>{page} / {Math.max(1, Math.ceil(patientTotal / 20))}<Button disabled={page * 20 >= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}</Button></div></>}
{tab === 'blocks' && <div className="clinic-list">{visibleBlocks.map(b => <div key={`${b.id}-${b.occurrence_start}`}>{renderBlock(b)}</div>)}</div>}
{tab === 'config' && isAdmin && <div className="clinic-config"><h3>{t('clinic_staff')}</h3><ComboBox value={staffUser} onChange={x => {
setStaffUser(x);
const s = staffRows.find(r => r.user_id === Number(x));
setStaffInitial(s ? {
scope: s.scope,
isDoctor: !!s.is_doctor,
specialty: s.specialty || '',
color: s.color || '#0078d4',
isActive: !!s.is_active,
editVersion: s.edit_version
} : {
scope: 'own',
isDoctor: true,
specialty: '',
color: '#0078d4',
isActive: true
});
}} options={[{
value: '',
label: t('common_select')
}, ...allUsers.filter(u => u.is_active).map(u => ({
value: u.id,
label: u.name
}))]} /><Field label={t('clinic_user_id')}><input className="input" type="number" value={staffUser} onChange={e => setStaffUser(e.target.value)} /></Field><ComboBox value={staffForm.scope} onChange={x => setStaffForm({
...staffForm,
scope: x
})} options={['all', 'own'].map(x => ({
value: x,
label: t(`clinic_scope_${x}`)
}))} /><label><input type="checkbox" checked={staffForm.isDoctor} onChange={e => setStaffForm({
...staffForm,
isDoctor: e.target.checked
})} />{t('clinic_is_doctor')}</label><label><input type="checkbox" checked={staffForm.isActive} onChange={e => setStaffForm({
...staffForm,
isActive: e.target.checked
})} />{t('clinic_active')}</label><Field label={t('clinic_specialty')}><input className="input" value={staffForm.specialty} onChange={e => setStaffForm({
...staffForm,
specialty: e.target.value
})} /></Field><Field label={t('clinic_color')}><input type="color" value={staffForm.color} onChange={e => setStaffForm({
...staffForm,
color: e.target.value
})} /></Field><Button disabled={!staffUser || !!staffForm.editVersion && !staffDraft.isDirty} onClick={async () => {
try {
const saved = await api.clinicSaveStaff(staffUser, staffForm);
staffDraft.clear();
setStaffInitial({
scope: saved.scope,
isDoctor: !!saved.is_doctor,
specialty: saved.specialty || '',
color: saved.color || 'rgb(var(--accent))',
isActive: !!saved.is_active,
editVersion: saved.edit_version
});
setStaffRows(await api.clinicStaff());
setDoctors((await api.clinicDoctors()).doctors);
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button>
<h3>{t('clinic_types')}</h3><ComboBox value={typeForm.id || ''} onChange={x => {
const row = allTypes.find(r => r.id === Number(x));
setTypeInitial(row ? {
id: row.id,
name: row.name,
durationMinutes: row.duration_minutes,
color: row.color,
isActive: !!row.is_active,
editVersion: row.edit_version
} : {
name: '',
durationMinutes: 30,
color: '#0078d4',
isActive: true
});
}} options={[{
value: '',
label: t('clinic_new_type')
}, ...allTypes.map(x => ({
value: x.id,
label: x.name
}))]} /><Field label={t('clinic_name')}><input className="input" value={typeForm.name} onChange={e => setTypeForm({
...typeForm,
name: e.target.value
})} /></Field><Field label={t('clinic_duration')}><input className="input" type="number" value={typeForm.durationMinutes} onChange={e => setTypeForm({
...typeForm,
durationMinutes: Number(e.target.value)
})} /></Field><input type="color" value={typeForm.color} onChange={e => setTypeForm({
...typeForm,
color: e.target.value
})} /><label><input type="checkbox" checked={typeForm.isActive} onChange={e => setTypeForm({
...typeForm,
isActive: e.target.checked
})} />{t('clinic_active')}</label><Button disabled={!typeForm.name?.trim() || !!typeForm.editVersion && !typeDraft.isDirty} onClick={async () => {
try {
const saved = await api.clinicSaveType(typeForm, typeForm.id);
typeDraft.clear();
setTypeInitial({
id: saved.id,
name: saved.name,
durationMinutes: saved.duration_minutes,
color: saved.color,
isActive: !!saved.is_active,
editVersion: saved.edit_version
});
const data = await api.clinicDoctors();
setTypes(data.types);
setAllTypes(await api.clinicTypes());
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button>
<h3>{t('clinic_schedules')}</h3><ComboBox value={scheduleDoctor} onChange={async x => {
setScheduleDoctor(x);
setScheduleInitial({
rows: x ? await api.clinicSchedules(x) : []
});
}} options={[{
value: '',
label: t('common_select')
}, ...doctors.map(d => ({
value: d.id,
label: d.name
}))]} />{scheduleRows.map((s, i) => <div className="clinic-schedule-row" key={i}><ComboBox value={s.weekday} onChange={x => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
weekday: Number(x)
} : r))} options={['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((x, n) => ({
value: n,
label: t(`calendar_${x}`)
}))} /><input type="time" value={s.startsTime || s.starts_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
startsTime: e.target.value
} : r))} /><input type="time" value={s.endsTime || s.ends_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
endsTime: e.target.value
} : r))} /><Button onClick={() => setScheduleRows(scheduleRows.filter((_, j) => j !== i))}>{t('common_delete')}</Button></div>)}<Button onClick={() => setScheduleRows([...scheduleRows, {
weekday: 0,
startsTime: '09:00',
endsTime: '17:00'
}])}>{t('calendar_add')}</Button><Button disabled={!scheduleDoctor || !scheduleDraft.isDirty} onClick={async () => {
try {
await api.clinicSaveSchedules(scheduleDoctor, {
segments: scheduleRows.map(s => ({
weekday: s.weekday,
startsTime: s.startsTime || s.starts_time,
endsTime: s.endsTime || s.ends_time
}))
});
const data = await api.clinicDoctors();
setDoctors(data.doctors);
scheduleDraft.clear();
setScheduleInitial({
rows: await api.clinicSchedules(scheduleDoctor)
});
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button></div>}
<DetailPopup open={!!popup} title={t(`clinic_${popup?.kind || 'appointment'}`)} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">{conflict && <InfoBar tone="error">{conflict}</InfoBar>}{affected.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} <Button onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}{editing && <DraftNotice draft={draft} />} {!editing ? <><div className="clinic-detail">{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => <p key={label}><strong>{label}:</strong> {String(val)}</p>)}</div>{cancelMode && <Field label={t('clinic_cancel_reason')}><input className="input" value={cancelReason} onChange={e => setCancelReason(e.target.value)} /></Field>}{cancelMode && <Button variant="danger" disabled={!cancelReason.trim()} onClick={cancelAppointment}>{t('clinic_cancel')}</Button>}{popup.kind === 'appointment' && <div className="toolbar-left">{isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button onClick={() => setEditing(true)}>{t('clinic_reprogram')}</Button><Button disabled={popup.item.status === 'confirmed'} onClick={() => void actionStatus('confirmed')}>{t('clinic_confirm')}</Button><Button onClick={() => setCancelMode(true)}>{t('clinic_cancel')}</Button></>}{['attended', 'no_show'].map(x => <Button key={x} disabled={popup.item.status === x} onClick={() => void actionStatus(x)}>{t(`clinic_status_${x}`)}</Button>)}</div>}{popup.kind === 'patient' && isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><h3>{t('clinic_history')}</h3>{patientHistory.map(a => <p key={a.id}>{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</p>)}</>}{popup.kind === 'block' && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button variant="danger" onClick={() => openDialog({
tone: 'warning',
title: t('clinic_delete_block'),
message: t('clinic_delete_block_confirm'),
actions: [{
label: t('common_cancel')
}, {
label: t('common_delete'),
variant: 'danger',
onClick: async () => {
try {
await api.clinicDeleteBlock(popup.item.id, popup.item.edit_version);
setPopup(null);
await load();
} catch (e) {
setConflict(e.message);
}
}
}]
})}>{t('common_delete')}</Button></>}</> : <>
{popup.kind === 'appointment' && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value);
api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {});
}} /></Field><ComboBox value={v.patientId} onChange={x => draft.setField('patientId', x)} options={[{
value: '',
label: t('common_select')
}, ...patients.map(p => ({
value: p.id,
label: `${p.first_names} ${p.last_names} · ${p.identification || p.phone}`
}))]} /><Button onClick={() => open('patient', null, {
returnTo: popup
})}>{t('clinic_new_patient')}</Button><Field label={t('clinic_doctor')}><ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} /></Field><Field label={t('clinic_type')}><ComboBox value={v.typeId} onChange={x => {
const ty = types.find(z => z.id === Number(x));
draft.setValues(old => ({
...old,
typeId: x,
endsAt: plus(old.startsAt, ty?.duration_minutes || 30)
}));
}} options={types.map(z => ({
value: z.id,
label: z.name
}))} /></Field><Field label={t('calendar_start')}><DateTimeEditor value={v.startsAt} onChange={x => draft.setValues(old => ({
...old,
startsAt: x,
endsAt: plus(x, types.find(z => z.id === Number(old.typeId))?.duration_minutes || 30)
}))} label={t('calendar_start')} /></Field><Field label={t('calendar_end')}><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /></Field><Field label={t('clinic_channel')}><ComboBox value={v.channel} onChange={x => draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map(x => ({
value: x,
label: t(`clinic_channel_${x}`)
}))} /></Field><Field label={t('clinic_notes')}><textarea className="textarea" value={v.notes} onChange={e => draft.setField('notes', e.target.value)} /></Field><Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map(s => <Button key={s.startsAt} size="sm" onClick={() => draft.setValues(old => ({
...old,
startsAt: s.startsAt,
endsAt: s.endsAt
}))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field></>}
{popup.kind === 'patient' && [['firstNames', 'clinic_first_names'], ['lastNames', 'clinic_last_names'], ['identification', 'clinic_identification'], ['phone', 'clinic_phone'], ['email', 'clinic_email'], ['birthDate', 'clinic_birth_date'], ['notes', 'clinic_notes']].map(([k, label]) => <Field key={k} label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={e => draft.setField(k, e.target.value)} /></Field>)}
{popup.kind === 'block' && <><Field label={t('clinic_doctor')}>{popup.item ? <strong>{doctors.find(d => d.id === v.doctorId)?.name}</strong> : <ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} />}</Field><Field label={t('clinic_kind')}><ComboBox value={v.kind} onChange={x => draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map(x => ({
value: x,
label: t(`clinic_kind_${x}`)
}))} /></Field><Field label={t('clinic_title')}><input className="input" value={v.title} onChange={e => draft.setField('title', e.target.value)} /></Field><DateTimeEditor value={v.startsAt} onChange={x => draft.setField('startsAt', x)} label={t('calendar_start')} /><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /><RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x => draft.setField('recurrence', x)} /></>}
<div className="toolbar-left"><Button variant="accent" disabled={!!popup.item && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={() => {
draft.discard();
if (!popup.item) setPopup(null);else setEditing(false);
}}>{t('common_cancel')}</Button></div></>}</div>}</DetailPopup></div>;
}