From 075fa92eb9cdf1cca2b7096d0e62b3c3336dac64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Sat, 18 Oct 2025 21:23:27 -0300 Subject: [PATCH] add-create-appoiments-endpoint --- .../app/(main-routes)/consultas/page.tsx | 4 +- susconecta/app/agenda/page.tsx | 41 +- .../forms/calendar-registration-form.tsx | 496 +++++++++++++++++- susconecta/lib/api.ts | 150 ++++++ 4 files changed, 664 insertions(+), 27 deletions(-) diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx index dbd77d4..f0da89a 100644 --- a/susconecta/app/(main-routes)/consultas/page.tsx +++ b/susconecta/app/(main-routes)/consultas/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { MoreHorizontal, PlusCircle, @@ -409,7 +409,7 @@ export default function ConsultasPage() {
- diff --git a/susconecta/app/agenda/page.tsx b/susconecta/app/agenda/page.tsx index fff1a81..dc02f92 100644 --- a/susconecta/app/agenda/page.tsx +++ b/susconecta/app/agenda/page.tsx @@ -5,6 +5,8 @@ import { CalendarRegistrationForm } from "@/components/forms/calendar-registrati import HeaderAgenda from "@/components/agenda/HeaderAgenda"; import FooterAgenda from "@/components/agenda/FooterAgenda"; import { useState } from "react"; +import { criarAgendamento } from '@/lib/api'; +import { toast } from '@/hooks/use-toast'; interface FormData { patientName?: string; @@ -37,9 +39,33 @@ export default function NovoAgendamentoPage() { }; const handleSave = () => { - console.log("Salvando novo agendamento...", formData); - alert("Novo agendamento salvo (simulado)!"); - router.push("/consultas"); + (async () => { + try { + // basic validation + if (!formData.patientId && !(formData as any).patient_id) throw new Error('Patient ID é obrigatório'); + if (!formData.doctorId && !(formData as any).doctor_id) throw new Error('Doctor ID é obrigatório'); + if (!formData.appointmentDate) throw new Error('Data é obrigatória'); + if (!formData.startTime) throw new Error('Horário de início é obrigatório'); + + const payload: any = { + patient_id: formData.patientId || (formData as any).patient_id, + doctor_id: formData.doctorId || (formData as any).doctor_id, + scheduled_at: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(), + duration_minutes: formData.duration_minutes ?? 30, + appointment_type: formData.appointmentType ?? 'presencial', + chief_complaint: formData.chief_complaint ?? null, + patient_notes: formData.patient_notes ?? null, + insurance_provider: formData.insurance_provider ?? null, + }; + + await criarAgendamento(payload); + // success + try { toast({ title: 'Agendamento criado', description: 'O agendamento foi criado com sucesso.' }); } catch {} + router.push('/consultas'); + } catch (err: any) { + alert(err?.message ?? String(err)); + } + })(); }; const handleCancel = () => { @@ -50,10 +76,11 @@ export default function NovoAgendamentoPage() {
- +
diff --git a/susconecta/components/forms/calendar-registration-form.tsx b/susconecta/components/forms/calendar-registration-form.tsx index f3fa4e5..245eeaa 100644 --- a/susconecta/components/forms/calendar-registration-form.tsx +++ b/susconecta/components/forms/calendar-registration-form.tsx @@ -2,10 +2,12 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { buscarPacientePorId } from "@/lib/api"; +import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades } from "@/lib/api"; +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"; interface FormData { @@ -21,6 +23,8 @@ interface FormData { validade?: string; documentos?: string; professionalName?: string; + patientId?: string | null; + doctorId?: string | null; unit?: string; appointmentDate?: string; startTime?: string; @@ -43,6 +47,7 @@ interface FormData { interface CalendarRegistrationFormProperties { formData: FormData; onFormChange: (data: FormData) => void; + createMode?: boolean; // when true, enable fields needed to create a new appointment } const formatValidityDate = (value: string) => { @@ -56,10 +61,21 @@ const formatValidityDate = (value: string) => { return cleaned; }; -export function CalendarRegistrationForm({ formData, onFormChange }: CalendarRegistrationFormProperties) { +export function CalendarRegistrationForm({ formData, onFormChange, createMode = false }: CalendarRegistrationFormProperties) { const [isAdditionalInfoOpen, setIsAdditionalInfoOpen] = useState(false); const [patientDetails, setPatientDetails] = useState(null); const [loadingPatient, setLoadingPatient] = useState(false); + const [doctorOptions, setDoctorOptions] = useState([]); + const [filteredDoctorOptions, setFilteredDoctorOptions] = useState(null); + const [patientOptions, setPatientOptions] = useState([]); + const [patientSearch, setPatientSearch] = useState(''); + const searchTimerRef = useRef(null); + const [loadingDoctors, setLoadingDoctors] = useState(false); + const [loadingPatients, setLoadingPatients] = useState(false); + const [loadingAssignedDoctors, setLoadingAssignedDoctors] = useState(false); + const [loadingPatientsForDoctor, setLoadingPatientsForDoctor] = useState(false); + const [availableSlots, setAvailableSlots] = useState>([]); + const [loadingSlots, setLoadingSlots] = useState(false); // Helpers to convert between ISO (server) and input[type=datetime-local] value const isoToDatetimeLocal = (iso?: string | null) => { @@ -136,6 +152,288 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg }; }, [(formData as any).patientId, (formData as any).patient_id]); + // Load doctor suggestions (simple listing) when the component mounts + useEffect(() => { + let mounted = true; + (async () => { + setLoadingDoctors(true); + try { + // listarMedicos returns a paginated list of doctors; request a reasonable limit + const docs = await listarMedicos({ limit: 200 }); + if (!mounted) return; + setDoctorOptions(docs || []); + } catch (e) { + console.warn('[CalendarRegistrationForm] falha ao carregar médicos', e); + } finally { + if (!mounted) return; + setLoadingDoctors(false); + } + })(); + return () => { mounted = false; }; + }, []); + + // Preload patients so the patient + {createMode ? ( + + ) : ( + + )}
@@ -246,13 +585,37 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg

Informações do atendimento

-
- -
- - -
-
+
+ +
+ + {createMode ? ( + + ) : ( + + )} +
+
@@ -263,7 +626,69 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
- + {createMode ? ( + + ) : ( + + )}
@@ -271,6 +696,41 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg
{/* Profissional solicitante removed per user request */}
+ {/* Available slots area (createMode only) */} + {createMode && ( +
+ +
+ {loadingSlots ? ( +
Carregando horários...
+ ) : availableSlots && availableSlots.length ? ( + availableSlots.map((s) => { + const dt = new Date(s.datetime); + const hh = String(dt.getHours()).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + const label = `${hh}:${mm}`; + return ( + + ); + }) + ) : ( +
Nenhum horário disponível para o médico nesta data.
+ )} +
+
+ )}
diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index 9c79cbc..27e58de 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -983,6 +983,126 @@ export type Appointment = { updated_by?: string | null; }; +// Payload to create an appointment +export type AppointmentCreate = { + patient_id: string; + doctor_id: string; + scheduled_at: string; // ISO date-time + duration_minutes?: number; + appointment_type?: 'presencial' | 'telemedicina' | string; + chief_complaint?: string | null; + patient_notes?: string | null; + insurance_provider?: string | null; +}; + +/** + * Chama a Function `/functions/v1/get-available-slots` para obter os slots disponíveis de um médico + */ +export async function getAvailableSlots(input: { doctor_id: string; start_date: string; end_date: string; appointment_type?: string }): Promise<{ slots: Array<{ datetime: string; available: boolean }> }> { + if (!input || !input.doctor_id || !input.start_date || !input.end_date) { + throw new Error('Parâmetros inválidos. É necessário doctor_id, start_date e end_date.'); + } + + const url = `${API_BASE}/functions/v1/get-available-slots`; + try { + const res = await fetch(url, { + method: 'POST', + headers: { ...baseHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ doctor_id: input.doctor_id, start_date: input.start_date, end_date: input.end_date, appointment_type: input.appointment_type ?? 'presencial' }), + }); + + // Do not short-circuit; let parse() produce friendly errors + const parsed = await parse<{ slots: Array<{ datetime: string; available: boolean }> }>(res); + // Ensure consistent return shape + if (!parsed || !Array.isArray((parsed as any).slots)) return { slots: [] }; + return parsed as { slots: Array<{ datetime: string; available: boolean }> }; + } catch (err) { + console.error('[getAvailableSlots] erro ao buscar horários disponíveis', err); + throw err; + } +} + +/** + * Cria um agendamento (POST /rest/v1/appointments) verificando disponibilidade previamente + */ +export async function criarAgendamento(input: AppointmentCreate): Promise { + if (!input || !input.patient_id || !input.doctor_id || !input.scheduled_at) { + throw new Error('Parâmetros inválidos para criar agendamento. patient_id, doctor_id e scheduled_at são obrigatórios.'); + } + + // Normalize scheduled_at to ISO + const scheduledDate = new Date(input.scheduled_at); + if (isNaN(scheduledDate.getTime())) throw new Error('scheduled_at inválido'); + + // Build day range for availability check (start of day to end of day of scheduled date) + const startDay = new Date(scheduledDate); + startDay.setHours(0, 0, 0, 0); + const endDay = new Date(scheduledDate); + endDay.setHours(23, 59, 59, 999); + + // Query availability + const av = await getAvailableSlots({ doctor_id: input.doctor_id, start_date: startDay.toISOString(), end_date: endDay.toISOString(), appointment_type: input.appointment_type }); + const scheduledMs = scheduledDate.getTime(); + + const matching = (av.slots || []).find((s) => { + try { + const dt = new Date(s.datetime).getTime(); + // allow small tolerance (<= 60s) to account for formatting/timezone differences + return s.available && Math.abs(dt - scheduledMs) <= 60_000; + } catch (e) { + return false; + } + }); + + if (!matching) { + throw new Error('Horário não disponível para o médico no horário solicitado. Verifique a disponibilidade antes de agendar.'); + } + + // Determine created_by similar to other creators (prefer localStorage then 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 + } + } + if (!createdBy) { + try { + const info = await getUserInfo(); + createdBy = info?.user?.id ?? null; + } catch (e) { + // ignore + } + } + + const payload: any = { + patient_id: input.patient_id, + doctor_id: input.doctor_id, + scheduled_at: new Date(scheduledDate).toISOString(), + duration_minutes: input.duration_minutes ?? 30, + appointment_type: input.appointment_type ?? 'presencial', + chief_complaint: input.chief_complaint ?? null, + patient_notes: input.patient_notes ?? null, + insurance_provider: input.insurance_provider ?? null, + }; + if (createdBy) payload.created_by = createdBy; + + const url = `${REST}/appointments`; + const res = await fetch(url, { + method: 'POST', + headers: withPrefer({ ...baseHeaders(), 'Content-Type': 'application/json' }, 'return=representation'), + body: JSON.stringify(payload), + }); + + const created = await parse(res); + return created; +} + // Payload for updating an appointment (PATCH /rest/v1/appointments/{id}) export type AppointmentUpdate = Partial<{ scheduled_at: string; @@ -1159,6 +1279,36 @@ export async function buscarPacientesPorIds(ids: Array): Promis return unique; } +/** + * Busca pacientes atribuídos a um médico (usando o user_id do médico para consultar patient_assignments) + * - Primeiro busca o médico para obter o campo user_id + * - Consulta a tabela patient_assignments para obter patient_id vinculados ao user_id + * - Retorna os pacientes via buscarPacientesPorIds + */ +export async function buscarPacientesPorMedico(doctorId: string): Promise { + if (!doctorId) return []; + try { + // buscar médico para obter user_id + const medico = await buscarMedicoPorId(doctorId).catch(() => null); + const userId = medico?.user_id ?? medico?.created_by ?? null; + if (!userId) { + // se não houver user_id, não há uma forma confiável de mapear atribuições + return []; + } + + // buscar atribuições para esse user_id + const url = `${REST}/patient_assignments?user_id=eq.${encodeURIComponent(String(userId))}&select=patient_id`; + const res = await fetch(url, { method: 'GET', headers: baseHeaders() }); + const rows = await parse>(res).catch(() => []); + const ids = (rows || []).map((r) => r.patient_id).filter(Boolean) as string[]; + if (!ids.length) return []; + return await buscarPacientesPorIds(ids); + } catch (err) { + console.warn('[buscarPacientesPorMedico] falha ao obter pacientes do médico', doctorId, err); + return []; + } +} + export async function criarPaciente(input: PacienteInput): Promise { const url = `${ENV_CONFIG.SUPABASE_URL}/rest/v1/patients`; const res = await fetch(url, {