feat: version 4.8.0 -- Agenda medica en vista de calendario (mes y semana)

Vista Mes tipo calendario (componente reutilizable MonthGrid) y vista Semana con
las citas de todos los medicos filtrados, coloreadas por medico; leyenda de
colores y vista elegida persistida.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 13:34:23 -05:00
parent 1e25022630
commit 91e15e8318
12 changed files with 106 additions and 28 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [4.8.0] - 2026-10-28
### Added
- Agenda médica: vista **Mes** tipo calendario (hasta 4 citas/bloqueos por día, clic abre el día) y
leyenda de colores por médico. Componente reutilizable `MonthGrid`.
### Changed
- Agenda médica: la vista **Semana** muestra las citas de todos los médicos filtrados (antes uno solo),
coloreadas por médico y en formato compacto. La vista elegida se recuerda.
## [4.7.0] - 2026-10-28 ## [4.7.0] - 2026-10-28
### Added ### Added
- Agenda médica para médicos y recepción: citas, pacientes, bloqueos recurrentes, horarios, tipos de cita, permisos por personal y disponibilidad. - Agenda médica para médicos y recepción: citas, pacientes, bloqueos recurrentes, horarios, tipos de cita, permisos por personal y disponibilidad.
+3
View File
@@ -6,6 +6,9 @@ autorregistro/aprobacion/SMTP.
## Agenda médica (v4.7.0, sin desplegar) ## Agenda médica (v4.7.0, sin desplegar)
- v4.8.0: vistas Mes (calendario) y Semana con todos los médicos filtrados, color por médico. Sin
migración. Revisado en dev con datos temporales (ya borrados), sin errores de consola.
- Módulo `clinic` con permisos por `clinic_staff` (`all` recepción, `own` médico), pacientes, horarios, tipos, citas y bloqueos recurrentes. - Módulo `clinic` con permisos por `clinic_staff` (`all` recepción, `own` médico), pacientes, horarios, tipos, citas y bloqueos recurrentes.
- Migración `036_clinic.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica el actualizador). - Migración `036_clinic.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica el actualizador).
- Probado en dev con usuarios/pacientes temporales (ya borrados), 29/29: configuración por admin que no es - Probado en dev con usuarios/pacientes temporales (ya borrados), 29/29: configuración por admin que no es
+4 -1
View File
@@ -100,7 +100,10 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
Los médicos conservan el resto de sus módulos y no se crea un rol del sistema. Los médicos conservan el resto de sus módulos y no se crea un rol del sistema.
- Admin/superadmin con acceso a `clinic` y fila de staff configuran personal, horarios por tramos - Admin/superadmin con acceso a `clinic` y fila de staff configuran personal, horarios por tramos
y tipos de cita desde la pestaña Configuración de agenda. y tipos de cita desde la pestaña Configuración de agenda.
- Día muestra columnas por médico; Semana muestra un médico; Lista muestra citas y bloqueos. - Vistas (v4.8.0, la elegida se recuerda): **Día** con una columna por médico; **Semana** como
calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes**
como calendario mensual (hasta 4 entradas por día + "N más"; clic en un día abre su vista Día);
**Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes.
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto. Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza - Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
+1 -1
View File
@@ -1 +1 @@
4.7.0 4.8.0
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.7.0", "version": "4.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.7.0", "version": "4.8.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.7.0", "version": "4.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.7.0", "version": "4.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.7.0", "version": "4.8.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
@@ -2748,7 +2748,7 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.8.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true, "dev": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.7.0", "version": "4.8.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+30
View File
@@ -0,0 +1,30 @@
// Cuadricula mensual (lunes a domingo) con las clases .calendar-* de app.css. El contenido de cada
// dia lo decide quien la usa (renderDay); clic en el dia -> onDayClick(YYYY-MM-DD).
const pad = (n) => String(n).padStart(2, '0');
const key = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
// Primer lunes visible y los 35/42 dias que cubren el mes de `day` (YYYY-MM-DD).
export function monthGridDays(day) {
const [y, m] = day.split('-').map(Number);
const first = new Date(Date.UTC(y, m - 1, 1));
const start = new Date(first.getTime() - ((first.getUTCDay() + 6) % 7) * 86400000);
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate();
const cells = Math.ceil((((first.getUTCDay() + 6) % 7) + daysInMonth) / 7) * 7;
return Array.from({ length: cells }, (_, i) => key(new Date(start.getTime() + i * 86400000)));
}
export function MonthGrid({ day, weekLabels, todayKey, renderDay, onDayClick }) {
const month = day.slice(0, 7);
return (
<div className="calendar-grid">
{weekLabels.map((label) => <div key={label} className="calendar-dow">{label}</div>)}
{monthGridDays(day).map((d) => (
<div key={d} className={`calendar-cell ${d.slice(0, 7) !== month ? 'muted' : ''} ${d === todayKey ? 'today' : ''}`}
onClick={() => onDayClick?.(d)} style={{ cursor: onDayClick ? 'pointer' : undefined, minWidth: 0 }}>
<div className="calendar-day-num">{Number(d.slice(8, 10))}</div>
{renderDay(d)}
</div>
))}
</div>
);
}
+2 -2
View File
@@ -21,7 +21,7 @@ export const messages = {
clinic_block: 'Bloqueo', clinic_block: 'Bloqueo',
clinic_view_day: 'Día', clinic_view_day: 'Día',
clinic_view_week: 'Semana', clinic_view_week: 'Semana',
clinic_view_list: 'Lista', clinic_view_list: 'Lista', clinic_view_month: 'Mes', clinic_more: 'más',
clinic_doctors: 'Médicos', clinic_doctors: 'Médicos',
clinic_doctor: 'Médico', clinic_doctor: 'Médico',
clinic_status: 'Estado', clinic_status: 'Estado',
@@ -713,7 +713,7 @@ export const messages = {
clinic_block: 'Block', clinic_block: 'Block',
clinic_view_day: 'Day', clinic_view_day: 'Day',
clinic_view_week: 'Week', clinic_view_week: 'Week',
clinic_view_list: 'List', clinic_view_list: 'List', clinic_view_month: 'Month', clinic_more: 'more',
clinic_doctors: 'Doctors', clinic_doctors: 'Doctors',
clinic_doctor: 'Doctor', clinic_doctor: 'Doctor',
clinic_status: 'Status', clinic_status: 'Status',
@@ -15,6 +15,7 @@ import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Tabs } from '../../components/Tabs.jsx'; import { Tabs } from '../../components/Tabs.jsx';
import { InfoBar } from '../../components/InfoBar.jsx'; import { InfoBar } from '../../components/InfoBar.jsx';
import { DatePicker } from '../../components/DatePicker.jsx'; import { DatePicker } from '../../components/DatePicker.jsx';
import { MonthGrid, monthGridDays } from '../../components/MonthGrid.jsx';
import * as api from '../../services/clinic.js'; import * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js'; import { listUsers } from '../../services/users.js';
import './clinic.css'; import './clinic.css';
@@ -112,7 +113,7 @@ export default function AgendaMedicaPage() {
[types, setTypes] = useState([]), [types, setTypes] = useState([]),
[allTypes, setAllTypes] = useState([]); [allTypes, setAllTypes] = useState([]);
const [tab, setTab] = useState('agenda'), const [tab, setTab] = useState('agenda'),
[view, setView] = useState('day'), [view, setView] = useState(() => { const v = readStored('plc_clinic_view', 'day'); return ['day', 'week', 'month', 'list'].includes(v) ? v : 'day'; }),
[day, setDay] = useState(today); [day, setDay] = useState(today);
const [doctorFilter, setDoctorFilter] = useState(() => { const [doctorFilter, setDoctorFilter] = useState(() => {
const value = readStored('plc_clinic_doctors', []); const value = readStored('plc_clinic_doctors', []);
@@ -198,6 +199,11 @@ export default function AgendaMedicaPage() {
from = move(day, -offset); from = move(day, -offset);
to = move(from, 7); 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); if (view === 'list') to = move(day, 30);
return { return {
from: at(from, '00:00'), from: at(from, '00:00'),
@@ -231,6 +237,11 @@ export default function AgendaMedicaPage() {
useEffect(() => { useEffect(() => {
void load(); void load();
}, [me?.user_id, period.from, period.to]); }, [me?.user_id, period.from, period.to]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_view', JSON.stringify(view));
} catch {}
}, [view]);
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter)); localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
@@ -377,7 +388,6 @@ export default function AgendaMedicaPage() {
}] }]
}); });
} }
const weekDoctorId = singleDoctor;
function appointmentMenu(item) { function appointmentMenu(item) {
const actions = [{ const actions = [{
label: t('common_close') label: t('common_close')
@@ -419,14 +429,24 @@ export default function AgendaMedicaPage() {
actions actions
}); });
} }
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status) && (view !== 'week' || a.doctor_user_id === weekDoctorId)); const doctorColor = id => doctors.find(d => d.id === id)?.color || 'rgb(var(--accent))';
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id) && (view !== 'week' || b.doctor_user_id === weekDoctorId)); const doctorShort = id => (doctors.find(d => d.id === id)?.name || '').split(' ').slice(0, 2).join(' ');
const renderEvent = a => <button key={`a-${a.id}`} className="clinic-event appointment" style={{ const weekLabels = [t('calendar_mon'), t('calendar_tue'), t('calendar_wed'), t('calendar_thu'), t('calendar_fri'), t('calendar_sat'), t('calendar_sun')];
borderLeftColor: a.type_color // 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 => { }} onClick={() => open('appointment', a)} onContextMenu={e => {
e.preventDefault(); e.preventDefault();
appointmentMenu(a); appointmentMenu(a);
}} title={`${a.first_names} ${a.last_names}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</button>; }} 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 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]] : []; 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>; if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
@@ -434,7 +454,7 @@ export default function AgendaMedicaPage() {
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00. // con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
const hourRange = (() => { const hourRange = (() => {
let lo = 24, hi = 0; let lo = 24, hi = 0;
for (const d of view === 'week' ? doctors.filter(x => x.id === weekDoctorId) : selectedDoctors) for (const seg of d.schedules || []) { for (const d of selectedDoctors) for (const seg of d.schedules || []) {
lo = Math.min(lo, Number(String(seg.starts_time).slice(0, 2))); 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)); hi = Math.max(hi, Math.ceil(Number(String(seg.ends_time).slice(0, 2)) + Number(String(seg.ends_time).slice(3, 5)) / 60));
} }
@@ -455,7 +475,7 @@ export default function AgendaMedicaPage() {
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null; const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null;
void guardedClose(current, () => setTab(key)); void guardedClose(current, () => setTab(key));
}} /> }} />
{tab === 'agenda' && <><div className="clinic-controls"><Button icon={ChevronLeft} onClick={() => setDay(move(day, view === 'week' ? -7 : -1))}>{t('calendar_previous')}</Button><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /><Button icon={ChevronRight} onClick={() => setDay(move(day, view === 'week' ? 7 : 1))}>{t('calendar_next')}</Button><ComboBox value={view} onChange={setView} options={['day', 'week', 'list'].map(x => ({ {tab === 'agenda' && <><div className="clinic-controls"><Button icon={ChevronLeft} onClick={() => setDay(step(-1))}>{t('calendar_previous')}</Button><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /><Button icon={ChevronRight} onClick={() => setDay(step(1))}>{t('calendar_next')}</Button><ComboBox value={view} onChange={setView} options={['day', 'week', 'month', 'list'].map(x => ({
value: x, value: x,
label: t(`clinic_view_${x}`) label: t(`clinic_view_${x}`)
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({ }))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
@@ -465,6 +485,7 @@ export default function AgendaMedicaPage() {
value: x, value: x,
label: t(`clinic_status_${x}`) label: t(`clinic_status_${x}`)
}))} values={statusFilter} onChange={setStatusFilter} /></div> }))} values={statusFilter} onChange={setStatusFilter} /></div>
{(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={{ {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))'}` borderTop: `3px solid ${d.color || 'rgb(var(--accent))'}`
}}>{d.name}</h3>{hourRange.map(h => { }}>{d.name}</h3>{hourRange.map(h => {
@@ -480,18 +501,23 @@ export default function AgendaMedicaPage() {
})}><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>; })}><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({ })}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
length: 7 length: 7
}, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{d} · {doctors.find(x => x.id === weekDoctorId)?.name}</h3>{hourRange.map(h => { }, (_, 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`), const start = at(d, `${String(h).padStart(2, '0')}:00`),
end = plus(start, 60), end = plus(start, 60),
doctor = doctors.find(x => x.id === weekDoctorId),
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7, weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
working = doctor?.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end); // Con varios medicos: la hora es "de atencion" si alguno atiende; el sombreado de bloqueo
const blocked = visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start); // 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, { return <div className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} key={h} onClick={() => working && !blocked && isReception && open('appointment', null, {
doctorId: weekDoctorId, doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
slot: start slot: start
})}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>; })}><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> : <div className="clinic-list">{[...visible.map(a => ({ })}</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, start: a.starts_at,
node: renderEvent(a) node: renderEvent(a)
})), ...visibleBlocks.map(b => ({ })), ...visibleBlocks.map(b => ({
+9 -1
View File
@@ -1,8 +1,16 @@
.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-tabs,.clinic-controls,.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-day-grid{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(170px,1fr);overflow-x:auto;gap:8px}.clinic-doctor{background:rgb(var(--surface));border:1px solid rgb(var(--line));border-radius:var(--r-lg);min-width:170px}.clinic-doctor h3{position:sticky;top:0;background:rgb(var(--surface));padding:10px;margin:0;font-size:.9rem;z-index:1}.clinic-hour{min-height:60px;border-top:1px solid rgb(var(--line));padding:4px;cursor:pointer}.clinic-hour.closed{background:rgb(var(--muted)/.12);cursor:default}.clinic-hour small{display:block;color:rgb(var(--muted));margin-bottom:4px}.clinic-event{display:block;width:100%;border:0;border-left:4px solid rgb(var(--accent));background:rgb(var(--accent)/.16);color:rgb(var(--body));border-radius:4px;text-align:left;padding:6px;margin:2px 0;cursor:pointer;font-size:.78rem}.clinic-event.block{border-left-color:rgb(var(--warning));background:rgb(var(--warning)/.2)}.clinic-list{display:grid;gap:6px;margin:12px 0}.clinic-list>div,.clinic-row{border:1px solid rgb(var(--line));background:rgb(var(--surface));padding:8px;border-radius:var(--r-sm);text-align:left;color:rgb(var(--body))}.clinic-list .clinic-event{display:inline-block;width:auto;min-width:260px;margin-left:12px}.clinic-form{display:grid;gap:12px}.clinic-form .field{min-width:0}.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}.clinic-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:4px 12px}.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}.clinic-config{display:grid;gap:8px;max-width:720px}.clinic-config h3{margin-top:24px}@media(max-width:640px){.clinic-day-grid{grid-auto-columns:minmax(220px,80vw)}.clinic-controls>*{max-width:100%}.clinic-detail{grid-template-columns:1fr}} .clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-tabs,.clinic-controls,.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-day-grid{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(170px,1fr);overflow-x:auto;gap:8px}.clinic-doctor{background:rgb(var(--surface));border:1px solid rgb(var(--line));border-radius:var(--r-lg);min-width:170px}.clinic-doctor h3{position:sticky;top:0;background:rgb(var(--surface));padding:10px;margin:0;font-size:.9rem;z-index:1}.clinic-hour{min-height:60px;border-top:1px solid rgb(var(--line));padding:4px;cursor:pointer}.clinic-hour.closed{background:rgb(var(--muted)/.12);cursor:default}.clinic-hour small{display:block;color:rgb(var(--muted));margin-bottom:4px}.clinic-event{display:block;width:100%;border:0;border-left:4px solid rgb(var(--accent));background:rgb(var(--accent)/.16);color:rgb(var(--body));border-radius:4px;text-align:left;padding:6px;margin:2px 0;cursor:pointer;font-size:.78rem}.clinic-event.block{border-left-color:rgb(var(--warning));background:rgb(var(--warning)/.2)}.clinic-list{display:grid;gap:6px;margin:12px 0}.clinic-list>div,.clinic-row{border:1px solid rgb(var(--line));background:rgb(var(--surface));padding:8px;border-radius:var(--r-sm);text-align:left;color:rgb(var(--body))}.clinic-list .clinic-event{display:inline-block;width:auto;min-width:260px;margin-left:12px}.clinic-form{display:grid;gap:12px}.clinic-form .field{min-width:0}.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}.clinic-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:4px 12px}.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}.clinic-config{display:grid;gap:8px;max-width:720px}.clinic-config h3{margin-top:24px}@media(max-width:640px){.clinic-day-grid{grid-auto-columns:minmax(220px,80vw)}.clinic-controls>*{max-width:100%}.clinic-detail{grid-template-columns:1fr}}
.clinic-week-grid{display:grid;grid-template-columns:repeat(7,minmax(165px,1fr));gap:6px;overflow-x:auto}.clinic-week-grid .clinic-doctor{min-width:165px}@media(max-width:900px){.clinic-week-grid{grid-template-columns:repeat(7,165px)}} .clinic-week-grid{display:grid;grid-template-columns:repeat(7,minmax(120px,1fr));gap:6px;overflow-x:auto}.clinic-week-grid .clinic-doctor{min-width:120px}.clinic-week-grid .clinic-event{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.72rem;padding:4px 6px}@media(max-width:900px){.clinic-week-grid{grid-template-columns:repeat(7,150px)}}
.clinic-controls .datepicker-trigger{flex:0 0 190px;width:190px} .clinic-controls .datepicker-trigger{flex:0 0 190px;width:190px}
.clinic-controls .combobox{flex:0 0 auto;min-width:150px} .clinic-controls .combobox{flex:0 0 auto;min-width:150px}
/* Hora ocupada por un bloqueo (quirofano, vacaciones...): no se puede agendar. */ /* Hora ocupada por un bloqueo (quirofano, vacaciones...): no se puede agendar. */
.clinic-hour.blocked{background:repeating-linear-gradient(135deg,rgb(var(--warning)/.10) 0 8px,transparent 8px 16px);cursor:not-allowed} .clinic-hour.blocked{background:repeating-linear-gradient(135deg,rgb(var(--warning)/.10) 0 8px,transparent 8px 16px);cursor:not-allowed}
/* Vista Mes y leyenda de medicos (semana/mes colorean por medico). */
.clinic-chip{font-size:.68rem;padding:2px 5px;margin-top:3px;border-radius:4px;border-left:3px solid rgb(var(--accent));background:rgb(var(--accent)/.10);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.clinic-chip.block{background:repeating-linear-gradient(135deg,rgb(var(--warning)/.16) 0 6px,transparent 6px 12px);color:rgb(var(--warning))}
.clinic-legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin:0 0 10px;font-size:.78rem;color:rgb(var(--muted))}
.clinic-legend>span{display:inline-flex;align-items:center;gap:6px}
.clinic-swatch{width:12px;height:12px;border-radius:3px;display:inline-block}
.clinic-swatch.block{background:repeating-linear-gradient(135deg,rgb(var(--warning)) 0 3px,transparent 3px 6px);border:1px solid rgb(var(--warning)/.6)}
.clinic-page .calendar-cell{min-height:104px}