Merge pull request 'faeture/integracao_api' (#1) from faeture/integracao_api into main

Reviewed-on: RiseUP/riseup_squad_03#1
This commit is contained in:
2026-04-28 17:41:12 +00:00
43 changed files with 2796 additions and 1296 deletions

View File

@@ -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

44
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,44 @@
# Arquitetura e Boas Práticas do Front-end (React)
Este documento estabelece as regras canônicas de arquitetura para este projeto. **Qualquer Inteligência Artificial ou desenvolvedor que atuar neste código DEVE ler e seguir estas diretrizes rigorosamente.**
O objetivo desta arquitetura é manter o ecossistema React amigável para desenvolvedores com mentalidade de Backend, priorizando a Separação de Conceitos (Separation of Concerns) e a previsibilidade dos dados.
## 1. A API é a Única Fonte da Verdade (Fim dos Mocks)
- **Regra:** Não crie, não mantenha e não faça fallback para dados mockados (falsos) em produção ou na integração.
- **Motivo:** O banco de dados (Supabase) dita as regras. Se a API falhar, o front-end deve exibir um estado de erro elegante, e não mascarar a falha com dados locais inventados.
- **Ação:** Repositórios (`*Repository.js`) devem apenas fazer o `fetch` seguro para a API e repassar a resposta.
## 2. O Padrão MVC Adaptado (Model-View-Hook)
Para evitar que as Páginas (`*Page.jsx`) se tornem "Componentes Deuses" (fazendo requisições, filtrando dados e renderizando HTML ao mesmo tempo), adotamos o seguinte fluxo:
### A. Repositório (Acesso a Dados)
- Fica na pasta `src/repositories/`.
- Sua ÚNICA função é bater na API, tratar erros HTTP e devolver o JSON puro (Array ou Objeto).
- Não deve conter regras de negócio, filtragem de tela ou formatação de datas.
### B. Mappers (Tradução Estrita)
- Fica na pasta `src/mappers/`.
- Traduz os dados do banco para o formato que a UI espera.
- **Regra de Ouro:** O Mapper deve ser **rígido**. Se o banco retorna `full_name`, o mapper converte para `name` e todo o resto da aplicação usa apenas `name`. Não propague a bagunça do banco para a tela.
### C. Custom Hooks (O Controlador)
- Fica na pasta `src/hooks/` (ex: `useAgenda.js`).
- Puxa os dados do repositório, passa pelo mapper, controla os estados de `loading`, `error`, e lida com a lógica de negócio (como submissão de formulários e filtragem de abas).
- Ele encapsula todos os `useEffect` e `useState` complexos.
### D. Páginas e Componentes (A View Burra)
- Fica em `src/pages/` e `src/components/`.
- As páginas são estritamente cascas visuais (HTML/Tailwind).
- Elas importam o Custom Hook, pegam as variáveis prontas e apenas decidem como desenhar isso na tela.
## 3. Lidar com Datas (Fuso Horário)
- Sempre que a API enviar uma data no formato string `YYYY-MM-DD`, lembre-se que o construtor nativo do JavaScript (`new Date('YYYY-MM-DD')`) converte para o horário UTC e, dependendo do fuso do usuário, pode jogar a data para o dia anterior.
- **Solução:** Use o helper local `parseLocalDate` ou processe os componentes da data (ano, mês, dia) manualmente antes de criar o objeto `Date`.
## Exemplo de Fluxo Ideal
1. A página `PacientesPage.jsx` chama o hook `const { pacientes, loading } = usePacientes()`.
2. O hook `usePacientes` chama o `patientRepository.getAll()`.
3. O repositório faz `fetch` na API.
4. O hook pega o resultado, passa no `patientMapper.toUi()` e atualiza o estado interno.
5. A página renderiza os dados que chegaram do hook.

View File

@@ -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.

4
openapi.json Normal file
View File

@@ -0,0 +1,4 @@
{
"message": "Invalid API key",
"hint": "Only the `service_role` API key can be used for this endpoint."
}

11
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "projeto-residencia",
"version": "0.0.0",
"dependencies": {
"date-fns": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
@@ -1495,6 +1496,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"date-fns": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},

View File

@@ -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 <LoginPage navigate={navigate} />
}
return (
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
{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 ? (
<PatientDetailPage navigate={navigate} patient={patient} />
) : (
<NotFoundPage navigate={navigate} />
),
title: patient?.name || 'Paciente nao encontrado',
element: <PatientDetailRoute navigate={navigate} patientId={patientId} />,
title: 'Paciente',
withShell: true,
}
}
@@ -143,7 +145,7 @@ function resolveRoute(pathname, navigate) {
if (pathname === '/laudos') {
return {
element: <ReportsPage navigate={navigate} />,
title: 'Laudos',
title: 'Relatorios medicos',
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() {
return {
pathname: normalizePath(window.location.pathname),

View File

@@ -1,21 +1,22 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { profileRepository } from '../repositories/profileRepository.js'
import { BrandLogo } from './Brand.jsx'
const navItems = [
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
{ href: '/laudos', label: 'Laudos', icon: 'clipboard' },
{ href: '/prontuario', label: 'Prontuario', icon: 'file' },
{ href: '/laudos', label: 'Relatorios medicos', icon: 'clipboard' },
{
href: '/camunicacao',
label: 'Comunicação',
label: 'Comunicacao',
icon: 'message',
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
},
{ href: '/relatorios', label: 'Relatórios', icon: 'chart' },
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
{ href: '/relatorios', label: 'Relatorios', icon: 'chart' },
{ href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
]
const titles = {
@@ -24,22 +25,23 @@ const titles = {
'/dashboard': 'Painel',
'/agenda': 'Agenda',
'/consultas': 'Consultas',
'/laudos': 'Laudos',
'/laudos': 'Relatorios medicos',
'/pacientes': 'Pacientes',
'/prontuario': 'Prontuário',
'/camunicacao': 'Comunicação',
'/comunicacao': 'Comunicação',
'/mensagens': 'Comunicação',
'/relatorios': 'Relatórios',
'/prontuario': 'Prontuario',
'/camunicacao': 'Comunicacao',
'/comunicacao': 'Comunicacao',
'/mensagens': 'Comunicacao',
'/relatorios': 'Relatorios',
'/profissionais': 'Profissionais',
'/perfil': 'Perfil',
'/configuracoes': 'Configurações',
'/config': 'Configurações',
'/configuracoes': 'Configuracoes',
'/config': 'Configuracoes',
}
export function AppShell({ children, currentPath, navigate, routeTitle }) {
const [menuOpen, setMenuOpen] = useState(false)
const [quickSearch, setQuickSearch] = useState('')
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' })
const pageTitle = useMemo(() => {
if (currentPath.startsWith('/pacientes/') && routeTitle) {
@@ -49,6 +51,25 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
return routeTitle || titles[currentPath] || 'MediConnect'
}, [currentPath, routeTitle])
useEffect(() => {
let active = true
profileRepository.getCurrentUserProfile()
.then((profile) => {
if (!active || !profile) return
setViewerProfile({
name: profile.name || 'Usuario',
role: profile.role || 'Usuario do Sistema',
})
})
.catch(() => {})
return () => {
active = false
}
}, [])
function goTo(path) {
setMenuOpen(false)
navigate(path)
@@ -95,8 +116,8 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
onClick={() => goTo('/perfil')}
type="button"
>
<p className="truncate text-xs font-semibold text-[#e5e5e5]">Dr. Henrique Cardoso</p>
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">Médico Clínico Geral</p>
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
</button>
</div>
</aside>
@@ -128,7 +149,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
aria-label="Busca rapida"
className="h-[38px] w-full rounded-sm border border-[#404040] bg-[#303030] py-2 pl-10 pr-4 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
onChange={(event) => setQuickSearch(event.target.value)}
placeholder="Buscar paciente, prontuário..."
placeholder="Buscar paciente, prontuario..."
value={quickSearch}
/>
</div>
@@ -154,14 +175,14 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
type="button"
>
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]">
HC
{getInitials(viewerProfile.name)}
</span>
<span className="hidden min-w-0 sm:block">
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
Dr. Henrique Cardoso
{viewerProfile.name}
</span>
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
Médico(a)
{viewerProfile.role}
</span>
</span>
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
@@ -331,3 +352,13 @@ function SearchIcon({ className = 'size-4' }) {
</svg>
)
}
function getInitials(name) {
return String(name || 'US')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}

View File

@@ -0,0 +1,39 @@
import { featureStateStyles } from './featureStateStyles.js'
export function FeatureBadge({ className = '', status = 'partial', text }) {
const current = featureStateStyles[status] || featureStateStyles.partial
return (
<span
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.14em] ${current.badge} ${className}`}
>
{text || current.label}
</span>
)
}
export function FeatureCallout({ className = '', description, status = 'partial', title }) {
const current = featureStateStyles[status] || featureStateStyles.partial
return (
<div className={`rounded-2xl border px-4 py-3 ${current.panel} ${className}`}>
<div className="flex flex-wrap items-center gap-3">
<FeatureBadge status={status} />
{title ? <p className={`text-sm font-semibold ${current.title}`}>{title}</p> : null}
</div>
{description ? <p className="mt-2 text-sm leading-6 text-[#d4d4d4]">{description}</p> : null}
</div>
)
}
export function FeatureLegend() {
return (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-[#404040] bg-[#202020] px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#a3a3a3]">Legenda</span>
<FeatureBadge status="live" />
<FeatureBadge status="partial" />
<FeatureBadge status="mock" />
<FeatureBadge status="wip" />
</div>
)
}

View File

@@ -0,0 +1,100 @@
import React from 'react'
import { format, isToday } from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { sortAppointmentsByTime } from '../../utils/agendaDate.js'
export function AgendaDailyView({ baseDate, appointments, onAppointmentClick }) {
const dailyAppointments = sortAppointmentsByTime(appointments)
return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
<div>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]">
Vista ampliada do dia
</span>
<h3 className="mt-2 text-xl font-bold text-[#e5e5e5]">
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
</h3>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]">
{dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'}
</span>
{isToday(baseDate) && (
<span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]">
Hoje
</span>
)}
</div>
</div>
{dailyAppointments.length === 0 ? (
<div className="mt-4 rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
Ajuste o filtro ou altere o período no calendário.
</p>
</div>
) : (
<div className="mt-4 grid gap-3">
{dailyAppointments.map((appointment) => (
<article
key={appointment.id}
className={`grid gap-4 rounded-xl border p-4 md:grid-cols-[96px_1fr_auto] ${getStatusColors(appointment.status)}`}
>
<div>
<p className="text-2xl font-bold leading-none">{appointment.time || '--:--'}</p>
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.14em] opacity-80">
{appointment.mode}
</p>
</div>
<div>
<button
className="text-left text-base font-bold transition hover:opacity-85"
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
type="button"
>
{appointment.patient}
</button>
<p className="mt-1 text-sm opacity-90">
{appointment.type} com {appointment.professional}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium opacity-80">
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.room}</span>
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.type}</span>
</div>
</div>
<div className="flex items-start justify-start md:justify-end">
<span className="rounded-full border border-current/20 bg-black/10 px-3 py-1 text-xs font-bold">
{appointment.status}
</span>
</div>
</article>
))}
</div>
)}
</div>
)
}
function getStatusColors(status) {
switch (status) {
case 'Confirmada':
return 'border-[#14532d] bg-[#052e1a] text-[#a7f3d0]'
case 'Em triagem':
return 'border-[#78350f] bg-[#2d1e05] text-[#fde68a]'
case 'Concluida':
case 'Concluída':
return 'border-[#1e3a8a] bg-[#172554] text-[#bfdbfe]'
case 'Cancelada':
return 'border-[#7f1d1d] bg-[#450a0a] text-[#fecaca]'
case 'Aguardando':
default:
return 'border-[#404040] bg-[#1f1f1f] text-[#e5e5e5]'
}
}

View File

@@ -0,0 +1,107 @@
import React from 'react'
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameMonth,
isSameDay,
isToday,
} from 'date-fns'
import { parseLocalDate, sortAppointmentsByTime } from '../../utils/agendaDate.js'
export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
const monthStart = startOfMonth(baseDate)
const monthEnd = endOfMonth(monthStart)
const startDate = startOfWeek(monthStart, { weekStartsOn: 0 })
const endDate = endOfWeek(monthEnd, { weekStartsOn: 0 })
const days = eachDayOfInterval({ start: startDate, end: endDate })
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
{weekDays.map((day) => (
<div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]">
{day}
</div>
))}
</div>
<div className="mt-4 grid grid-cols-7 gap-2">
{days.map((day) => {
const isCurrentMonth = isSameMonth(day, monthStart)
const dayAppointments = sortAppointmentsByTime(
appointments.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
return appointmentDate && isSameDay(appointmentDate, day)
}),
)
return (
<button
key={day.toISOString()}
onClick={() => onDayClick && onDayClick(day)}
className={`flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${
isCurrentMonth
? 'border-[#404040] bg-[#1f1f1f]'
: 'border-transparent bg-transparent opacity-40 hover:opacity-80'
}`}
>
<span
className={`text-sm font-bold ${
isToday(day)
? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#3b82f6] text-white'
: 'text-[#e5e5e5]'
}`}
>
{format(day, 'd')}
</span>
<div className="mt-2 flex w-full flex-col gap-1">
{dayAppointments.slice(0, 3).map((appointment) => (
<div
key={appointment.id}
className="flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${getDotColor(appointment.status)}`} />
<span className="truncate">
{appointment.time} - {appointment.patient}
</span>
</div>
))}
{dayAppointments.length > 3 && (
<span className="text-[10px] font-semibold text-[#3b82f6]">
+ {dayAppointments.length - 3} mais
</span>
)}
</div>
</button>
)
})}
</div>
</div>
)
}
function getDotColor(status) {
switch (status) {
case 'Confirmada':
return 'bg-[#10b981]'
case 'Em triagem':
return 'bg-[#f59e0b]'
case 'Aguardando':
return 'bg-[#a3a3a3]'
case 'Bloqueado':
return 'bg-[#737373]'
default:
return 'bg-[#3b82f6]'
}
}

