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