From 7c077fbf457e4221dc3386ced8738a90c344a6c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Mon, 20 Oct 2025 15:23:27 -0300 Subject: [PATCH] fix-exceptions-endpoint --- susconecta/app/agenda/page.tsx | 14 +- .../forms/calendar-registration-form.tsx | 113 +++++++++++++++- susconecta/lib/api.ts | 121 +++++++----------- 3 files changed, 168 insertions(+), 80 deletions(-) diff --git a/susconecta/app/agenda/page.tsx b/susconecta/app/agenda/page.tsx index 2a88845..747b7d1 100644 --- a/susconecta/app/agenda/page.tsx +++ b/susconecta/app/agenda/page.tsx @@ -67,10 +67,20 @@ export default function NovoAgendamentoPage() { await criarAgendamento(payload); // success - try { toast({ title: 'Agendamento criado', description: 'O agendamento foi criado com sucesso.' }); } catch {} + try { toast({ title: 'Agendamento criado', description: 'O agendamento foi criado com sucesso.' }); } catch {} router.push('/consultas'); } catch (err: any) { - alert(err?.message ?? String(err)); + // If the API threw a blocking exception message, surface it as a toast with additional info + const msg = err?.message ?? String(err); + // Heuristic: messages from criarAgendamento about exceptions start with "Não é possível agendar" + if (typeof msg === 'string' && msg.includes('Não é possível agendar')) { + try { + toast({ title: 'Data indisponível', description: msg }); + } catch (_) {} + } else { + // fallback to generic alert for unexpected errors + alert(msg); + } } })(); }; diff --git a/susconecta/components/forms/calendar-registration-form.tsx b/susconecta/components/forms/calendar-registration-form.tsx index b675f30..8253e9f 100644 --- a/susconecta/components/forms/calendar-registration-form.tsx +++ b/susconecta/components/forms/calendar-registration-form.tsx @@ -2,13 +2,24 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades } from "@/lib/api"; +import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades, listarExcecoes } from "@/lib/api"; +import { toast } from '@/hooks/use-toast'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; import { listAssignmentsForPatient } from "@/lib/assignment"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; -import { Calendar, Search, ChevronDown } from "lucide-react"; +import { Calendar, Search, ChevronDown, X } from "lucide-react"; interface FormData { patientName?: string; @@ -77,6 +88,8 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = const [availableSlots, setAvailableSlots] = useState>([]); const [loadingSlots, setLoadingSlots] = useState(false); const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false); + const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false); + const [exceptionDialogMessage, setExceptionDialogMessage] = useState(null); // Helpers to convert between ISO (server) and input[type=datetime-local] value const isoToDatetimeLocal = (iso?: string | null) => { @@ -273,6 +286,31 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = setLoadingSlots(true); (async () => { try { + // Check for blocking exceptions on this exact date before querying availability. + try { + const exceptions = await listarExcecoes({ doctorId: String(docId), date: String(date) }).catch(() => []); + if (exceptions && exceptions.length) { + const blocking = (exceptions || []).find((e: any) => e && e.kind === 'bloqueio'); + if (blocking) { + 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; + 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 @@ -583,6 +621,21 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = return (
+ {/* Exception dialog shown when a blocking exception exists for selected date */} + { if (!open) { setExceptionDialogOpen(false); setExceptionDialogMessage(null); } }}> + + + Data indisponível + + {exceptionDialogMessage ?? 'Não será possível agendar uma consulta nesta data/horário.'} + + + + Fechar + { setExceptionDialogOpen(false); setExceptionDialogMessage(null); }}>OK + + +

Informações do paciente

