forked from RiseUP/riseup_squad_03
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:
@@ -1,4 +1,5 @@
|
|||||||
VITE_SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co
|
VITE_SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co
|
||||||
|
VITE_API_BASE_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1
|
||||||
VITE_SUPABASE_REST_URL=https://yuanqfswhberkoevtmfr.supabase.co/rest/v1
|
VITE_SUPABASE_REST_URL=https://yuanqfswhberkoevtmfr.supabase.co/rest/v1
|
||||||
VITE_SUPABASE_FUNCTIONS_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1
|
VITE_SUPABASE_FUNCTIONS_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1
|
||||||
VITE_SUPABASE_STORAGE_URL=https://yuanqfswhberkoevtmfr.supabase.co/storage/v1
|
VITE_SUPABASE_STORAGE_URL=https://yuanqfswhberkoevtmfr.supabase.co/storage/v1
|
||||||
|
|||||||
44
docs/ARCHITECTURE.md
Normal file
44
docs/ARCHITECTURE.md
Normal 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.
|
||||||
@@ -1,82 +1,49 @@
|
|||||||
# Auditoria dos repositories contra a API
|
# Auditoria de Implementacao e Mapeamento da API
|
||||||
|
|
||||||
Fonte da documentacao: https://do5wegrct3.apidog.io/llms.txt
|
Este documento resume o estado atual da integracao entre o front-end e os endpoints da API.
|
||||||
|
|
||||||
## Endpoints documentados
|
## Integrado no front
|
||||||
|
|
||||||
| Grupo | Metodo | Endpoint | Status no frontend |
|
- **Autenticacao**
|
||||||
| --- | --- | --- | --- |
|
- Login com email e senha via Supabase Auth (`/auth/v1/token`).
|
||||||
| Autenticacao | POST | `/auth/v1/token?grant_type=password` | Sem repository dedicado |
|
- Solicitar reset de senha: tenta `/solicitar-reset-de-senha` e usa `/auth/v1/recover` como fallback.
|
||||||
| Autenticacao | POST | `/auth/v1/otp` | Sem repository dedicado |
|
- Dados do usuario autenticado: tenta `/informacoes-do-usuario-autenticado` e usa `/auth/v1/user` como fallback.
|
||||||
| Autenticacao | POST | `/auth/v1/logout` | Sem repository dedicado |
|
- Logout: tenta `/logout`, usa `/auth/v1/logout` como fallback e sempre limpa a sessao local.
|
||||||
| Autenticacao | GET | `/auth/v1/user` | Sem repository dedicado; relacionado a `profileRepository` |
|
|
||||||
| Usuarios | POST | `/delete-user` | Sem repository dedicado |
|
|
||||||
| Usuarios | POST | `/functions/v1/create-user` | Sem repository dedicado |
|
|
||||||
| Usuarios | POST | `/functions/v1/create-user-with-password` | Sem repository dedicado |
|
|
||||||
| Usuarios | POST | `/request-password-reset` | Sem repository dedicado |
|
|
||||||
| Usuarios | POST | `/functions/v1/user-info` | Sem repository dedicado; relacionado a `profileRepository` |
|
|
||||||
| Usuarios | POST | `/functions/v1/user-info-by-id` | Sem repository dedicado |
|
|
||||||
| SMS | POST | `/functions/v1/send-sms` | `communicationRepository` nao implementa chamada real |
|
|
||||||
| Pacientes | GET | `/rest/v1/patients` | Implementado em `patientRepository.getAll` |
|
|
||||||
| Pacientes | POST | `/rest/v1/patients` | Implementado em `patientRepository.create` |
|
|
||||||
| Pacientes | PATCH | `/rest/v1/patients?id=eq.{id}` | Implementado em `patientRepository.update` |
|
|
||||||
| Pacientes | DELETE | `/rest/v1/patients?id=eq.{id}` | Implementado em `patientRepository.remove` |
|
|
||||||
| Pacientes | POST | `/functions/v1/create-patient` | Implementado em `patientRepository.createWithValidation` |
|
|
||||||
| Pacientes | POST | `/functions/v1/register-patient` | Nao implementado |
|
|
||||||
| Medicos | GET | `/rest/v1/doctors` | `professionalRepository.getAll` usa mock |
|
|
||||||
| Medicos | POST | `/functions/v1/create-doctor` | Nao implementado |
|
|
||||||
| Agendamentos | GET | `/rest/v1/appointments` | `appointmentRepository.getAll` usa mock |
|
|
||||||
| Agendamentos | POST | `/rest/v1/appointments` | Nao implementado |
|
|
||||||
| Agendamentos | POST | `/functions/v1/get-available-slots` | Nao implementado |
|
|
||||||
| Disponibilidade | GET | `/rest/v1/doctor_availability` | Nao implementado |
|
|
||||||
| Disponibilidade | POST | `/rest/v1/doctor_availability` | Nao implementado |
|
|
||||||
| Disponibilidade | PATCH | `/rest/v1/doctor_availability?id=eq.{id}` | Nao implementado |
|
|
||||||
| Disponibilidade | DELETE | `/rest/v1/doctor_availability?id=eq.{id}` | Nao implementado |
|
|
||||||
| Disponibilidade | GET | `/rest/v1/doctor_exceptions` | Nao implementado |
|
|
||||||
| Disponibilidade | POST | `/rest/v1/doctor_exceptions` | Nao implementado |
|
|
||||||
| Storage | POST | `/storage/v1/object/avatars/{path}` | Nao implementado |
|
|
||||||
| Storage | GET | `/storage/v1/object/avatars/{path}` | Nao implementado |
|
|
||||||
| Reports | GET | `/rest/v1/reports` | `reportRepository.getInitialReports` usa mock |
|
|
||||||
| Reports | POST | `/rest/v1/reports` | Nao implementado |
|
|
||||||
| Reports | PATCH | `/rest/v1/reports?id=eq.{id}` | Nao implementado |
|
|
||||||
|
|
||||||
## Repositories ainda nao implementados
|
- **Pacientes**
|
||||||
|
- Listar, criar, atualizar e deletar pacientes via Supabase REST.
|
||||||
|
- Criar paciente com validacao via Edge Function quando disponivel.
|
||||||
|
|
||||||
| Repository | Metodos atuais | Endpoint equivalente | Observacao |
|
- **Agendamentos**
|
||||||
| --- | --- | --- | --- |
|
- Listar agendamentos: tenta `GET /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||||
| `analyticsRepository` | `getDashboardData` | Nao documentado | Retorna dados estaticos de KPIs, graficos e pacientes frequentes. |
|
- Criar agendamento: tenta `POST /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||||
| `appointmentRepository` | `getAll`, `getTodayTimeline`, `getPredictiveQueueSummary`, `getWeekDays` | Parcial: `GET /rest/v1/appointments`, `POST /rest/v1/appointments`, `POST /functions/v1/get-available-slots` | `getAll` deveria chamar a API; demais metodos sao derivados/visuais e nao aparecem na doc. |
|
|
||||||
| `communicationRepository` | `getCampaigns`, `getInitialMessages`, `getInitialTemplates` | Parcial: `POST /functions/v1/send-sms` | A API so documenta envio de SMS; nao ha endpoints para campanhas, mensagens ou templates. |
|
|
||||||
| `homeRepository` | `getDashboardOverview` | Nao documentado | Tela inicial usa agregados estaticos. |
|
|
||||||
| `medicalRecordRepository` | `getRecordTypes`, `getInitialRecords` | Nao documentado | Nao ha endpoint de prontuarios/medical records na doc atual. |
|
|
||||||
| `professionalRepository` | `getAll`, `getCoverageMap` | Parcial: `GET /rest/v1/doctors`, `POST /functions/v1/create-doctor` | `getAll` deveria usar doctors; `getCoverageMap` parece derivado de disponibilidade, mas nao bate direto com um endpoint. |
|
|
||||||
| `profileRepository` | `getCurrentUserProfile` | Parcial: `GET /auth/v1/user`, `POST /functions/v1/user-info` | Retorna perfil fixo; deveria consumir dados do usuario autenticado. |
|
|
||||||
| `reportRepository` | `getAdminUsers`, `getCurrentUser`, `getDoctors`, `getInitialReports`, `getReportTypes`, `getTemplates` | Parcial: `GET/POST/PATCH /rest/v1/reports` | Lista e metadados sao mockados; nao existem metodos de criar/atualizar usando API. |
|
|
||||||
| `settingsRepository` | `getIntegrations`, `getSections` | Nao documentado | Configuracoes exibidas sao estaticas. |
|
|
||||||
| `visitRepository` | `getCareQueue`, `getStages` | Nao documentado | Nao ha endpoint de atendimentos/fila/visits na doc atual. |
|
|
||||||
|
|
||||||
## Inconsistencias encontradas
|
- **Laudos Medicos**
|
||||||
|
- Listar relatorios: tenta `GET /reports` e usa Supabase REST `reports` como fallback.
|
||||||
|
- Criar relatorio: tenta `POST /reports` e usa Supabase REST `reports` como fallback.
|
||||||
|
- Atualizar relatorio: tenta `PATCH /reports/{id}`, depois `PATCH /reports`, e usa Supabase REST `reports` como fallback.
|
||||||
|
|
||||||
- `patientRepository.getById(patientId)` nao bate com um endpoint documentado especifico. Ele chama `getAll()` e filtra em memoria; se a API aceitar filtro Supabase por id, o ideal seria usar `/rest/v1/patients?id=eq.{id}&select=*`, mas isso nao aparece como endpoint proprio na documentacao.
|
- **Medicos / Profissionais**
|
||||||
- `patientRepository.getDirectoryRows()` transforma pacientes em campos de UI e preenche `insurance`, `city`, `state`, `vip`, `lastVisit` e `nextVisit` com valores fixos. Esses campos nao estao descritos na resposta de `GET /rest/v1/patients`.
|
- Listar medicos: tenta `GET /listar-medicos` e usa Supabase REST `doctors` como fallback.
|
||||||
- `patientRepository.create(data)` e `createWithValidation(data)` enviam `created_by` com UUID zerado quando nao informado. A documentacao nao confirma esse fallback; isso pode gerar registro invalido se a API exigir usuario real.
|
|
||||||
- `patientRepository.createWithValidation(data)` usa a Edge Function documentada (`/functions/v1/create-patient`), mas a API tambem possui o endpoint publico `/functions/v1/register-patient`, ainda sem metodo correspondente.
|
|
||||||
- `appointmentRepository.getAll()` nao chama `GET /rest/v1/appointments`; usa `mockData`. Alem disso, nao existem metodos para `POST /rest/v1/appointments` nem para `POST /functions/v1/get-available-slots`.
|
|
||||||
- `professionalRepository.getAll()` nao chama `GET /rest/v1/doctors`; usa `mockData`. Tambem falta metodo para `POST /functions/v1/create-doctor`.
|
|
||||||
- `reportRepository.getInitialReports()` nao chama `GET /rest/v1/reports`; usa dados estaticos e nomes/status em portugues (`rascunho`, `finalizado`, `enviado`) diferentes dos status documentados (`draft`, `completed`).
|
|
||||||
- `reportRepository` expoe templates, tipos, usuarios admin e medico atual, mas esses recursos nao aparecem na API documentada de Reports.
|
|
||||||
- `communicationRepository` tem campanhas, mensagens e templates, mas a documentacao so possui `POST /functions/v1/send-sms`; nao ha equivalencia para listar ou gerenciar esses dados.
|
|
||||||
- `profileRepository.getCurrentUserProfile()` retorna perfil fixo; deveria ser alinhado com `GET /auth/v1/user` ou `POST /functions/v1/user-info`.
|
|
||||||
- `homeRepository`, `analyticsRepository`, `settingsRepository`, `visitRepository` e `medicalRecordRepository` nao possuem endpoints equivalentes na documentacao atual.
|
|
||||||
|
|
||||||
## Configuracao extraida
|
- **Mensageria**
|
||||||
|
- Enviar SMS: tenta `POST /enviar-sms-via-twilio` e usa Edge Function `send-sms` como fallback.
|
||||||
|
- O formulario agora coleta telefone quando o canal selecionado e SMS.
|
||||||
|
|
||||||
As variaveis reutilizaveis de API foram centralizadas em `src/config/api.js`:
|
- **Storage**
|
||||||
|
- Upload de avatar: tenta `/upload-avatar` e usa Supabase Storage no bucket `avatars` como fallback.
|
||||||
|
- A tela de perfil atualiza a imagem exibida apos upload bem-sucedido.
|
||||||
|
|
||||||
- `VITE_SUPABASE_URL`
|
## Ainda sem endpoint consolidado documentado
|
||||||
- `VITE_SUPABASE_REST_URL`
|
|
||||||
- `VITE_SUPABASE_FUNCTIONS_URL`
|
|
||||||
- `VITE_SUPABASE_STORAGE_URL`
|
|
||||||
- `VITE_SUPABASE_ANON_KEY`
|
|
||||||
|
|
||||||
O arquivo mantem os valores atuais como fallback para nao quebrar o ambiente local, mas o ideal e configurar esses valores via `.env`.
|
- Dashboard / Inicio (`HomePage` / `homeRepository.js`).
|
||||||
|
- Estatisticas e BI (`AnalyticsPage` / `analyticsRepository.js`).
|
||||||
|
- Prontuarios especificos separados de laudos (`MedicalRecordsPage` / `medicalRecordRepository.js`).
|
||||||
|
- Consultas isoladas fora de agendamento (`VisitsPage` / `visitRepository.js`).
|
||||||
|
- Configuracoes gerais do tenant (`SettingsPage` / `settingsRepository.js`).
|
||||||
|
|
||||||
|
## Observacoes
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL` define a base dos endpoints nomeados da API. Quando nao informado, o front usa `VITE_SUPABASE_FUNCTIONS_URL`.
|
||||||
|
- Os reposititorios aceitam formatos de resposta comuns como arrays diretos ou objetos com chaves `data`, `reports`, `agendamentos`, `medicos` etc.
|
||||||
|
- Os fallbacks existem para manter o front funcional em ambientes onde parte das Edge Functions ainda nao foi publicada.
|
||||||
|
|||||||
4
openapi.json
Normal file
4
openapi.json
Normal 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
11
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "projeto-residencia",
|
"name": "projeto-residencia",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
},
|
},
|
||||||
@@ -1495,6 +1496,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
45
src/App.jsx
45
src/App.jsx
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { authRepository } from './repositories/authRepository.js'
|
||||||
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { AppShell } from './components/AppShell.jsx'
|
import { AppShell } from './components/AppShell.jsx'
|
||||||
import { AgendaPage } from './pages/AgendaPage.jsx'
|
import { AgendaPage } from './pages/AgendaPage.jsx'
|
||||||
@@ -48,11 +50,16 @@ function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate])
|
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate])
|
||||||
|
const isAuthenticated = authRepository.isAuthenticated()
|
||||||
|
|
||||||
if (!route.withShell) {
|
if (!route.withShell) {
|
||||||
return route.element
|
return route.element
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <LoginPage navigate={navigate} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
|
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
|
||||||
{route.element}
|
{route.element}
|
||||||
@@ -119,15 +126,10 @@ function resolveRoute(pathname, navigate) {
|
|||||||
|
|
||||||
if (pathname.startsWith('/pacientes/')) {
|
if (pathname.startsWith('/pacientes/')) {
|
||||||
const patientId = pathname.split('/')[2]
|
const patientId = pathname.split('/')[2]
|
||||||
const patient = patientRepository.getById(patientId)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element: patient ? (
|
element: <PatientDetailRoute navigate={navigate} patientId={patientId} />,
|
||||||
<PatientDetailPage navigate={navigate} patient={patient} />
|
title: 'Paciente',
|
||||||
) : (
|
|
||||||
<NotFoundPage navigate={navigate} />
|
|
||||||
),
|
|
||||||
title: patient?.name || 'Paciente nao encontrado',
|
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,7 @@ function resolveRoute(pathname, navigate) {
|
|||||||
if (pathname === '/laudos') {
|
if (pathname === '/laudos') {
|
||||||
return {
|
return {
|
||||||
element: <ReportsPage navigate={navigate} />,
|
element: <ReportsPage navigate={navigate} />,
|
||||||
title: 'Laudos',
|
title: 'Relatorios medicos',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +197,33 @@ function resolveRoute(pathname, navigate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PatientDetailRoute({ navigate, patientId }) {
|
||||||
|
const [patient, setPatient] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
patientRepository.getById(patientId)
|
||||||
|
.then((data) => {
|
||||||
|
if (active) setPatient(data)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [patientId])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="pt-10 text-sm text-[#a3a3a3]">Carregando paciente...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return patient ? <PatientDetailPage navigate={navigate} patient={patient} /> : <NotFoundPage navigate={navigate} />
|
||||||
|
}
|
||||||
|
|
||||||
function readLocation() {
|
function readLocation() {
|
||||||
return {
|
return {
|
||||||
pathname: normalizePath(window.location.pathname),
|
pathname: normalizePath(window.location.pathname),
|
||||||
|
|||||||
@@ -1,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'
|
import { BrandLogo } from './Brand.jsx'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
|
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
|
||||||
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
||||||
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
||||||
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
|
{ href: '/prontuario', label: 'Prontuario', icon: 'file' },
|
||||||
{ href: '/laudos', label: 'Laudos', icon: 'clipboard' },
|
{ href: '/laudos', label: 'Relatorios medicos', icon: 'clipboard' },
|
||||||
{
|
{
|
||||||
href: '/camunicacao',
|
href: '/camunicacao',
|
||||||
label: 'Comunicação',
|
label: 'Comunicacao',
|
||||||
icon: 'message',
|
icon: 'message',
|
||||||
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
|
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
|
||||||
},
|
},
|
||||||
{ href: '/relatorios', label: 'Relatórios', icon: 'chart' },
|
{ href: '/relatorios', label: 'Relatorios', icon: 'chart' },
|
||||||
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
{ href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const titles = {
|
const titles = {
|
||||||
@@ -24,22 +25,23 @@ const titles = {
|
|||||||
'/dashboard': 'Painel',
|
'/dashboard': 'Painel',
|
||||||
'/agenda': 'Agenda',
|
'/agenda': 'Agenda',
|
||||||
'/consultas': 'Consultas',
|
'/consultas': 'Consultas',
|
||||||
'/laudos': 'Laudos',
|
'/laudos': 'Relatorios medicos',
|
||||||
'/pacientes': 'Pacientes',
|
'/pacientes': 'Pacientes',
|
||||||
'/prontuario': 'Prontuário',
|
'/prontuario': 'Prontuario',
|
||||||
'/camunicacao': 'Comunicação',
|
'/camunicacao': 'Comunicacao',
|
||||||
'/comunicacao': 'Comunicação',
|
'/comunicacao': 'Comunicacao',
|
||||||
'/mensagens': 'Comunicação',
|
'/mensagens': 'Comunicacao',
|
||||||
'/relatorios': 'Relatórios',
|
'/relatorios': 'Relatorios',
|
||||||
'/profissionais': 'Profissionais',
|
'/profissionais': 'Profissionais',
|
||||||
'/perfil': 'Perfil',
|
'/perfil': 'Perfil',
|
||||||
'/configuracoes': 'Configurações',
|
'/configuracoes': 'Configuracoes',
|
||||||
'/config': 'Configurações',
|
'/config': 'Configuracoes',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [quickSearch, setQuickSearch] = useState('')
|
const [quickSearch, setQuickSearch] = useState('')
|
||||||
|
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' })
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (currentPath.startsWith('/pacientes/') && routeTitle) {
|
if (currentPath.startsWith('/pacientes/') && routeTitle) {
|
||||||
@@ -49,6 +51,25 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
|||||||
return routeTitle || titles[currentPath] || 'MediConnect'
|
return routeTitle || titles[currentPath] || 'MediConnect'
|
||||||
}, [currentPath, routeTitle])
|
}, [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) {
|
function goTo(path) {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
navigate(path)
|
navigate(path)
|
||||||
@@ -95,8 +116,8 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
|||||||
onClick={() => goTo('/perfil')}
|
onClick={() => goTo('/perfil')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<p className="truncate text-xs font-semibold text-[#e5e5e5]">Dr. Henrique Cardoso</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]">Médico Clínico Geral</p>
|
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -128,7 +149,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
|||||||
aria-label="Busca rapida"
|
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"
|
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)}
|
onChange={(event) => setQuickSearch(event.target.value)}
|
||||||
placeholder="Buscar paciente, prontuário..."
|
placeholder="Buscar paciente, prontuario..."
|
||||||
value={quickSearch}
|
value={quickSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,14 +175,14 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
|||||||
type="button"
|
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]">
|
<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>
|
||||||
<span className="hidden min-w-0 sm:block">
|
<span className="hidden min-w-0 sm:block">
|
||||||
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
||||||
Dr. Henrique Cardoso
|
{viewerProfile.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
||||||
Médico(a)
|
{viewerProfile.role}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
|
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
|
||||||
@@ -331,3 +352,13 @@ function SearchIcon({ className = 'size-4' }) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitials(name) {
|
||||||
|
return String(name || 'US')
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|||||||
39
src/components/FeatureState.jsx
Normal file
39
src/components/FeatureState.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
src/components/calendar/AgendaDailyView.jsx
Normal file
100
src/components/calendar/AgendaDailyView.jsx
Normal 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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/components/calendar/AgendaMonthlyView.jsx
Normal file
107
src/components/calendar/AgendaMonthlyView.jsx
Normal 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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/components/calendar/AgendaWeeklyView.jsx
Normal file
122
src/components/calendar/AgendaWeeklyView.jsx
Normal 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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/components/featureStateStyles.js
Normal file
31
src/components/featureStateStyles.js
Normal 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
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberkoevtmfr.supabase.co'
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberkoevtmfr.supabase.co'
|
||||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
||||||
|
|
||||||
|
const AUTH_SESSION_KEY = 'mediconnect.auth.session'
|
||||||
|
|
||||||
export const apiConfig = {
|
export const apiConfig = {
|
||||||
|
apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
||||||
supabaseUrl: SUPABASE_URL,
|
supabaseUrl: SUPABASE_URL,
|
||||||
restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`,
|
restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`,
|
||||||
functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
||||||
@@ -9,8 +12,76 @@ export const apiConfig = {
|
|||||||
anonKey: SUPABASE_ANON_KEY,
|
anonKey: SUPABASE_ANON_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiHeaders = {
|
export function apiEndpoint(path, baseUrl = apiConfig.apiUrl) {
|
||||||
apikey: apiConfig.anonKey,
|
const normalizedBase = baseUrl.replace(/\/+$/, '')
|
||||||
Authorization: `Bearer ${apiConfig.anonKey}`,
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
'Content-Type': 'application/json',
|
return `${normalizedBase}${normalizedPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthSession() {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const rawSession = window.sessionStorage.getItem(AUTH_SESSION_KEY)
|
||||||
|
if (!rawSession) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawSession)
|
||||||
|
} catch {
|
||||||
|
clearAuthSession()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuthSession(session) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthSession() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAuthenticatedSession() {
|
||||||
|
const session = getAuthSession()
|
||||||
|
if (!session?.access_token) return false
|
||||||
|
|
||||||
|
// Validate expiration locally if available
|
||||||
|
if (session.expires_at && session.expires_at * 1000 <= Date.now()) {
|
||||||
|
clearAuthSession()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnonHeaders(extraHeaders = {}) {
|
||||||
|
return cleanHeaders({
|
||||||
|
apikey: apiConfig.anonKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...extraHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthenticatedHeaders(extraHeaders = {}) {
|
||||||
|
const session = getAuthSession()
|
||||||
|
const accessToken = session?.access_token
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Sessão expirada. Faça login novamente.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanHeaders({
|
||||||
|
apikey: apiConfig.anonKey,
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...extraHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHeaders(headers) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(headers).filter(([, value]) => value !== undefined && value !== null),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
210
src/hooks/useAgenda.js
Normal file
210
src/hooks/useAgenda.js
Normal 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()
|
||||||
|
}
|
||||||
99
src/mappers/appointmentMapper.js
Normal file
99
src/mappers/appointmentMapper.js
Normal 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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
61
src/mappers/reportMapper.js
Normal file
61
src/mappers/reportMapper.js
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
||||||
import { patientRepository } from '../repositories/patientRepository.js'
|
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
||||||
|
import { useAgenda } from '../hooks/useAgenda.js'
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ label: 'Todos', value: 'Todos' },
|
{ label: 'Todos', value: 'Todos' },
|
||||||
@@ -11,70 +23,47 @@ const statusFilters = [
|
|||||||
{ label: 'Aguardando', value: 'Aguardando' },
|
{ 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 }) {
|
export function AgendaPage({ navigate }) {
|
||||||
const [patients, setPatients] = useState([])
|
const {
|
||||||
const professionals = professionalRepository.getAll()
|
patients,
|
||||||
const queue = appointmentRepository.getPredictiveQueueSummary()
|
professionals,
|
||||||
const timeline = appointmentRepository.getTodayTimeline()
|
currentProfessional,
|
||||||
const weekDays = appointmentRepository.getWeekDays()
|
viewerProfile,
|
||||||
const [activeView, setActiveView] = useState('Dia')
|
agendaScope,
|
||||||
const [status, setStatus] = useState('Todos')
|
loading,
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
error,
|
||||||
const [localAppointments, setLocalAppointments] = useState(() => appointmentRepository.getAll())
|
canCreateAppointment,
|
||||||
const [form, setForm] = useState({
|
activeView,
|
||||||
patientId: '',
|
setActiveView,
|
||||||
professional: professionals[0]?.name || '',
|
baseDate,
|
||||||
type: 'Retorno',
|
setBaseDate,
|
||||||
time: '15:30',
|
status,
|
||||||
mode: 'Teleconsulta',
|
setStatus,
|
||||||
})
|
modalOpen,
|
||||||
|
setModalOpen,
|
||||||
|
form,
|
||||||
|
updateForm,
|
||||||
|
handleCreate,
|
||||||
|
visibleAppointments,
|
||||||
|
} = useAgenda()
|
||||||
|
|
||||||
useEffect(() => {
|
if (loading) {
|
||||||
patientRepository.getAll().then((data) => {
|
return (
|
||||||
setPatients(data)
|
<div className="flex h-[50vh] items-center justify-center text-[#a3a3a3]">
|
||||||
setForm((current) => ({
|
<p>Carregando agenda...</p>
|
||||||
...current,
|
</div>
|
||||||
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 }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreate(event) {
|
const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
|
||||||
event.preventDefault()
|
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
|
||||||
const patient = patients.find((item) => item.id === form.patientId) || patients[0]
|
const isDoctorScope = agendaScope === 'doctor'
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
||||||
@@ -84,20 +73,53 @@ useEffect(() => {
|
|||||||
Agenda
|
Agenda
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
|
<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>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
Hoje
|
Hoje
|
||||||
</button>
|
</button>
|
||||||
<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)}
|
onClick={() => setModalOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -106,133 +128,103 @@ useEffect(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-5">
|
{error ? (
|
||||||
{weekDays.map((day) => (
|
<section className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
|
||||||
<button
|
<div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
|
||||||
className={`rounded-2xl border p-4 text-left transition ${
|
<h2 className="text-base font-bold text-[#fecaca]">Nao foi possivel liberar a agenda</h2>
|
||||||
day.active
|
<p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
|
||||||
? 'border-[#3b82f6] bg-[#3b82f6]/10'
|
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
|
||||||
: 'border-[#404040] bg-[#262626] hover:border-[#525252]'
|
Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico.
|
||||||
}`}
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
) : (
|
||||||
{statusFilters.map((filter) => (
|
<section className="grid gap-6 xl:grid-cols-1">
|
||||||
<button
|
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
|
||||||
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
status === filter.value
|
<div>
|
||||||
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">
|
||||||
}`}
|
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
|
||||||
key={filter.value}
|
</h2>
|
||||||
onClick={() => setStatus(filter.value)}
|
</div>
|
||||||
type="button"
|
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
|
||||||
>
|
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros visíveis
|
||||||
{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.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
{viewFilters.map((view) => (
|
||||||
<h2 className="text-base font-bold text-[#e5e5e5]">Linha do tempo</h2>
|
<button
|
||||||
<div className="mt-5 grid gap-1">
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
{timeline.map((item) => (
|
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
|
<button
|
||||||
className="grid grid-cols-[58px_1fr] gap-4 rounded-md px-2 py-3 text-left transition hover:bg-[#303030]"
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
disabled={!item.patientId}
|
status === filter.value
|
||||||
key={`${item.hour}-${item.patient}`}
|
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||||
onClick={() => item.patientId && navigate(`/pacientes/${item.patientId}`)}
|
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => setStatus(filter.value)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold text-[#3b82f6]">{item.hour}</span>
|
{filter.label}
|
||||||
<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>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
{!isDoctorScope && (
|
||||||
<h2 className="text-base font-bold text-[#e5e5e5]">Resumo preditivo</h2>
|
<div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
||||||
<div className="mt-5 grid gap-3">
|
Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais.
|
||||||
{queue.map((item) => (
|
</div>
|
||||||
<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 className="mt-6 grid gap-3">
|
||||||
</div>
|
{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>
|
</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>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
|
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
|
||||||
<form className="grid gap-4" onSubmit={handleCreate}>
|
<form className="grid gap-4" onSubmit={handleCreate}>
|
||||||
@@ -244,7 +236,7 @@ useEffect(() => {
|
|||||||
>
|
>
|
||||||
{patients.map((patient) => (
|
{patients.map((patient) => (
|
||||||
<option key={patient.id} value={patient.id}>
|
<option key={patient.id} value={patient.id}>
|
||||||
{patient.name}
|
{patient.name || patient.full_name || patient.nome}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -272,15 +264,26 @@ useEffect(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DarkField label="Profissional">
|
<DarkField label="Profissional">
|
||||||
<select
|
{isDoctorScope ? (
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
<input
|
||||||
onChange={(event) => updateForm('professional', event.target.value)}
|
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
||||||
value={form.professional}
|
disabled
|
||||||
>
|
readOnly
|
||||||
{professionals.map((professional) => (
|
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||||
<option key={professional.id}>{professional.name}</option>
|
/>
|
||||||
))}
|
) : (
|
||||||
</select>
|
<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>
|
||||||
|
|
||||||
<DarkField label="Tipo de consulta">
|
<DarkField label="Tipo de consulta">
|
||||||
@@ -300,7 +303,8 @@ useEffect(() => {
|
|||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<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"
|
type="submit"
|
||||||
>
|
>
|
||||||
Salvar consulta
|
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 }) {
|
function DarkField({ children, label }) {
|
||||||
return (
|
return (
|
||||||
<label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]">
|
<label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]">
|
||||||
@@ -376,30 +349,3 @@ function DarkModal({ children, onClose, open, title }) {
|
|||||||
</div>
|
</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]'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { analyticsRepository } from '../repositories/analyticsRepository.js'
|
import { analyticsRepository } from '../repositories/analyticsRepository.js'
|
||||||
|
|
||||||
const periods = [
|
const periods = [
|
||||||
@@ -25,6 +26,12 @@ export function AnalyticsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<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">
|
<section className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Relatórios & Analytics</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Relatórios & Analytics</h1>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
|
|
||||||
import { BrandLogo } from '../components/Brand.jsx'
|
import { BrandLogo } from '../components/Brand.jsx'
|
||||||
|
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import loginClinicImage from '../assets/figma/login-clinic.png'
|
import loginClinicImage from '../assets/figma/login-clinic.png'
|
||||||
|
|
||||||
export function LoginPage({ navigate }) {
|
export function LoginPage({ navigate }) {
|
||||||
@@ -9,14 +12,26 @@ export function LoginPage({ navigate }) {
|
|||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
function updateField(field, value) {
|
function updateField(field, value) {
|
||||||
setForm((current) => ({ ...current, [field]: value }))
|
setForm((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
navigate('/inicio')
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authRepository.login(form)
|
||||||
|
navigate('/inicio')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Erro de autenticação')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,6 +89,12 @@ export function LoginPage({ navigate }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded bg-red-500/10 p-3 text-sm font-semibold text-red-500 border border-red-500/20">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form className="mt-8 grid gap-5" onSubmit={handleSubmit}>
|
<form className="mt-8 grid gap-5" onSubmit={handleSubmit}>
|
||||||
<LoginField htmlFor="login-email" label="E-mail">
|
<LoginField htmlFor="login-email" label="E-mail">
|
||||||
<input
|
<input
|
||||||
@@ -122,10 +143,11 @@ export function LoginPage({ navigate }) {
|
|||||||
</LoginField>
|
</LoginField>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2),0_4px_6px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6]"
|
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2),0_4px_6px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6] disabled:opacity-50"
|
||||||
|
disabled={loading}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Entrar
|
{loading ? 'Entrando...' : 'Entrar'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,6 +164,7 @@ export function LoginPage({ navigate }) {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
dev · credenciais
|
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 aria-hidden="true" className="text-[9px]">
|
||||||
^
|
^
|
||||||
</span>
|
</span>
|
||||||
@@ -160,6 +183,12 @@ export function RegisterPage({ navigate }) {
|
|||||||
description="Crie um acesso mockado para navegar pelo ambiente da clínica."
|
description="Crie um acesso mockado para navegar pelo ambiente da clínica."
|
||||||
title="Criar acesso"
|
title="Criar acesso"
|
||||||
>
|
>
|
||||||
|
<FeatureCallout
|
||||||
|
className="mt-6"
|
||||||
|
description="Cadastro ainda é apenas demonstrativo e não cria conta real."
|
||||||
|
status="mock"
|
||||||
|
title="Fluxo mockado"
|
||||||
|
/>
|
||||||
<form
|
<form
|
||||||
className="mt-8 grid gap-5"
|
className="mt-8 grid gap-5"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -204,29 +233,52 @@ export function RegisterPage({ navigate }) {
|
|||||||
|
|
||||||
export function ForgotPasswordPage({ navigate }) {
|
export function ForgotPasswordPage({ navigate }) {
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
|
const [email, setEmail] = useState('recepcao@mediconnect.com')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await authRepository.requestPasswordReset(email)
|
||||||
|
setSent(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Erro ao comunicar com o servidor.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
description="Informe o e-mail cadastrado para receber um link mockado."
|
description="Informe o e-mail cadastrado para receber o link de acesso."
|
||||||
title="Recuperar senha"
|
title="Recuperar senha"
|
||||||
>
|
>
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<div className="mt-8 rounded-[6px] border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm leading-6 text-emerald-300">
|
<div className="mt-8 rounded-[6px] border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm leading-6 text-emerald-300">
|
||||||
Link de recuperação mockado enviado para o e-mail informado.
|
Link de recuperação enviado para o e-mail informado. Siga as instruções do link!
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form
|
<form
|
||||||
className="mt-8 grid gap-5"
|
className="mt-8 grid gap-5"
|
||||||
onSubmit={(event) => {
|
onSubmit={handleSubmit}
|
||||||
event.preventDefault()
|
|
||||||
setSent(true)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-500/10 p-3 text-sm font-semibold text-red-500 border border-red-500/20">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AuthField label="E-mail cadastrado">
|
<AuthField label="E-mail cadastrado">
|
||||||
<input autoComplete="email" className={authInputClass} defaultValue="recepcao@mediconnect.com" type="email" />
|
<input autoComplete="email" className={authInputClass} onChange={e => setEmail(e.target.value)} value={email} type="email" />
|
||||||
</AuthField>
|
</AuthField>
|
||||||
<button className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed]" type="submit">
|
<button
|
||||||
Enviar link
|
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] disabled:opacity-50"
|
||||||
|
disabled={loading}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{loading ? "Enviando..." : "Enviar link"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import loginClinicImage from '../assets/figma/login-clinic.png'
|
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'
|
import { homeRepository } from '../repositories/homeRepository.js'
|
||||||
|
|
||||||
export function HomePage({ navigate }) {
|
export function HomePage({ navigate }) {
|
||||||
@@ -6,6 +8,12 @@ export function HomePage({ navigate }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
<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">
|
<section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
|
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
|
||||||
@@ -41,14 +49,17 @@ export function HomePage({ navigate }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.7fr_0.9fr]">
|
<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 justify-between gap-4">
|
||||||
<div className="flex items-start 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">
|
<div className="grid size-12 shrink-0 place-items-center rounded-md bg-[#3b82f6] text-white">
|
||||||
<SparkLineIcon className="size-6" />
|
<SparkLineIcon className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<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]">
|
<p className="mt-1 text-sm font-medium leading-5 text-[#a3a3a3]">
|
||||||
Evolução de absenteísmo e risco da semana
|
Evolução de absenteísmo e risco da semana
|
||||||
</p>
|
</p>
|
||||||
@@ -65,8 +76,11 @@ export function HomePage({ navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
|
||||||
<h2 className="text-base font-bold text-[#e5e5e5]">Pacientes de hoje</h2>
|
<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">
|
<div className="mt-4 grid gap-3">
|
||||||
{appointmentsToday.map((item) => (
|
{appointmentsToday.map((item) => (
|
||||||
<button
|
<button
|
||||||
@@ -85,8 +99,11 @@ export function HomePage({ navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
|
||||||
<h2 className="text-base font-bold text-[#e5e5e5]">Alerta preditivo</h2>
|
<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]">
|
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
|
||||||
3 pacientes apresentam risco de falta. Recomenda-se confirmar presença antes das 16h.
|
3 pacientes apresentam risco de falta. Recomenda-se confirmar presença antes das 16h.
|
||||||
</p>
|
</p>
|
||||||
@@ -102,7 +119,10 @@ export function HomePage({ navigate }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4" id="relatorios">
|
<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">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<button
|
<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)]"
|
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)]"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +35,12 @@ export function MedicalRecordsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
<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 className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Prontuário Médico</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Prontuário Médico</h1>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
|
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||||
import { communicationRepository } from '../repositories/communicationRepository.js'
|
import { communicationRepository } from '../repositories/communicationRepository.js'
|
||||||
|
|
||||||
const channels = {
|
const channels = {
|
||||||
@@ -18,6 +20,7 @@ const statusConfig = {
|
|||||||
|
|
||||||
const emptyMessage = {
|
const emptyMessage = {
|
||||||
patient: '',
|
patient: '',
|
||||||
|
phone: '',
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
template: 'Lembrete 48h',
|
template: 'Lembrete 48h',
|
||||||
content: '',
|
content: '',
|
||||||
@@ -79,6 +82,7 @@ export function MessagesPage() {
|
|||||||
function openTemplate(template) {
|
function openTemplate(template) {
|
||||||
setComposer({
|
setComposer({
|
||||||
patient: '',
|
patient: '',
|
||||||
|
phone: '',
|
||||||
channel: template.channel,
|
channel: template.channel,
|
||||||
template: template.name,
|
template: template.name,
|
||||||
content: template.content,
|
content: template.content,
|
||||||
@@ -86,13 +90,33 @@ export function MessagesPage() {
|
|||||||
setComposerOpen(true)
|
setComposerOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitMessage(event) {
|
async function submitMessage(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!composer.patient.trim()) {
|
if (!composer.patient.trim()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let smsSent = false
|
||||||
|
|
||||||
|
if (composer.channel === 'sms') {
|
||||||
|
if (!composer.phone.trim()) {
|
||||||
|
alert('Informe o telefone para enviar SMS.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await communicationRepository.sendSms({
|
||||||
|
patientName: composer.patient.trim(),
|
||||||
|
phone: composer.phone.trim(),
|
||||||
|
content: composer.content,
|
||||||
|
})
|
||||||
|
smsSent = true
|
||||||
|
} catch (e) {
|
||||||
|
alert('Falha ao disparar SMS: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setMessages((current) => [
|
setMessages((current) => [
|
||||||
{
|
{
|
||||||
id: `local-${Date.now()}`,
|
id: `local-${Date.now()}`,
|
||||||
@@ -100,7 +124,7 @@ export function MessagesPage() {
|
|||||||
channel: composer.channel,
|
channel: composer.channel,
|
||||||
template: composer.template.trim() || 'Mensagem avulsa',
|
template: composer.template.trim() || 'Mensagem avulsa',
|
||||||
sentAt: 'Agora',
|
sentAt: 'Agora',
|
||||||
status: 'pendente',
|
status: composer.channel === 'sms' ? (smsSent ? 'entregue' : 'falha') : 'pendente',
|
||||||
response: '',
|
response: '',
|
||||||
},
|
},
|
||||||
...current,
|
...current,
|
||||||
@@ -133,6 +157,12 @@ export function MessagesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<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 className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
|
||||||
@@ -188,7 +218,7 @@ export function MessagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'historico' ? (
|
{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">
|
<div className="mb-6 flex flex-col gap-3 md:flex-row">
|
||||||
<label className="relative flex-1">
|
<label className="relative flex-1">
|
||||||
<span className="sr-only">Buscar comunicação</span>
|
<span className="sr-only">Buscar comunicação</span>
|
||||||
@@ -257,7 +287,7 @@ export function MessagesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'templates' ? (
|
{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">
|
<div className="flex justify-end">
|
||||||
<button
|
<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]"
|
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}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'campanha' ? (
|
{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="py-8 text-center">
|
||||||
<div className="mx-auto mb-4 grid size-16 place-items-center rounded-full bg-[#303030]">
|
<div className="mx-auto mb-4 grid size-16 place-items-center rounded-full bg-[#303030]">
|
||||||
<CommIcon className="size-8 text-[#51a2ff]" name="send" />
|
<CommIcon className="size-8 text-[#51a2ff]" name="send" />
|
||||||
@@ -300,6 +330,7 @@ export function MessagesPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setComposer({
|
setComposer({
|
||||||
patient: campaign.count,
|
patient: campaign.count,
|
||||||
|
phone: '',
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
template: campaign.title,
|
template: campaign.title,
|
||||||
content: campaign.desc,
|
content: campaign.desc,
|
||||||
@@ -470,6 +501,17 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
|||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{draft.channel === 'sms' ? (
|
||||||
|
<DarkField label="Telefone">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => update('phone', event.target.value)}
|
||||||
|
placeholder="(81) 99999-9999"
|
||||||
|
value={draft.phone}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DarkField label="Template">
|
<DarkField label="Template">
|
||||||
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
|
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
|
||||||
<option value="Mensagem avulsa">Mensagem avulsa</option>
|
<option value="Mensagem avulsa">Mensagem avulsa</option>
|
||||||
|
|||||||
@@ -301,43 +301,43 @@ async function deletePatient(patientId) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-[#404040]">
|
<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]">
|
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Nome</th>
|
<th className="w-[24%] px-6 py-4">Nome</th>
|
||||||
<th className="px-6 py-4">Telefone</th>
|
<th className="w-[14%] px-6 py-4">Telefone</th>
|
||||||
<th className="px-6 py-4">Cidade</th>
|
<th className="w-[12%] px-6 py-4">Cidade</th>
|
||||||
<th className="px-6 py-4">Estado</th>
|
<th className="w-[8%] px-6 py-4">Estado</th>
|
||||||
<th className="px-6 py-4">Ultimo atendimento</th>
|
<th className="w-[16%] px-6 py-4">Ultimo atendimento</th>
|
||||||
<th className="px-6 py-4">Proximo atendimento</th>
|
<th className="w-[18%] px-6 py-4">Proximo atendimento</th>
|
||||||
<th className="px-6 py-4 text-right">Acoes</th>
|
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-6 py-4 text-right">Acoes</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
||||||
{paginatedPatients.length ? (
|
{paginatedPatients.length ? (
|
||||||
paginatedPatients.map((patient) => (
|
paginatedPatients.map((patient) => (
|
||||||
<tr className="transition hover:bg-[#303030]" key={patient.id}>
|
<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">
|
<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)}
|
{patient.name.charAt(0)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="min-w-0">
|
||||||
<span className="block font-medium text-[#e5e5e5] transition hover:text-[#3b82f6]">
|
<span className="block whitespace-normal break-words font-medium text-[#e5e5e5] transition hover:text-[#3b82f6]">
|
||||||
{patient.name}
|
{patient.name}
|
||||||
</span>
|
</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' : ''}
|
{patient.insurance || 'Sem convenio'} {patient.vip ? ' | VIP' : ''}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-[#a3a3a3]">{patient.phone}</td>
|
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.phone}</td>
|
||||||
<td className="px-6 py-4 text-[#a3a3a3]">{patient.city}</td>
|
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.city}</td>
|
||||||
<td className="px-6 py-4 text-[#a3a3a3]">{patient.state}</td>
|
<td className="px-6 py-4 align-top 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 align-top whitespace-normal break-words 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="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
|
||||||
<td className="relative px-6 py-4 text-right">
|
<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
|
<button
|
||||||
aria-label={`Acoes de ${patient.name}`}
|
aria-label={`Acoes de ${patient.name}`}
|
||||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
|
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">
|
<div className="grid gap-3">
|
||||||
{[
|
{[
|
||||||
{ date: patient.nextVisit, status: 'Agendada', description: `Retorno para ${patient.condition}` },
|
{ 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) => (
|
].map((visit) => (
|
||||||
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={`${visit.date}-${visit.status}`}>
|
<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">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -814,7 +814,7 @@ function PatientDocuments({ patient }) {
|
|||||||
{patient.exams.map((exam) => (
|
{patient.exams.map((exam) => (
|
||||||
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={exam}>
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={exam}>
|
||||||
<p className="font-semibold text-[#f5f5f5]">{exam}</p>
|
<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">
|
<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
|
A revisar
|
||||||
</span>
|
</span>
|
||||||
@@ -1276,4 +1276,4 @@ function maskCEPInput(event) {
|
|||||||
.replace(/\D/g, '')
|
.replace(/\D/g, '')
|
||||||
.replace(/(\d{5})(\d)/, '$1-$2')
|
.replace(/(\d{5})(\d)/, '$1-$2')
|
||||||
.replace(/(-\d{3})\d+?$/, '$1')
|
.replace(/(-\d{3})\d+?$/, '$1')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { profileRepository } from '../repositories/profileRepository.js'
|
||||||
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage({ navigate }) {
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [profile, setProfile] = useState(() => profileRepository.getCurrentUserProfile())
|
const [profile, setProfile] = useState({ name: '', role: '', email: '', phone: '', unit: '', avatarUrl: '' })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||||
|
const [avatarError, setAvatarError] = useState('')
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
profileRepository.getCurrentUserProfile().then(data => {
|
||||||
|
setProfile(data)
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
function update(field, value) {
|
function update(field, value) {
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
setProfile((current) => ({ ...current, [field]: value }))
|
setProfile((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authRepository.logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarChange(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setUploadingAvatar(true)
|
||||||
|
setAvatarError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await profileRepository.updateAvatar(file)
|
||||||
|
setProfile((current) => ({
|
||||||
|
...current,
|
||||||
|
avatarUrl: result.avatarUrl || URL.createObjectURL(file),
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
setAvatarError(err.message || 'Erro ao enviar avatar.')
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false)
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center pt-20 text-[#a3a3a3]">Localizando dados do paciente...</div>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<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>
|
<header>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1>
|
<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>
|
<p className="mt-1 text-sm text-[#b8b8b8]">Dados locais do usuário logado e preferências básicas do shell.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
<section className={`${cardClass} p-6`}>
|
<section className={`${cardClass} ${featurePanelClass('partial')} p-6`}>
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
{profile.avatarUrl ? (
|
||||||
HC
|
<img
|
||||||
</div>
|
alt=""
|
||||||
|
className="size-16 rounded-full border border-[#3b82f6]/30 object-cover"
|
||||||
|
src={profile.avatarUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
||||||
|
{initials(profile.name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
|
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
|
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
|
||||||
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
|
<button
|
||||||
Alterar foto
|
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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">
|
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
|
||||||
Salvar alterações
|
Salvar alterações
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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>
|
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
|
||||||
<dl className="mt-5 grid gap-4 text-sm">
|
<dl className="mt-5 grid gap-4 text-sm">
|
||||||
<Info label="Perfil" value="Administrador da clínica" />
|
<Info label="Perfil" value={profile.role} />
|
||||||
<Info label="Último acesso" value="07 abr 2026, 09:15" />
|
<Info label="E-mail principal" value={profile.email} />
|
||||||
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
||||||
</dl>
|
</dl>
|
||||||
|
<div className="mt-8 border-t border-[#404040] pt-6">
|
||||||
|
<button
|
||||||
|
className="w-full h-10 rounded-sm border border-red-500/30 text-red-500 font-semibold text-sm transition hover:bg-red-500/10"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Sair da conta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,3 +185,13 @@ function Info({ label, value }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initials(name) {
|
||||||
|
return String(name || 'US')
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { settingsRepository } from '../repositories/settingsRepository.js'
|
import { settingsRepository } from '../repositories/settingsRepository.js'
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,13 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl">
|
<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">
|
<header className="mb-8">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
|
<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>
|
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>
|
||||||
|
|||||||
@@ -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'
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
export function TeamPage({ navigate }) {
|
export function TeamPage({ navigate }) {
|
||||||
const professionals = professionalRepository.getAll()
|
const [professionals, setProfessionals] = useState([])
|
||||||
const { slots, weekdays } = professionalRepository.getCoverageMap()
|
const { slots, weekdays } = professionalRepository.getCoverageMap()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
professionalRepository.getAll().then(setProfessionals).catch(console.error)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<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">
|
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Profissionais</h1>
|
<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">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe médica">
|
||||||
{professionals.map((professional) => (
|
{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 className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="grid size-11 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-sm font-bold text-[#3b82f6]">
|
<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>
|
||||||
|
|
||||||
<section className={`${cardClass} p-5`}>
|
<section className={`${cardClass} ${featurePanelClass('mock')} p-5`}>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<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]">
|
<p className="mt-1 text-sm text-[#a3a3a3]">
|
||||||
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
|
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { visitRepository } from '../repositories/visitRepository.js'
|
import { visitRepository } from '../repositories/visitRepository.js'
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -35,6 +36,12 @@ export function VisitsPage({ navigate }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<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">
|
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Consultas</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Consultas</h1>
|
||||||
|
|||||||
@@ -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 = {
|
export const appointmentRepository = {
|
||||||
getAll() {
|
async getAll({ doctorId } = {}) {
|
||||||
return mockAppointments
|
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() {
|
async create(uiData) {
|
||||||
return [
|
const response = await fetch(`${apiConfig.restUrl}/appointments`, {
|
||||||
{ hour: '08:00', patient: 'Carla Mendes', type: 'Consulta inicial', status: 'Confirmada', patientId: 'carla-mendes' },
|
method: 'POST',
|
||||||
{ hour: '09:30', patient: 'Ana Souza', type: 'Retorno clinico', status: 'Em triagem', patientId: 'ana-souza' },
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
{ hour: '11:00', patient: 'Diego Alves', type: 'Acompanhamento', status: 'Aguardando', patientId: 'diego-alves' },
|
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
|
||||||
{ 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 },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
getPredictiveQueueSummary() {
|
if (!response.ok) throw new Error('Falha ao criar o agendamento.')
|
||||||
return [
|
|
||||||
{ label: 'Alta prioridade', value: 3, tone: 'red' },
|
|
||||||
{ label: 'A confirmar', value: 5, tone: 'amber' },
|
|
||||||
{ label: 'Teleconsultas', value: 6, tone: 'blue' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
getWeekDays() {
|
const data = await response.json()
|
||||||
return [
|
const item = Array.isArray(data) ? data[0] : data
|
||||||
{ label: 'Seg', day: '06', active: false, count: 6 },
|
return appointmentMapper.toUi(item)
|
||||||
{ 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 },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/repositories/authRepository.js
Normal file
131
src/repositories/authRepository.js
Normal 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
|
||||||
|
}
|
||||||
@@ -1,4 +1,42 @@
|
|||||||
|
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
import { fetchJsonWithFallback } from './repositoryUtils.js'
|
||||||
|
|
||||||
export const communicationRepository = {
|
export const communicationRepository = {
|
||||||
|
async sendSms({ patientName, phone, content }) {
|
||||||
|
const message = `[MediConnect] Ola ${patientName}, ${content}`
|
||||||
|
const payload = {
|
||||||
|
telefone: normalizePhone(phone),
|
||||||
|
phone: normalizePhone(phone),
|
||||||
|
mensagem: message,
|
||||||
|
message,
|
||||||
|
paciente: patientName,
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchJsonWithFallback(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url: apiEndpoint('/enviar-sms-via-twilio'),
|
||||||
|
options: {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`,
|
||||||
|
options: {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Falha no envio de SMS via Twilio.',
|
||||||
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
getCampaigns() {
|
getCampaigns() {
|
||||||
return [
|
return [
|
||||||
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' },
|
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' },
|
||||||
@@ -31,3 +69,9 @@ export const communicationRepository = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePhone(phone) {
|
||||||
|
const digits = String(phone || '').replace(/\D/g, '')
|
||||||
|
if (!digits) return ''
|
||||||
|
return digits.startsWith('55') ? `+${digits}` : `+55${digits}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import { apiConfig, apiHeaders } from '../config/api.js'
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
|
||||||
export const patientRepository = {
|
export const patientRepository = {
|
||||||
// 1. Listar pacientes
|
// 1. Listar pacientes
|
||||||
async getAll() {
|
async getAll() {
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: apiHeaders })
|
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() })
|
||||||
if (!response.ok) throw new Error('Erro ao buscar pacientes')
|
if (!response.ok) throw new Error('Erro ao buscar pacientes')
|
||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
async getById(patientId) {
|
async getById(patientId) {
|
||||||
const patients = await this.getAll()
|
const patients = await this.getAll()
|
||||||
return patients.find((p) => String(p.id) === String(patientId)) || null
|
const patient = patients.find((p) => String(p.id) === String(patientId)) || null
|
||||||
|
return patient ? mapPatientToDetail(patient) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
async getDirectoryRows() {
|
async getDirectoryRows() {
|
||||||
const patients = await this.getAll()
|
const patients = await this.getAll()
|
||||||
return patients.map((patient) => ({
|
return patients.map(mapPatientToDirectory)
|
||||||
...patient,
|
|
||||||
name: patient.full_name,
|
|
||||||
phone: patient.phone_mobile,
|
|
||||||
detailId: patient.id,
|
|
||||||
insurance: 'Particular',
|
|
||||||
city: 'Recife',
|
|
||||||
state: 'PE',
|
|
||||||
vip: false,
|
|
||||||
lastVisitIso: null,
|
|
||||||
lastVisit: 'Ainda nao houve atendimento',
|
|
||||||
nextVisit: 'Nenhum atendimento agendado',
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 2. Criar paciente (direto)
|
// 2. Criar paciente (direto)
|
||||||
@@ -43,7 +32,7 @@ export const patientRepository = {
|
|||||||
|
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients`, {
|
const response = await fetch(`${apiConfig.restUrl}/patients`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...apiHeaders, Prefer: 'return=representation' },
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,7 +58,7 @@ export const patientRepository = {
|
|||||||
|
|
||||||
const response = await fetch(`${apiConfig.functionsUrl}/create-patient`, {
|
const response = await fetch(`${apiConfig.functionsUrl}/create-patient`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: apiHeaders,
|
headers: getAuthenticatedHeaders(),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,7 +82,7 @@ export const patientRepository = {
|
|||||||
|
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { ...apiHeaders, Prefer: 'return=representation' },
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -105,10 +94,62 @@ export const patientRepository = {
|
|||||||
async remove(patientId) {
|
async remove(patientId) {
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: apiHeaders,
|
headers: getAuthenticatedHeaders(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao deletar paciente')
|
if (!response.ok) throw new Error('Erro ao deletar paciente')
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapPatientToDirectory(patient) {
|
||||||
|
return {
|
||||||
|
...patient,
|
||||||
|
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
||||||
|
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
|
||||||
|
detailId: patient.id,
|
||||||
|
insurance: patient.insurance || patient.convenio || 'Particular',
|
||||||
|
city: patient.city || patient.cidade || 'Recife',
|
||||||
|
state: patient.state || patient.uf || 'PE',
|
||||||
|
vip: Boolean(patient.vip),
|
||||||
|
lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || null,
|
||||||
|
lastVisit: patient.lastVisit || patient.last_visit || 'Ainda nao houve atendimento',
|
||||||
|
nextVisit: patient.nextVisit || patient.next_visit || 'Nenhum atendimento agendado',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPatientToDetail(patient) {
|
||||||
|
const directory = mapPatientToDirectory(patient)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...directory,
|
||||||
|
age: patient.age || patient.idade || calculateAge(patient.birth_date),
|
||||||
|
document: patient.document || patient.cpf || 'CPF nao informado',
|
||||||
|
plan: directory.insurance,
|
||||||
|
condition: patient.condition || patient.condicao || 'Sem condicao principal',
|
||||||
|
status: patient.status || 'Acompanhamento',
|
||||||
|
risk: patient.risk || patient.risco || 'Baixo',
|
||||||
|
email: patient.email || '',
|
||||||
|
address: patient.address || patient.endereco || 'Endereco nao informado',
|
||||||
|
team: patient.team || patient.equipe || [],
|
||||||
|
notes: patient.notes || patient.observacoes || [],
|
||||||
|
exams: patient.exams || patient.exames || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateAge(birthDate) {
|
||||||
|
if (!birthDate) return 0
|
||||||
|
|
||||||
|
const birth = new Date(birthDate)
|
||||||
|
if (Number.isNaN(birth.getTime())) return 0
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
let age = today.getFullYear() - birth.getFullYear()
|
||||||
|
const monthDiff = today.getMonth() - birth.getMonth()
|
||||||
|
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||||
|
age -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return age
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { professionals as mockProfessionals } from '../data/mockData.js'
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
|
||||||
export const professionalRepository = {
|
export const professionalRepository = {
|
||||||
getAll() {
|
async getAll() {
|
||||||
return mockProfessionals
|
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() {
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
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 {
|
return {
|
||||||
email: 'henrique.cardoso@mediconnect.com.br',
|
id: profile?.id || user?.id || user?.user_id || user?.uid || '',
|
||||||
name: 'Dr. Henrique Cardoso',
|
email: profile?.email || user?.email || meta.email || '',
|
||||||
phone: '(81) 98888-0101',
|
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
|
||||||
role: 'Medico Clinico Geral',
|
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
||||||
unit: 'Clinica Boa Vista',
|
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,121 +1,64 @@
|
|||||||
const reportTypes = [
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
'Atestado Medico',
|
import { reportMapper } from '../mappers/reportMapper.js'
|
||||||
'Laudo de Exame',
|
import { getResponseError, normalizeItem } from './repositoryUtils.js'
|
||||||
'Laudo de Imagem',
|
|
||||||
'Relatorio Cirurgico',
|
|
||||||
'Declaracao de Acompanhante',
|
|
||||||
'Encaminhamento',
|
|
||||||
]
|
|
||||||
|
|
||||||
const doctors = ['Dra. Ana Silva', 'Dr. Carlos Mendes', 'Dr. Roberto Nunes']
|
|
||||||
const currentUser = 'Dra. Ana Silva'
|
|
||||||
const adminUsers = ['Dr. Roberto Nunes']
|
|
||||||
|
|
||||||
export const reportRepository = {
|
export const reportRepository = {
|
||||||
getAdminUsers() {
|
async getInitialReports(filters = {}) {
|
||||||
return adminUsers
|
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() {
|
async create(uiData) {
|
||||||
return currentUser
|
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() {
|
async update(id, uiData) {
|
||||||
return doctors
|
const response = await fetch(`${apiConfig.restUrl}/reports?id=eq.${id}`, {
|
||||||
},
|
method: 'PATCH',
|
||||||
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
|
body: JSON.stringify(reportMapper.toApi(uiData)),
|
||||||
|
})
|
||||||
|
|
||||||
getInitialReports() {
|
if (!response.ok) {
|
||||||
return [
|
throw new Error(await getResponseError(response, 'Falha ao atualizar relatorio medico.'))
|
||||||
{
|
}
|
||||||
id: 'report-1',
|
|
||||||
type: 'Atestado Medico',
|
|
||||||
patient: 'Carlos Eduardo Santos',
|
|
||||||
doctor: 'Dra. Ana Silva',
|
|
||||||
date: '27/03/2026',
|
|
||||||
status: 'finalizado',
|
|
||||||
content: 'Atesto que o paciente esteve em consulta medica nesta data, necessitando de repouso por 2 dias.',
|
|
||||||
showDate: true,
|
|
||||||
signDigital: true,
|
|
||||||
versions: [
|
|
||||||
{ version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' },
|
|
||||||
{ version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Ajuste no periodo de repouso' },
|
|
||||||
{ version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Laudo liberado e finalizado' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'report-2',
|
|
||||||
type: 'Laudo de Exame',
|
|
||||||
patient: 'Mariana Costa',
|
|
||||||
doctor: 'Dra. Ana Silva',
|
|
||||||
date: '26/03/2026',
|
|
||||||
status: 'enviado',
|
|
||||||
content: 'Laudo referente ao exame de ecocardiograma. Resultado dentro dos parametros normais.',
|
|
||||||
showDate: true,
|
|
||||||
signDigital: true,
|
|
||||||
versions: [
|
|
||||||
{ version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Laudo criado' },
|
|
||||||
{ version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Adicao da data do exame' },
|
|
||||||
{ version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao incluida' },
|
|
||||||
{ version: 4, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'report-3',
|
|
||||||
type: 'Relatorio Cirurgico',
|
|
||||||
patient: 'Fernanda Lima',
|
|
||||||
doctor: 'Dr. Carlos Mendes',
|
|
||||||
date: '25/03/2026',
|
|
||||||
status: 'rascunho',
|
|
||||||
content: 'Relatorio do procedimento de colecistectomia laparoscopica realizado sob anestesia geral.',
|
|
||||||
showDate: false,
|
|
||||||
signDigital: true,
|
|
||||||
versions: [
|
|
||||||
{ version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Relatorio criado' },
|
|
||||||
{ version: 2, action: 'Rascunho', user: 'Dr. Carlos Mendes', summary: 'Detalhamento do procedimento' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'report-4',
|
|
||||||
type: 'Declaracao de Acompanhante',
|
|
||||||
patient: 'Joao Pedro Alves',
|
|
||||||
doctor: 'Dr. Roberto Nunes',
|
|
||||||
date: '24/03/2026',
|
|
||||||
status: 'finalizado',
|
|
||||||
content: 'Declaro que o acompanhante esteve presente durante todo o periodo de internacao.',
|
|
||||||
showDate: true,
|
|
||||||
signDigital: false,
|
|
||||||
versions: [
|
|
||||||
{ version: 1, action: 'Criado', user: 'Dr. Roberto Nunes', summary: 'Declaracao criada e liberada' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'report-5',
|
|
||||||
type: 'Laudo de Imagem',
|
|
||||||
patient: 'Roberto Campos',
|
|
||||||
doctor: 'Dra. Ana Silva',
|
|
||||||
date: '22/03/2026',
|
|
||||||
status: 'enviado',
|
|
||||||
content: 'Ultrassonografia de abdomen total sem achados patologicos relevantes.',
|
|
||||||
showDate: true,
|
|
||||||
signDigital: true,
|
|
||||||
versions: [
|
|
||||||
{ version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' },
|
|
||||||
{ version: 2, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao adicionada' },
|
|
||||||
{ version: 3, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
getReportTypes() {
|
const data = await response.json()
|
||||||
return reportTypes
|
return reportMapper.toUi(normalizeItem(data))
|
||||||
},
|
|
||||||
|
|
||||||
getTemplates() {
|
|
||||||
return [
|
|
||||||
{ id: 'template-1', name: 'Atestado de Repouso Simples', type: 'Atestado Medico', description: 'Atestado padrao concedendo dias de repouso ao paciente.', content: 'Atesto, para os devidos fins, que o(a) paciente necessita de repouso pelo periodo indicado.' },
|
|
||||||
{ id: 'template-2', name: 'Laudo de Hemograma', type: 'Laudo de Exame', description: 'Resultado de hemograma completo com interpretacao clinica.', content: 'Laudo de hemograma completo com parametros avaliados e interpretacao clinica.' },
|
|
||||||
{ id: 'template-3', name: 'Relatorio Cirurgico', type: 'Relatorio Cirurgico', description: 'Relatorio padronizado para procedimento cirurgico.', content: 'Relatorio do procedimento cirurgico, achados, conduta e evolucao imediata.' },
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/repositories/repositoryUtils.js
Normal file
74
src/repositories/repositoryUtils.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export async function fetchJsonWithFallback(requests, fallbackMessage) {
|
||||||
|
let lastResponse = null
|
||||||
|
let lastError = null
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
let response
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(request.url, request.options)
|
||||||
|
lastResponse = response
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return parseJsonResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldFallback(response)) {
|
||||||
|
throw new Error(await getResponseError(response, fallbackMessage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError && !lastResponse) {
|
||||||
|
throw new Error(lastError.message || fallbackMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(await getResponseError(lastResponse, fallbackMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCollection(data, keys = []) {
|
||||||
|
if (Array.isArray(data)) return data
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Array.isArray(data?.[key])) return data[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeItem(data, keys = []) {
|
||||||
|
if (Array.isArray(data)) return data[0] || null
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (data?.[key]) return data[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return data || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResponseError(response, fallbackMessage) {
|
||||||
|
if (!response) return fallbackMessage
|
||||||
|
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
return error.error_description || error.msg || error.message || error.error || fallbackMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallback(response) {
|
||||||
|
return [404, 405].includes(response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse(response) {
|
||||||
|
if (response.status === 204) return null
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { message: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/utils/agendaDate.js
Normal file
40
src/utils/agendaDate.js
Normal 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
11
test.mjs
Normal 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
11
test2.mjs
Normal 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
14
test3.mjs
Normal 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
13
test4.mjs
Normal 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
11
test5.mjs
Normal 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);
|
||||||
Reference in New Issue
Block a user