From a9bbd2f872077d97038faef9ae1a9fd154a091cc 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 15:17:44 -0300 Subject: [PATCH] add-availability-endpoints --- .../app/(main-routes)/doutores/page.tsx | 136 +++++++++ .../components/forms/availability-form.tsx | 181 ++++++++++++ susconecta/lib/api.ts | 279 ++++++++++++++++++ 3 files changed, 596 insertions(+) create mode 100644 susconecta/components/forms/availability-form.tsx diff --git a/susconecta/app/(main-routes)/doutores/page.tsx b/susconecta/app/(main-routes)/doutores/page.tsx index 50612db..1cd3c77 100644 --- a/susconecta/app/(main-routes)/doutores/page.tsx +++ b/susconecta/app/(main-routes)/doutores/page.tsx @@ -10,6 +10,8 @@ import { Label } from "@/components/ui/label"; import { MoreHorizontal, Plus, Search, Edit, Trash2, ArrowLeft, Eye, Users } from "lucide-react"; 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 { listarMedicos, excluirMedico, buscarMedicos, buscarMedicoPorId, buscarPacientesPorIds, Medico } from "@/lib/api"; @@ -57,6 +59,28 @@ function normalizeMedico(m: any): Medico { }; } +function translateWeekday(w?: string) { + if (!w) return ''; + const key = w.toString().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').replace(/[^a-z0-9]/g, ''); + const map: 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', + }; + return map[key] ?? w; +} + export default function DoutoresPage() { const [doctors, setDoctors] = useState([]); @@ -69,6 +93,11 @@ export default function DoutoresPage() { const [assignedPatients, setAssignedPatients] = useState([]); const [assignedLoading, setAssignedLoading] = useState(false); const [assignedDoctor, setAssignedDoctor] = useState(null); + const [availabilityOpenFor, setAvailabilityOpenFor] = useState(null); + const [availabilityViewingFor, setAvailabilityViewingFor] = useState(null); + const [availabilities, setAvailabilities] = useState([]); + const [availLoading, setAvailLoading] = useState(false); + const [editingAvailability, setEditingAvailability] = useState(null); const [searchResults, setSearchResults] = useState([]); const [searchMode, setSearchMode] = useState(false); const [searchTimeout, setSearchTimeout] = useState(null); @@ -280,6 +309,19 @@ export default function DoutoresPage() { } } + async function reloadAvailabilities(doctorId?: string) { + if (!doctorId) return; + setAvailLoading(true); + try { + const list = await listarDisponibilidades({ doctorId, active: true }); + setAvailabilities(list || []); + } catch (e) { + console.warn('Erro ao recarregar disponibilidades:', e); + } finally { + setAvailLoading(false); + } + } + async function handleDelete(id: string) { if (!confirm("Excluir este médico?")) return; @@ -433,6 +475,27 @@ export default function DoutoresPage() { Ver pacientes atribuídos + setAvailabilityOpenFor(doctor)}> + + Criar disponibilidade + + + { + setAvailLoading(true); + try { + const list = await listarDisponibilidades({ doctorId: doctor.id, active: true }); + setAvailabilities(list || []); + setAvailabilityViewingFor(doctor); + } catch (e) { + console.warn('Erro ao listar disponibilidades:', e); + } finally { + setAvailLoading(false); + } + }}> + + Ver disponibilidades + + handleEdit(String(doctor.id))}> Editar @@ -497,6 +560,79 @@ export default function DoutoresPage() { )} + {/* Availability modal */} + {availabilityOpenFor && ( + { if (!open) setAvailabilityOpenFor(null); }} + doctorId={availabilityOpenFor?.id} + onSaved={(saved) => { console.log('Disponibilidade salva', saved); setAvailabilityOpenFor(null); /* optionally reload list */ reloadAvailabilities(availabilityOpenFor?.id); }} + /> + )} + + {/* Edit availability modal */} + {editingAvailability && ( + { if (!open) setEditingAvailability(null); }} + doctorId={editingAvailability?.doctor_id ?? availabilityViewingFor?.id} + availability={editingAvailability} + mode="edit" + onSaved={(saved) => { console.log('Disponibilidade atualizada', saved); setEditingAvailability(null); reloadAvailabilities(editingAvailability?.doctor_id ?? availabilityViewingFor?.id); }} + /> + )} + + {/* Ver disponibilidades dialog */} + {availabilityViewingFor && ( + { if (!open) { setAvailabilityViewingFor(null); setAvailabilities([]); } }}> + + + Disponibilidades - {availabilityViewingFor.full_name} + + Lista de disponibilidades públicas do médico selecionado. + + + +
+ {availLoading ? ( +
Carregando disponibilidades…
+ ) : availabilities && availabilities.length ? ( +
+ {availabilities.map((a) => ( +
+
+
{translateWeekday(a.weekday)} • {a.start_time} — {a.end_time}
+
Duração: {a.slot_minutes} min • Tipo: {a.appointment_type || '—'} • {a.active ? 'Ativa' : 'Inativa'}
+
+
+ + +
+
+ ))} +
+ ) : ( +
Nenhuma disponibilidade 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 new file mode 100644 index 0000000..d288d24 --- /dev/null +++ b/susconecta/components/forms/availability-form.tsx @@ -0,0 +1,181 @@ +"use client" + +import { useState, useEffect } 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 { criarDisponibilidade, atualizarDisponibilidade, DoctorAvailabilityCreate, DoctorAvailability, DoctorAvailabilityUpdate } from '@/lib/api' +import { useToast } from '@/hooks/use-toast' + +export interface AvailabilityFormProps { + open: boolean + onOpenChange: (open: boolean) => void + doctorId?: string | null + onSaved?: (saved: any) => void + // when editing, pass the existing availability and set mode to 'edit' + availability?: DoctorAvailability | null + mode?: 'create' | 'edit' +} + +export function AvailabilityForm({ open, onOpenChange, doctorId = null, onSaved, availability = null, mode = 'create' }: AvailabilityFormProps) { + const [weekday, setWeekday] = useState('segunda') + const [startTime, setStartTime] = useState('09:00') + const [endTime, setEndTime] = useState('17:00') + const [slotMinutes, setSlotMinutes] = useState(30) + const [appointmentType, setAppointmentType] = useState<'presencial'|'telemedicina'>('presencial') + const [active, setActive] = useState(true) + const [submitting, setSubmitting] = useState(false) + const { toast } = useToast() + + // When editing, populate state from availability prop + useEffect(() => { + if (mode === 'edit' && availability) { + // weekday may be 'monday' or 'segunda' — keep original string + setWeekday(String(availability.weekday ?? 'segunda')) + // strip seconds for time inputs (HH:MM) + const st = String(availability.start_time ?? '09:00:00').replace(/:00$/,'') + const et = String(availability.end_time ?? '17:00:00').replace(/:00$/,'') + setStartTime(st) + setEndTime(et) + setSlotMinutes(Number(availability.slot_minutes ?? 30)) + setAppointmentType((availability.appointment_type ?? 'presencial') as any) + setActive(!!availability.active) + } + }, [mode, availability]) + + async function handleSubmit(e?: React.FormEvent) { + e?.preventDefault() + if (!doctorId) { + toast({ title: 'Erro', description: 'ID do médico não informado', variant: 'destructive' }) + return + } + + setSubmitting(true) + try { + if (mode === 'create') { + const payload: DoctorAvailabilityCreate = { + doctor_id: String(doctorId), + weekday: weekday as any, + start_time: `${startTime}:00`, + end_time: `${endTime}:00`, + slot_minutes: slotMinutes, + appointment_type: appointmentType, + active, + } + + 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' + } + const label = labelMap[weekday as string] ?? String(weekday) + toast({ title: 'Disponibilidade criada', description: `${label} ${startTime}–${endTime}`, variant: 'default' }) + onSaved?.(saved) + onOpenChange(false) + } else { + // edit mode: update existing availability + if (!availability || !availability.id) { + throw new Error('Disponibilidade inválida para edição') + } + const payload: DoctorAvailabilityUpdate = { + weekday: weekday as any, + start_time: `${startTime}:00`, + end_time: `${endTime}:00`, + slot_minutes: slotMinutes, + appointment_type: appointmentType, + active, + } + const updated = await atualizarDisponibilidade(String(availability.id), 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' + } + const label = labelMap[weekday as string] ?? String(weekday) + toast({ title: 'Disponibilidade atualizada', description: `${label} ${startTime}–${endTime}`, variant: 'default' }) + onSaved?.(updated) + onOpenChange(false) + } + } catch (err: any) { + console.error('Erro ao criar disponibilidade:', err) + toast({ title: 'Erro', description: err?.message || String(err), variant: 'destructive' }) + } finally { + setSubmitting(false) + } + } + + return ( + + + + Criar disponibilidade + + +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + setStartTime(e.target.value)} /> +
+
+ + setEndTime(e.target.value)} /> +
+
+ + setSlotMinutes(Number(e.target.value || 30))} /> +
+
+ +
+ +
+ + + + + +
+
+
+ ) +} + +export default AvailabilityForm diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index 6dcc2ba..afe87cb 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -1,6 +1,7 @@ // lib/api.ts import { ENV_CONFIG } from '@/lib/env-config'; +import { AUTH_STORAGE_KEYS } from '@/types/auth' // Use ENV_CONFIG for SUPABASE URL and anon key in frontend export type ApiOk = { @@ -146,6 +147,284 @@ export type MedicoInput = { updated_by?: string | null; }; +// ===== DISPONIBILIDADE (Doctor Availability) ===== +export type DoctorAvailabilityCreate = { + doctor_id: string; + weekday: 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado' | 'domingo'; + start_time: string; // 'HH:MM:SS' + end_time: string; // 'HH:MM:SS' + slot_minutes?: number; + appointment_type?: 'presencial' | 'telemedicina'; + active?: boolean; +}; + +export type DoctorAvailability = DoctorAvailabilityCreate & { + id: string; + created_at?: string; + updated_at?: string; + created_by?: string; + updated_by?: string | null; +}; + +export type DoctorAvailabilityUpdate = Partial<{ + weekday: 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado' | 'domingo' | string; + start_time: string; + end_time: string; + slot_minutes: number; + appointment_type: 'presencial' | 'telemedicina' | string; + active: boolean; +}>; + +/** + * Cria uma disponibilidade de médico (POST /rest/v1/doctor_availability) + */ +export async function criarDisponibilidade(input: DoctorAvailabilityCreate): Promise { + // Apply sensible defaults + // Map weekday to the server-expected enum value if necessary. Some deployments use + // English weekday names (monday..sunday) as the enum values; the UI uses + // Portuguese values (segunda..domingo). Normalize and convert here so POST + // doesn't fail with Postgres `invalid input value for enum weekday`. + const mapWeekdayForServer = (w?: string) => { + if (!w) return w; + const key = w.toString().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', + // allow common english names through + 'monday': 'monday', + 'tuesday': 'tuesday', + 'wednesday': 'wednesday', + 'thursday': 'thursday', + 'friday': 'friday', + 'saturday': 'saturday', + 'sunday': 'sunday', + }; + return map[key] ?? w; + }; + + // Determine created_by: try localStorage first, then fall back to calling user-info + 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 parse errors + } + } + + 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 disponibilidade.'); + } + + const payload: any = { + slot_minutes: input.slot_minutes ?? 30, + appointment_type: input.appointment_type ?? 'presencial', + active: typeof input.active === 'undefined' ? true : input.active, + doctor_id: input.doctor_id, + weekday: mapWeekdayForServer(input.weekday), + start_time: input.start_time, + end_time: input.end_time, + }; + + const url = `${REST}/doctor_availability`; + // Try several payload permutations to tolerate different server enum/time formats. + const attempts = [] as Array<{ weekdayVal: string | undefined; withSeconds: boolean }>; + const mappedWeekday = mapWeekdayForServer(input.weekday); + const originalWeekday = input.weekday; + attempts.push({ weekdayVal: mappedWeekday, withSeconds: true }); + attempts.push({ weekdayVal: originalWeekday, withSeconds: true }); + attempts.push({ weekdayVal: mappedWeekday, withSeconds: false }); + attempts.push({ weekdayVal: originalWeekday, withSeconds: false }); + + let lastRes: Response | null = null; + for (const at of attempts) { + const start = at.withSeconds ? input.start_time : String(input.start_time).replace(/:00$/,''); + const end = at.withSeconds ? input.end_time : String(input.end_time).replace(/:00$/,''); + const tryPayload: any = { + slot_minutes: input.slot_minutes ?? 30, + appointment_type: input.appointment_type ?? 'presencial', + active: typeof input.active === 'undefined' ? true : input.active, + doctor_id: input.doctor_id, + weekday: at.weekdayVal, + start_time: start, + end_time: end, + created_by: createdBy, + }; + + try { + const res = await fetch(url, { + method: 'POST', + headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'), + body: JSON.stringify(tryPayload), + }); + lastRes = res; + if (res.ok) { + const arr = await parse(res); + return Array.isArray(arr) ? arr[0] : (arr as DoctorAvailability); + } + + // If server returned a 4xx, try next permutation; for 5xx, bail out and throw the error from parse() + if (res.status >= 500) { + // Let parse produce the error with friendly messaging + return await parse(res); + } + + // Log a warning and continue to next attempt + const raw = await res.clone().text().catch(() => ''); + console.warn('[criarDisponibilidade] tentativa falhou', { status: res.status, weekday: at.weekdayVal, withSeconds: at.withSeconds, raw }); + // continue to next attempt + } catch (e) { + console.warn('[criarDisponibilidade] fetch erro na tentativa', e); + // continue to next attempt + } + } + + // All attempts failed — throw using the last response to get friendly message from parse() + if (lastRes) { + return await parse(lastRes); + } + throw new Error('Falha ao criar disponibilidade: nenhuma resposta do servidor.'); +} + +/** + * Lista disponibilidades. Se doctorId for passado, filtra por médico. + */ +export async function listarDisponibilidades(params?: { doctorId?: string; active?: boolean }): Promise { + const qs = new URLSearchParams(); + if (params?.doctorId) qs.set('doctor_id', `eq.${encodeURIComponent(String(params.doctorId))}`); + if (typeof params?.active !== 'undefined') qs.set('active', `eq.${params.active ? 'true' : 'false'}`); + + const url = `${REST}/doctor_availability${qs.toString() ? `?${qs.toString()}` : ''}`; + const res = await fetch(url, { method: 'GET', headers: baseHeaders() }); + return await parse(res); +} + + +/** + * Atualiza uma disponibilidade existente (PATCH /rest/v1/doctor_availability?id=eq.) + */ +export async function atualizarDisponibilidade(id: string, input: DoctorAvailabilityUpdate): Promise { + if (!id) throw new Error('ID da disponibilidade é obrigatório'); + + const mapWeekdayForServer = (w?: string) => { + if (!w) return w; + const key = w.toString().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[key] ?? w; + }; + + // determine updated_by + let updatedBy: string | null = null; + if (typeof window !== 'undefined') { + try { + const raw = localStorage.getItem(AUTH_STORAGE_KEYS.USER); + if (raw) { + const parsed = JSON.parse(raw); + updatedBy = parsed?.id ?? parsed?.user?.id ?? null; + } + } catch (e) {} + } + if (!updatedBy) { + try { + const info = await getUserInfo(); + updatedBy = info?.user?.id ?? null; + } catch (e) {} + } + + const restUrl = `${REST}/doctor_availability?id=eq.${encodeURIComponent(String(id))}`; + + // Build candidate payloads (weekday mapped/original, with/without seconds) + const candidates: Array = []; + const wk = input.weekday ? mapWeekdayForServer(String(input.weekday)) : undefined; + // preferred candidate + candidates.push({ ...input, weekday: wk }); + // original weekday if different + if (input.weekday && String(input.weekday) !== wk) candidates.push({ ...input, weekday: input.weekday }); + // times without seconds + const stripSeconds = (t?: string) => t ? String(t).replace(/:00$/,'') : t; + candidates.push({ ...input, start_time: stripSeconds(input.start_time), end_time: stripSeconds(input.end_time), weekday: wk }); + + let lastRes: Response | null = null; + for (const cand of candidates) { + const payload: any = { ...cand }; + if (updatedBy) payload.updated_by = updatedBy; + + try { + const res = await fetch(restUrl, { + method: 'PATCH', + headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'), + body: JSON.stringify(payload), + }); + lastRes = res; + if (res.ok) { + const arr = await parse(res); + return Array.isArray(arr) ? arr[0] : (arr as DoctorAvailability); + } + if (res.status >= 500) return await parse(res); + const raw = await res.clone().text().catch(() => ''); + console.warn('[atualizarDisponibilidade] tentativa falhou', { status: res.status, payload, raw }); + } catch (e) { + console.warn('[atualizarDisponibilidade] erro fetch', e); + } + } + + if (lastRes) return await parse(lastRes); + throw new Error('Falha ao atualizar disponibilidade: sem resposta do servidor'); +} + +/** + * Deleta uma disponibilidade por ID (DELETE /rest/v1/doctor_availability?id=eq.) + */ +export async function deletarDisponibilidade(id: string): Promise { + if (!id) throw new Error('ID da disponibilidade é obrigatório'); + const url = `${REST}/doctor_availability?id=eq.${encodeURIComponent(String(id))}`; + // Request minimal return to get a 204 No Content when the delete succeeds. + const res = await fetch(url, { + method: 'DELETE', + headers: withPrefer({ ...baseHeaders() }, 'return=minimal'), + }); + + if (res.status === 204) return; + // Some deployments may return 200 with a representation — accept that too + if (res.status === 200) return; + // Otherwise surface a friendly error using parse() + await parse(res as Response); +} + + // ===== CONFIG =====