@@ -591,6 +644,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
{createMode ? ( +
+ {((formData as any).patientId || (formData as any).patient_id) && ( + + )} +
) : ( {createMode ? ( +
+ {((formData as any).doctorId || (formData as any).doctor_id) && ( + + )} +
) : ( )} diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index c26142d..008d6ef 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -234,82 +234,6 @@ export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Pro throw new Error('Não foi possível determinar o usuário atual (created_by). Faça login novamente antes de criar uma disponibilidade.'); } - // --- Prevent creating an availability if a blocking exception exists --- - // We fetch exceptions for this doctor and check upcoming exceptions (from today) - // that either block the whole day (start_time/end_time null) or overlap the - // requested time window on any matching future date within the next year. - try { - const exceptions = await listarExcecoes({ doctorId: input.doctor_id }); - const today = new Date(); - const oneYearAhead = new Date(); - oneYearAhead.setFullYear(oneYearAhead.getFullYear() + 1); - - const parseTimeToMinutes = (t?: string | null) => { - if (!t) return null; - const parts = String(t).split(':').map((p) => Number(p)); - if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) { - return parts[0] * 60 + parts[1]; - } - return null; - }; - - // requested availability interval in minutes (relative to a day) - const reqStart = parseTimeToMinutes(input.start_time); - const reqEnd = parseTimeToMinutes(input.end_time); - - const weekdayKey = (w?: string) => { - if (!w) return w; - const k = String(w).toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, ''); - const map: Record = { - 'segunda':'monday','terca':'tuesday','quarta':'wednesday','quinta':'thursday','sexta':'friday','sabado':'saturday','domingo':'sunday', - 'monday':'monday','tuesday':'tuesday','wednesday':'wednesday','thursday':'thursday','friday':'friday','saturday':'saturday','sunday':'sunday' - }; - return map[k] ?? k; - }; - - for (const ex of exceptions || []) { - try { - if (!ex || !ex.date) continue; - const exDate = new Date(ex.date + 'T00:00:00'); - if (isNaN(exDate.getTime())) continue; - // only consider future exceptions within one year - if (exDate < today || exDate > oneYearAhead) continue; - - // if the exception is of kind 'bloqueio' it blocks times - if (ex.kind !== 'bloqueio') continue; - - // map exDate weekday to server weekday mapping - const exWeekday = weekdayKey(exDate.toLocaleDateString('en-US', { weekday: 'long' })); - const reqWeekday = weekdayKey(input.weekday); - - // We only consider exceptions that fall on the same weekday as the requested availability - if (exWeekday !== reqWeekday) continue; - - // If exception has no start_time/end_time -> blocks whole day - if (!ex.start_time && !ex.end_time) { - throw new Error(`Existe uma exceção de bloqueio no dia ${ex.date}. Não é possível criar disponibilidade para este dia.`); - } - - // otherwise check time overlap - const exStart = parseTimeToMinutes(ex.start_time ?? undefined); - const exEnd = parseTimeToMinutes(ex.end_time ?? undefined); - - if (reqStart != null && reqEnd != null && exStart != null && exEnd != null) { - // overlap if reqStart < exEnd && exStart < reqEnd - if (reqStart < exEnd && exStart < reqEnd) { - throw new Error(`A disponibilidade conflita com uma exceção de bloqueio em ${ex.date} (${ex.start_time}–${ex.end_time}).`); - } - } - } catch (inner) { - // rethrow to be handled below - throw inner; - } - } - } catch (e) { - // If listarExcecoes failed (network etc), surface that error; it's safer - // to prevent creation if we cannot verify exceptions? We'll rethrow. - if (e instanceof Error) throw e; - } const payload: any = { slot_minutes: input.slot_minutes ?? 30, @@ -1058,6 +982,51 @@ export async function criarAgendamento(input: AppointmentCreate): Promise []); + if (exceptions && exceptions.length) { + for (const ex of exceptions) { + try { + if (!ex || !ex.kind) continue; + if (ex.kind !== 'bloqueio') continue; + // If no start_time/end_time -> blocks whole day + if (!ex.start_time && !ex.end_time) { + const reason = ex.reason ? ` Motivo: ${ex.reason}` : ''; + throw new Error(`Não é possível agendar para esta data. Existe uma exceção que bloqueia o dia.${reason}`); + } + // Otherwise check overlap with scheduled time + // Parse exception times and scheduled time to minutes + const parseToMinutes = (t?: string | null) => { + if (!t) return null; + const parts = String(t).split(':').map((p) => Number(p)); + if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1]; + return null; + }; + const exStart = parseToMinutes(ex.start_time ?? undefined); + const exEnd = parseToMinutes(ex.end_time ?? undefined); + const sched = new Date(input.scheduled_at); + const schedMinutes = sched.getHours() * 60 + sched.getMinutes(); + const schedDuration = input.duration_minutes ?? 30; + const schedEndMinutes = schedMinutes + Number(schedDuration); + if (exStart != null && exEnd != null) { + if (schedMinutes < exEnd && exStart < schedEndMinutes) { + const reason = ex.reason ? ` Motivo: ${ex.reason}` : ''; + throw new Error(`Não é possível agendar neste horário por uma exceção que bloqueia parte do dia.${reason}`); + } + } + } catch (inner) { + // Propagate the exception as user-facing error + throw inner; + } + } + } + } catch (e) { + if (e instanceof Error) throw e; + } + // Determine created_by similar to other creators (prefer localStorage then user-info) let createdBy: string | null = null; if (typeof window !== 'undefined') {