From 38fd9668d675dc7244c6d723d73479e4e5aa487c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:27:36 -0300 Subject: [PATCH] add-exceptions-endpoints --- .../app/(main-routes)/doutores/page.tsx | 87 ++++++++- .../components/forms/availability-form.tsx | 97 +++++++++- .../components/forms/exception-form.tsx | 112 ++++++++++++ susconecta/lib/api.ts | 172 ++++++++++++++++++ 4 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 susconecta/components/forms/exception-form.tsx diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index 1cd3c77..8400a1f 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -11,7 +11,8 @@ import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye, Users } fro import { Badge } from "@/components/ui/badge"; import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form"; import AvailabilityForm from '@/components/forms/availability-form' -import { listarDisponibilidades, DoctorAvailability, deletarDisponibilidade } from '@/lib/api' +import ExceptionForm from '@/components/forms/exception-form' +import { listarDisponibilidades, DoctorAvailability, deletarDisponibilidade, listarExcecoes, DoctorException, deletarExcecao } from '@/lib/api' import { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, buscarPacientesPorIds, Medico } from "@/lib/api"; @@ -98,6 +99,10 @@ export default function DoutoresPage() { const [availabilities, setAvailabilities] = useState([]); const [availLoading, setAvailLoading] = useState(false); const [editingAvailability, setEditingAvailability] = useState(null); + const [exceptions, setExceptions] = useState([]); + const [exceptionsLoading, setExceptionsLoading] = useState(false); + const [exceptionViewingFor, setExceptionViewingFor] = useState(null); + const [exceptionOpenFor, setExceptionOpenFor] = useState(null); const [searchResults, setSearchResults] = useState([]); const [searchMode, setSearchMode] = useState(false); const [searchTimeout, setSearchTimeout] = useState(null); @@ -480,6 +485,11 @@ export default function DoutoresPage() { Criar disponibilidade + setExceptionOpenFor(doctor)}> + + Criar exceção + + { setAvailLoading(true); try { @@ -496,6 +506,22 @@ export default function DoutoresPage() { Ver disponibilidades + { + setExceptionsLoading(true); + try { + const list = await listarExcecoes({ doctorId: doctor.id }); + setExceptions(list || []); + setExceptionViewingFor(doctor); + } catch (e) { + console.warn('Erro ao listar exceções:', e); + } finally { + setExceptionsLoading(false); + } + }}> + + Ver exceções + + handleEdit(String(doctor.id))}> Editar @@ -570,6 +596,15 @@ export default function DoutoresPage() { /> )} + {exceptionOpenFor && ( + { if (!open) setExceptionOpenFor(null); }} + doctorId={exceptionOpenFor?.id} + onSaved={(saved) => { console.log('Exceção criada', saved); setExceptionOpenFor(null); /* reload availabilities in case a full-day block affects listing */ reloadAvailabilities(exceptionOpenFor?.id); }} + /> + )} + {/* Edit availability modal */} {editingAvailability && ( )} + {/* Ver exceções dialog */} + {exceptionViewingFor && ( + { if (!open) { setExceptionViewingFor(null); setExceptions([]); } }}> + + + Exceções - {exceptionViewingFor.full_name} + + Lista de exceções (bloqueios/liberações) do médico selecionado. + + + +
+ {exceptionsLoading ? ( +
Carregando exceções…
+ ) : exceptions && exceptions.length ? ( +
+ {exceptions.map((ex) => ( +
+
+
{ex.date} {ex.start_time ? `• ${ex.start_time}` : ''} {ex.end_time ? `— ${ex.end_time}` : ''}
+
Tipo: {ex.kind} • Motivo: {ex.reason || '—'}
+
+
+ +
+
+ ))} +
+ ) : ( +
Nenhuma exceção encontrada.
+ )} +
+ + + + +
+
+ )} +
Mostrando {displayedDoctors.length} {searchMode ? 'resultado(s) da busca' : `de ${doctors.length}`}
diff --git a/susconecta/components/forms/availability-form.tsx b/susconecta/components/forms/availability-form.tsx index d288d24..5b140f2 100644 --- a/susconecta/components/forms/availability-form.tsx +++ b/susconecta/components/forms/availability-form.tsx @@ -2,11 +2,12 @@ import { useState, useEffect } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogFooter, AlertDialogAction, AlertDialogCancel } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { criarDisponibilidade, atualizarDisponibilidade, DoctorAvailabilityCreate, DoctorAvailability, DoctorAvailabilityUpdate } from '@/lib/api' +import { criarDisponibilidade, atualizarDisponibilidade, listarExcecoes, DoctorAvailabilityCreate, DoctorAvailability, DoctorAvailabilityUpdate, DoctorException } from '@/lib/api' import { useToast } from '@/hooks/use-toast' export interface AvailabilityFormProps { @@ -28,6 +29,7 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, const [active, setActive] = useState(true) const [submitting, setSubmitting] = useState(false) const { toast } = useToast() + const [blockedException, setBlockedException] = useState(null) // When editing, populate state from availability prop useEffect(() => { @@ -54,6 +56,73 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, setSubmitting(true) try { + // Pre-check exceptions for this doctor to avoid creating an availability + // that is blocked by an existing exception. If a blocking exception is + // found we show a specific toast and abort the creation request. + try { + const exceptions: DoctorException[] = await listarExcecoes({ doctorId: String(doctorId) }); + 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; + }; + + const reqStart = parseTimeToMinutes(`${startTime}:00`); + const reqEnd = parseTimeToMinutes(`${endTime}:00`); + + const normalizeWeekday = (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; + }; + + const reqWeekday = normalizeWeekday(weekday); + + for (const ex of exceptions || []) { + if (!ex || !ex.date) continue; + const exDate = new Date(ex.date + 'T00:00:00'); + if (isNaN(exDate.getTime())) continue; + if (exDate < today || exDate > oneYearAhead) continue; + if (ex.kind !== 'bloqueio') continue; + + const exWeekday = normalizeWeekday(exDate.toLocaleDateString('en-US', { weekday: 'long' })); + if (exWeekday !== reqWeekday) continue; + + // whole-day block + if (!ex.start_time && !ex.end_time) { + setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: undefined }) + setSubmitting(false); + return; + } + + const exStart = parseTimeToMinutes(ex.start_time ?? undefined); + const exEnd = parseTimeToMinutes(ex.end_time ?? undefined); + if (reqStart != null && reqEnd != null && exStart != null && exEnd != null) { + if (reqStart < exEnd && exStart < reqEnd) { + setBlockedException({ date: ex.date, reason: ex.reason ?? undefined, times: `${ex.start_time}–${ex.end_time}` }) + setSubmitting(false); + return; + } + } + } + } catch (e) { + // If checking exceptions fails, continue and let the API handle it. We + // intentionally do not block the flow here because failure to fetch + // exceptions shouldn't completely prevent admins from creating slots. + console.warn('Falha ao verificar exceções antes da criação:', e); + } + if (mode === 'create') { const payload: DoctorAvailabilityCreate = { doctor_id: String(doctorId), @@ -65,7 +134,7 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, active, } - const saved = await criarDisponibilidade(payload) + const saved = await criarDisponibilidade(payload) const labelMap: Record = { 'segunda':'Segunda','terca':'Terça','quarta':'Quarta','quinta':'Quinta','sexta':'Sexta','sabado':'Sábado','domingo':'Domingo', 'monday':'Segunda','tuesday':'Terça','wednesday':'Quarta','thursday':'Quinta','friday':'Sexta','saturday':'Sábado','sunday':'Domingo' @@ -105,7 +174,10 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, } } + const be = blockedException + return ( + <> @@ -175,6 +247,27 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, + + { if (!open) setBlockedException(null) }}> + + + Data bloqueada + +
+ {be ? ( +
+

Não é possível criar disponibilidade para o dia {be!.date}.

+ {be!.times ?

Horário bloqueado: {be!.times}

: null} + {be!.reason ?

Motivo: {be!.reason}

: null} +
+ ) : null} +
+ + setBlockedException(null)}>OK + +
+
+ ) } diff --git a/susconecta/components/forms/exception-form.tsx b/susconecta/components/forms/exception-form.tsx new file mode 100644 index 0000000..b42ca0c --- /dev/null +++ b/susconecta/components/forms/exception-form.tsx @@ -0,0 +1,112 @@ +"use client" + +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { criarExcecao, DoctorExceptionCreate } from '@/lib/api' +import { useToast } from '@/hooks/use-toast' + +export interface ExceptionFormProps { + open: boolean + onOpenChange: (open: boolean) => void + doctorId?: string | null + onSaved?: (saved: any) => void +} + +export default function ExceptionForm({ open, onOpenChange, doctorId = null, onSaved }: ExceptionFormProps) { + const [date, setDate] = useState('') + const [startTime, setStartTime] = useState('') + const [endTime, setEndTime] = useState('') + const [kind, setKind] = useState<'bloqueio'|'liberacao'>('bloqueio') + const [reason, setReason] = useState('') + const [submitting, setSubmitting] = useState(false) + const { toast } = useToast() + + async function handleSubmit(e?: React.FormEvent) { + e?.preventDefault() + if (!doctorId) { + toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' }) + return + } + if (!date) { + toast({ title: 'Erro', description: 'Data obrigatória', variant: 'destructive' }) + return + } + + setSubmitting(true) + try { + const payload: DoctorExceptionCreate = { + doctor_id: String(doctorId), + date: String(date), + start_time: startTime ? `${startTime}:00` : undefined, + end_time: endTime ? `${endTime}:00` : undefined, + kind, + reason: reason || undefined, + } + + const saved = await criarExcecao(payload) + toast({ title: 'Exceção criada', description: `${payload.date} • ${kind}`, variant: 'default' }) + onSaved?.(saved) + onOpenChange(false) + } catch (err: any) { + console.error('Erro ao criar exceção:', err) + toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' }) + } finally { + setSubmitting(false) + } + } + + return ( + + + + Criar exceção + + +
+
+ + setDate(e.target.value)} /> +
+ +
+
+ + setStartTime(e.target.value)} /> +
+
+ + setEndTime(e.target.value)} /> +
+
+ +
+ + +
+ +
+ + setReason(e.target.value)} /> +
+ + + + + +
+
+
+ ) +} diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index aeaff45..a2a2b53 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -234,6 +234,83 @@ 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, appointment_type: input.appointment_type ?? 'presencial', @@ -424,6 +501,101 @@ export async function deletarDisponibilidade(id: string): Promise { await parse(res as Response); } +// ===== EXCEÇÕES (Doctor Exceptions) ===== +export type DoctorExceptionCreate = { + doctor_id: string; + date: string; // YYYY-MM-DD + start_time?: string | null; // HH:MM:SS (optional) + end_time?: string | null; // HH:MM:SS (optional) + kind: 'bloqueio' | 'liberacao'; + reason?: string | null; +}; + +export type DoctorException = DoctorExceptionCreate & { + id: string; + created_at?: string; + created_by?: string | null; +}; + +/** + * Cria uma exceção para um médico (POST /rest/v1/doctor_exceptions) + */ +export async function criarExcecao(input: DoctorExceptionCreate): Promise { + // populate created_by as other functions do + let createdBy: string | null = null; + if (typeof window !== 'undefined') { + try { + const raw = localStorage.getItem(AUTH_STORAGE_KEYS.USER); + if (raw) { + const parsed = JSON.parse(raw); + createdBy = parsed?.id ?? parsed?.user?.id ?? null; + } + } catch (e) { + // ignore + } + } + if (!createdBy) { + try { + const info = await getUserInfo(); + createdBy = info?.user?.id ?? null; + } catch (e) { + // ignore + } + } + + if (!createdBy) { + throw new Error('Não foi possível determinar o usuário atual (created_by). Faça login novamente antes de criar uma exceção.'); + } + + const payload: any = { + doctor_id: input.doctor_id, + date: input.date, + start_time: input.start_time ?? null, + end_time: input.end_time ?? null, + kind: input.kind, + reason: input.reason ?? null, + created_by: createdBy, + }; + + const url = `${REST}/doctor_exceptions`; + const res = await fetch(url, { + method: 'POST', + headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'), + body: JSON.stringify(payload), + }); + + const arr = await parse(res); + return Array.isArray(arr) ? arr[0] : (arr as DoctorException); +} + +/** + * Lista exceções. Se doctorId for passado, filtra por médico; se date for passado, filtra por data. + */ +export async function listarExcecoes(params?: { doctorId?: string; date?: string }): Promise { + const qs = new URLSearchParams(); + if (params?.doctorId) qs.set('doctor_id', `eq.${encodeURIComponent(String(params.doctorId))}`); + if (params?.date) qs.set('date', `eq.${encodeURIComponent(String(params.date))}`); + const url = `${REST}/doctor_exceptions${qs.toString() ? `?${qs.toString()}` : ''}`; + const res = await fetch(url, { method: 'GET', headers: baseHeaders() }); + return await parse(res); +} + +/** + * Deleta uma exceção por ID (DELETE /rest/v1/doctor_exceptions?id=eq.) + */ +export async function deletarExcecao(id: string): Promise { + if (!id) throw new Error('ID da exceção é obrigatório'); + const url = `${REST}/doctor_exceptions?id=eq.${encodeURIComponent(String(id))}`; + const res = await fetch(url, { + method: 'DELETE', + headers: withPrefer({ ...baseHeaders() }, 'return=minimal'), + }); + + if (res.status === 204) return; + if (res.status === 200) return; + await parse(res as Response); +} +