feat: version 4.8.0 -- Agenda medica en vista de calendario (mes y semana)

Vista Mes tipo calendario (componente reutilizable MonthGrid) y vista Semana con
las citas de todos los medicos filtrados, coloreadas por medico; leyenda de
colores y vista elegida persistida.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 13:34:23 -05:00
parent 1e25022630
commit 91e15e8318
12 changed files with 106 additions and 28 deletions
+30
View File
@@ -0,0 +1,30 @@
// 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 -2
View File
@@ -21,7 +21,7 @@ export const messages = {
clinic_block: 'Bloqueo',
clinic_view_day: 'Día',
clinic_view_week: 'Semana',
clinic_view_list: 'Lista',
clinic_view_list: 'Lista', clinic_view_month: 'Mes', clinic_more: 'más',
clinic_doctors: 'Médicos',
clinic_doctor: 'Médico',
clinic_status: 'Estado',
@@ -713,7 +713,7 @@ export const messages = {
clinic_block: 'Block',
clinic_view_day: 'Day',
clinic_view_week: 'Week',
clinic_view_list: 'List',
clinic_view_list: 'List', clinic_view_month: 'Month', clinic_more: 'more',
clinic_doctors: 'Doctors',
clinic_doctor: 'Doctor',
clinic_status: 'Status',
@@ -15,6 +15,7 @@ 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 * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js';
import './clinic.css';
@@ -112,7 +113,7 @@ export default function AgendaMedicaPage() {
[types, setTypes] = useState([]),
[allTypes, setAllTypes] = useState([]);
const [tab, setTab] = useState('agenda'),
[view, setView] = useState('day'),
[view, setView] = useState(() => { const v = readStored('plc_clinic_view', 'day'); return ['day', 'week', 'month', 'list'].includes(v) ? v : 'day'; }),
[day, setDay] = useState(today);
const [doctorFilter, setDoctorFilter] = useState(() => {
const value = readStored('plc_clinic_doctors', []);
@@ -198,6 +199,11 @@ export default function AgendaMedicaPage() {
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'),
@@ -231,6 +237,11 @@ export default function AgendaMedicaPage() {
useEffect(() => {
void load();
}, [me?.user_id, period.from, period.to]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_view', JSON.stringify(view));
} catch {}
}, [view]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
@@ -377,7 +388,6 @@ export default function AgendaMedicaPage() {
}]
});
}
const weekDoctorId = singleDoctor;
function appointmentMenu(item) {
const actions = [{
label: t('common_close')
@@ -419,14 +429,24 @@ export default function AgendaMedicaPage() {
actions
});
}
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status) && (view !== 'week' || a.doctor_user_id === weekDoctorId));
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id) && (view !== 'week' || b.doctor_user_id === weekDoctorId));
const renderEvent = a => <button key={`a-${a.id}`} className="clinic-event appointment" style={{
borderLeftColor: a.type_color
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 visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status));
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" style={{
borderLeftColor: byDoctor ? doctorColor(a.doctor_user_id) : a.type_color
}} onClick={() => open('appointment', a)} onContextMenu={e => {
e.preventDefault();
appointmentMenu(a);
}} title={`${a.first_names} ${a.last_names}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</button>;
}} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${a.status}`)}`}>{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_${a.status}`)}`}</button>;
const renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={() => open('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_notes'), popup.item.notes]] : 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]] : [];
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
@@ -434,7 +454,7 @@ export default function AgendaMedicaPage() {
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
const hourRange = (() => {
let lo = 24, hi = 0;
for (const d of view === 'week' ? doctors.filter(x => x.id === weekDoctorId) : selectedDoctors) for (const seg of d.schedules || []) {
for (const d of selectedDoctors) for (const seg of d.schedules || []) {
lo = Math.min(lo, Number(String(seg.starts_time).slice(0, 2)));
hi = Math.max(hi, Math.ceil(Number(String(seg.ends_time).slice(0, 2)) + Number(String(seg.ends_time).slice(3, 5)) / 60));
}
@@ -455,7 +475,7 @@ 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="clinic-controls"><Button icon={ChevronLeft} onClick={() => setDay(move(day, view === 'week' ? -7 : -1))}>{t('calendar_previous')}</Button><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /><Button icon={ChevronRight} onClick={() => setDay(move(day, view === 'week' ? 7 : 1))}>{t('calendar_next')}</Button><ComboBox value={view} onChange={setView} options={['day', 'week', 'list'].map(x => ({
{tab === 'agenda' && <><div className="clinic-controls"><Button icon={ChevronLeft} onClick={() => setDay(step(-1))}>{t('calendar_previous')}</Button><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /><Button icon={ChevronRight} onClick={() => setDay(step(1))}>{t('calendar_next')}</Button><ComboBox value={view} onChange={setView} options={['day', 'week', 'month', 'list'].map(x => ({
value: x,
label: t(`clinic_view_${x}`)
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
@@ -465,6 +485,7 @@ export default function AgendaMedicaPage() {
value: x,
label: t(`clinic_status_${x}`)
}))} values={statusFilter} onChange={setStatusFilter} /></div>
{(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 => {
@@ -480,18 +501,23 @@ export default function AgendaMedicaPage() {
})}><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>{d} · {doctors.find(x => x.id === weekDoctorId)?.name}</h3>{hourRange.map(h => {
}, (_, 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),
doctor = doctors.find(x => x.id === weekDoctorId),
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
working = doctor?.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end);
const blocked = visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start);
// 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 && isReception && open('appointment', null, {
doctorId: weekDoctorId,
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
slot: start
})}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
})}</div>)}</div> : <div className="clinic-list">{[...visible.map(a => ({
})}><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 => { setDay(d); setView('day'); }} 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: <div key={`b-${b.id}-${b.occurrence_start}`} className="clinic-chip block" style={{ borderLeftColor: doctorColor(b.doctor_user_id) }} title={b.title}>{doctorShort(b.doctor_user_id)} · {b.title}</div> })),
...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: <div key={`a-${a.id}`} className="clinic-chip" style={{ borderLeftColor: doctorColor(a.doctor_user_id) }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names.slice(0, 1)}.</div> }))].sort((x, y) => x.start.localeCompare(y.start));
return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && <div className="text-muted" style={{ fontSize: '.66rem' }}>+{items.length - 4} {t('clinic_more')}</div>}</>;
}} /> : <div className="clinic-list">{[...visible.map(a => ({
start: a.starts_at,
node: renderEvent(a)
})), ...visibleBlocks.map(b => ({
+9 -1
View File
@@ -1,8 +1,16 @@
.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(165px,1fr));gap:6px;overflow-x:auto}.clinic-week-grid .clinic-doctor{min-width:165px}@media(max-width:900px){.clinic-week-grid{grid-template-columns:repeat(7,165px)}}
.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}