Merge pull request 'feature/add-appointments-endpoint' (#54) from feature/add-appointments-endpoint into develop

Reviewed-on: #54
This commit is contained in:
M-Gabrielly 2025-10-21 03:17:50 +00:00
commit bb6dbe4841
3 changed files with 486 additions and 341 deletions

View File

@ -78,6 +78,8 @@ export default function ConsultasPage() {
const [appointments, setAppointments] = useState<any[]>([]);
const [originalAppointments, setOriginalAppointments] = useState<any[]>([]);
const [searchValue, setSearchValue] = useState<string>('');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const [filterDate, setFilterDate] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [showForm, setShowForm] = useState(false);
const [editingAppointment, setEditingAppointment] = useState<any | null>(null);
@ -101,6 +103,8 @@ export default function ConsultasPage() {
id: appointment.id,
patientName: appointment.patient,
patientId: appointment.patient_id || appointment.patientId || null,
// include doctor id so the form can run availability/exception checks when editing
doctorId: appointment.doctor_id || appointment.doctorId || null,
professionalName: appointment.professional || "",
appointmentDate: appointmentDateStr,
startTime,
@ -208,6 +212,8 @@ export default function ConsultasPage() {
id: updated.id,
patient: formData.patientName || existing.patient || '',
patient_id: existing.patient_id ?? null,
// preserve doctor id so future edits retain the selected professional
doctor_id: existing.doctor_id ?? (formData.doctorId || (formData as any).doctor_id) ?? null,
// preserve server-side fields so future edits read them
scheduled_at: updated.scheduled_at ?? scheduled_at,
duration_minutes: updated.duration_minutes ?? duration_minutes,
@ -280,6 +286,8 @@ export default function ConsultasPage() {
id: a.id,
patient,
patient_id: a.patient_id,
// preserve the doctor's id so later edit flows can access it
doctor_id: a.doctor_id ?? null,
// keep some server-side fields so edit can access them later
scheduled_at: a.scheduled_at ?? a.time ?? a.created_at ?? null,
duration_minutes: a.duration_minutes ?? a.duration ?? null,
@ -323,15 +331,14 @@ export default function ConsultasPage() {
// Search box: allow fetching a single appointment by ID when pressing Enter
// Perform a local-only search against the already-loaded appointments.
// This intentionally does not call the server — it filters the cached list.
const performSearch = (val: string) => {
const trimmed = String(val || '').trim();
if (!trimmed) {
setAppointments(originalAppointments || []);
return;
}
const applyFilters = (val?: string) => {
const trimmed = String((val ?? searchValue) || '').trim();
let list = (originalAppointments || []).slice();
// search
if (trimmed) {
const q = trimmed.toLowerCase();
const localMatches = (originalAppointments || []).filter((a) => {
list = list.filter((a) => {
const patient = String(a.patient || '').toLowerCase();
const professional = String(a.professional || '').toLowerCase();
const pid = String(a.patient_id || '').toLowerCase();
@ -343,10 +350,30 @@ export default function ConsultasPage() {
aid === q
);
});
}
setAppointments(localMatches as any[]);
// status filter
if (selectedStatus && selectedStatus !== 'all') {
list = list.filter((a) => String(a.status || '').toLowerCase() === String(selectedStatus).toLowerCase());
}
// date filter (YYYY-MM-DD)
if (filterDate) {
list = list.filter((a) => {
try {
const sched = a.scheduled_at || a.time || a.created_at || null;
if (!sched) return false;
const iso = new Date(sched).toISOString().split('T')[0];
return iso === filterDate;
} catch (e) { return false; }
});
}
setAppointments(list as any[]);
};
const performSearch = (val: string) => { applyFilters(val); };
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
@ -379,6 +406,10 @@ export default function ConsultasPage() {
return () => clearTimeout(t);
}, [searchValue, originalAppointments]);
useEffect(() => {
applyFilters();
}, [selectedStatus, filterDate, originalAppointments]);
// Keep localForm synchronized with editingAppointment
useEffect(() => {
if (showForm && editingAppointment) {
@ -404,7 +435,7 @@ export default function ConsultasPage() {
</Button>
<h1 className="text-lg font-semibold md:text-2xl">Editar Consulta</h1>
</div>
<CalendarRegistrationForm formData={localForm} onFormChange={onFormChange} />
<CalendarRegistrationForm formData={localForm} onFormChange={onFormChange} createMode={true} />
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}>
Cancelar
@ -451,18 +482,19 @@ export default function ConsultasPage() {
/>
</div>
</div>
<Select>
<Select onValueChange={(v) => { setSelectedStatus(String(v)); }}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filtrar por status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="confirmed">Confirmada</SelectItem>
<SelectItem value="pending">Pendente</SelectItem>
{/* backend uses 'requested' for pending requests, map UI label to that value */}
<SelectItem value="requested">Pendente</SelectItem>
<SelectItem value="cancelled">Cancelada</SelectItem>
</SelectContent>
</Select>
<Input type="date" className="w-[180px]" />
<Input type="date" className="w-[180px]" value={filterDate} onChange={(e) => setFilterDate(e.target.value)} />
</div>
</CardHeader>
<CardContent>

View File

@ -849,7 +849,23 @@ const ProfissionalPage = () => {
try {
if (isMaybeId(term)) {
try {
const r = await buscarRelatorioPorId(term);
let r: any = null;
// Try direct API lookup first
try {
r = await buscarRelatorioPorId(term);
} catch (e) {
console.warn('[SearchBox] buscarRelatorioPorId failed, will try loadReportById fallback', e);
}
// Fallback: use hook loader if direct API didn't return
if (!r) {
try {
r = await loadReportById(term);
} catch (e) {
console.warn('[SearchBox] loadReportById fallback failed', e);
}
}
if (r) {
// If token exists, attempt batch enrichment like useReports
const enriched: any = { ...r };
@ -935,8 +951,14 @@ const ProfissionalPage = () => {
const handleClear = async () => {
setSearchTerm('');
await loadReports();
try {
// Reuse the same logic as initial load so Clear restores the doctor's assigned laudos
await loadAssignedLaudos();
} catch (err) {
console.warn('[SearchBox] erro ao restaurar laudos do médico ao limpar busca:', err);
// Safe fallback to whatever reports are available
setLaudos(reports || []);
}
};
return (
@ -965,31 +987,26 @@ const ProfissionalPage = () => {
);
}
// carregar laudos ao montar - somente dos pacientes atribuídos ao médico logado
useEffect(() => {
let mounted = true;
(async () => {
// helper to load laudos for the patients assigned to the logged-in user
const loadAssignedLaudos = async () => {
try {
// obter assignments para o usuário logado
const assignments = await import('@/lib/assignment').then(m => m.listAssignmentsForUser(user?.id || ''));
const patientIds = Array.isArray(assignments) ? assignments.map(a => String(a.patient_id)).filter(Boolean) : [];
if (patientIds.length === 0) {
if (mounted) setLaudos([]);
setLaudos([]);
return;
}
// Tentar carregar todos os relatórios em uma única chamada usando in.(...)
try {
const reportsMod = await import('@/lib/reports');
if (typeof reportsMod.listarRelatoriosPorPacientes === 'function') {
const batch = await reportsMod.listarRelatoriosPorPacientes(patientIds);
// Filtrar apenas relatórios criados/solicitados por este usuário (evita mostrar laudos de outros médicos)
const mineOnly = (batch || []).filter((r: any) => {
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
return user?.id && requester && requester === user.id;
});
// Enrich reports with paciente objects so UI shows name/cpf immediately
const enriched = await (async (reportsArr: any[]) => {
if (!reportsArr || !reportsArr.length) return reportsArr;
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
@ -997,23 +1014,22 @@ const ProfissionalPage = () => {
try {
const patients = await buscarPacientesPorIds(pids);
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
return reportsArr.map(r => {
return reportsArr.map((r: any) => {
const pid = String(getReportPatientId(r));
return { ...r, paciente: r.paciente ?? map.get(pid) ?? r.paciente };
return { ...r, paciente: r.paciente ?? map.get(pid) ?? r.paciente } as any;
});
} catch (e) {
return reportsArr;
}
})(mineOnly);
if (mounted) setLaudos(enriched || []);
setLaudos(enriched || []);
return;
} else {
// fallback: 请求 por paciente individual
const allReports: any[] = [];
for (const pid of patientIds) {
try {
const rels = await import('@/lib/reports').then(m => m.listarRelatoriosPorPaciente(pid));
if (Array.isArray(rels) && rels.length) {
// filtrar por autor (requested_by / created_by / executante)
const mine = rels.filter((r: any) => {
const requester = ((r.requested_by ?? r.created_by ?? r.executante ?? r.requestedBy ?? r.createdBy) || '').toString();
return user?.id && requester && requester === user.id;
@ -1024,7 +1040,7 @@ const ProfissionalPage = () => {
console.warn('[LaudoManager] falha ao carregar relatórios para paciente', pid, err);
}
}
// enrich fallback results too
const enrichedAll = await (async (reportsArr: any[]) => {
if (!reportsArr || !reportsArr.length) return reportsArr;
const pids = reportsArr.map(r => String(getReportPatientId(r))).filter(Boolean);
@ -1032,12 +1048,13 @@ const ProfissionalPage = () => {
try {
const patients = await buscarPacientesPorIds(pids);
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
return reportsArr.map(r => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente }));
return reportsArr.map((r: any) => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente } as any));
} catch (e) {
return reportsArr;
}
})(allReports);
if (mounted) setLaudos(enrichedAll);
setLaudos(enrichedAll);
return;
}
} catch (err) {
console.warn('[LaudoManager] erro ao carregar relatórios em batch, tentando por paciente individual', err);
@ -1063,17 +1080,26 @@ const ProfissionalPage = () => {
try {
const patients = await buscarPacientesPorIds(pids);
const map = new Map((patients || []).map((p: any) => [String(p.id), p]));
return reportsArr.map(r => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente }));
return reportsArr.map((r: any) => ({ ...r, paciente: r.paciente ?? map.get(String(getReportPatientId(r))) ?? r.paciente } as any));
} catch (e) {
return reportsArr;
}
})(allReports);
if (mounted) setLaudos(enrichedAll);
setLaudos(enrichedAll);
return;
}
} catch (e) {
console.warn('[LaudoManager] erro ao carregar laudos para pacientes atribuídos:', e);
if (mounted) setLaudos(reports || []);
setLaudos(reports || []);
}
};
// carregar laudos ao montar - somente dos pacientes atribuídos ao médico logado
useEffect(() => {
let mounted = true;
(async () => {
// call the helper and bail if the component unmounted during async work
await loadAssignedLaudos();
})();
return () => { mounted = false; };
}, [user?.id]);

View File

@ -86,6 +86,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const [loadingAssignedDoctors, setLoadingAssignedDoctors] = useState(false);
const [loadingPatientsForDoctor, setLoadingPatientsForDoctor] = useState(false);
const [availableSlots, setAvailableSlots] = useState<Array<{ datetime: string; available: boolean; slot_minutes?: number }>>([]);
const [availabilityWindows, setAvailabilityWindows] = useState<Array<{ winStart: Date; winEnd: Date; slotMinutes?: number }>>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false);
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
@ -275,18 +276,27 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
}, [(formData as any).doctorId, (formData as any).doctor_id]);
// When doctor or date changes, fetch available slots
// Keep a mounted ref to avoid setting state after unmount when reused
const mountedRef = useRef(true);
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
const date = (formData as any).appointmentDate || null;
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
// Extract the availability + exceptions logic into a reusable function so we
// can call it both when dependencies change and once at mount-time when the
// form already contains doctor/date (covering the edit flow).
const fetchExceptionsAndSlots = async (docIdArg?: string | null, dateArg?: string | null) => {
const docId = docIdArg ?? ((formData as any).doctorId || (formData as any).doctor_id || null);
const date = dateArg ?? ((formData as any).appointmentDate || null);
if (!docId || !date) {
setAvailableSlots([]);
if (mountedRef.current) setAvailableSlots([]);
return;
}
let mounted = true;
setLoadingSlots(true);
(async () => {
if (mountedRef.current) setLoadingSlots(true);
try {
// Check for blocking exceptions on this exact date before querying availability.
// Check for blocking exceptions first
try {
const exceptions = await listarExcecoes({ doctorId: String(docId), date: String(date) }).catch(() => []);
if (exceptions && exceptions.length) {
@ -295,63 +305,54 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const reason = blocking.reason ? ` Motivo: ${blocking.reason}` : '';
const msg = `Não é possível agendar nesta data.${reason}`;
try {
// open modal dialog with message
setExceptionDialogMessage(msg);
setExceptionDialogOpen(true);
} catch (e) {
try { toast({ title: 'Data indisponível', description: msg }); } catch (ee) {}
}
if (!mounted) return;
if (mountedRef.current) {
setAvailableSlots([]);
setLoadingSlots(false);
}
return;
}
}
} catch (exCheckErr) {
// If the exceptions check fails for network reasons, proceed to availability fetch
console.warn('[CalendarRegistrationForm] listarExcecoes falhou, continuando para getAvailableSlots', exCheckErr);
}
console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
console.debug('[CalendarRegistrationForm] doctorOptions count', (doctorOptions || []).length, 'selectedDoctorId', docId, 'doctorOptions sample', (doctorOptions || []).slice(0,3));
// Build start/end as local day bounds from YYYY-MM-DD to avoid
// timezone/parsing issues (sending incorrect UTC offsets that shift
// the requested day to the previous/next calendar day).
// Expect `date` to be in format 'YYYY-MM-DD'. Parse explicitly.
// Build local start/end for the day
let start: Date;
let end: Date;
try {
const parts = String(date).split('-').map((p) => Number(p));
if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
const [y, m, d] = parts;
// new Date(y, m-1, d, hh, mm, ss, ms) constructs a date in the
// local timezone at the requested hour. toISOString() will then
// represent that local instant in UTC which is what the server
// expects for availability checks across timezones.
start = new Date(y, m - 1, d, 0, 0, 0, 0);
end = new Date(y, m - 1, d, 23, 59, 59, 999);
} else {
// fallback to previous logic if parsing fails
start = new Date(date);
start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
} catch (err) {
// fallback safe behavior
start = new Date(date);
start.setHours(0,0,0,0);
end = new Date(date);
end.setHours(23,59,59,999);
}
const av = await getAvailableSlots({ doctor_id: String(docId), start_date: start.toISOString(), end_date: end.toISOString(), appointment_type: formData.appointmentType || 'presencial' });
if (!mounted) return;
if (!mountedRef.current) return;
console.debug('[CalendarRegistrationForm] getAvailableSlots - response slots count', (av && av.slots && av.slots.length) || 0, av);
// Try to restrict the returned slots to the doctor's public availability windows
try {
const disponibilidades = await listarDisponibilidades({ doctorId: String(docId) }).catch(() => []);
const weekdayNumber = start.getDay(); // 0 (Sun) .. 6 (Sat)
// map weekday number to possible representations (numeric, en, pt, abbrev)
const weekdayNumber = start.getDay();
const weekdayNames: Record<number, string[]> = {
0: ['0', 'sun', 'sunday', 'domingo'],
1: ['1', 'mon', 'monday', 'segunda', 'segunda-feira'],
@ -363,25 +364,19 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
};
const allowed = (weekdayNames[weekdayNumber] || []).map((s) => String(s).toLowerCase());
// Filter disponibilidades to those matching the weekday (try multiple fields)
const matched = (disponibilidades || []).filter((d: any) => {
try {
const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase();
if (!raw) return false;
// direct numeric or name match
if (allowed.includes(raw)) return true;
// sometimes API returns numbers as integers
if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true;
if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true;
return false;
} catch (e) { return false; }
});
console.debug('[CalendarRegistrationForm] disponibilidades fetched', disponibilidades, 'matched for weekday', weekdayNumber, matched);
if (matched && matched.length) {
// Build windows from matched disponibilidades and filter av.slots
const windows = matched.map((d: any) => {
// d.start_time may be '09:00:00' or '09:00'
const parseTime = (t?: string) => {
if (!t) return { hh: 0, mm: 0, ss: 0 };
const parts = String(t).split(':').map((p) => Number(p));
@ -391,29 +386,27 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const e2 = parseTime(d.end_time);
const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0);
const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999);
const slotMinutes = Number(d.slot_minutes || d.slot_minutes_minutes || null) || null;
const slotMinutes = (() => { const n = Number(d.slot_minutes ?? d.slot_minutes_minutes ?? NaN); return Number.isFinite(n) ? n : undefined; })();
return { winStart, winEnd, slotMinutes };
});
// If any disponibilidade declares slot_minutes, prefill duration_minutes on the form
try {
// persist windows so UI can apply duration-fit filtering
if (mountedRef.current) setAvailabilityWindows(windows);
const candidate = windows.find((w: any) => w.slotMinutes && Number.isFinite(Number(w.slotMinutes)));
if (candidate) {
const durationVal = Number(candidate.slotMinutes);
// Only set if different to avoid unnecessary updates
if ((formData as any).duration_minutes !== durationVal) {
onFormChange({ ...formData, duration_minutes: durationVal });
}
try { setLockedDurationFromSlot(true); } catch (e) {}
} else {
// no slot_minutes declared -> ensure unlocked
try { setLockedDurationFromSlot(false); } catch (e) {}
}
} catch (e) {
console.debug('[CalendarRegistrationForm] erro ao definir duração automática', e);
}
// Keep backend slots that fall inside windows
const existingInWindow = (av.slots || []).filter((s: any) => {
try {
const sd = new Date(s.datetime);
@ -428,7 +421,6 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
} catch (e) { return false; }
});
// Determine global step (minutes) from returned slots, fallback to 30
let stepMinutes = 30;
try {
const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
@ -437,24 +429,16 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const d = Math.round((times[i] - times[i - 1]) / 60000);
if (d > 0) diffs.push(d);
}
if (diffs.length) {
stepMinutes = Math.min(...diffs);
}
} catch (e) {
// keep fallback
}
if (diffs.length) stepMinutes = Math.min(...diffs);
} catch (e) {}
// Generate missing slots per window respecting slot_minutes (if present).
const generatedSet = new Set<string>();
windows.forEach((w: any) => {
try {
const perWindowStep = Number(w.slotMinutes) || stepMinutes;
const startMs = w.winStart.getTime();
const endMs = w.winEnd.getTime();
// compute last allowed slot start so that start + perWindowStep <= winEnd
const lastStartMs = endMs - perWindowStep * 60000;
// backend slots inside this window (ms)
const backendSlotsInWindow = (av.slots || []).filter((s: any) => {
try {
const sd = new Date(s.datetime);
@ -466,14 +450,12 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
}).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b);
if (!backendSlotsInWindow.length) {
// generate full window from winStart to lastStartMs
let cursorMs = startMs;
while (cursorMs <= lastStartMs) {
generatedSet.add(new Date(cursorMs).toISOString());
cursorMs += perWindowStep * 60000;
}
} else {
// generate after last backend slot up to lastStartMs
const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1];
let cursorMs = lastBackendMs + perWindowStep * 60000;
while (cursorMs <= lastStartMs) {
@ -481,14 +463,10 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
cursorMs += perWindowStep * 60000;
}
}
} catch (e) {
// skip malformed window
}
} catch (e) {}
});
// Merge existingInWindow (prefer backend objects) with generated ones
const mergedMap = new Map<string, { datetime: string; available: boolean; slot_minutes?: number }>();
// helper to find window slotMinutes for a given ISO datetime
const findWindowSlotMinutes = (isoDt: string) => {
try {
const sd = new Date(isoDt);
@ -516,28 +494,66 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
});
const merged = Array.from(mergedMap.values()).sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
console.debug('[CalendarRegistrationForm] slots after merge/generated count', merged.length, 'stepMinutes', stepMinutes);
setAvailableSlots(merged || []);
if (mountedRef.current) setAvailableSlots(merged || []);
} else {
// No disponibilidade entries for this weekday -> use av.slots as-is
if (mountedRef.current) {
setAvailabilityWindows([]);
setAvailableSlots(av.slots || []);
}
}
} catch (e) {
console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e);
setAvailableSlots(av.slots || []);
if (mountedRef.current) setAvailableSlots(av.slots || []);
}
} catch (e) {
console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e);
if (!mounted) return;
setAvailableSlots([]);
if (mountedRef.current) setAvailableSlots([]);
} finally {
if (!mounted) return;
setLoadingSlots(false);
if (mountedRef.current) setLoadingSlots(false);
}
})();
return () => { mounted = false; };
};
// Call when doctor/date/appointmentType change
useEffect(() => {
fetchExceptionsAndSlots();
// note: we intentionally keep the same dependency list to preserve existing behaviour
}, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate, (formData as any).appointmentType]);
// Also attempt a mount-time call to cover the case where the form is mounted
// with doctor/date already present (edit flow). This ensures parity with
// the create flow which triggers the requests during user interaction.
useEffect(() => {
const docId = (formData as any).doctorId || (formData as any).doctor_id || null;
const date = (formData as any).appointmentDate || null;
console.debug('[CalendarRegistrationForm] mount-time check formData doctor/date', { doctorId: docId, doctor_id: (formData as any).doctor_id, appointmentDate: date, sampleFormData: formData });
const profName = (formData as any).professionalName || (formData as any).professional || '';
// If we don't have an id but have a professional name, try to find the id from loaded options
if (!docId && profName && doctorOptions && doctorOptions.length) {
const found = doctorOptions.find((d: any) => {
const name = (d.full_name || d.name || '').toLowerCase();
return name && profName.toLowerCase() && (name === profName.toLowerCase() || name.includes(profName.toLowerCase()));
});
if (found) {
// set doctorId on form so the normal effect will run
try { onFormChange({ ...formData, doctorId: String(found.id) }); } catch (e) {}
// Also proactively fetch availability using the discovered id
if (date) {
Promise.resolve().then(() => { if (mountedRef.current) fetchExceptionsAndSlots(String(found.id), date); });
} else {
Promise.resolve().then(() => { if (mountedRef.current) fetchExceptionsAndSlots(String(found.id)); });
}
}
}
if ((docId || ((doctorOptions || []).find((d: any) => (d.full_name || d.name || '').toLowerCase().includes(((formData as any).professionalName || '').toLowerCase())))) && date) {
// schedule microtask so mount effects ordering won't conflict with parent
Promise.resolve().then(() => {
if (mountedRef.current) fetchExceptionsAndSlots();
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = event.target;
@ -618,6 +634,55 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
else effectiveDoctorOptions = doctorOptions || [];
}
// derive displayed slots by filtering availableSlots to those that can fit the
// desired duration within any availability window. If no windows are present,
// fall back to availableSlots as-is.
const displayedSlots = (() => {
try {
const duration = Number((formData as any).duration_minutes) || 0;
if (!availabilityWindows || !availabilityWindows.length) return availableSlots || [];
// For each slot, check whether slot start + duration <= window.winEnd
return (availableSlots || []).filter((s) => {
try {
const sd = new Date(s.datetime);
const slotStartMs = sd.getTime();
const slotEndMs = slotStartMs + (duration || (s.slot_minutes || 0)) * 60000;
// find a window that contains the entire appointment (start..end)
return availabilityWindows.some((w) => {
return slotStartMs >= w.winStart.getTime() && slotEndMs <= w.winEnd.getTime();
});
} catch (e) { return false; }
});
} catch (e) {
return availableSlots || [];
}
})();
// Ensure the currently-selected startTime (from formData) is present in the list
// so that editing an existing appointment can keep its original time even if
// the server availability doesn't return it (historic booking).
try {
const date = (formData as any).appointmentDate;
const start = (formData as any).startTime;
if (date && start) {
const [y, m, d] = String(date).split('-').map((n) => Number(n));
const [hh, mm] = String(start).split(':').map((n) => Number(n));
if (![y, m, d, hh, mm].some((n) => Number.isNaN(n))) {
const iso = new Date(y, m - 1, d, hh, mm, 0, 0).toISOString();
const present = (displayedSlots || []).some((s) => s.datetime === iso);
if (!present) {
// find in availableSlots if exists to copy slot_minutes, else synthesize
const found = (availableSlots || []).find((s) => {
try { return new Date(s.datetime).toISOString() === iso; } catch (e) { return false; }
});
const toAdd = found ? { ...found } : { datetime: iso, available: false, slot_minutes: (formData as any).duration_minutes || undefined };
// prepend so current appointment time appears first
displayedSlots.unshift(toAdd as any);
}
}
}
} catch (e) {}
return (
<form className="space-y-8">
@ -676,7 +741,6 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
// clear patient selection and also clear doctor/date/time and slots
setFilteredDoctorOptions(null);
setAvailableSlots([]);
setPatientOptions(await listarPacientes({ limit: 200 }).catch(() => []));
const newData: any = { ...formData };
newData.patientId = null;
newData.patientName = '';
@ -685,7 +749,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
newData.appointmentDate = null;
newData.startTime = '';
newData.endTime = '';
// update form first so dependent effects (doctor->patients) run
onFormChange(newData);
// then repopulate the patientOptions (fetch may be async)
const pats = await listarPacientes({ limit: 200 }).catch(() => []);
setPatientOptions(pats || []);
} catch (e) {}
}}
>
@ -767,7 +835,6 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
onClick={async () => {
try {
// clear doctor selection and also clear patient/date/time and slots
setPatientOptions(await listarPacientes({ limit: 200 }).catch(() => []));
setAvailableSlots([]);
setFilteredDoctorOptions(null);
const newData2: any = { ...formData };
@ -778,7 +845,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
newData2.appointmentDate = null;
newData2.startTime = '';
newData2.endTime = '';
// update form first so effects that clear options don't erase our repopulation
onFormChange(newData2);
// then repopulate patients list
const pats = await listarPacientes({ limit: 200 }).catch(() => []);
setPatientOptions(pats || []);
} catch (e) {}
}}
>
@ -839,11 +910,19 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
// set duration from slot if available
const sel = (availableSlots || []).find((s) => s.datetime === value) as any;
const slotMinutes = sel && sel.slot_minutes ? Number(sel.slot_minutes) : null;
// compute endTime from duration (slotMinutes or existing duration)
const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0;
const endDt = new Date(dt.getTime() + Number(durationForCalc) * 60000);
const endH = String(endDt.getHours()).padStart(2, '0');
const endM = String(endDt.getMinutes()).padStart(2, '0');
const endStr = `${endH}:${endM}`;
if (slotMinutes) {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: slotMinutes });
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: slotMinutes, endTime: endStr });
try { setLockedDurationFromSlot(true); } catch (e) {}
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} else {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}` });
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, endTime: endStr });
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
}
} catch (e) {
// noop
@ -948,11 +1027,19 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
const isoDate = dt.toISOString();
const dateOnly = isoDate.split('T')[0];
const slotMinutes = s.slot_minutes || null;
// compute endTime based on duration
const durationForCalc = slotMinutes || (formData as any).duration_minutes || 0;
const endDt = new Date(dt.getTime() + Number(durationForCalc) * 60000);
const endH = String(endDt.getHours()).padStart(2, '0');
const endM = String(endDt.getMinutes()).padStart(2, '0');
const endStr = `${endH}:${endM}`;
if (slotMinutes) {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes) });
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, duration_minutes: Number(slotMinutes), endTime: endStr });
try { setLockedDurationFromSlot(true); } catch (e) {}
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
} else {
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}` });
onFormChange({ ...formData, appointmentDate: dateOnly, startTime: `${hh}:${mm}`, endTime: endStr });
try { (lastAutoEndRef as any).current = endStr; } catch (e) {}
}
}}
>