fix-exceptions-endpoint

This commit is contained in:
João Gustavo 2025-10-20 15:23:27 -03:00
parent 99986dbdd7
commit 7c077fbf45
3 changed files with 168 additions and 80 deletions

View File

@ -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);
}
}
})();
};

View File

@ -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<Array<{ datetime: string; available: boolean; slot_minutes?: number }>>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [lockedDurationFromSlot, setLockedDurationFromSlot] = useState(false);
const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false);
const [exceptionDialogMessage, setExceptionDialogMessage] = useState<string | null>(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 (
<form className="space-y-8">
{/* Exception dialog shown when a blocking exception exists for selected date */}
<AlertDialog open={exceptionDialogOpen} onOpenChange={(open) => { if (!open) { setExceptionDialogOpen(false); setExceptionDialogMessage(null); } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data indisponível</AlertDialogTitle>
<AlertDialogDescription>
{exceptionDialogMessage ?? 'Não será possível agendar uma consulta nesta data/horário.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Fechar</AlertDialogCancel>
<AlertDialogAction onClick={() => { setExceptionDialogOpen(false); setExceptionDialogMessage(null); }}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="border border-border rounded-md p-6 space-y-4 bg-card">
<h2 className="font-medium text-foreground">Informações do paciente</h2>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
@ -591,6 +644,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
{createMode ? (
<div className="flex items-center gap-2">
<Select
value={(formData as any).patientId || (formData as any).patient_id || ''}
onValueChange={(value) => {
@ -612,6 +666,33 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
)}
</SelectContent>
</Select>
{((formData as any).patientId || (formData as any).patient_id) && (
<button
type="button"
title="Limpar seleção"
className="h-10 w-10 flex items-center justify-center rounded-md bg-muted/10 text-foreground/90"
onClick={async () => {
try {
// 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 = '';
newData.doctorId = null;
newData.professionalName = '';
newData.appointmentDate = null;
newData.startTime = '';
newData.endTime = '';
onFormChange(newData);
} catch (e) {}
}}
>
<X size={16} />
</button>
)}
</div>
) : (
<Input
name="patientName"
@ -656,6 +737,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
{createMode ? (
<div className="flex items-center gap-2">
<Select
value={(formData as any).doctorId || (formData as any).doctor_id || ''}
onValueChange={(value) => {
@ -677,6 +759,33 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
)}
</SelectContent>
</Select>
{((formData as any).doctorId || (formData as any).doctor_id) && (
<button
type="button"
title="Limpar seleção"
className="h-10 w-10 flex items-center justify-center rounded-md bg-muted/10 text-foreground/90"
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 };
newData2.doctorId = null;
newData2.professionalName = '';
newData2.patientId = null;
newData2.patientName = '';
newData2.appointmentDate = null;
newData2.startTime = '';
newData2.endTime = '';
onFormChange(newData2);
} catch (e) {}
}}
>
<X size={16} />
</button>
)}
</div>
) : (
<Input name="professionalName" className="h-11 w-full rounded-md pl-8 pr-12 text-[13px] transition-colors hover:bg-muted/30" value={formData.professionalName || ''} disabled />
)}

View File

@ -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<string,string> = {
'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<Appoin
throw new Error('Horário não disponível para o médico no horário solicitado. Verifique a disponibilidade antes de agendar.');
}
// --- Prevent creating an appointment on a date with a blocking exception ---
try {
// listarExcecoes can filter by date
const dateOnly = startDay.toISOString().split('T')[0];
const exceptions = await listarExcecoes({ doctorId: input.doctor_id, date: dateOnly }).catch(() => []);
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') {