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} }
+ onCursorChange(ecuadorNow())}>{t('calendar_today')} navigate(-1)} aria-label={t('calendar_previous')} /> navigate(1)} aria-label={t('calendar_next')} />
+ {first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}
+
({ 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 &&
{ e.stopPropagation(); onCursorChange(day); onViewChange('day'); }}>+{dayItems.length - limit} {t('calendar_more')} }
+ {view !== 'month' &&
{hours.map((hour) => { const state = hourState?.(key, hour); return
{ e.stopPropagation(); onSlot?.(key, hour); }}>{String(hour).padStart(2, '0')}:00 {!state && } ; })}
}
+
;
+ })}
}
+
;
+}
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) => openItem(e, 'appointment', a)} onContextMenu={e => {
+ // Entradas del calendario: color del medico; canceladas/eliminadas tachadas. En Mes/Semana van
+ // compactas; en Dia/Agenda con medico, tipo y estado.
+ const compact = view === 'month' || view === 'week';
+ const renderItem = ({ kind, data: x }) => kind === 'appointment' ? openItem(e, 'appointment', x)} onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
- appointmentMenu(a);
- }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${statusOf(a)}`)}`}>{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_${statusOf(a)}`)}`} ;
+ 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)}`)}`} : openItem(e, 'block', x)} title={`${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctors.find(d => d.id === x.doctor_user_id)?.name || ''} · ${x.title}`}>{compact ? `${doctorShort(x.doctor_user_id)} · ${x.title}` : `${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctors.find(d => d.id === x.doctor_user_id)?.name || ''} · ${t(`clinic_kind_${x.kind}`) === x.title ? x.title : `${t(`clinic_kind_${x.kind}`)} · ${x.title}`}`} ;
+ // 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 => openItem(e, 'block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title} ;
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' &&
open('appointment', null, {
- doctorId: singleDoctor
- })}>{t('clinic_new_appointment')} }{isReception && tab === 'patients' &&
open('patient')}>{t('clinic_new_patient')} }{tab === 'blocks' &&
open('block', null, {
+ return
{t('nav_clinic')}{isReception && tab === 'patients' && open('patient')}>{t('clinic_new_patient')} }{tab === 'blocks' && open('block', null, {
doctorId: singleDoctor
})}>{t('clinic_new_block')} }
{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' && <>
setDay(today())}>{t('calendar_today')} setDay(step(-1))} aria-label={t('calendar_previous')} /> setDay(step(1))} aria-label={t('calendar_next')} />{periodTitle}
({
- 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: openItem(e, 'block', b)}>{doctorShort(b.doctor_user_id)} · {b.title} })),
- ...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: openItem(e, 'appointment', a)} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); appointmentMenu(a); }}>{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 && { e.stopPropagation(); setDay(d); setView('day'); }}>+{items.length - 4} {t('clinic_more')} }>;
- }} /> : {[...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 && newAt(day)}>{t('clinic_new_appointment')} }
+ filters={<>
+ {t('clinic_doctors')}
+ d.id === doctorFilter[0])?.name || '1' : `${doctorFilter.length} / ${doctors.length}`} options={doctors.map(d => ({ value: d.id, label: d.name }))} values={doctorFilter} onChange={setDoctorFilter} width={240} />
+
+ {t('clinic_status')}
+ ({ value: x, label: t(`clinic_status_${x}`) }))} values={statusFilter} onChange={setStatusFilter} width={220} />
+
+ {t('clinic_go_to_date')}
+ x && setCursor(fromKey(x))} label={t('clinic_go_to_date')} />
+
+ >}
+ 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')}
- setCursor(ecuadorNow())}>{t('calendar_today')} navigatePeriod(-1)} aria-label={t('calendar_previous')} /> navigatePeriod(1)} aria-label={t('calendar_next')} />
- {first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}
-
({ value:x, label:t(`calendar_${x}`) }))} value={view} onChange={changeView} />
- openNew()}>{t('calendar_new')}
-
- {t('calendar_filter_origin')}
- ({ value: x, label: t(`calendar_origin_${x}`) }))} value={origin} onChange={setOrigin} />
-
- {t('calendar_filter_types')}
- k === activeTypes[0])[1] : `${activeTypes.length} / ${availableLayers.length}`} options={[{ value: 'all', label: t('calendar_all_types') }, ...availableLayers.map(([value, label]) => ({ value, label }))]} values={typeValues} onChange={changeTypes} width={240} />
-
-
-
- {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) =>
{ e.stopPropagation(); openNew(`${key} ${String(hour).padStart(2,'0')}:00:00`); }}>{String(hour).padStart(2,'0')}:00 )}
}
-
; })}
}
+
openNew()}>{t('calendar_new')}}
+ filters={<>
+ {t('calendar_filter_origin')}
+ ({ value: x, label: t(`calendar_origin_${x}`) }))} value={origin} onChange={setOrigin} />
+
+ {t('calendar_filter_types')}
+ k === activeTypes[0])[1] : `${activeTypes.length} / ${availableLayers.length}`} options={[{ value: 'all', label: t('calendar_all_types') }, ...availableLayers.map(([value, label]) => ({ value, label }))]} values={typeValues} onChange={changeTypes} width={240} />
+
+ >}
+ 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')}