View File

@@ -0,0 +1,122 @@
import React from 'react'
import {
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameDay,
isToday,
} from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { parseLocalDate, sortAppointmentsByTime } from '../../utils/agendaDate.js'
export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick }) {
const start = startOfWeek(baseDate, { weekStartsOn: 0 })
const end = endOfWeek(baseDate, { weekStartsOn: 0 })
const days = eachDayOfInterval({ start, end })
const weeklyAppointments = sortAppointmentsByTime(
appointments.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
return appointmentDate && appointmentDate >= start && appointmentDate <= end
}),
)
return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
{days.map((day) => {
const isWeekend = day.getDay() === 0
return (
<div key={day.toISOString()} className="text-center">
<span
className={`block text-xs font-semibold uppercase tracking-[0.16em] ${
isWeekend ? 'text-[#93c5fd]' : 'text-[#a3a3a3]'
}`}
>
{format(day, 'EEE', { locale: ptBR })}
</span>
<span className={`mt-1 block text-2xl font-bold ${isToday(day) ? 'text-[#3b82f6]' : 'text-[#e5e5e5]'}`}>
{format(day, 'dd')}
</span>
</div>
)
})}
</div>
<div className="mt-4 grid min-h-[400px] grid-cols-7 gap-4">
{days.map((day) => {
const dayAppointments = weeklyAppointments.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
return appointmentDate && isSameDay(appointmentDate, day)
})
return (
<div
key={day.toISOString()}
className="flex h-full flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2"
>
{dayAppointments.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<span className="text-center text-xs text-[#737373]">Livre</span>
</div>
) : (
dayAppointments.map((appointment) => (
<button
key={appointment.id}
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
className={`flex w-full flex-col items-start rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`}
>
<div className="mb-1 flex items-center gap-2">
<span className="rounded bg-black/20 px-1.5 py-0.5 text-xs font-bold leading-none">
{appointment.time}
</span>
<span className="truncate text-[10px] font-semibold uppercase tracking-wider opacity-80">
{appointment.mode}
</span>
</div>
<span className="w-full truncate text-xs font-bold leading-tight" title={appointment.patient}>
{appointment.patient}
</span>
<span
className="mt-0.5 w-full truncate text-[10px] font-medium opacity-80"
title={appointment.professional}
>
Dr(a). {appointment.professional?.split(' ')[0]}
</span>
</button>
))
)}
</div>
)
})}
</div>
</div>
)
}
function getStatusColors(status) {
switch (status) {
case 'Confirmada':
return 'border-[#14532d] bg-[#052e1a] text-[#10b981]'
case 'Em triagem':
return 'border-[#78350f] bg-[#2d1e05] text-[#f59e0b]'
case 'Concluida':
case 'Concluída':
return 'border-[#1e3a8a] bg-[#172554] text-[#60a5fa]'
case 'Aguardando':
return 'border-[#404040] bg-[#303030] text-[#e5e5e5]'
case 'Cancelada':
return 'border-[#7f1d1d] bg-[#450a0a] text-[#f87171] opacity-75'
case 'Bloqueado':
return 'border-[#404040] bg-[#1f1f1f] text-[#737373]'
default:
return 'border-[#404040] bg-[#303030] text-[#e5e5e5]'
}
}

