diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c78e5..a3fe06e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog - project.linkedcorp.net +## [4.9.1] - 2026-10-29 +### Fixed +- Agenda médica: ahora usa el mismo calendario que Calendario (barra Hoy/anterior/siguiente, vistas + Mes/Semana/Día/Agenda, filtros con etiqueta y leyenda). Se ve completo aunque aún no haya médicos; + antes podía quedar en blanco si el navegador tenía guardada la vista Día. Siempre abre en Mes. +- Semana/Día de la agenda: las horas fuera del horario de los médicos (o bloqueadas, viendo un solo + médico) se muestran deshabilitadas; clic en una hora libre abre la cita nueva a esa hora. +### Changed +- Componente compartido `CalendarBoard` (reemplaza a `MonthGrid`); Calendario y Agenda médica lo + usan, sin código de cuadrícula duplicado. Sin migración. + ## [4.9.0] - 2026-10-29 ### Changed - Agenda médica: clic en un día del mes (o en una hora libre de Semana/Día) abre directamente el diff --git a/ESTADO.md b/ESTADO.md index ba548f5..fce507f 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -6,6 +6,8 @@ autorregistro/aprobacion/SMTP. ## Agenda médica (v4.7.0, sin desplegar) +- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre + abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos. - v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en lectura (Editar, Confirmar, Cancelar, Eliminar). Autor fijo de la cita (`created_by_kind` user/ai_agent → "Agente IA" para el futuro WhatsApp). Borrado lógico de citas (con motivo) y diff --git a/VERSION b/VERSION index 6ed7776..5b341fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.9.0 +4.9.1 diff --git a/backend/package-lock.json b/backend/package-lock.json index 337b06e..0f54f2d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-backend", - "version": "4.9.0", + "version": "4.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "4.9.0", + "version": "4.9.1", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index ccd6fbb..1479d9e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.9.0", + "version": "4.9.1", "private": true, "type": "module", "main": "src/server.js", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fc2be1e..bddff64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-frontend", - "version": "4.9.0", + "version": "4.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "4.9.0", + "version": "4.9.1", "dependencies": { "dompurify": "^3.4.16", "highlight.js": "^11.12.0", diff --git a/frontend/package.json b/frontend/package.json index 264f57f..3f318d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.9.0", + "version": "4.9.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Calendario/calendar.css b/frontend/src/components/CalendarBoard.css similarity index 86% rename from frontend/src/pages/Calendario/calendar.css rename to frontend/src/components/CalendarBoard.css index a75a0d6..e05949e 100644 --- a/frontend/src/pages/Calendario/calendar.css +++ b/frontend/src/components/CalendarBoard.css @@ -50,3 +50,9 @@ .meeting-calendar .calendar-entry.clinic { color:rgb(var(--cal-task)); background:rgb(var(--cal-task)/.2); border-left:3px solid rgb(var(--cal-task)); } .meeting-calendar .calendar-legend-item.clinic .calendar-swatch { background:rgb(var(--cal-task)); } +/* "+N mas" (Mes) y horas no disponibles (Agenda medica: fuera de horario o bloqueadas). */ +.meeting-calendar .calendar-more { border:0; background:none; padding:2px 0; color:rgb(var(--accent)); font:inherit; font-size:.68rem; text-align:left; cursor:pointer; } +.meeting-calendar .calendar-hours button:disabled { cursor:default; color:rgb(var(--muted) / .6); background:rgb(var(--muted) / .08); } +.meeting-calendar .calendar-hours button.blocked { background:repeating-linear-gradient(135deg, rgb(var(--warning) / .12) 0 8px, transparent 8px 16px); } +.meeting-calendar .calendar-hours button:disabled:hover { background:rgb(var(--muted) / .08); color:rgb(var(--muted) / .6); } +.meeting-calendar .calendar-hours button.blocked:hover { background:repeating-linear-gradient(135deg, rgb(var(--warning) / .12) 0 8px, transparent 8px 16px); } diff --git a/frontend/src/components/CalendarBoard.jsx b/frontend/src/components/CalendarBoard.jsx new file mode 100644 index 0000000..2440e5e --- /dev/null +++ b/frontend/src/components/CalendarBoard.jsx @@ -0,0 +1,74 @@ +import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; +import { useUi } from '../context/ui-context.jsx'; +import { Button } from './Button.jsx'; +import { ComboBox } from './ComboBox.jsx'; +import './CalendarBoard.css'; + +// Calendario reutilizable (Calendario y Agenda medica): barra Hoy / anterior / siguiente / periodo, +// vistas Mes, Semana, Dia y Agenda, y la cuadricula. Que entradas hay y que pasa al hacer clic lo +// decide quien lo usa. Fechas en hora civil de Ecuador (America/Guayaquil). +export const CALENDAR_VIEWS = ['month', 'week', 'day', 'agenda']; +export const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +export const ecuadorNow = () => { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Guayaquil', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date()).map((p) => [p.type, p.value])); + return new Date(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute)); +}; +export const shift = (d, days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days); +export const fromKey = (key) => { const [y, m, d] = key.split('-').map(Number); return new Date(y, m - 1, d); }; +export function calendarBounds(cursor, view) { + let first, last; + if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); } + else if (view === 'week') { first = shift(cursor, -((cursor.getDay() + 6) % 7)); last = shift(first, 7); } + else if (view === 'day') { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first, 1); } + else { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first, 30); } + // En Mes se piden tambien los dias de relleno de la primera y ultima semana visibles. + const gridFirst = view === 'month' ? shift(first, -((first.getDay() + 6) % 7)) : first; + const gridLast = view === 'month' ? shift(last, (7 - ((last.getDay() + 6) % 7)) % 7) : last; + return { from: `${dateKey(gridFirst)} 00:00:00`, to: `${dateKey(gridLast)} 00:00:00`, first, last }; +} +const DEFAULT_HOURS = Array.from({ length: 11 }, (_, i) => i + 8); + +/** + * - items: entradas con `day` (YYYY-MM-DD), ya ordenadas; renderItem(item) dibuja cada una (boton + * `calendar-entry` que debe cortar la propagacion del clic). + * - onSlot(day, hour|null): clic en un dia (Mes) o en una hora (Semana/Dia). + * - hourState(day, hour): 'closed' | 'blocked' | undefined; la hora se muestra deshabilitada. + * - actions/filters/legend: nodos de la barra (derecha), fila de filtros y leyenda. + */ +export function CalendarBoard({ title, icon: Icon, view, onViewChange, cursor, onCursorChange, actions, filters, legend, notice, items, renderItem, onSlot, hours = DEFAULT_HOURS, hourState, maxPerDay }) { + const { t, lang } = useUi(); + const { first, last } = calendarBounds(cursor, view); + const todayKey = dateKey(ecuadorNow()); + function navigate(delta) { + if (view === 'month') onCursorChange(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1)); + else onCursorChange(shift(cursor, delta * (view === 'week' ? 7 : view === 'agenda' ? 30 : 1))); + } + const byDay = items.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {}); + const days = view === 'month' + ? Array.from({ length: Math.ceil((((first.getDay() + 6) % 7) + Math.round((last - first) / 86400000)) / 7) * 7 }, (_, i) => shift(first, i - ((first.getDay() + 6) % 7))) + : Array.from({ length: Math.round((last - first) / 86400000) }, (_, i) => shift(first, i)); + const weekLabels = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((d) => t(`calendar_${d}`)); + return
+
{title &&

{Icon && } {title}

} +
+
({ value: x, label: t(`calendar_${x}`) }))} value={view} onChange={onViewChange} />{actions}
+ {filters &&
{filters}
} + {legend &&
{legend}
} + {notice} + {view === 'agenda' ?
{items.length ? items.map((item, i) =>
{item.day}{renderItem(item)}
) :

{t('calendar_empty')}

}
:
+ {view !== 'day' && weekLabels.map((label) =>
{label}
)} + {days.map((day) => { + const key = dateKey(day); + const muted = view === 'month' && day.getMonth() !== cursor.getMonth(); + const dayItems = byDay[key] || []; + const limit = view === 'month' && maxPerDay && dayItems.length > maxPerDay ? maxPerDay - 1 : dayItems.length; + return
onSlot?.(key, null)}> +
{view === 'day' ? day.toLocaleDateString(lang, { weekday: 'long', day: 'numeric', month: 'long' }) : day.getDate()}
+ {dayItems.slice(0, limit).map(renderItem)} + {limit < dayItems.length && } + {view !== 'month' &&
{hours.map((hour) => { const state = hourState?.(key, hour); return ; })}
} +
; + })}
} +
; +} diff --git a/frontend/src/components/MonthGrid.jsx b/frontend/src/components/MonthGrid.jsx deleted file mode 100644 index 64f5c2a..0000000 --- a/frontend/src/components/MonthGrid.jsx +++ /dev/null @@ -1,30 +0,0 @@ -// 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 79d1d08..59c9f5c 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -19,9 +19,7 @@ export const messages = { clinic_appointment: 'Cita', clinic_patient: 'Paciente', clinic_block: 'Bloqueo', - clinic_view_day: 'Día', - clinic_view_week: 'Semana', - clinic_view_list: 'Agenda', clinic_view_month: 'Mes', clinic_no_doctors: 'Aún no hay médicos configurados en la agenda. Pide a un administrador que los registre.', clinic_no_doctors_admin: 'Aún no hay médicos configurados. Regístralos con su horario en la pestaña Configuración de agenda.', clinic_more: 'más', clinic_status_deleted: 'Eliminada', clinic_delete: 'Eliminar cita', clinic_delete_reason: 'Motivo de eliminación', clinic_delete_confirm: 'La cita se marcará como eliminada: deja de ocupar el horario, pero queda registrada con el motivo para la trazabilidad.', clinic_deleted_notice: 'Cita eliminada: se conserva solo para consulta.', clinic_created_by: 'Agendado por', clinic_cancelled_by: 'Cancelada por', clinic_deleted_by: 'Eliminada por', clinic_ai_agent: 'Agente IA', + clinic_no_doctors: 'Aún no hay médicos configurados en la agenda. Pide a un administrador que los registre.', clinic_no_doctors_admin: 'Aún no hay médicos configurados. Regístralos con su horario en la pestaña Configuración de agenda.', calendar_more: 'más', clinic_all_doctors: 'Todos los médicos', clinic_go_to_date: 'Ir a fecha', clinic_status_deleted: 'Eliminada', clinic_delete: 'Eliminar cita', clinic_delete_reason: 'Motivo de eliminación', clinic_delete_confirm: 'La cita se marcará como eliminada: deja de ocupar el horario, pero queda registrada con el motivo para la trazabilidad.', clinic_deleted_notice: 'Cita eliminada: se conserva solo para consulta.', clinic_created_by: 'Agendado por', clinic_cancelled_by: 'Cancelada por', clinic_deleted_by: 'Eliminada por', clinic_ai_agent: 'Agente IA', clinic_doctors: 'Médicos', clinic_doctor: 'Médico', clinic_status: 'Estado', @@ -711,9 +709,7 @@ export const messages = { clinic_appointment: 'Appointment', clinic_patient: 'Patient', clinic_block: 'Block', - clinic_view_day: 'Day', - clinic_view_week: 'Week', - clinic_view_list: 'Agenda', clinic_view_month: 'Month', clinic_no_doctors: 'No doctors are set up in the schedule yet. Ask an administrator to add them.', clinic_no_doctors_admin: 'No doctors are set up yet. Add them with their hours in the Schedule settings tab.', clinic_more: 'more', clinic_status_deleted: 'Deleted', clinic_delete: 'Delete appointment', clinic_delete_reason: 'Deletion reason', clinic_delete_confirm: 'The appointment will be marked as deleted: it frees the time slot but stays recorded with the reason for traceability.', clinic_deleted_notice: 'Deleted appointment: kept for reference only.', clinic_created_by: 'Booked by', clinic_cancelled_by: 'Cancelled by', clinic_deleted_by: 'Deleted by', clinic_ai_agent: 'AI agent', + clinic_no_doctors: 'No doctors are set up in the schedule yet. Ask an administrator to add them.', clinic_no_doctors_admin: 'No doctors are set up yet. Add them with their hours in the Schedule settings tab.', calendar_more: 'more', clinic_all_doctors: 'All doctors', clinic_go_to_date: 'Go to date', clinic_status_deleted: 'Deleted', clinic_delete: 'Delete appointment', clinic_delete_reason: 'Deletion reason', clinic_delete_confirm: 'The appointment will be marked as deleted: it frees the time slot but stays recorded with the reason for traceability.', clinic_deleted_notice: 'Deleted appointment: kept for reference only.', clinic_created_by: 'Booked by', clinic_cancelled_by: 'Cancelled by', clinic_deleted_by: 'Deleted by', clinic_ai_agent: 'AI agent', 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 7c93f26..fc90120 100644 --- a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx +++ b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Stethoscope, Plus, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Stethoscope, Plus } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { useDraftForm } from '../../hooks/useDraftForm.js'; @@ -15,21 +15,10 @@ 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 { CalendarBoard, CALENDAR_VIEWS, calendarBounds, dateKey, ecuadorNow, fromKey } from '../../components/CalendarBoard.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`); @@ -114,8 +103,10 @@ export default function AgendaMedicaPage() { [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); + // Clave nueva: la 4.8.x podia dejar guardada la vista Dia; ahora siempre arranca en Mes. + [view, setView] = useState(() => { const v = readStored('plc_clinic_view_v3', 'month'); return CALENDAR_VIEWS.includes(v) ? v : 'month'; }), + [cursor, setCursor] = useState(ecuadorNow); + const day = dateKey(cursor); const [doctorFilter, setDoctorFilter] = useState(() => { const value = readStored('plc_clinic_doctors', []); return Array.isArray(value) ? value : []; @@ -191,26 +182,7 @@ export default function AgendaMedicaPage() { 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]); + const period = useMemo(() => calendarBounds(cursor, view), [cursor, view]); async function load() { if (!me) return; try { @@ -240,7 +212,7 @@ export default function AgendaMedicaPage() { }, [me?.user_id, period.from, period.to]); useEffect(() => { try { - localStorage.setItem('plc_clinic_view_v2', JSON.stringify(view)); + localStorage.setItem('plc_clinic_view_v3', JSON.stringify(view)); } catch {} }, [view]); useEffect(() => { @@ -451,23 +423,8 @@ export default function AgendaMedicaPage() { 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 statusOf = a => a.deleted_at ? 'deleted' : a.status; const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(statusOf(a))); // Quien agendo: fijo desde la creacion; si fue el agente de WhatsApp dice "Agente IA". @@ -476,8 +433,10 @@ export default function AgendaMedicaPage() { // si se mira uno solo). Sin permiso de agendar, el clic solo abre ese dia. function newAt(d, slot) { if (!isReception || !doctors.length) { - setDay(d); - setView('day'); + if (!slot) { + setCursor(fromKey(d)); + setView('day'); + } return; } const weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7; @@ -492,13 +451,31 @@ export default function AgendaMedicaPage() { open(kind, item); }; const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id)); - const renderEvent = (a, byDoctor = false) => ; + appointmentMenu(x); + }} title={`${x.starts_at.slice(11, 16)}–${x.ends_at.slice(11, 16)} ${x.first_names} ${x.last_names} · ${x.doctor_name} · ${x.type_name} · ${t(`clinic_status_${statusOf(x)}`)}`}>{compact ? `${x.starts_at.slice(11, 16)} ${x.first_names} ${x.last_names.slice(0, 1)}.` : `${x.starts_at.slice(11, 16)}–${x.ends_at.slice(11, 16)} ${x.first_names} ${x.last_names} · ${x.doctor_name} · ${x.type_name} · ${t(`clinic_status_${statusOf(x)}`)}`} : ; + // Un bloqueo de varios dias (vacaciones) aparece en cada dia que cubre. + const items = [...visible.map(a => ({ kind: 'appointment', day: a.starts_at.slice(0, 10), start: a.starts_at, data: a })), ...visibleBlocks.flatMap(b => { + const out = []; + for (let d = fromKey(b.occurrence_start.slice(0, 10)); dateKey(d) <= b.occurrence_end.slice(0, 10) && out.length < 62; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1)) out.push({ kind: 'block', day: dateKey(d), start: dateKey(d) === b.occurrence_start.slice(0, 10) ? b.occurrence_start : `${dateKey(d)} 00:00:00`, data: b }); + return out; + })].sort((x, y) => x.start.localeCompare(y.start)); + // Hora de Semana/Dia: cerrada si ningun medico mostrado atiende; bloqueada si se mira un solo + // medico y un bloqueo la cubre entera. + const hourState = (d, h) => { + if (!selectedDoctors.length) return undefined; + const start = at(d, `${String(h).padStart(2, '0')}:00`), + end = plus(start, 60), + weekday = (fromKey(d).getDay() + 6) % 7; + if (!selectedDoctors.some(doc => doc.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` < end && `${d} ${seg.ends_time}` > start))) return 'closed'; + if (selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start <= start && b.occurrence_end >= end)) return 'blocked'; + return undefined; + }; 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_created_by'), `${author(popup.item)} · ${String(popup.item.created_at || '').slice(0, 16)}`], [t('clinic_notes'), popup.item.notes], [t('clinic_cancelled_by'), popup.item.cancelled_at && `${popup.item.cancelled_by_name || '—'} · ${popup.item.cancelled_at.slice(0, 16)} · ${popup.item.cancelled_reason || ''}`], [t('clinic_deleted_by'), popup.item.deleted_at && `${popup.item.deleted_by_name || '—'} · ${popup.item.deleted_at.slice(0, 16)} · ${popup.item.deleted_reason || ''}`]] : 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], [t('clinic_created_by'), popup.item.created_by_name]] : []; if (error && !me) return {error}; @@ -517,9 +494,7 @@ export default function AgendaMedicaPage() { if (lo >= hi) [lo, hi] = [7, 19]; return Array.from({ length: hi - lo }, (_, i) => lo + i); })(); - return

