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
+58
View File
@@ -0,0 +1,58 @@
.meeting-calendar .page-title { display:flex; align-items:center; gap:8px; }
.meeting-calendar .calendar-filter { display:flex; align-items:center; gap:7px; margin:10px 0 16px; font-size:.82rem; }
.meeting-datetime { display:flex; align-items:center; gap:4px; }
.meeting-datetime .datepicker-trigger { flex:1; min-width:112px; }
.meeting-datetime .combobox { width:55px; flex:none; }
.meeting-datetime .combobox .input { padding:6px 4px; }
.meeting-calendar .calendar-grid { width:100%; }
.meeting-calendar .calendar-grid.calendar-day { grid-template-columns:1fr; }
.meeting-calendar .calendar-cell { min-height:110px; min-width:0; cursor:pointer; }
.meeting-calendar .calendar-day .calendar-cell { min-height:360px; }
.meeting-calendar .calendar-hours { display:flex; flex-direction:column; margin-top:6px; }
.meeting-calendar .calendar-hours button { border:0; border-top:1px solid rgb(var(--line)); background:transparent; color:rgb(var(--muted)); text-align:left; min-height:32px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; font:inherit; }
.meeting-calendar .calendar-hours button:hover { background:rgb(var(--accent) / .10); color:rgb(var(--accent)); }
.meeting-calendar .calendar-entry { display:block; width:100%; border:0; border-radius:4px; padding:4px 6px; text-align:left; font:inherit; font-size:.72rem; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:rgb(var(--accent)); background:rgb(var(--accent) / .16); }
.meeting-calendar .calendar-entry.activity { color:rgb(var(--warning)); background:rgb(var(--warning) / .14); }
.meeting-calendar .calendar-entry:hover, .meeting-calendar .calendar-entry:focus-visible { outline:2px solid rgb(var(--accent)); }
.meeting-calendar .calendar-entry-more { float:right; padding:0 3px; }
.meeting-calendar .calendar-agenda-row { display:flex; gap:12px; align-items:center; padding:8px 4px; border-bottom:1px solid rgb(var(--line)); }
.meeting-calendar .calendar-agenda-row strong { min-width:105px; font-size:.78rem; }
.meeting-calendar .calendar-agenda-row .calendar-entry { flex:1; }
.calendar-context-layer { position:fixed; inset:0; z-index:101; }
.calendar-context { position:fixed; visibility:visible; min-width:190px; }
.calendar-tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.calendar-tags button { background:rgb(var(--accent) / .16); color:rgb(var(--body)); border:1px solid rgb(var(--line)); border-radius:4px; padding:3px 7px; cursor:pointer; }
.calendar-weekdays { display:flex; flex-wrap:wrap; gap:12px; margin:8px 0; font-size:.8rem; }
.calendar-detail p { line-height:1.5; }
@media (max-width:700px) { .meeting-calendar .calendar-grid { gap:2px; } .meeting-calendar .calendar-cell { min-height:80px; padding:3px; } .meeting-calendar .calendar-entry { font-size:.6rem; padding:2px; } .meeting-calendar .calendar-dow { font-size:.58rem; } }
.meeting-calendar .calendar-entry.project { color:rgb(var(--cal-project)); background:rgb(var(--cal-project) / .14); }
.meeting-calendar .calendar-entry.task { color:rgb(var(--cal-task)); background:rgb(var(--cal-task) / .14); }
.meeting-calendar .calendar-legend-item.project .calendar-swatch { background:rgb(var(--cal-project)); }
.meeting-calendar .calendar-legend-item.task .calendar-swatch { background:rgb(var(--cal-task)); }
/* Colores por tipo de evento (leyenda = mismos colores que las entradas). */
.meeting-calendar .calendar-entry.recurring { color:rgb(var(--success)); background:rgb(var(--success) / .14); }
.meeting-calendar .calendar-entry.projected { opacity:.72; border:1px dashed rgb(var(--success) / .55); }
.meeting-calendar .calendar-entry.done { opacity:.55; text-decoration:line-through; }
.meeting-calendar .calendar-entry.overdue { color:rgb(var(--danger)); background:rgb(var(--danger) / .14); border:1px solid rgb(var(--danger) / .5); }
.meeting-calendar .calendar-legend { display:flex; flex-wrap:wrap; align-items:center; gap:8px 18px; margin:10px 0 16px; font-size:.82rem; }
.meeting-calendar .calendar-legend-item { display:inline-flex; align-items:center; gap:6px; cursor:pointer; }
.meeting-calendar .calendar-legend-item.overdue { cursor:default; color:rgb(var(--muted)); }
.meeting-calendar .calendar-swatch { width:12px; height:12px; border-radius:3px; background:rgb(var(--accent)); }
.meeting-calendar .calendar-legend-item.activity .calendar-swatch { background:rgb(var(--warning)); }
.meeting-calendar .calendar-legend-item.recurring .calendar-swatch { background:rgb(var(--success)); }
.meeting-calendar .calendar-legend-item.overdue .calendar-swatch { background:rgb(var(--danger)); }
.meeting-calendar .calendar-filters { display:flex; flex-wrap:wrap; align-items:flex-end; gap:10px 18px; margin:10px 0 6px; }
.meeting-calendar .calendar-filter-label { display:flex; flex-direction:column; gap:4px; font-size:.72rem; color:rgb(var(--muted)); min-width:200px; }
/* Compartido conmigo: franja a la izquierda del color del tipo. */
.meeting-calendar .calendar-entry.shared { box-shadow:inset 3px 0 0 currentColor; padding-left:8px; }
.meeting-calendar .calendar-legend-item.shared-sample { cursor:default; color:rgb(var(--muted)); }
.meeting-calendar .calendar-legend-item.shared-sample .calendar-swatch { background:rgb(var(--muted) / .25); box-shadow:inset 3px 0 0 rgb(var(--body)); }
.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>
);
}