View File

@@ -0,0 +1,31 @@
export const featureStateStyles = {
live: {
badge: 'border-emerald-500/40 bg-emerald-500/15 text-emerald-300',
panel: 'border-emerald-500/35 bg-emerald-500/8',
title: 'text-emerald-300',
label: 'Integrado',
},
partial: {
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300',
panel: 'border-sky-500/35 bg-sky-500/8',
title: 'text-sky-300',
label: 'Parcial',
},
mock: {
badge: 'border-amber-500/40 bg-amber-500/15 text-amber-300',
panel: 'border-amber-500/35 bg-amber-500/8',
title: 'text-amber-300',
label: 'Mockado',
},
wip: {
badge: 'border-rose-500/40 bg-rose-500/15 text-rose-300',
panel: 'border-rose-500/35 bg-rose-500/8',
title: 'text-rose-300',
label: 'WIP',
},
}
export function featurePanelClass(status = 'partial') {
const current = featureStateStyles[status] || featureStateStyles.partial
return current.panel
}

View File

@@ -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),
)
}

210
src/hooks/useAgenda.js Normal file
View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useMemo } from 'react'
import { isSameDay } from 'date-fns'
import { appointmentRepository } from '../repositories/appointmentRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
import { professionalRepository } from '../repositories/professionalRepository.js'
import { profileRepository } from '../repositories/profileRepository.js'
import { formatLocalDateInput, parseLocalDate, sortAppointmentsByTime } from '../utils/agendaDate.js'
export function useAgenda() {
const [patients, setPatients] = useState([])
const [professionals, setProfessionals] = useState([])
const [currentProfessional, setCurrentProfessional] = useState(null)
const [viewerProfile, setViewerProfile] = useState(null)
const [localAppointments, setLocalAppointments] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [activeView, setActiveView] = useState('Dia')
const [baseDate, setBaseDate] = useState(new Date())
const [status, setStatus] = useState('Todos')
const [modalOpen, setModalOpen] = useState(false)
const [form, setForm] = useState({
patientId: '',
professionalId: '',
type: 'Retorno',
time: '15:30',
mode: 'Teleconsulta',
})
useEffect(() => {
let active = true
async function loadAgendaContext() {
try {
setError('')
const [patientsData, professionalsData, currentProfile] = await Promise.all([
patientRepository.getAll(),
professionalRepository.getAll(),
profileRepository.getCurrentUserProfile(),
])
if (!active) return
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global'
const resolvedProfessional = resolveCurrentProfessional(currentProfile, professionalsData)
const initialProfessionalId =
agendaScope === 'doctor'
? resolvedProfessional?.id || ''
: professionalsData?.[0]?.id || ''
setViewerProfile(currentProfile)
setPatients(patientsData || [])
setCurrentProfessional(resolvedProfessional)
setProfessionals(professionalsData || [])
setForm((current) => ({
...current,
patientId: patientsData?.length ? patientsData[0].id : '',
professionalId: initialProfessionalId,
}))
if (agendaScope === 'doctor' && !resolvedProfessional) {
setLocalAppointments([])
setError('Nao foi possivel vincular o medico logado a um profissional da base.')
return
}
const appointmentsData = await appointmentRepository.getAll({
doctorId: agendaScope === 'doctor' ? resolvedProfessional?.id : undefined,
})
if (!active) return
setLocalAppointments(
agendaScope === 'doctor' && resolvedProfessional
? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id)
: sortAppointmentsByTime(appointmentsData || []),
)
} catch (loadError) {
if (!active) return
console.error(loadError)
setError(loadError.message || 'Erro ao carregar agenda.')
} finally {
if (active) {
setLoading(false)
}
}
}
loadAgendaContext()
return () => {
active = false
}
}, [])
const visibleAppointments = useMemo(() => {
let filtered = localAppointments
if (status !== 'Todos') {
filtered = filtered.filter((appointment) => appointment.status === status)
}
if (activeView === 'Dia') {
filtered = filtered.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
if (!appointmentDate) return false
return isSameDay(appointmentDate, baseDate)
})
}
return sortAppointmentsByTime(filtered)
}, [localAppointments, status, activeView, baseDate])
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
const canCreateAppointment = agendaScope === 'doctor'
? Boolean(currentProfessional?.id)
: professionals.length > 0
function updateForm(field, value) {
setForm((current) => ({ ...current, [field]: value }))
}
async function handleCreate(event) {
event.preventDefault()
const targetProfessionalId = agendaScope === 'doctor'
? currentProfessional?.id
: form.professionalId
if (!targetProfessionalId) {
alert('Nao foi possivel identificar o profissional da consulta.')
return
}
const dateStr = formatLocalDateInput(baseDate)
try {
const created = await appointmentRepository.create({
patientId: form.patientId,
date: dateStr,
time: form.time,
type: form.type,
mode: form.mode,
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
professionalId: targetProfessionalId,
})
setLocalAppointments((current) => sortAppointmentsByTime([...current, created]))
setModalOpen(false)
} catch (createError) {
alert(createError.message || 'Erro ao criar agendamento.')
}
}
return {
patients,
professionals,
currentProfessional,
viewerProfile,
agendaScope,
loading,
error,
canCreateAppointment,
activeView,
setActiveView,
baseDate,
setBaseDate,
status,
setStatus,
modalOpen,
setModalOpen,
form,
updateForm,
handleCreate,
visibleAppointments,
}
}
function resolveCurrentProfessional(profile, professionals) {
const doctorId = normalizeValue(profile?.doctorId)
const userId = normalizeValue(profile?.id)
const email = normalizeValue(profile?.email)
return (
professionals.find((professional) => normalizeValue(professional.id) === doctorId) ||
professionals.find((professional) => normalizeValue(professional.userId) === userId) ||
professionals.find((professional) => normalizeValue(professional.id) === userId) ||
professionals.find((professional) => normalizeValue(professional.email) === email) ||
null
)
}
function filterAppointmentsByProfessional(appointments, professionalId) {
const normalizedProfessionalId = normalizeValue(professionalId)
return sortAppointmentsByTime(
appointments.filter((appointment) => normalizeValue(appointment.professionalId) === normalizedProfessionalId),
)
}
function normalizeValue(value) {
return String(value || '').trim().toLowerCase()
}

View File

