Merge pull request 'fix-exceptions-endpoint' (#53) from feature/add-appointments-endpoint into develop
Reviewed-on: #53
This commit is contained in:
commit
30c37adbed
@ -67,10 +67,20 @@ export default function NovoAgendamentoPage() {
|
|||||||
|
|
||||||
await criarAgendamento(payload);
|
await criarAgendamento(payload);
|
||||||
// success
|
// 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');
|
router.push('/consultas');
|
||||||
} catch (err: any) {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,13 +2,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
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 { listAssignmentsForPatient } from "@/lib/assignment";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
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 {
|
interface FormData {
|
||||||
patientName?: string;
|
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 [availableSlots, setAvailableSlots] = useState<Array<{ datetime: string; available: boolean; slot_minutes?: number }>>([]);
|
||||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||||
const [lockedDurationFromSlot, setLockedDurationFromSlot] = 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
|
// Helpers to convert between ISO (server) and input[type=datetime-local] value
|
||||||
const isoToDatetimeLocal = (iso?: string | null) => {
|
const isoToDatetimeLocal = (iso?: string | null) => {
|
||||||
@ -273,6 +286,31 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
setLoadingSlots(true);
|
setLoadingSlots(true);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType });
|
||||||
console.debug('[CalendarRegistrationForm] doctorOptions count', (doctorOptions || []).length, 'selectedDoctorId', docId, 'doctorOptions sample', (doctorOptions || []).slice(0,3));
|
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
|
// Build start/end as local day bounds from YYYY-MM-DD to avoid
|
||||||
@ -583,6 +621,21 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-8">
|
<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">
|
<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>
|
<h2 className="font-medium text-foreground">Informações do paciente</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
<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">
|
<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" />
|
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
{createMode ? (
|
{createMode ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={(formData as any).patientId || (formData as any).patient_id || ''}
|
value={(formData as any).patientId || (formData as any).patient_id || ''}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@ -612,6 +666,33 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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
|
<Input
|
||||||
name="patientName"
|
name="patientName"
|
||||||
@ -656,6 +737,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
<div className="relative">
|
<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" />
|
<Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
{createMode ? (
|
{createMode ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={(formData as any).doctorId || (formData as any).doctor_id || ''}
|
value={(formData as any).doctorId || (formData as any).doctor_id || ''}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@ -677,6 +759,33 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode =
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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 />
|
<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 />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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.');
|
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 = {
|
const payload: any = {
|
||||||
slot_minutes: input.slot_minutes ?? 30,
|
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.');
|
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)
|
// Determine created_by similar to other creators (prefer localStorage then user-info)
|
||||||
let createdBy: string | null = null;
|
let createdBy: string | null = null;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user