{t('nav_clinic')}

{isReception && tab === 'agenda' && }{isReception && tab === 'patients' && }{tab === 'blocks' && }{tab === 'blocks' && }
{error && {error}}{reprogram.length > 0 && {t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}} @@ -527,53 +502,26 @@ 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' && <>
({ - value: x, - label: t(`clinic_view_${x}`) - }))} /> ({ - value: d.id, - label: d.name - }))} values={doctorFilter} onChange={setDoctorFilter} /> ({ - value: x, - label: t(`clinic_status_${x}`) - }))} values={statusFilter} onChange={setStatusFilter} />
- {me && doctors.length === 0 && {isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}} - {(view === 'week' || view === 'month') &&
{selectedDoctors.map(d => {d.name})}{t('clinic_blocks')}
} - {view === 'day' ?
{selectedDoctors.map(d =>

{d.name}

{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
working && !blocked && isReception && open('appointment', null, { - doctorId: d.id, - slot: start - })} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}>{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 =>

{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), - 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
working && !blocked && newAt(d, start)} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}>{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' ? newAt(d)} 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: })), - ...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: }))].sort((x, y) => x.start.localeCompare(y.start)); - return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && }; - }} /> :
{[...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) =>
{x.start.slice(0, 10)}{x.node}
)}
}} + {tab === 'agenda' && newAt(d, h == null ? undefined : at(d, `${String(h).padStart(2, '0')}:00`))} + actions={isReception && } + filters={<> + + + + } + legend={<> + {selectedDoctors.map(d => {d.name})} + {t('clinic_blocks')} + {t('clinic_status_cancelled')} / {t('clinic_status_deleted')} + } + notice={me && doctors.length === 0 && {isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}} />} {tab === 'patients' && isReception && <> { setSearch(e.target.value); setPage(1); diff --git a/frontend/src/pages/AgendaMedica/clinic.css b/frontend/src/pages/AgendaMedica/clinic.css index 659eaed..696370b 100644 --- a/frontend/src/pages/AgendaMedica/clinic.css +++ b/frontend/src/pages/AgendaMedica/clinic.css @@ -1,29 +1,10 @@ -.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(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} -/* Barra igual a la del Calendario: Hoy, anterior/siguiente y titulo del periodo; a la derecha vista y filtros. */ -.clinic-toolbar{margin:12px 0} -.clinic-toolbar .clinic-controls{margin:0} -.clinic-period{display:inline-block}.clinic-period::first-letter{text-transform:uppercase} -.clinic-toolbar .clinic-controls .combobox{width:140px;min-width:0;flex:0 0 140px} -.clinic-toolbar .clinic-controls .datepicker-trigger{flex:0 0 150px;width:150px} -/* Chips del mes clicables; canceladas/eliminadas tachadas y atenuadas (solo si el filtro las muestra). */ -button.clinic-chip{display:block;width:100%;text-align:left;border-top:0;border-right:0;border-bottom:0;color:rgb(var(--body));cursor:pointer;font:inherit;font-size:.68rem} -button.clinic-chip:hover,.clinic-event:hover{filter:brightness(.95)} -.clinic-more{border:0;background:none;padding:2px 0;color:rgb(var(--accent));font-size:.66rem;cursor:pointer} -.clinic-chip.status-cancelled,.clinic-chip.status-deleted,.clinic-event.status-cancelled,.clinic-event.status-deleted{text-decoration:line-through;opacity:.6} -.clinic-chip.status-deleted,.clinic-event.status-deleted{border-left-style:dashed} +.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.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-detail{grid-template-columns:1fr}} +/* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */ +.meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px} +.meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)} +.meeting-calendar .calendar-entry.clinic-block{color:rgb(var(--warning));background:repeating-linear-gradient(135deg,rgb(var(--warning)/.16) 0 6px,transparent 6px 12px);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px} +.meeting-calendar .calendar-legend-item.clinic-block-sample .calendar-swatch{background:repeating-linear-gradient(135deg,rgb(var(--warning)) 0 3px,transparent 3px 6px);border:1px solid rgb(var(--warning)/.6)} +.meeting-calendar .calendar-legend-item.clinic-done-sample{color:rgb(var(--muted));text-decoration:line-through} +.meeting-calendar .calendar-legend-item.clinic-done-sample .calendar-swatch{background:rgb(var(--muted)/.3)} +.meeting-calendar .calendar-legend-item{cursor:default} .clinic-deleted{opacity:.65} diff --git a/frontend/src/pages/Calendario/CalendarioPage.jsx b/frontend/src/pages/Calendario/CalendarioPage.jsx index a059752..2b39843 100644 --- a/frontend/src/pages/Calendario/CalendarioPage.jsx +++ b/frontend/src/pages/Calendario/CalendarioPage.jsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { CalendarDays, ChevronLeft, ChevronRight, Plus, Pencil, Trash2, XCircle } from 'lucide-react'; +import { CalendarDays, Plus, Pencil, Trash2, XCircle } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; @@ -21,30 +21,15 @@ import { listUsers } from '../../services/users.js'; import { hasModule } from '../../utils/modules.js'; import { listMeetings, listMeetingActivities, listMeetingRecurring, listMeetingTasks, listMeetingProjects, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js'; import { clinicMe, clinicAppointments, clinicBlocks } from '../../services/clinic.js'; -import './calendar.css'; +import { CalendarBoard, calendarBounds, dateKey, ecuadorNow } from '../../components/CalendarBoard.jsx'; -const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; -const ecuadorNow = () => { - const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone:'America/Guayaquil', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hourCycle:'h23' }).formatToParts(new Date()).map((p) => [p.type,p.value])); - return new Date(Number(parts.year), Number(parts.month)-1, Number(parts.day), Number(parts.hour), Number(parts.minute)); -}; const dateTime = (d) => `${dateKey(d)} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:00`; -const shift = (d, days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days); const plusHour = (s) => { const d = new Date(s.replace(' ', 'T')); d.setHours(d.getHours()+1); return dateTime(d); }; const blank = (start = nextSlot()) => ({ title: '', description: '', startsAt: start, endsAt: plusHour(start), allDay: false, location: '', videoUrl: '', projectId: '', reminderMinutes: '', participantUserIds: [], externalEmails: [], recurrence: null }); function toForm(row) { return { title: row.title, description: row.description || '', startsAt: row.starts_at, endsAt: row.ends_at, allDay: !!row.all_day, location: row.location || '', videoUrl: row.video_url || '', projectId: row.project_id || '', reminderMinutes: row.reminder_minutes ?? '', participantUserIds: row.participants.map((p) => p.user_id), externalEmails: row.external_emails, recurrence: row.recurrence_json ? JSON.parse(row.recurrence_json) : null, editVersion: row.edit_version }; } // Hora por defecto de una reunion nueva: siguiente media hora (10:31 -> 11:00, 10:10 -> 10:30). const nextSlot = () => { const d = ecuadorNow(); d.setMinutes(d.getMinutes() < 30 ? 30 : 60, 0, 0); return dateTime(d); }; const readable = (s, lang, allDay) => new Date(s.replace(' ', 'T')).toLocaleString(lang, allDay ? { dateStyle: 'full' } : { dateStyle: 'full', timeStyle: 'short' }); -function bounds(cursor, view) { - let first, last; - if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1); } - else if (view === 'week') { first = shift(cursor, -((cursor.getDay()+6)%7)); last = shift(first, 7); } - else if (view === 'day') { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first,1); } - else { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first,30); } - return { from: `${dateKey(first)} 00:00:00`, to: `${dateKey(last)} 00:00:00`, first, last }; -} - const ALL_LAYERS = { meeting: true, project: true, activity: true, task: true, recurring: true, clinic: true }; export default function CalendarioPage() { @@ -73,7 +58,7 @@ export default function CalendarioPage() { const [externalInput, setExternalInput] = useState(''); const [userChoice, setUserChoice] = useState(''); const [context, setContext] = useState(null); - const { from, to, first, last } = useMemo(() => bounds(cursor, view), [cursor, view]); + const { from, to } = useMemo(() => calendarBounds(cursor, view), [cursor, view]); const { data: meetings, reload } = useAsync(() => listMeetings(from, to), [from, to]); const { data: activities } = useAsync(() => showDue ? listMeetingActivities(from, to) : Promise.resolve([]), [from, to, showDue]); const canRecurring = hasModule(user, 'recurring_tasks'); @@ -110,11 +95,6 @@ export default function CalendarioPage() { function openNew(day) { setNewStart(day || nextSlot()); setCreating(true); setEditing(true); setSelected(null); setWarning([]); } async function openMeeting(item, edit = false) { try { const row = await getMeeting(item.id); setSelected(row); setCreating(false); setEditing(edit); setWarning([]); } catch (err) { pushToast({tone:'error',message:err.message}); } } function close() { void guardedClose((creating || editing) ? draft : null, () => { setCreating(false); setEditing(false); setSelected(null); }); } - function changeView(next) { setView(next); } - function navigatePeriod(delta) { - if (view === 'month') setCursor(new Date(cursor.getFullYear(), cursor.getMonth()+delta, 1)); - else setCursor(shift(cursor, delta * (view === 'week' ? 7 : view === 'agenda' ? 30 : 1))); - } async function submit(confirmConflicts = false) { try { await runAction(async () => { const payload = { ...v, participantUserIds: v.participantUserIds.map(Number), externalEmails: v.externalEmails, projectId: v.projectId || null, reminderMinutes: v.reminderMinutes || null, confirmConflicts }; @@ -145,9 +125,6 @@ export default function CalendarioPage() { const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` })); const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))]; const allItems = [...(layers.meeting ? meetings || [] : []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : []), ...(layers.clinic && clinicDoctor ? [...(clinicAppointmentsData||[]).filter((a)=>!a.deleted_at&&a.status!=='cancelled').map((a)=>({...a,kind:'clinic',day:a.starts_at.slice(0,10),occurrence_start:a.starts_at,clinicLabel:`${a.first_names} ${a.last_names?.slice(0,1)||''}.`})),...(clinicBlocksData||[]).map((b)=>({...b,kind:'clinic',day:b.occurrence_start.slice(0,10),clinicLabel:b.title}))] : []), ...(layers.recurring && canRecurring ? (recurring || []).map((r) => ({ ...r, kind: 'recurring', day: r.due_date, occurrence_start: r.due_date })) : []), ...(layers.project && canProjects ? (projectDates || []).map((p) => ({ ...p, kind: 'project', day: p.due_date, occurrence_start: `${p.milestone}-${p.due_date}` })) : []), ...(layers.task && canProjects ? (myTasks || []).map((x) => ({ ...x, kind: 'task', day: x.due_date, occurrence_start: x.due_date })) : [])].filter((item) => origin === 'all' || (origin === 'shared' ? item.shared : !item.shared)).sort((a,b) => (a.day || '').localeCompare(b.day || '')); - const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {}); - const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i)); - const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')]; const availableLayers = [['meeting', t('calendar_layer_meetings')], ...(canProjects ? [['project', t('calendar_layer_projects')], ['activity', t('calendar_layer_activities')], ['task', t('calendar_layer_tasks')]] : []), ...(canRecurring ? [['recurring', t('calendar_layer_recurring')]] : []), ...(clinicDoctor ? [['clinic', t('calendar_layer_clinic')]] : [])]; const activeTypes = availableLayers.filter(([kind]) => layers[kind]).map(([kind]) => kind); @@ -175,31 +152,22 @@ export default function CalendarioPage() { ; } return
-

