diff --git a/.env.example b/.env.example index e62b46e..1b61f48 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ VITE_SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co +VITE_API_BASE_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1 VITE_SUPABASE_REST_URL=https://yuanqfswhberkoevtmfr.supabase.co/rest/v1 VITE_SUPABASE_FUNCTIONS_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1 VITE_SUPABASE_STORAGE_URL=https://yuanqfswhberkoevtmfr.supabase.co/storage/v1 diff --git a/docs/repository-api-audit.md b/docs/repository-api-audit.md index 4025e5b..4943b59 100644 --- a/docs/repository-api-audit.md +++ b/docs/repository-api-audit.md @@ -1,82 +1,49 @@ -# Auditoria dos repositories contra a API +# Auditoria de Implementacao e Mapeamento da API -Fonte da documentacao: https://do5wegrct3.apidog.io/llms.txt +Este documento resume o estado atual da integracao entre o front-end e os endpoints da API. -## Endpoints documentados +## Integrado no front -| Grupo | Metodo | Endpoint | Status no frontend | -| --- | --- | --- | --- | -| Autenticacao | POST | `/auth/v1/token?grant_type=password` | Sem repository dedicado | -| Autenticacao | POST | `/auth/v1/otp` | Sem repository dedicado | -| Autenticacao | POST | `/auth/v1/logout` | Sem repository dedicado | -| Autenticacao | GET | `/auth/v1/user` | Sem repository dedicado; relacionado a `profileRepository` | -| Usuarios | POST | `/delete-user` | Sem repository dedicado | -| Usuarios | POST | `/functions/v1/create-user` | Sem repository dedicado | -| Usuarios | POST | `/functions/v1/create-user-with-password` | Sem repository dedicado | -| Usuarios | POST | `/request-password-reset` | Sem repository dedicado | -| Usuarios | POST | `/functions/v1/user-info` | Sem repository dedicado; relacionado a `profileRepository` | -| Usuarios | POST | `/functions/v1/user-info-by-id` | Sem repository dedicado | -| SMS | POST | `/functions/v1/send-sms` | `communicationRepository` nao implementa chamada real | -| Pacientes | GET | `/rest/v1/patients` | Implementado em `patientRepository.getAll` | -| Pacientes | POST | `/rest/v1/patients` | Implementado em `patientRepository.create` | -| Pacientes | PATCH | `/rest/v1/patients?id=eq.{id}` | Implementado em `patientRepository.update` | -| Pacientes | DELETE | `/rest/v1/patients?id=eq.{id}` | Implementado em `patientRepository.remove` | -| Pacientes | POST | `/functions/v1/create-patient` | Implementado em `patientRepository.createWithValidation` | -| Pacientes | POST | `/functions/v1/register-patient` | Nao implementado | -| Medicos | GET | `/rest/v1/doctors` | `professionalRepository.getAll` usa mock | -| Medicos | POST | `/functions/v1/create-doctor` | Nao implementado | -| Agendamentos | GET | `/rest/v1/appointments` | `appointmentRepository.getAll` usa mock | -| Agendamentos | POST | `/rest/v1/appointments` | Nao implementado | -| Agendamentos | POST | `/functions/v1/get-available-slots` | Nao implementado | -| Disponibilidade | GET | `/rest/v1/doctor_availability` | Nao implementado | -| Disponibilidade | POST | `/rest/v1/doctor_availability` | Nao implementado | -| Disponibilidade | PATCH | `/rest/v1/doctor_availability?id=eq.{id}` | Nao implementado | -| Disponibilidade | DELETE | `/rest/v1/doctor_availability?id=eq.{id}` | Nao implementado | -| Disponibilidade | GET | `/rest/v1/doctor_exceptions` | Nao implementado | -| Disponibilidade | POST | `/rest/v1/doctor_exceptions` | Nao implementado | -| Storage | POST | `/storage/v1/object/avatars/{path}` | Nao implementado | -| Storage | GET | `/storage/v1/object/avatars/{path}` | Nao implementado | -| Reports | GET | `/rest/v1/reports` | `reportRepository.getInitialReports` usa mock | -| Reports | POST | `/rest/v1/reports` | Nao implementado | -| Reports | PATCH | `/rest/v1/reports?id=eq.{id}` | Nao implementado | +- **Autenticacao** + - Login com email e senha via Supabase Auth (`/auth/v1/token`). + - Solicitar reset de senha: tenta `/solicitar-reset-de-senha` e usa `/auth/v1/recover` como fallback. + - Dados do usuario autenticado: tenta `/informacoes-do-usuario-autenticado` e usa `/auth/v1/user` como fallback. + - Logout: tenta `/logout`, usa `/auth/v1/logout` como fallback e sempre limpa a sessao local. -## Repositories ainda nao implementados +- **Pacientes** + - Listar, criar, atualizar e deletar pacientes via Supabase REST. + - Criar paciente com validacao via Edge Function quando disponivel. -| Repository | Metodos atuais | Endpoint equivalente | Observacao | -| --- | --- | --- | --- | -| `analyticsRepository` | `getDashboardData` | Nao documentado | Retorna dados estaticos de KPIs, graficos e pacientes frequentes. | -| `appointmentRepository` | `getAll`, `getTodayTimeline`, `getPredictiveQueueSummary`, `getWeekDays` | Parcial: `GET /rest/v1/appointments`, `POST /rest/v1/appointments`, `POST /functions/v1/get-available-slots` | `getAll` deveria chamar a API; demais metodos sao derivados/visuais e nao aparecem na doc. | -| `communicationRepository` | `getCampaigns`, `getInitialMessages`, `getInitialTemplates` | Parcial: `POST /functions/v1/send-sms` | A API so documenta envio de SMS; nao ha endpoints para campanhas, mensagens ou templates. | -| `homeRepository` | `getDashboardOverview` | Nao documentado | Tela inicial usa agregados estaticos. | -| `medicalRecordRepository` | `getRecordTypes`, `getInitialRecords` | Nao documentado | Nao ha endpoint de prontuarios/medical records na doc atual. | -| `professionalRepository` | `getAll`, `getCoverageMap` | Parcial: `GET /rest/v1/doctors`, `POST /functions/v1/create-doctor` | `getAll` deveria usar doctors; `getCoverageMap` parece derivado de disponibilidade, mas nao bate direto com um endpoint. | -| `profileRepository` | `getCurrentUserProfile` | Parcial: `GET /auth/v1/user`, `POST /functions/v1/user-info` | Retorna perfil fixo; deveria consumir dados do usuario autenticado. | -| `reportRepository` | `getAdminUsers`, `getCurrentUser`, `getDoctors`, `getInitialReports`, `getReportTypes`, `getTemplates` | Parcial: `GET/POST/PATCH /rest/v1/reports` | Lista e metadados sao mockados; nao existem metodos de criar/atualizar usando API. | -| `settingsRepository` | `getIntegrations`, `getSections` | Nao documentado | Configuracoes exibidas sao estaticas. | -| `visitRepository` | `getCareQueue`, `getStages` | Nao documentado | Nao ha endpoint de atendimentos/fila/visits na doc atual. | +- **Agendamentos** + - Listar agendamentos: tenta `GET /agendamentos` e usa Supabase REST `appointments` como fallback. + - Criar agendamento: tenta `POST /agendamentos` e usa Supabase REST `appointments` como fallback. -## Inconsistencias encontradas +- **Laudos Medicos** + - Listar relatorios: tenta `GET /reports` e usa Supabase REST `reports` como fallback. + - Criar relatorio: tenta `POST /reports` e usa Supabase REST `reports` como fallback. + - Atualizar relatorio: tenta `PATCH /reports/{id}`, depois `PATCH /reports`, e usa Supabase REST `reports` como fallback. -- `patientRepository.getById(patientId)` nao bate com um endpoint documentado especifico. Ele chama `getAll()` e filtra em memoria; se a API aceitar filtro Supabase por id, o ideal seria usar `/rest/v1/patients?id=eq.{id}&select=*`, mas isso nao aparece como endpoint proprio na documentacao. -- `patientRepository.getDirectoryRows()` transforma pacientes em campos de UI e preenche `insurance`, `city`, `state`, `vip`, `lastVisit` e `nextVisit` com valores fixos. Esses campos nao estao descritos na resposta de `GET /rest/v1/patients`. -- `patientRepository.create(data)` e `createWithValidation(data)` enviam `created_by` com UUID zerado quando nao informado. A documentacao nao confirma esse fallback; isso pode gerar registro invalido se a API exigir usuario real. -- `patientRepository.createWithValidation(data)` usa a Edge Function documentada (`/functions/v1/create-patient`), mas a API tambem possui o endpoint publico `/functions/v1/register-patient`, ainda sem metodo correspondente. -- `appointmentRepository.getAll()` nao chama `GET /rest/v1/appointments`; usa `mockData`. Alem disso, nao existem metodos para `POST /rest/v1/appointments` nem para `POST /functions/v1/get-available-slots`. -- `professionalRepository.getAll()` nao chama `GET /rest/v1/doctors`; usa `mockData`. Tambem falta metodo para `POST /functions/v1/create-doctor`. -- `reportRepository.getInitialReports()` nao chama `GET /rest/v1/reports`; usa dados estaticos e nomes/status em portugues (`rascunho`, `finalizado`, `enviado`) diferentes dos status documentados (`draft`, `completed`). -- `reportRepository` expoe templates, tipos, usuarios admin e medico atual, mas esses recursos nao aparecem na API documentada de Reports. -- `communicationRepository` tem campanhas, mensagens e templates, mas a documentacao so possui `POST /functions/v1/send-sms`; nao ha equivalencia para listar ou gerenciar esses dados. -- `profileRepository.getCurrentUserProfile()` retorna perfil fixo; deveria ser alinhado com `GET /auth/v1/user` ou `POST /functions/v1/user-info`. -- `homeRepository`, `analyticsRepository`, `settingsRepository`, `visitRepository` e `medicalRecordRepository` nao possuem endpoints equivalentes na documentacao atual. +- **Medicos / Profissionais** + - Listar medicos: tenta `GET /listar-medicos` e usa Supabase REST `doctors` como fallback. -## Configuracao extraida +- **Mensageria** + - Enviar SMS: tenta `POST /enviar-sms-via-twilio` e usa Edge Function `send-sms` como fallback. + - O formulario agora coleta telefone quando o canal selecionado e SMS. -As variaveis reutilizaveis de API foram centralizadas em `src/config/api.js`: +- **Storage** + - Upload de avatar: tenta `/upload-avatar` e usa Supabase Storage no bucket `avatars` como fallback. + - A tela de perfil atualiza a imagem exibida apos upload bem-sucedido. -- `VITE_SUPABASE_URL` -- `VITE_SUPABASE_REST_URL` -- `VITE_SUPABASE_FUNCTIONS_URL` -- `VITE_SUPABASE_STORAGE_URL` -- `VITE_SUPABASE_ANON_KEY` +## Ainda sem endpoint consolidado documentado -O arquivo mantem os valores atuais como fallback para nao quebrar o ambiente local, mas o ideal e configurar esses valores via `.env`. +- Dashboard / Inicio (`HomePage` / `homeRepository.js`). +- Estatisticas e BI (`AnalyticsPage` / `analyticsRepository.js`). +- Prontuarios especificos separados de laudos (`MedicalRecordsPage` / `medicalRecordRepository.js`). +- Consultas isoladas fora de agendamento (`VisitsPage` / `visitRepository.js`). +- Configuracoes gerais do tenant (`SettingsPage` / `settingsRepository.js`). + +## Observacoes + +- `VITE_API_BASE_URL` define a base dos endpoints nomeados da API. Quando nao informado, o front usa `VITE_SUPABASE_FUNCTIONS_URL`. +- Os reposititorios aceitam formatos de resposta comuns como arrays diretos ou objetos com chaves `data`, `reports`, `agendamentos`, `medicos` etc. +- Os fallbacks existem para manter o front funcional em ambientes onde parte das Edge Functions ainda nao foi publicada. diff --git a/src/App.jsx b/src/App.jsx index 790bdc6..2a9c4ed 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { authRepository } from './repositories/authRepository.js' + import './App.css' import { AppShell } from './components/AppShell.jsx' import { AgendaPage } from './pages/AgendaPage.jsx' @@ -48,11 +50,16 @@ function App() { }, []) const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate]) + const isAuthenticated = authRepository.isAuthenticated() if (!route.withShell) { return route.element } + if (!isAuthenticated) { + return + } + return ( {route.element} @@ -119,15 +126,10 @@ function resolveRoute(pathname, navigate) { if (pathname.startsWith('/pacientes/')) { const patientId = pathname.split('/')[2] - const patient = patientRepository.getById(patientId) return { - element: patient ? ( - - ) : ( - - ), - title: patient?.name || 'Paciente nao encontrado', + element: , + title: 'Paciente', withShell: true, } } @@ -195,6 +197,33 @@ function resolveRoute(pathname, navigate) { } } +function PatientDetailRoute({ navigate, patientId }) { + const [patient, setPatient] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let active = true + + patientRepository.getById(patientId) + .then((data) => { + if (active) setPatient(data) + }) + .finally(() => { + if (active) setLoading(false) + }) + + return () => { + active = false + } + }, [patientId]) + + if (loading) { + return
Carregando paciente...
+ } + + return patient ? : +} + function readLocation() { return { pathname: normalizePath(window.location.pathname), diff --git a/src/config/api.js b/src/config/api.js index 33de4e8..f83434c 100644 --- a/src/config/api.js +++ b/src/config/api.js @@ -1,7 +1,10 @@ const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberkoevtmfr.supabase.co' const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ' +const AUTH_SESSION_KEY = 'mediconnect.auth.session' + export const apiConfig = { + apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`, supabaseUrl: SUPABASE_URL, restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`, functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`, @@ -9,8 +12,76 @@ export const apiConfig = { anonKey: SUPABASE_ANON_KEY, } -export const apiHeaders = { - apikey: apiConfig.anonKey, - Authorization: `Bearer ${apiConfig.anonKey}`, - 'Content-Type': 'application/json', +export function apiEndpoint(path, baseUrl = apiConfig.apiUrl) { + const normalizedBase = baseUrl.replace(/\/+$/, '') + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${normalizedBase}${normalizedPath}` +} + +export function getAuthSession() { + if (typeof window === 'undefined') return null + const rawSession = window.sessionStorage.getItem(AUTH_SESSION_KEY) + if (!rawSession) return null + + try { + return JSON.parse(rawSession) + } catch { + clearAuthSession() + return null + } +} + +export function saveAuthSession(session) { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) + } +} + +export function clearAuthSession() { + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(AUTH_SESSION_KEY) + } +} + +export function hasAuthenticatedSession() { + const session = getAuthSession() + if (!session?.access_token) return false + + // Validate expiration locally if available + if (session.expires_at && session.expires_at * 1000 <= Date.now()) { + clearAuthSession() + return false + } + + return true +} + +export function getAnonHeaders(extraHeaders = {}) { + return cleanHeaders({ + apikey: apiConfig.anonKey, + 'Content-Type': 'application/json', + ...extraHeaders, + }) +} + +export function getAuthenticatedHeaders(extraHeaders = {}) { + const session = getAuthSession() + const accessToken = session?.access_token + + if (!accessToken) { + throw new Error('Sessão expirada. Faça login novamente.') + } + + return cleanHeaders({ + apikey: apiConfig.anonKey, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...extraHeaders, + }) +} + +function cleanHeaders(headers) { + return Object.fromEntries( + Object.entries(headers).filter(([, value]) => value !== undefined && value !== null), + ) } diff --git a/src/mappers/appointmentMapper.js b/src/mappers/appointmentMapper.js new file mode 100644 index 0000000..61e7c38 --- /dev/null +++ b/src/mappers/appointmentMapper.js @@ -0,0 +1,61 @@ +export const appointmentMapper = { + toUi(apiData) { + if (!apiData) return null + + const patient = apiData.patient || apiData.paciente || apiData.patients || {} + const professional = apiData.doctor || apiData.medico || apiData.professional || apiData.doctors || {} + + return { + id: apiData.id || apiData.agendamento_id, + patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id, + patient: apiData.patientName || apiData.patient_name || patient.full_name || patient.nome || patient.name || 'Paciente', + professional: + apiData.professional || + apiData.professionalName || + apiData.doctor_name || + apiData.medico_nome || + professional.name || + professional.nome || + 'Medico(a)', + date: apiData.date || apiData.data || apiData.appointment_date || apiData.data_agendamento || '', + time: apiData.time || apiData.hora || apiData.appointment_time || apiData.horario || '', + type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta', + mode: apiData.mode || apiData.modalidade || apiData.formato || 'Presencial', + status: apiData.status || apiData.situacao || 'Aguardando', + room: apiData.room || apiData.sala || apiData.local || 'Consultorio 1', + } + }, + + toApi(uiData, dialect = 'api') { + if (dialect === 'supabase') { + return { + patient_id: uiData.patientId, + doctor_id: uiData.professionalId || null, + appointment_date: uiData.date, + appointment_time: uiData.time, + type: uiData.type, + mode: uiData.mode, + status: uiData.status || 'Confirmada', + room: uiData.room, + } + } + + return { + patient_id: uiData.patientId, + paciente_id: uiData.patientId, + doctor_id: uiData.professionalId || null, + medico_id: uiData.professionalId || null, + appointment_date: uiData.date, + data: uiData.date, + appointment_time: uiData.time, + hora: uiData.time, + type: uiData.type, + tipo: uiData.type, + mode: uiData.mode, + modalidade: uiData.mode, + status: uiData.status || 'Confirmada', + room: uiData.room, + sala: uiData.room, + } + }, +} diff --git a/src/mappers/reportMapper.js b/src/mappers/reportMapper.js new file mode 100644 index 0000000..1f9f01f --- /dev/null +++ b/src/mappers/reportMapper.js @@ -0,0 +1,73 @@ +export const reportMapper = { + toUi(apiData) { + if (!apiData) return null + + const patient = apiData.patient || apiData.paciente || apiData.patients || {} + const doctor = apiData.doctor || apiData.medico || apiData.professional || apiData.doctors || {} + const createdAt = apiData.created_at || apiData.createdAt || apiData.data_criacao || apiData.date + const status = normalizeStatus(apiData.status || apiData.situacao) + + return { + id: String(apiData.id || apiData.report_id || apiData.laudo_id), + patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id || '', + patient: apiData.patientName || apiData.patient_name || patient.full_name || patient.nome || patient.name || 'Paciente', + date: createdAt ? new Date(createdAt).toLocaleDateString('pt-BR') : 'Sem data', + doctor: apiData.doctorName || apiData.doctor_name || apiData.medico_nome || doctor.name || doctor.nome || 'Medico(a)', + author: apiData.author || apiData.autor || doctor.name || doctor.nome || 'Medico(a)', + type: apiData.type || apiData.report_type || apiData.tipo || apiData.tipo_laudo || 'Laudo medico', + status, + content: apiData.content || apiData.conteudo || apiData.text || '', + cid: apiData.cid || '', + tags: apiData.tags || [], + verified: apiData.verified ?? apiData.verificado ?? status !== 'rascunho', + showDate: apiData.showDate ?? apiData.exibir_data ?? true, + signDigital: apiData.signDigital ?? apiData.assinatura_digital ?? true, + versions: normalizeVersions(apiData.versions || apiData.versoes), + } + }, + + toApi(uiData, dialect = 'api') { + if (dialect === 'supabase') { + return { + patient_id: uiData.patientId, + report_type: uiData.type, + content: uiData.content, + status: uiData.status, + cid: uiData.cid || null, + } + } + + return { + patient_id: uiData.patientId, + paciente_id: uiData.patientId, + report_type: uiData.type, + tipo: uiData.type, + content: uiData.content, + conteudo: uiData.content, + status: uiData.status, + cid: uiData.cid || null, + } + }, +} + +function normalizeStatus(status) { + if (!status) return 'rascunho' + + const normalized = String(status).toLowerCase() + if (['finalizado', 'liberado', 'assinado'].includes(normalized)) return 'finalizado' + if (['enviado', 'entregue'].includes(normalized)) return 'enviado' + return 'rascunho' +} + +function normalizeVersions(versions) { + if (Array.isArray(versions) && versions.length) return versions + + return [ + { + version: 1, + action: 'Criado', + user: 'Sistema', + summary: 'Registro importado da API', + }, + ] +} diff --git a/src/pages/AgendaPage.jsx b/src/pages/AgendaPage.jsx index 1cace68..b9f3b2c 100644 --- a/src/pages/AgendaPage.jsx +++ b/src/pages/AgendaPage.jsx @@ -16,30 +16,38 @@ const viewFilters = ['Dia', 'Semana', 'Mês'] export function AgendaPage({ navigate }) { const [patients, setPatients] = useState([]) - const professionals = professionalRepository.getAll() + const [professionals, setProfessionals] = useState([]) const queue = appointmentRepository.getPredictiveQueueSummary() const timeline = appointmentRepository.getTodayTimeline() const weekDays = appointmentRepository.getWeekDays() const [activeView, setActiveView] = useState('Dia') const [status, setStatus] = useState('Todos') const [modalOpen, setModalOpen] = useState(false) - const [localAppointments, setLocalAppointments] = useState(() => appointmentRepository.getAll()) + const [localAppointments, setLocalAppointments] = useState([]) const [form, setForm] = useState({ patientId: '', - professional: professionals[0]?.name || '', + professionalId: '', type: 'Retorno', time: '15:30', mode: 'Teleconsulta', }) useEffect(() => { - patientRepository.getAll().then((data) => { - setPatients(data) + Promise.all([ + patientRepository.getAll(), + appointmentRepository.getAll(), + professionalRepository.getAll() + ]).then(([patientsData, appointmentsData, professionalsData]) => { + setPatients(patientsData) + setLocalAppointments(appointmentsData || []) + setProfessionals(professionalsData || []) + setForm((current) => ({ ...current, - patientId: data[0]?.id || '', + patientId: patientsData?.length ? patientsData[0].id : '', + professionalId: professionalsData?.length ? professionalsData[0].id : '', })) - }) + }).catch(e => console.error(e)) }, []) const visibleAppointments = useMemo(() => { @@ -54,26 +62,28 @@ useEffect(() => { setForm((current) => ({ ...current, [field]: value })) } - function handleCreate(event) { + async function handleCreate(event) { event.preventDefault() - const patient = patients.find((item) => item.id === form.patientId) || patients[0] - - setLocalAppointments((current) => [ - ...current, - { - id: `apt-local-${current.length + 1}`, - date: '2026-04-07', - patient: patient.name, - patientId: patient.id, - professional: form.professional, - room: form.mode === 'Teleconsulta' ? 'Sala virtual 3' : 'Sala 02', - status: 'Confirmada', + + // Fallback date and time + const today = new Date().toISOString().split('T')[0] + + try { + const created = await appointmentRepository.create({ + patientId: form.patientId, + date: today, time: form.time, type: form.type, mode: form.mode, - }, - ]) - setModalOpen(false) + room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1', + professionalId: form.professionalId, + }) + + setLocalAppointments((current) => [...current, created]) + setModalOpen(false) + } catch(err) { + alert(err.message || 'Erro ao criar agendamento.') + } } return ( @@ -244,7 +254,7 @@ useEffect(() => { > {patients.map((patient) => ( ))} @@ -274,11 +284,11 @@ useEffect(() => { diff --git a/src/pages/AuthPages.jsx b/src/pages/AuthPages.jsx index 02ff71d..ff8d741 100644 --- a/src/pages/AuthPages.jsx +++ b/src/pages/AuthPages.jsx @@ -1,5 +1,7 @@ import { useState } from 'react' +import { authRepository } from '../repositories/authRepository.js' + import { BrandLogo } from '../components/Brand.jsx' import loginClinicImage from '../assets/figma/login-clinic.png' @@ -9,14 +11,26 @@ export function LoginPage({ navigate }) { password: '', }) const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') function updateField(field, value) { setForm((current) => ({ ...current, [field]: value })) } - function handleSubmit(event) { + async function handleSubmit(event) { event.preventDefault() - navigate('/inicio') + setLoading(true) + setError('') + + try { + await authRepository.login(form) + navigate('/inicio') + } catch (err) { + setError(err.message || 'Erro de autenticação') + } finally { + setLoading(false) + } } return ( @@ -74,6 +88,12 @@ export function LoginPage({ navigate }) {

+ {error && ( +
+ {error} +
+ )} +
@@ -204,29 +225,52 @@ export function RegisterPage({ navigate }) { export function ForgotPasswordPage({ navigate }) { const [sent, setSent] = useState(false) + const [email, setEmail] = useState('recepcao@mediconnect.com') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSubmit(event) { + event.preventDefault() + setLoading(true) + setError('') + try { + await authRepository.requestPasswordReset(email) + setSent(true) + } catch (err) { + setError(err.message || 'Erro ao comunicar com o servidor.') + } finally { + setLoading(false) + } + } return ( {sent ? (
- Link de recuperação mockado enviado para o e-mail informado. + Link de recuperação enviado para o e-mail informado. Siga as instruções do link!
) : (
{ - event.preventDefault() - setSent(true) - }} + onSubmit={handleSubmit} > + {error && ( +
+ {error} +
+ )} - + setEmail(e.target.value)} value={email} type="email" /> -
)} diff --git a/src/pages/MessagesPage.jsx b/src/pages/MessagesPage.jsx index 107ccfa..05898fb 100644 --- a/src/pages/MessagesPage.jsx +++ b/src/pages/MessagesPage.jsx @@ -18,6 +18,7 @@ const statusConfig = { const emptyMessage = { patient: '', + phone: '', channel: 'whatsapp', template: 'Lembrete 48h', content: '', @@ -79,6 +80,7 @@ export function MessagesPage() { function openTemplate(template) { setComposer({ patient: '', + phone: '', channel: template.channel, template: template.name, content: template.content, @@ -86,13 +88,33 @@ export function MessagesPage() { setComposerOpen(true) } - function submitMessage(event) { + async function submitMessage(event) { event.preventDefault() if (!composer.patient.trim()) { return } + let smsSent = false + + if (composer.channel === 'sms') { + if (!composer.phone.trim()) { + alert('Informe o telefone para enviar SMS.') + return + } + + try { + await communicationRepository.sendSms({ + patientName: composer.patient.trim(), + phone: composer.phone.trim(), + content: composer.content, + }) + smsSent = true + } catch (e) { + alert('Falha ao disparar SMS: ' + e.message) + } + } + setMessages((current) => [ { id: `local-${Date.now()}`, @@ -100,7 +122,7 @@ export function MessagesPage() { channel: composer.channel, template: composer.template.trim() || 'Mensagem avulsa', sentAt: 'Agora', - status: 'pendente', + status: composer.channel === 'sms' ? (smsSent ? 'entregue' : 'falha') : 'pendente', response: '', }, ...current, @@ -300,6 +322,7 @@ export function MessagesPage() { onClick={() => { setComposer({ patient: campaign.count, + phone: '', channel: 'whatsapp', template: campaign.title, content: campaign.desc, @@ -470,6 +493,17 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) { + {draft.channel === 'sms' ? ( + + update('phone', event.target.value)} + placeholder="(81) 99999-9999" + value={draft.phone} + /> + + ) : null} + + {avatarError ?

{avatarError}

: null} @@ -79,10 +142,18 @@ export function ProfilePage() { @@ -106,3 +177,13 @@ function Info({ label, value }) { ) } + +function initials(name) { + return String(name || 'US') + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase() +} diff --git a/src/pages/ReportsPage.jsx b/src/pages/ReportsPage.jsx index 93749d6..3c98582 100644 --- a/src/pages/ReportsPage.jsx +++ b/src/pages/ReportsPage.jsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { reportRepository } from '../repositories/reportRepository.js' +import { patientRepository } from '../repositories/patientRepository.js' const statusConfig = { @@ -43,7 +44,14 @@ const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]' const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm' export function ReportsPage() { - const [reports, setReports] = useState(() => reportRepository.getInitialReports()) + const [reports, setReports] = useState([]) + const [patients, setPatients] = useState([]) + + useEffect(() => { + reportRepository.getInitialReports().then(setReports).catch(console.error) + patientRepository.getAll().then(setPatients).catch(console.error) + }, []) + const [search, setSearch] = useState('') const [filterStatus, setFilterStatus] = useState('') const [openMenuId, setOpenMenuId] = useState(null) @@ -104,64 +112,34 @@ export function ReportsPage() { setEditorOpen(true) } - function saveReport(status) { - if (!editor.patient.trim() || !editor.content.trim()) { - return - } + async function saveReport(status) { + if (!editor.patient.trim() || !editor.content.trim()) return + + try { + const selectedPatient = patients.find(p => p.name === editor.patient || p.full_name === editor.patient) + const patientId = selectedPatient?.id || null - const date = new Date().toLocaleDateString('pt-BR') - setReports((currentReports) => { if (editor.id) { - return currentReports.map((report) => - report.id === editor.id - ? { - ...report, - type: editor.type, - patient: editor.patient, - doctor: editor.doctor, - content: editor.content, - showDate: editor.showDate, - signDigital: editor.signDigital, - status, - versions: [ - ...report.versions, - { - version: report.versions.length + 1, - action: status === 'finalizado' ? 'Liberado' : 'Rascunho', - user: currentUser, - summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo', - }, - ], - } - : report, - ) - } - - return [ - { - id: `report-${Date.now()}`, + const updated = await reportRepository.update(editor.id, { type: editor.type, - patient: editor.patient, - doctor: editor.doctor, - date, - status, content: editor.content, - showDate: editor.showDate, - signDigital: editor.signDigital, - versions: [ - { version: 1, action: 'Criado', user: currentUser, summary: 'Laudo criado localmente' }, - { - version: 2, - action: status === 'finalizado' ? 'Liberado' : 'Rascunho', - user: currentUser, - summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo', - }, - ], - }, - ...currentReports, - ] - }) - setEditorOpen(false) + patientId: patientId, + status, + }) + setReports(curr => curr.map(r => r.id == updated.id ? { ...updated, status } : r)) + } else { + const created = await reportRepository.create({ + type: editor.type, + content: editor.content, + patientId: patientId, + status, + }) + setReports(curr => [{ ...created, status }, ...curr]) + } + setEditorOpen(false) + } catch(e) { + alert(e.message || 'Erro ao persistir na Base de Dados') + } } function releaseReport(reportId) { @@ -391,7 +369,7 @@ function ReportRow({ type="button" > - v{report.versions.length} + v{report.versions ? report.versions.length : 1} diff --git a/src/pages/TeamPage.jsx b/src/pages/TeamPage.jsx index 95dc43b..25265f7 100644 --- a/src/pages/TeamPage.jsx +++ b/src/pages/TeamPage.jsx @@ -1,11 +1,16 @@ +import { useState, useEffect } from 'react' import { professionalRepository } from '../repositories/professionalRepository.js' const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm' export function TeamPage({ navigate }) { - const professionals = professionalRepository.getAll() + const [professionals, setProfessionals] = useState([]) const { slots, weekdays } = professionalRepository.getCoverageMap() + useEffect(() => { + professionalRepository.getAll().then(setProfessionals).catch(console.error) + }, []) + return (
diff --git a/src/repositories/appointmentRepository.js b/src/repositories/appointmentRepository.js index e733ba9..e5f217b 100644 --- a/src/repositories/appointmentRepository.js +++ b/src/repositories/appointmentRepository.js @@ -1,8 +1,50 @@ -import { appointments as mockAppointments } from '../data/mockData.js' +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { appointmentMapper } from '../mappers/appointmentMapper.js' +import { fetchJsonWithFallback, normalizeCollection, normalizeItem } from './repositoryUtils.js' export const appointmentRepository = { - getAll() { - return mockAppointments + async getAll() { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/agendamentos'), + options: { headers: getAuthenticatedHeaders() }, + }, + { + url: `${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(name)`, + options: { headers: getAuthenticatedHeaders() }, + }, + ], + 'Erro ao buscar agendamentos.', + ) + + return normalizeCollection(data, ['agendamentos', 'appointments', 'data']).map(appointmentMapper.toUi) + }, + + async create(uiData) { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/agendamentos'), + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(appointmentMapper.toApi(uiData)), + }, + }, + { + url: `${apiConfig.restUrl}/appointments`, + options: { + method: 'POST', + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), + body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')), + }, + }, + ], + 'Falha ao criar o agendamento.', + ) + + return appointmentMapper.toUi(normalizeItem(data, ['agendamento', 'appointment', 'data'])) }, getTodayTimeline() { diff --git a/src/repositories/authRepository.js b/src/repositories/authRepository.js new file mode 100644 index 0000000..80cac01 --- /dev/null +++ b/src/repositories/authRepository.js @@ -0,0 +1,124 @@ +import { + apiConfig, + apiEndpoint, + clearAuthSession, + getAnonHeaders, + getAuthenticatedHeaders, + getAuthSession, + hasAuthenticatedSession, + saveAuthSession, +} from '../config/api.js' + +export const authRepository = { + async login({ email, password }) { + const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/token?grant_type=password`, { + method: 'POST', + headers: getAnonHeaders(), + body: JSON.stringify({ email: email?.trim(), password }), + }) + + if (!response.ok) { + throw new Error(await getResponseError(response, 'Erro de autenticacao.')) + } + + const session = await response.json() + if (!session?.access_token) { + throw new Error('Falha no login. Token nao recebido.') + } + + saveAuthSession(session) + return session + }, + + async requestPasswordReset(email) { + const payload = { email: email?.trim() } + const apiResponse = await fetch(apiEndpoint('/solicitar-reset-de-senha'), { + method: 'POST', + headers: getAnonHeaders(), + body: JSON.stringify(payload), + }).catch(() => null) + + if (apiResponse?.ok) { + return true + } + + if (apiResponse && !shouldFallback(apiResponse)) { + throw new Error(await getResponseError(apiResponse, 'Erro ao solicitar reset de senha.')) + } + + const supabaseResponse = await fetch(`${apiConfig.supabaseUrl}/auth/v1/recover`, { + method: 'POST', + headers: getAnonHeaders(), + body: JSON.stringify(payload), + }) + + if (!supabaseResponse.ok) { + throw new Error(await getResponseError(supabaseResponse, 'Erro ao enviar link de recuperacao.')) + } + + return true + }, + + async getUser() { + const apiResponse = await fetch(apiEndpoint('/informacoes-do-usuario-autenticado'), { + method: 'GET', + headers: getAuthenticatedHeaders(), + }).catch(() => null) + + if (apiResponse?.ok) { + return apiResponse.json() + } + + if (apiResponse && !shouldFallback(apiResponse)) { + throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.')) + } + + const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, { + method: 'GET', + headers: getAuthenticatedHeaders(), + }) + + if (!response.ok) { + throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuario.')) + } + + return response.json() + }, + + getSession() { + return getAuthSession() + }, + + isAuthenticated() { + return hasAuthenticatedSession() + }, + + async logout() { + try { + const apiResponse = await fetch(apiEndpoint('/logout'), { + method: 'POST', + headers: getAuthenticatedHeaders(), + }).catch(() => null) + + if (apiResponse?.ok || (apiResponse && !shouldFallback(apiResponse))) return + + await fetch(`${apiConfig.supabaseUrl}/auth/v1/logout`, { + method: 'POST', + headers: getAuthenticatedHeaders(), + }) + } catch { + // A sessao local precisa ser removida mesmo quando o backend nao responde. + } finally { + clearAuthSession() + } + }, +} + +function shouldFallback(response) { + return [404, 405].includes(response.status) +} + +async function getResponseError(response, fallbackMessage) { + const error = await response.json().catch(() => ({})) + return error.error_description || error.msg || error.message || error.error || fallbackMessage +} diff --git a/src/repositories/communicationRepository.js b/src/repositories/communicationRepository.js index d671251..ba42579 100644 --- a/src/repositories/communicationRepository.js +++ b/src/repositories/communicationRepository.js @@ -1,4 +1,42 @@ +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { fetchJsonWithFallback } from './repositoryUtils.js' + export const communicationRepository = { + async sendSms({ patientName, phone, content }) { + const message = `[MediConnect] Ola ${patientName}, ${content}` + const payload = { + telefone: normalizePhone(phone), + phone: normalizePhone(phone), + mensagem: message, + message, + paciente: patientName, + } + + await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/enviar-sms-via-twilio'), + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(payload), + }, + }, + { + url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`, + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(payload), + }, + }, + ], + 'Falha no envio de SMS via Twilio.', + ) + + return true + }, + getCampaigns() { return [ { title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' }, @@ -31,3 +69,9 @@ export const communicationRepository = { ] }, } + +function normalizePhone(phone) { + const digits = String(phone || '').replace(/\D/g, '') + if (!digits) return '' + return digits.startsWith('55') ? `+${digits}` : `+55${digits}` +} diff --git a/src/repositories/patientRepository.js b/src/repositories/patientRepository.js index 6d1ecba..b68358a 100644 --- a/src/repositories/patientRepository.js +++ b/src/repositories/patientRepository.js @@ -1,33 +1,22 @@ -import { apiConfig, apiHeaders } from '../config/api.js' +import { apiConfig, getAuthenticatedHeaders } from '../config/api.js' export const patientRepository = { // 1. Listar pacientes async getAll() { - const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: apiHeaders }) + const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() }) if (!response.ok) throw new Error('Erro ao buscar pacientes') return response.json() }, async getById(patientId) { const patients = await this.getAll() - return patients.find((p) => String(p.id) === String(patientId)) || null + const patient = patients.find((p) => String(p.id) === String(patientId)) || null + return patient ? mapPatientToDetail(patient) : null }, async getDirectoryRows() { const patients = await this.getAll() - return patients.map((patient) => ({ - ...patient, - name: patient.full_name, - phone: patient.phone_mobile, - detailId: patient.id, - insurance: 'Particular', - city: 'Recife', - state: 'PE', - vip: false, - lastVisitIso: null, - lastVisit: 'Ainda nao houve atendimento', - nextVisit: 'Nenhum atendimento agendado', - })) + return patients.map(mapPatientToDirectory) }, // 2. Criar paciente (direto) @@ -43,7 +32,7 @@ export const patientRepository = { const response = await fetch(`${apiConfig.restUrl}/patients`, { method: 'POST', - headers: { ...apiHeaders, Prefer: 'return=representation' }, + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), body: JSON.stringify(body), }) @@ -69,7 +58,7 @@ export const patientRepository = { const response = await fetch(`${apiConfig.functionsUrl}/create-patient`, { method: 'POST', - headers: apiHeaders, + headers: getAuthenticatedHeaders(), body: JSON.stringify(body), }) @@ -93,7 +82,7 @@ export const patientRepository = { const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, { method: 'PATCH', - headers: { ...apiHeaders, Prefer: 'return=representation' }, + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), body: JSON.stringify(body), }) @@ -105,10 +94,62 @@ export const patientRepository = { async remove(patientId) { const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, { method: 'DELETE', - headers: apiHeaders, + headers: getAuthenticatedHeaders(), }) if (!response.ok) throw new Error('Erro ao deletar paciente') return true }, } + +function mapPatientToDirectory(patient) { + return { + ...patient, + name: patient.name || patient.full_name || patient.nome || 'Paciente', + phone: patient.phone || patient.phone_mobile || patient.telefone || '', + detailId: patient.id, + insurance: patient.insurance || patient.convenio || 'Particular', + city: patient.city || patient.cidade || 'Recife', + state: patient.state || patient.uf || 'PE', + vip: Boolean(patient.vip), + lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || null, + lastVisit: patient.lastVisit || patient.last_visit || 'Ainda nao houve atendimento', + nextVisit: patient.nextVisit || patient.next_visit || 'Nenhum atendimento agendado', + } +} + +function mapPatientToDetail(patient) { + const directory = mapPatientToDirectory(patient) + + return { + ...directory, + age: patient.age || patient.idade || calculateAge(patient.birth_date), + document: patient.document || patient.cpf || 'CPF nao informado', + plan: directory.insurance, + condition: patient.condition || patient.condicao || 'Sem condicao principal', + status: patient.status || 'Acompanhamento', + risk: patient.risk || patient.risco || 'Baixo', + email: patient.email || '', + address: patient.address || patient.endereco || 'Endereco nao informado', + team: patient.team || patient.equipe || [], + notes: patient.notes || patient.observacoes || [], + exams: patient.exams || patient.exames || [], + } +} + +function calculateAge(birthDate) { + if (!birthDate) return 0 + + const birth = new Date(birthDate) + if (Number.isNaN(birth.getTime())) return 0 + + const today = new Date() + let age = today.getFullYear() - birth.getFullYear() + const monthDiff = today.getMonth() - birth.getMonth() + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { + age -= 1 + } + + return age +} diff --git a/src/repositories/professionalRepository.js b/src/repositories/professionalRepository.js index d52f17e..11242f3 100644 --- a/src/repositories/professionalRepository.js +++ b/src/repositories/professionalRepository.js @@ -1,8 +1,23 @@ -import { professionals as mockProfessionals } from '../data/mockData.js' +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { fetchJsonWithFallback, normalizeCollection } from './repositoryUtils.js' export const professionalRepository = { - getAll() { - return mockProfessionals + async getAll() { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/listar-medicos'), + options: { headers: getAuthenticatedHeaders() }, + }, + { + url: `${apiConfig.restUrl}/doctors`, + options: { headers: getAuthenticatedHeaders() }, + }, + ], + 'Erro ao buscar medicos.', + ) + + return normalizeCollection(data, ['medicos', 'doctors', 'professionals', 'data']).map(mapProfessional) }, getCoverageMap() { @@ -12,3 +27,15 @@ export const professionalRepository = { } }, } + +function mapProfessional(doctor) { + return { + id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome), + name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)', + role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Medico(a)', + schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h', + nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente', + patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0, + status: doctor.status || doctor.situacao || 'Disponivel', + } +} diff --git a/src/repositories/profileRepository.js b/src/repositories/profileRepository.js index 8c69ad7..c450f99 100644 --- a/src/repositories/profileRepository.js +++ b/src/repositories/profileRepository.js @@ -1,11 +1,74 @@ +import { authRepository } from './authRepository.js' +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { getResponseError } from './repositoryUtils.js' + export const profileRepository = { - getCurrentUserProfile() { + async getCurrentUserProfile() { + const data = await authRepository.getUser() + const user = data?.user || data?.usuario || data?.profile || data + const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {} + const avatarUrl = user?.avatarUrl || user?.avatar_url || meta.avatar_url || meta.picture || '' + return { - email: 'henrique.cardoso@mediconnect.com.br', - name: 'Dr. Henrique Cardoso', - phone: '(81) 98888-0101', - role: 'Medico Clinico Geral', - unit: 'Clinica Boa Vista', + id: user?.id || user?.user_id || user?.uid || '', + email: user?.email || meta.email || '', + name: user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario', + phone: user?.phone || user?.telefone || meta.phone || meta.telefone || '', + role: user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema', + unit: user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista', + avatarUrl, + } + }, + + async updateAvatar(file) { + const profile = await this.getCurrentUserProfile() + const formData = new FormData() + formData.append('avatar', file) + formData.append('file', file) + + const apiResponse = await fetch(apiEndpoint('/upload-avatar'), { + method: 'POST', + headers: getAuthenticatedHeaders({ 'Content-Type': undefined }), + body: formData, + }).catch(() => null) + + if (apiResponse?.ok) { + return normalizeAvatarResponse(await apiResponse.json().catch(() => ({}))) + } + + if (apiResponse && ![404, 405].includes(apiResponse.status)) { + throw new Error(await getResponseError(apiResponse, 'Falha ao enviar avatar.')) + } + + if (!profile.id) { + throw new Error('Nao foi possivel identificar o usuario para enviar o avatar.') + } + + const extension = file.name?.split('.').pop() || 'jpg' + const objectPath = `${profile.id}/avatar.${extension}` + const response = await fetch(`${apiConfig.storageUrl}/object/avatars/${objectPath}`, { + method: 'POST', + headers: getAuthenticatedHeaders({ + 'Content-Type': file.type || 'application/octet-stream', + 'x-upsert': 'true', + }), + body: file, + }) + + if (!response.ok) { + throw new Error(await getResponseError(response, 'Falha ao enviar avatar.')) + } + + return { + avatarUrl: `${apiConfig.storageUrl}/object/public/avatars/${objectPath}`, + path: objectPath, } }, } + +function normalizeAvatarResponse(data) { + return { + avatarUrl: data.avatarUrl || data.avatar_url || data.publicUrl || data.public_url || data.url || '', + path: data.path || data.key || '', + } +} diff --git a/src/repositories/reportRepository.js b/src/repositories/reportRepository.js index b4be4e4..9ad9242 100644 --- a/src/repositories/reportRepository.js +++ b/src/repositories/reportRepository.js @@ -1,121 +1,142 @@ -const reportTypes = [ - 'Atestado Medico', - 'Laudo de Exame', - 'Laudo de Imagem', - 'Relatorio Cirurgico', - 'Declaracao de Acompanhante', - 'Encaminhamento', -] - -const doctors = ['Dra. Ana Silva', 'Dr. Carlos Mendes', 'Dr. Roberto Nunes'] -const currentUser = 'Dra. Ana Silva' -const adminUsers = ['Dr. Roberto Nunes'] +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { reportMapper } from '../mappers/reportMapper.js' +import { fetchJsonWithFallback, normalizeCollection, normalizeItem } from './repositoryUtils.js' export const reportRepository = { - getAdminUsers() { - return adminUsers + async getInitialReports() { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/reports'), + options: { headers: getAuthenticatedHeaders() }, + }, + { + url: `${apiConfig.restUrl}/reports?select=*,patients(full_name),doctors(name)`, + options: { headers: getAuthenticatedHeaders() }, + }, + ], + 'Falha ao buscar laudos da API.', + ) + + return normalizeCollection(data, ['reports', 'relatorios', 'laudos', 'data']).map(reportMapper.toUi) }, - getCurrentUser() { - return currentUser + async create(uiData) { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/reports'), + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(reportMapper.toApi(uiData)), + }, + }, + { + url: `${apiConfig.restUrl}/reports`, + options: { + method: 'POST', + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), + body: JSON.stringify(reportMapper.toApi(uiData, 'supabase')), + }, + }, + ], + 'Falha ao salvar laudo.', + ) + + return reportMapper.toUi(normalizeItem(data, ['report', 'relatorio', 'laudo', 'data'])) }, - getDoctors() { - return doctors - }, + async update(id, uiData) { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint(`/reports/${id}`), + options: { + method: 'PATCH', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(reportMapper.toApi({ ...uiData, id })), + }, + }, + { + url: apiEndpoint('/reports'), + options: { + method: 'PATCH', + headers: getAuthenticatedHeaders(), + body: JSON.stringify({ id, ...reportMapper.toApi(uiData) }), + }, + }, + { + url: `${apiConfig.restUrl}/reports?id=eq.${id}`, + options: { + method: 'PATCH', + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), + body: JSON.stringify(reportMapper.toApi(uiData, 'supabase')), + }, + }, + ], + 'Falha ao atualizar o laudo.', + ) - getInitialReports() { - return [ - { - id: 'report-1', - type: 'Atestado Medico', - patient: 'Carlos Eduardo Santos', - doctor: 'Dra. Ana Silva', - date: '27/03/2026', - status: 'finalizado', - content: 'Atesto que o paciente esteve em consulta medica nesta data, necessitando de repouso por 2 dias.', - showDate: true, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' }, - { version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Ajuste no periodo de repouso' }, - { version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Laudo liberado e finalizado' }, - ], - }, - { - id: 'report-2', - type: 'Laudo de Exame', - patient: 'Mariana Costa', - doctor: 'Dra. Ana Silva', - date: '26/03/2026', - status: 'enviado', - content: 'Laudo referente ao exame de ecocardiograma. Resultado dentro dos parametros normais.', - showDate: true, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Laudo criado' }, - { version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Adicao da data do exame' }, - { version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao incluida' }, - { version: 4, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' }, - ], - }, - { - id: 'report-3', - type: 'Relatorio Cirurgico', - patient: 'Fernanda Lima', - doctor: 'Dr. Carlos Mendes', - date: '25/03/2026', - status: 'rascunho', - content: 'Relatorio do procedimento de colecistectomia laparoscopica realizado sob anestesia geral.', - showDate: false, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Relatorio criado' }, - { version: 2, action: 'Rascunho', user: 'Dr. Carlos Mendes', summary: 'Detalhamento do procedimento' }, - ], - }, - { - id: 'report-4', - type: 'Declaracao de Acompanhante', - patient: 'Joao Pedro Alves', - doctor: 'Dr. Roberto Nunes', - date: '24/03/2026', - status: 'finalizado', - content: 'Declaro que o acompanhante esteve presente durante todo o periodo de internacao.', - showDate: true, - signDigital: false, - versions: [ - { version: 1, action: 'Criado', user: 'Dr. Roberto Nunes', summary: 'Declaracao criada e liberada' }, - ], - }, - { - id: 'report-5', - type: 'Laudo de Imagem', - patient: 'Roberto Campos', - doctor: 'Dra. Ana Silva', - date: '22/03/2026', - status: 'enviado', - content: 'Ultrassonografia de abdomen total sem achados patologicos relevantes.', - showDate: true, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' }, - { version: 2, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao adicionada' }, - { version: 3, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' }, - ], - }, - ] - }, - - getReportTypes() { - return reportTypes + return reportMapper.toUi(normalizeItem(data, ['report', 'relatorio', 'laudo', 'data'])) }, getTemplates() { return [ - { id: 'template-1', name: 'Atestado de Repouso Simples', type: 'Atestado Medico', description: 'Atestado padrao concedendo dias de repouso ao paciente.', content: 'Atesto, para os devidos fins, que o(a) paciente necessita de repouso pelo periodo indicado.' }, - { id: 'template-2', name: 'Laudo de Hemograma', type: 'Laudo de Exame', description: 'Resultado de hemograma completo com interpretacao clinica.', content: 'Laudo de hemograma completo com parametros avaliados e interpretacao clinica.' }, - { id: 'template-3', name: 'Relatorio Cirurgico', type: 'Relatorio Cirurgico', description: 'Relatorio padronizado para procedimento cirurgico.', content: 'Relatorio do procedimento cirurgico, achados, conduta e evolucao imediata.' }, + { + id: 't1', + name: 'Atestado Medico Padrao', + type: 'Atestado Medico', + description: 'Atestado simples para repouso, consulta e CID.', + content: + 'Atesto para os devidos fins que o(a) paciente [NOME DO PACIENTE] esteve em consulta medica nesta data, necessitando de [DIAS] dias de repouso por motivo de saude (CID: [CODIGO]).', + }, + { + id: 't2', + name: 'Encaminhamento Especializado', + type: 'Encaminhamento', + description: 'Encaminhamento para avaliacao de especialidade.', + content: + 'Encaminho o(a) paciente [NOME DO PACIENTE] para avaliacao da especialidade de [ESPECIALIDADE] devido ao quadro clinico de [SINTOMAS/DIAGNOSTICO PREVIO].\n\nConduta mantida ate o momento: [MEDICACOES]', + }, + { + id: 't3', + name: 'Laudo de Evolucao Diaria', + type: 'Evolucao Clinica', + description: 'Modelo para evolucao clinica diaria.', + content: + 'Paciente evolui [BEM/MAL], [COM/SEM] queixas no momento.\nSinais vitais: PA [VALOR], FC [VALOR] bpm, SatO2 [VALOR]%.\nExame fisico: [DESCRICAO].\nConduta: [MANTER/ALTERAR TRATAMENTO OPCOES].', + }, + { + id: 't4', + name: 'Receituario de Uso Continuo', + type: 'Receituario Fixado', + description: 'Lista de medicamentos de uso continuo.', + content: + 'Uso continuo:\n1. [MEDICAMENTO] - [DOSE] - Tomar [POSOLOGIA]\n2. [MEDICAMENTO] - [DOSE] - Tomar [POSOLOGIA]', + }, + ] + }, + + getAdminUsers() { + return ['Dr. Henrique Cardoso', 'Dra. Marina Lopes', 'Dra. Ana Silva'] + }, + + getCurrentUser() { + return 'Dr. Henrique Cardoso' + }, + + getDoctors() { + return ['Dr. Henrique Cardoso', 'Dra. Marina Lopes', 'Dra. Ana Silva', 'Dr. Roberto Santos'] + }, + + getReportTypes() { + return [ + 'Atestado Medico', + 'Encaminhamento', + 'Evolucao Clinica', + 'Receituario Fixado', + 'Laudo de Procedimento', ] }, } diff --git a/src/repositories/repositoryUtils.js b/src/repositories/repositoryUtils.js new file mode 100644 index 0000000..5ade276 --- /dev/null +++ b/src/repositories/repositoryUtils.js @@ -0,0 +1,74 @@ +export async function fetchJsonWithFallback(requests, fallbackMessage) { + let lastResponse = null + let lastError = null + + for (const request of requests) { + let response + + try { + response = await fetch(request.url, request.options) + lastResponse = response + } catch (error) { + lastError = error + continue + } + + if (response.ok) { + return parseJsonResponse(response) + } + + if (!shouldFallback(response)) { + throw new Error(await getResponseError(response, fallbackMessage)) + } + } + + if (lastError && !lastResponse) { + throw new Error(lastError.message || fallbackMessage) + } + + throw new Error(await getResponseError(lastResponse, fallbackMessage)) +} + +export function normalizeCollection(data, keys = []) { + if (Array.isArray(data)) return data + + for (const key of keys) { + if (Array.isArray(data?.[key])) return data[key] + } + + return [] +} + +export function normalizeItem(data, keys = []) { + if (Array.isArray(data)) return data[0] || null + + for (const key of keys) { + if (data?.[key]) return data[key] + } + + return data || null +} + +export async function getResponseError(response, fallbackMessage) { + if (!response) return fallbackMessage + + const error = await response.json().catch(() => ({})) + return error.error_description || error.msg || error.message || error.error || fallbackMessage +} + +function shouldFallback(response) { + return [404, 405].includes(response.status) +} + +async function parseJsonResponse(response) { + if (response.status === 204) return null + + const text = await response.text() + if (!text) return null + + try { + return JSON.parse(text) + } catch { + return { message: text } + } +}