fix: version 4.9.1 -- Agenda medica usa el mismo calendario que Calendario

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 08:26:38 -05:00
parent 366447dffc
commit 28444b1485
14 changed files with 186 additions and 230 deletions
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "4.9.0",
"version": "4.9.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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); }
+74
View File
@@ -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 <div className="meeting-calendar">
<div className="toolbar"><div className="toolbar-left">{title && <h2 className="page-title mt-0">{Icon && <Icon size={22} />} {title}</h2>}
<Button onClick={() => onCursorChange(ecuadorNow())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => navigate(-1)} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => navigate(1)} aria-label={t('calendar_next')} />
<strong>{first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}</strong></div>
<div className="toolbar-left"><ComboBox options={CALENDAR_VIEWS.map((x) => ({ value: x, label: t(`calendar_${x}`) }))} value={view} onChange={onViewChange} />{actions}</div></div>
{filters && <div className="calendar-filters">{filters}</div>}
{legend && <div className="calendar-legend" aria-label={t('calendar_legend')}>{legend}</div>}
{notice}
{view === 'agenda' ? <div className="calendar-agenda">{items.length ? items.map((item, i) => <div key={i} className="calendar-agenda-row"><strong>{item.day}</strong>{renderItem(item)}</div>) : <p className="text-muted">{t('calendar_empty')}</p>}</div> : <div className={`calendar-grid calendar-${view}`}>
{view !== 'day' && weekLabels.map((label) => <div key={label} className="calendar-dow">{label}</div>)}
{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 <div key={key} className={`calendar-cell ${muted ? 'muted' : ''} ${key === todayKey ? 'today' : ''}`} onClick={() => onSlot?.(key, null)}>
<div className="calendar-day-num">{view === 'day' ? day.toLocaleDateString(lang, { weekday: 'long', day: 'numeric', month: 'long' }) : day.getDate()}</div>
{dayItems.slice(0, limit).map(renderItem)}
{limit < dayItems.length && <button type="button" className="calendar-more" onClick={(e) => { e.stopPropagation(); onCursorChange(day); onViewChange('day'); }}>+{dayItems.length - limit} {t('calendar_more')}</button>}
{view !== 'month' && <div className="calendar-hours">{hours.map((hour) => { const state = hourState?.(key, hour); return <button key={hour} className={state || undefined} disabled={!!state} onClick={(e) => { e.stopPropagation(); onSlot?.(key, hour); }}>{String(hour).padStart(2, '0')}:00 {!state && <Plus size={12} />}</button>; })}</div>}
</div>;
})}</div>}
</div>;
}
-30
View File
@@ -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 (
<div className="calendar-grid">
{weekLabels.map((label) => <div key={label} className="calendar-dow">{label}</div>)}
{monthGridDays(day).map((d) => (
<div key={d} className={`calendar-cell ${d.slice(0, 7) !== month ? 'muted' : ''} ${d === todayKey ? 'today' : ''}`}
onClick={() => onDayClick?.(d)} style={{ cursor: onDayClick ? 'pointer' : undefined, minWidth: 0 }}>
<div className="calendar-day-num">{Number(d.slice(8, 10))}</div>
{renderDay(d)}
</div>
))}
</div>
);
}
+2 -6
View File
@@ -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',
@@ -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) => <button key={`a-${a.id}`} className={`clinic-event appointment status-${statusOf(a)}`} style={{
borderLeftColor: byDoctor ? doctorColor(a.doctor_user_id) : a.type_color
}} onClick={e => 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' ? <button key={`a-${x.id}`} className={`calendar-entry clinic-appt${['cancelled', 'deleted'].includes(statusOf(x)) ? ' done' : ''}${x.deleted_at ? ' deleted' : ''}`} style={{ '--doctor': doctorColor(x.doctor_user_id) }} onClick={e => 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)}`)}`}</button>;
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)}`)}`}</button> : <button key={`b-${x.id}-${x.occurrence_start}`} className="calendar-entry clinic-block" style={{ '--doctor': doctorColor(x.doctor_user_id) }} onClick={e => 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}`}`}</button>;
// 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 => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={e => 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}</button>;
const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_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 <InfoBar tone="error">{error}</InfoBar>;
@@ -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 <div className="clinic-page"><div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div><div className="toolbar-left">{isReception && tab === 'agenda' && <Button variant="accent" icon={Plus} onClick={() => open('appointment', null, {
doctorId: singleDoctor
})}>{t('clinic_new_appointment')}</Button>}{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, {
return <div className="clinic-page"><div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div><div className="toolbar-left">{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, {
doctorId: singleDoctor
})}>{t('clinic_new_block')}</Button>}</div></div>
{error && <InfoBar tone="error">{error}</InfoBar>}{reprogram.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}</InfoBar>}
@@ -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' && <><div className="toolbar clinic-toolbar"><div className="toolbar-left"><Button onClick={() => setDay(today())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => setDay(step(-1))} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => setDay(step(1))} aria-label={t('calendar_next')} /><strong className="clinic-period">{periodTitle}</strong></div><div className="toolbar-left clinic-controls"><ComboBox value={view} onChange={setView} options={['month', 'week', 'day', 'list'].map(x => ({
value: x,
label: t(`clinic_view_${x}`)
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} values={doctorFilter} onChange={setDoctorFilter} /><CheckListDropdown label={t('clinic_status')} options={['scheduled', 'confirmed', 'attended', 'no_show', 'cancelled', 'deleted'].map(x => ({
value: x,
label: t(`clinic_status_${x}`)
}))} values={statusFilter} onChange={setStatusFilter} /><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /></div></div>
{me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>}
{(view === 'week' || view === 'month') && <div className="clinic-legend">{selectedDoctors.map(d => <span key={d.id}><span className="clinic-swatch" style={{ background: d.color || 'rgb(var(--accent))' }} />{d.name}</span>)}<span><span className="clinic-swatch block" />{t('clinic_blocks')}</span></div>}
{view === 'day' ? <div className="clinic-day-grid">{selectedDoctors.map(d => <div className="clinic-doctor" key={d.id}><h3 style={{
borderTop: `3px solid ${d.color || 'rgb(var(--accent))'}`
}}>{d.name}</h3>{hourRange.map(h => {
const hour = String(h).padStart(2, '0'),
start = at(day, `${hour}:00`),
end = plus(start, 60),
weekday = (new Date(`${day}T12:00:00Z`).getUTCDay() + 6) % 7,
working = d.schedules?.some(s => s.weekday === weekday && `${day} ${s.starts_time}` <= start && `${day} ${s.ends_time}` >= end);
const blocked = visibleBlocks.some(b => b.doctor_user_id === d.id && b.occurrence_start < end && b.occurrence_end > start);
return <div key={hour} className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} onClick={() => working && !blocked && isReception && open('appointment', null, {
doctorId: d.id,
slot: start
})} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}><small>{hour}:00</small>{visible.filter(a => a.doctor_user_id === d.id && a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.doctor_user_id === d.id && b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
})}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
length: 7
}, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{weekLabels[(new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7]} {Number(d.slice(8, 10))}</h3>{hourRange.map(h => {
const start = at(d, `${String(h).padStart(2, '0')}:00`),
end = plus(start, 60),
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
// Con varios medicos: la hora es "de atencion" si alguno atiende; el sombreado de bloqueo
// solo aplica cuando se mira un medico (con varios, cada bloqueo se ve como entrada).
working = selectedDoctors.some(doc => doc.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end)),
blocked = selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start);
return <div className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} key={h} onClick={() => working && !blocked && newAt(d, start)} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(a => renderEvent(a, true))}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
})}</div>)}</div> : view === 'month' ? <MonthGrid day={day} weekLabels={weekLabels} todayKey={today()} onDayClick={d => 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: <button type="button" key={`b-${b.id}-${b.occurrence_start}`} className="clinic-chip block" style={{ borderLeftColor: doctorColor(b.doctor_user_id) }} title={b.title} onClick={e => openItem(e, 'block', b)}>{doctorShort(b.doctor_user_id)} · {b.title}</button> })),
...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: <button type="button" key={`a-${a.id}`} className={`clinic-chip status-${statusOf(a)}`} style={{ borderLeftColor: doctorColor(a.doctor_user_id) }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${t(`clinic_status_${statusOf(a)}`)}`} onClick={e => 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)}.</button> }))].sort((x, y) => x.start.localeCompare(y.start));
return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && <button type="button" className="clinic-more" onClick={e => { e.stopPropagation(); setDay(d); setView('day'); }}>+{items.length - 4} {t('clinic_more')}</button>}</>;
}} /> : <div className="clinic-list">{[...visible.map(a => ({
start: a.starts_at,
node: renderEvent(a)
})), ...visibleBlocks.map(b => ({
start: b.occurrence_start,
node: renderBlock(b)
}))].sort((a, b) => a.start.localeCompare(b.start)).map((x, i) => <div key={i}><strong>{x.start.slice(0, 10)}</strong>{x.node}</div>)}</div>}</>}
{tab === 'agenda' && <CalendarBoard view={view} onViewChange={setView} cursor={cursor} onCursorChange={setCursor} items={items} renderItem={renderItem} hours={hourRange} hourState={hourState} maxPerDay={5}
onSlot={(d, h) => newAt(d, h == null ? undefined : at(d, `${String(h).padStart(2, '0')}:00`))}
actions={isReception && <Button variant="accent" icon={Plus} onClick={() => newAt(day)}>{t('clinic_new_appointment')}</Button>}
filters={<>
<label className="calendar-filter-label">{t('clinic_doctors')}
<CheckListDropdown label={!doctorFilter.length || doctorFilter.length === doctors.length ? t('clinic_all_doctors') : doctorFilter.length === 1 ? doctors.find(d => 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} />
</label>
<label className="calendar-filter-label">{t('clinic_status')}
<CheckListDropdown label={statusFilter.length === 1 ? t(`clinic_status_${statusFilter[0]}`) : `${statusFilter.length} / 6`} options={['scheduled', 'confirmed', 'attended', 'no_show', 'cancelled', 'deleted'].map(x => ({ value: x, label: t(`clinic_status_${x}`) }))} values={statusFilter} onChange={setStatusFilter} width={220} />
</label>
<label className="calendar-filter-label">{t('clinic_go_to_date')}
<DatePicker value={day} onChange={x => x && setCursor(fromKey(x))} label={t('clinic_go_to_date')} />
</label>
</>}
legend={<>
{selectedDoctors.map(d => <span key={d.id} className="calendar-legend-item"><span className="calendar-swatch" style={{ background: d.color || 'rgb(var(--accent))' }} />{d.name}</span>)}
<span className="calendar-legend-item clinic-block-sample"><span className="calendar-swatch" />{t('clinic_blocks')}</span>
<span className="calendar-legend-item clinic-done-sample"><span className="calendar-swatch" />{t('clinic_status_cancelled')} / {t('clinic_status_deleted')}</span>
</>}
notice={me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>} />}
{tab === 'patients' && isReception && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value);
setPage(1);
+9 -28
View File
@@ -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}
@@ -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() {
</button>; }
return <div className="meeting-calendar">
<div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><CalendarDays size={22} /> {t('nav_calendar')}</h2>
<Button onClick={() => setCursor(ecuadorNow())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => navigatePeriod(-1)} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => navigatePeriod(1)} aria-label={t('calendar_next')} />
<strong>{first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}</strong></div>
<div className="toolbar-left"><ComboBox options={['month','week','day','agenda'].map((x) => ({ value:x, label:t(`calendar_${x}`) }))} value={view} onChange={changeView} />
<Button variant="accent" icon={Plus} onClick={() => openNew()}>{t('calendar_new')}</Button></div></div>
<div className="calendar-filters">
<label className="calendar-filter-label">{t('calendar_filter_origin')}
<ComboBox options={['all', 'mine', 'shared'].map((x) => ({ value: x, label: t(`calendar_origin_${x}`) }))} value={origin} onChange={setOrigin} />
</label>
<label className="calendar-filter-label">{t('calendar_filter_types')}
<CheckListDropdown label={allTypesOn ? t('calendar_all_types') : activeTypes.length === 0 ? t('calendar_no_types') : activeTypes.length === 1 ? availableLayers.find(([k]) => 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} />
</label>
</div>
<div className="calendar-legend" aria-label={t('calendar_legend')}>
{availableLayers.filter(([kind]) => layers[kind]).map(([kind, label]) => <span key={kind} className={`calendar-legend-item ${kind}`}><span className="calendar-swatch" />{label}</span>)}
<span className="calendar-legend-item overdue"><span className="calendar-swatch" />{t('calendar_overdue')}</span>
<span className="calendar-legend-item shared-sample"><span className="calendar-swatch" />{t('calendar_origin_shared')}</span>
</div>
{view === 'agenda' ? <div className="calendar-agenda">{allItems.length ? allItems.map((item) => <div key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className="calendar-agenda-row"><strong>{item.day}</strong>{itemButton(item)}</div>) : <p className="text-muted">{t('calendar_empty')}</p>}</div> : <div className={`calendar-grid calendar-${view}`}>
{view !== 'day' && weekLabels.map((label) => <div key={label} className="calendar-dow">{label}</div>)}
{days.map((day) => { const key = dateKey(day); const muted = view === 'month' && day.getMonth() !== cursor.getMonth(); return <div key={key} className={`calendar-cell ${muted ? 'muted' : ''} ${key === dateKey(ecuadorNow()) ? 'today' : ''}`} onClick={() => openNew(`${key} 09:00:00`)}>
<div className="calendar-day-num">{view === 'day' ? day.toLocaleDateString(lang, { weekday:'long', day:'numeric', month:'long' }) : day.getDate()}</div>
{(byDay[key] || []).map(itemButton)}
{view !== 'month' && <div className="calendar-hours">{Array.from({length:11}, (_,i) => i+8).map((hour) => <button key={hour} onClick={(e) => { e.stopPropagation(); openNew(`${key} ${String(hour).padStart(2,'0')}:00:00`); }}>{String(hour).padStart(2,'0')}:00 <Plus size={12} /></button>)}</div>}
</div>; })}</div>}
<CalendarBoard title={t('nav_calendar')} icon={CalendarDays} view={view} onViewChange={setView} cursor={cursor} onCursorChange={setCursor}
actions={<Button variant="accent" icon={Plus} onClick={() => openNew()}>{t('calendar_new')}</Button>}
filters={<>
<label className="calendar-filter-label">{t('calendar_filter_origin')}
<ComboBox options={['all', 'mine', 'shared'].map((x) => ({ value: x, label: t(`calendar_origin_${x}`) }))} value={origin} onChange={setOrigin} />
</label>
<label className="calendar-filter-label">{t('calendar_filter_types')}
<CheckListDropdown label={allTypesOn ? t('calendar_all_types') : activeTypes.length === 0 ? t('calendar_no_types') : activeTypes.length === 1 ? availableLayers.find(([k]) => 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} />
</label>
</>}
legend={<>
{availableLayers.filter(([kind]) => layers[kind]).map(([kind, label]) => <span key={kind} className={`calendar-legend-item ${kind}`}><span className="calendar-swatch" />{label}</span>)}
<span className="calendar-legend-item overdue"><span className="calendar-swatch" />{t('calendar_overdue')}</span>
<span className="calendar-legend-item shared-sample"><span className="calendar-swatch" />{t('calendar_origin_shared')}</span>
</>}
items={allItems} renderItem={itemButton} onSlot={(key, hour) => openNew(`${key} ${String(hour ?? 9).padStart(2, '0')}:00:00`)} />
{context && <div className="calendar-context-layer" onClick={() => setContext(null)}><div className="flyout-panel calendar-context" style={{ left:Math.min(context.x, window.innerWidth-220), top:Math.min(context.y, window.innerHeight-190) }} onClick={(e) => e.stopPropagation()}>
<FlyoutItem onClick={() => { void openMeeting(context.item); setContext(null); }}>{t('calendar_open')}</FlyoutItem>