{t('nav_calendar')}

-
-
({ value:x, label:t(`calendar_${x}`) }))} value={view} onChange={changeView} /> -
-
- - -
-
- {availableLayers.filter(([kind]) => layers[kind]).map(([kind, label]) => {label})} - {t('calendar_overdue')} - {t('calendar_origin_shared')} -
- {view === 'agenda' ?
{allItems.length ? allItems.map((item) =>
{item.day}{itemButton(item)}
) :

{t('calendar_empty')}

}
:
- {view !== 'day' && weekLabels.map((label) =>
{label}
)} - {days.map((day) => { const key = dateKey(day); const muted = view === 'month' && day.getMonth() !== cursor.getMonth(); return
openNew(`${key} 09:00:00`)}> -
{view === 'day' ? day.toLocaleDateString(lang, { weekday:'long', day:'numeric', month:'long' }) : day.getDate()}
- {(byDay[key] || []).map(itemButton)} - {view !== 'month' &&
{Array.from({length:11}, (_,i) => i+8).map((hour) => )}
} -
; })}
} + openNew()}>{t('calendar_new')}} + filters={<> + + + } + legend={<> + {availableLayers.filter(([kind]) => layers[kind]).map(([kind, label]) => {label})} + {t('calendar_overdue')} + {t('calendar_origin_shared')} + } + items={allItems} renderItem={itemButton} onSlot={(key, hour) => openNew(`${key} ${String(hour ?? 9).padStart(2, '0')}:00:00`)} /> {context &&
setContext(null)}>
e.stopPropagation()}> { void openMeeting(context.item); setContext(null); }}>{t('calendar_open')}