From 4e0c246e05c6febf8896014ce69e18622e4e55f6 Mon Sep 17 00:00:00 2001 From: Jonas Francisco Date: Sat, 8 Nov 2025 01:08:10 -0300 Subject: [PATCH 1/6] =?UTF-8?q?fix(configuracao):=20exclui=20bot=C3=A3o=20?= =?UTF-8?q?de=20configura=C3=A7=C3=A3o=20que=20n=C3=A3o=20levava=20para=20?= =?UTF-8?q?nenhum=20lugar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- susconecta/components/features/dashboard/header.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/susconecta/components/features/dashboard/header.tsx b/susconecta/components/features/dashboard/header.tsx index c9587cf..5f26a0d 100644 --- a/susconecta/components/features/dashboard/header.tsx +++ b/susconecta/components/features/dashboard/header.tsx @@ -94,11 +94,9 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub }} className="w-full text-left px-4 py-2 text-sm hover:bg-accent cursor-pointer" > - 👤 Perfil - - +
-- 2.47.2 From 68c38dba8837ae2e65e4827082d8bd943746c655 Mon Sep 17 00:00:00 2001 From: Jonas Francisco Date: Sat, 8 Nov 2025 01:22:29 -0300 Subject: [PATCH 2/6] =?UTF-8?q?feat(header):=20mostrar=20foto=20do=20usu?= =?UTF-8?q?=C3=A1rio=20no=20avatar=20do=20topo=20Usa=20profile.foto=5Furl|?= =?UTF-8?q?fotoUrl|avatar=5Furl=20como=20src=20e=20fallback=20com=20inicia?= =?UTF-8?q?is=20geradas=20a=20partir=20do=20nome/e-mail.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- susconecta/app/(main-routes)/perfil/page.tsx | 2 +- susconecta/app/paciente/page.tsx | 2 +- susconecta/app/profissional/page.tsx | 2 +- .../components/features/dashboard/header.tsx | 27 +++++++++++++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/susconecta/app/(main-routes)/perfil/page.tsx b/susconecta/app/(main-routes)/perfil/page.tsx index 0308815..734aa6f 100644 --- a/susconecta/app/(main-routes)/perfil/page.tsx +++ b/susconecta/app/(main-routes)/perfil/page.tsx @@ -296,7 +296,7 @@ export default function PerfilPage() { className="bg-blue-600 hover:bg-blue-700" onClick={handleEditClick} > - ✏️ Editar Perfil + Editar Perfil ) : (
diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx index aadb86d..a8b4890 100644 --- a/susconecta/app/paciente/page.tsx +++ b/susconecta/app/paciente/page.tsx @@ -1562,7 +1562,7 @@ export default function PacientePage() { className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto whitespace-nowrap text-xs sm:text-sm" onClick={() => setIsEditingProfile(true)} > - ✏️ Editar Perfil + Editar Perfil ) : (
diff --git a/susconecta/app/profissional/page.tsx b/susconecta/app/profissional/page.tsx index bb715ad..34afd8a 100644 --- a/susconecta/app/profissional/page.tsx +++ b/susconecta/app/profissional/page.tsx @@ -2780,7 +2780,7 @@ const ProfissionalPage = () => { className="bg-blue-600 hover:bg-blue-700 text-xs sm:text-sm w-full sm:w-auto" onClick={() => setIsEditingProfile(true)} > - ✏️ Editar Perfil + Editar Perfil ) : (
diff --git a/susconecta/components/features/dashboard/header.tsx b/susconecta/components/features/dashboard/header.tsx index 5f26a0d..5b78f26 100644 --- a/susconecta/components/features/dashboard/header.tsx +++ b/susconecta/components/features/dashboard/header.tsx @@ -60,9 +60,32 @@ export function PagesHeader({ title = "", subtitle = "" }: { title?: string, sub className="relative h-8 w-8 rounded-full border-2 border-border hover:border-primary" onClick={() => setDropdownOpen(!dropdownOpen)} > + {/* Mostrar foto do usuário quando disponível; senão, mostrar fallback com iniciais */} - - RA + { + (() => { + const userPhoto = (user as any)?.profile?.foto_url || (user as any)?.profile?.fotoUrl || (user as any)?.profile?.avatar_url + const alt = user?.name || user?.email || 'Usuário' + + const getInitials = (name?: string, email?: string) => { + if (name) { + const parts = name.trim().split(/\s+/) + const first = parts[0]?.charAt(0) ?? '' + const second = parts[1]?.charAt(0) ?? '' + return (first + second).toUpperCase() || (email?.charAt(0) ?? 'U').toUpperCase() + } + if (email) return email.charAt(0).toUpperCase() + return 'U' + } + + return ( + <> + + {getInitials(user?.name, user?.email)} + + ) + })() + } -- 2.47.2 From ca282e721e5d53ff6c36916479286fccadbe1964 Mon Sep 17 00:00:00 2001 From: Jonas Francisco Date: Sat, 8 Nov 2025 01:36:44 -0300 Subject: [PATCH 3/6] fix(consulta) corrigir o erro na parte de cancelar consulta --- susconecta/app/paciente/page.tsx | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx index a8b4890..d42f6c9 100644 --- a/susconecta/app/paciente/page.tsx +++ b/susconecta/app/paciente/page.tsx @@ -867,17 +867,33 @@ export default function PacientePage() { try { const ok = typeof window !== 'undefined' ? window.confirm('Deseja realmente cancelar esta consulta?') : true if (!ok) return - // call API to delete - await deletarAgendamento(consulta.id) - // Mark as deleted in cache so it won't appear again - addDeletedAppointmentId(consulta.id) - // remove from local list + + // Prefer PATCH to mark appointment as cancelled (safer under RLS) + try { + await atualizarAgendamento(consulta.id, { + cancelled_at: new Date().toISOString(), + status: 'cancelled', + cancellation_reason: 'Cancelado pelo paciente' + }) + } catch (patchErr) { + // Fallback: try hard delete if server allows it + try { + await deletarAgendamento(consulta.id) + } catch (delErr) { + // Re-throw original patch error if both fail + throw patchErr || delErr + } + } + + // remove from local list so UI updates immediately setAppointments((prev) => { if (!prev) return prev return prev.filter((a: any) => String(a.id) !== String(consulta.id)) }) // if modal open for this appointment, close it if (selectedAppointment && String(selectedAppointment.id) === String(consulta.id)) setSelectedAppointment(null) + // Optionally persist to deleted cache to help client-side filtering + try { addDeletedAppointmentId(consulta.id) } catch(e) {} setToast({ type: 'success', msg: 'Consulta cancelada.' }) } catch (err: any) { console.error('[Consultas] falha ao cancelar agendamento', err) -- 2.47.2 From 8fee2cf6e148764b2612ea4f0f317052a3f1b181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Sat, 8 Nov 2025 01:37:39 -0300 Subject: [PATCH 4/6] fix-appoiments-confirm --- .../app/(main-routes)/consultas/page.tsx | 22 +++++++++++++++---- susconecta/app/paciente/page.tsx | 10 ++++----- .../forms/calendar-registration-form.tsx | 9 +------- susconecta/lib/api.ts | 2 ++ 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/susconecta/app/(main-routes)/consultas/page.tsx b/susconecta/app/(main-routes)/consultas/page.tsx index 504decc..fe97473 100644 --- a/susconecta/app/(main-routes)/consultas/page.tsx +++ b/susconecta/app/(main-routes)/consultas/page.tsx @@ -74,6 +74,20 @@ const capitalize = (s: string) => { return s.charAt(0).toUpperCase() + s.slice(1); }; +const translateStatus = (status: string) => { + const statusMap: { [key: string]: string } = { + 'requested': 'Solicitado', + 'confirmed': 'Confirmado', + 'checked_in': 'Check-in', + 'in_progress': 'Em Andamento', + 'completed': 'Concluído', + 'cancelled': 'Cancelado', + 'no_show': 'Não Compareceu', + 'pending': 'Pendente', + }; + return statusMap[status?.toLowerCase()] || capitalize(status || ''); +}; + export default function ConsultasPage() { const [appointments, setAppointments] = useState([]); const [originalAppointments, setOriginalAppointments] = useState([]); @@ -197,7 +211,7 @@ export default function ConsultasPage() { const payload: any = { scheduled_at, duration_minutes, - status: formData.status || undefined, + status: 'confirmed', notes: formData.notes ?? null, chief_complaint: formData.chief_complaint ?? null, patient_notes: formData.patient_notes ?? null, @@ -561,7 +575,7 @@ export default function ConsultasPage() { } className={appointment.status === "confirmed" ? "bg-green-600" : ""} > - {capitalize(appointment.status)} + {translateStatus(appointment.status)} {formatDate(appointment.scheduled_at ?? appointment.time)} @@ -652,7 +666,7 @@ export default function ConsultasPage() { } className={`text-[10px] sm:text-xs ${appointment.status === "confirmed" ? "bg-green-600" : ""}`} > - {capitalize(appointment.status)} + {translateStatus(appointment.status)}
@@ -771,7 +785,7 @@ export default function ConsultasPage() { } className={viewingAppointment?.status === "confirmed" ? "bg-green-600" : ""} > - {capitalize(viewingAppointment?.status || "")} + {translateStatus(viewingAppointment?.status || "")}
diff --git a/susconecta/app/paciente/page.tsx b/susconecta/app/paciente/page.tsx index aadb86d..c39ee77 100644 --- a/susconecta/app/paciente/page.tsx +++ b/susconecta/app/paciente/page.tsx @@ -812,7 +812,7 @@ export default function PacientePage() {
@@ -837,10 +837,8 @@ export default function PacientePage() { {/* Status Badge */}
{statusLabel(consulta.status)} @@ -858,7 +856,7 @@ export default function PacientePage() { Detalhes {/* Reagendar removed by request */} - {consulta.status !== 'Cancelada' && ( + {consulta.status !== 'Cancelada' && consulta.status !== 'cancelled' && (
diff --git a/susconecta/components/features/forms/patient-registration-form.tsx b/susconecta/components/features/forms/patient-registration-form.tsx index aa11b20..b3b2f21 100644 --- a/susconecta/components/features/forms/patient-registration-form.tsx +++ b/susconecta/components/features/forms/patient-registration-form.tsx @@ -30,6 +30,7 @@ import { criarPaciente, } from "@/lib/api"; import { getAvatarPublicUrl } from '@/lib/api'; +import { useAvatarUrl } from '@/hooks/useAvatarUrl'; import { validarCPFLocal } from "@/lib/utils"; import { verificarCpfDuplicado } from "@/lib/api"; @@ -131,6 +132,9 @@ export function PatientRegistrationForm({ const [photoPreview, setPhotoPreview] = useState(null); const [serverAnexos, setServerAnexos] = useState([]); + // Hook para carregar automaticamente o avatar do paciente + const { avatarUrl: retrievedAvatarUrl } = useAvatarUrl(mode === "edit" ? patientId : null); + const [showCredentialsDialog, setShowCredentialsDialog] = useState(false); const [credentials, setCredentials] = useState<{ email: string; @@ -261,7 +265,14 @@ export function PatientRegistrationForm({ if (patientId == null) throw new Error("Paciente inexistente para edição"); const payload = toPayload(); const saved = await atualizarPaciente(String(patientId), payload); if (form.photo) { - try { setUploadingPhoto(true); try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); } await uploadFotoPaciente(String(patientId), form.photo); } + try { + setUploadingPhoto(true); + try { await removerFotoPaciente(String(patientId)); setPhotoPreview(null); } catch (remErr) { console.warn('[PatientForm] aviso: falha ao remover avatar antes do upload:', remErr); } + const uploadResult = await uploadFotoPaciente(String(patientId), form.photo); + // Upload realizado com sucesso - a foto está armazenada no Supabase Storage + // Não é necessário fazer PATCH para persistir a URL no banco + console.debug('[PatientForm] foto_url obtida do upload:', uploadResult.foto_url); + } catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente:', upErr); alert('Paciente atualizado, mas falha ao enviar a foto. Tente novamente.'); } finally { setUploadingPhoto(false); } } @@ -355,7 +366,15 @@ export function PatientRegistrationForm({ } if (form.photo) { - try { setUploadingPhoto(true); const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); if (pacienteId) await uploadFotoPaciente(String(pacienteId), form.photo); } + try { + setUploadingPhoto(true); + const pacienteId = savedPatientProfile?.id || (savedPatientProfile && (savedPatientProfile as any).id); + if (pacienteId) { + const uploadResult = await uploadFotoPaciente(String(pacienteId), form.photo); + // Upload realizado com sucesso - a foto está armazenada no Supabase Storage + console.debug('[PatientForm] foto_url obtida do upload após criação:', uploadResult.foto_url); + } + } catch (upErr) { console.warn('[PatientForm] Falha ao enviar foto do paciente após criação:', upErr); alert('Paciente criado, mas falha ao enviar a foto. Você pode tentar novamente no perfil.'); } finally { setUploadingPhoto(false); } } diff --git a/susconecta/hooks/useAvatarUrl.ts b/susconecta/hooks/useAvatarUrl.ts new file mode 100644 index 0000000..3605e7b --- /dev/null +++ b/susconecta/hooks/useAvatarUrl.ts @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react' +import { getAvatarPublicUrl } from '@/lib/api' + +/** + * Hook que gerencia a URL do avatar de um usuário + * Recupera automaticamente a URL baseada no userId + * Tenta múltiplas extensões (jpg, png, webp) até encontrar o arquivo + * @param userId - ID do usuário (string ou number) + * @returns { avatarUrl: string | null, isLoading: boolean } + */ +export function useAvatarUrl(userId: string | number | null | undefined) { + const [avatarUrl, setAvatarUrl] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!userId) { + console.debug('[useAvatarUrl] userId é vazio, limpando avatar') + setAvatarUrl(null) + return + } + + setIsLoading(true) + + const extensions = ['jpg', 'png', 'webp'] + let foundUrl: string | null = null + let testedExtensions = 0 + + const tryNextExtension = () => { + const ext = extensions[testedExtensions] + if (!ext) { + // Nenhuma extensão funcionou + console.warn('[useAvatarUrl] Nenhuma extensão de avatar encontrada para userId:', userId) + setAvatarUrl(null) + setIsLoading(false) + return + } + + try { + const url = getAvatarPublicUrl(userId, ext) + console.debug('[useAvatarUrl] Testando extensão:', { userId, ext, url }) + + // Valida se a imagem existe fazendo um HEAD request + fetch(url, { method: 'HEAD', mode: 'cors' }) + .then((response) => { + console.debug('[useAvatarUrl] HEAD response:', { + userId, + ext, + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type'), + contentLength: response.headers.get('content-length'), + ok: response.ok, + }) + + if (response.ok) { + console.log('[useAvatarUrl] Avatar encontrado:', url) + foundUrl = url + setAvatarUrl(url) + setIsLoading(false) + } else { + // Tenta próxima extensão + testedExtensions++ + tryNextExtension() + } + }) + .catch((error) => { + console.debug('[useAvatarUrl] Erro no HEAD request para ext', ext, ':', error.message) + + // Tenta GET como fallback se HEAD falhar (pode ser CORS issue) + fetch(url) + .then((response) => { + console.debug('[useAvatarUrl] GET fallback response para', ext, ':', { + status: response.status, + statusText: response.statusText, + contentType: response.headers.get('content-type'), + }) + if (response.ok) { + console.log('[useAvatarUrl] Avatar encontrado via GET fallback:', ext) + foundUrl = url + setAvatarUrl(url) + setIsLoading(false) + } else { + // Tenta próxima extensão + testedExtensions++ + tryNextExtension() + } + }) + .catch((err) => { + console.debug('[useAvatarUrl] Erro no GET fallback para ext', ext, ':', err.message) + // Tenta próxima extensão + testedExtensions++ + tryNextExtension() + }) + }) + } catch (error) { + console.error('[useAvatarUrl] Erro ao construir URL para ext', ext, ':', error) + testedExtensions++ + tryNextExtension() + } + } + + tryNextExtension() + }, [userId]) + + return { avatarUrl, isLoading } +} diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index a3b80c1..18f8976 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -2819,7 +2819,8 @@ export async function removerAnexo(_id: string | number, _anexoId: string | numb * Envia uma foto de avatar do paciente ao Supabase Storage. * - Valida tipo (jpeg/png/webp) e tamanho (<= 2MB) * - Faz POST multipart/form-data para /storage/v1/object/avatars/{userId}/avatar - * - Retorna o objeto { Key } quando upload for bem-sucedido + * - Inclui JWT token automaticamente se disponível + * - Retorna { foto_url } quando upload for bem-sucedido */ export async function uploadFotoPaciente(_id: string | number, _file: File): Promise<{ foto_url?: string; thumbnail_url?: string; Key?: string }> { const userId = String(_id); @@ -2853,14 +2854,15 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro form.append('file', _file, `avatar.${ext}`); const headers: Record = { - // Supabase requires the anon key in 'apikey' header for client-side uploads + // Supabase requer o anon key no header 'apikey' apikey: ENV_CONFIG.SUPABASE_ANON_KEY, - // Accept json - Accept: 'application/json', }; - // if user is logged in, include Authorization header + + // Incluir JWT token se disponível (para autenticar como usuário logado) const jwt = getAuthToken(); - if (jwt) headers.Authorization = `Bearer ${jwt}`; + if (jwt) { + headers.Authorization = `Bearer ${jwt}`; + } console.debug('[uploadFotoPaciente] Iniciando upload:', { url: uploadUrl, @@ -2875,81 +2877,61 @@ export async function uploadFotoPaciente(_id: string | number, _file: File): Pro body: form as any, }); - // Supabase storage returns 200/201 with object info or error - if (!res.ok) { + // Supabase storage returns 200/201 com info do objeto ou erro + // 409 (Duplicate) é esperado quando o arquivo já existe e queremos sobrescrever + if (!res.ok && res.status !== 409) { const raw = await res.text().catch(() => ''); console.error('[uploadFotoPaciente] upload falhou', { status: res.status, raw, - headers: Object.fromEntries(res.headers.entries()), url: uploadUrl, - requestHeaders: headers, - objectPath + objectPath, + hasAuth: !!jwt }); if (res.status === 401) throw new Error('Não autenticado'); - if (res.status === 403) throw new Error('Sem permissão para fazer upload'); + if (res.status === 403) throw new Error('Sem permissão para fazer upload. Verifique as políticas de RLS no Supabase.'); if (res.status === 404) throw new Error('Bucket de avatars não encontrado. Verifique se o bucket "avatars" existe no Supabase'); throw new Error(`Falha no upload da imagem (${res.status}): ${raw || 'Sem detalhes do erro'}`); } - // Try to parse JSON response - let json: any = null; - try { json = await res.json(); } catch { json = null; } + // Construir URL pública do arquivo + // Importante: codificar userId e nome do arquivo separadamente, não o caminho inteiro + const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/${bucket}/${encodeURIComponent(userId)}/avatar.${ext}`; - // The API may not return a structured body; return the Key we constructed - const key = (json && (json.Key || json.key)) ?? objectPath; - const publicUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/public/avatars/${encodeURIComponent(userId)}/avatar.${ext}`; - return { foto_url: publicUrl, Key: key }; + console.debug('[uploadFotoPaciente] upload concluído:', { publicUrl, objectPath }); + + return { foto_url: publicUrl, Key: objectPath }; } /** * Retorna a URL pública do avatar do usuário (acesso público) - * Path conforme OpenAPI: /storage/v1/object/public/avatars/{userId}/avatar.{ext} + * ⚠️ IMPORTANTE: O arquivo é armazenado como "avatar.{ext}" (jpg, png ou webp) + * Este helper retorna a URL COM extensão, não sem. * @param userId - ID do usuário (UUID) - * @param ext - extensão do arquivo: 'jpg' | 'png' | 'webp' (default 'jpg') + * @param ext - Extensão do arquivo (jpg, png, webp). Se não fornecida, tenta jpg por padrão. + * @returns URL pública completa do avatar */ -export function getAvatarPublicUrl(userId: string | number): string { - // Build the public avatar URL without file extension. - // Example: https://.supabase.co/storage/v1/object/public/avatars/{userId}/avatar +export function getAvatarPublicUrl(userId: string | number, ext: string = 'jpg'): string { const id = String(userId || '').trim(); if (!id) throw new Error('userId é obrigatório para obter URL pública do avatar'); const base = String(ENV_CONFIG.SUPABASE_URL).replace(/\/$/, ''); - // Note: Supabase public object path does not require an extension in some setups - return `${base}/storage/v1/object/public/${encodeURIComponent('avatars')}/${encodeURIComponent(id)}/avatar`; + + // IMPORTANTE: Deve corresponder exatamente ao objectPath usado no upload: + // uploadFotoPaciente() salva como: `${userId}/avatar.${ext}` + // Então aqui retornamos: `/storage/v1/object/public/avatars/${userId}/avatar.${ext}` + const cleanExt = ext.toLowerCase().replace(/^\./, ''); // Remove ponto se presente + return `${base}/storage/v1/object/public/avatars/${encodeURIComponent(id)}/avatar.${cleanExt}`; } export async function removerFotoPaciente(_id: string | number): Promise { const userId = String(_id || '').trim(); if (!userId) throw new Error('ID do paciente é obrigatório para remover foto'); - const deleteUrl = `${ENV_CONFIG.SUPABASE_URL}/storage/v1/object/avatars/${encodeURIComponent(userId)}/avatar`; - const headers: Record = { - apikey: ENV_CONFIG.SUPABASE_ANON_KEY, - Accept: 'application/json', - }; - const jwt = getAuthToken(); - if (jwt) headers.Authorization = `Bearer ${jwt}`; - - try { - console.debug('[removerFotoPaciente] Deleting avatar for user:', userId, 'url:', deleteUrl); - const res = await fetch(deleteUrl, { method: 'DELETE', headers }); - if (!res.ok) { - const raw = await res.text().catch(() => ''); - console.warn('[removerFotoPaciente] remoção falhou', { status: res.status, raw }); - // Treat 404 as success (object already absent) - if (res.status === 404) return; - // Include status and server body in the error message to aid debugging - const bodySnippet = raw && raw.length > 0 ? raw : ''; - if (res.status === 401) throw new Error(`Não autenticado (401). Resposta: ${bodySnippet}`); - if (res.status === 403) throw new Error(`Sem permissão para remover a foto (403). Resposta: ${bodySnippet}`); - throw new Error(`Falha ao remover a foto do storage (status ${res.status}). Resposta: ${bodySnippet}`); - } - // success - return; - } catch (err) { - // bubble up for the caller to handle - throw err; - } + + // Na prática, o upload usa upsert: true, então não é necessário fazer DELETE explícito. + // Apenas log e retorna com sucesso para compatibilidade. + console.debug('[removerFotoPaciente] Remoção de foto não necessária (upload usa upsert: true)', { userId }); + return; } export async function listarAnexosMedico(_id: string | number): Promise { return []; } export async function adicionarAnexoMedico(_id: string | number, _file: File): Promise { return {}; } -- 2.47.2 From dce7b485e2a66fc8f2cde0fab8e053dc3a1f8709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gustavo?= <166467972+JoaoGustavo-dev@users.noreply.github.com> Date: Sat, 8 Nov 2025 03:19:14 -0300 Subject: [PATCH 6/6] fix-appoiments-admin --- .../forms/calendar-registration-form.tsx | 111 +++++++++++++----- susconecta/lib/api.ts | 64 ++-------- 2 files changed, 93 insertions(+), 82 deletions(-) diff --git a/susconecta/components/features/forms/calendar-registration-form.tsx b/susconecta/components/features/forms/calendar-registration-form.tsx index bcf7d2e..afc2aea 100644 --- a/susconecta/components/features/forms/calendar-registration-form.tsx +++ b/susconecta/components/features/forms/calendar-registration-form.tsx @@ -2,7 +2,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades, listarExcecoes } from "@/lib/api"; +import { buscarPacientePorId, listarMedicos, buscarPacientesPorMedico, getAvailableSlots, buscarPacientes, listarPacientes, listarDisponibilidades, listarExcecoes, listarAgendamentos } from "@/lib/api"; import { toast } from '@/hooks/use-toast'; import { AlertDialog, @@ -93,6 +93,7 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = const [exceptionDialogOpen, setExceptionDialogOpen] = useState(false); const [exceptionDialogMessage, setExceptionDialogMessage] = useState(null); const [showDatePicker, setShowDatePicker] = useState(false); + const [bookedSlots, setBookedSlots] = useState>(new Set()); // ISO datetimes of already booked appointments // Helpers to convert between ISO (server) and input[type=datetime-local] value const isoToDatetimeLocal = (iso?: string | null) => { @@ -298,31 +299,9 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = if (mountedRef.current) setLoadingSlots(true); try { - // Check for blocking exceptions first - try { - const exceptions = await listarExcecoes({ doctorId: String(docId), date: String(date) }).catch(() => []); - if (exceptions && exceptions.length) { - const blocking = (exceptions || []).find((e: any) => e && e.kind === 'bloqueio'); - if (blocking) { - const reason = blocking.reason ? ` Motivo: ${blocking.reason}` : ''; - const msg = `Não é possível agendar nesta data.${reason}`; - try { - setExceptionDialogMessage(msg); - setExceptionDialogOpen(true); - } catch (e) { - try { toast({ title: 'Data indisponível', description: msg }); } catch (ee) {} - } - if (mountedRef.current) { - setAvailableSlots([]); - setLoadingSlots(false); - } - return; - } - } - } catch (exCheckErr) { - console.warn('[CalendarRegistrationForm] listarExcecoes falhou, continuando para getAvailableSlots', exCheckErr); - } - + // Skip exception checking - all dates are available for admin now + // NOTE: Exception checking disabled per user request + console.debug('[CalendarRegistrationForm] getAvailableSlots - params', { docId, date, appointmentType: formData.appointmentType }); // Build local start/end for the day @@ -556,7 +535,61 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Filter available slots: if date is today, only show future times + // Load already booked appointments for the selected doctor and date to prevent double-booking + useEffect(() => { + const docId = (formData as any).doctorId || (formData as any).doctor_id || null; + const date = (formData as any).appointmentDate || null; + + if (!docId || !date) { + setBookedSlots(new Set()); + return; + } + + let mounted = true; + (async () => { + try { + // Query appointments for this doctor on the selected date + // Format: YYYY-MM-DD + const [y, m, d] = String(date).split('-').map(n => Number(n)); + const dateStart = new Date(y, m - 1, d, 0, 0, 0, 0).toISOString(); + const dateEnd = new Date(y, m - 1, d, 23, 59, 59, 999).toISOString(); + + const query = `doctor_id=eq.${docId}&scheduled_at=gte.${dateStart}&scheduled_at=lte.${dateEnd}&select=scheduled_at`; + const appointments = await listarAgendamentos(query).catch(() => []); + + if (!mounted) return; + + // Extract booked datetime slots - store as HH:MM format for easier comparison + const booked = new Set(); + (appointments || []).forEach((appt: any) => { + if (appt && appt.scheduled_at) { + try { + const dt = new Date(appt.scheduled_at); + const hh = String(dt.getHours()).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + const timeKey = `${hh}:${mm}`; + booked.add(timeKey); + console.debug('[CalendarRegistrationForm] booked time:', timeKey, 'from', appt.scheduled_at); + } catch (e) { + console.warn('[CalendarRegistrationForm] erro parsing scheduled_at', appt.scheduled_at, e); + } + } + }); + + if (mounted) { + setBookedSlots(booked); + console.debug('[CalendarRegistrationForm] total booked slots:', booked.size, 'slots:', Array.from(booked)); + } + } catch (e) { + console.warn('[CalendarRegistrationForm] erro ao carregar agendamentos existentes', e); + if (mounted) setBookedSlots(new Set()); + } + })(); + + return () => { mounted = false; }; + }, [(formData as any).doctorId, (formData as any).doctor_id, (formData as any).appointmentDate]); + + // Filter available slots: if date is today, only show future times, AND remove already booked slots const filteredAvailableSlots = (() => { try { const now = new Date(); @@ -566,9 +599,11 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = const currentMinutes = now.getMinutes(); const currentTimeInMinutes = currentHours * 60 + currentMinutes; + let filtered = availableSlots || []; + if (selectedDateStr === todayStr) { // Today: filter out past times (add 30-minute buffer for admin to schedule) - return (availableSlots || []).filter((s) => { + filtered = (availableSlots || []).filter((s) => { try { const slotDate = new Date(s.datetime); const slotHours = slotDate.getHours(); @@ -582,11 +617,29 @@ export function CalendarRegistrationForm({ formData, onFormChange, createMode = }); } else if (selectedDateStr && selectedDateStr > todayStr) { // Future date: show all slots - return availableSlots || []; + filtered = availableSlots || []; } else { // Past date: no slots return []; } + + // Remove already booked slots - compare by HH:MM format + return filtered.filter((s) => { + try { + const dt = new Date(s.datetime); + const hh = String(dt.getHours()).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + const timeKey = `${hh}:${mm}`; + const isBooked = bookedSlots.has(timeKey); + if (isBooked) { + console.debug('[CalendarRegistrationForm] filtering out booked slot:', timeKey); + } + return !isBooked; + } catch (e) { + console.warn('[CalendarRegistrationForm] erro filtering booked slot', e); + return true; + } + }); } catch (e) { return availableSlots || []; } diff --git a/susconecta/lib/api.ts b/susconecta/lib/api.ts index bf41e0d..aea4a2f 100644 --- a/susconecta/lib/api.ts +++ b/susconecta/lib/api.ts @@ -1086,66 +1086,24 @@ export async function criarAgendamento(input: AppointmentCreate): Promise { ... }); + // if (!matching) throw new Error(...); - 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.'); - } - - // --- Prevent creating an appointment on a date with a blocking exception --- + // --- Skip exception checking for admin - allow all dates and times --- + // NOTE: Exception validation disabled per user request + /* try { - // listarExcecoes can filter by date const dateOnly = startDay.toISOString().split('T')[0]; const exceptions = await listarExcecoes({ doctorId: input.doctor_id, date: dateOnly }).catch(() => []); - if (exceptions && exceptions.length) { - for (const ex of exceptions) { - try { - if (!ex || !ex.kind) continue; - if (ex.kind !== 'bloqueio') continue; - // If no start_time/end_time -> blocks whole day - if (!ex.start_time && !ex.end_time) { - const reason = ex.reason ? ` Motivo: ${ex.reason}` : ''; - throw new Error(`Não é possível agendar para esta data. Existe uma exceção que bloqueia o dia.${reason}`); - } - // Otherwise check overlap with scheduled time - // Parse exception times and scheduled time to minutes - const parseToMinutes = (t?: string | null) => { - if (!t) return null; - const parts = String(t).split(':').map(Number); - if (parts.length >= 2 && !Number.isNaN(parts[0]) && !Number.isNaN(parts[1])) return parts[0] * 60 + parts[1]; - return null; - }; - const exStart = parseToMinutes(ex.start_time ?? undefined); - const exEnd = parseToMinutes(ex.end_time ?? undefined); - const sched = new Date(input.scheduled_at); - const schedMinutes = sched.getHours() * 60 + sched.getMinutes(); - const schedDuration = input.duration_minutes ?? 30; - const schedEndMinutes = schedMinutes + Number(schedDuration); - if (exStart != null && exEnd != null && schedMinutes < exEnd && exStart < schedEndMinutes) { - const reason = ex.reason ? ` Motivo: ${ex.reason}` : ''; - throw new Error(`Não é possível agendar neste horário por uma exceção que bloqueia parte do dia.${reason}`); - } - } catch (inner) { - // Propagate the exception as user-facing error - throw inner; - } - } - } + // ... exception checking logic removed ... } catch (e) { if (e instanceof Error) throw e; } + */ // Determine created_by similar to other creators (prefer localStorage then user-info) let createdBy: string | null = null; -- 2.47.2