diff --git a/susconecta/app/(main-routes)/calendar/page.tsx b/susconecta/app/(main-routes)/calendar/page.tsx index b5a7947..f1ce1b3 100644 --- a/susconecta/app/(main-routes)/calendar/page.tsx +++ b/susconecta/app/(main-routes)/calendar/page.tsx @@ -11,10 +11,7 @@ import { EventInput } from "@fullcalendar/core/index.js"; import { Sidebar } from "@/components/dashboard/sidebar"; import { PagesHeader } from "@/components/dashboard/header"; import { Button } from "@/components/ui/button"; -import { - mockAppointments, - mockWaitingList, -} from "@/lib/mocks/appointment-mocks"; +import { mockWaitingList } from "@/lib/mocks/appointment-mocks"; import "./index.css"; import Link from "next/link"; import { @@ -30,7 +27,7 @@ const ListaEspera = dynamic( ); export default function AgendamentoPage() { - const [appointments, setAppointments] = useState(mockAppointments); + const [appointments, setAppointments] = useState([]); const [waitingList, setWaitingList] = useState(mockWaitingList); const [activeTab, setActiveTab] = useState<"calendar" | "espera">("calendar"); const [requestsList, setRequestsList] = useState(); @@ -47,23 +44,51 @@ export default function AgendamentoPage() { }, []); useEffect(() => { - let events: EventInput[] = []; - appointments.forEach((obj) => { - const event: EventInput = { - title: `${obj.patient}: ${obj.type}`, - start: new Date(obj.time), - end: new Date(new Date(obj.time).getTime() + obj.duration * 60 * 1000), - color: - obj.status === "confirmed" - ? "#68d68a" - : obj.status === "pending" - ? "#ffe55f" - : "#ff5f5fff", - }; - events.push(event); - }); - setRequestsList(events); - }, [appointments]); + // Fetch real appointments and map to calendar events + let mounted = true; + (async () => { + try { + // listarAgendamentos accepts a query string; request a reasonable limit and order + const arr = await (await import('@/lib/api')).listarAgendamentos('select=*&order=scheduled_at.desc&limit=500').catch(() => []); + if (!mounted) return; + if (!arr || !arr.length) { + setAppointments([]); + setRequestsList([]); + return; + } + + // Batch-fetch patient names for display + const patientIds = Array.from(new Set(arr.map((a: any) => a.patient_id).filter(Boolean))); + const patients = (patientIds && patientIds.length) ? await (await import('@/lib/api')).buscarPacientesPorIds(patientIds) : []; + const patientsById: Record = {}; + (patients || []).forEach((p: any) => { if (p && p.id) patientsById[String(p.id)] = p; }); + + setAppointments(arr || []); + + const events: EventInput[] = (arr || []).map((obj: any) => { + const scheduled = obj.scheduled_at || obj.scheduledAt || obj.time || null; + const start = scheduled ? new Date(scheduled) : null; + const duration = Number(obj.duration_minutes ?? obj.duration ?? 30) || 30; + const patient = (patientsById[String(obj.patient_id)]?.full_name) || obj.patient_name || obj.patient_full_name || obj.patient || 'Paciente'; + const title = `${patient}: ${obj.appointment_type ?? obj.type ?? ''}`.trim(); + const color = obj.status === 'confirmed' ? '#68d68a' : obj.status === 'pending' ? '#ffe55f' : '#ff5f5fff'; + return { + title, + start: start || new Date(), + end: start ? new Date(start.getTime() + duration * 60 * 1000) : undefined, + color, + extendedProps: { raw: obj }, + } as EventInput; + }); + setRequestsList(events || []); + } catch (err) { + console.warn('[AgendamentoPage] falha ao carregar agendamentos', err); + setAppointments([]); + setRequestsList([]); + } + })(); + return () => { mounted = false; }; + }, []); // mantive para caso a lógica de salvar consulta passe a funcionar const handleSaveAppointment = (appointment: any) => { diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx index 47365d8..29462ac 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 { useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { MoreHorizontal, PlusCircle, @@ -10,6 +10,7 @@ import { Edit, Trash2, ArrowLeft, + Loader2, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -53,10 +54,10 @@ import { SelectValue, } from "@/components/ui/select"; -import { mockAppointments, mockProfessionals } from "@/lib/mocks/appointment-mocks"; +import { mockProfessionals } from "@/lib/mocks/appointment-mocks"; +import { listarAgendamentos, buscarPacientesPorIds, buscarMedicosPorIds, atualizarAgendamento, buscarAgendamentoPorId, deletarAgendamento } from "@/lib/api"; import { CalendarRegistrationForm } from "@/components/forms/calendar-registration-form"; - const formatDate = (date: string | Date) => { if (!date) return ""; return new Date(date).toLocaleDateString("pt-BR", { @@ -69,43 +70,81 @@ const formatDate = (date: string | Date) => { }; const capitalize = (s: string) => { - if (typeof s !== 'string' || s.length === 0) return ''; - return s.charAt(0).toUpperCase() + s.slice(1); + if (typeof s !== "string" || s.length === 0) return ""; + return s.charAt(0).toUpperCase() + s.slice(1); }; export default function ConsultasPage() { - const [appointments, setAppointments] = useState(mockAppointments); + const [appointments, setAppointments] = useState([]); + const [originalAppointments, setOriginalAppointments] = useState([]); + const [searchValue, setSearchValue] = useState(''); + const [isLoading, setIsLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingAppointment, setEditingAppointment] = useState(null); const [viewingAppointment, setViewingAppointment] = useState(null); + // Local form state used when editing. Keep hook at top-level to avoid Hooks order changes. + const [localForm, setLocalForm] = useState(null); const mapAppointmentToFormData = (appointment: any) => { - const professional = mockProfessionals.find(p => p.id === appointment.professional); - const appointmentDate = new Date(appointment.time); - + // prefer scheduled_at (ISO) if available + const scheduledBase = appointment.scheduled_at || appointment.time || appointment.created_at || null; + const baseDate = scheduledBase ? new Date(scheduledBase) : new Date(); + const duration = appointment.duration_minutes ?? appointment.duration ?? 30; + + // compute start and end times (HH:MM) + const appointmentDateStr = baseDate.toISOString().split("T")[0]; + const startTime = `${String(baseDate.getHours()).padStart(2, '0')}:${String(baseDate.getMinutes()).padStart(2, '0')}`; + const endDate = new Date(baseDate.getTime() + duration * 60000); + const endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`; + return { - id: appointment.id, - patientName: appointment.patient, - professionalName: professional ? professional.name : '', - appointmentDate: appointmentDate.toISOString().split('T')[0], - startTime: appointmentDate.toTimeString().split(' ')[0].substring(0, 5), - endTime: new Date(appointmentDate.getTime() + appointment.duration * 60000).toTimeString().split(' ')[0].substring(0, 5), - status: appointment.status, - appointmentType: appointment.type, - notes: appointment.notes, - cpf: '', - rg: '', - birthDate: '', - phoneCode: '+55', - phoneNumber: '', - email: '', - unit: 'nei', + id: appointment.id, + patientName: appointment.patient, + patientId: appointment.patient_id || appointment.patientId || null, + professionalName: appointment.professional || "", + appointmentDate: appointmentDateStr, + startTime, + endTime, + status: appointment.status, + appointmentType: appointment.appointment_type || appointment.type, + notes: appointment.notes || appointment.patient_notes || "", + cpf: "", + rg: "", + birthDate: "", + phoneCode: "+55", + phoneNumber: "", + email: "", + unit: "nei", + // API-editable fields (populate so the form shows existing values) + duration_minutes: duration, + chief_complaint: appointment.chief_complaint ?? null, + patient_notes: appointment.patient_notes ?? null, + insurance_provider: appointment.insurance_provider ?? null, + checked_in_at: appointment.checked_in_at ?? null, + completed_at: appointment.completed_at ?? null, + cancelled_at: appointment.cancelled_at ?? null, + cancellation_reason: appointment.cancellation_reason ?? appointment.cancellationReason ?? "", }; }; - const handleDelete = (appointmentId: string) => { - if (window.confirm("Tem certeza que deseja excluir esta consulta?")) { + const handleDelete = async (appointmentId: string) => { + if (!window.confirm("Tem certeza que deseja excluir esta consulta?")) return; + try { + // call server DELETE + await deletarAgendamento(appointmentId); + // remove from UI setAppointments((prev) => prev.filter((a) => a.id !== appointmentId)); + // also update originalAppointments cache + setOriginalAppointments((prev) => (prev || []).filter((a) => a.id !== appointmentId)); + alert('Agendamento excluído com sucesso.'); + } catch (err) { + console.error('[ConsultasPage] Falha ao excluir agendamento', err); + try { + const msg = err instanceof Error ? err.message : String(err); + alert('Falha ao excluir agendamento: ' + msg); + } catch (e) { + // ignore + } } }; @@ -114,7 +153,7 @@ export default function ConsultasPage() { setEditingAppointment(formData); setShowForm(true); }; - + const handleView = (appointment: any) => { setViewingAppointment(appointment); }; @@ -122,43 +161,258 @@ export default function ConsultasPage() { const handleCancel = () => { setEditingAppointment(null); setShowForm(false); + setLocalForm(null); }; - const handleSave = (formData: any) => { - - const updatedAppointment = { - id: formData.id, - patient: formData.patientName, - time: new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(), - duration: 30, - type: formData.appointmentType as any, - status: formData.status as any, - professional: appointments.find(a => a.id === formData.id)?.professional || '', - notes: formData.notes, - }; + const handleSave = async (formData: any) => { + try { + // build scheduled_at ISO (formData.startTime is 'HH:MM') + const scheduled_at = new Date(`${formData.appointmentDate}T${formData.startTime}`).toISOString(); - setAppointments(prev => - prev.map(a => a.id === updatedAppointment.id ? updatedAppointment : a) - ); - handleCancel(); + // compute duration from start/end times when available + let duration_minutes = 30; + try { + if (formData.startTime && formData.endTime) { + const [sh, sm] = String(formData.startTime).split(":").map((n: string) => Number(n)); + const [eh, em] = String(formData.endTime).split(":").map((n: string) => Number(n)); + const start = (sh || 0) * 60 + (sm || 0); + const end = (eh || 0) * 60 + (em || 0); + if (!Number.isNaN(start) && !Number.isNaN(end) && end > start) duration_minutes = end - start; + } + } catch (e) { + // fallback to default + duration_minutes = 30; + } + + const payload: any = { + scheduled_at, + duration_minutes, + status: formData.status || undefined, + notes: formData.notes ?? null, + chief_complaint: formData.chief_complaint ?? null, + patient_notes: formData.patient_notes ?? null, + insurance_provider: formData.insurance_provider ?? null, + // convert local datetime-local inputs (which may be in 'YYYY-MM-DDTHH:MM' format) to proper ISO if present + checked_in_at: formData.checked_in_at ? new Date(formData.checked_in_at).toISOString() : null, + completed_at: formData.completed_at ? new Date(formData.completed_at).toISOString() : null, + cancelled_at: formData.cancelled_at ? new Date(formData.cancelled_at).toISOString() : null, + cancellation_reason: formData.cancellation_reason ?? null, + }; + + // Call PATCH endpoint + const updated = await atualizarAgendamento(formData.id, payload); + + // Build UI-friendly row using server response and existing local fields + const existing = appointments.find((a) => a.id === formData.id) || {}; + const mapped = { + id: updated.id, + patient: formData.patientName || existing.patient || '', + patient_id: existing.patient_id ?? null, + // preserve server-side fields so future edits read them + scheduled_at: updated.scheduled_at ?? scheduled_at, + duration_minutes: updated.duration_minutes ?? duration_minutes, + appointment_type: updated.appointment_type ?? formData.appointmentType ?? existing.type ?? 'presencial', + status: updated.status ?? formData.status ?? existing.status, + professional: existing.professional || formData.professionalName || '', + notes: updated.notes ?? updated.patient_notes ?? formData.notes ?? existing.notes ?? '', + chief_complaint: updated.chief_complaint ?? formData.chief_complaint ?? existing.chief_complaint ?? null, + patient_notes: updated.patient_notes ?? formData.patient_notes ?? existing.patient_notes ?? null, + insurance_provider: updated.insurance_provider ?? formData.insurance_provider ?? existing.insurance_provider ?? null, + checked_in_at: updated.checked_in_at ?? formData.checked_in_at ?? existing.checked_in_at ?? null, + completed_at: updated.completed_at ?? formData.completed_at ?? existing.completed_at ?? null, + cancelled_at: updated.cancelled_at ?? formData.cancelled_at ?? existing.cancelled_at ?? null, + cancellation_reason: updated.cancellation_reason ?? formData.cancellation_reason ?? existing.cancellation_reason ?? null, + }; + + setAppointments((prev) => prev.map((a) => (a.id === mapped.id ? mapped : a))); + handleCancel(); + } catch (err) { + console.error('[ConsultasPage] Falha ao atualizar agendamento', err); + // Inform the user + try { + const msg = err instanceof Error ? err.message : String(err); + alert('Falha ao salvar alterações: ' + msg); + } catch (e) { + // ignore + } + } }; - if (showForm && editingAppointment) { + // Fetch and map appointments (used at load and when clearing search) + const fetchAndMapAppointments = async () => { + const arr = await listarAgendamentos("select=*&order=scheduled_at.desc&limit=200"); + + // Collect unique patient_ids and doctor_ids + const patientIds = new Set(); + const doctorIds = new Set(); + for (const a of arr || []) { + if (a.patient_id) patientIds.add(String(a.patient_id)); + if (a.doctor_id) doctorIds.add(String(a.doctor_id)); + } + + // Batch fetch patients and doctors + const patientsMap = new Map(); + const doctorsMap = new Map(); + + try { + if (patientIds.size) { + const list = await buscarPacientesPorIds(Array.from(patientIds)); + for (const p of list || []) patientsMap.set(String(p.id), p); + } + } catch (e) { + console.warn("[ConsultasPage] Falha ao buscar pacientes em lote", e); + } + + try { + if (doctorIds.size) { + const list = await buscarMedicosPorIds(Array.from(doctorIds)); + for (const d of list || []) doctorsMap.set(String(d.id), d); + } + } catch (e) { + console.warn("[ConsultasPage] Falha ao buscar médicos em lote", e); + } + + // Map appointments using the maps + const mapped = (arr || []).map((a: any) => { + const patient = a.patient_id ? patientsMap.get(String(a.patient_id))?.full_name || String(a.patient_id) : ""; + const professional = a.doctor_id ? doctorsMap.get(String(a.doctor_id))?.full_name || String(a.doctor_id) : ""; + return { + id: a.id, + patient, + patient_id: a.patient_id, + // keep some server-side fields so edit can access them later + scheduled_at: a.scheduled_at ?? a.time ?? a.created_at ?? null, + duration_minutes: a.duration_minutes ?? a.duration ?? null, + appointment_type: a.appointment_type ?? a.type ?? null, + status: a.status ?? "requested", + professional, + notes: a.notes || a.patient_notes || "", + // additional editable fields + chief_complaint: a.chief_complaint ?? null, + patient_notes: a.patient_notes ?? null, + insurance_provider: a.insurance_provider ?? null, + checked_in_at: a.checked_in_at ?? null, + completed_at: a.completed_at ?? null, + cancelled_at: a.cancelled_at ?? null, + cancellation_reason: a.cancellation_reason ?? a.cancellationReason ?? null, + }; + }); + + return mapped; + }; + + useEffect(() => { + let mounted = true; + (async () => { + try { + const mapped = await fetchAndMapAppointments(); + if (!mounted) return; + setAppointments(mapped); + setOriginalAppointments(mapped || []); + setIsLoading(false); + } catch (err) { + console.warn("[ConsultasPage] Falha ao carregar agendamentos, usando mocks", err); + if (!mounted) return; + setAppointments([]); + setIsLoading(false); + } + })(); + return () => { mounted = false; }; + }, []); + + // Search box: allow fetching a single appointment by ID when pressing Enter + // Perform a local-only search against the already-loaded appointments. + // This intentionally does not call the server — it filters the cached list. + const performSearch = (val: string) => { + const trimmed = String(val || '').trim(); + if (!trimmed) { + setAppointments(originalAppointments || []); + return; + } + + const q = trimmed.toLowerCase(); + const localMatches = (originalAppointments || []).filter((a) => { + const patient = String(a.patient || '').toLowerCase(); + const professional = String(a.professional || '').toLowerCase(); + const pid = String(a.patient_id || '').toLowerCase(); + const aid = String(a.id || '').toLowerCase(); + return ( + patient.includes(q) || + professional.includes(q) || + pid === q || + aid === q + ); + }); + + setAppointments(localMatches as any[]); + }; + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + // keep behavior consistent: perform a local filter immediately + performSearch(searchValue); + } else if (e.key === 'Escape') { + setSearchValue(''); + setAppointments(originalAppointments || []); + } + }; + + const handleClearSearch = async () => { + setSearchValue(''); + setIsLoading(true); + try { + // Reset to the original cached list without refetching from server + setAppointments(originalAppointments || []); + } catch (err) { + setAppointments([]); + } finally { + setIsLoading(false); + } + }; + + // Debounce live filtering as the user types. Operates only on the cached originalAppointments. + useEffect(() => { + const t = setTimeout(() => { + performSearch(searchValue); + }, 250); + return () => clearTimeout(t); + }, [searchValue, originalAppointments]); + + // Keep localForm synchronized with editingAppointment + useEffect(() => { + if (showForm && editingAppointment) { + setLocalForm(editingAppointment); + } + if (!showForm) setLocalForm(null); + }, [showForm, editingAppointment]); + + const onFormChange = (d: any) => setLocalForm(d); + + const saveLocal = async () => { + if (!localForm) return; + await handleSave(localForm); + }; + + // If editing, render the edit form as a focused view (keeps hooks stable) + if (showForm && localForm) { return ( -
-
- -

Editar Consulta

-
- +
+
+ +

Editar Consulta

- ) + +
+ + +
+
+ ); } return ( @@ -169,12 +423,11 @@ export default function ConsultasPage() {

Visualize, filtre e gerencie todas as consultas da clínica.

- -
@@ -183,17 +436,20 @@ export default function ConsultasPage() { Consultas Agendadas - - Visualize, filtre e gerencie todas as consultas da clínica. - + Visualize, filtre e gerencie todas as consultas da clínica.
-
- - +
+
+ + setSearchValue(e.target.value)} + onKeyDown={handleSearchKeyDown} + /> +
always has options on open + useEffect(() => { + let mounted = true; + (async () => { + setLoadingPatients(true); + try { + // request a reasonable number of patients for the select; adjust limit if needed + const pats = await listarPacientes({ limit: 200 }); + if (!mounted) return; + setPatientOptions(pats || []); + } catch (err) { + console.warn('[CalendarRegistrationForm] falha ao carregar pacientes', err); + if (!mounted) return; + setPatientOptions([]); + } finally { + if (!mounted) return; + setLoadingPatients(false); + } + })(); + return () => { mounted = false; }; + }, []); + + // When a patient is selected, filter doctorOptions to only doctors assigned to that patient + useEffect(() => { + const patientId = (formData as any).patientId || (formData as any).patient_id || null; + if (!patientId) { + // clear filter when no patient selected + setFilteredDoctorOptions(null); + setLoadingAssignedDoctors(false); + return; + } + + let mounted = true; + setLoadingAssignedDoctors(true); + (async () => { + try { + // listAssignmentsForPatient returns rows with user_id (the auth user assigned) + const assignments = await listAssignmentsForPatient(String(patientId)); + if (!mounted) return; + const userIds = Array.from(new Set((assignments || []).map((a: any) => a.user_id).filter(Boolean))); + + if (!userIds.length) { + // no assignments -> fallback to full list but keep a non-null marker + setFilteredDoctorOptions([]); + return; + } + + // Filter already-loaded doctorOptions by matching user_id + // If doctorOptions isn't loaded yet, we'll wait for it (doctorOptions effect will run first on mount) + const matched = (doctorOptions || []).filter((d) => userIds.includes(String(d.user_id))); + if (mounted) setFilteredDoctorOptions(matched); + } catch (err) { + console.warn('[CalendarRegistrationForm] falha ao carregar médicos atribuídos ao paciente', err); + if (mounted) setFilteredDoctorOptions([]); + } finally { + if (mounted) setLoadingAssignedDoctors(false); + } + })(); + + return () => { mounted = false; }; + }, [(formData as any).patientId, (formData as any).patient_id, doctorOptions]); + + // When doctorId changes, load patients assigned to that doctor + useEffect(() => { + const docId = (formData as any).doctorId || (formData as any).doctor_id || null; + if (!docId) { + setPatientOptions([]); + return; + } + let mounted = true; + setLoadingPatientsForDoctor(true); + (async () => { + try { + const pats = await buscarPacientesPorMedico(String(docId)); + if (!mounted) return; + setPatientOptions(pats || []); + } catch (e) { + console.warn('[CalendarRegistrationForm] falha ao carregar pacientes por médico', e); + if (!mounted) return; + setPatientOptions([]); + } finally { + if (!mounted) return; + setLoadingPatientsForDoctor(false); + } + })(); + return () => { mounted = false; }; + }, [(formData as any).doctorId, (formData as any).doctor_id]); + + // When doctor or date changes, fetch available slots + useEffect(() => { + const docId = (formData as any).doctorId || (formData as any).doctor_id || null; + const date = (formData as any).appointmentDate || null; + if (!docId || !date) { + setAvailableSlots([]); + return; + } + let mounted = true; + setLoadingSlots(true); + (async () => { + try { + 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)); + // Build start/end as local day bounds from YYYY-MM-DD to avoid + // timezone/parsing issues (sending incorrect UTC offsets that shift + // the requested day to the previous/next calendar day). + // Expect `date` to be in format 'YYYY-MM-DD'. Parse explicitly. + let start: Date; + let end: Date; + try { + const parts = String(date).split('-').map((p) => Number(p)); + if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) { + const [y, m, d] = parts; + // new Date(y, m-1, d, hh, mm, ss, ms) constructs a date in the + // local timezone at the requested hour. toISOString() will then + // represent that local instant in UTC which is what the server + // expects for availability checks across timezones. + start = new Date(y, m - 1, d, 0, 0, 0, 0); + end = new Date(y, m - 1, d, 23, 59, 59, 999); + } else { + // fallback to previous logic if parsing fails + start = new Date(date); + start.setHours(0,0,0,0); + end = new Date(date); + end.setHours(23,59,59,999); + } + } catch (err) { + // fallback safe behavior + start = new Date(date); + start.setHours(0,0,0,0); + end = new Date(date); + end.setHours(23,59,59,999); + } + const av = await getAvailableSlots({ doctor_id: String(docId), start_date: start.toISOString(), end_date: end.toISOString(), appointment_type: formData.appointmentType || 'presencial' }); + if (!mounted) return; + console.debug('[CalendarRegistrationForm] getAvailableSlots - response slots count', (av && av.slots && av.slots.length) || 0, av); + + // Try to restrict the returned slots to the doctor's public availability windows + try { + const disponibilidades = await listarDisponibilidades({ doctorId: String(docId) }).catch(() => []); + const weekdayNumber = start.getDay(); // 0 (Sun) .. 6 (Sat) + // map weekday number to possible representations (numeric, en, pt, abbrev) + const weekdayNames: Record = { + 0: ['0', 'sun', 'sunday', 'domingo'], + 1: ['1', 'mon', 'monday', 'segunda', 'segunda-feira'], + 2: ['2', 'tue', 'tuesday', 'terca', 'terça', 'terça-feira'], + 3: ['3', 'wed', 'wednesday', 'quarta', 'quarta-feira'], + 4: ['4', 'thu', 'thursday', 'quinta', 'quinta-feira'], + 5: ['5', 'fri', 'friday', 'sexta', 'sexta-feira'], + 6: ['6', 'sat', 'saturday', 'sabado', 'sábado'] + }; + const allowed = (weekdayNames[weekdayNumber] || []).map((s) => String(s).toLowerCase()); + + // Filter disponibilidades to those matching the weekday (try multiple fields) + const matched = (disponibilidades || []).filter((d: any) => { + try { + const raw = String(d.weekday ?? d.weekday_name ?? d.day ?? d.day_of_week ?? '').toLowerCase(); + if (!raw) return false; + // direct numeric or name match + if (allowed.includes(raw)) return true; + // sometimes API returns numbers as integers + if (typeof d.weekday === 'number' && d.weekday === weekdayNumber) return true; + if (typeof d.day_of_week === 'number' && d.day_of_week === weekdayNumber) return true; + return false; + } catch (e) { return false; } + }); + console.debug('[CalendarRegistrationForm] disponibilidades fetched', disponibilidades, 'matched for weekday', weekdayNumber, matched); + + if (matched && matched.length) { + // Build windows from matched disponibilidades and filter av.slots + const windows = matched.map((d: any) => { + // d.start_time may be '09:00:00' or '09:00' + const parseTime = (t?: string) => { + if (!t) return { hh: 0, mm: 0, ss: 0 }; + const parts = String(t).split(':').map((p) => Number(p)); + return { hh: parts[0] || 0, mm: parts[1] || 0, ss: parts[2] || 0 }; + }; + const s = parseTime(d.start_time); + const e2 = parseTime(d.end_time); + const winStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), s.hh, s.mm, s.ss || 0, 0); + const winEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), e2.hh, e2.mm, e2.ss || 0, 999); + const slotMinutes = Number(d.slot_minutes || d.slot_minutes_minutes || null) || null; + return { winStart, winEnd, slotMinutes }; + }); + + // If any disponibilidade declares slot_minutes, prefill duration_minutes on the form + try { + const candidate = windows.find((w: any) => w.slotMinutes && Number.isFinite(Number(w.slotMinutes))); + if (candidate) { + const durationVal = Number(candidate.slotMinutes); + // Only set if different to avoid unnecessary updates + if ((formData as any).duration_minutes !== durationVal) { + onFormChange({ ...formData, duration_minutes: durationVal }); + } + try { setLockedDurationFromSlot(true); } catch (e) {} + } else { + // no slot_minutes declared -> ensure unlocked + try { setLockedDurationFromSlot(false); } catch (e) {} + } + } catch (e) { + console.debug('[CalendarRegistrationForm] erro ao definir duração automática', e); + } + + // Keep backend slots that fall inside windows + const existingInWindow = (av.slots || []).filter((s: any) => { + try { + const sd = new Date(s.datetime); + const slotMinutes = sd.getHours() * 60 + sd.getMinutes(); + return windows.some((w: any) => { + const ws = w.winStart; + const we = w.winEnd; + const winStartMinutes = ws.getHours() * 60 + ws.getMinutes(); + const winEndMinutes = we.getHours() * 60 + we.getMinutes(); + return slotMinutes >= winStartMinutes && slotMinutes <= winEndMinutes; + }); + } catch (e) { return false; } + }); + + // Determine global step (minutes) from returned slots, fallback to 30 + let stepMinutes = 30; + try { + const times = (av.slots || []).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b); + const diffs: number[] = []; + for (let i = 1; i < times.length; i++) { + const d = Math.round((times[i] - times[i - 1]) / 60000); + if (d > 0) diffs.push(d); + } + if (diffs.length) { + stepMinutes = Math.min(...diffs); + } + } catch (e) { + // keep fallback + } + + // Generate missing slots per window respecting slot_minutes (if present). + const generatedSet = new Set(); + windows.forEach((w: any) => { + try { + const perWindowStep = Number(w.slotMinutes) || stepMinutes; + const startMs = w.winStart.getTime(); + const endMs = w.winEnd.getTime(); + // compute last allowed slot start so that start + perWindowStep <= winEnd + const lastStartMs = endMs - perWindowStep * 60000; + + // backend slots inside this window (ms) + const backendSlotsInWindow = (av.slots || []).filter((s: any) => { + try { + const sd = new Date(s.datetime); + const sm = sd.getHours() * 60 + sd.getMinutes(); + const wmStart = w.winStart.getHours() * 60 + w.winStart.getMinutes(); + const wmEnd = w.winEnd.getHours() * 60 + w.winEnd.getMinutes(); + return sm >= wmStart && sm <= wmEnd; + } catch (e) { return false; } + }).map((s: any) => new Date(s.datetime).getTime()).sort((a: number, b: number) => a - b); + + if (!backendSlotsInWindow.length) { + // generate full window from winStart to lastStartMs + let cursorMs = startMs; + while (cursorMs <= lastStartMs) { + generatedSet.add(new Date(cursorMs).toISOString()); + cursorMs += perWindowStep * 60000; + } + } else { + // generate after last backend slot up to lastStartMs + const lastBackendMs = backendSlotsInWindow[backendSlotsInWindow.length - 1]; + let cursorMs = lastBackendMs + perWindowStep * 60000; + while (cursorMs <= lastStartMs) { + generatedSet.add(new Date(cursorMs).toISOString()); + cursorMs += perWindowStep * 60000; + } + } + } catch (e) { + // skip malformed window + } + }); + + // Merge existingInWindow (prefer backend objects) with generated ones + const mergedMap = new Map(); + // helper to find window slotMinutes for a given ISO datetime + const findWindowSlotMinutes = (isoDt: string) => { + try { + const sd = new Date(isoDt); + const sm = sd.getHours() * 60 + sd.getMinutes(); + const w = windows.find((win: any) => { + const ws = win.winStart; + const we = win.winEnd; + const winStartMinutes = ws.getHours() * 60 + ws.getMinutes(); + const winEndMinutes = we.getHours() * 60 + we.getMinutes(); + return sm >= winStartMinutes && sm <= winEndMinutes; + }); + return w && w.slotMinutes ? Number(w.slotMinutes) : null; + } catch (e) { return null; } + }; + + (existingInWindow || []).forEach((s: any) => { + const sm = findWindowSlotMinutes(s.datetime); + mergedMap.set(s.datetime, sm ? { ...s, slot_minutes: sm } : { ...s }); + }); + Array.from(generatedSet).forEach((dt) => { + if (!mergedMap.has(dt)) { + const sm = findWindowSlotMinutes(dt) || stepMinutes; + mergedMap.set(dt, { datetime: dt, available: true, slot_minutes: sm }); + } + }); + + const merged = Array.from(mergedMap.values()).sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()); + console.debug('[CalendarRegistrationForm] slots after merge/generated count', merged.length, 'stepMinutes', stepMinutes); + setAvailableSlots(merged || []); + } else { + // No disponibilidade entries for this weekday -> use av.slots as-is + setAvailableSlots(av.slots || []); + } + } catch (e) { + console.warn('[CalendarRegistrationForm] erro ao filtrar por disponibilidades públicas', e); + setAvailableSlots(av.slots || []); + } + } catch (e) { + console.warn('[CalendarRegistrationForm] falha ao carregar horários disponíveis', e); + if (!mounted) return; + setAvailableSlots([]); + } finally { + if (!mounted) return; + setLoadingSlots(false); + } + })(); + return () => { mounted = false; }; + }, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate, (formData as any).appointmentType]); const handleChange = (event: React.ChangeEvent) => { const { name, value } = event.target; - if (name === 'validade') { - const formattedValue = formatValidityDate(value); - onFormChange({ ...formData, [name]: formattedValue }); - } else { - onFormChange({ ...formData, [name]: value }); + // map datetime-local fields to ISO before sending up + if (name === 'checked_in_at' || name === 'completed_at' || name === 'cancelled_at') { + const iso = datetimeLocalToIso(value as string); + onFormChange({ ...formData, [name]: iso }); + return; } + + if (name === 'validade') { + const formattedValue = formatValidityDate(value); + onFormChange({ ...formData, [name]: formattedValue }); + return; + } + + // if selecting a patientId from the select, also populate patientName + if (name === 'patientId') { + // event.target is a select; get the selected option text + const sel = event.target as HTMLSelectElement; + const selectedText = sel.options[sel.selectedIndex]?.text ?? ''; + onFormChange({ ...formData, patientId: value || null, patientName: selectedText }); + return; + } + + // ensure duration is stored as a number + if (name === 'duration_minutes') { + const n = Number(value); + onFormChange({ ...formData, duration_minutes: Number.isNaN(n) ? undefined : n }); + return; + } + + // If user edits endTime manually, accept the value and clear lastAutoEndRef so auto-calc won't overwrite + if (name === 'endTime') { + // store as-is (HH:MM) + try { + // clear auto flag so user edits persist + (lastAutoEndRef as any).current = null; + } catch (e) {} + onFormChange({ ...formData, endTime: value }); + return; + } + + onFormChange({ ...formData, [name]: value }); }; + // Auto-calculate endTime from startTime + duration_minutes + const lastAutoEndRef = useRef(null); + + useEffect(() => { + const start = (formData as any).startTime; + const dur = (formData as any).duration_minutes; + const date = (formData as any).appointmentDate; // YYYY-MM-DD + if (!start) return; + // if duration is not a finite number, don't compute + const minutes = typeof dur === 'number' && Number.isFinite(dur) ? dur : 0; + // build a Date from appointmentDate + startTime; fall back to today if appointmentDate missing + const datePart = date || new Date().toISOString().slice(0, 10); + const [y, m, d] = String(datePart).split('-').map((s) => parseInt(s, 10)); + const [hh, mm] = String(start).split(':').map((s) => parseInt(s, 10)); + if ([y, m, d, hh, mm].some((n) => Number.isNaN(n))) return; + const dt = new Date(y, m - 1, d, hh, mm, 0, 0); + const dt2 = new Date(dt.getTime() + minutes * 60000); + const newEnd = `${String(dt2.getHours()).padStart(2, '0')}:${String(dt2.getMinutes()).padStart(2, '0')}`; + const currentEnd = (formData as any).endTime; + // Only overwrite if endTime is empty or it was the previously auto-calculated value + if (!currentEnd || currentEnd === lastAutoEndRef.current) { + lastAutoEndRef.current = newEnd; + onFormChange({ ...formData, endTime: newEnd }); + } + }, [(formData as any).startTime, (formData as any).duration_minutes, (formData as any).appointmentDate]); + + // derive the doctor options to show based on selected patient (inverse lookup) + const patientSelected = (formData as any).patientId || (formData as any).patient_id; + let effectiveDoctorOptions = doctorOptions || []; + if (patientSelected) { + if (filteredDoctorOptions && filteredDoctorOptions.length) effectiveDoctorOptions = filteredDoctorOptions; + else effectiveDoctorOptions = doctorOptions || []; + } + + return (

Informações do paciente

- -
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
- - -
-
- -
- - -
-
-
-
-
- - -
-
- - -
-
-
-
-
setIsAdditionalInfoOpen(!isAdditionalInfoOpen)} - > -
- - -
-
- {isAdditionalInfoOpen && ( -
+
- - + + {createMode ? ( + + ) : ( + + )}
- )} +
+
+ {loadingPatient ? ( +
Carregando dados do paciente...
+ ) : patientDetails ? ( + patientDetails.error ? ( +
Erro ao carregar paciente: {String(patientDetails.error)}
+ ) : ( +
+
CPF: {patientDetails.cpf || '-'}
+
Telefone: {patientDetails.phone_mobile || patientDetails.telefone || '-'}
+
E-mail: {patientDetails.email || '-'}
+
Data de nascimento: {patientDetails.birth_date || '-'}
+
+ ) + ) : ( +
Paciente não vinculado
+ )} +
Para editar os dados do paciente, acesse a ficha do paciente.
+
@@ -168,80 +651,305 @@ export function CalendarRegistrationForm({ formData, onFormChange }: CalendarReg

Informações do atendimento

-
- -
- - -
-
-
-
- - -
-
- -
- - -
-
-
+
+ +
+ + {createMode ? ( + + ) : ( + + )} +
+
+
+ +
+ + +
+
- + {createMode ? ( + + ) : ( + + )}
- -
-
- -
- - - -
+ {/* + When creating a new appointment from a predefined slot, the end time + is derived from the slot's start + duration and therefore cannot be + edited. We disable/readOnly the input in create mode when either a + slot is selected (startTime corresponds to an availableSlots entry) + or the duration was locked from a slot (lockedDurationFromSlot). + */} + { + try { + const date = (formData as any).appointmentDate || ''; + const time = (formData as any).startTime || ''; + if (!date || !time) return false; + // Check if startTime matches one of the availableSlots (meaning slot-driven) + return (availableSlots || []).some((s) => { + try { + const d = new Date(s.datetime); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const dateOnly = d.toISOString().split('T')[0]; + return dateOnly === date && `${hh}:${mm}` === time; + } catch (e) { return false; } + }); + } catch (e) { return false; } + })()))} + disabled={createMode && (lockedDurationFromSlot || Boolean(((): boolean => { + try { + const date = (formData as any).appointmentDate || ''; + const time = (formData as any).startTime || ''; + if (!date || !time) return false; + return (availableSlots || []).some((s) => { + try { + const d = new Date(s.datetime); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const dateOnly = d.toISOString().split('T')[0]; + return dateOnly === date && `${hh}:${mm}` === time; + } catch (e) { return false; } + }); + } catch (e) { return false; } + })()))} + />
+ {/* 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.
+ )} +
+
+ )}
-
- -
- - -
-
-
- - -
+
+
-
-
- -
- - -
-
-