add-exceptions-endpoints

This commit is contained in:
João Gustavo 2025-10-15 16:27:36 -03:00
parent ab8905859c
commit 38fd9668d6
4 changed files with 465 additions and 3 deletions

View File

@ -11,7 +11,8 @@ import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye, Users } fro
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form"; import { DoctorRegistrationForm } from "@/components/forms/doctor-registration-form";
import AvailabilityForm from '@/components/forms/availability-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"; import { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, buscarPacientesPorIds, Medico } from "@/lib/api";
@ -98,6 +99,10 @@ export default function DoutoresPage() {
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>([]); const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>([]);
const [availLoading, setAvailLoading] = useState(false); const [availLoading, setAvailLoading] = useState(false);
const [editingAvailability, setEditingAvailability] = useState<DoctorAvailability | null>(null); const [editingAvailability, setEditingAvailability] = useState<DoctorAvailability | null>(null);
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
const [exceptionsLoading, setExceptionsLoading] = useState(false);
const [exceptionViewingFor, setExceptionViewingFor] = useState<Medico | null>(null);
const [exceptionOpenFor, setExceptionOpenFor] = useState<Medico | null>(null);
const [searchResults, setSearchResults] = useState<Medico[]>([]); const [searchResults, setSearchResults] = useState<Medico[]>([]);
const [searchMode, setSearchMode] = useState(false); const [searchMode, setSearchMode] = useState(false);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null); const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
@ -480,6 +485,11 @@ export default function DoutoresPage() {
Criar disponibilidade Criar disponibilidade
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setExceptionOpenFor(doctor)}>
<Plus className="mr-2 h-4 w-4" />
Criar exceção
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => { <DropdownMenuItem onClick={async () => {
setAvailLoading(true); setAvailLoading(true);
try { try {
@ -496,6 +506,22 @@ export default function DoutoresPage() {
Ver disponibilidades Ver disponibilidades
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={async () => {
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);
}
}}>
<Users className="mr-2 h-4 w-4" />
Ver exceções
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(String(doctor.id))}> <DropdownMenuItem onClick={() => handleEdit(String(doctor.id))}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Editar Editar
@ -570,6 +596,15 @@ export default function DoutoresPage() {
/> />
)} )}
{exceptionOpenFor && (
<ExceptionForm
open={!!exceptionOpenFor}
onOpenChange={(open) => { 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 */} {/* Edit availability modal */}
{editingAvailability && ( {editingAvailability && (
<AvailabilityForm <AvailabilityForm
@ -633,6 +668,56 @@ export default function DoutoresPage() {
</Dialog> </Dialog>
)} )}
{/* Ver exceções dialog */}
{exceptionViewingFor && (
<Dialog open={!!exceptionViewingFor} onOpenChange={(open) => { if (!open) { setExceptionViewingFor(null); setExceptions([]); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Exceções - {exceptionViewingFor.full_name}</DialogTitle>
<DialogDescription>
Lista de exceções (bloqueios/liberações) do médico selecionado.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{exceptionsLoading ? (
<div>Carregando exceções</div>
) : exceptions && exceptions.length ? (
<div className="space-y-2">
{exceptions.map((ex) => (
<div key={String(ex.id)} className="p-2 border rounded flex justify-between items-start">
<div>
<div className="font-medium">{ex.date} {ex.start_time ? `${ex.start_time}` : ''} {ex.end_time ? `${ex.end_time}` : ''}</div>
<div className="text-xs text-muted-foreground">Tipo: {ex.kind} Motivo: {ex.reason || '—'}</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={async () => {
if (!confirm('Excluir esta exceção?')) return;
try {
await deletarExcecao(String(ex.id));
const list = await listarExcecoes({ doctorId: exceptionViewingFor?.id });
setExceptions(list || []);
} catch (e) {
console.warn('Erro ao deletar exceção:', e);
alert((e as any)?.message || 'Erro ao deletar exceção');
}
}}>Excluir</Button>
</div>
</div>
))}
</div>
) : (
<div>Nenhuma exceção encontrada.</div>
)}
</div>
<DialogFooter>
<Button onClick={() => { setExceptionViewingFor(null); setExceptions([]); }}>Fechar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Mostrando {displayedDoctors.length} {searchMode ? 'resultado(s) da busca' : `de ${doctors.length}`} Mostrando {displayedDoctors.length} {searchMode ? 'resultado(s) da busca' : `de ${doctors.length}`}
</div> </div>

View File

@ -2,11 +2,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' 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 { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' 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' import { useToast } from '@/hooks/use-toast'
export interface AvailabilityFormProps { export interface AvailabilityFormProps {
@ -28,6 +29,7 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved,
const [active, setActive] = useState<boolean>(true) const [active, setActive] = useState<boolean>(true)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const { toast } = useToast() const { toast } = useToast()
const [blockedException, setBlockedException] = useState<null | { date: string; reason?: string; times?: string }>(null)
// When editing, populate state from availability prop // When editing, populate state from availability prop
useEffect(() => { useEffect(() => {
@ -54,6 +56,73 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved,
setSubmitting(true) setSubmitting(true)
try { 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<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;
};
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') { if (mode === 'create') {
const payload: DoctorAvailabilityCreate = { const payload: DoctorAvailabilityCreate = {
doctor_id: String(doctorId), doctor_id: String(doctorId),
@ -105,7 +174,10 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved,
} }
} }
const be = blockedException
return ( return (
<>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@ -175,6 +247,27 @@ export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved,
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<AlertDialog open={!!be} onOpenChange={(open) => { if (!open) setBlockedException(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Data bloqueada</AlertDialogTitle>
</AlertDialogHeader>
<div className="px-6 pb-6 pt-2">
{be ? (
<div className="space-y-2">
<p>Não é possível criar disponibilidade para o dia <strong>{be!.date}</strong>.</p>
{be!.times ? <p>Horário bloqueado: <strong>{be!.times}</strong></p> : null}
{be!.reason ? <p>Motivo: <strong>{be!.reason}</strong></p> : null}
</div>
) : null}
</div>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setBlockedException(null)}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) )
} }

View File

@ -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<string>('')
const [startTime, setStartTime] = useState<string>('')
const [endTime, setEndTime] = useState<string>('')
const [kind, setKind] = useState<'bloqueio'|'liberacao'>('bloqueio')
const [reason, setReason] = useState<string>('')
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar exceção</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4">
<div>
<Label>Data</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Início (opcional)</Label>
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</div>
<div>
<Label>Fim (opcional)</Label>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</div>
</div>
<div>
<Label>Tipo</Label>
<Select value={kind} onValueChange={(v) => setKind(v as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bloqueio">Bloqueio</SelectItem>
<SelectItem value="liberacao">Liberação</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Motivo (opcional)</Label>
<Input value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>Cancelar</Button>
<Button type="submit" disabled={submitting}>{submitting ? 'Salvando...' : 'Criar exceção'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -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.'); 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,
appointment_type: input.appointment_type ?? 'presencial', appointment_type: input.appointment_type ?? 'presencial',
@ -424,6 +501,101 @@ export async function deletarDisponibilidade(id: string): Promise<void> {
await parse(res as Response); 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<DoctorException> {
// 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<DoctorException[] | DoctorException>(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<DoctorException[]> {
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<DoctorException[]>(res);
}
/**
* Deleta uma exceção por ID (DELETE /rest/v1/doctor_exceptions?id=eq.<id>)
*/
export async function deletarExcecao(id: string): Promise<void> {
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);
}