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:
@@ -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); }
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user