Merge pull request 'modified: .env.example' (#8) from módulo-prontuário into main
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
|
# copiar e colar para o seu .env.local
|
||||||
|
|
||||||
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_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
|
||||||
VITE_SUPABASE_ANON_KEY=cole_a_chave_anon_aqui
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ
|
||||||
6
.env.local
Normal file
6
.env.local
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
VITE_SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co
|
||||||
|
VITE_API_BASE_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1
|
||||||
|
VITE_SUPABASE_REST_URL=https://yuanqfswhberkoevtmfr.supabase.co/rest/v1
|
||||||
|
VITE_SUPABASE_FUNCTIONS_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1
|
||||||
|
VITE_SUPABASE_STORAGE_URL=https://yuanqfswhberkoevtmfr.supabase.co/storage/v1
|
||||||
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,7 +10,6 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
|
||||||
projeto-figma
|
projeto-figma
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
45
docs/mock-audit.md
Normal file
45
docs/mock-audit.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Auditoria de Mocks e Integracoes Parciais
|
||||||
|
|
||||||
|
Este documento lista os pontos do sistema que ainda usam dados simulados, fallback local ou integracao parcial. O objetivo e separar comportamento intencional de prototipo de fluxos que ja dependem da API.
|
||||||
|
|
||||||
|
## Painel
|
||||||
|
|
||||||
|
- Origem atual: dados agregados montados na tela.
|
||||||
|
- Risco: indicadores podem divergir da base real.
|
||||||
|
- Acao recomendada: substituir por endpoints de metricas assim que a API disponibilizar indicadores consolidados.
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
- Origem atual: graficos e series estaticas/locais.
|
||||||
|
- Risco: analises gerenciais podem parecer reais sem refletir producao.
|
||||||
|
- Acao recomendada: criar repositorio dedicado para metricas e remover mocks apos validacao dos endpoints.
|
||||||
|
|
||||||
|
## Consultas
|
||||||
|
|
||||||
|
- Origem atual: `careQueue` em `src/data/mockData.js`.
|
||||||
|
- Risco: fila de atendimento nao representa a operacao real.
|
||||||
|
- Acao recomendada: trocar por endpoint de fila/triagem ou derivar de agendamentos com status.
|
||||||
|
|
||||||
|
## Comunicacao
|
||||||
|
|
||||||
|
- Origem atual: templates, mensagens e campanhas iniciais mockados no repositorio do modulo.
|
||||||
|
- Risco: usuario pode confundir historico simulado com mensagens enviadas.
|
||||||
|
- Acao recomendada: manter sinalizador visual ate existir endpoint de envio/listagem real.
|
||||||
|
|
||||||
|
## Prontuario
|
||||||
|
|
||||||
|
- Origem atual: registros locais com fallback para historico mockado quando relatorios reais nao carregam.
|
||||||
|
- Risco: detalhe clinico pode misturar dados reais e simulados.
|
||||||
|
- Acao recomendada: integrar CRUD completo de prontuario e remover fallback em fluxos clinicos.
|
||||||
|
|
||||||
|
## Relatorios
|
||||||
|
|
||||||
|
- Origem atual: templates de conteudo sao locais em `src/data/reportTemplates.js`.
|
||||||
|
- Risco: baixo; templates sao conteudo inicial, nao dados clinicos gravados.
|
||||||
|
- Acao recomendada: manter local se forem padroes do produto ou migrar para configuracao administrativa no futuro.
|
||||||
|
|
||||||
|
## Configuracoes
|
||||||
|
|
||||||
|
- Origem atual: preferencias visuais locais no navegador.
|
||||||
|
- Risco: preferencia nao acompanha o usuario em outro dispositivo.
|
||||||
|
- Acao recomendada: persistir preferencias no perfil quando houver campo/API para isso.
|
||||||
@@ -1,29 +1,38 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import react from 'eslint-plugin-react'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
react.configs.flat['jsx-runtime'],
|
||||||
reactRefresh.configs.vite,
|
reactHooks.configs.flat.recommended,
|
||||||
],
|
reactRefresh.configs.vite,
|
||||||
languageOptions: {
|
],
|
||||||
ecmaVersion: 2020,
|
languageOptions: {
|
||||||
globals: globals.browser,
|
ecmaVersion: 2020,
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
ecmaFeatures: { jsx: true },
|
ecmaFeatures: { jsx: true },
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
plugins: {
|
||||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
react,
|
||||||
},
|
},
|
||||||
},
|
rules: {
|
||||||
])
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'react/jsx-uses-vars': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|||||||
1959
package-lock.json
generated
1959
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -4,11 +4,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"test": "node --test \"tests/*.test.mjs\"",
|
||||||
},
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-text-align": "^3.23.1",
|
"@tiptap/extension-text-align": "^3.23.1",
|
||||||
"@tiptap/extension-underline": "^3.23.1",
|
"@tiptap/extension-underline": "^3.23.1",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
.line-clamp-2 {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
70
src/App.jsx
70
src/App.jsx
@@ -1,6 +1,5 @@
|
|||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
import { Component, lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import './App.css'
|
|
||||||
import { AppShell } from './components/AppShell.jsx'
|
import { AppShell } from './components/AppShell.jsx'
|
||||||
import { canAccess } from './config/permissions.js'
|
import { canAccess } from './config/permissions.js'
|
||||||
import { useAuth } from './hooks/useAuth.js'
|
import { useAuth } from './hooks/useAuth.js'
|
||||||
@@ -78,7 +77,7 @@ function App() {
|
|||||||
|
|
||||||
// Rotas públicas (sem shell)
|
// Rotas públicas (sem shell)
|
||||||
if (!route.withShell) {
|
if (!route.withShell) {
|
||||||
return <RouteSuspense>{route.element}</RouteSuspense>
|
return <RouteSuspense resetKey={location.pathname}>{route.element}</RouteSuspense>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usuário não autenticado
|
// Usuário não autenticado
|
||||||
@@ -103,23 +102,74 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
||||||
<RouteSuspense>{route.element}</RouteSuspense>
|
<RouteSuspense resetKey={location.pathname}>{route.element}</RouteSuspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteSuspense({ children }) {
|
class RouteErrorBoundary extends Component {
|
||||||
|
state = { error: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(previousProps) {
|
||||||
|
if (previousProps.resetKey !== this.props.resetKey && this.state.error) {
|
||||||
|
this.setState({ error: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return <RouteErrorFallback />
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteSuspense({ children, resetKey }) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<RouteFallback />}>
|
<RouteErrorBoundary resetKey={resetKey}>
|
||||||
{children}
|
<Suspense fallback={<RouteFallback />}>
|
||||||
</Suspense>
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
</RouteErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteFallback() {
|
function RouteFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[40vh] items-center justify-center">
|
<div className="flex min-h-[40vh] items-center justify-center px-4">
|
||||||
<p className="text-sm text-[#a3a3a3]">Carregando...</p>
|
<div className="w-full max-w-xl rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-sm">
|
||||||
|
<div className="h-4 w-36 animate-pulse rounded bg-[#404040]" />
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<div className="h-20 animate-pulse rounded-xl bg-[#1a1a1a]" />
|
||||||
|
<div className="h-20 animate-pulse rounded-xl bg-[#1a1a1a]" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-[#a3a3a3]">Carregando modulo...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteErrorFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center px-4">
|
||||||
|
<div className="max-w-xl rounded-2xl border border-red-500/40 bg-[#262626] p-6 text-center shadow-sm">
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Não foi possÃvel carregar esta tela</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
||||||
|
Ocorreu um erro ao abrir o modulo. Recarregue a pagina e tente novamente.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="mt-5 rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Recarregar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
148
src/components/RichTextEditor.jsx
Normal file
148
src/components/RichTextEditor.jsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { EditorContent, useEditor } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
|
import Underline from '@tiptap/extension-underline'
|
||||||
|
|
||||||
|
export function RichTextEditor({ onChange, value }) {
|
||||||
|
const lastSyncedHtmlRef = useRef(value || '')
|
||||||
|
const applyingExternalContentRef = useRef(false)
|
||||||
|
const tiptapEditor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Underline,
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ['heading', 'paragraph'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: value || '',
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'report-rich-surface min-h-[560px] px-4 py-3 text-sm leading-6 text-[#e5e5e5] outline-none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shouldRerenderOnTransaction: false,
|
||||||
|
onUpdate: ({ editor: currentEditor }) => {
|
||||||
|
if (applyingExternalContentRef.current) return
|
||||||
|
|
||||||
|
const nextHtml = currentEditor.getHTML()
|
||||||
|
lastSyncedHtmlRef.current = nextHtml
|
||||||
|
onChange(nextHtml)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tiptapEditor) return
|
||||||
|
|
||||||
|
const nextValue = value || ''
|
||||||
|
if (lastSyncedHtmlRef.current === nextValue) return
|
||||||
|
|
||||||
|
if (tiptapEditor.getHTML() === nextValue) {
|
||||||
|
lastSyncedHtmlRef.current = nextValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyingExternalContentRef.current = true
|
||||||
|
try {
|
||||||
|
tiptapEditor.commands.setContent(nextValue, { emitUpdate: false })
|
||||||
|
} finally {
|
||||||
|
applyingExternalContentRef.current = false
|
||||||
|
}
|
||||||
|
lastSyncedHtmlRef.current = nextValue
|
||||||
|
}, [tiptapEditor, value])
|
||||||
|
|
||||||
|
const blockFormat = tiptapEditor?.isActive('heading', { level: 2 })
|
||||||
|
? 'h2'
|
||||||
|
: tiptapEditor?.isActive('heading', { level: 3 })
|
||||||
|
? 'h3'
|
||||||
|
: 'p'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="report-rich-editor overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
|
||||||
|
<div className="report-rich-toolbar flex flex-wrap items-center gap-1 border-b border-[#404040] bg-[#202020] px-3 py-2">
|
||||||
|
<ToolbarButton disabled={!tiptapEditor?.can().undo()} label="Desfazer" name="undo" onClick={() => tiptapEditor?.chain().focus().undo().run()} />
|
||||||
|
<ToolbarButton disabled={!tiptapEditor?.can().redo()} label="Refazer" name="redo" onClick={() => tiptapEditor?.chain().focus().redo().run()} />
|
||||||
|
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-sm border border-[#404040] bg-[#171717] px-2 text-xs font-semibold text-[#d4d4d4]"
|
||||||
|
onChange={(event) => {
|
||||||
|
const selected = event.target.value
|
||||||
|
|
||||||
|
if (selected === 'h2') {
|
||||||
|
tiptapEditor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected === 'h3') {
|
||||||
|
tiptapEditor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tiptapEditor?.chain().focus().setParagraph().run()
|
||||||
|
}}
|
||||||
|
value={blockFormat}
|
||||||
|
>
|
||||||
|
<option value="p">Padrao</option>
|
||||||
|
<option value="h2">Titulo</option>
|
||||||
|
<option value="h3">Subtitulo</option>
|
||||||
|
</select>
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive('bold')} label="Negrito" name="bold" onClick={() => tiptapEditor?.chain().focus().toggleBold().run()} />
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive('italic')} label="Italico" name="italic" onClick={() => tiptapEditor?.chain().focus().toggleItalic().run()} />
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive('underline')} label="Sublinhado" name="underline" onClick={() => tiptapEditor?.chain().focus().toggleUnderline().run()} />
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive('strike')} label="Tachado" name="strike" onClick={() => tiptapEditor?.chain().focus().toggleStrike().run()} />
|
||||||
|
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive({ textAlign: 'left' })} label="Alinhar a esquerda" name="align-left" onClick={() => tiptapEditor?.chain().focus().setTextAlign('left').run()} />
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive({ textAlign: 'center' })} label="Centralizar" name="align-center" onClick={() => tiptapEditor?.chain().focus().setTextAlign('center').run()} />
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive({ textAlign: 'right' })} label="Alinhar a direita" name="align-right" onClick={() => tiptapEditor?.chain().focus().setTextAlign('right').run()} />
|
||||||
|
<ToolbarButton active={tiptapEditor?.isActive('bulletList')} label="Lista" name="list" onClick={() => tiptapEditor?.chain().focus().toggleBulletList().run()} />
|
||||||
|
</div>
|
||||||
|
<EditorContent editor={tiptapEditor} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({ active = false, disabled = false, label, name, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`grid size-8 place-items-center rounded-sm transition ${
|
||||||
|
active ? 'bg-[#3b82f6]/20 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||||
|
} disabled:cursor-not-allowed disabled:opacity-40`}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RichTextIcon className="size-4" name={name} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RichTextIcon({ className = 'size-4', name }) {
|
||||||
|
const common = {
|
||||||
|
className,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-1" />,
|
||||||
|
redo: <path d="m15 14 5-5-5-5M20 9H10a6 6 0 0 0 0 12h1" />,
|
||||||
|
bold: <path d="M7 5h6a4 4 0 0 1 0 8H7zM7 13h7a4 4 0 0 1 0 8H7z" />,
|
||||||
|
italic: <path d="M19 4h-9M14 20H5M15 4 9 20" />,
|
||||||
|
underline: <path d="M6 4v6a6 6 0 0 0 12 0V4M4 21h16" />,
|
||||||
|
strike: <path d="M5 12h14M16 6a4 4 0 0 0-4-2c-2 0-4 1-4 3 0 4 8 2 8 7 0 2-2 4-5 4-2 0-4-1-5-3" />,
|
||||||
|
'align-left': <path d="M4 6h16M4 12h10M4 18h16" />,
|
||||||
|
'align-center': <path d="M4 6h16M7 12h10M4 18h16" />,
|
||||||
|
'align-right': <path d="M4 6h16M10 12h10M4 18h16" />,
|
||||||
|
list: <path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <svg {...common}>{icons[name] || icons.list}</svg>
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
endOfMonth,
|
endOfMonth,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
endOfWeek,
|
endOfWeek,
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
const toneClasses = {
|
|
||||||
blue: 'bg-sky-50 text-sky-700 border-sky-200',
|
|
||||||
green: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
|
||||||
amber: 'bg-amber-50 text-amber-700 border-amber-200',
|
|
||||||
red: 'bg-rose-50 text-rose-700 border-rose-200',
|
|
||||||
slate: 'bg-slate-100 text-slate-700 border-slate-200',
|
|
||||||
neutral: 'bg-white text-slate-700 border-slate-200',
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonVariants = {
|
const buttonVariants = {
|
||||||
primary:
|
primary:
|
||||||
'border-sky-700 bg-sky-700 text-white hover:bg-sky-800 focus-visible:outline-sky-700',
|
'border-sky-700 bg-sky-700 text-white hover:bg-sky-800 focus-visible:outline-sky-700',
|
||||||
@@ -18,6 +9,13 @@ const buttonVariants = {
|
|||||||
'border-rose-600 bg-rose-600 text-white hover:bg-rose-700 focus-visible:outline-rose-600',
|
'border-rose-600 bg-rose-600 text-white hover:bg-rose-700 focus-visible:outline-rose-600',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const appCardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
export const appInputClass =
|
||||||
|
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||||
|
export const appTextareaClass =
|
||||||
|
'min-h-28 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||||
|
export const appLabelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
@@ -44,16 +42,6 @@ export function Card({ children, className = '' }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Badge({ children, tone = 'neutral', className = '' }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold ${toneClasses[tone] || toneClasses.neutral} ${className}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageHeader({ actions, description, eyebrow, title }) {
|
export function PageHeader({ actions, description, eyebrow, title }) {
|
||||||
return (
|
return (
|
||||||
<header className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
<header className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
@@ -77,129 +65,11 @@ export function PageHeader({ actions, description, eyebrow, title }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({ helper, label, tone = 'slate', value }) {
|
export function DarkField({ children, label }) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-5">
|
<div className="block">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<span className={appLabelClass}>{label}</span>
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-500">{label}</p>
|
|
||||||
<p className="mt-2 text-3xl font-bold text-slate-950">{value}</p>
|
|
||||||
</div>
|
|
||||||
<span className={`h-3 w-3 rounded-sm ${dotTone(tone)}`} aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm text-slate-600">{helper}</p>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyState({ action, description, title }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
|
||||||
<h3 className="text-lg font-semibold text-slate-950">{title}</h3>
|
|
||||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-slate-600">{description}</p>
|
|
||||||
{action ? <div className="mt-5 flex justify-center">{action}</div> : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Field({ children, hint, label }) {
|
|
||||||
return (
|
|
||||||
<label className="grid gap-2 text-sm font-semibold text-slate-700">
|
|
||||||
<span>{label}</span>
|
|
||||||
{children}
|
{children}
|
||||||
{hint ? <span className="text-xs font-normal text-slate-500">{hint}</span> : null}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextInput({ className = '', ...props }) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
className={`min-h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-sky-600 focus:ring-2 focus:ring-sky-100 ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectInput({ children, className = '', ...props }) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
className={`min-h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition focus:border-sky-600 focus:ring-2 focus:ring-sky-100 ${className}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</select>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Textarea({ className = '', ...props }) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={`min-h-28 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-sky-600 focus:ring-2 focus:ring-sky-100 ${className}`}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tabs({ active, items, onChange }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap gap-2 rounded-lg border border-slate-200 bg-white p-1">
|
|
||||||
{items.map((item) => (
|
|
||||||
<button
|
|
||||||
className={`rounded-md px-3 py-2 text-sm font-semibold transition ${
|
|
||||||
active === item.value
|
|
||||||
? 'bg-sky-700 text-white'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-950'
|
|
||||||
}`}
|
|
||||||
key={item.value}
|
|
||||||
onClick={() => onChange(item.value)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ actions, children, onClose, open, title }) {
|
|
||||||
if (!open) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/50 p-4 sm:items-center">
|
|
||||||
<div className="w-full max-w-xl rounded-lg border border-slate-200 bg-white shadow-xl">
|
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
|
|
||||||
<button
|
|
||||||
aria-label="Fechar"
|
|
||||||
className="rounded-md px-2 py-1 text-xl leading-none text-slate-500 hover:bg-slate-100"
|
|
||||||
onClick={onClose}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-5">{children}</div>
|
|
||||||
{actions ? (
|
|
||||||
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-200 px-5 py-4">
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dotTone(tone) {
|
|
||||||
const dots = {
|
|
||||||
blue: 'bg-sky-500',
|
|
||||||
green: 'bg-emerald-500',
|
|
||||||
amber: 'bg-amber-500',
|
|
||||||
red: 'bg-rose-500',
|
|
||||||
slate: 'bg-slate-500',
|
|
||||||
}
|
|
||||||
|
|
||||||
return dots[tone] || dots.slate
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,49 @@
|
|||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberkoevtmfr.supabase.co'
|
const env = import.meta.env ?? {}
|
||||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
const SUPABASE_URL = readEnv('VITE_SUPABASE_URL')
|
||||||
|
const SUPABASE_ANON_KEY = readEnv('VITE_SUPABASE_ANON_KEY')
|
||||||
|
const SUPABASE_FUNCTIONS_URL = readEnv('VITE_SUPABASE_FUNCTIONS_URL')
|
||||||
|
const SUPABASE_REST_URL = readEnv('VITE_SUPABASE_REST_URL')
|
||||||
|
const SUPABASE_STORAGE_URL = readEnv('VITE_SUPABASE_STORAGE_URL')
|
||||||
|
const API_BASE_URL = readEnv('VITE_API_BASE_URL')
|
||||||
|
|
||||||
const AUTH_SESSION_KEY = 'mediconnect.auth.session'
|
const AUTH_SESSION_KEY = 'mediconnect.auth.session'
|
||||||
export const AUTH_SESSION_CHANGED_EVENT = 'mediconnect:auth-session-changed'
|
export const AUTH_SESSION_CHANGED_EVENT = 'mediconnect:auth-session-changed'
|
||||||
|
|
||||||
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`,
|
apiUrl: API_BASE_URL || SUPABASE_FUNCTIONS_URL || joinUrl(SUPABASE_URL, '/functions/v1'),
|
||||||
supabaseUrl: SUPABASE_URL,
|
supabaseUrl: SUPABASE_URL,
|
||||||
restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`,
|
restUrl: SUPABASE_REST_URL || joinUrl(SUPABASE_URL, '/rest/v1'),
|
||||||
functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
functionsUrl: SUPABASE_FUNCTIONS_URL || joinUrl(SUPABASE_URL, '/functions/v1'),
|
||||||
storageUrl: import.meta.env.VITE_SUPABASE_STORAGE_URL || `${SUPABASE_URL}/storage/v1`,
|
storageUrl: SUPABASE_STORAGE_URL || joinUrl(SUPABASE_URL, '/storage/v1'),
|
||||||
anonKey: SUPABASE_ANON_KEY,
|
anonKey: SUPABASE_ANON_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMissingApiConfig() {
|
||||||
|
return [
|
||||||
|
['VITE_SUPABASE_URL', apiConfig.supabaseUrl],
|
||||||
|
['VITE_SUPABASE_ANON_KEY', apiConfig.anonKey],
|
||||||
|
]
|
||||||
|
.filter(([, value]) => !value)
|
||||||
|
.map(([name]) => name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertApiConfig() {
|
||||||
|
const missing = getMissingApiConfig()
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Configuração da API incompleta. Defina ${missing.join(' e ')} no ambiente.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function apiEndpoint(path, baseUrl = apiConfig.apiUrl) {
|
export function apiEndpoint(path, baseUrl = apiConfig.apiUrl) {
|
||||||
|
assertApiConfig()
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error('URL da API não configurada.')
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedBase = baseUrl.replace(/\/+$/, '')
|
const normalizedBase = baseUrl.replace(/\/+$/, '')
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
return `${normalizedBase}${normalizedPath}`
|
return `${normalizedBase}${normalizedPath}`
|
||||||
@@ -50,7 +80,6 @@ export function hasAuthenticatedSession() {
|
|||||||
const session = getAuthSession()
|
const session = getAuthSession()
|
||||||
if (!session?.access_token) return false
|
if (!session?.access_token) return false
|
||||||
|
|
||||||
// Validate expiration locally if available
|
|
||||||
if (session.expires_at && session.expires_at * 1000 <= Date.now()) {
|
if (session.expires_at && session.expires_at * 1000 <= Date.now()) {
|
||||||
clearAuthSession()
|
clearAuthSession()
|
||||||
return false
|
return false
|
||||||
@@ -60,6 +89,8 @@ export function hasAuthenticatedSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAnonHeaders(extraHeaders = {}) {
|
export function getAnonHeaders(extraHeaders = {}) {
|
||||||
|
assertApiConfig()
|
||||||
|
|
||||||
return cleanHeaders({
|
return cleanHeaders({
|
||||||
apikey: apiConfig.anonKey,
|
apikey: apiConfig.anonKey,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -68,6 +99,8 @@ export function getAnonHeaders(extraHeaders = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthenticatedHeaders(extraHeaders = {}) {
|
export function getAuthenticatedHeaders(extraHeaders = {}) {
|
||||||
|
assertApiConfig()
|
||||||
|
|
||||||
const session = getAuthSession()
|
const session = getAuthSession()
|
||||||
const accessToken = session?.access_token
|
const accessToken = session?.access_token
|
||||||
|
|
||||||
@@ -90,5 +123,22 @@ function cleanHeaders(headers) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notifyAuthSessionChanged() {
|
function notifyAuthSessionChanged() {
|
||||||
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnv(name) {
|
||||||
|
if (env[name]) return env[name]
|
||||||
|
|
||||||
|
if (typeof process !== 'undefined' && process.env?.[name]) {
|
||||||
|
return process.env[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrl(baseUrl, path) {
|
||||||
|
if (!baseUrl) return ''
|
||||||
|
return `${baseUrl.replace(/\/+$/, '')}${path}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,170 +1,3 @@
|
|||||||
export const todayLabel = '07 abr 2026'
|
|
||||||
|
|
||||||
export const dashboardMetrics = [
|
|
||||||
{
|
|
||||||
label: 'Consultas hoje',
|
|
||||||
value: '18',
|
|
||||||
helper: '6 por teleconsulta',
|
|
||||||
tone: 'blue',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Pacientes em acompanhamento',
|
|
||||||
value: '124',
|
|
||||||
helper: '12 com prioridade alta',
|
|
||||||
tone: 'green',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Mensagens novas',
|
|
||||||
value: '9',
|
|
||||||
helper: '4 aguardando resposta',
|
|
||||||
tone: 'amber',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Documentos pendentes',
|
|
||||||
value: '7',
|
|
||||||
helper: 'Exames e receitas',
|
|
||||||
tone: 'slate',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const patients = [
|
|
||||||
{
|
|
||||||
id: 'ana-souza',
|
|
||||||
name: 'Ana Souza',
|
|
||||||
age: 42,
|
|
||||||
document: 'CPF 284.019.430-10',
|
|
||||||
plan: 'Unimed',
|
|
||||||
condition: 'Diabetes tipo 2',
|
|
||||||
status: 'Acompanhamento',
|
|
||||||
risk: 'Moderado',
|
|
||||||
phone: '(81) 98812-2301',
|
|
||||||
email: 'ana.souza@email.com',
|
|
||||||
address: 'Rua das Flores, 220',
|
|
||||||
lastVisit: '31 mar 2026',
|
|
||||||
nextVisit: '08 abr 2026, 10:00',
|
|
||||||
team: ['Dra. Marina Lopes', 'Enf. Paulo Reis'],
|
|
||||||
notes: [
|
|
||||||
'Paciente relatou melhora na rotina alimentar.',
|
|
||||||
'Solicitar retorno com glicemia de jejum atualizada.',
|
|
||||||
],
|
|
||||||
exams: ['Hemoglobina glicada', 'Glicemia de jejum', 'Perfil lipidico'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bruno-lima',
|
|
||||||
name: 'Bruno Lima',
|
|
||||||
age: 35,
|
|
||||||
document: 'CPF 031.762.880-04',
|
|
||||||
plan: 'SulAmerica',
|
|
||||||
condition: 'Hipertensao',
|
|
||||||
status: 'Retorno',
|
|
||||||
risk: 'Alto',
|
|
||||||
phone: '(81) 99744-9011',
|
|
||||||
email: 'bruno.lima@email.com',
|
|
||||||
address: 'Av. Norte, 1180',
|
|
||||||
lastVisit: '02 abr 2026',
|
|
||||||
nextVisit: '07 abr 2026, 14:30',
|
|
||||||
team: ['Dr. Rafael Nunes', 'Nutri. Clara Meireles'],
|
|
||||||
notes: [
|
|
||||||
'Pressão ainda oscilando no período da tarde.',
|
|
||||||
'Conferir adesão ao medicamento e orientar diário de pressão.',
|
|
||||||
],
|
|
||||||
exams: ['MAPA 24h', 'Eletrocardiograma', 'Creatinina'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'carla-mendes',
|
|
||||||
name: 'Carla Mendes',
|
|
||||||
age: 29,
|
|
||||||
document: 'CPF 740.991.112-80',
|
|
||||||
plan: 'Particular',
|
|
||||||
condition: 'Pre-natal',
|
|
||||||
status: 'Primeira consulta',
|
|
||||||
risk: 'Baixo',
|
|
||||||
phone: '(81) 98120-4477',
|
|
||||||
email: 'carla.mendes@email.com',
|
|
||||||
address: 'Rua Aurora, 90',
|
|
||||||
lastVisit: 'Sem historico',
|
|
||||||
nextVisit: '09 abr 2026, 08:30',
|
|
||||||
team: ['Dra. Marina Lopes'],
|
|
||||||
notes: [
|
|
||||||
'Primeiro atendimento cadastrado pela recepcao.',
|
|
||||||
'Confirmar exames iniciais e historico familiar.',
|
|
||||||
],
|
|
||||||
exams: ['Beta HCG', 'Ultrassom obstetrico', 'Hemograma'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'diego-alves',
|
|
||||||
name: 'Diego Alves',
|
|
||||||
age: 51,
|
|
||||||
document: 'CPF 607.113.904-18',
|
|
||||||
plan: 'Bradesco Saude',
|
|
||||||
condition: 'Pos-operatorio',
|
|
||||||
status: 'Monitoramento',
|
|
||||||
risk: 'Moderado',
|
|
||||||
phone: '(81) 98772-5330',
|
|
||||||
email: 'diego.alves@email.com',
|
|
||||||
address: 'Rua Imperial, 410',
|
|
||||||
lastVisit: '05 abr 2026',
|
|
||||||
nextVisit: '10 abr 2026, 16:00',
|
|
||||||
team: ['Dr. Rafael Nunes', 'Fisio. Jonas Pedro'],
|
|
||||||
notes: [
|
|
||||||
'Evolucao dentro do esperado no curativo.',
|
|
||||||
'Manter avaliacao de dor e mobilidade nos proximos contatos.',
|
|
||||||
],
|
|
||||||
exams: ['Raio X controle', 'Hemograma', 'PCR'],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const appointments = [
|
|
||||||
{
|
|
||||||
id: 'apt-001',
|
|
||||||
date: '2026-04-07',
|
|
||||||
time: '08:00',
|
|
||||||
patient: 'Carla Mendes',
|
|
||||||
patientId: 'carla-mendes',
|
|
||||||
professional: 'Dra. Marina Lopes',
|
|
||||||
type: 'Consulta inicial',
|
|
||||||
room: 'Sala 01',
|
|
||||||
status: 'Confirmada',
|
|
||||||
mode: 'Presencial',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apt-002',
|
|
||||||
date: '2026-04-07',
|
|
||||||
time: '09:30',
|
|
||||||
patient: 'Ana Souza',
|
|
||||||
patientId: 'ana-souza',
|
|
||||||
professional: 'Dra. Marina Lopes',
|
|
||||||
type: 'Retorno',
|
|
||||||
room: 'Sala virtual 1',
|
|
||||||
status: 'Em triagem',
|
|
||||||
mode: 'Teleconsulta',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apt-003',
|
|
||||||
date: '2026-04-07',
|
|
||||||
time: '11:00',
|
|
||||||
patient: 'Diego Alves',
|
|
||||||
patientId: 'diego-alves',
|
|
||||||
professional: 'Dr. Rafael Nunes',
|
|
||||||
type: 'Acompanhamento',
|
|
||||||
room: 'Sala 03',
|
|
||||||
status: 'Aguardando',
|
|
||||||
mode: 'Presencial',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apt-004',
|
|
||||||
date: '2026-04-07',
|
|
||||||
time: '14:30',
|
|
||||||
patient: 'Bruno Lima',
|
|
||||||
patientId: 'bruno-lima',
|
|
||||||
professional: 'Dr. Rafael Nunes',
|
|
||||||
type: 'Retorno',
|
|
||||||
room: 'Sala virtual 2',
|
|
||||||
status: 'Confirmada',
|
|
||||||
mode: 'Teleconsulta',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const careQueue = [
|
export const careQueue = [
|
||||||
{
|
{
|
||||||
id: 'queue-001',
|
id: 'queue-001',
|
||||||
@@ -179,10 +12,10 @@ export const careQueue = [
|
|||||||
id: 'queue-002',
|
id: 'queue-002',
|
||||||
patient: 'Bruno Lima',
|
patient: 'Bruno Lima',
|
||||||
patientId: 'bruno-lima',
|
patientId: 'bruno-lima',
|
||||||
status: 'Aguardando médico',
|
status: 'Aguardando medico',
|
||||||
priority: 'Alta',
|
priority: 'Alta',
|
||||||
wait: '25 min',
|
wait: '25 min',
|
||||||
reason: 'Pressão elevada',
|
reason: 'Pressao elevada',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'queue-003',
|
id: 'queue-003',
|
||||||
@@ -194,135 +27,3 @@ export const careQueue = [
|
|||||||
reason: 'Consulta inicial',
|
reason: 'Consulta inicial',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const conversations = [
|
|
||||||
{
|
|
||||||
id: 'conv-ana',
|
|
||||||
patient: 'Ana Souza',
|
|
||||||
patientId: 'ana-souza',
|
|
||||||
subject: 'Duvida sobre exame',
|
|
||||||
unread: 2,
|
|
||||||
lastMessage: 'Enviei o resultado pelo portal.',
|
|
||||||
status: 'Aguardando equipe',
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
from: 'patient',
|
|
||||||
body: 'Bom dia, consegui enviar a hemoglobina glicada pelo app?',
|
|
||||||
time: '08:12',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: 'team',
|
|
||||||
body: 'Sim, Ana. Recebemos o arquivo e a Dra. Marina vai revisar antes da consulta.',
|
|
||||||
time: '08:20',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: 'patient',
|
|
||||||
body: 'Obrigada. Enviei o resultado pelo portal.',
|
|
||||||
time: '08:24',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'conv-bruno',
|
|
||||||
patient: 'Bruno Lima',
|
|
||||||
patientId: 'bruno-lima',
|
|
||||||
subject: 'Pressão no fim do dia',
|
|
||||||
unread: 1,
|
|
||||||
lastMessage: 'Hoje marcou 15 por 9 novamente.',
|
|
||||||
status: 'Prioridade alta',
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
from: 'patient',
|
|
||||||
body: 'Hoje marcou 15 por 9 novamente.',
|
|
||||||
time: '13:05',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: 'team',
|
|
||||||
body: 'Bruno, vamos acompanhar no retorno de hoje. Traga as medidas da semana.',
|
|
||||||
time: '13:12',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'conv-carla',
|
|
||||||
patient: 'Carla Mendes',
|
|
||||||
patientId: 'carla-mendes',
|
|
||||||
subject: 'Confirmação de horario',
|
|
||||||
unread: 0,
|
|
||||||
lastMessage: 'Confirmado para quinta as 08:30.',
|
|
||||||
status: 'Respondida',
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
from: 'team',
|
|
||||||
body: 'Carla, sua consulta ficou confirmada para quinta as 08:30.',
|
|
||||||
time: '17:42',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const professionals = [
|
|
||||||
{
|
|
||||||
id: 'marina-lopes',
|
|
||||||
name: 'Dra. Marina Lopes',
|
|
||||||
role: 'Clínica geral',
|
|
||||||
schedule: 'Seg a sex, 08:00-16:00',
|
|
||||||
status: 'Disponivel',
|
|
||||||
nextSlot: 'Hoje, 15:30',
|
|
||||||
patients: 48,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rafael-nunes',
|
|
||||||
name: 'Dr. Rafael Nunes',
|
|
||||||
role: 'Cardiologista',
|
|
||||||
schedule: 'Ter e qui, 09:00-18:00',
|
|
||||||
status: 'Em atendimento',
|
|
||||||
nextSlot: 'Hoje, 17:00',
|
|
||||||
patients: 36,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'clara-meireles',
|
|
||||||
name: 'Nutri. Clara Meireles',
|
|
||||||
role: 'Nutricionista',
|
|
||||||
schedule: 'Seg, qua e sex, 10:00-15:00',
|
|
||||||
status: 'Disponivel',
|
|
||||||
nextSlot: 'Amanha, 10:30',
|
|
||||||
patients: 21,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'paulo-reis',
|
|
||||||
name: 'Enf. Paulo Reis',
|
|
||||||
role: 'Enfermagem',
|
|
||||||
schedule: 'Seg a sex, 07:00-13:00',
|
|
||||||
status: 'Triagem',
|
|
||||||
nextSlot: 'Hoje, 12:10',
|
|
||||||
patients: 64,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const activityFeed = [
|
|
||||||
{
|
|
||||||
id: 'feed-001',
|
|
||||||
title: 'Receita enviada',
|
|
||||||
detail: 'Dra. Marina enviou orientacao para Ana Souza.',
|
|
||||||
time: '09:10',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'feed-002',
|
|
||||||
title: 'Triagem aberta',
|
|
||||||
detail: 'Bruno Lima entrou na fila de atendimento.',
|
|
||||||
time: '09:45',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'feed-003',
|
|
||||||
title: 'Documento pendente',
|
|
||||||
detail: 'Exame de Diego Alves aguarda revisao.',
|
|
||||||
time: '10:05',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const reminders = [
|
|
||||||
'Confirmar retornos de alto risco ate 16:00.',
|
|
||||||
'Revisar documentos enviados pelos pacientes.',
|
|
||||||
'Atualizar fila de teleconsultas antes do plantao.',
|
|
||||||
]
|
|
||||||
|
|||||||
82
src/data/reportTemplates.js
Normal file
82
src/data/reportTemplates.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export const reportTemplates = [
|
||||||
|
{
|
||||||
|
id: 'consulta-medica',
|
||||||
|
category: 'Relatorios',
|
||||||
|
title: 'Relatorio de Consulta Medica',
|
||||||
|
description: 'Resumo clinico com queixa, exame fisico, hipotese diagnostica e conduta.',
|
||||||
|
popular: true,
|
||||||
|
tags: ['consulta', 'clinico', 'conduta'],
|
||||||
|
exam: 'Consulta medica',
|
||||||
|
cidCode: 'Z00.0',
|
||||||
|
diagnosis: 'Paciente avaliado(a) em consulta medica, com hipotese diagnostica em investigacao conforme quadro clinico.',
|
||||||
|
conclusion: 'Paciente orientado(a) quanto a conduta proposta, sinais de alerta e necessidade de seguimento.',
|
||||||
|
contentHtml:
|
||||||
|
'<h2>Relatorio de Consulta Medica</h2><p><strong>Queixa principal:</strong> </p><p><strong>Historia clinica:</strong> </p><p><strong>Exame fisico:</strong> </p><p><strong>Hipoteses diagnosticas:</strong> </p><p><strong>Conduta:</strong> </p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'evolucao-clinica',
|
||||||
|
category: 'Relatorios',
|
||||||
|
title: 'Evolucao Clinica',
|
||||||
|
description: 'Registro de evolucao diaria para acompanhamento de internacao.',
|
||||||
|
tags: ['internacao', 'evolucao', 'diario'],
|
||||||
|
exam: 'Evolucao clinica',
|
||||||
|
cidCode: 'Z51.9',
|
||||||
|
diagnosis: 'Paciente em acompanhamento clinico durante internacao, com evolucao registrada em prontuario.',
|
||||||
|
conclusion: 'Manter acompanhamento multiprofissional e reavaliar conduta conforme evolucao.',
|
||||||
|
contentHtml:
|
||||||
|
'<h2>Evolucao Clinica</h2><p><strong>Data e hora:</strong> </p><p><strong>Estado geral:</strong> </p><p><strong>Sinais vitais:</strong> </p><p><strong>Evolucao:</strong> </p><p><strong>Conduta do dia:</strong> </p><p><strong>Profissional:</strong> </p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hemograma',
|
||||||
|
category: 'Laudos',
|
||||||
|
title: 'Laudo de Hemograma',
|
||||||
|
description: 'Interpretacao clinica de hemograma com correlacao diagnostica.',
|
||||||
|
tags: ['laboratorial', 'sangue', 'hemograma'],
|
||||||
|
exam: 'Hemograma completo',
|
||||||
|
cidCode: 'Z01.7',
|
||||||
|
diagnosis: 'Exame laboratorial avaliado em conjunto com quadro clinico e exames complementares.',
|
||||||
|
conclusion: 'Resultado analisado e correlacionado com a hipotese diagnostica descrita.',
|
||||||
|
contentHtml:
|
||||||
|
'<h2>Laudo de Hemograma</h2><p><strong>Material:</strong> Sangue periferico.</p><p><strong>Achados principais:</strong> </p><p><strong>Interpretacao:</strong> </p><p><strong>Conclusao:</strong> </p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'imagem',
|
||||||
|
category: 'Laudos',
|
||||||
|
title: 'Laudo de Imagem',
|
||||||
|
description: 'Modelo para exames de imagem com descricao tecnica e impressao diagnostica.',
|
||||||
|
popular: true,
|
||||||
|
tags: ['imagem', 'radiologia', 'exame'],
|
||||||
|
exam: 'Exame de imagem',
|
||||||
|
cidCode: 'Z01.6',
|
||||||
|
diagnosis: 'Achados de imagem descritos conforme exame realizado e indicacao clinica.',
|
||||||
|
conclusion: 'Impressao diagnostica registrada conforme achados do exame.',
|
||||||
|
contentHtml:
|
||||||
|
'<h2>Laudo de Imagem</h2><p><strong>Tecnica:</strong> </p><p><strong>Achados:</strong> </p><p><strong>Impressao diagnostica:</strong> </p><p><strong>Recomendacao:</strong> </p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pre-operatorio',
|
||||||
|
category: 'Relatorios',
|
||||||
|
title: 'Avaliacao Pre-operatoria',
|
||||||
|
description: 'Avaliacao clinica para estratificacao de risco e liberacao cirurgica.',
|
||||||
|
tags: ['pre-op', 'cirurgia', 'risco'],
|
||||||
|
exam: 'Avaliacao pre-operatoria',
|
||||||
|
cidCode: 'Z01.8',
|
||||||
|
diagnosis: 'Paciente em avaliacao pre-operatoria, com risco definido conforme dados clinicos disponiveis.',
|
||||||
|
conclusion: 'Conduta pre-operatoria orientada conforme avaliacao clinica e exames apresentados.',
|
||||||
|
contentHtml:
|
||||||
|
'<h2>Avaliacao Pre-operatoria</h2><p><strong>Procedimento proposto:</strong> </p><p><strong>Comorbidades:</strong> </p><p><strong>Medicamentos em uso:</strong> </p><p><strong>Estratificacao de risco:</strong> </p><p><strong>Orientacoes:</strong> </p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'encaminhamento',
|
||||||
|
category: 'Encaminhamentos',
|
||||||
|
title: 'Encaminhamento Especializado',
|
||||||
|
description: 'Encaminhamento com justificativa clinica e resumo do caso.',
|
||||||
|
tags: ['encaminhamento', 'especialista', 'conduta'],
|
||||||
|
exam: 'Encaminhamento medico',
|
||||||
|
cidCode: 'Z75.8',
|
||||||
|
diagnosis: 'Paciente encaminhado(a) para avaliacao especializada por necessidade clinica descrita.',
|
||||||
|
conclusion: 'Solicitada avaliacao especializada e continuidade do cuidado compartilhado.',
|
||||||
|
contentHtml:
|
||||||
|
'<h2>Encaminhamento Especializado</h2><p><strong>Especialidade solicitada:</strong> </p><p><strong>Resumo clinico:</strong> </p><p><strong>Motivo do encaminhamento:</strong> </p><p><strong>Exames anexos:</strong> </p>',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -355,6 +355,8 @@ export function useAgenda() {
|
|||||||
notes: form.notes,
|
notes: form.notes,
|
||||||
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
||||||
professionalId: targetProfessionalId,
|
professionalId: targetProfessionalId,
|
||||||
|
createdBy: editingAppointment?.createdBy || viewerProfile?.id || '',
|
||||||
|
createdByName: editingAppointment?.createdByName || viewerProfile?.name || viewerProfile?.email || '',
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,6 +423,8 @@ function enrichAppointment(appointment, payload, patients, professionals) {
|
|||||||
status: payload.status,
|
status: payload.status,
|
||||||
notes: payload.notes,
|
notes: payload.notes,
|
||||||
room: payload.room,
|
room: payload.room,
|
||||||
|
createdBy: appointment.createdBy || payload.createdBy,
|
||||||
|
createdByName: appointment.createdByName || payload.createdByName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ export const appointmentMapper = {
|
|||||||
status: mappedStatus,
|
status: mappedStatus,
|
||||||
notes: apiData.notes || apiData.observations || apiData.observacoes || apiData.observacao || apiData.description || '',
|
notes: apiData.notes || apiData.observations || apiData.observacoes || apiData.observacao || apiData.description || '',
|
||||||
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
|
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
|
||||||
|
createdBy: apiData.createdBy || apiData.created_by || '',
|
||||||
|
createdByName:
|
||||||
|
apiData.createdByName ||
|
||||||
|
apiData.created_by_name ||
|
||||||
|
apiData.created_by_profile?.full_name ||
|
||||||
|
apiData.created_by_profile?.name ||
|
||||||
|
apiData.created_by_profile?.email ||
|
||||||
|
'',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -85,6 +93,7 @@ export const appointmentMapper = {
|
|||||||
notes: emptyToUndefined(uiData.notes),
|
notes: emptyToUndefined(uiData.notes),
|
||||||
observations: emptyToUndefined(uiData.notes),
|
observations: emptyToUndefined(uiData.notes),
|
||||||
duration_minutes: 30, // Padrao
|
duration_minutes: 30, // Padrao
|
||||||
|
created_by: emptyToUndefined(uiData.createdBy),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +107,7 @@ export const appointmentMapper = {
|
|||||||
status: uiData.status || 'Confirmada',
|
status: uiData.status || 'Confirmada',
|
||||||
room: uiData.room,
|
room: uiData.room,
|
||||||
notes: uiData.notes,
|
notes: uiData.notes,
|
||||||
|
created_by: uiData.createdBy,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useState } from 'react'
|
|||||||
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
||||||
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
||||||
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||||
|
import { StethoscopeIcon } from '../components/Brand.jsx'
|
||||||
import { useAgenda } from '../hooks/useAgenda.js'
|
import { useAgenda } from '../hooks/useAgenda.js'
|
||||||
import { formatLocalDateInput, parseLocalDate } from '../utils/agendaDate.js'
|
import { formatLocalDateInput, parseLocalDate } from '../utils/agendaDate.js'
|
||||||
|
|
||||||
@@ -332,16 +333,20 @@ export function AgendaPage() {
|
|||||||
type="search"
|
type="search"
|
||||||
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
||||||
/>
|
/>
|
||||||
<SearchResults
|
{modalPatientSearch && !form.patientId ? (
|
||||||
emptyText="Nenhum paciente encontrado."
|
<SearchResults
|
||||||
getLabel={getPatientLabel}
|
emptyText="Nenhum paciente encontrado."
|
||||||
items={filteredPatients.slice(0, 5)}
|
getLabel={getPatientLabel}
|
||||||
onSelect={(patient) => {
|
items={filteredPatients.slice(0, 5)}
|
||||||
updateForm('patientId', patient.id)
|
onSelect={(patient) => {
|
||||||
setModalPatientSearch(getPatientLabel(patient))
|
updateForm('patientId', patient.id)
|
||||||
}}
|
setModalPatientSearch(getPatientLabel(patient))
|
||||||
selectedId={form.patientId}
|
}}
|
||||||
/>
|
selectedId={form.patientId}
|
||||||
|
/>
|
||||||
|
) : selectedPatient ? (
|
||||||
|
<SelectedHint label={getPatientLabel(selectedPatient)} />
|
||||||
|
) : null}
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Profissional">
|
<DarkField label="Profissional">
|
||||||
@@ -364,17 +369,21 @@ export function AgendaPage() {
|
|||||||
type="search"
|
type="search"
|
||||||
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
||||||
/>
|
/>
|
||||||
<SearchResults
|
{modalDoctorSearch && !form.professionalId ? (
|
||||||
emptyText="Nenhum médico encontrado."
|
<SearchResults
|
||||||
getDescription={(professional) => professional.unit || professional.email}
|
emptyText="Nenhum médico encontrado."
|
||||||
getLabel={(professional) => professional.name}
|
getDescription={(professional) => professional.unit || professional.email}
|
||||||
items={filteredProfessionals.slice(0, 5)}
|
getLabel={(professional) => professional.name}
|
||||||
onSelect={(professional) => {
|
items={filteredProfessionals.slice(0, 5)}
|
||||||
updateForm('professionalId', professional.id)
|
onSelect={(professional) => {
|
||||||
setModalDoctorSearch(professional.name)
|
updateForm('professionalId', professional.id)
|
||||||
}}
|
setModalDoctorSearch(professional.name)
|
||||||
selectedId={form.professionalId}
|
}}
|
||||||
/>
|
selectedId={form.professionalId}
|
||||||
|
/>
|
||||||
|
) : selectedProfessional ? (
|
||||||
|
<SelectedHint label={selectedProfessional.name} />
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DarkField>
|
</DarkField>
|
||||||
@@ -481,6 +490,7 @@ export function AgendaPage() {
|
|||||||
Agendamento de {selectedPatient ? getPatientLabel(selectedPatient) : 'paciente não informado'} às {form.time}.
|
Agendamento de {selectedPatient ? getPatientLabel(selectedPatient) : 'paciente não informado'} às {form.time}.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1">Status atual: {form.status}</p>
|
<p className="mt-1">Status atual: {form.status}</p>
|
||||||
|
<p className="mt-1">Criado por: {editingAppointment.createdByName || editingAppointment.createdBy || 'Usuário não informado'}</p>
|
||||||
{form.notes ? <p className="mt-1">Observações: {form.notes}</p> : null}
|
{form.notes ? <p className="mt-1">Observações: {form.notes}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -530,9 +540,14 @@ function DarkModal({ children, onClose, open, title }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
||||||
<div className="w-full max-w-4xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
|
<div className="flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl">
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-[#404040] px-5 py-4">
|
<div className="flex items-center justify-between gap-4 border-b border-[#404040] px-5 py-4">
|
||||||
<h2 className="text-lg font-bold text-[#e5e5e5]">{title}</h2>
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="grid size-9 place-items-center rounded-sm bg-[#3b82f6] text-white">
|
||||||
|
<StethoscopeIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">{title}</h2>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label="Fechar"
|
aria-label="Fechar"
|
||||||
className="grid size-8 place-items-center rounded-sm text-xl leading-none text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
|
className="grid size-8 place-items-center rounded-sm text-xl leading-none text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
|
||||||
@@ -542,12 +557,20 @@ function DarkModal({ children, onClose, open, title }) {
|
|||||||
x
|
x
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">{children}</div>
|
<div className="min-h-0 overflow-y-auto p-5">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SelectedHint({ label }) {
|
||||||
|
return (
|
||||||
|
<span className="rounded-md border border-[#404040] bg-[#1f1f1f] px-3 py-2 text-xs font-semibold text-[#a3a3a3]">
|
||||||
|
Selecionado: {label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SearchResults({ emptyText, getDescription, getLabel, items, onSelect, selectedId }) {
|
function SearchResults({ emptyText, getDescription, getLabel, items, onSelect, selectedId }) {
|
||||||
return (
|
return (
|
||||||
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
|
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
|
import { DarkField, appCardClass as cardClass, appInputClass as inputClass, appTextareaClass as textareaClass } from '../components/ui.jsx'
|
||||||
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
||||||
import { patientRepository } from '../repositories/patientRepository.js'
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
import { reportRepository } from '../repositories/reportRepository.js'
|
import { reportRepository } from '../repositories/reportRepository.js'
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
|
||||||
const textareaClass =
|
|
||||||
'min-h-28 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
|
||||||
const labelClass = 'mb-1 block text-xs font-medium text-[#e5e5e5]'
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
|
||||||
|
|
||||||
const emptyRecord = {
|
const emptyRecord = {
|
||||||
patientId: '',
|
patientId: '',
|
||||||
patient: '',
|
patient: '',
|
||||||
@@ -426,19 +420,28 @@ function RecordEditorPage({ navigate, onSave, patients, record, recordTypes }) {
|
|||||||
<DarkField label="Paciente *">
|
<DarkField label="Paciente *">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
list="medical-record-patients"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setPatientSearch(event.target.value)
|
const value = event.target.value
|
||||||
setFormData((currentData) => ({ ...currentData, patientId: '', patient: event.target.value }))
|
const matchedPatient = patients.find((patient) => normalizeSearch(getPatientName(patient)) === normalizeSearch(value))
|
||||||
|
setPatientSearch(value)
|
||||||
|
|
||||||
|
if (matchedPatient) {
|
||||||
|
selectPatient(matchedPatient)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData((currentData) => ({ ...currentData, patientId: '', patient: value }))
|
||||||
}}
|
}}
|
||||||
placeholder="Buscar paciente..."
|
placeholder="Buscar paciente..."
|
||||||
type="search"
|
type="search"
|
||||||
value={patientSearch}
|
value={patientSearch}
|
||||||
/>
|
/>
|
||||||
<PatientPickList
|
<datalist id="medical-record-patients">
|
||||||
items={filteredPatients}
|
{filteredPatients.map((patient) => (
|
||||||
onSelect={selectPatient}
|
<option key={patient.id || getPatientName(patient)} value={getPatientName(patient)} />
|
||||||
selectedId={formData.patientId}
|
))}
|
||||||
/>
|
</datalist>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField label="Status *">
|
<DarkField label="Status *">
|
||||||
<select className={inputClass} name="status" onChange={updateField} value={formData.status}>
|
<select className={inputClass} name="status" onChange={updateField} value={formData.status}>
|
||||||
@@ -547,33 +550,6 @@ function RecordEditorPage({ navigate, onSave, patients, record, recordTypes }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PatientPickList({ items, onSelect, selectedId }) {
|
|
||||||
return (
|
|
||||||
<div className="mt-2 max-h-44 overflow-y-auto rounded-lg border border-[#404040] bg-[#1a1a1a]">
|
|
||||||
{items.length ? (
|
|
||||||
items.map((patient) => {
|
|
||||||
const selected = String(patient.id || '') === String(selectedId || '')
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`block w-full px-3 py-2 text-left text-sm transition ${
|
|
||||||
selected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#2a2a2a] hover:text-[#e5e5e5]'
|
|
||||||
}`}
|
|
||||||
key={patient.id || getPatientName(patient)}
|
|
||||||
onClick={() => onSelect(patient)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="block font-semibold">{getPatientName(patient)}</span>
|
|
||||||
<span className="mt-0.5 block text-xs text-[#737373]">{patient.cpf || patient.document || patient.email || 'Sem documento'}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<p className="px-3 py-2 text-xs text-[#737373]">Nenhum paciente encontrado.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PatientReportHistory({ fallbackReports = [], patientId, patientName }) {
|
function PatientReportHistory({ fallbackReports = [], patientId, patientName }) {
|
||||||
const [reportState, setReportState] = useState({ patientId: '', reports: [] })
|
const [reportState, setReportState] = useState({ patientId: '', reports: [] })
|
||||||
const reports = patientId && reportState.patientId === patientId ? reportState.reports : fallbackReports
|
const reports = patientId && reportState.patientId === patientId ? reportState.reports : fallbackReports
|
||||||
@@ -689,15 +665,6 @@ function IconButton({ label, name, onClick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DarkField({ children, label }) {
|
|
||||||
return (
|
|
||||||
<div className="block">
|
|
||||||
<span className={labelClass}>{label}</span>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RecordNotFound({ navigate }) {
|
function RecordNotFound({ navigate }) {
|
||||||
return (
|
return (
|
||||||
<div className={`${cardClass} mx-auto max-w-2xl p-8 text-center`}>
|
<div className={`${cardClass} mx-auto max-w-2xl p-8 text-center`}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { normalizeRole } from '../config/permissions.js'
|
import { normalizeRole } from '../config/permissions.js'
|
||||||
|
import { StethoscopeIcon } from '../components/Brand.jsx'
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||||
import { communicationRepository } from '../repositories/communicationRepository.js'
|
import { communicationRepository } from '../repositories/communicationRepository.js'
|
||||||
@@ -588,8 +589,8 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalFrame onClose={onClose} title="Nova Mensagem">
|
<ModalFrame branded onClose={onClose} title="Nova Mensagem">
|
||||||
<form className="space-y-4" onSubmit={onSubmit}>
|
<form className="space-y-5" onSubmit={onSubmit}>
|
||||||
<DarkField label="Paciente">
|
<DarkField label="Paciente">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
@@ -671,7 +672,7 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
|||||||
|
|
||||||
<DarkField label="Mensagem">
|
<DarkField label="Mensagem">
|
||||||
<textarea
|
<textarea
|
||||||
className={textareaClass}
|
className={`${textareaClass} min-h-44`}
|
||||||
onChange={(event) => update('content', event.target.value)}
|
onChange={(event) => update('content', event.target.value)}
|
||||||
placeholder="Escreva a mensagem"
|
placeholder="Escreva a mensagem"
|
||||||
value={draft.content}
|
value={draft.content}
|
||||||
@@ -738,17 +739,24 @@ function TemplateEditor({ allowedChannelKeys, draft, onChange, onClose, onSubmit
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModalFrame({ children, onClose, title }) {
|
function ModalFrame({ branded = false, children, onClose, title }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||||
<div className="w-full max-w-2xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
|
<div className={`flex max-h-[94vh] w-full ${branded ? 'max-w-6xl' : 'max-w-2xl'} flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl`}>
|
||||||
<div className="flex items-center justify-between border-b border-[#404040] px-5 py-4">
|
<div className="flex items-center justify-between border-b border-[#404040] px-5 py-4">
|
||||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{title}</h2>
|
<div className="flex items-center gap-3">
|
||||||
|
{branded ? (
|
||||||
|
<span className="grid size-9 place-items-center rounded-sm bg-[#3b82f6] text-white">
|
||||||
|
<StethoscopeIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<h2 className="text-lg font-bold text-[#f5f5f5]">{title}</h2>
|
||||||
|
</div>
|
||||||
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] hover:bg-[#303030]" onClick={onClose} type="button">
|
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] hover:bg-[#303030]" onClick={onClose} type="button">
|
||||||
<CommIcon className="size-5" name="x" />
|
<CommIcon className="size-5" name="x" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">{children}</div>
|
<div className="min-h-0 overflow-y-auto p-5">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { hasCapability } from '../config/permissions.js'
|
import { hasCapability } from '../config/permissions.js'
|
||||||
import { patientRepository } from '../repositories/patientRepository.js'
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
@@ -179,18 +179,32 @@ export function PatientsPage({ navigate, role }) {
|
|||||||
try {
|
try {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
const created = normalizeCreatedPatient(await patientRepository.create(patient))
|
const created = normalizeCreatedPatient(await patientRepository.create(patient))
|
||||||
|
const patientId = created?.id || patient.id
|
||||||
|
const avatarResult = patient.avatarFile
|
||||||
|
? await patientRepository.uploadAvatar(patientId, patient.avatarFile)
|
||||||
|
: null
|
||||||
const newRow = {
|
const newRow = {
|
||||||
...patient,
|
...patient,
|
||||||
id: created?.id || patient.id,
|
avatarFile: undefined,
|
||||||
detailId: created?.id || patient.detailId || patient.id,
|
avatarUrl: avatarResult?.avatarUrl || patient.avatarUrl,
|
||||||
|
id: patientId,
|
||||||
|
detailId: patientId || patient.detailId || patient.id,
|
||||||
name: created?.full_name || created?.name || patient.name,
|
name: created?.full_name || created?.name || patient.name,
|
||||||
phone: created?.phone_mobile || created?.phone || patient.phone,
|
phone: created?.phone_mobile || created?.phone || patient.phone,
|
||||||
}
|
}
|
||||||
setRows((currentRows) => [newRow, ...currentRows])
|
setRows((currentRows) => [newRow, ...currentRows])
|
||||||
} else {
|
} else {
|
||||||
await patientRepository.update(patient.id, patient)
|
await patientRepository.update(patient.id, patient)
|
||||||
|
const avatarResult = patient.avatarFile
|
||||||
|
? await patientRepository.uploadAvatar(patient.id, patient.avatarFile)
|
||||||
|
: null
|
||||||
|
const nextPatient = {
|
||||||
|
...patient,
|
||||||
|
avatarFile: undefined,
|
||||||
|
avatarUrl: avatarResult?.avatarUrl || patient.avatarUrl,
|
||||||
|
}
|
||||||
setRows((currentRows) =>
|
setRows((currentRows) =>
|
||||||
currentRows.map((item) => (item.id === patient.id ? patient : item))
|
currentRows.map((item) => (item.id === patient.id ? nextPatient : item))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -358,9 +372,13 @@ export function PatientsPage({ navigate, role }) {
|
|||||||
<tr className="transition hover:bg-[#303030]" key={patient.id}>
|
<tr className="transition hover:bg-[#303030]" key={patient.id}>
|
||||||
<td className="px-6 py-4 align-top">
|
<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 shrink-0 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
|
{patient.avatarUrl ? (
|
||||||
{patient.name.charAt(0)}
|
<img alt="" className="size-8 shrink-0 rounded-full border border-[#3b82f6]/30 object-cover" src={patient.avatarUrl} />
|
||||||
</span>
|
) : (
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
|
||||||
|
{patient.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block whitespace-normal break-words 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}
|
||||||
@@ -509,7 +527,11 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
lastVisit: patient?.lastVisit || null,
|
lastVisit: patient?.lastVisit || null,
|
||||||
nextVisit: patient?.nextVisit || null,
|
nextVisit: patient?.nextVisit || null,
|
||||||
lastVisitIso: patient?.lastVisitIso || null,
|
lastVisitIso: patient?.lastVisitIso || null,
|
||||||
|
avatarUrl: patient?.avatarUrl || patient?.avatar_url || '',
|
||||||
}))
|
}))
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const [avatarFile, setAvatarFile] = useState(null)
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState(formData.avatarUrl)
|
||||||
const [attachmentsOpen, setAttachmentsOpen] = useState(false)
|
const [attachmentsOpen, setAttachmentsOpen] = useState(false)
|
||||||
const isNewPatient = !patient
|
const isNewPatient = !patient
|
||||||
|
|
||||||
@@ -536,6 +558,15 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
setFormData((currentData) => ({ ...currentData, [name]: nextValue }))
|
setFormData((currentData) => ({ ...currentData, [name]: nextValue }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAvatarChange(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setAvatarFile(file)
|
||||||
|
setAvatarPreview(URL.createObjectURL(file))
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit(event) {
|
function handleSubmit(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -584,6 +615,8 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
state: formData.state,
|
state: formData.state,
|
||||||
address: formatAddress(formData),
|
address: formatAddress(formData),
|
||||||
notes: formData.notesText ? [formData.notesText] : [],
|
notes: formData.notesText ? [formData.notesText] : [],
|
||||||
|
avatarFile,
|
||||||
|
avatarUrl: avatarFile ? formData.avatarUrl : avatarPreview || formData.avatarUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,15 +642,27 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
<section className={darkCard}>
|
<section className={darkCard}>
|
||||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Dados do Paciente</h2>
|
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Dados do Paciente</h2>
|
||||||
<div className="mb-8 flex flex-col items-start gap-4 md:flex-row">
|
<div className="mb-8 flex flex-col items-start gap-4 md:flex-row">
|
||||||
<div className="grid size-20 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/20 text-[#3b82f6]">
|
{avatarPreview ? (
|
||||||
<PatientIcon className="size-10" name="user" />
|
<img alt="" className="size-20 shrink-0 rounded-full border border-[#3b82f6]/30 object-cover" src={avatarPreview} />
|
||||||
</div>
|
) : (
|
||||||
|
<div className="grid size-20 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/20 text-[#3b82f6]">
|
||||||
|
<PatientIcon className="size-10" name="user" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="mt-2 rounded-lg border border-[#404040] bg-[#1a1a1a] px-4 py-1.5 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
className="mt-2 rounded-lg border border-[#404040] bg-[#1a1a1a] px-4 py-1.5 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Carregar
|
Carregar
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
||||||
@@ -784,7 +829,15 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await patientRepository.update(updatedPatient.id, updatedPatient)
|
await patientRepository.update(updatedPatient.id, updatedPatient)
|
||||||
setLocalPatient((current) => ({ ...current, ...updatedPatient }))
|
const avatarResult = updatedPatient.avatarFile
|
||||||
|
? await patientRepository.uploadAvatar(updatedPatient.id, updatedPatient.avatarFile)
|
||||||
|
: null
|
||||||
|
setLocalPatient((current) => ({
|
||||||
|
...current,
|
||||||
|
...updatedPatient,
|
||||||
|
avatarFile: undefined,
|
||||||
|
avatarUrl: avatarResult?.avatarUrl || updatedPatient.avatarUrl,
|
||||||
|
}))
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert(`Erro ao salvar paciente: ${err.message}`)
|
window.alert(`Erro ao salvar paciente: ${err.message}`)
|
||||||
@@ -831,6 +884,9 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
|||||||
>
|
>
|
||||||
<PatientIcon className="size-5" name="chevron-left" />
|
<PatientIcon className="size-5" name="chevron-left" />
|
||||||
</button>
|
</button>
|
||||||
|
{localPatient.avatarUrl ? (
|
||||||
|
<img alt="" className="mt-1 size-12 rounded-full border border-[#3b82f6]/30 object-cover" src={localPatient.avatarUrl} />
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#3b82f6]">Dados do Paciente</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#3b82f6]">Dados do Paciente</p>
|
||||||
<h1 className="mt-1 text-2xl font-bold tracking-tight text-[#f5f5f5]">{localPatient.name}</h1>
|
<h1 className="mt-1 text-2xl font-bold tracking-tight text-[#f5f5f5]">{localPatient.name}</h1>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
|
||||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
|
||||||
import { normalizeRole } from '../config/permissions.js'
|
import { normalizeRole } from '../config/permissions.js'
|
||||||
import { authRepository } from '../repositories/authRepository.js'
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
import { profileRepository } from '../repositories/profileRepository.js'
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
@@ -71,21 +69,13 @@ export function ProfilePage({ navigate }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
{canEditProfile ? (
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<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 do usuário logado e preferências básicas do shell.</p>
|
<p className="mt-1 text-sm text-[#b8b8b8]">Dados 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} ${featurePanelClass(canEditProfile ? 'partial' : 'live')} p-6`}>
|
<section className={`${cardClass} p-6`}>
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
{profile.avatarUrl ? (
|
{profile.avatarUrl ? (
|
||||||
<img alt="" className="size-16 rounded-full border border-[#3b82f6]/30 object-cover" src={profile.avatarUrl} />
|
<img alt="" className="size-16 rounded-full border border-[#3b82f6]/30 object-cover" src={profile.avatarUrl} />
|
||||||
@@ -97,25 +87,21 @@ export function ProfilePage({ navigate }) {
|
|||||||
<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>
|
||||||
{canEditProfile ? (
|
<button
|
||||||
<>
|
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
|
||||||
<button
|
disabled={uploadingAvatar}
|
||||||
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={uploadingAvatar}
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
>
|
||||||
type="button"
|
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
||||||
>
|
</button>
|
||||||
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
<input
|
||||||
</button>
|
accept="image/*"
|
||||||
<input
|
className="hidden"
|
||||||
accept="image/*"
|
onChange={handleAvatarChange}
|
||||||
className="hidden"
|
ref={fileInputRef}
|
||||||
onChange={handleAvatarChange}
|
type="file"
|
||||||
ref={fileInputRef}
|
/>
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
|
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +151,7 @@ export function ProfilePage({ navigate }) {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className={`${cardClass} ${featurePanelClass('live')} p-6`}>
|
<aside className={`${cardClass} p-6`}>
|
||||||
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
|
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
|
||||||
<dl className="mt-5 grid gap-4 text-sm">
|
<dl className="mt-5 grid gap-4 text-sm">
|
||||||
<Info label="Perfil" value={profile.role} />
|
<Info label="Perfil" value={profile.role} />
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { EditorContent, useEditor } from '@tiptap/react'
|
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
|
||||||
import Underline from '@tiptap/extension-underline'
|
|
||||||
import TextAlign from '@tiptap/extension-text-align'
|
|
||||||
|
|
||||||
import { normalizeRole } from '../config/permissions.js'
|
import { normalizeRole } from '../config/permissions.js'
|
||||||
|
import { StethoscopeIcon } from '../components/Brand.jsx'
|
||||||
|
import { RichTextEditor } from '../components/RichTextEditor.jsx'
|
||||||
|
import { DarkField, appCardClass as cardClass, appInputClass as inputClass, appLabelClass as labelClass } from '../components/ui.jsx'
|
||||||
|
import { reportTemplates } from '../data/reportTemplates.js'
|
||||||
import { patientRepository } from '../repositories/patientRepository.js'
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
import { profileRepository } from '../repositories/profileRepository.js'
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
import { reportRepository } from '../repositories/reportRepository.js'
|
import { reportRepository } from '../repositories/reportRepository.js'
|
||||||
import { StethoscopeIcon } from '../components/Brand.jsx'
|
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 25
|
const ITEMS_PER_PAGE = 25
|
||||||
|
|
||||||
@@ -33,94 +32,6 @@ const orderOptions = [
|
|||||||
{ label: 'Prazo mais distante', value: 'due_at.desc' },
|
{ label: 'Prazo mais distante', value: 'due_at.desc' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
|
||||||
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
|
||||||
|
|
||||||
const reportTemplates = [
|
|
||||||
{
|
|
||||||
id: 'consulta-medica',
|
|
||||||
category: 'Relatórios',
|
|
||||||
title: 'Relatório de Consulta Médica',
|
|
||||||
description: 'Resumo clínico com queixa, exame físico, hipótese diagnóstica e conduta.',
|
|
||||||
popular: true,
|
|
||||||
tags: ['consulta', 'clínico', 'conduta'],
|
|
||||||
exam: 'Consulta médica',
|
|
||||||
cidCode: 'Z00.0',
|
|
||||||
diagnosis: 'Paciente avaliado(a) em consulta médica, com hipótese diagnóstica em investigação conforme quadro clínico.',
|
|
||||||
conclusion: 'Paciente orientado(a) quanto à conduta proposta, sinais de alerta e necessidade de seguimento.',
|
|
||||||
contentHtml:
|
|
||||||
'<h2>Relatório de Consulta Médica</h2><p><strong>Queixa principal:</strong> </p><p><strong>História clínica:</strong> </p><p><strong>Exame físico:</strong> </p><p><strong>Hipóteses diagnósticas:</strong> </p><p><strong>Conduta:</strong> </p>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'evolucao-clinica',
|
|
||||||
category: 'Relatórios',
|
|
||||||
title: 'Evolução Clínica',
|
|
||||||
description: 'Registro de evolução diária para acompanhamento de internação.',
|
|
||||||
tags: ['internação', 'evolução', 'diário'],
|
|
||||||
exam: 'Evolução clínica',
|
|
||||||
cidCode: 'Z51.9',
|
|
||||||
diagnosis: 'Paciente em acompanhamento clínico durante internação, com evolução registrada em prontuário.',
|
|
||||||
conclusion: 'Manter acompanhamento multiprofissional e reavaliar conduta conforme evolução.',
|
|
||||||
contentHtml:
|
|
||||||
'<h2>Evolução Clínica</h2><p><strong>Data e hora:</strong> </p><p><strong>Estado geral:</strong> </p><p><strong>Sinais vitais:</strong> </p><p><strong>Evolução:</strong> </p><p><strong>Conduta do dia:</strong> </p><p><strong>Profissional:</strong> </p>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hemograma',
|
|
||||||
category: 'Laudos',
|
|
||||||
title: 'Laudo de Hemograma',
|
|
||||||
description: 'Interpretação clínica de hemograma com correlação diagnóstica.',
|
|
||||||
tags: ['laboratorial', 'sangue', 'hemograma'],
|
|
||||||
exam: 'Hemograma completo',
|
|
||||||
cidCode: 'Z01.7',
|
|
||||||
diagnosis: 'Exame laboratorial avaliado em conjunto com quadro clínico e exames complementares.',
|
|
||||||
conclusion: 'Resultado analisado e correlacionado com a hipótese diagnóstica descrita.',
|
|
||||||
contentHtml:
|
|
||||||
'<h2>Laudo de Hemograma</h2><p><strong>Material:</strong> Sangue periférico.</p><p><strong>Achados principais:</strong> </p><p><strong>Interpretação:</strong> </p><p><strong>Conclusão:</strong> </p>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'imagem',
|
|
||||||
category: 'Laudos',
|
|
||||||
title: 'Laudo de Imagem',
|
|
||||||
description: 'Modelo para exames de imagem com descrição técnica e impressão diagnóstica.',
|
|
||||||
popular: true,
|
|
||||||
tags: ['imagem', 'radiologia', 'exame'],
|
|
||||||
exam: 'Exame de imagem',
|
|
||||||
cidCode: 'Z01.6',
|
|
||||||
diagnosis: 'Achados de imagem descritos conforme exame realizado e indicação clínica.',
|
|
||||||
conclusion: 'Impressão diagnóstica registrada conforme achados do exame.',
|
|
||||||
contentHtml:
|
|
||||||
'<h2>Laudo de Imagem</h2><p><strong>Técnica:</strong> </p><p><strong>Achados:</strong> </p><p><strong>Impressão diagnóstica:</strong> </p><p><strong>Recomendação:</strong> </p>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pre-operatorio',
|
|
||||||
category: 'Relatórios',
|
|
||||||
title: 'Avaliação Pré-operatória',
|
|
||||||
description: 'Avaliação clínica para estratificação de risco e liberação cirúrgica.',
|
|
||||||
tags: ['pré-op', 'cirurgia', 'risco'],
|
|
||||||
exam: 'Avaliação pré-operatória',
|
|
||||||
cidCode: 'Z01.8',
|
|
||||||
diagnosis: 'Paciente em avaliação pré-operatória, com risco definido conforme dados clínicos disponíveis.',
|
|
||||||
conclusion: 'Conduta pré-operatória orientada conforme avaliação clínica e exames apresentados.',
|
|
||||||
contentHtml:
|
|
||||||
'<h2>Avaliação Pré-operatória</h2><p><strong>Procedimento proposto:</strong> </p><p><strong>Comorbidades:</strong> </p><p><strong>Medicamentos em uso:</strong> </p><p><strong>Estratificação de risco:</strong> </p><p><strong>Orientações:</strong> </p>',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'encaminhamento',
|
|
||||||
category: 'Encaminhamentos',
|
|
||||||
title: 'Encaminhamento Especializado',
|
|
||||||
description: 'Encaminhamento com justificativa clínica e resumo do caso.',
|
|
||||||
tags: ['encaminhamento', 'especialista', 'conduta'],
|
|
||||||
exam: 'Encaminhamento médico',
|
|
||||||
cidCode: 'Z75.8',
|
|
||||||
diagnosis: 'Paciente encaminhado(a) para avaliação especializada por necessidade clínica descrita.',
|
|
||||||
conclusion: 'Solicitada avaliação especializada e continuidade do cuidado compartilhado.',
|
|
||||||
contentHtml:
|
|
||||||
'<h2>Encaminhamento Especializado</h2><p><strong>Especialidade solicitada:</strong> </p><p><strong>Resumo clínico:</strong> </p><p><strong>Motivo do encaminhamento:</strong> </p><p><strong>Exames anexos:</strong> </p>',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const emptyEditor = {
|
const emptyEditor = {
|
||||||
id: null,
|
id: null,
|
||||||
orderNumber: '',
|
orderNumber: '',
|
||||||
@@ -320,6 +231,7 @@ export function ReportsPage({ role }) {
|
|||||||
setEditor({
|
setEditor({
|
||||||
...emptyEditor,
|
...emptyEditor,
|
||||||
patientId: patientOptions[0]?.id || '',
|
patientId: patientOptions[0]?.id || '',
|
||||||
|
requestedBy: isDoctorRole ? currentProfessional?.name || viewerProfile?.name || '' : '',
|
||||||
})
|
})
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
@@ -566,13 +478,16 @@ export function ReportsPage({ role }) {
|
|||||||
|
|
||||||
{editorOpen ? (
|
{editorOpen ? (
|
||||||
<ReportEditorModalV3
|
<ReportEditorModalV3
|
||||||
|
currentProfessional={currentProfessional}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
isDoctorRole={isDoctorRole}
|
||||||
onChange={setEditor}
|
onChange={setEditor}
|
||||||
onClose={() => setEditorOpen(false)}
|
onClose={() => setEditorOpen(false)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
patientOptions={patientOptions}
|
patientOptions={patientOptions}
|
||||||
professionalOptions={professionalOptions}
|
professionalOptions={professionalOptions}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
viewerProfile={viewerProfile}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -613,10 +528,33 @@ function ReportRow({ onEdit, onView, report }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
function ReportEditorModalV3({
|
||||||
|
currentProfessional,
|
||||||
|
editor,
|
||||||
|
isDoctorRole,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
patientOptions,
|
||||||
|
professionalOptions,
|
||||||
|
saving,
|
||||||
|
viewerProfile,
|
||||||
|
}) {
|
||||||
|
const selectedPatient = patientOptions.find((patient) => String(patient.id) === String(editor.patientId))
|
||||||
|
const doctorRequesterName = currentProfessional?.name || viewerProfile?.name || ''
|
||||||
|
const [patientSearch, setPatientSearch] = useState(selectedPatient?.name || '')
|
||||||
|
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || doctorRequesterName)
|
||||||
const [templateSearch, setTemplateSearch] = useState('')
|
const [templateSearch, setTemplateSearch] = useState('')
|
||||||
const [templatesOpen, setTemplatesOpen] = useState(false)
|
const [templatesOpen, setTemplatesOpen] = useState(false)
|
||||||
const isValid = isReportEditorValid(editor)
|
const isValid = isReportEditorValid(editor)
|
||||||
|
const filteredPatients = patientOptions.filter((patient) => {
|
||||||
|
const query = normalizeSearch(patientSearch)
|
||||||
|
return query && normalizeSearch(patient.name).includes(query)
|
||||||
|
})
|
||||||
|
const filteredProfessionals = professionalOptions.filter((professional) => {
|
||||||
|
const query = normalizeSearch(requesterSearch)
|
||||||
|
return query && normalizeSearch(professional.name).includes(query)
|
||||||
|
})
|
||||||
const filteredTemplates = reportTemplates.filter((template) => {
|
const filteredTemplates = reportTemplates.filter((template) => {
|
||||||
const query = normalizeSearch(templateSearch)
|
const query = normalizeSearch(templateSearch)
|
||||||
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
||||||
@@ -627,6 +565,22 @@ function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
|||||||
onChange((current) => ({ ...current, [field]: value }))
|
onChange((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDoctorRole && doctorRequesterName && !editor.requestedBy) {
|
||||||
|
onChange((current) => ({ ...current, requestedBy: doctorRequesterName }))
|
||||||
|
}
|
||||||
|
}, [doctorRequesterName, editor.requestedBy, isDoctorRole, onChange])
|
||||||
|
|
||||||
|
function selectPatient(patient) {
|
||||||
|
setPatientSearch(patient.name)
|
||||||
|
updateField('patientId', patient.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRequester(professional) {
|
||||||
|
setRequesterSearch(professional.name)
|
||||||
|
updateField('requestedBy', professional.name)
|
||||||
|
}
|
||||||
|
|
||||||
function applyTemplate(template) {
|
function applyTemplate(template) {
|
||||||
setTemplatesOpen(false)
|
setTemplatesOpen(false)
|
||||||
onChange((current) => ({
|
onChange((current) => ({
|
||||||
@@ -722,6 +676,71 @@ function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<DarkField label="Paciente *">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPatientSearch(event.target.value)
|
||||||
|
updateField('patientId', '')
|
||||||
|
}}
|
||||||
|
placeholder="Digite o nome do paciente"
|
||||||
|
type="search"
|
||||||
|
value={patientSearch}
|
||||||
|
/>
|
||||||
|
{patientSearch && !editor.patientId ? (
|
||||||
|
<SearchMenu
|
||||||
|
emptyText="Nenhum paciente encontrado."
|
||||||
|
items={filteredPatients.slice(0, 6)}
|
||||||
|
onSelect={selectPatient}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Solicitante *">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
disabled={isDoctorRole}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (isDoctorRole) return
|
||||||
|
setRequesterSearch(event.target.value)
|
||||||
|
updateField('requestedBy', event.target.value)
|
||||||
|
}}
|
||||||
|
placeholder="Digite o nome do médico solicitante"
|
||||||
|
readOnly={isDoctorRole}
|
||||||
|
type="search"
|
||||||
|
value={isDoctorRole ? doctorRequesterName : requesterSearch}
|
||||||
|
/>
|
||||||
|
{!isDoctorRole && requesterSearch ? (
|
||||||
|
<SearchMenu
|
||||||
|
emptyText="Nenhum médico encontrado."
|
||||||
|
items={filteredProfessionals.slice(0, 6)}
|
||||||
|
onSelect={selectRequester}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Exame *">
|
||||||
|
<input className={inputClass} onChange={(event) => updateField('exam', event.target.value)} value={editor.exam} />
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="CID-10 *">
|
||||||
|
<input className={inputClass} onChange={(event) => updateField('cidCode', event.target.value)} value={editor.cidCode} />
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Diagnóstico *">
|
||||||
|
<input className={inputClass} onChange={(event) => updateField('diagnosis', event.target.value)} value={editor.diagnosis} />
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Conclusão *">
|
||||||
|
<input className={inputClass} onChange={(event) => updateField('conclusion', event.target.value)} value={editor.conclusion} />
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DarkField label="Editor de texto">
|
<DarkField label="Editor de texto">
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
onChange={(value) => updateField('contentHtml', value)}
|
onChange={(value) => updateField('contentHtml', value)}
|
||||||
@@ -755,122 +774,6 @@ function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RichTextEditor({ onChange, value }) {
|
|
||||||
const lastSyncedHtmlRef = useRef(value || '')
|
|
||||||
const applyingExternalContentRef = useRef(false)
|
|
||||||
const tiptapEditor = useEditor({
|
|
||||||
extensions: [
|
|
||||||
StarterKit,
|
|
||||||
Underline,
|
|
||||||
TextAlign.configure({
|
|
||||||
types: ['heading', 'paragraph'],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content: value || '',
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: 'report-rich-surface min-h-[560px] px-4 py-3 text-sm leading-6 text-[#e5e5e5] outline-none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shouldRerenderOnTransaction: false,
|
|
||||||
onUpdate: ({ editor: currentEditor }) => {
|
|
||||||
if (applyingExternalContentRef.current) return
|
|
||||||
|
|
||||||
const nextHtml = currentEditor.getHTML()
|
|
||||||
lastSyncedHtmlRef.current = nextHtml
|
|
||||||
onChange(nextHtml)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tiptapEditor) return
|
|
||||||
|
|
||||||
const nextValue = value || ''
|
|
||||||
if (lastSyncedHtmlRef.current === nextValue) return
|
|
||||||
|
|
||||||
if (tiptapEditor.getHTML() === nextValue) {
|
|
||||||
lastSyncedHtmlRef.current = nextValue
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applyingExternalContentRef.current = true
|
|
||||||
try {
|
|
||||||
tiptapEditor.commands.setContent(nextValue, { emitUpdate: false })
|
|
||||||
} finally {
|
|
||||||
applyingExternalContentRef.current = false
|
|
||||||
}
|
|
||||||
lastSyncedHtmlRef.current = nextValue
|
|
||||||
}, [tiptapEditor, value])
|
|
||||||
|
|
||||||
const blockFormat = tiptapEditor?.isActive('heading', { level: 2 })
|
|
||||||
? 'h2'
|
|
||||||
: tiptapEditor?.isActive('heading', { level: 3 })
|
|
||||||
? 'h3'
|
|
||||||
: 'p'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="report-rich-editor overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
|
|
||||||
<div className="report-rich-toolbar flex flex-wrap items-center gap-1 border-b border-[#404040] bg-[#202020] px-3 py-2">
|
|
||||||
<TipTapToolbarButton disabled={!tiptapEditor?.can().undo()} label="Desfazer" name="undo" onClick={() => tiptapEditor?.chain().focus().undo().run()} />
|
|
||||||
<TipTapToolbarButton disabled={!tiptapEditor?.can().redo()} label="Refazer" name="redo" onClick={() => tiptapEditor?.chain().focus().redo().run()} />
|
|
||||||
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
|
||||||
<select
|
|
||||||
className="h-8 rounded-sm border border-[#404040] bg-[#171717] px-2 text-xs font-semibold text-[#d4d4d4]"
|
|
||||||
onChange={(event) => {
|
|
||||||
const selected = event.target.value
|
|
||||||
|
|
||||||
if (selected === 'h2') {
|
|
||||||
tiptapEditor?.chain().focus().toggleHeading({ level: 2 }).run()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected === 'h3') {
|
|
||||||
tiptapEditor?.chain().focus().toggleHeading({ level: 3 }).run()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tiptapEditor?.chain().focus().setParagraph().run()
|
|
||||||
}}
|
|
||||||
value={blockFormat}
|
|
||||||
>
|
|
||||||
<option value="p">Padrao</option>
|
|
||||||
<option value="h2">Titulo</option>
|
|
||||||
<option value="h3">Subtitulo</option>
|
|
||||||
</select>
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive('bold')} label="Negrito" name="bold" onClick={() => tiptapEditor?.chain().focus().toggleBold().run()} />
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive('italic')} label="Italico" name="italic" onClick={() => tiptapEditor?.chain().focus().toggleItalic().run()} />
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive('underline')} label="Sublinhado" name="underline" onClick={() => tiptapEditor?.chain().focus().toggleUnderline().run()} />
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive('strike')} label="Tachado" name="strike" onClick={() => tiptapEditor?.chain().focus().toggleStrike().run()} />
|
|
||||||
<span className="mx-1 h-5 w-px bg-[#404040]" />
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'left' })} label="Alinhar a esquerda" name="align-left" onClick={() => tiptapEditor?.chain().focus().setTextAlign('left').run()} />
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'center' })} label="Centralizar" name="align-center" onClick={() => tiptapEditor?.chain().focus().setTextAlign('center').run()} />
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'right' })} label="Alinhar a direita" name="align-right" onClick={() => tiptapEditor?.chain().focus().setTextAlign('right').run()} />
|
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive('bulletList')} label="Lista" name="list" onClick={() => tiptapEditor?.chain().focus().toggleBulletList().run()} />
|
|
||||||
</div>
|
|
||||||
<EditorContent editor={tiptapEditor} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TipTapToolbarButton({ active = false, disabled = false, label, name, onClick }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label={label}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={`grid size-8 place-items-center rounded-sm transition ${
|
|
||||||
active ? 'bg-[#3b82f6]/20 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
|
||||||
} disabled:cursor-not-allowed disabled:opacity-40`}
|
|
||||||
disabled={disabled}
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
onClick={onClick}
|
|
||||||
title={label}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReportIcon className="size-4" name={name} />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizePreviewHtml(value) {
|
function sanitizePreviewHtml(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
|
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
|
||||||
@@ -926,7 +829,7 @@ function ReportViewModal({ onClose, report }) {
|
|||||||
<DetailBlock label="Conclusão" value={report.conclusion || '-'} />
|
<DetailBlock label="Conclusão" value={report.conclusion || '-'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 rounded-xl border border-[#404040] bg-[#1a1a1a] p-5">
|
<div className="mt-6 rounded-xl border border-[#404040] bg-[#1a1a1a] p-5">
|
||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Complemento</p>
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Relatório</p>
|
||||||
{report.contentHtml ? (
|
{report.contentHtml ? (
|
||||||
<div
|
<div
|
||||||
className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]"
|
className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]"
|
||||||
@@ -951,11 +854,24 @@ function FilterField({ children, label }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DarkField({ children, label }) {
|
function SearchMenu({ emptyText, items, onSelect }) {
|
||||||
return (
|
return (
|
||||||
<div className="block">
|
<div className="absolute left-0 right-0 top-11 z-20 max-h-56 overflow-y-auto rounded-md border border-[#404040] bg-[#202020] shadow-2xl">
|
||||||
<span className={labelClass}>{label}</span>
|
{items.length ? (
|
||||||
{children}
|
items.map((item) => (
|
||||||
|
<button
|
||||||
|
className="block w-full px-3 py-2 text-left text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
key={item.id || item.name}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-2 text-xs text-[#737373]">{emptyText}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1044,6 +960,12 @@ function uniqueValues(values) {
|
|||||||
|
|
||||||
function isReportEditorValid(editor) {
|
function isReportEditorValid(editor) {
|
||||||
return [
|
return [
|
||||||
|
editor.patientId,
|
||||||
|
editor.requestedBy,
|
||||||
|
editor.exam,
|
||||||
|
editor.cidCode,
|
||||||
|
editor.diagnosis,
|
||||||
|
editor.conclusion,
|
||||||
editor.status,
|
editor.status,
|
||||||
stripHtml(editor.contentHtml),
|
stripHtml(editor.contentHtml),
|
||||||
].every((value) => String(value || '').trim())
|
].every((value) => String(value || '').trim())
|
||||||
@@ -1116,7 +1038,7 @@ function printReportAsPdf(report, status) {
|
|||||||
<p class="value">${escapeHtml(report.conclusion || '-')}</p>
|
<p class="value">${escapeHtml(report.conclusion || '-')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="section box">
|
<div class="section box">
|
||||||
<p class="label">Complemento</p>
|
<p class="label">Relatório</p>
|
||||||
<div class="value">${report.contentHtml ? sanitizePreviewHtml(report.contentHtml) : 'Nenhum complemento informado.'}</div>
|
<div class="value">${report.contentHtml ? sanitizePreviewHtml(report.contentHtml) : 'Nenhum complemento informado.'}</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { StethoscopeIcon } from '../components/Brand.jsx'
|
||||||
import { ADMIN_CREATABLE_ROLES, GESTOR_CREATABLE_ROLES, hasCapability, normalizeRole, ROLE_LABELS } from '../config/permissions.js'
|
import { ADMIN_CREATABLE_ROLES, GESTOR_CREATABLE_ROLES, hasCapability, normalizeRole, ROLE_LABELS } from '../config/permissions.js'
|
||||||
import { userRepository } from '../repositories/userRepository.js'
|
import { userRepository } from '../repositories/userRepository.js'
|
||||||
|
|
||||||
@@ -18,6 +19,11 @@ const authMethodOptions = [
|
|||||||
description: 'Definir senha inicial agora',
|
description: 'Definir senha inicial agora',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
const BRAZILIAN_UF = [
|
||||||
|
'AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MT', 'MS', 'MG',
|
||||||
|
'PA', 'PB', 'PR', 'PE', 'PI', 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE',
|
||||||
|
'TO',
|
||||||
|
]
|
||||||
const initialUserForm = {
|
const initialUserForm = {
|
||||||
email: '',
|
email: '',
|
||||||
full_name: '',
|
full_name: '',
|
||||||
@@ -28,6 +34,8 @@ const initialUserForm = {
|
|||||||
password: '',
|
password: '',
|
||||||
confirm_password: '',
|
confirm_password: '',
|
||||||
create_patient_record: false,
|
create_patient_record: false,
|
||||||
|
crm: '',
|
||||||
|
crm_uf: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsersPage({ role: currentRole }) {
|
export function UsersPage({ role: currentRole }) {
|
||||||
@@ -37,6 +45,8 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState(null)
|
const [deletingId, setDeletingId] = useState(null)
|
||||||
|
const [editingUserId, setEditingUserId] = useState(null)
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null)
|
||||||
const [form, setForm] = useState(initialUserForm)
|
const [form, setForm] = useState(initialUserForm)
|
||||||
const [roleFilter, setRoleFilter] = useState('Todos')
|
const [roleFilter, setRoleFilter] = useState('Todos')
|
||||||
|
|
||||||
@@ -44,6 +54,7 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
|
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
|
||||||
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||||
const isPasswordCreation = form.auth_method === 'password'
|
const isPasswordCreation = form.auth_method === 'password'
|
||||||
|
const isDoctorForm = normalizeRole(form.role) === 'medico'
|
||||||
const filterableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
const filterableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||||
const filteredUsers = users.filter((user) => {
|
const filteredUsers = users.filter((user) => {
|
||||||
if (roleFilter === 'Todos') return true
|
if (roleFilter === 'Todos') return true
|
||||||
@@ -72,6 +83,28 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
setForm((current) => ({ ...current, [name]: type === 'checkbox' ? checked : value }))
|
setForm((current) => ({ ...current, [name]: type === 'checkbox' ? checked : value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
setEditingUserId(null)
|
||||||
|
setForm(initialUserForm)
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(user) {
|
||||||
|
setSelectedUser(null)
|
||||||
|
setEditingUserId(user.id)
|
||||||
|
setForm({
|
||||||
|
...initialUserForm,
|
||||||
|
email: user.email || '',
|
||||||
|
full_name: user.full_name || user.name || '',
|
||||||
|
phone: user.phone || user.phone_mobile || '',
|
||||||
|
cpf: user.cpf || '',
|
||||||
|
role: normalizeRole(getUserRole(user)) || '',
|
||||||
|
crm: user.crm || '',
|
||||||
|
crm_uf: user.crm_uf || user.crmUf || '',
|
||||||
|
})
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreate(event) {
|
async function handleCreate(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!canManageUsers) {
|
if (!canManageUsers) {
|
||||||
@@ -84,7 +117,12 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPasswordCreation) {
|
if (isDoctorForm && (!form.crm || !form.crm_uf)) {
|
||||||
|
window.alert('CRM e CRM UF são obrigatórios para usuários médicos.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingUserId && isPasswordCreation) {
|
||||||
if (!form.password || !form.confirm_password) {
|
if (!form.password || !form.confirm_password) {
|
||||||
window.alert('Preencha a senha e a confirmação de senha.')
|
window.alert('Preencha a senha e a confirmação de senha.')
|
||||||
return
|
return
|
||||||
@@ -103,7 +141,11 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
if (isPasswordCreation) {
|
if (editingUserId) {
|
||||||
|
const updatedUser = await userRepository.update(editingUserId, form)
|
||||||
|
setUsers((current) => current.map((user) => (user.id === editingUserId ? { ...user, ...form, ...updatedUser } : user)))
|
||||||
|
window.alert(`Usuário atualizado: ${form.email}.`)
|
||||||
|
} else if (isPasswordCreation) {
|
||||||
await userRepository.createWithPassword(form)
|
await userRepository.createWithPassword(form)
|
||||||
window.alert(`Usuário criado com email e senha para ${form.email}.`)
|
window.alert(`Usuário criado com email e senha para ${form.email}.`)
|
||||||
} else {
|
} else {
|
||||||
@@ -111,10 +153,11 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
window.alert(`Usuário criado! Magic Link enviado para ${form.email}.`)
|
window.alert(`Usuário criado! Magic Link enviado para ${form.email}.`)
|
||||||
}
|
}
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
|
setEditingUserId(null)
|
||||||
setForm(initialUserForm)
|
setForm(initialUserForm)
|
||||||
loadUsers()
|
if (!editingUserId) loadUsers()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert(`Erro ao criar usuário: ${err.message}`)
|
window.alert(`Erro ao salvar usuário: ${err.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -160,7 +203,7 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white shadow-sm transition hover:bg-[#2563eb] md:w-auto"
|
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white shadow-sm transition hover:bg-[#2563eb] md:w-auto"
|
||||||
onClick={() => setModalOpen(true)}
|
onClick={openCreateModal}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
+ Novo usuário
|
+ Novo usuário
|
||||||
@@ -212,7 +255,7 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
filteredUsers.map((user) => {
|
filteredUsers.map((user) => {
|
||||||
const userRole = getUserRole(user)
|
const userRole = getUserRole(user)
|
||||||
return (
|
return (
|
||||||
<tr className="transition hover:bg-[#303030]" key={user.id}>
|
<tr className="cursor-pointer transition hover:bg-[#303030]" key={user.id} onClick={() => setSelectedUser(user)}>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="grid size-8 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
|
<span className="grid size-8 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
|
||||||
@@ -238,7 +281,10 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
<button
|
<button
|
||||||
className="rounded-lg border border-[#ef4444]/30 bg-[#ef4444]/10 px-3 py-1.5 text-xs font-semibold text-[#ef4444] transition hover:bg-[#ef4444]/20 disabled:opacity-50"
|
className="rounded-lg border border-[#ef4444]/30 bg-[#ef4444]/10 px-3 py-1.5 text-xs font-semibold text-[#ef4444] transition hover:bg-[#ef4444]/20 disabled:opacity-50"
|
||||||
disabled={deletingId === user.id}
|
disabled={deletingId === user.id}
|
||||||
onClick={() => handleDelete(user)}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleDelete(user)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{deletingId === user.id ? 'Deletando...' : 'Deletar'}
|
{deletingId === user.id ? 'Deletando...' : 'Deletar'}
|
||||||
@@ -260,18 +306,32 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedUser ? (
|
||||||
|
<UserDetailModal
|
||||||
|
onClose={() => setSelectedUser(null)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEdit={openEditModal}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{modalOpen ? (
|
{modalOpen ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setModalOpen(false)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setModalOpen(false)}>
|
||||||
<div
|
<div
|
||||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
|
className="flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-lg font-bold text-[#e5e5e5]">Novo Usuário</h2>
|
<span className="grid size-9 place-items-center rounded-sm bg-[#3b82f6] text-white">
|
||||||
<p className="mt-1 text-xs text-[#a3a3a3]">
|
<StethoscopeIcon className="size-5" />
|
||||||
{isPasswordCreation ? 'Crie o acesso inicial com email e senha.' : 'Um Magic Link sera enviado para o email cadastrado.'}
|
</span>
|
||||||
</p>
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">{editingUserId ? 'Editar Usuário' : 'Novo Usuário'}</h2>
|
||||||
|
<p className="mt-1 text-xs text-[#a3a3a3]">
|
||||||
|
{editingUserId ? 'Atualize os dados e permissões do usuário.' : isPasswordCreation ? 'Crie o acesso inicial com email e senha.' : 'Um Magic Link sera enviado para o email cadastrado.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333]"
|
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333]"
|
||||||
@@ -282,7 +342,8 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleCreate}>
|
<form className="min-h-0 space-y-5 overflow-y-auto p-6" onSubmit={handleCreate}>
|
||||||
|
{!editingUserId ? (
|
||||||
<div>
|
<div>
|
||||||
<span className={darkLabel}>Criar usuário usando *</span>
|
<span className={darkLabel}>Criar usuário usando *</span>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@@ -316,6 +377,7 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={darkLabel}>Nome completo *</label>
|
<label className={darkLabel}>Nome completo *</label>
|
||||||
@@ -417,6 +479,38 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDoctorForm ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>CRM *</label>
|
||||||
|
<input
|
||||||
|
className={darkInput}
|
||||||
|
name="crm"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="Ex: 123456"
|
||||||
|
required={isDoctorForm}
|
||||||
|
value={form.crm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>CRM UF *</label>
|
||||||
|
<select
|
||||||
|
className={darkInput}
|
||||||
|
name="crm_uf"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
required={isDoctorForm}
|
||||||
|
value={form.crm_uf}
|
||||||
|
>
|
||||||
|
<option value="">Selecione</option>
|
||||||
|
{BRAZILIAN_UF.map((uf) => (
|
||||||
|
<option key={uf} value={uf}>{uf}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!editingUserId ? (
|
||||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||||
<input
|
<input
|
||||||
checked={form.create_patient_record}
|
checked={form.create_patient_record}
|
||||||
@@ -428,7 +522,9 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
Criar também um registro de paciente
|
Criar também um registro de paciente
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 border-t border-[#404040] pt-4">
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -442,7 +538,7 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{saving ? 'Criando...' : isPasswordCreation ? 'Criar com senha' : 'Criar e enviar Magic Link'}
|
{saving ? 'Salvando...' : editingUserId ? 'Salvar alterações' : isPasswordCreation ? 'Criar com senha' : 'Criar e enviar Magic Link'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -453,6 +549,73 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserDetailModal({ onClose, onDelete, onEdit, user }) {
|
||||||
|
const userRole = normalizeRole(getUserRole(user)) || getUserRole(user)
|
||||||
|
const details = [
|
||||||
|
['Nome', user.full_name || user.name || 'Não informado'],
|
||||||
|
['Email', user.email || 'Não informado'],
|
||||||
|
['Celular', user.phone || user.phone_mobile || 'Não informado'],
|
||||||
|
['CPF', user.cpf || 'Não informado'],
|
||||||
|
['Perfil', ROLE_LABELS[userRole] || userRole || 'Não informado'],
|
||||||
|
['Status', user.email_confirmed_at ? 'Ativo' : 'Pendente'],
|
||||||
|
]
|
||||||
|
|
||||||
|
if (userRole === 'medico') {
|
||||||
|
details.push(['CRM', user.crm || 'Não informado'])
|
||||||
|
details.push(['CRM UF', user.crm_uf || user.crmUf || 'Não informado'])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-3xl rounded-xl border border-[#404040] bg-[#242424] shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Detalhes do usuário</h2>
|
||||||
|
<p className="mt-1 text-xs text-[#a3a3a3]">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<button className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333]" onClick={onClose} type="button">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 p-6 md:grid-cols-2">
|
||||||
|
{details.map(([label, value]) => (
|
||||||
|
<div className="rounded-xl border border-[#404040] bg-[#1a1a1a] p-4" key={label}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">{label}</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[#e5e5e5]">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-3 border-t border-[#404040] px-6 py-4">
|
||||||
|
<button
|
||||||
|
className="mr-auto rounded-lg border border-[#ef4444]/30 bg-[#ef4444]/10 px-4 py-2 text-sm font-semibold text-[#ef4444] transition hover:bg-[#ef4444]/20"
|
||||||
|
onClick={() => onDelete(user)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Deletar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
|
onClick={() => onEdit(user)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RoleBadge({ role }) {
|
function RoleBadge({ role }) {
|
||||||
const styles = {
|
const styles = {
|
||||||
admin: 'bg-purple-500/20 text-purple-400',
|
admin: 'bg-purple-500/20 text-purple-400',
|
||||||
|
|||||||
@@ -18,20 +18,20 @@ export function VisitsPage({ navigate }) {
|
|||||||
|
|
||||||
const visibleQueue = useMemo(() => {
|
const visibleQueue = useMemo(() => {
|
||||||
if (activeTab === 'finalizadas') {
|
if (activeTab === 'finalizadas') {
|
||||||
return careQueue.filter((item) => item.status === 'Finalizada')
|
return careQueue.filter((item) => isFinalizedStatus(item.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === 'atendimento') {
|
if (activeTab === 'atendimento') {
|
||||||
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando médico')
|
return careQueue.filter((item) => !isFinalizedStatus(item.status) && !isWaitingDoctorStatus(item.status))
|
||||||
}
|
}
|
||||||
|
|
||||||
return careQueue.filter((item) => item.status !== 'Finalizada')
|
return careQueue.filter((item) => !isFinalizedStatus(item.status))
|
||||||
}, [activeTab, careQueue])
|
}, [activeTab, careQueue])
|
||||||
|
|
||||||
const summary = [
|
const summary = [
|
||||||
{ label: 'Na fila', value: careQueue.filter((item) => item.status !== 'Finalizada').length, tone: 'text-[#3b82f6]' },
|
{ label: 'Na fila', value: careQueue.filter((item) => !isFinalizedStatus(item.status)).length, tone: 'text-[#3b82f6]' },
|
||||||
{ label: 'Alta prioridade', value: careQueue.filter((item) => item.priority === 'Alta').length, tone: 'text-red-400' },
|
{ label: 'Alta prioridade', value: careQueue.filter((item) => item.priority === 'Alta').length, tone: 'text-red-400' },
|
||||||
{ label: 'Finalizadas', value: careQueue.filter((item) => item.status === 'Finalizada').length, tone: 'text-emerald-400' },
|
{ label: 'Finalizadas', value: careQueue.filter((item) => isFinalizedStatus(item.status)).length, tone: 'text-emerald-400' },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -160,3 +160,21 @@ function PriorityPill({ priority }) {
|
|||||||
|
|
||||||
return <span className={`rounded px-2.5 py-1 text-xs font-bold ${className}`}>{priority}</span>
|
return <span className={`rounded px-2.5 py-1 text-xs font-bold ${className}`}>{priority}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFinalizedStatus(status) {
|
||||||
|
return normalizeStatus(status) === 'finalizada'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWaitingDoctorStatus(status) {
|
||||||
|
return normalizeStatus(status) === 'aguardando_medico'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(status) {
|
||||||
|
return String(status || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ export const patientRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getById(patientId) {
|
async getById(patientId) {
|
||||||
const [patients, appointments] = await Promise.all([
|
const [patient, appointments] = await Promise.all([
|
||||||
this.getAll(),
|
getPatientById(patientId),
|
||||||
getAppointments().catch(() => []),
|
getAppointments().catch(() => []),
|
||||||
])
|
])
|
||||||
const patient = patients.find((p) => String(p.id) === String(patientId)) || null
|
|
||||||
return patient ? mapPatientToDetail(patient, appointments) : null
|
return patient ? mapPatientToDetail(patient, appointments) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -126,6 +125,35 @@ export const patientRepository = {
|
|||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async uploadAvatar(patientId, file) {
|
||||||
|
if (!patientId) {
|
||||||
|
throw new Error('Não foi possível identificar o paciente para enviar o avatar.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = file.name?.split('.').pop() || 'jpg'
|
||||||
|
const objectPath = `patients/${patientId}/avatar.${extension}`
|
||||||
|
const avatarUrl = `${apiConfig.storageUrl}/object/avatars/${objectPath}`
|
||||||
|
const response = await fetch(avatarUrl, {
|
||||||
|
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 do paciente.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
await updatePatientAvatarUrl(patientId, avatarUrl).catch(() => null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarUrl,
|
||||||
|
path: objectPath,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 5. Deletar paciente
|
// 5. Deletar paciente
|
||||||
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}`, {
|
||||||
@@ -138,6 +166,20 @@ export const patientRepository = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getPatientById(patientId) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
select: '*',
|
||||||
|
id: `eq.${patientId}`,
|
||||||
|
limit: '1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`${apiConfig.restUrl}/patients?${query.toString()}`, { headers: getAuthenticatedHeaders() })
|
||||||
|
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao buscar paciente.'))
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return Array.isArray(data) ? data[0] || null : data
|
||||||
|
}
|
||||||
|
|
||||||
function mapPatientToDirectory(patient, appointments = []) {
|
function mapPatientToDirectory(patient, appointments = []) {
|
||||||
const appointmentSummary = summarizeAppointments(patient.id, appointments)
|
const appointmentSummary = summarizeAppointments(patient.id, appointments)
|
||||||
const city = getFirstValue(patient, ['city', 'cidade', 'address_city', 'municipio'], patient.address?.city)
|
const city = getFirstValue(patient, ['city', 'cidade', 'address_city', 'municipio'], patient.address?.city)
|
||||||
@@ -148,6 +190,7 @@ function mapPatientToDirectory(patient, appointments = []) {
|
|||||||
...patient,
|
...patient,
|
||||||
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
||||||
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
|
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
|
||||||
|
avatarUrl: normalizeAvatarUrl(patient.avatarUrl || patient.avatar_url || patient.avatar_path),
|
||||||
detailId: patient.id,
|
detailId: patient.id,
|
||||||
insurance: normalizeInsurance(insurance),
|
insurance: normalizeInsurance(insurance),
|
||||||
city,
|
city,
|
||||||
@@ -183,6 +226,7 @@ function mapPatientToDetail(patient, appointments = []) {
|
|||||||
status: patient.status || 'Acompanhamento',
|
status: patient.status || 'Acompanhamento',
|
||||||
risk: patient.risk || patient.risco || 'Baixo',
|
risk: patient.risk || patient.risco || 'Baixo',
|
||||||
email: patient.email || '',
|
email: patient.email || '',
|
||||||
|
avatarUrl: directory.avatarUrl,
|
||||||
address: formatAddress(directory) || patient.address || patient.endereco || 'Endereço não informado',
|
address: formatAddress(directory) || patient.address || patient.endereco || 'Endereço não informado',
|
||||||
team: patient.team || patient.equipe || [],
|
team: patient.team || patient.equipe || [],
|
||||||
notes: normalizeNotes(patient.notes || patient.observacoes || directory.notesText),
|
notes: normalizeNotes(patient.notes || patient.observacoes || directory.notesText),
|
||||||
@@ -332,6 +376,25 @@ function normalizeInsurance(value) {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAvatarUrl(value) {
|
||||||
|
const avatar = String(value || '').trim()
|
||||||
|
if (!avatar) return ''
|
||||||
|
if (/^https?:\/\//i.test(avatar)) return avatar
|
||||||
|
return `${apiConfig.storageUrl}/object/avatars/${avatar.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePatientAvatarUrl(patientId, avatarUrl) {
|
||||||
|
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: getAuthenticatedHeaders({ Prefer: 'return=minimal' }),
|
||||||
|
body: JSON.stringify({ avatar_url: avatarUrl }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao salvar avatar do paciente.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function calculateAge(birthDate) {
|
function calculateAge(birthDate) {
|
||||||
if (!birthDate) return 0
|
if (!birthDate) return 0
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export const profileRepository = {
|
|||||||
profile?.avatarUrl ||
|
profile?.avatarUrl ||
|
||||||
user?.avatarUrl ||
|
user?.avatarUrl ||
|
||||||
user?.avatar_url ||
|
user?.avatar_url ||
|
||||||
|
profile?.avatar_path ||
|
||||||
|
user?.avatar_path ||
|
||||||
meta.avatar_url ||
|
meta.avatar_url ||
|
||||||
|
meta.avatar_path ||
|
||||||
meta.picture ||
|
meta.picture ||
|
||||||
''
|
''
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ export const profileRepository = {
|
|||||||
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
||||||
role: ROLE_LABELS[normalizedRole] || user?.role || user?.cargo || meta.role || meta.cargo || 'Usuário do Sistema',
|
role: ROLE_LABELS[normalizedRole] || user?.role || user?.cargo || meta.role || meta.cargo || 'Usuário do Sistema',
|
||||||
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clínica Boa Vista',
|
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clínica Boa Vista',
|
||||||
avatarUrl,
|
avatarUrl: getAvatarUrl(avatarUrl),
|
||||||
doctorId: data?.doctor_id || data?.doctorId || null,
|
doctorId: data?.doctor_id || data?.doctorId || null,
|
||||||
patientId: data?.patient_id || data?.patientId || null,
|
patientId: data?.patient_id || data?.patientId || null,
|
||||||
roles,
|
roles,
|
||||||
@@ -80,7 +83,7 @@ export const profileRepository = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
avatarUrl: `${apiConfig.storageUrl}/object/public/avatars/${objectPath}`,
|
avatarUrl: getAvatarUrl(objectPath),
|
||||||
path: objectPath,
|
path: objectPath,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -105,12 +108,20 @@ export const profileRepository = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAvatarResponse(data) {
|
function normalizeAvatarResponse(data) {
|
||||||
|
const path = data.path || data.key || ''
|
||||||
return {
|
return {
|
||||||
avatarUrl: data.avatarUrl || data.avatar_url || data.publicUrl || data.public_url || data.url || '',
|
avatarUrl: data.avatarUrl || data.avatar_url || data.publicUrl || data.public_url || data.url || getAvatarUrl(path),
|
||||||
path: data.path || data.key || '',
|
path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAvatarUrl(path) {
|
||||||
|
const objectPath = String(path || '').replace(/^\/+/, '')
|
||||||
|
if (!objectPath) return ''
|
||||||
|
if (/^https?:\/\//i.test(objectPath)) return objectPath
|
||||||
|
return `${apiConfig.storageUrl}/object/avatars/${objectPath}`
|
||||||
|
}
|
||||||
|
|
||||||
function collectRoles({ data, meta, profile, user }) {
|
function collectRoles({ data, meta, profile, user }) {
|
||||||
return [
|
return [
|
||||||
...(Array.isArray(data?.roles) ? data.roles : []),
|
...(Array.isArray(data?.roles) ? data.roles : []),
|
||||||
|
|||||||
@@ -78,6 +78,42 @@ export const userRepository = {
|
|||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async update(userId, data) {
|
||||||
|
let lastResponse = null
|
||||||
|
const body = cleanPayload({
|
||||||
|
email: data.email?.trim(),
|
||||||
|
full_name: data.full_name?.trim(),
|
||||||
|
phone: data.phone?.trim(),
|
||||||
|
phone_mobile: data.phone?.trim(),
|
||||||
|
cpf: data.cpf?.trim(),
|
||||||
|
role: data.role,
|
||||||
|
crm: data.crm?.trim(),
|
||||||
|
crm_uf: data.crm_uf?.trim() || data.crmUf?.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const table of USER_PROFILE_TABLES) {
|
||||||
|
const response = await fetch(`${apiConfig.restUrl}/${table}?id=eq.${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (!response) continue
|
||||||
|
lastResponse = response
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
return normalizeListedUser(normalizeCollection(data)[0] || data || body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (![404, 406].includes(response.status)) {
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao atualizar usuário.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(await getResponseError(lastResponse, 'Tabela de perfis de usuários não encontrada.'))
|
||||||
|
},
|
||||||
|
|
||||||
async remove(userId) {
|
async remove(userId) {
|
||||||
const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, {
|
const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -100,6 +136,8 @@ function buildCreateUserBody(data) {
|
|||||||
phone: data.phone?.trim(),
|
phone: data.phone?.trim(),
|
||||||
cpf: data.cpf?.trim(),
|
cpf: data.cpf?.trim(),
|
||||||
role: data.role,
|
role: data.role,
|
||||||
|
crm: data.crm?.trim(),
|
||||||
|
crm_uf: data.crm_uf?.trim() || data.crmUf?.trim(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.create_patient_record) {
|
if (data.create_patient_record) {
|
||||||
@@ -116,5 +154,13 @@ function normalizeListedUser(user) {
|
|||||||
email: user.email || user.user_email || '',
|
email: user.email || user.user_email || '',
|
||||||
full_name: user.full_name || user.name || user.nome || '',
|
full_name: user.full_name || user.name || user.nome || '',
|
||||||
role: Array.isArray(user.roles) ? user.roles[0] : (user.role || user.cargo || ''),
|
role: Array.isArray(user.roles) ? user.roles[0] : (user.role || user.cargo || ''),
|
||||||
|
crm: user.crm || '',
|
||||||
|
crm_uf: user.crm_uf || user.crmUf || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanPayload(payload) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== ''),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
11
test.mjs
11
test.mjs
@@ -1,11 +0,0 @@
|
|||||||
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
11
test2.mjs
@@ -1,11 +0,0 @@
|
|||||||
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
14
test3.mjs
@@ -1,14 +0,0 @@
|
|||||||
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
13
test4.mjs
@@ -1,13 +0,0 @@
|
|||||||
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
11
test5.mjs
@@ -1,11 +0,0 @@
|
|||||||
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);
|
|
||||||
60
tests/mappers.test.mjs
Normal file
60
tests/mappers.test.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { appointmentMapper } from '../src/mappers/appointmentMapper.js'
|
||||||
|
import { reportMapper } from '../src/mappers/reportMapper.js'
|
||||||
|
|
||||||
|
test('appointmentMapper envia valores aceitos pela API Supabase', () => {
|
||||||
|
const payload = appointmentMapper.toApi(
|
||||||
|
{
|
||||||
|
patientId: 'patient-1',
|
||||||
|
professionalId: 'doctor-1',
|
||||||
|
date: '2026-05-11',
|
||||||
|
time: '10:30',
|
||||||
|
mode: 'Teleconsulta',
|
||||||
|
status: 'Em triagem',
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
|
'supabase',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(payload.patient_id, 'patient-1')
|
||||||
|
assert.equal(payload.doctor_id, 'doctor-1')
|
||||||
|
assert.equal(payload.appointment_type, 'telemedicina')
|
||||||
|
assert.equal(payload.status, 'checked_in')
|
||||||
|
assert.equal(payload.duration_minutes, 30)
|
||||||
|
assert.equal('notes' in payload, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('appointmentMapper converte resposta da API para labels da agenda', () => {
|
||||||
|
const appointment = appointmentMapper.toUi({
|
||||||
|
id: 'appt-1',
|
||||||
|
status: 'confirmed',
|
||||||
|
appointment_type: 'telemedicina',
|
||||||
|
scheduled_at: '2026-05-11T13:30:00.000Z',
|
||||||
|
patients: { id: 'patient-1', full_name: 'Ana Souza' },
|
||||||
|
doctors: { id: 'doctor-1', full_name: 'Dra. Leticia' },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(appointment.id, 'appt-1')
|
||||||
|
assert.equal(appointment.status, 'Confirmada')
|
||||||
|
assert.equal(appointment.mode, 'Teleconsulta')
|
||||||
|
assert.equal(appointment.patient, 'Ana Souza')
|
||||||
|
assert.equal(appointment.professional, 'Dra. Leticia')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reportMapper remove campos vazios e normaliza status', () => {
|
||||||
|
const payload = reportMapper.toApi({
|
||||||
|
patientId: 'patient-1',
|
||||||
|
status: 'finalized',
|
||||||
|
exam: '',
|
||||||
|
requestedBy: 'Dra. Leticia',
|
||||||
|
contentHtml: '<p>Conclusao clinica</p>',
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(payload.patient_id, 'patient-1')
|
||||||
|
assert.equal(payload.status, 'finalized')
|
||||||
|
assert.equal(payload.requested_by, 'Dra. Leticia')
|
||||||
|
assert.equal(payload.content_html, '<p>Conclusao clinica</p>')
|
||||||
|
assert.equal('exam' in payload, false)
|
||||||
|
})
|
||||||
59
tests/patientRepository.test.mjs
Normal file
59
tests/patientRepository.test.mjs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
process.env.VITE_SUPABASE_URL = 'https://example.supabase.co'
|
||||||
|
process.env.VITE_SUPABASE_ANON_KEY = 'anon-key'
|
||||||
|
|
||||||
|
globalThis.Event = class Event {
|
||||||
|
constructor(type) {
|
||||||
|
this.type = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.window = {
|
||||||
|
dispatchEvent() {},
|
||||||
|
sessionStorage: {
|
||||||
|
getItem() {
|
||||||
|
return JSON.stringify({
|
||||||
|
access_token: 'access-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeItem() {},
|
||||||
|
setItem() {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
test('patientRepository.getById busca o paciente direto por id', async () => {
|
||||||
|
const calls = []
|
||||||
|
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
const requestUrl = String(url)
|
||||||
|
calls.push(requestUrl)
|
||||||
|
|
||||||
|
if (requestUrl.includes('/patients?')) {
|
||||||
|
return Response.json([
|
||||||
|
{
|
||||||
|
id: 'patient-1',
|
||||||
|
full_name: 'Ana Souza',
|
||||||
|
cpf: '12345678900',
|
||||||
|
birth_date: '1990-01-01',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestUrl.includes('/appointments?')) {
|
||||||
|
return Response.json([])
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`URL inesperada: ${requestUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { patientRepository } = await import('../src/repositories/patientRepository.js')
|
||||||
|
const patient = await patientRepository.getById('patient-1')
|
||||||
|
|
||||||
|
assert.equal(patient.id, 'patient-1')
|
||||||
|
assert.equal(patient.name, 'Ana Souza')
|
||||||
|
assert.ok(calls.some((url) => url.includes('/patients?') && url.includes('id=eq.patient-1')))
|
||||||
|
assert.ok(calls.every((url) => !url.includes('/patients?select=*') || url.includes('id=eq.patient-1')))
|
||||||
|
})
|
||||||
30
tests/permissions.test.mjs
Normal file
30
tests/permissions.test.mjs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { canAccess, hasCapability, normalizeRole } from '../src/config/permissions.js'
|
||||||
|
|
||||||
|
test('normaliza aliases de perfis conhecidos', () => {
|
||||||
|
assert.equal(normalizeRole('doctor'), 'medico')
|
||||||
|
assert.equal(normalizeRole('Gestao / Coordenacao'), 'gestor')
|
||||||
|
assert.equal(normalizeRole('administrator'), 'admin')
|
||||||
|
assert.equal(normalizeRole('secretary'), 'secretaria')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('medico acessa pacientes e prontuario, mas nao painel ou analytics', () => {
|
||||||
|
assert.equal(canAccess('medico', '/pacientes'), true)
|
||||||
|
assert.equal(canAccess('medico', '/prontuario/123'), true)
|
||||||
|
assert.equal(canAccess('medico', '/inicio'), false)
|
||||||
|
assert.equal(canAccess('medico', '/relatorios'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('secretaria acessa agenda e pacientes, mas nao painel', () => {
|
||||||
|
assert.equal(canAccess('secretaria', '/agenda'), true)
|
||||||
|
assert.equal(canAccess('secretaria', '/pacientes'), true)
|
||||||
|
assert.equal(canAccess('secretaria', '/inicio'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('roles administrativos mantem capacidades criticas', () => {
|
||||||
|
assert.equal(hasCapability('admin', 'manageUsers'), true)
|
||||||
|
assert.equal(hasCapability('gestor', 'hardDeletePatients'), true)
|
||||||
|
assert.equal(hasCapability('medico', 'hardDeletePatients'), false)
|
||||||
|
})
|
||||||
40
tests/repositoryUtils.test.mjs
Normal file
40
tests/repositoryUtils.test.mjs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { getResponseError, translateErrorMessage } from '../src/repositories/repositoryUtils.js'
|
||||||
|
|
||||||
|
test('traduz erros crus comuns do Supabase para pt-BR', () => {
|
||||||
|
assert.equal(translateErrorMessage('Invalid login credentials'), 'E-mail ou senha inválidos.')
|
||||||
|
assert.equal(
|
||||||
|
translateErrorMessage('new row violates row-level security policy for table "patients"'),
|
||||||
|
'Você não tem permissão para realizar esta ação.',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
translateErrorMessage('invalid input value for enum appointment_type: "teleconsulta"'),
|
||||||
|
'Valor inválido para uma opção do sistema.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getResponseError preserva erros estruturados em portugues da API', async () => {
|
||||||
|
const response = new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
title: 'Erro de Validacao',
|
||||||
|
errors: {
|
||||||
|
cpf: ['Campo obrigatorio'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const message = await getResponseError(response, 'Erro ao criar usuario.')
|
||||||
|
|
||||||
|
assert.match(message, /Erro ao criar usuario\. \(400\):/)
|
||||||
|
assert.match(message, /cpf: Campo obrigatorio/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getResponseError usa fallback em ingles desconhecido', async () => {
|
||||||
|
const response = new Response('Something went wrong in backend', { status: 500 })
|
||||||
|
const message = await getResponseError(response, 'Falha ao salvar registro.')
|
||||||
|
|
||||||
|
assert.equal(message, 'Falha ao salvar registro. (500): Falha ao salvar registro.')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user