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:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
|
## [4.8.0] - 2026-10-28
|
||||||
|
### Added
|
||||||
|
- Agenda médica: vista **Mes** tipo calendario (hasta 4 citas/bloqueos por día, clic abre el día) y
|
||||||
|
leyenda de colores por médico. Componente reutilizable `MonthGrid`.
|
||||||
|
### Changed
|
||||||
|
- Agenda médica: la vista **Semana** muestra las citas de todos los médicos filtrados (antes uno solo),
|
||||||
|
coloreadas por médico y en formato compacto. La vista elegida se recuerda.
|
||||||
|
|
||||||
## [4.7.0] - 2026-10-28
|
## [4.7.0] - 2026-10-28
|
||||||
### Added
|
### Added
|
||||||
- Agenda médica para médicos y recepción: citas, pacientes, bloqueos recurrentes, horarios, tipos de cita, permisos por personal y disponibilidad.
|
- Agenda médica para médicos y recepción: citas, pacientes, bloqueos recurrentes, horarios, tipos de cita, permisos por personal y disponibilidad.
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ autorregistro/aprobacion/SMTP.
|
|||||||
|
|
||||||
## Agenda médica (v4.7.0, sin desplegar)
|
## Agenda médica (v4.7.0, sin desplegar)
|
||||||
|
|
||||||
|
- v4.8.0: vistas Mes (calendario) y Semana con todos los médicos filtrados, color por médico. Sin
|
||||||
|
migración. Revisado en dev con datos temporales (ya borrados), sin errores de consola.
|
||||||
|
|
||||||
- Módulo `clinic` con permisos por `clinic_staff` (`all` recepción, `own` médico), pacientes, horarios, tipos, citas y bloqueos recurrentes.
|
- Módulo `clinic` con permisos por `clinic_staff` (`all` recepción, `own` médico), pacientes, horarios, tipos, citas y bloqueos recurrentes.
|
||||||
- Migración `036_clinic.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica el actualizador).
|
- Migración `036_clinic.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica el actualizador).
|
||||||
- Probado en dev con usuarios/pacientes temporales (ya borrados), 29/29: configuración por admin que no es
|
- Probado en dev con usuarios/pacientes temporales (ya borrados), 29/29: configuración por admin que no es
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
|||||||
Los médicos conservan el resto de sus módulos y no se crea un rol del sistema.
|
Los médicos conservan el resto de sus módulos y no se crea un rol del sistema.
|
||||||
- Admin/superadmin con acceso a `clinic` y fila de staff configuran personal, horarios por tramos
|
- Admin/superadmin con acceso a `clinic` y fila de staff configuran personal, horarios por tramos
|
||||||
y tipos de cita desde la pestaña Configuración de agenda.
|
y tipos de cita desde la pestaña Configuración de agenda.
|
||||||
- Día muestra columnas por médico; Semana muestra un médico; Lista muestra citas y bloqueos.
|
- Vistas (v4.8.0, la elegida se recuerda): **Día** con una columna por médico; **Semana** como
|
||||||
|
calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes**
|
||||||
|
como calendario mensual (hasta 4 entradas por día + "N más"; clic en un día abre su vista Día);
|
||||||
|
**Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes.
|
||||||
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
|
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
|
||||||
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
|
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
|
||||||
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
|
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.16",
|
"dompurify": "^3.4.16",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
@@ -2748,7 +2748,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.7.0",
|
"version": "4.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export const messages = {
|
|||||||
clinic_block: 'Bloqueo',
|
clinic_block: 'Bloqueo',
|
||||||
clinic_view_day: 'Día',
|
clinic_view_day: 'Día',
|
||||||
clinic_view_week: 'Semana',
|
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_doctors: 'Médicos',
|
||||||
clinic_doctor: 'Médico',
|
clinic_doctor: 'Médico',
|
||||||
clinic_status: 'Estado',
|
clinic_status: 'Estado',
|
||||||
@@ -713,7 +713,7 @@ export const messages = {
|
|||||||
clinic_block: 'Block',
|
clinic_block: 'Block',
|
||||||
clinic_view_day: 'Day',
|
clinic_view_day: 'Day',
|
||||||
clinic_view_week: 'Week',
|
clinic_view_week: 'Week',
|
||||||
clinic_view_list: 'List',
|
clinic_view_list: 'List', clinic_view_month: 'Month', clinic_more: 'more',
|
||||||
clinic_doctors: 'Doctors',
|
clinic_doctors: 'Doctors',
|
||||||
clinic_doctor: 'Doctor',
|
clinic_doctor: 'Doctor',
|
||||||
clinic_status: 'Status',
|
clinic_status: 'Status',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { DetailPopup } from '../../components/DetailPopup.jsx';
|
|||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
import { DatePicker } from '../../components/DatePicker.jsx';
|
import { DatePicker } from '../../components/DatePicker.jsx';
|
||||||
|
import { MonthGrid, monthGridDays } from '../../components/MonthGrid.jsx';
|
||||||
import * as api from '../../services/clinic.js';
|
import * as api from '../../services/clinic.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
import './clinic.css';
|
import './clinic.css';
|
||||||
@@ -112,7 +113,7 @@ export default function AgendaMedicaPage() {
|
|||||||
[types, setTypes] = useState([]),
|
[types, setTypes] = useState([]),
|
||||||
[allTypes, setAllTypes] = useState([]);
|
[allTypes, setAllTypes] = useState([]);
|
||||||
const [tab, setTab] = useState('agenda'),
|
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);
|
[day, setDay] = useState(today);
|
||||||
const [doctorFilter, setDoctorFilter] = useState(() => {
|
const [doctorFilter, setDoctorFilter] = useState(() => {
|
||||||
const value = readStored('plc_clinic_doctors', []);
|
const value = readStored('plc_clinic_doctors', []);
|
||||||
@@ -198,6 +199,11 @@ export default function AgendaMedicaPage() {
|
|||||||
from = move(day, -offset);
|
from = move(day, -offset);
|
||||||
to = move(from, 7);
|
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);
|
if (view === 'list') to = move(day, 30);
|
||||||
return {
|
return {
|
||||||
from: at(from, '00:00'),
|
from: at(from, '00:00'),
|
||||||
@@ -231,6 +237,11 @@ export default function AgendaMedicaPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [me?.user_id, period.from, period.to]);
|
}, [me?.user_id, period.from, period.to]);
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('plc_clinic_view', JSON.stringify(view));
|
||||||
|
} catch {}
|
||||||
|
}, [view]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
|
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
|
||||||
@@ -377,7 +388,6 @@ export default function AgendaMedicaPage() {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const weekDoctorId = singleDoctor;
|
|
||||||
function appointmentMenu(item) {
|
function appointmentMenu(item) {
|
||||||
const actions = [{
|
const actions = [{
|
||||||
label: t('common_close')
|
label: t('common_close')
|
||||||
@@ -419,14 +429,24 @@ export default function AgendaMedicaPage() {
|
|||||||
actions
|
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 doctorColor = id => doctors.find(d => d.id === id)?.color || 'rgb(var(--accent))';
|
||||||
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id) && (view !== 'week' || b.doctor_user_id === weekDoctorId));
|
const doctorShort = id => (doctors.find(d => d.id === id)?.name || '').split(' ').slice(0, 2).join(' ');
|
||||||
const renderEvent = a => <button key={`a-${a.id}`} className="clinic-event appointment" style={{
|
const weekLabels = [t('calendar_mon'), t('calendar_tue'), t('calendar_wed'), t('calendar_thu'), t('calendar_fri'), t('calendar_sat'), t('calendar_sun')];
|
||||||
borderLeftColor: a.type_color
|
// 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 => {
|
}} onClick={() => open('appointment', a)} onContextMenu={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
appointmentMenu(a);
|
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 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]] : [];
|
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>;
|
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.
|
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
|
||||||
const hourRange = (() => {
|
const hourRange = (() => {
|
||||||
let lo = 24, hi = 0;
|
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)));
|
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));
|
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;
|
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null;
|
||||||
void guardedClose(current, () => setTab(key));
|
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,
|
value: x,
|
||||||
label: t(`clinic_view_${x}`)
|
label: t(`clinic_view_${x}`)
|
||||||
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
|
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
|
||||||
@@ -465,6 +485,7 @@ export default function AgendaMedicaPage() {
|
|||||||
value: x,
|
value: x,
|
||||||
label: t(`clinic_status_${x}`)
|
label: t(`clinic_status_${x}`)
|
||||||
}))} values={statusFilter} onChange={setStatusFilter} /></div>
|
}))} 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={{
|
{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))'}`
|
borderTop: `3px solid ${d.color || 'rgb(var(--accent))'}`
|
||||||
}}>{d.name}</h3>{hourRange.map(h => {
|
}}>{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>;
|
})}><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({
|
})}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
|
||||||
length: 7
|
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`),
|
const start = at(d, `${String(h).padStart(2, '0')}:00`),
|
||||||
end = plus(start, 60),
|
end = plus(start, 60),
|
||||||
doctor = doctors.find(x => x.id === weekDoctorId),
|
|
||||||
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
|
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);
|
// Con varios medicos: la hora es "de atencion" si alguno atiende; el sombreado de bloqueo
|
||||||
const blocked = visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start);
|
// 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, {
|
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
|
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>;
|
})}><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> : <div className="clinic-list">{[...visible.map(a => ({
|
})}</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,
|
start: a.starts_at,
|
||||||
node: renderEvent(a)
|
node: renderEvent(a)
|
||||||
})), ...visibleBlocks.map(b => ({
|
})), ...visibleBlocks.map(b => ({
|
||||||
|
|||||||
@@ -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-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 .datepicker-trigger{flex:0 0 190px;width:190px}
|
||||||
.clinic-controls .combobox{flex:0 0 auto;min-width:150px}
|
.clinic-controls .combobox{flex:0 0 auto;min-width:150px}
|
||||||
/* Hora ocupada por un bloqueo (quirofano, vacaciones...): no se puede agendar. */
|
/* 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}
|
.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}
|
||||||
|
|||||||
Reference in New Issue
Block a user