diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae123d..6fbd25a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 ### Added - Agenda médica para médicos y recepción: citas, pacientes, bloqueos recurrentes, horarios, tipos de cita, permisos por personal y disponibilidad. diff --git a/ESTADO.md b/ESTADO.md index fe4ce68..0254f72 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -6,6 +6,9 @@ autorregistro/aprobacion/SMTP. ## 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. - 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 diff --git a/SPEC.md b/SPEC.md index 61d1c8d..a341172 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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. - 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. -- 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. - 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 diff --git a/VERSION b/VERSION index f6cdf40..88f1811 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.7.0 +4.8.0 diff --git a/backend/package-lock.json b/backend/package-lock.json index ddef376..9e5c311 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-backend", - "version": "4.7.0", + "version": "4.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "4.7.0", + "version": "4.8.0", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index c2054c2..2848dbe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.7.0", + "version": "4.8.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 39945df..3684c52 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-frontend", - "version": "4.7.0", + "version": "4.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "4.7.0", + "version": "4.8.0", "dependencies": { "dompurify": "^3.4.16", "highlight.js": "^11.12.0", @@ -2748,7 +2748,7 @@ "license": "MIT" }, "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", "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index ca68d73..ef5f122 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.7.0", + "version": "4.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/MonthGrid.jsx b/frontend/src/components/MonthGrid.jsx new file mode 100644 index 0000000..64f5c2a --- /dev/null +++ b/frontend/src/components/MonthGrid.jsx @@ -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 ( +
+ {weekLabels.map((label) =>
{label}
)} + {monthGridDays(day).map((d) => ( +
onDayClick?.(d)} style={{ cursor: onDayClick ? 'pointer' : undefined, minWidth: 0 }}> +
{Number(d.slice(8, 10))}
+ {renderDay(d)} +
+ ))} +
+ ); +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index a51d857..05a8ca4 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -21,7 +21,7 @@ export const messages = { clinic_block: 'Bloqueo', clinic_view_day: 'Día', 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_doctor: 'Médico', clinic_status: 'Estado', @@ -713,7 +713,7 @@ export const messages = { clinic_block: 'Block', clinic_view_day: 'Day', clinic_view_week: 'Week', - clinic_view_list: 'List', + clinic_view_list: 'List', clinic_view_month: 'Month', clinic_more: 'more', clinic_doctors: 'Doctors', clinic_doctor: 'Doctor', clinic_status: 'Status', diff --git a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx index 2ffad61..5beb574 100644 --- a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx +++ b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx @@ -15,6 +15,7 @@ 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'; @@ -112,7 +113,7 @@ export default function AgendaMedicaPage() { [types, setTypes] = useState([]), [allTypes, setAllTypes] = useState([]); 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); const [doctorFilter, setDoctorFilter] = useState(() => { const value = readStored('plc_clinic_doctors', []); @@ -198,6 +199,11 @@ export default function AgendaMedicaPage() { 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'), @@ -231,6 +237,11 @@ export default function AgendaMedicaPage() { useEffect(() => { void load(); }, [me?.user_id, period.from, period.to]); + useEffect(() => { + try { + localStorage.setItem('plc_clinic_view', JSON.stringify(view)); + } catch {} + }, [view]); useEffect(() => { try { localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter)); @@ -377,7 +388,6 @@ export default function AgendaMedicaPage() { }] }); } - const weekDoctorId = singleDoctor; function appointmentMenu(item) { const actions = [{ label: t('common_close') @@ -419,14 +429,24 @@ export default function AgendaMedicaPage() { 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 visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id) && (view !== 'week' || b.doctor_user_id === weekDoctorId)); - const renderEvent = 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}`)}`}; const renderBlock = b => ; 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 {error}; @@ -434,7 +454,7 @@ export default function AgendaMedicaPage() { // 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 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))); 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; void guardedClose(current, () => setTab(key)); }} /> - {tab === 'agenda' && <>
({ + {tab === 'agenda' && <>
({ value: x, label: t(`clinic_view_${x}`) }))} /> ({ @@ -465,6 +485,7 @@ export default function AgendaMedicaPage() { value: x, label: t(`clinic_status_${x}`) }))} values={statusFilter} onChange={setStatusFilter} />
+ {(view === 'week' || view === 'month') &&
{selectedDoctors.map(d => {d.name})}{t('clinic_blocks')}
} {view === 'day' ?
{selectedDoctors.map(d =>

{d.name}

{hourRange.map(h => { @@ -480,18 +501,23 @@ export default function AgendaMedicaPage() { })}>{hour}:00{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)}
; })}
)}
: view === 'week' ?
{Array.from({ length: 7 - }, (_, i) => move(period.from.slice(0, 10), i)).map(d =>

{d} · {doctors.find(x => x.id === weekDoctorId)?.name}

{hourRange.map(h => { + }, (_, i) => move(period.from.slice(0, 10), i)).map(d =>

{weekLabels[(new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7]} {Number(d.slice(8, 10))}

{hourRange.map(h => { const start = at(d, `${String(h).padStart(2, '0')}:00`), end = plus(start, 60), - doctor = doctors.find(x => x.id === weekDoctorId), 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); - const blocked = visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start); + // 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
working && !blocked && isReception && open('appointment', null, { - doctorId: weekDoctorId, + doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined, slot: start - })}>{String(h).padStart(2, '0')}:00{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)}
; - })}
)}
:
{[...visible.map(a => ({ + })}>{String(h).padStart(2, '0')}:00{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)}
; + })}
)} : view === 'month' ? { 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:
{doctorShort(b.doctor_user_id)} · {b.title}
})), + ...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node:
{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names.slice(0, 1)}.
}))].sort((x, y) => x.start.localeCompare(y.start)); + return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 &&
+{items.length - 4} {t('clinic_more')}
}; + }} /> :
{[...visible.map(a => ({ start: a.starts_at, node: renderEvent(a) })), ...visibleBlocks.map(b => ({ diff --git a/frontend/src/pages/AgendaMedica/clinic.css b/frontend/src/pages/AgendaMedica/clinic.css index 036ab41..96084ad 100644 --- a/frontend/src/pages/AgendaMedica/clinic.css +++ b/frontend/src/pages/AgendaMedica/clinic.css @@ -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-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 .combobox{flex:0 0 auto;min-width:150px} /* 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} +/* 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}