@@ -0,0 +1,99 @@
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 || {}
// Tratamento de data e hora do campo scheduled_at
let dateStr = apiData.date || apiData.data || apiData.appointment_date || apiData.data_agendamento || ''
let timeStr = apiData.time || apiData.hora || apiData.appointment_time || apiData.horario || ''
if (apiData.scheduled_at) {
const d = new Date(apiData.scheduled_at)
if (!isNaN(d)) {
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
dateStr = `${yyyy}-${mm}-${dd}`
const hh = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
timeStr = `${hh}:${mins}`
}
}
// Tradução de status do banco (inglês) para UI (português)
const statusMap = {
requested: 'Aguardando',
confirmed: 'Confirmada',
checked_in: 'Em triagem',
completed: 'Concluída',
cancelled: 'Cancelada',
}
const rawStatus = (apiData.status || '').toLowerCase()
const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando'
// Modalidade
let mode = apiData.mode || apiData.modalidade || apiData.formato || 'Presencial'
if (apiData.appointment_type) {
mode = apiData.appointment_type === 'telemedicina' ? 'Teleconsulta' : 'Presencial'
}
return {
id: apiData.id || apiData.agendamento_id,
patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id,
professionalId:
apiData.professionalId ||
apiData.doctor_id ||
apiData.medico_id ||
apiData.professional_id ||
professional.id ||
null,
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.full_name ||
professional.name ||
professional.nome ||
'Medico(a)',
date: dateStr,
time: timeStr,
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
mode: mode,
status: mappedStatus,
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
}
},
toApi(uiData, dialect = 'api') {
if (dialect === 'supabase') {
// Monta o scheduled_at no formato ISO assumindo fuso local
const scheduledAt = new Date(`${uiData.date}T${uiData.time}:00`).toISOString()
return {
patient_id: uiData.patientId,
doctor_id: uiData.professionalId || null,
scheduled_at: scheduledAt,
appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial',
status: uiData.status === 'Confirmada' ? 'confirmed' : 'requested',
duration_minutes: 30, // Padrao
}
}
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,
}
},
}

View File

@@ -0,0 +1,61 @@
export const reportMapper = {
toUi(apiData) {
if (!apiData) return null
return {
id: String(apiData.id || ''),
orderNumber: apiData.order_number || '',
patientId: apiData.patient_id || '',
status: normalizeStatus(apiData.status),
exam: apiData.exam || '',
requestedBy: apiData.requested_by || '',
cidCode: apiData.cid_code || '',
diagnosis: apiData.diagnosis || '',
conclusion: apiData.conclusion || '',
contentHtml: apiData.content_html || '',
contentJson: apiData.content_json ?? null,
hideDate: Boolean(apiData.hide_date),
hideSignature: Boolean(apiData.hide_signature),
dueAt: apiData.due_at || '',
createdBy: apiData.created_by || '',
updatedBy: apiData.updated_by || '',
createdAt: apiData.created_at || '',
updatedAt: apiData.updated_at || '',
}
},
toApi(uiData) {
return cleanPayload({
patient_id: uiData.patientId,
status: normalizeApiStatus(uiData.status),
exam: emptyToUndefined(uiData.exam),
requested_by: emptyToUndefined(uiData.requestedBy),
cid_code: emptyToUndefined(uiData.cidCode),
diagnosis: emptyToUndefined(uiData.diagnosis),
conclusion: emptyToUndefined(uiData.conclusion),
content_html: emptyToUndefined(uiData.contentHtml),
content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson,
hide_date: Boolean(uiData.hideDate),
hide_signature: Boolean(uiData.hideSignature),
due_at: emptyToUndefined(uiData.dueAt),
})
},
}
function normalizeStatus(status) {
return status === 'draft' ? 'draft' : 'draft'
}
function normalizeApiStatus(status) {
return status === 'draft' ? 'draft' : 'draft'
}
function emptyToUndefined(value) {
return value === '' || value === null ? undefined : value
}
function cleanPayload(payload) {
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined),
)
}

View File

