forked from RiseUP/riseup_squad_03
fix(principal): integra auth, agenda e laudos com a api
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
VITE_SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co
|
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_REST_URL=https://yuanqfswhberkoevtmfr.supabase.co/rest/v1
|
||||||
VITE_SUPABASE_FUNCTIONS_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1
|
VITE_SUPABASE_FUNCTIONS_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1
|
||||||
VITE_SUPABASE_STORAGE_URL=https://yuanqfswhberkoevtmfr.supabase.co/storage/v1
|
VITE_SUPABASE_STORAGE_URL=https://yuanqfswhberkoevtmfr.supabase.co/storage/v1
|
||||||
|
|||||||
@@ -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**
|
||||||
| --- | --- | --- | --- |
|
- Login com email e senha via Supabase Auth (`/auth/v1/token`).
|
||||||
| Autenticacao | POST | `/auth/v1/token?grant_type=password` | Sem repository dedicado |
|
- Solicitar reset de senha: tenta `/solicitar-reset-de-senha` e usa `/auth/v1/recover` como fallback.
|
||||||
| Autenticacao | POST | `/auth/v1/otp` | Sem repository dedicado |
|
- Dados do usuario autenticado: tenta `/informacoes-do-usuario-autenticado` e usa `/auth/v1/user` como fallback.
|
||||||
| Autenticacao | POST | `/auth/v1/logout` | Sem repository dedicado |
|
- Logout: tenta `/logout`, usa `/auth/v1/logout` como fallback e sempre limpa a sessao local.
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## 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 |
|
- **Agendamentos**
|
||||||
| --- | --- | --- | --- |
|
- Listar agendamentos: tenta `GET /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||||
| `analyticsRepository` | `getDashboardData` | Nao documentado | Retorna dados estaticos de KPIs, graficos e pacientes frequentes. |
|
- Criar agendamento: tenta `POST /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||||
| `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. |
|
|
||||||
|
|
||||||
## 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.
|
- **Medicos / Profissionais**
|
||||||
- `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`.
|
- Listar medicos: tenta `GET /listar-medicos` e usa Supabase REST `doctors` como fallback.
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 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`
|
## Ainda sem endpoint consolidado documentado
|
||||||
- `VITE_SUPABASE_REST_URL`
|
|
||||||
- `VITE_SUPABASE_FUNCTIONS_URL`
|
|
||||||
- `VITE_SUPABASE_STORAGE_URL`
|
|
||||||
- `VITE_SUPABASE_ANON_KEY`
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
43
src/App.jsx
43
src/App.jsx
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { authRepository } from './repositories/authRepository.js'
|
||||||
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { AppShell } from './components/AppShell.jsx'
|
import { AppShell } from './components/AppShell.jsx'
|
||||||
import { AgendaPage } from './pages/AgendaPage.jsx'
|
import { AgendaPage } from './pages/AgendaPage.jsx'
|
||||||
@@ -48,11 +50,16 @@ function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate])
|
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate])
|
||||||
|
const isAuthenticated = authRepository.isAuthenticated()
|
||||||
|
|
||||||
if (!route.withShell) {
|
if (!route.withShell) {
|
||||||
return route.element
|
return route.element
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <LoginPage navigate={navigate} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
|
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
|
||||||
{route.element}
|
{route.element}
|
||||||
@@ -119,15 +126,10 @@ function resolveRoute(pathname, navigate) {
|
|||||||
|
|
||||||
if (pathname.startsWith('/pacientes/')) {
|
if (pathname.startsWith('/pacientes/')) {
|
||||||
const patientId = pathname.split('/')[2]
|
const patientId = pathname.split('/')[2]
|
||||||
const patient = patientRepository.getById(patientId)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element: patient ? (
|
element: <PatientDetailRoute navigate={navigate} patientId={patientId} />,
|
||||||
<PatientDetailPage navigate={navigate} patient={patient} />
|
title: 'Paciente',
|
||||||
) : (
|
|
||||||
<NotFoundPage navigate={navigate} />
|
|
||||||
),
|
|
||||||
title: patient?.name || 'Paciente nao encontrado',
|
|
||||||
withShell: true,
|
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 <div className="pt-10 text-sm text-[#a3a3a3]">Carregando paciente...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return patient ? <PatientDetailPage navigate={navigate} patient={patient} /> : <NotFoundPage navigate={navigate} />
|
||||||
|
}
|
||||||
|
|
||||||
function readLocation() {
|
function readLocation() {
|
||||||
return {
|
return {
|
||||||
pathname: normalizePath(window.location.pathname),
|
pathname: normalizePath(window.location.pathname),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberkoevtmfr.supabase.co'
|
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 SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
||||||
|
|
||||||
|
const AUTH_SESSION_KEY = 'mediconnect.auth.session'
|
||||||
|
|
||||||
export const apiConfig = {
|
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,
|
supabaseUrl: SUPABASE_URL,
|
||||||
restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`,
|
restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`,
|
||||||
functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
||||||
@@ -9,8 +12,76 @@ export const apiConfig = {
|
|||||||
anonKey: SUPABASE_ANON_KEY,
|
anonKey: SUPABASE_ANON_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiHeaders = {
|
export function apiEndpoint(path, baseUrl = apiConfig.apiUrl) {
|
||||||
apikey: apiConfig.anonKey,
|
const normalizedBase = baseUrl.replace(/\/+$/, '')
|
||||||
Authorization: `Bearer ${apiConfig.anonKey}`,
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
'Content-Type': 'application/json',
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/mappers/appointmentMapper.js
Normal file
61
src/mappers/appointmentMapper.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
73
src/mappers/reportMapper.js
Normal file
73
src/mappers/reportMapper.js
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,30 +16,38 @@ const viewFilters = ['Dia', 'Semana', 'Mês']
|
|||||||
|
|
||||||
export function AgendaPage({ navigate }) {
|
export function AgendaPage({ navigate }) {
|
||||||
const [patients, setPatients] = useState([])
|
const [patients, setPatients] = useState([])
|
||||||
const professionals = professionalRepository.getAll()
|
const [professionals, setProfessionals] = useState([])
|
||||||
const queue = appointmentRepository.getPredictiveQueueSummary()
|
const queue = appointmentRepository.getPredictiveQueueSummary()
|
||||||
const timeline = appointmentRepository.getTodayTimeline()
|
const timeline = appointmentRepository.getTodayTimeline()
|
||||||
const weekDays = appointmentRepository.getWeekDays()
|
const weekDays = appointmentRepository.getWeekDays()
|
||||||
const [activeView, setActiveView] = useState('Dia')
|
const [activeView, setActiveView] = useState('Dia')
|
||||||
const [status, setStatus] = useState('Todos')
|
const [status, setStatus] = useState('Todos')
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [localAppointments, setLocalAppointments] = useState(() => appointmentRepository.getAll())
|
const [localAppointments, setLocalAppointments] = useState([])
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
patientId: '',
|
patientId: '',
|
||||||
professional: professionals[0]?.name || '',
|
professionalId: '',
|
||||||
type: 'Retorno',
|
type: 'Retorno',
|
||||||
time: '15:30',
|
time: '15:30',
|
||||||
mode: 'Teleconsulta',
|
mode: 'Teleconsulta',
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
patientRepository.getAll().then((data) => {
|
Promise.all([
|
||||||
setPatients(data)
|
patientRepository.getAll(),
|
||||||
|
appointmentRepository.getAll(),
|
||||||
|
professionalRepository.getAll()
|
||||||
|
]).then(([patientsData, appointmentsData, professionalsData]) => {
|
||||||
|
setPatients(patientsData)
|
||||||
|
setLocalAppointments(appointmentsData || [])
|
||||||
|
setProfessionals(professionalsData || [])
|
||||||
|
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...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(() => {
|
const visibleAppointments = useMemo(() => {
|
||||||
@@ -54,26 +62,28 @@ useEffect(() => {
|
|||||||
setForm((current) => ({ ...current, [field]: value }))
|
setForm((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreate(event) {
|
async function handleCreate(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const patient = patients.find((item) => item.id === form.patientId) || patients[0]
|
|
||||||
|
|
||||||
setLocalAppointments((current) => [
|
// Fallback date and time
|
||||||
...current,
|
const today = new Date().toISOString().split('T')[0]
|
||||||
{
|
|
||||||
id: `apt-local-${current.length + 1}`,
|
try {
|
||||||
date: '2026-04-07',
|
const created = await appointmentRepository.create({
|
||||||
patient: patient.name,
|
patientId: form.patientId,
|
||||||
patientId: patient.id,
|
date: today,
|
||||||
professional: form.professional,
|
|
||||||
room: form.mode === 'Teleconsulta' ? 'Sala virtual 3' : 'Sala 02',
|
|
||||||
status: 'Confirmada',
|
|
||||||
time: form.time,
|
time: form.time,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
mode: form.mode,
|
mode: form.mode,
|
||||||
},
|
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
||||||
])
|
professionalId: form.professionalId,
|
||||||
setModalOpen(false)
|
})
|
||||||
|
|
||||||
|
setLocalAppointments((current) => [...current, created])
|
||||||
|
setModalOpen(false)
|
||||||
|
} catch(err) {
|
||||||
|
alert(err.message || 'Erro ao criar agendamento.')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -244,7 +254,7 @@ useEffect(() => {
|
|||||||
>
|
>
|
||||||
{patients.map((patient) => (
|
{patients.map((patient) => (
|
||||||
<option key={patient.id} value={patient.id}>
|
<option key={patient.id} value={patient.id}>
|
||||||
{patient.name}
|
{patient.name || patient.full_name || patient.nome}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -274,11 +284,11 @@ useEffect(() => {
|
|||||||
<DarkField label="Profissional">
|
<DarkField label="Profissional">
|
||||||
<select
|
<select
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
onChange={(event) => updateForm('professional', event.target.value)}
|
onChange={(event) => updateForm('professionalId', event.target.value)}
|
||||||
value={form.professional}
|
value={form.professionalId}
|
||||||
>
|
>
|
||||||
{professionals.map((professional) => (
|
{professionals.map((professional) => (
|
||||||
<option key={professional.id}>{professional.name}</option>
|
<option key={professional.id} value={professional.id}>{professional.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
|
|
||||||
import { BrandLogo } from '../components/Brand.jsx'
|
import { BrandLogo } from '../components/Brand.jsx'
|
||||||
import loginClinicImage from '../assets/figma/login-clinic.png'
|
import loginClinicImage from '../assets/figma/login-clinic.png'
|
||||||
|
|
||||||
@@ -9,14 +11,26 @@ export function LoginPage({ navigate }) {
|
|||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
function updateField(field, value) {
|
function updateField(field, value) {
|
||||||
setForm((current) => ({ ...current, [field]: value }))
|
setForm((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault()
|
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 (
|
return (
|
||||||
@@ -74,6 +88,12 @@ export function LoginPage({ navigate }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded bg-red-500/10 p-3 text-sm font-semibold text-red-500 border border-red-500/20">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form className="mt-8 grid gap-5" onSubmit={handleSubmit}>
|
<form className="mt-8 grid gap-5" onSubmit={handleSubmit}>
|
||||||
<LoginField htmlFor="login-email" label="E-mail">
|
<LoginField htmlFor="login-email" label="E-mail">
|
||||||
<input
|
<input
|
||||||
@@ -122,10 +142,11 @@ export function LoginPage({ navigate }) {
|
|||||||
</LoginField>
|
</LoginField>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2),0_4px_6px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6]"
|
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2),0_4px_6px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6] disabled:opacity-50"
|
||||||
|
disabled={loading}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Entrar
|
{loading ? 'Entrando...' : 'Entrar'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,29 +225,52 @@ export function RegisterPage({ navigate }) {
|
|||||||
|
|
||||||
export function ForgotPasswordPage({ navigate }) {
|
export function ForgotPasswordPage({ navigate }) {
|
||||||
const [sent, setSent] = useState(false)
|
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 (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
description="Informe o e-mail cadastrado para receber um link mockado."
|
description="Informe o e-mail cadastrado para receber o link de acesso."
|
||||||
title="Recuperar senha"
|
title="Recuperar senha"
|
||||||
>
|
>
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="mt-8 rounded-[6px] border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm leading-6 text-emerald-300">
|
<div className="mt-8 rounded-[6px] border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm leading-6 text-emerald-300">
|
||||||
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!
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
className="mt-8 grid gap-5"
|
className="mt-8 grid gap-5"
|
||||||
onSubmit={(event) => {
|
onSubmit={handleSubmit}
|
||||||
event.preventDefault()
|
|
||||||
setSent(true)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-500/10 p-3 text-sm font-semibold text-red-500 border border-red-500/20">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AuthField label="E-mail cadastrado">
|
<AuthField label="E-mail cadastrado">
|
||||||
<input autoComplete="email" className={authInputClass} defaultValue="recepcao@mediconnect.com" type="email" />
|
<input autoComplete="email" className={authInputClass} onChange={e => setEmail(e.target.value)} value={email} type="email" />
|
||||||
</AuthField>
|
</AuthField>
|
||||||
<button className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed]" type="submit">
|
<button
|
||||||
Enviar link
|
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] disabled:opacity-50"
|
||||||
|
disabled={loading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{loading ? "Enviando..." : "Enviar link"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const statusConfig = {
|
|||||||
|
|
||||||
const emptyMessage = {
|
const emptyMessage = {
|
||||||
patient: '',
|
patient: '',
|
||||||
|
phone: '',
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
template: 'Lembrete 48h',
|
template: 'Lembrete 48h',
|
||||||
content: '',
|
content: '',
|
||||||
@@ -79,6 +80,7 @@ export function MessagesPage() {
|
|||||||
function openTemplate(template) {
|
function openTemplate(template) {
|
||||||
setComposer({
|
setComposer({
|
||||||
patient: '',
|
patient: '',
|
||||||
|
phone: '',
|
||||||
channel: template.channel,
|
channel: template.channel,
|
||||||
template: template.name,
|
template: template.name,
|
||||||
content: template.content,
|
content: template.content,
|
||||||
@@ -86,13 +88,33 @@ export function MessagesPage() {
|
|||||||
setComposerOpen(true)
|
setComposerOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitMessage(event) {
|
async function submitMessage(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!composer.patient.trim()) {
|
if (!composer.patient.trim()) {
|
||||||
return
|
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) => [
|
setMessages((current) => [
|
||||||
{
|
{
|
||||||
id: `local-${Date.now()}`,
|
id: `local-${Date.now()}`,
|
||||||
@@ -100,7 +122,7 @@ export function MessagesPage() {
|
|||||||
channel: composer.channel,
|
channel: composer.channel,
|
||||||
template: composer.template.trim() || 'Mensagem avulsa',
|
template: composer.template.trim() || 'Mensagem avulsa',
|
||||||
sentAt: 'Agora',
|
sentAt: 'Agora',
|
||||||
status: 'pendente',
|
status: composer.channel === 'sms' ? (smsSent ? 'entregue' : 'falha') : 'pendente',
|
||||||
response: '',
|
response: '',
|
||||||
},
|
},
|
||||||
...current,
|
...current,
|
||||||
@@ -300,6 +322,7 @@ export function MessagesPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setComposer({
|
setComposer({
|
||||||
patient: campaign.count,
|
patient: campaign.count,
|
||||||
|
phone: '',
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
template: campaign.title,
|
template: campaign.title,
|
||||||
content: campaign.desc,
|
content: campaign.desc,
|
||||||
@@ -470,6 +493,17 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
|||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{draft.channel === 'sms' ? (
|
||||||
|
<DarkField label="Telefone">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => update('phone', event.target.value)}
|
||||||
|
placeholder="(81) 99999-9999"
|
||||||
|
value={draft.phone}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DarkField label="Template">
|
<DarkField label="Template">
|
||||||
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
|
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
|
||||||
<option value="Mensagem avulsa">Mensagem avulsa</option>
|
<option value="Mensagem avulsa">Mensagem avulsa</option>
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState, useEffect } from 'react'
|
||||||
|
|
||||||
import { profileRepository } from '../repositories/profileRepository.js'
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage({ navigate }) {
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [profile, setProfile] = useState(() => profileRepository.getCurrentUserProfile())
|
const [profile, setProfile] = useState({ name: '', role: '', email: '', phone: '', unit: '', avatarUrl: '' })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||||
|
const [avatarError, setAvatarError] = useState('')
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
profileRepository.getCurrentUserProfile().then(data => {
|
||||||
|
setProfile(data)
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
function update(field, value) {
|
function update(field, value) {
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
setProfile((current) => ({ ...current, [field]: value }))
|
setProfile((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authRepository.logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarChange(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setUploadingAvatar(true)
|
||||||
|
setAvatarError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await profileRepository.updateAvatar(file)
|
||||||
|
setProfile((current) => ({
|
||||||
|
...current,
|
||||||
|
avatarUrl: result.avatarUrl || URL.createObjectURL(file),
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
setAvatarError(err.message || 'Erro ao enviar avatar.')
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false)
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center pt-20 text-[#a3a3a3]">Localizando dados do paciente...</div>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
<header>
|
<header>
|
||||||
@@ -25,15 +67,36 @@ export function ProfilePage() {
|
|||||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
<section className={`${cardClass} p-6`}>
|
<section className={`${cardClass} p-6`}>
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
{profile.avatarUrl ? (
|
||||||
HC
|
<img
|
||||||
</div>
|
alt=""
|
||||||
|
className="size-16 rounded-full border border-[#3b82f6]/30 object-cover"
|
||||||
|
src={profile.avatarUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
||||||
|
{initials(profile.name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
|
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
|
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
|
||||||
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
|
<button
|
||||||
Alterar foto
|
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,10 +142,18 @@ export function ProfilePage() {
|
|||||||
<aside className={`${cardClass} p-6`}>
|
<aside className={`${cardClass} p-6`}>
|
||||||
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
|
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
|
||||||
<dl className="mt-5 grid gap-4 text-sm">
|
<dl className="mt-5 grid gap-4 text-sm">
|
||||||
<Info label="Perfil" value="Administrador da clínica" />
|
<Info label="Perfil" value={profile.role} />
|
||||||
<Info label="Último acesso" value="07 abr 2026, 09:15" />
|
<Info label="E-mail principal" value={profile.email} />
|
||||||
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
||||||
</dl>
|
</dl>
|
||||||
|
<div className="mt-8 border-t border-[#404040] pt-6">
|
||||||
|
<button
|
||||||
|
className="w-full h-10 rounded-sm border border-red-500/30 text-red-500 font-semibold text-sm transition hover:bg-red-500/10"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Sair da conta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,3 +177,13 @@ function Info({ label, value }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initials(name) {
|
||||||
|
return String(name || 'US')
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { reportRepository } from '../repositories/reportRepository.js'
|
import { reportRepository } from '../repositories/reportRepository.js'
|
||||||
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
|
|
||||||
|
|
||||||
const statusConfig = {
|
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'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
export function ReportsPage() {
|
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 [search, setSearch] = useState('')
|
||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [openMenuId, setOpenMenuId] = useState(null)
|
const [openMenuId, setOpenMenuId] = useState(null)
|
||||||
@@ -104,64 +112,34 @@ export function ReportsPage() {
|
|||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveReport(status) {
|
async function saveReport(status) {
|
||||||
if (!editor.patient.trim() || !editor.content.trim()) {
|
if (!editor.patient.trim() || !editor.content.trim()) return
|
||||||
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) {
|
if (editor.id) {
|
||||||
return currentReports.map((report) =>
|
const updated = await reportRepository.update(editor.id, {
|
||||||
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()}`,
|
|
||||||
type: editor.type,
|
type: editor.type,
|
||||||
patient: editor.patient,
|
|
||||||
doctor: editor.doctor,
|
|
||||||
date,
|
|
||||||
status,
|
|
||||||
content: editor.content,
|
content: editor.content,
|
||||||
showDate: editor.showDate,
|
patientId: patientId,
|
||||||
signDigital: editor.signDigital,
|
status,
|
||||||
versions: [
|
})
|
||||||
{ version: 1, action: 'Criado', user: currentUser, summary: 'Laudo criado localmente' },
|
setReports(curr => curr.map(r => r.id == updated.id ? { ...updated, status } : r))
|
||||||
{
|
} else {
|
||||||
version: 2,
|
const created = await reportRepository.create({
|
||||||
action: status === 'finalizado' ? 'Liberado' : 'Rascunho',
|
type: editor.type,
|
||||||
user: currentUser,
|
content: editor.content,
|
||||||
summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo',
|
patientId: patientId,
|
||||||
},
|
status,
|
||||||
],
|
})
|
||||||
},
|
setReports(curr => [{ ...created, status }, ...curr])
|
||||||
...currentReports,
|
}
|
||||||
]
|
setEditorOpen(false)
|
||||||
})
|
} catch(e) {
|
||||||
setEditorOpen(false)
|
alert(e.message || 'Erro ao persistir na Base de Dados')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseReport(reportId) {
|
function releaseReport(reportId) {
|
||||||
@@ -391,7 +369,7 @@ function ReportRow({
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ReportIcon className="size-3.5" name="history" />
|
<ReportIcon className="size-3.5" name="history" />
|
||||||
v{report.versions.length}
|
v{report.versions ? report.versions.length : 1}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="relative px-4 py-3 text-right">
|
<td className="relative px-4 py-3 text-right">
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
export function TeamPage({ navigate }) {
|
export function TeamPage({ navigate }) {
|
||||||
const professionals = professionalRepository.getAll()
|
const [professionals, setProfessionals] = useState([])
|
||||||
const { slots, weekdays } = professionalRepository.getCoverageMap()
|
const { slots, weekdays } = professionalRepository.getCoverageMap()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
professionalRepository.getAll().then(setProfessionals).catch(console.error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
|||||||
@@ -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 = {
|
export const appointmentRepository = {
|
||||||
getAll() {
|
async getAll() {
|
||||||
return mockAppointments
|
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() {
|
getTodayTimeline() {
|
||||||
|
|||||||
124
src/repositories/authRepository.js
Normal file
124
src/repositories/authRepository.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,4 +1,42 @@
|
|||||||
|
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
import { fetchJsonWithFallback } from './repositoryUtils.js'
|
||||||
|
|
||||||
export const communicationRepository = {
|
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() {
|
getCampaigns() {
|
||||||
return [
|
return [
|
||||||
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' },
|
{ 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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import { apiConfig, apiHeaders } from '../config/api.js'
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
|
||||||
export const patientRepository = {
|
export const patientRepository = {
|
||||||
// 1. Listar pacientes
|
// 1. Listar pacientes
|
||||||
async getAll() {
|
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')
|
if (!response.ok) throw new Error('Erro ao buscar pacientes')
|
||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
async getById(patientId) {
|
async getById(patientId) {
|
||||||
const patients = await this.getAll()
|
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() {
|
async getDirectoryRows() {
|
||||||
const patients = await this.getAll()
|
const patients = await this.getAll()
|
||||||
return patients.map((patient) => ({
|
return patients.map(mapPatientToDirectory)
|
||||||
...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',
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 2. Criar paciente (direto)
|
// 2. Criar paciente (direto)
|
||||||
@@ -43,7 +32,7 @@ export const patientRepository = {
|
|||||||
|
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients`, {
|
const response = await fetch(`${apiConfig.restUrl}/patients`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...apiHeaders, Prefer: 'return=representation' },
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,7 +58,7 @@ export const patientRepository = {
|
|||||||
|
|
||||||
const response = await fetch(`${apiConfig.functionsUrl}/create-patient`, {
|
const response = await fetch(`${apiConfig.functionsUrl}/create-patient`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: apiHeaders,
|
headers: getAuthenticatedHeaders(),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,7 +82,7 @@ export const patientRepository = {
|
|||||||
|
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { ...apiHeaders, Prefer: 'return=representation' },
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -105,10 +94,62 @@ export const patientRepository = {
|
|||||||
async remove(patientId) {
|
async remove(patientId) {
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: apiHeaders,
|
headers: getAuthenticatedHeaders(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao deletar paciente')
|
if (!response.ok) throw new Error('Erro ao deletar paciente')
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
export const professionalRepository = {
|
||||||
getAll() {
|
async getAll() {
|
||||||
return mockProfessionals
|
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() {
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
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 {
|
return {
|
||||||
email: 'henrique.cardoso@mediconnect.com.br',
|
id: user?.id || user?.user_id || user?.uid || '',
|
||||||
name: 'Dr. Henrique Cardoso',
|
email: user?.email || meta.email || '',
|
||||||
phone: '(81) 98888-0101',
|
name: user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
|
||||||
role: 'Medico Clinico Geral',
|
phone: user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
||||||
unit: 'Clinica Boa Vista',
|
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 || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,121 +1,142 @@
|
|||||||
const reportTypes = [
|
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
'Atestado Medico',
|
import { reportMapper } from '../mappers/reportMapper.js'
|
||||||
'Laudo de Exame',
|
import { fetchJsonWithFallback, normalizeCollection, normalizeItem } from './repositoryUtils.js'
|
||||||
'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']
|
|
||||||
|
|
||||||
export const reportRepository = {
|
export const reportRepository = {
|
||||||
getAdminUsers() {
|
async getInitialReports() {
|
||||||
return adminUsers
|
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() {
|
async create(uiData) {
|
||||||
return currentUser
|
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() {
|
async update(id, uiData) {
|
||||||
return doctors
|
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 reportMapper.toUi(normalizeItem(data, ['report', 'relatorio', 'laudo', 'data']))
|
||||||
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
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getTemplates() {
|
getTemplates() {
|
||||||
return [
|
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: 't1',
|
||||||
{ 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.' },
|
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',
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/repositories/repositoryUtils.js
Normal file
74
src/repositories/repositoryUtils.js
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user