import { useEffect, useMemo, useState } from 'react';
import { Stethoscope, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
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';
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`);
return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
};
const initialAppointment = (day, doctorId, slot) => ({
doctorId: doctorId || '',
patientId: '',
typeId: '',
startsAt: slot || at(day),
endsAt: plus(slot || at(day), 30),
channel: 'phone',
notes: ''
});
const initialPatient = () => ({
firstNames: '',
lastNames: '',
identification: '',
phone: '',
email: '',
birthDate: '',
notes: ''
});
const initialBlock = (day, doctorId) => ({
doctorId: doctorId || '',
kind: 'surgery',
title: '',
startsAt: at(day),
endsAt: plus(at(day), 60),
recurrence: null
});
const formAppointment = a => ({
doctorId: a.doctor_user_id,
patientId: a.patient_id,
typeId: a.type_id,
startsAt: a.starts_at,
endsAt: a.ends_at,
channel: a.channel,
notes: a.notes || '',
editVersion: a.edit_version
});
const formPatient = p => ({
firstNames: p.first_names,
lastNames: p.last_names,
identification: p.identification || '',
phone: p.phone,
email: p.email || '',
birthDate: p.birth_date || '',
notes: p.notes || '',
editVersion: p.edit_version
});
const formBlock = b => ({
doctorId: b.doctor_user_id,
kind: b.kind,
title: b.title,
startsAt: b.starts_at,
endsAt: b.ends_at,
recurrence: b.recurrence_json ? JSON.parse(b.recurrence_json) : null,
editVersion: b.edit_version
});
const readStored = (key, fallback) => {
try {
return JSON.parse(localStorage.getItem(key)) ?? fallback;
} catch {
return fallback;
}
};
export default function AgendaMedicaPage() {
const {
t,
lang,
openDialog,
pushToast
} = useUi();
const {
user
} = useAuth();
const guardedClose = useGuardedClose();
const [me, setMe] = useState(null),
[error, setError] = useState(''),
[doctors, setDoctors] = useState([]),
[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);
const [doctorFilter, setDoctorFilter] = useState(() => {
const value = readStored('plc_clinic_doctors', []);
return Array.isArray(value) ? value : [];
});
const [statusFilter, setStatusFilter] = useState(() => {
const value = readStored('plc_clinic_status', ['scheduled', 'confirmed', 'attended', 'no_show']);
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
});
const [appointments, setAppointments] = useState([]),
[blocks, setBlocks] = useState([]),
[patients, setPatients] = useState([]),
[search, setSearch] = useState(''),
[page, setPage] = useState(1),
[patientTotal, setPatientTotal] = useState(0);
const [popup, setPopup] = useState(null),
[editing, setEditing] = useState(false),
[conflict, setConflict] = useState(''),
[affected, setAffected] = useState([]),
[slots, setSlots] = useState([]),
[cancelReason, setCancelReason] = useState(''),
[cancelMode, setCancelMode] = useState(false),
[patientHistory, setPatientHistory] = useState([]),
[quickPatientId, setQuickPatientId] = useState(null),
[reprogram, setReprogram] = useState([]);
const [allUsers, setAllUsers] = useState([]),
[staffRows, setStaffRows] = useState([]),
[staffUser, setStaffUser] = useState(''),
[staffInitial, setStaffInitial] = useState({
scope: 'own',
isDoctor: true,
specialty: '',
color: '#0078d4',
isActive: true
});
const [typeInitial, setTypeInitial] = useState({
name: '',
durationMinutes: 30,
color: '#0078d4',
isActive: true
});
const [scheduleDoctor, setScheduleDoctor] = useState(''),
[scheduleInitial, setScheduleInitial] = useState({
rows: []
});
const staffDraft = useDraftForm({
formId: 'clinic-staff',
entityId: staffUser || null,
initial: staffInitial,
label: t('clinic_staff'),
enabled: tab === 'config'
});
const typeDraft = useDraftForm({
formId: 'clinic-type',
entityId: typeInitial.id || null,
initial: typeInitial,
label: t('clinic_types'),
enabled: tab === 'config'
});
const scheduleDraft = useDraftForm({
formId: 'clinic-schedule',
entityId: scheduleDoctor || null,
initial: scheduleInitial,
label: t('clinic_schedules'),
enabled: tab === 'config'
});
const staffForm = staffDraft.values,
typeForm = typeDraft.values,
scheduleRows = scheduleDraft.values.rows || [];
const setStaffForm = staffDraft.setValues,
setTypeForm = typeDraft.setValues;
const setScheduleRows = rows => scheduleDraft.setField('rows', rows);
const isReception = me?.scope === 'all',
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]);
async function load() {
if (!me) return;
try {
const [a, b] = await Promise.all([api.clinicAppointments(period.from, period.to), api.clinicBlocks(period.from, period.to)]);
setAppointments(a);
setBlocks(b);
setError('');
} catch (e) {
setError(e.message);
}
}
useEffect(() => {
if (!user?.modules?.includes('clinic')) return;
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
api.clinicMe().then(async staff => {
setMe(staff);
const data = await api.clinicDoctors();
setDoctors(data.doctors);
setTypes(data.types);
}).catch(e => {
if (isAdmin) setTab('config');
else setError(e.message);
});
}, [user?.id]);
useEffect(() => {
void load();
}, [me?.user_id, period.from, period.to]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_view_v2', JSON.stringify(view));
} catch {}
}, [view]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
} catch {}
}, [doctorFilter]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter));
} catch {}
}, [statusFilter]);
useEffect(() => {
if (tab === 'patients' && isReception) api.clinicPatients(search, page).then(x => {
setPatients(x.items);
setPatientTotal(x.total);
}).catch(e => setError(e.message));
}, [tab, search, page, isReception]);
useEffect(() => {
if (tab === 'config' && isAdmin) Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]).then(([rows, users, kindRows]) => {
setStaffRows(rows);
setAllUsers(users);
setAllTypes(kindRows);
}).catch(e => setError(e.message));
}, [tab, isAdmin]);
const formId = popup?.kind === 'appointment' ? 'clinic-appointment' : popup?.kind === 'patient' ? 'clinic-patient' : 'clinic-block';
const initial = popup?.kind === 'appointment' ? popup.item ? formAppointment(popup.item) : initialAppointment(day, popup.doctorId, popup.slot) : popup?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient() : popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
const draft = useDraftForm({
formId,
entityId: popup?.item?.id,
initial,
label: t(`clinic_${popup?.kind || 'appointment'}`),
enabled: !!popup && editing,
onDiscard: () => setEditing(false)
});
const v = draft.values;
useEffect(() => {
if (quickPatientId && popup?.kind === 'appointment' && editing) {
draft.setField('patientId', quickPatientId);
setQuickPatientId(null);
}
}, [quickPatientId, popup?.kind, editing]);
useEffect(() => {
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return;
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([]));
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
function open(kind, item = null, extra = {}) {
setPopup({
kind,
item,
...extra
});
setEditing(!item);
setConflict('');
setAffected([]);
setCancelMode(false);
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {});
}
function close() {
void guardedClose(editing ? draft : null, () => {
setPopup(null);
setEditing(false);
setConflict('');
});
}
async function save(confirmAffected = false) {
if (popup.item && !draft.isDirty) return;
try {
let result;
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
if (popup.kind === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
if (popup.kind === 'block') result = await api.clinicSaveBlock({
...v,
confirmAffected
}, popup.item?.id);
if (result?.requiresConfirmation) {
setAffected(result.affected);
return;
}
draft.clear();
if (popup.returnTo) {
setQuickPatientId(result.id);
setPopup(popup.returnTo);
setEditing(true);
} else {
setPopup(null);
setEditing(false);
}
setConflict('');
setAffected([]);
if (result?.affected?.length) setReprogram(result.affected);
await load();
if (tab === 'patients') {
const list = await api.clinicPatients(search, page);
setPatients(list.items);
}
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setConflict(e.message);
}
}
async function actionStatus(status) {
try {
await api.clinicAppointmentStatus(popup.item.id, {
status,
editVersion: popup.item.edit_version
});
setPopup(null);
await load();
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setConflict(e.message);
}
}
function cancelAppointment() {
if (!cancelReason.trim()) return;
openDialog({
tone: 'warning',
title: t('clinic_cancel'),
message: t('clinic_cancel_confirm'),
actions: [{
label: t('common_cancel')
}, {
label: t('clinic_cancel'),
variant: 'danger',
onClick: async () => {
try {
await api.clinicCancelAppointment(popup.item.id, {
reason: cancelReason,
editVersion: popup.item.edit_version
});
setPopup(null);
setCancelMode(false);
await load();
} catch (e) {
setConflict(e.message);
}
}
}]
});
}
function appointmentMenu(item) {
const actions = [{
label: t('common_close')
}];
if (isReception) {
actions.push({
label: t('clinic_confirm'),
onClick: () => api.clinicAppointmentStatus(item.id, {
status: 'confirmed',
editVersion: item.edit_version
}).then(load).catch(e => setError(e.message))
});
actions.push({
label: t('clinic_reprogram'),
onClick: () => {
open('appointment', item);
setEditing(true);
}
});
actions.push({
label: t('clinic_cancel'),
variant: 'danger',
onClick: () => {
open('appointment', item);
setCancelMode(true);
}
});
}
for (const status of ['attended', 'no_show']) actions.push({
label: t(`clinic_status_${status}`),
onClick: () => api.clinicAppointmentStatus(item.id, {
status,
editVersion: item.edit_version
}).then(load).catch(e => setError(e.message))
});
openDialog({
title: `${item.first_names} ${item.last_names}`,
message: item.starts_at,
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 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) => ;
const renderBlock = b => ;
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
{label}: {String(val)}
)}{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}
)}>}{popup.kind === 'block' && <>