@@ -1,8 +1,20 @@
import { useEffect, useMemo, useState } from 'react'
import {
addDays,
subDays,
addWeeks,
subWeeks,
addMonths,
subMonths,
endOfWeek,
format,
startOfWeek,
} from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { appointmentRepository } from '../repositories/appointmentRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
import { professionalRepository } from '../repositories/professionalRepository.js'
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
import { useAgenda } from '../hooks/useAgenda.js'
const statusFilters = [
{ label: 'Todos', value: 'Todos' },
@@ -11,70 +23,47 @@ const statusFilters = [
{ label: 'Aguardando', value: 'Aguardando' },
]
const viewFilters = ['Dia', 'Semana', 'Mês']
const viewFilters = [
{ label: 'Dia', value: 'Dia' },
{ label: 'Semana', value: 'Semana' },
{ label: 'Mês', value: 'Mes' },
]
export function AgendaPage({ navigate }) {
const [patients, setPatients] = useState([])
const professionals = professionalRepository.getAll()
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 [form, setForm] = useState({
patientId: '',
professional: professionals[0]?.name || '',
type: 'Retorno',
time: '15:30',
mode: 'Teleconsulta',
})
const {
patients,
professionals,
currentProfessional,
viewerProfile,
agendaScope,
loading,
error,
canCreateAppointment,
activeView,
setActiveView,
baseDate,
setBaseDate,
status,
setStatus,
modalOpen,
setModalOpen,
form,
updateForm,
handleCreate,
visibleAppointments,
} = useAgenda()
useEffect(() => {
patientRepository.getAll().then((data) => {
setPatients(data)
setForm((current) => ({
...current,
patientId: data[0]?.id || '',
}))
})
}, [])
const visibleAppointments = useMemo(() => {
if (status === 'Todos') {
return localAppointments
}
return localAppointments.filter((appointment) => appointment.status === status)
}, [localAppointments, status])
function updateForm(field, value) {
setForm((current) => ({ ...current, [field]: value }))
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center text-[#a3a3a3]">
<p>Carregando agenda...</p>
</div>
)
}
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',
time: form.time,
type: form.type,
mode: form.mode,
},
])
setModalOpen(false)
}
const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
const isDoctorScope = agendaScope === 'doctor'
return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
@@ -84,20 +73,53 @@ useEffect(() => {
Agenda
</h1>
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
Organize consultas, retornos e teleatendimentos do dia.
{isDoctorScope
? `Agenda restrita ao médico logado: ${currentProfessional?.name || viewerProfile?.name || 'Médico atual'}.`
: 'Visualização completa da agenda com todos os médicos.'}
</p>
</div>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1 rounded-sm border border-[#404040] bg-[#262626] p-1">
<button
className="grid size-7 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
onClick={() => {
if (activeView === 'Dia') setBaseDate((current) => subDays(current, 1))
if (activeView === 'Semana') setBaseDate((current) => subWeeks(current, 1))
if (activeView === 'Mes') setBaseDate((current) => subMonths(current, 1))
}}
type="button"
>
{'<'}
</button>
<span className="min-w-[160px] text-center text-sm font-semibold text-[#e5e5e5] capitalize">
{activeView === 'Dia' && format(baseDate, "dd 'de' MMM", { locale: ptBR })}
{activeView === 'Semana' &&
`${format(weekStart, 'dd MMM', { locale: ptBR })} - ${format(weekEnd, 'dd MMM', { locale: ptBR })}`}
{activeView === 'Mes' && format(baseDate, 'MMMM yyyy', { locale: ptBR })}
</span>
<button
className="grid size-7 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
onClick={() => {
if (activeView === 'Dia') setBaseDate((current) => addDays(current, 1))
if (activeView === 'Semana') setBaseDate((current) => addWeeks(current, 1))
if (activeView === 'Mes') setBaseDate((current) => addMonths(current, 1))
}}
type="button"
>
{'>'}
</button>
</div>
<button
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => setStatus('Todos')}
onClick={() => setBaseDate(new Date())}
type="button"
>
Hoje
</button>
<button
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed]"
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373] disabled:shadow-none"
disabled={!canCreateAppointment}
onClick={() => setModalOpen(true)}
type="button"
>
@@ -106,133 +128,103 @@ useEffect(() => {
</div>
</section>
<section className="grid gap-4 lg:grid-cols-5">
{weekDays.map((day) => (
<button
className={`rounded-2xl border p-4 text-left transition ${
day.active
? 'border-[#3b82f6] bg-[#3b82f6]/10'
: 'border-[#404040] bg-[#262626] hover:border-[#525252]'
}`}
key={`${day.label}-${day.day}`}
type="button"
>
<span className="block text-xs font-semibold uppercase tracking-[0.16em] text-[#a3a3a3]">
{day.label}
</span>
<span className="mt-2 block text-[32px] font-bold leading-8 text-[#e5e5e5]">{day.day}</span>
<span className="mt-3 block text-sm text-[#3b82f6]">{day.count} consultas</span>
</button>
))}
</section>
<section className="grid gap-6 xl:grid-cols-[1.45fr_0.85fr]">
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">Terça, 07 abril</h2>
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros no filtro
</p>
</div>
<div className="flex flex-wrap gap-2">
{viewFilters.map((view) => (
<button
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
activeView === view
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
}`}
key={view}
onClick={() => setActiveView(view)}
type="button"
>
{view}
</button>
))}
</div>
{error ? (
<section className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
<div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
<h2 className="text-base font-bold text-[#fecaca]">Nao foi possivel liberar a agenda</h2>
<p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico.
</p>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{statusFilters.map((filter) => (
<button
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
status === filter.value
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
}`}
key={filter.value}
onClick={() => setStatus(filter.value)}
type="button"
>
{filter.label}
</button>
))}
</div>
<div className="mt-6 grid gap-3">
{visibleAppointments.length ? (
visibleAppointments.map((appointment) => (
<AgendaListItem
appointment={appointment}
key={appointment.id}
navigate={navigate}
/>
))
) : (
<div className="rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
Ajuste o filtro ou crie uma consulta mockada para este período.
</section>
) : (
<section className="grid gap-6 xl:grid-cols-1">
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
</h2>
</div>
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros visíveis
</p>
</div>
)}
</div>
</div>
<div className="grid gap-6">
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Linha do tempo</h2>
<div className="mt-5 grid gap-1">
{timeline.map((item) => (
<div className="flex flex-wrap gap-2">
{viewFilters.map((view) => (
<button
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
activeView === view.value
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
}`}
key={view.value}
onClick={() => setActiveView(view.value)}
type="button"
>
{view.label}
</button>
))}
</div>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{statusFilters.map((filter) => (
<button
className="grid grid-cols-[58px_1fr] gap-4 rounded-md px-2 py-3 text-left transition hover:bg-[#303030]"
disabled={!item.patientId}
key={`${item.hour}-${item.patient}`}
onClick={() => item.patientId && navigate(`/pacientes/${item.patientId}`)}
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
status === filter.value
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
}`}
key={filter.value}
onClick={() => setStatus(filter.value)}
type="button"
>
<span className="text-sm font-bold text-[#3b82f6]">{item.hour}</span>
<span className="border-l border-[#404040] pl-4">
<span className="block text-sm font-semibold text-[#e5e5e5]">{item.patient}</span>
<span className="mt-1 block text-xs text-[#a3a3a3]">{item.type}</span>
</span>
{filter.label}
</button>
))}
</div>
</div>
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Resumo preditivo</h2>
<div className="mt-5 grid gap-3">
{queue.map((item) => (
<div className="flex items-center justify-between rounded-md bg-[#2a2a2a] px-4 py-3" key={item.label}>
<span className="text-sm font-medium text-[#a3a3a3]">{item.label}</span>
<span className={`text-lg font-bold ${queueTone(item.tone)}`}>{item.value}</span>
</div>
))}
{!isDoctorScope && (
<div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais.
</div>
)}
<div className="mt-6 grid gap-3">
{activeView === 'Semana' && (
<AgendaWeeklyView
baseDate={baseDate}
appointments={visibleAppointments}
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
/>
)}
{activeView === 'Mes' && (
<AgendaMonthlyView
baseDate={baseDate}
appointments={visibleAppointments}
onDayClick={(day) => {
setBaseDate(day)
setActiveView('Dia')
}}
/>
)}
{activeView === 'Dia' && (
<AgendaDailyView
baseDate={baseDate}
appointments={visibleAppointments}
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
/>
)}
</div>
<button
className="mt-5 h-9 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6] hover:text-[#3b82f6]"
onClick={() => navigate('/mensagens')}
type="button"
>
Confirmar presenças
</button>
</div>
</div>
</section>
</section>
)}
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
<form className="grid gap-4" onSubmit={handleCreate}>
@@ -244,7 +236,7 @@ useEffect(() => {
>
{patients.map((patient) => (
<option key={patient.id} value={patient.id}>
{patient.name}
{patient.name || patient.full_name || patient.nome}
</option>
))}
</select>
@@ -272,15 +264,26 @@ useEffect(() => {
</div>
<DarkField label="Profissional">
<select
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)}
value={form.professional}
>
{professionals.map((professional) => (
<option key={professional.id}>{professional.name}</option>
))}
</select>
{isDoctorScope ? (
<input
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
disabled
readOnly
value={currentProfessional?.name || 'Médico não vinculado'}
/>
) : (
<select
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('professionalId', event.target.value)}
value={form.professionalId}
>
{professionals.map((professional) => (
<option key={professional.id} value={professional.id}>
{professional.name}
</option>
))}
</select>
)}
</DarkField>
<DarkField label="Tipo de consulta">
@@ -300,7 +303,8 @@ useEffect(() => {
Cancelar
</button>
<button
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed]"
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
disabled={!canCreateAppointment}
type="submit"
>
Salvar consulta
@@ -312,37 +316,6 @@ useEffect(() => {
)
}
function AgendaListItem({ appointment, navigate }) {
return (
<article className="grid gap-4 rounded-xl border border-[#404040] bg-[#1f1f1f] p-4 md:grid-cols-[72px_1fr_auto]">
<div>
<p className="text-xl font-bold leading-7 text-[#e5e5e5]">{appointment.time}</p>
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-[#737373]">
{appointment.mode}
</p>
</div>
<div>
<button
className="text-left text-base font-bold text-[#e5e5e5] transition hover:text-[#3b82f6]"
onClick={() => navigate(`/pacientes/${appointment.patientId}`)}
type="button"
>
{appointment.patient}
</button>
<p className="mt-1 text-sm text-[#a3a3a3]">
{appointment.type} com {appointment.professional}
</p>
<p className="mt-2 text-xs font-medium text-[#737373]">{appointment.room}</p>
</div>
<div className="flex items-start justify-between gap-3 md:justify-end">
<StatusPill status={appointment.status} />
</div>
</article>
)
}
function DarkField({ children, label }) {
return (
<label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]">
@@ -376,30 +349,3 @@ function DarkModal({ children, onClose, open, title }) {
</div>
)
}
function StatusPill({ status }) {
const classes = {
Confirmada: 'border-[#14532d] bg-[#052e1a] text-[#10b981]',
'Em triagem': 'border-[#78350f] bg-[#2d1e05] text-[#f59e0b]',
Aguardando: 'border-[#404040] bg-[#303030] text-[#a3a3a3]',
Bloqueado: 'border-[#404040] bg-[#303030] text-[#737373]',
}
return (
<span className={`rounded-full border px-3 py-1 text-xs font-bold ${classes[status] || classes.Aguardando}`}>
{status}
</span>
)
}
function queueTone(tone) {
if (tone === 'red') {
return 'text-[#ef4444]'
}
if (tone === 'amber') {
return 'text-[#f59e0b]'
}
return 'text-[#3b82f6]'
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { analyticsRepository } from '../repositories/analyticsRepository.js'
const periods = [
@@ -25,6 +26,12 @@ export function AnalyticsPage() {
return (
<div className="mx-auto max-w-7xl space-y-6">
<FeatureCallout
description="Os indicadores, gráficos e rankings desta tela ainda vêm de dados mockados."
status="mock"
title="Analytics ainda é demonstrativo"
/>
<section className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Relatórios & Analytics</h1>

View File

@@ -1,6 +1,9 @@
import { useState } from 'react'
import { authRepository } from '../repositories/authRepository.js'
import { BrandLogo } from '../components/Brand.jsx'
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
import loginClinicImage from '../assets/figma/login-clinic.png'
export function LoginPage({ navigate }) {
@@ -9,14 +12,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 +89,12 @@ export function LoginPage({ navigate }) {
</p>
</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}>
<LoginField htmlFor="login-email" label="E-mail">
<input
@@ -122,10 +143,11 @@ export function LoginPage({ navigate }) {
</LoginField>
<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"
>
Entrar
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
</div>
@@ -142,6 +164,7 @@ export function LoginPage({ navigate }) {
type="button"
>
dev · credenciais
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" />
<span aria-hidden="true" className="text-[9px]">
^
</span>
@@ -160,6 +183,12 @@ export function RegisterPage({ navigate }) {
description="Crie um acesso mockado para navegar pelo ambiente da clínica."
title="Criar acesso"
>
<FeatureCallout
className="mt-6"
description="Cadastro ainda é apenas demonstrativo e não cria conta real."
status="mock"
title="Fluxo mockado"
/>
<form
className="mt-8 grid gap-5"
onSubmit={(event) => {
@@ -204,29 +233,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 (
<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"
>
{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">
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>
) : (
<form
className="mt-8 grid gap-5"
onSubmit={(event) => {
event.preventDefault()
setSent(true)
}}
onSubmit={handleSubmit}
>
{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">
<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>
<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">
Enviar link
<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] disabled:opacity-50"
disabled={loading}
type="submit"
>
{loading ? "Enviando..." : "Enviar link"}
</button>
</form>
)}

View File

@@ -1,4 +1,6 @@
import loginClinicImage from '../assets/figma/login-clinic.png'
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.js'
import { homeRepository } from '../repositories/homeRepository.js'
export function HomePage({ navigate }) {
@@ -6,6 +8,12 @@ export function HomePage({ navigate }) {
return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
<FeatureCallout
description="Painel, métricas, insights e relatórios desta tela ainda usam dados mockados."
status="mock"
title="Visão geral ainda não está ligada à API"
/>
<section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
@@ -41,14 +49,17 @@ export function HomePage({ navigate }) {
</section>
<section className="grid gap-6 xl:grid-cols-[1.7fr_0.9fr]">
<div className="rounded-2xl border border-[#3b82f6] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
<div className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${featurePanelClass('mock')}`}>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="grid size-12 shrink-0 place-items-center rounded-md bg-[#3b82f6] text-white">
<SparkLineIcon className="size-6" />
</div>
<div>
<h2 className="text-base font-bold leading-6 text-[#3b82f6]">Insights de IA</h2>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold leading-6 text-[#3b82f6]">Insights de IA</h2>
<FeatureBadge status="mock" />
</div>
<p className="mt-1 text-sm font-medium leading-5 text-[#a3a3a3]">
Evolução de absenteísmo e risco da semana
</p>
@@ -65,8 +76,11 @@ export function HomePage({ navigate }) {
</div>
<div className="grid gap-4">
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Pacientes de hoje</h2>
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold text-[#e5e5e5]">Pacientes de hoje</h2>
<FeatureBadge status="mock" />
</div>
<div className="mt-4 grid gap-3">
{appointmentsToday.map((item) => (
<button
@@ -85,8 +99,11 @@ export function HomePage({ navigate }) {
</div>
</div>
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<h2 className="text-base font-bold text-[#e5e5e5]">Alerta preditivo</h2>
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold text-[#e5e5e5]">Alerta preditivo</h2>
<FeatureBadge status="mock" />
</div>
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
3 pacientes apresentam risco de falta. Recomenda-se confirmar presença antes das 16h.
</p>
@@ -102,7 +119,10 @@ export function HomePage({ navigate }) {
</section>
<section className="grid gap-4" id="relatorios">
<h2 className="text-base font-bold text-[#e5e5e5]">Relatórios e Análises</h2>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold text-[#e5e5e5]">Relatórios e Análises</h2>
<FeatureBadge status="mock" />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<button
className="relative min-h-[164px] overflow-hidden rounded-2xl border border-[#3b82f6] bg-[#262626] p-5 text-left shadow-[0_1px_3px_rgba(0,0,0,0.2)]"

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
@@ -34,6 +35,12 @@ export function MedicalRecordsPage() {
return (
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
<FeatureCallout
description="Prontuário, listagem e criação de registros ainda usam dados locais e não persistem na API."
status="mock"
title="Prontuário ainda é mockado"
/>
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Prontuário Médico</h1>

View File

@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.js'
import { communicationRepository } from '../repositories/communicationRepository.js'
const channels = {
@@ -18,6 +20,7 @@ const statusConfig = {
const emptyMessage = {
patient: '',
phone: '',
channel: 'whatsapp',
template: 'Lembrete 48h',
content: '',
@@ -79,6 +82,7 @@ export function MessagesPage() {
function openTemplate(template) {
setComposer({
patient: '',
phone: '',
channel: template.channel,
template: template.name,
content: template.content,
@@ -86,13 +90,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 +124,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,
@@ -133,6 +157,12 @@ export function MessagesPage() {
return (
<div className="mx-auto max-w-7xl space-y-6">
<FeatureCallout
description="Envio de SMS usa API. Histórico, templates e campanhas ainda são dados locais de demonstração."
status="partial"
title="Mensageria híbrida"
/>
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
@@ -188,7 +218,7 @@ export function MessagesPage() {
</div>
{activeTab === 'historico' ? (
<section className={`${cardClass} p-5 md:p-6`} aria-label="Histórico de comunicação">
<section className={`${cardClass} ${featurePanelClass('mock')} p-5 md:p-6`} aria-label="Histórico de comunicação">
<div className="mb-6 flex flex-col gap-3 md:flex-row">
<label className="relative flex-1">
<span className="sr-only">Buscar comunicação</span>
@@ -257,7 +287,7 @@ export function MessagesPage() {
) : null}
{activeTab === 'templates' ? (
<section className="space-y-4" aria-label="Templates de comunicação">
<section className={`space-y-4 rounded-2xl p-4 ${featurePanelClass('mock')}`} aria-label="Templates de comunicação">
<div className="flex justify-end">
<button
className="inline-flex h-10 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
@@ -278,7 +308,7 @@ export function MessagesPage() {
) : null}
{activeTab === 'campanha' ? (
<section className={`${cardClass} p-6`} aria-label="Campanhas inteligentes">
<section className={`${cardClass} ${featurePanelClass('mock')} p-6`} aria-label="Campanhas inteligentes">
<div className="py-8 text-center">
<div className="mx-auto mb-4 grid size-16 place-items-center rounded-full bg-[#303030]">
<CommIcon className="size-8 text-[#51a2ff]" name="send" />
@@ -300,6 +330,7 @@ export function MessagesPage() {
onClick={() => {
setComposer({
patient: campaign.count,
phone: '',
channel: 'whatsapp',
template: campaign.title,
content: campaign.desc,
@@ -470,6 +501,17 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
</DarkField>
</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">
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
<option value="Mensagem avulsa">Mensagem avulsa</option>

View File

@@ -301,43 +301,43 @@ async function deletePatient(patientId) {
) : null}
<div className="overflow-x-auto rounded-lg border border-[#404040]">
<table className="w-full whitespace-nowrap text-left text-sm">
<table className="w-full min-w-full table-fixed text-left text-sm">
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
<tr>
<th className="px-6 py-4">Nome</th>
<th className="px-6 py-4">Telefone</th>
<th className="px-6 py-4">Cidade</th>
<th className="px-6 py-4">Estado</th>
<th className="px-6 py-4">Ultimo atendimento</th>
<th className="px-6 py-4">Proximo atendimento</th>
<th className="px-6 py-4 text-right">Acoes</th>
<th className="w-[24%] px-6 py-4">Nome</th>
<th className="w-[14%] px-6 py-4">Telefone</th>
<th className="w-[12%] px-6 py-4">Cidade</th>
<th className="w-[8%] px-6 py-4">Estado</th>
<th className="w-[16%] px-6 py-4">Ultimo atendimento</th>
<th className="w-[18%] px-6 py-4">Proximo atendimento</th>
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-6 py-4 text-right">Acoes</th>
</tr>
</thead>
<tbody className="divide-y divide-[#404040] bg-[#262626]">
{paginatedPatients.length ? (
paginatedPatients.map((patient) => (
<tr className="transition hover:bg-[#303030]" key={patient.id}>
<td className="px-6 py-4">
<td className="px-6 py-4 align-top">
<button className="flex items-center gap-3 text-left" onClick={() => openDetail(patient)} type="button">
<span className="grid size-8 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
{patient.name.charAt(0)}
</span>
<span>
<span className="block font-medium text-[#e5e5e5] transition hover:text-[#3b82f6]">
<span className="min-w-0">
<span className="block whitespace-normal break-words font-medium text-[#e5e5e5] transition hover:text-[#3b82f6]">
{patient.name}
</span>
<span className="mt-0.5 block text-xs text-[#a3a3a3]">
<span className="mt-0.5 block whitespace-normal break-words text-xs text-[#a3a3a3]">
{patient.insurance || 'Sem convenio'} {patient.vip ? ' | VIP' : ''}
</span>
</span>
</button>
</td>
<td className="px-6 py-4 text-[#a3a3a3]">{patient.phone}</td>
<td className="px-6 py-4 text-[#a3a3a3]">{patient.city}</td>
<td className="px-6 py-4 text-[#a3a3a3]">{patient.state}</td>
<td className="px-6 py-4 text-[#a3a3a3]">{patient.lastVisit || 'Ainda nao houve atendimento'}</td>
<td className="px-6 py-4 text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
<td className="relative px-6 py-4 text-right">
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.phone}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.city}</td>
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.lastVisit || 'Ainda nao houve atendimento'}</td>
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
<td className="relative sticky right-0 bg-[#262626] px-6 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
<button
aria-label={`Acoes de ${patient.name}`}
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
@@ -779,7 +779,7 @@ function PatientVisits({ navigate, patient }) {
<div className="grid gap-3">
{[
{ date: patient.nextVisit, status: 'Agendada', description: `Retorno para ${patient.condition}` },
{ date: patient.lastVisit, status: 'Finalizada', description: 'Consulta registrada no historico local.' },
{ date: patient.lastVisit, status: 'Finalizada', description: 'Consulta registrada no historico do paciente.' },
].map((visit) => (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={`${visit.date}-${visit.status}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -814,7 +814,7 @@ function PatientDocuments({ patient }) {
{patient.exams.map((exam) => (
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={exam}>
<p className="font-semibold text-[#f5f5f5]">{exam}</p>
<p className="mt-2 text-sm text-[#a3a3a3]">Pendente de revisão mockada.</p>
<p className="mt-2 text-sm text-[#a3a3a3]">Pendente de revisão.</p>
<span className="mt-4 inline-flex rounded bg-amber-500/20 px-2.5 py-1 text-xs font-bold text-amber-400">
A revisar
</span>
@@ -1276,4 +1276,4 @@ function maskCEPInput(event) {
.replace(/\D/g, '')
.replace(/(\d{5})(\d)/, '$1-$2')
.replace(/(-\d{3})\d+?$/, '$1')
}
}

View File

@@ -1,39 +1,110 @@
import { useState } from 'react'
import { useRef, useState, useEffect } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.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 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'
export function ProfilePage() {
export function ProfilePage({ navigate }) {
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) {
setSaved(false)
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 (
<div className="mx-auto max-w-6xl space-y-6">
<FeatureCallout
description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente."
status="partial"
title="Perfil com persistência parcial"
/>
<header>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Dados locais do usuário logado e preferências básicas do shell.</p>
</header>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<section className={`${cardClass} p-6`}>
<section className={`${cardClass} ${featurePanelClass('partial')} p-6`}>
<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]">
HC
</div>
{profile.avatarUrl ? (
<img
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>
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
Alterar foto
<button
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>
<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>
@@ -71,18 +142,26 @@ export function ProfilePage() {
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
Salvar alterações
</button>
{saved ? <span className="rounded bg-emerald-500/20 px-2.5 py-1 text-xs font-bold text-emerald-400">Preferências salvas localmente</span> : null}
{saved ? <span className="rounded bg-amber-500/20 px-2.5 py-1 text-xs font-bold text-amber-300">Preferências salvas localmente</span> : null}
</div>
</form>
</section>
<aside className={`${cardClass} p-6`}>
<aside className={`${cardClass} ${featurePanelClass('live')} p-6`}>
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
<dl className="mt-5 grid gap-4 text-sm">
<Info label="Perfil" value="Administrador da clínica" />
<Info label="Último acesso" value="07 abr 2026, 09:15" />
<Info label="Perfil" value={profile.role} />
<Info label="E-mail principal" value={profile.email} />
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
</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>
</div>
</div>
@@ -106,3 +185,13 @@ function Info({ label, value }) {
</div>
)
}
function initials(name) {
return String(name || 'US')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { settingsRepository } from '../repositories/settingsRepository.js'
@@ -14,6 +15,13 @@ export function SettingsPage() {
return (
<div className="mx-auto max-w-5xl">
<FeatureCallout
className="mb-6"
description="Preferências, integrações e backup ainda são protótipos locais, sem persistência real."
status="mock"
title="Configurações ainda estão em modo protótipo"
/>
<header className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>

View File

@@ -1,13 +1,26 @@
import { useState, useEffect } from 'react'
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.js'
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 (
<div className="mx-auto max-w-7xl space-y-6">
<FeatureCallout
description="A listagem de profissionais usa API, mas o mapa de cobertura e parte da disponibilidade ainda são simulados."
status="partial"
title="Tela híbrida: parte real, parte mockada"
/>
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Profissionais</h1>
@@ -24,7 +37,7 @@ export function TeamPage({ navigate }) {
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe médica">
{professionals.map((professional) => (
<article className={`${cardClass} p-5`} key={professional.id}>
<article className={`${cardClass} ${featurePanelClass('live')} p-5`} key={professional.id}>
<div className="flex items-start justify-between gap-3">
<div>
<div className="grid size-11 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-sm font-bold text-[#3b82f6]">
@@ -45,10 +58,13 @@ export function TeamPage({ navigate }) {
))}
</section>
<section className={`${cardClass} p-5`}>
<section className={`${cardClass} ${featurePanelClass('mock')} p-5`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-[#f5f5f5]">Mapa de cobertura</h2>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-bold text-[#f5f5f5]">Mapa de cobertura</h2>
<FeatureBadge status="mock" />
</div>
<p className="mt-1 text-sm text-[#a3a3a3]">
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
</p>

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { visitRepository } from '../repositories/visitRepository.js'
const tabs = [
@@ -35,6 +36,12 @@ export function VisitsPage({ navigate }) {
return (
<div className="mx-auto max-w-7xl space-y-6">
<FeatureCallout
description="Fila, etapas e resumo desta tela ainda são inteiramente mockados."
status="mock"
title="Consultas ainda não usam backend"
/>
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Consultas</h1>

View File

@@ -1,35 +1,31 @@
import { appointments as mockAppointments } from '../data/mockData.js'
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { appointmentMapper } from '../mappers/appointmentMapper.js'
export const appointmentRepository = {
getAll() {
return mockAppointments
async getAll({ doctorId } = {}) {
const doctorFilter = doctorId ? `&doctor_id=eq.${encodeURIComponent(doctorId)}` : ''
const response = await fetch(`${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(full_name)${doctorFilter}`, {
headers: getAuthenticatedHeaders()
})
if (!response.ok) throw new Error('Erro ao buscar agendamentos.')
const data = await response.json()
return (Array.isArray(data) ? data : []).map(appointmentMapper.toUi)
},
getTodayTimeline() {
return [
{ hour: '08:00', patient: 'Carla Mendes', type: 'Consulta inicial', status: 'Confirmada', patientId: 'carla-mendes' },
{ hour: '09:30', patient: 'Ana Souza', type: 'Retorno clinico', status: 'Em triagem', patientId: 'ana-souza' },
{ hour: '11:00', patient: 'Diego Alves', type: 'Acompanhamento', status: 'Aguardando', patientId: 'diego-alves' },
{ hour: '14:30', patient: 'Bruno Lima', type: 'Teleconsulta', status: 'Confirmada', patientId: 'bruno-lima' },
{ hour: '16:00', patient: 'Horario protegido', type: 'Revisao de laudos', status: 'Bloqueado', patientId: null },
]
},
async create(uiData) {
const response = await fetch(`${apiConfig.restUrl}/appointments`, {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
})
getPredictiveQueueSummary() {
return [
{ label: 'Alta prioridade', value: 3, tone: 'red' },
{ label: 'A confirmar', value: 5, tone: 'amber' },
{ label: 'Teleconsultas', value: 6, tone: 'blue' },
]
},
if (!response.ok) throw new Error('Falha ao criar o agendamento.')
getWeekDays() {
return [
{ label: 'Seg', day: '06', active: false, count: 6 },
{ label: 'Ter', day: '07', active: true, count: 18 },
{ label: 'Qua', day: '08', active: false, count: 12 },
{ label: 'Qui', day: '09', active: false, count: 9 },
{ label: 'Sex', day: '10', active: false, count: 15 },
]
},
const data = await response.json()
const item = Array.isArray(data) ? data[0] : data
return appointmentMapper.toUi(item)
}
}

View File

@@ -0,0 +1,131 @@
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 apiEndpoints = [
apiEndpoint('/user-info'),
apiEndpoint('/informacoes-do-usuario-autenticado'),
]
for (const url of apiEndpoints) {
const apiResponse = await fetch(url, {
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
}

View File

@@ -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}`
}

View File

@@ -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
}

View File

@@ -1,8 +1,15 @@
import { professionals as mockProfessionals } from '../data/mockData.js'
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
export const professionalRepository = {
getAll() {
return mockProfessionals
async getAll() {
const response = await fetch(`${apiConfig.restUrl}/doctors`, {
headers: getAuthenticatedHeaders()
})
if (!response.ok) throw new Error('Erro ao buscar medicos.')
const data = await response.json()
return (Array.isArray(data) ? data : []).map(mapProfessional)
},
getCoverageMap() {
@@ -12,3 +19,17 @@ export const professionalRepository = {
}
},
}
function mapProfessional(doctor) {
return {
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome),
userId: doctor.user_id || doctor.userId || doctor.usuario_id || doctor.auth_user_id || null,
name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)',
email: doctor.email || doctor.user_email || doctor.usuario_email || '',
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',
}
}

View File

@@ -1,11 +1,100 @@
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 profile = data?.profile || data?.perfil || {}
const user = data?.user || data?.usuario || profile || data
const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {}
const permissions = data?.permissions || {}
const roles = Array.isArray(data?.roles) ? data.roles : []
const avatarUrl =
profile?.avatar_url ||
profile?.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: profile?.id || user?.id || user?.user_id || user?.uid || '',
email: profile?.email || user?.email || meta.email || '',
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
role: resolveProfileRole({ permissions, roles, user, meta }),
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista',
avatarUrl,
doctorId: data?.doctor_id || data?.doctorId || null,
patientId: data?.patient_id || data?.patientId || null,
roles,
permissions,
isDoctor: Boolean(permissions.isDoctor || roles.includes('doctor') || data?.doctor_id),
isAdmin: Boolean(permissions.isAdmin || roles.includes('admin')),
}
},
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 || '',
}
}
function resolveProfileRole({ permissions, roles, user, meta }) {
if (permissions.isAdmin || roles.includes('admin')) return 'Administrador'
if (permissions.isManager || roles.includes('manager')) return 'Gestor'
if (permissions.isDoctor || roles.includes('doctor')) return 'Medico(a)'
if (permissions.isSecretary || roles.includes('secretary')) return 'Secretaria'
if (permissions.isPatient || roles.includes('patient')) return 'Paciente'
return user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema'
}

View File

@@ -1,121 +1,64 @@
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, getAuthenticatedHeaders } from '../config/api.js'
import { reportMapper } from '../mappers/reportMapper.js'
import { getResponseError, normalizeItem } from './repositoryUtils.js'
export const reportRepository = {
getAdminUsers() {
return adminUsers
async getInitialReports(filters = {}) {
const query = new URLSearchParams()
query.set('select', '*')
query.set('order', filters.order || 'created_at.desc')
if (filters.patientId) {
query.set('patient_id', `eq.${filters.patientId}`)
}
if (filters.status) {
query.set('status', `eq.${filters.status}`)
}
if (filters.createdBy) {
query.set('created_by', `eq.${filters.createdBy}`)
}
const response = await fetch(`${apiConfig.restUrl}/reports?${query.toString()}`, {
headers: getAuthenticatedHeaders(),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao buscar relatorios medicos.'))
}
const data = await response.json()
return (Array.isArray(data) ? data : []).map(reportMapper.toUi)
},
getCurrentUser() {
return currentUser
async create(uiData) {
const response = await fetch(`${apiConfig.restUrl}/reports`, {
method: 'POST',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(reportMapper.toApi(uiData)),
})
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao criar relatorio medico.'))
}
const data = await response.json()
return reportMapper.toUi(normalizeItem(data))
},
getDoctors() {
return doctors
},
async update(id, uiData) {
const response = await fetch(`${apiConfig.restUrl}/reports?id=eq.${id}`, {
method: 'PATCH',
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
body: JSON.stringify(reportMapper.toApi(uiData)),
})
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' },
],
},
]
},
if (!response.ok) {
throw new Error(await getResponseError(response, 'Falha ao atualizar relatorio medico.'))
}
getReportTypes() {
return reportTypes
},
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.' },
]
const data = await response.json()
return reportMapper.toUi(normalizeItem(data))
},
}

View 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 }
}
}

40
src/utils/agendaDate.js Normal file
View File

@@ -0,0 +1,40 @@
export function parseLocalDate(dateString) {
if (!dateString || typeof dateString !== 'string') return null
const parts = dateString.split('T')[0].split('-')
if (parts.length === 3) {
const [year, month, day] = parts.map(Number)
return new Date(year, month - 1, day)
}
const parsed = new Date(dateString)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
export function formatLocalDateInput(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function getTimeSortValue(timeString) {
const normalized = String(timeString || '').trim()
const match = normalized.match(/^(\d{1,2}):(\d{2})/)
if (!match) return Number.MAX_SAFE_INTEGER
return Number(match[1]) * 60 + Number(match[2])
}
export function sortAppointmentsByTime(appointments) {
return [...appointments].sort((a, b) => {
const difference = getTimeSortValue(a.time) - getTimeSortValue(b.time)
if (difference !== 0) {
return difference
}
return String(a.patient || '').localeCompare(String(b.patient || ''), 'pt-BR')
})
}

11
test.mjs Normal file
View File

@@ -0,0 +1,11 @@
import { apiConfig } from './src/config/api.js';
async function test() {
const url = `${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(name)`;
const res = await fetch(url, { headers: { apikey: apiConfig.anonKey }});
const text = await res.text();
console.log('Status:', res.status);
console.log('Response:', text);
}
test().catch(console.error);

11
test2.mjs Normal file
View File

@@ -0,0 +1,11 @@
const url = "https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/appointments?select=*,patients(full_name),doctors(name)";
const key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
async function test() {
const res = await fetch(url, { headers: { apikey: key, Authorization: "Bearer " + key }});
const text = await res.text();
console.log('Status:', res.status);
console.log('Response:', text);
}
test().catch(console.error);

14
test3.mjs Normal file
View File

@@ -0,0 +1,14 @@
const url1 = "https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/doctors?select=*&limit=1";
const url2 = "https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/patients?select=*&limit=1";
const url3 = "https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/appointments?select=*&limit=1";
const key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
async function test() {
const reqs = [url1, url2, url3].map(u => fetch(u, { headers: { apikey: key, Authorization: "Bearer " + key }}));
const res = await Promise.all(reqs);
for(const r of res) {
console.log(r.url, await r.text());
}
}
test().catch(console.error);

13
test4.mjs Normal file
View File

@@ -0,0 +1,13 @@
const url = "https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/";
const key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
async function test() {
const res = await fetch(url, { headers: { apikey: key }});
const json = await res.json();
console.log("Doctors columns:", Object.keys(json.definitions.doctors.properties));
console.log("Patients columns:", Object.keys(json.definitions.patients.properties));
console.log("Appointments columns:", Object.keys(json.definitions.appointments.properties));
}
test().catch(console.error);

11
test5.mjs Normal file
View File

@@ -0,0 +1,11 @@
import fs from 'fs';
const url = "https://yuanqfswhberkoevtmfr.supabase.co/rest/v1/";
const key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
async function test() {
const res = await fetch(url, { headers: { apikey: key }});
const json = await res.json();
fs.writeFileSync('openapi.json', JSON.stringify(json, null, 2));
}
test().catch(console.error);