forked from RiseUP/riseup_squad_03
Compare commits
9 Commits
bc900fbdd4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bcaee47ba9 | |||
| bd337349e1 | |||
| e77077f5fc | |||
| 8f0e616d2b | |||
| 04a13c24d3 | |||
| 307ad9431b | |||
| fba021e048 | |||
| bcee06b908 | |||
| 94dab58d85 |
@@ -1,6 +1,8 @@
|
||||
# copiar e colar para o seu .env.local
|
||||
|
||||
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=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
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
projeto-figma
|
||||
|
||||
# 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,49 +1,73 @@
|
||||
# Auditoria de Implementacao e Mapeamento da API
|
||||
|
||||
Este documento resume o estado atual da integracao entre o front-end e os endpoints da API.
|
||||
Este documento resume as APIs do Apidog conectadas no projeto `riseup_squad_03`.
|
||||
|
||||
## Integrado no front
|
||||
## Autenticacao
|
||||
|
||||
- **Autenticacao**
|
||||
- Login com email e senha via Supabase Auth (`/auth/v1/token`).
|
||||
- Solicitar reset de senha: tenta `/solicitar-reset-de-senha` e usa `/auth/v1/recover` como fallback.
|
||||
- Dados do usuario autenticado: tenta `/informacoes-do-usuario-autenticado` e usa `/auth/v1/user` como fallback.
|
||||
- Logout: tenta `/logout`, usa `/auth/v1/logout` como fallback e sempre limpa a sessao local.
|
||||
- `POST /auth/v1/token?grant_type=password` em `authRepository.login`
|
||||
- `POST /auth/v1/otp` em `authRepository.sendMagicLink`
|
||||
- `POST /functions/v1/request-password-reset` em `authRepository.requestPasswordReset`
|
||||
- `GET /auth/v1/user` em `authRepository.getUser` como fallback
|
||||
- `POST /functions/v1/user-info` em `authRepository.getUser`
|
||||
- `POST /auth/v1/logout` em `authRepository.logout`
|
||||
|
||||
- **Pacientes**
|
||||
- Listar, criar, atualizar e deletar pacientes via Supabase REST.
|
||||
- Criar paciente com validacao via Edge Function quando disponivel.
|
||||
## Usuarios
|
||||
|
||||
- **Agendamentos**
|
||||
- Listar agendamentos: tenta `GET /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||
- Criar agendamento: tenta `POST /agendamentos` e usa Supabase REST `appointments` como fallback.
|
||||
- `POST /functions/v1/create-user` em `userRepository.create`
|
||||
- `POST /functions/v1/create-user-with-password` em `userRepository.createWithPassword`
|
||||
- `POST /functions/v1/user-info-by-id/:id` em `userRepository.getById`
|
||||
- `POST /functions/v1/delete-user` em `userRepository.remove`
|
||||
- Listagem de perfis via REST em `profiles` / `user_profiles` em `userRepository.getAll`
|
||||
|
||||
- **Laudos Medicos**
|
||||
- Listar relatorios: tenta `GET /reports` e usa Supabase REST `reports` como fallback.
|
||||
- Criar relatorio: tenta `POST /reports` e usa Supabase REST `reports` como fallback.
|
||||
- Atualizar relatorio: tenta `PATCH /reports/{id}`, depois `PATCH /reports`, e usa Supabase REST `reports` como fallback.
|
||||
## Pacientes
|
||||
|
||||
- **Medicos / Profissionais**
|
||||
- Listar medicos: tenta `GET /listar-medicos` e usa Supabase REST `doctors` como fallback.
|
||||
- `GET /rest/v1/patients` em `patientRepository.getAll`
|
||||
- `POST /rest/v1/patients` em `patientRepository.create`
|
||||
- `PATCH /rest/v1/patients?id=eq.ID` em `patientRepository.update`
|
||||
- `DELETE /rest/v1/patients?id=eq.ID` em `patientRepository.remove`
|
||||
- `POST /functions/v1/create-patient` em `patientRepository.createWithValidation`
|
||||
- `POST /functions/v1/register-patient` em `patientRepository.registerPublic`
|
||||
|
||||
- **Mensageria**
|
||||
- Enviar SMS: tenta `POST /enviar-sms-via-twilio` e usa Edge Function `send-sms` como fallback.
|
||||
- O formulario agora coleta telefone quando o canal selecionado e SMS.
|
||||
## Medicos
|
||||
|
||||
- **Storage**
|
||||
- Upload de avatar: tenta `/upload-avatar` e usa Supabase Storage no bucket `avatars` como fallback.
|
||||
- A tela de perfil atualiza a imagem exibida apos upload bem-sucedido.
|
||||
- `GET /rest/v1/doctors` em `professionalRepository.getAll`
|
||||
- `POST /functions/v1/create-doctor` em `professionalRepository.create`
|
||||
|
||||
## Ainda sem endpoint consolidado documentado
|
||||
## Agendamentos
|
||||
|
||||
- Dashboard / Inicio (`HomePage` / `homeRepository.js`).
|
||||
- Estatisticas e BI (`AnalyticsPage` / `analyticsRepository.js`).
|
||||
- Prontuarios especificos separados de laudos (`MedicalRecordsPage` / `medicalRecordRepository.js`).
|
||||
- Consultas isoladas fora de agendamento (`VisitsPage` / `visitRepository.js`).
|
||||
- Configuracoes gerais do tenant (`SettingsPage` / `settingsRepository.js`).
|
||||
- `GET /rest/v1/appointments` em `appointmentRepository.getAll`
|
||||
- `POST /rest/v1/appointments` em `appointmentRepository.create`
|
||||
- `PATCH /rest/v1/appointments?id=eq.ID` em `appointmentRepository.update`
|
||||
- Cancelamento via `PATCH /rest/v1/appointments?id=eq.ID` em `appointmentRepository.cancel`
|
||||
|
||||
## Disponibilidade e Slots
|
||||
|
||||
- `GET /rest/v1/doctor_availability` em `availabilityRepository.getAll`
|
||||
- `POST /rest/v1/doctor_availability` em `availabilityRepository.create`
|
||||
- `PATCH /rest/v1/doctor_availability?id=eq.ID` em `availabilityRepository.update`
|
||||
- `DELETE /rest/v1/doctor_availability?id=eq.ID` em `availabilityRepository.remove`
|
||||
- `GET /rest/v1/doctor_exceptions` em `availabilityRepository.getExceptions`
|
||||
- `POST /rest/v1/doctor_exceptions` em `availabilityRepository.createException`
|
||||
- `POST /functions/v1/get-available-slots` em `availabilityRepository.getAvailableSlots`
|
||||
|
||||
## Reports / Laudos Medicos
|
||||
|
||||
- `GET /rest/v1/reports` em `reportRepository.getInitialReports`
|
||||
- `POST /rest/v1/reports` em `reportRepository.create`
|
||||
- `PATCH /rest/v1/reports?id=eq.ID` em `reportRepository.update`
|
||||
|
||||
## SMS / Comunicacao
|
||||
|
||||
- `POST /functions/v1/send-sms` em `communicationRepository.sendSms`
|
||||
|
||||
## Storage
|
||||
|
||||
- `POST /storage/v1/object/avatars/{path}` em `profileRepository.updateAvatar`
|
||||
- `GET /storage/v1/object/avatars/{path}` em `profileRepository.downloadAvatar`
|
||||
|
||||
## Observacoes
|
||||
|
||||
- `VITE_API_BASE_URL` define a base dos endpoints nomeados da API. Quando nao informado, o front usa `VITE_SUPABASE_FUNCTIONS_URL`.
|
||||
- Os reposititorios aceitam formatos de resposta comuns como arrays diretos ou objetos com chaves `data`, `reports`, `agendamentos`, `medicos` etc.
|
||||
- Os fallbacks existem para manter o front funcional em ambientes onde parte das Edge Functions ainda nao foi publicada.
|
||||
- O Supabase real responde as rotas REST em `/rest/v1/...`.
|
||||
- As Edge Functions reais respondem em `/functions/v1/...`.
|
||||
- Algumas rotas curtas do Apidog retornam `404` no ambiente real; o codigo usa o caminho que respondeu em producao.
|
||||
- `Schemas` no Apidog nao sao endpoints executaveis, apenas contratos de dados.
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
react.configs.flat['jsx-runtime'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'react/jsx-uses-vars': 'error',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
2613
package-lock.json
generated
2613
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -4,12 +4,18 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test \"tests/*.test.mjs\"",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-text-align": "^3.23.1",
|
||||
"@tiptap/extension-underline": "^3.23.1",
|
||||
"@tiptap/pm": "^3.23.1",
|
||||
"@tiptap/react": "^3.23.1",
|
||||
"@tiptap/starter-kit": "^3.23.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
@@ -22,6 +28,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
|
||||
10
public/favicon.svg
Normal file
10
public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<rect width="48" height="48" rx="8" fill="#3b82f6"/>
|
||||
<g fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4">
|
||||
<path d="M22 7v5"/>
|
||||
<path d="M12 7v5"/>
|
||||
<path d="M12 9h-2a4 4 0 0 0-4 4v8a12 12 0 0 0 24 0v-8a4 4 0 0 0-4-4h-2"/>
|
||||
<path d="M18 34a12 12 0 0 0 24 0v-6"/>
|
||||
<circle cx="42" cy="24" r="4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
@@ -1,6 +0,0 @@
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
119
src/App.jsx
119
src/App.jsx
@@ -1,30 +1,35 @@
|
||||
import { 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 { canAccess } from './config/permissions.js'
|
||||
import { useAuth } from './hooks/useAuth.js'
|
||||
import { AgendaPage } from './pages/AgendaPage.jsx'
|
||||
import { AnalyticsPage } from './pages/AnalyticsPage.jsx'
|
||||
import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx'
|
||||
import { HomePage } from './pages/HomePage.jsx'
|
||||
import { MedicalRecordsPage } from './pages/MedicalRecordsPage.jsx'
|
||||
import { MessagesPage } from './pages/MessagesPage.jsx'
|
||||
import { NotFoundPage } from './pages/NotFoundPage.jsx'
|
||||
import { PatientDetailPage, PatientsPage } from './pages/PatientsPage.jsx'
|
||||
import { ProfilePage } from './pages/ProfilePage.jsx'
|
||||
import { ReportsPage } from './pages/ReportsPage.jsx'
|
||||
import { SettingsPage } from './pages/SettingsPage.jsx'
|
||||
import { UsersPage } from './pages/UsersPage.jsx'
|
||||
import { VisitsPage } from './pages/VisitsPage.jsx'
|
||||
import { patientRepository } from './repositories/patientRepository.js'
|
||||
|
||||
const AgendaPage = lazyPage(() => import('./pages/AgendaPage.jsx'), 'AgendaPage')
|
||||
const AnalyticsPage = lazyPage(() => import('./pages/AnalyticsPage.jsx'), 'AnalyticsPage')
|
||||
const HomePage = lazyPage(() => import('./pages/HomePage.jsx'), 'HomePage')
|
||||
const MedicalRecordsPage = lazyPage(() => import('./pages/MedicalRecordsPage.jsx'), 'MedicalRecordsPage')
|
||||
const MessagesPage = lazyPage(() => import('./pages/MessagesPage.jsx'), 'MessagesPage')
|
||||
const PatientDetailPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientDetailPage')
|
||||
const PatientsPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientsPage')
|
||||
const ProfilePage = lazyPage(() => import('./pages/ProfilePage.jsx'), 'ProfilePage')
|
||||
const ReportsPage = lazyPage(() => import('./pages/ReportsPage.jsx'), 'ReportsPage')
|
||||
const SettingsPage = lazyPage(() => import('./pages/SettingsPage.jsx'), 'SettingsPage')
|
||||
const UsersPage = lazyPage(() => import('./pages/UsersPage.jsx'), 'UsersPage')
|
||||
const VisitsPage = lazyPage(() => import('./pages/VisitsPage.jsx'), 'VisitsPage')
|
||||
|
||||
const PANEL_PATHS = ['/inicio', '/home', '/dashboard']
|
||||
const ROLE_HOME_PATHS = {
|
||||
medico: '/agenda',
|
||||
secretaria: '/agenda',
|
||||
}
|
||||
|
||||
function lazyPage(loader, exportName) {
|
||||
return lazy(() => loader().then((module) => ({ default: module[exportName] })))
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [location, setLocation] = useState(() => readLocation())
|
||||
const { isAuthenticated, role, loading: authLoading } = useAuth()
|
||||
@@ -72,7 +77,7 @@ function App() {
|
||||
|
||||
// Rotas públicas (sem shell)
|
||||
if (!route.withShell) {
|
||||
return route.element
|
||||
return <RouteSuspense resetKey={location.pathname}>{route.element}</RouteSuspense>
|
||||
}
|
||||
|
||||
// Usuário não autenticado
|
||||
@@ -97,11 +102,78 @@ function App() {
|
||||
|
||||
return (
|
||||
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
||||
{route.element}
|
||||
<RouteSuspense resetKey={location.pathname}>{route.element}</RouteSuspense>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<RouteErrorBoundary resetKey={resetKey}>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center px-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRoute(pathname, navigate, role) {
|
||||
if (pathname === '/' || pathname === '/login') {
|
||||
return {
|
||||
@@ -159,6 +231,23 @@ function resolveRoute(pathname, navigate, role) {
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/prontuario/novo') {
|
||||
return {
|
||||
element: <MedicalRecordsPage mode="new" navigate={navigate} />,
|
||||
title: 'Novo prontuário',
|
||||
withShell: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/prontuario/')) {
|
||||
const [, , recordId, action] = pathname.split('/')
|
||||
return {
|
||||
element: <MedicalRecordsPage mode={action === 'editar' ? 'edit' : 'detail'} navigate={navigate} recordId={recordId} />,
|
||||
title: action === 'editar' ? 'Editar prontuário' : 'Prontuário',
|
||||
withShell: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/pacientes/')) {
|
||||
const patientId = pathname.split('/')[2]
|
||||
return {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -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 |
@@ -43,6 +43,7 @@ const titles = {
|
||||
|
||||
export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false)
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
||||
@@ -139,6 +140,14 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
function toggleSidebarCollapsed() {
|
||||
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) {
|
||||
return
|
||||
}
|
||||
|
||||
setSidebarCollapsed((current) => !current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
|
||||
<a
|
||||
@@ -149,15 +158,19 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
</a>
|
||||
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-transform duration-200 lg:translate-x-0 ${
|
||||
className={`fixed inset-y-0 left-0 z-40 flex w-56 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-all duration-200 lg:translate-x-0 ${
|
||||
sidebarCollapsed ? 'lg:w-16' : 'lg:w-56'
|
||||
} ${
|
||||
menuOpen ? 'translate-x-0' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-16 items-center border-b border-[#404040] px-3">
|
||||
<div className={`flex h-16 items-center border-b border-[#404040] px-3 ${sidebarCollapsed ? 'lg:justify-center' : ''}`}>
|
||||
<BrandLogo
|
||||
iconClassName="size-8 rounded-sm"
|
||||
iconButtonLabel={sidebarCollapsed ? 'Expandir sidebar' : 'Recolher sidebar'}
|
||||
markClassName="size-5"
|
||||
textClassName="text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5]"
|
||||
onIconClick={toggleSidebarCollapsed}
|
||||
textClassName={`text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5] ${sidebarCollapsed ? 'lg:hidden' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -169,6 +182,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
item={item}
|
||||
key={`${item.label}-${item.href}`}
|
||||
onNavigate={goTo}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -176,12 +190,21 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
|
||||
<div className="p-3">
|
||||
<button
|
||||
className="w-full rounded-md border border-[#404040] bg-[#303030] px-3 py-2.5 text-left transition hover:border-[#525252] hover:bg-[#333333]"
|
||||
className={`w-full rounded-md border border-[#404040] bg-[#303030] text-left transition hover:border-[#525252] hover:bg-[#333333] ${
|
||||
sidebarCollapsed ? 'grid h-10 place-items-center px-0 py-0 lg:rounded-full' : 'px-3 py-2.5'
|
||||
}`}
|
||||
onClick={() => goTo('/perfil')}
|
||||
title={sidebarCollapsed ? `${viewerProfile.name} - ${viewerProfile.role}` : undefined}
|
||||
type="button"
|
||||
>
|
||||
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
|
||||
{sidebarCollapsed ? (
|
||||
<span className="text-xs font-bold text-[#3b82f6]">{getInitials(viewerProfile.name)}</span>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -195,7 +218,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="lg:pl-64">
|
||||
<div className={`transition-[padding] duration-200 ${sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-56'}`}>
|
||||
<header className="sticky top-0 z-20 h-auto border-b border-[#404040] bg-[#262626] px-4 py-3 md:px-8 lg:h-16 lg:py-0">
|
||||
<div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
@@ -236,7 +259,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-sm font-semibold text-[#e5e5e5]">Notificações</p>
|
||||
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
|
||||
<span className="feature-badge-mock rounded border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
|
||||
Mock
|
||||
</span>
|
||||
</div>
|
||||
@@ -346,10 +369,11 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({ active, item, onNavigate }) {
|
||||
function NavItem({ active, item, onNavigate, sidebarCollapsed = false }) {
|
||||
return (
|
||||
<a
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={sidebarCollapsed ? item.label : undefined}
|
||||
className={`flex h-9 items-center gap-3 rounded-sm px-2 text-sm font-medium transition ${
|
||||
active ? 'bg-[#3b82f6]/10 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
@@ -358,9 +382,10 @@ function NavItem({ active, item, onNavigate }) {
|
||||
event.preventDefault()
|
||||
onNavigate(item.href)
|
||||
}}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
>
|
||||
<AppIcon className="size-5 shrink-0" name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
<AppIcon className={`size-5 shrink-0 ${sidebarCollapsed ? 'lg:mx-auto' : ''}`} name={item.icon} />
|
||||
<span className={sidebarCollapsed ? 'lg:hidden' : ''}>{item.label}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
export function BrandLogo({
|
||||
className = '',
|
||||
iconClassName = 'size-10 rounded-[6px]',
|
||||
iconButtonLabel = 'MediConnect',
|
||||
markClassName = 'size-6',
|
||||
onIconClick,
|
||||
textClassName = 'text-2xl font-bold leading-8 tracking-[-0.025em] text-white',
|
||||
}) {
|
||||
const icon = (
|
||||
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
|
||||
<StethoscopeIcon className={markClassName} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
|
||||
<StethoscopeIcon className={markClassName} />
|
||||
</div>
|
||||
{onIconClick ? (
|
||||
<button
|
||||
aria-label={iconButtonLabel}
|
||||
className="shrink-0 rounded-sm transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-[#3b82f6]/50"
|
||||
onClick={onIconClick}
|
||||
title={iconButtonLabel}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<p className={textClassName}>MediConnect</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
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,18 +1,23 @@
|
||||
import React from 'react'
|
||||
import { format, isToday } from 'date-fns'
|
||||
import { ptBR } from 'date-fns/locale'
|
||||
|
||||
import { sortAppointmentsByTime } from '../../utils/agendaDate.js'
|
||||
|
||||
export function AgendaDailyView({ baseDate, appointments, onAppointmentClick }) {
|
||||
const DAY_START = '07:00'
|
||||
const DAY_END = '19:00'
|
||||
const SLOT_MINUTES = 30
|
||||
|
||||
export function AgendaDailyView({ baseDate, appointments, canCreateAppointment = true, onAppointmentClick, onSlotCreate }) {
|
||||
const dailyAppointments = sortAppointmentsByTime(appointments)
|
||||
const appointmentsByTime = groupAppointmentsByTime(dailyAppointments)
|
||||
const slots = mergeSlotsWithAppointmentTimes(generateSlots(DAY_START, DAY_END, SLOT_MINUTES), dailyAppointments)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="agenda-calendar-header flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]">
|
||||
Vista ampliada do dia
|
||||
Grade de horários do dia
|
||||
</span>
|
||||
<h3 className="mt-2 text-xl font-bold text-[#e5e5e5]">
|
||||
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
|
||||
@@ -20,9 +25,15 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]">
|
||||
<span className="agenda-legend-pill rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]">
|
||||
{dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'}
|
||||
</span>
|
||||
<span className="agenda-legend-pill agenda-legend-free rounded-full border border-emerald-700/40 bg-emerald-950/30 px-3 py-1 text-xs font-semibold text-emerald-200 shadow-sm">
|
||||
Livre
|
||||
</span>
|
||||
<span className="agenda-legend-pill agenda-legend-booked rounded-full border border-red-700/40 bg-red-950/30 px-3 py-1 text-xs font-semibold text-red-200 shadow-sm">
|
||||
Agendado
|
||||
</span>
|
||||
{isToday(baseDate) && (
|
||||
<span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]">
|
||||
Hoje
|
||||
@@ -31,70 +42,134 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dailyAppointments.length === 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
|
||||
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
||||
Ajuste o filtro ou altere o período no calendário.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-3">
|
||||
{dailyAppointments.map((appointment) => (
|
||||
<div className="agenda-day-grid mt-4 grid gap-2">
|
||||
{slots.map((time) => {
|
||||
const slotAppointments = appointmentsByTime.get(time) || []
|
||||
const primaryAppointment = slotAppointments[0]
|
||||
const isBooked = Boolean(primaryAppointment)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={appointment.id}
|
||||
className={`grid gap-4 rounded-xl border p-4 md:grid-cols-[96px_1fr_auto] ${getStatusColors(appointment.status)}`}
|
||||
className={`agenda-slot ${isBooked ? getDailyToneClass(primaryAppointment.status) : 'agenda-slot-free'} grid gap-3 rounded-xl border px-4 py-3 shadow-[0_8px_18px_rgba(0,0,0,0.16)] md:grid-cols-[84px_1fr_auto] ${
|
||||
isBooked
|
||||
? 'border-red-700/50 bg-red-950/35 text-red-50'
|
||||
: 'border-emerald-700/50 bg-emerald-950/35 text-emerald-50'
|
||||
}`}
|
||||
key={time}
|
||||
>
|
||||
<div>
|
||||
<p className="text-2xl font-bold leading-none">{appointment.time || '--:--'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.14em] opacity-80">
|
||||
{appointment.mode}
|
||||
<p className="text-xl font-bold leading-none">{time}</p>
|
||||
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.12em] opacity-80">
|
||||
{isBooked ? 'Agendado' : 'Disponível'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className="text-left text-base font-bold transition hover:opacity-85"
|
||||
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
|
||||
type="button"
|
||||
>
|
||||
{appointment.patient}
|
||||
</button>
|
||||
<p className="mt-1 text-sm opacity-90">
|
||||
{appointment.type} com {appointment.professional}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium opacity-80">
|
||||
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.room}</span>
|
||||
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.type}</span>
|
||||
{isBooked ? (
|
||||
<div>
|
||||
<button
|
||||
className="text-left text-sm font-bold transition hover:opacity-85"
|
||||
onClick={() => onAppointmentClick?.(primaryAppointment)}
|
||||
type="button"
|
||||
>
|
||||
{primaryAppointment.patient}
|
||||
</button>
|
||||
<p className="mt-1 text-sm opacity-90">
|
||||
{primaryAppointment.type} com {primaryAppointment.professional}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs font-medium opacity-80">
|
||||
{primaryAppointment.room ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">{primaryAppointment.room}</span> : null}
|
||||
{primaryAppointment.mode ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">{primaryAppointment.mode}</span> : null}
|
||||
{slotAppointments.length > 1 ? <span className="agenda-slot-chip rounded-full bg-black/25 px-2.5 py-1 shadow-sm">+{slotAppointments.length - 1}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-sm font-medium opacity-90">
|
||||
Horário disponível para novo agendamento.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-start md:justify-end">
|
||||
<span className="rounded-full border border-current/20 bg-black/10 px-3 py-1 text-xs font-bold">
|
||||
{appointment.status}
|
||||
<div className="flex flex-wrap items-start justify-start gap-2 md:justify-end">
|
||||
<span className="agenda-slot-status rounded-full border border-current/30 bg-black/25 px-3 py-1 text-xs font-bold shadow-sm">
|
||||
{isBooked ? primaryAppointment.status : 'Livre'}
|
||||
</span>
|
||||
{canCreateAppointment ? (
|
||||
<button
|
||||
aria-label={`Criar agendamento às ${time}`}
|
||||
className="agenda-slot-add grid size-8 place-items-center rounded-full border border-current/30 bg-black/30 text-base font-bold leading-none shadow-sm transition hover:bg-black/45"
|
||||
onClick={() => onSlotCreate?.(time)}
|
||||
title={`Novo agendamento às ${time}`}
|
||||
type="button"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusColors(status) {
|
||||
function getDailyToneClass(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
return 'border-[#14532d] bg-[#052e1a] text-[#a7f3d0]'
|
||||
return 'agenda-slot-confirmed'
|
||||
case 'Em triagem':
|
||||
return 'border-[#78350f] bg-[#2d1e05] text-[#fde68a]'
|
||||
case 'Concluida':
|
||||
case 'Concluída':
|
||||
return 'border-[#1e3a8a] bg-[#172554] text-[#bfdbfe]'
|
||||
return 'agenda-slot-triage'
|
||||
case 'Cancelada':
|
||||
return 'border-[#7f1d1d] bg-[#450a0a] text-[#fecaca]'
|
||||
return 'agenda-slot-cancelled'
|
||||
case 'Bloqueado':
|
||||
return 'agenda-slot-blocked'
|
||||
case 'Aguardando':
|
||||
default:
|
||||
return 'border-[#404040] bg-[#1f1f1f] text-[#e5e5e5]'
|
||||
return 'agenda-slot-waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function generateSlots(start, end, intervalMinutes) {
|
||||
const [startHour, startMinute] = start.split(':').map(Number)
|
||||
const [endHour, endMinute] = end.split(':').map(Number)
|
||||
const slots = []
|
||||
let cursor = startHour * 60 + startMinute
|
||||
const last = endHour * 60 + endMinute
|
||||
|
||||
while (cursor < last) {
|
||||
slots.push(formatMinutes(cursor))
|
||||
cursor += intervalMinutes
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
function groupAppointmentsByTime(appointments) {
|
||||
return appointments.reduce((map, appointment) => {
|
||||
const time = normalizeTime(appointment.time)
|
||||
if (!time) return map
|
||||
map.set(time, [...(map.get(time) || []), appointment])
|
||||
return map
|
||||
}, new Map())
|
||||
}
|
||||
|
||||
function mergeSlotsWithAppointmentTimes(slots, appointments) {
|
||||
return [...new Set([...slots, ...appointments.map((appointment) => normalizeTime(appointment.time)).filter(Boolean)])]
|
||||
.sort((first, second) => minutesFromTime(first) - minutesFromTime(second))
|
||||
}
|
||||
|
||||
function normalizeTime(value) {
|
||||
const match = String(value || '').match(/^(\d{1,2}):(\d{2})/)
|
||||
if (!match) return ''
|
||||
return `${match[1].padStart(2, '0')}:${match[2]}`
|
||||
}
|
||||
|
||||
function minutesFromTime(value) {
|
||||
const [hours, minutes] = normalizeTime(value).split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
function formatMinutes(totalMinutes) {
|
||||
const hours = String(Math.floor(totalMinutes / 60)).padStart(2, '0')
|
||||
const minutes = String(totalMinutes % 60).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
@@ -23,8 +22,8 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
|
||||
<div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="agenda-calendar-header grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
|
||||
{weekDays.map((day) => (
|
||||
<div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]">
|
||||
{day}
|
||||
@@ -49,7 +48,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => onDayClick && onDayClick(day)}
|
||||
className={`flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${
|
||||
className={`agenda-month-day flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${
|
||||
isCurrentMonth
|
||||
? 'border-[#404040] bg-[#1f1f1f]'
|
||||
: 'border-transparent bg-transparent opacity-40 hover:opacity-80'
|
||||
@@ -69,7 +68,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
{dayAppointments.slice(0, 3).map((appointment) => (
|
||||
<div
|
||||
key={appointment.id}
|
||||
className="flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]"
|
||||
className={`agenda-month-event ${getStatusToneClass(appointment.status)} flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${getDotColor(appointment.status)}`} />
|
||||
<span className="truncate">
|
||||
@@ -91,6 +90,22 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusToneClass(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
return 'agenda-event-confirmed'
|
||||
case 'Em triagem':
|
||||
return 'agenda-event-triage'
|
||||
case 'Cancelada':
|
||||
return 'agenda-event-cancelled'
|
||||
case 'Bloqueado':
|
||||
return 'agenda-event-blocked'
|
||||
case 'Aguardando':
|
||||
default:
|
||||
return 'agenda-event-waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function getDotColor(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
@@ -26,8 +25,8 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
|
||||
<div className="agenda-calendar-shell rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||
<div className="agenda-calendar-header grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
|
||||
{days.map((day) => {
|
||||
const isWeekend = day.getDay() === 0
|
||||
|
||||
@@ -60,7 +59,7 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="flex h-full flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2"
|
||||
className="agenda-week-day flex h-full min-w-0 flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2"
|
||||
>
|
||||
{dayAppointments.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
@@ -71,21 +70,21 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
<button
|
||||
key={appointment.id}
|
||||
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
|
||||
className={`flex w-full flex-col items-start rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`}
|
||||
className={`agenda-event ${getStatusToneClass(appointment.status)} flex w-full min-w-0 flex-col items-start overflow-hidden rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="rounded bg-black/20 px-1.5 py-0.5 text-xs font-bold leading-none">
|
||||
<div className="mb-1 flex w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="shrink-0 rounded bg-black/20 px-1.5 py-0.5 text-[10px] font-bold leading-none">
|
||||
{appointment.time}
|
||||
</span>
|
||||
<span className="truncate text-[10px] font-semibold uppercase tracking-wider opacity-80">
|
||||
<span className="min-w-0 flex-1 truncate text-[9px] font-semibold uppercase tracking-normal opacity-80">
|
||||
{appointment.mode}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full truncate text-xs font-bold leading-tight" title={appointment.patient}>
|
||||
<span className="block w-full min-w-0 truncate text-xs font-bold leading-tight" title={appointment.patient}>
|
||||
{appointment.patient}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 w-full truncate text-[10px] font-medium opacity-80"
|
||||
className="mt-0.5 block w-full min-w-0 truncate text-[10px] font-medium opacity-80"
|
||||
title={appointment.professional}
|
||||
>
|
||||
Dr(a). {appointment.professional?.split(' ')[0]}
|
||||
@@ -101,6 +100,25 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusToneClass(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
return 'agenda-event-confirmed'
|
||||
case 'Em triagem':
|
||||
return 'agenda-event-triage'
|
||||
case 'Concluida':
|
||||
case 'Concluída':
|
||||
return 'agenda-event-finished'
|
||||
case 'Cancelada':
|
||||
return 'agenda-event-cancelled'
|
||||
case 'Bloqueado':
|
||||
return 'agenda-event-blocked'
|
||||
case 'Aguardando':
|
||||
default:
|
||||
return 'agenda-event-waiting'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColors(status) {
|
||||
switch (status) {
|
||||
case 'Confirmada':
|
||||
|
||||
@@ -6,21 +6,21 @@ export const featureStateStyles = {
|
||||
label: '',
|
||||
},
|
||||
partial: {
|
||||
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300',
|
||||
panel: 'border-sky-500/35 bg-sky-500/8',
|
||||
title: 'text-sky-300',
|
||||
badge: 'feature-badge-partial border-sky-500/40 bg-sky-500/15 text-sky-300',
|
||||
panel: 'feature-panel-partial border-sky-500/35 bg-sky-500/8',
|
||||
title: 'feature-title-partial text-sky-300',
|
||||
label: 'Parcial',
|
||||
},
|
||||
mock: {
|
||||
badge: 'border-amber-500/40 bg-amber-500/15 text-amber-300',
|
||||
panel: 'border-amber-500/35 bg-amber-500/8',
|
||||
title: 'text-amber-300',
|
||||
badge: 'feature-badge-mock border-amber-500/40 bg-amber-500/15 text-amber-300',
|
||||
panel: 'feature-panel-mock border-amber-500/35 bg-amber-500/8',
|
||||
title: 'feature-title-mock text-amber-300',
|
||||
label: 'Mockado',
|
||||
},
|
||||
wip: {
|
||||
badge: 'border-rose-500/40 bg-rose-500/15 text-rose-300',
|
||||
panel: 'border-rose-500/35 bg-rose-500/8',
|
||||
title: 'text-rose-300',
|
||||
badge: 'feature-badge-wip border-rose-500/40 bg-rose-500/15 text-rose-300',
|
||||
panel: 'feature-panel-wip border-rose-500/35 bg-rose-500/8',
|
||||
title: 'feature-title-wip text-rose-300',
|
||||
label: 'WIP',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
primary:
|
||||
'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',
|
||||
}
|
||||
|
||||
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({
|
||||
children,
|
||||
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 }) {
|
||||
return (
|
||||
<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 (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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>
|
||||
<div className="block">
|
||||
<span className={appLabelClass}>{label}</span>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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 SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
||||
const env = import.meta.env ?? {}
|
||||
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'
|
||||
export const AUTH_SESSION_CHANGED_EVENT = 'mediconnect:auth-session-changed'
|
||||
|
||||
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,
|
||||
restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`,
|
||||
functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
||||
storageUrl: import.meta.env.VITE_SUPABASE_STORAGE_URL || `${SUPABASE_URL}/storage/v1`,
|
||||
restUrl: SUPABASE_REST_URL || joinUrl(SUPABASE_URL, '/rest/v1'),
|
||||
functionsUrl: SUPABASE_FUNCTIONS_URL || joinUrl(SUPABASE_URL, '/functions/v1'),
|
||||
storageUrl: SUPABASE_STORAGE_URL || joinUrl(SUPABASE_URL, '/storage/v1'),
|
||||
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) {
|
||||
assertApiConfig()
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('URL da API não configurada.')
|
||||
}
|
||||
|
||||
const normalizedBase = baseUrl.replace(/\/+$/, '')
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||
return `${normalizedBase}${normalizedPath}`
|
||||
@@ -50,7 +80,6 @@ export function hasAuthenticatedSession() {
|
||||
const session = getAuthSession()
|
||||
if (!session?.access_token) return false
|
||||
|
||||
// Validate expiration locally if available
|
||||
if (session.expires_at && session.expires_at * 1000 <= Date.now()) {
|
||||
clearAuthSession()
|
||||
return false
|
||||
@@ -60,6 +89,8 @@ export function hasAuthenticatedSession() {
|
||||
}
|
||||
|
||||
export function getAnonHeaders(extraHeaders = {}) {
|
||||
assertApiConfig()
|
||||
|
||||
return cleanHeaders({
|
||||
apikey: apiConfig.anonKey,
|
||||
'Content-Type': 'application/json',
|
||||
@@ -68,6 +99,8 @@ export function getAnonHeaders(extraHeaders = {}) {
|
||||
}
|
||||
|
||||
export function getAuthenticatedHeaders(extraHeaders = {}) {
|
||||
assertApiConfig()
|
||||
|
||||
const session = getAuthSession()
|
||||
const accessToken = session?.access_token
|
||||
|
||||
@@ -90,5 +123,22 @@ function cleanHeaders(headers) {
|
||||
}
|
||||
|
||||
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 = [
|
||||
{
|
||||
id: 'queue-001',
|
||||
@@ -179,10 +12,10 @@ export const careQueue = [
|
||||
id: 'queue-002',
|
||||
patient: 'Bruno Lima',
|
||||
patientId: 'bruno-lima',
|
||||
status: 'Aguardando médico',
|
||||
status: 'Aguardando medico',
|
||||
priority: 'Alta',
|
||||
wait: '25 min',
|
||||
reason: 'Pressão elevada',
|
||||
reason: 'Pressao elevada',
|
||||
},
|
||||
{
|
||||
id: 'queue-003',
|
||||
@@ -194,135 +27,3 @@ export const careQueue = [
|
||||
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>',
|
||||
},
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { isSameDay } from 'date-fns'
|
||||
|
||||
import { appointmentRepository } from '../repositories/appointmentRepository.js'
|
||||
@@ -8,6 +8,16 @@ import { professionalRepository } from '../repositories/professionalRepository.j
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
import { formatLocalDateInput, parseLocalDate, sortAppointmentsByTime } from '../utils/agendaDate.js'
|
||||
|
||||
const initialForm = {
|
||||
patientId: '',
|
||||
professionalId: '',
|
||||
type: 'Retorno',
|
||||
time: '15:30',
|
||||
mode: 'Teleconsulta',
|
||||
status: 'Aguardando',
|
||||
notes: '',
|
||||
}
|
||||
|
||||
export function useAgenda() {
|
||||
const [patients, setPatients] = useState([])
|
||||
const [professionals, setProfessionals] = useState([])
|
||||
@@ -27,14 +37,9 @@ export function useAgenda() {
|
||||
const [doctorSearch, setDoctorSearch] = useState('')
|
||||
const [unitFilter, setUnitFilter] = useState('')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editingAppointment, setEditingAppointment] = useState(null)
|
||||
const [form, setForm] = useState(initialForm)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
patientId: '',
|
||||
professionalId: '',
|
||||
type: 'Retorno',
|
||||
time: '15:30',
|
||||
mode: 'Teleconsulta',
|
||||
})
|
||||
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const canCreateAppointment = agendaScope === 'doctor'
|
||||
? Boolean(currentProfessional?.id)
|
||||
@@ -55,10 +60,10 @@ export function useAgenda() {
|
||||
|
||||
if (!active) return
|
||||
|
||||
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const currentScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const resolvedProfessional = professionalRepository.resolveCurrentProfessional(currentProfile, professionalsData)
|
||||
const initialProfessionalId =
|
||||
agendaScope === 'doctor'
|
||||
currentScope === 'doctor'
|
||||
? resolvedProfessional?.id || ''
|
||||
: professionalsData?.[0]?.id || ''
|
||||
|
||||
@@ -72,20 +77,20 @@ export function useAgenda() {
|
||||
professionalId: initialProfessionalId,
|
||||
}))
|
||||
|
||||
if (agendaScope === 'doctor' && !resolvedProfessional) {
|
||||
if (currentScope === 'doctor' && !resolvedProfessional) {
|
||||
setLocalAppointments([])
|
||||
setError('Não foi possível vincular o médico logado a um profissional da base.')
|
||||
return
|
||||
}
|
||||
|
||||
const appointmentsData = await appointmentRepository.getAll({
|
||||
doctorId: agendaScope === 'doctor' ? resolvedProfessional?.id : undefined,
|
||||
doctorId: currentScope === 'doctor' ? resolvedProfessional?.id : undefined,
|
||||
})
|
||||
|
||||
if (!active) return
|
||||
|
||||
setLocalAppointments(
|
||||
agendaScope === 'doctor' && resolvedProfessional
|
||||
currentScope === 'doctor' && resolvedProfessional
|
||||
? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id)
|
||||
: sortAppointmentsByTime(appointmentsData || []),
|
||||
)
|
||||
@@ -95,9 +100,7 @@ export function useAgenda() {
|
||||
console.error(loadError)
|
||||
setError(loadError.message || 'Erro ao carregar agenda.')
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +112,7 @@ export function useAgenda() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return
|
||||
if (!modalOpen || editingAppointment) return
|
||||
|
||||
const targetProfessionalId = agendaScope === 'doctor'
|
||||
? currentProfessional?.id
|
||||
@@ -160,7 +163,7 @@ export function useAgenda() {
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [agendaScope, baseDate, currentProfessional?.id, form.mode, form.professionalId, modalOpen])
|
||||
}, [agendaScope, baseDate, currentProfessional?.id, editingAppointment, form.mode, form.professionalId, modalOpen])
|
||||
|
||||
const visibleAppointments = useMemo(() => {
|
||||
let filtered = localAppointments
|
||||
@@ -205,46 +208,156 @@ export function useAgenda() {
|
||||
}
|
||||
|
||||
return sortAppointmentsByTime(filtered)
|
||||
}, [localAppointments, status, agendaScope, doctorFilter, doctorSearch, unitFilter, professionals, activeView, baseDate])
|
||||
}, [activeView, agendaScope, baseDate, doctorFilter, doctorSearch, localAppointments, professionals, status, unitFilter])
|
||||
|
||||
function updateForm(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
async function handleCreate(event) {
|
||||
function openCreateModal({ date, time } = {}) {
|
||||
if (date) {
|
||||
const parsedDate = parseLocalDate(date)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
}
|
||||
|
||||
setEditingAppointment(null)
|
||||
setAvailableSlots([])
|
||||
setSlotsError('')
|
||||
setForm((current) => ({
|
||||
...initialForm,
|
||||
patientId: current.patientId || patients[0]?.id || '',
|
||||
professionalId:
|
||||
agendaScope === 'doctor'
|
||||
? currentProfessional?.id || ''
|
||||
: current.professionalId || professionals[0]?.id || '',
|
||||
time: time || current.time || initialForm.time,
|
||||
}))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function openAppointmentModal(appointment) {
|
||||
const parsedDate = parseLocalDate(appointment.date)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
|
||||
setEditingAppointment(appointment)
|
||||
setAvailableSlots([])
|
||||
setSlotsError('')
|
||||
setForm({
|
||||
patientId: appointment.patientId || '',
|
||||
professionalId: appointment.professionalId || '',
|
||||
type: appointment.type || initialForm.type,
|
||||
time: appointment.time || initialForm.time,
|
||||
mode: appointment.mode || initialForm.mode,
|
||||
status: appointment.status || initialForm.status,
|
||||
notes: appointment.notes || '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function closeAppointmentModal() {
|
||||
setModalOpen(false)
|
||||
setEditingAppointment(null)
|
||||
}
|
||||
|
||||
async function handleSubmitAppointment(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!form.patientId) {
|
||||
alert('Selecione um paciente para criar o agendamento.')
|
||||
if (editingAppointment) {
|
||||
await updateAppointment()
|
||||
return
|
||||
}
|
||||
|
||||
await createAppointment()
|
||||
}
|
||||
|
||||
async function createAppointment() {
|
||||
const payload = buildPayload()
|
||||
if (!payload) return
|
||||
|
||||
try {
|
||||
const created = await appointmentRepository.create(payload)
|
||||
setLocalAppointments((current) => sortAppointmentsByTime([...current, enrichAppointment(created, payload, patients, professionals)]))
|
||||
closeAppointmentModal()
|
||||
} catch (createError) {
|
||||
alert(createError.message || 'Erro ao criar agendamento.')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAppointment() {
|
||||
if (!editingAppointment) return
|
||||
|
||||
const payload = buildPayload()
|
||||
if (!payload) return
|
||||
|
||||
try {
|
||||
const updated = await appointmentRepository.update(editingAppointment.id, payload)
|
||||
setLocalAppointments((current) =>
|
||||
sortAppointmentsByTime(
|
||||
current.map((appointment) =>
|
||||
appointment.id === editingAppointment.id
|
||||
? enrichAppointment(updated, payload, patients, professionals)
|
||||
: appointment,
|
||||
),
|
||||
),
|
||||
)
|
||||
closeAppointmentModal()
|
||||
} catch (updateError) {
|
||||
alert(updateError.message || 'Erro ao atualizar agendamento.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelAppointment() {
|
||||
if (!editingAppointment) return
|
||||
if (!window.confirm('Tem certeza que deseja cancelar este agendamento?')) return
|
||||
|
||||
const payload = buildPayload({ status: 'Cancelada' })
|
||||
if (!payload) return
|
||||
|
||||
try {
|
||||
const cancelled = await appointmentRepository.cancel(editingAppointment.id, payload)
|
||||
setLocalAppointments((current) =>
|
||||
sortAppointmentsByTime(
|
||||
current.map((appointment) =>
|
||||
appointment.id === editingAppointment.id
|
||||
? enrichAppointment(cancelled, payload, patients, professionals)
|
||||
: appointment,
|
||||
),
|
||||
),
|
||||
)
|
||||
closeAppointmentModal()
|
||||
} catch (cancelError) {
|
||||
alert(cancelError.message || 'Erro ao cancelar agendamento.')
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(overrides = {}) {
|
||||
if (!form.patientId) {
|
||||
alert('Selecione um paciente para salvar o agendamento.')
|
||||
return null
|
||||
}
|
||||
|
||||
const targetProfessionalId = agendaScope === 'doctor'
|
||||
? currentProfessional?.id
|
||||
: form.professionalId
|
||||
|
||||
if (!targetProfessionalId) {
|
||||
alert('Não foi possível identificar o profissional da consulta.')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const dateStr = formatLocalDateInput(baseDate)
|
||||
|
||||
try {
|
||||
const created = await appointmentRepository.create({
|
||||
patientId: form.patientId,
|
||||
date: dateStr,
|
||||
time: form.time,
|
||||
type: form.type,
|
||||
mode: form.mode,
|
||||
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
||||
professionalId: targetProfessionalId,
|
||||
})
|
||||
|
||||
setLocalAppointments((current) => sortAppointmentsByTime([...current, created]))
|
||||
setModalOpen(false)
|
||||
} catch (createError) {
|
||||
alert(createError.message || 'Erro ao criar agendamento.')
|
||||
return {
|
||||
patientId: form.patientId,
|
||||
date: formatLocalDateInput(baseDate),
|
||||
time: form.time,
|
||||
type: form.type,
|
||||
mode: form.mode,
|
||||
status: form.status,
|
||||
notes: form.notes,
|
||||
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
||||
professionalId: targetProfessionalId,
|
||||
createdBy: editingAppointment?.createdBy || viewerProfile?.id || '',
|
||||
createdByName: editingAppointment?.createdByName || viewerProfile?.name || viewerProfile?.email || '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,10 +383,14 @@ export function useAgenda() {
|
||||
unitFilter,
|
||||
setUnitFilter,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
editingAppointment,
|
||||
form,
|
||||
updateForm,
|
||||
handleCreate,
|
||||
openCreateModal,
|
||||
openAppointmentModal,
|
||||
closeAppointmentModal,
|
||||
handleSubmitAppointment,
|
||||
handleCancelAppointment,
|
||||
visibleAppointments,
|
||||
availableSlots,
|
||||
slotsLoading,
|
||||
@@ -289,6 +406,28 @@ function filterAppointmentsByProfessional(appointments, professionalId) {
|
||||
)
|
||||
}
|
||||
|
||||
function enrichAppointment(appointment, payload, patients, professionals) {
|
||||
const patient = patients.find((item) => String(item.id) === String(payload.patientId))
|
||||
const professional = professionals.find((item) => String(item.id) === String(payload.professionalId))
|
||||
|
||||
return {
|
||||
...appointment,
|
||||
patientId: payload.patientId,
|
||||
professionalId: payload.professionalId,
|
||||
patient: patient?.name || patient?.full_name || patient?.nome || appointment.patient,
|
||||
professional: professional?.name || professional?.full_name || professional?.nome || appointment.professional,
|
||||
date: payload.date,
|
||||
time: payload.time,
|
||||
type: payload.type,
|
||||
mode: payload.mode,
|
||||
status: payload.status,
|
||||
notes: payload.notes,
|
||||
room: payload.room,
|
||||
createdBy: appointment.createdBy || payload.createdBy,
|
||||
createdByName: appointment.createdByName || payload.createdByName,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeValue(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
542
src/index.css
542
src/index.css
@@ -46,12 +46,12 @@ button:disabled {
|
||||
|
||||
:root[data-theme='light'] {
|
||||
color: #333333;
|
||||
background: #eef2f7;
|
||||
background: #d9e4f0;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='light'] body {
|
||||
background: #eef2f7;
|
||||
background: #d9e4f0;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
@@ -61,13 +61,21 @@ button:disabled {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='light'] :where(input, textarea, [contenteditable='true'], .ProseMirror) {
|
||||
caret-color: #000000;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark :where(input, textarea) {
|
||||
caret-color: #e5e5e5;
|
||||
}
|
||||
|
||||
[data-theme='light'] aside.bg-\[\#262626\] {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
[data-theme='light'] .bg-\[\#0a0a0a\],
|
||||
[data-theme='light'] .bg-\[\#171717\] {
|
||||
background-color: #eef2f7;
|
||||
background-color: #d9e4f0;
|
||||
}
|
||||
|
||||
[data-theme='light'] .bg-\[\#1a1a1a\] {
|
||||
@@ -106,7 +114,7 @@ button:disabled {
|
||||
}
|
||||
|
||||
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
|
||||
background-color: #eef2f7;
|
||||
background-color: #d9e4f0;
|
||||
}
|
||||
|
||||
[data-theme='light'] .border-\[\#404040\],
|
||||
@@ -114,11 +122,21 @@ button:disabled {
|
||||
border-color: #d6dee8;
|
||||
}
|
||||
|
||||
[data-theme='light'] .divide-y.divide-\[\#404040\] > :not(:last-child),
|
||||
[data-theme='light'] .divide-\[\#404040\] > :not(:last-child),
|
||||
[data-theme='light'] table .divide-\[\#404040\] > tr:not(:last-child) {
|
||||
border-color: #d6dee8;
|
||||
}
|
||||
|
||||
[data-theme='light'] .border-\[\#525252\],
|
||||
[data-theme='light'] .hover\:border-\[\#525252\]:hover {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
[data-theme='light'] .border-\[\#5b4b75\] {
|
||||
border-color: #d6dee8;
|
||||
}
|
||||
|
||||
[data-theme='light'] .hover\:border-\[\#404040\]:hover,
|
||||
[data-theme='light'] .disabled\:border-\[\#404040\]:disabled {
|
||||
border-color: #d6dee8;
|
||||
@@ -173,3 +191,519 @@ button:disabled {
|
||||
[data-theme='light'] svg [fill='#171717'] {
|
||||
fill: #f9fafb;
|
||||
}
|
||||
|
||||
.auth-dark {
|
||||
background: #0a0a0a;
|
||||
color: #ffffff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.auth-dark .auth-input {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #e5e5e5;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.auth-dark .auth-input::placeholder {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.auth-dark .auth-menu {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.auth-dark .auth-menu:hover {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark {
|
||||
background: #0a0a0a;
|
||||
color: #ffffff;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-input {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #e5e5e5;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-input::placeholder {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-menu {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
[data-theme='light'] .auth-dark .auth-menu:hover {
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark {
|
||||
border-color: #525252;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-bar {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-side {
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-dark .settings-theme-preview-line {
|
||||
background: #525252;
|
||||
}
|
||||
|
||||
[data-theme='light'] .settings-theme-preview-light {
|
||||
border-color: #d6dee8;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
[data-theme='light'] button:has(.settings-theme-preview-dark) .bg-\[\#3b82f6\] {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-badge-partial {
|
||||
border-color: #0284c7;
|
||||
background: #dff3ff;
|
||||
color: #075985;
|
||||
box-shadow: inset 0 0 0 1px rgba(2, 132, 199, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-panel-partial {
|
||||
border-color: #38bdf8;
|
||||
background: #eef9ff;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-title-partial {
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-badge-mock {
|
||||
border-color: #d97706;
|
||||
background: #fff2c2;
|
||||
color: #92400e;
|
||||
box-shadow: inset 0 0 0 1px rgba(217, 119, 6, 0.14);
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-panel-mock {
|
||||
border-color: #f59e0b;
|
||||
background: #fff8db;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-title-mock {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-badge-wip {
|
||||
border-color: #e11d48;
|
||||
background: #ffe4e8;
|
||||
color: #9f1239;
|
||||
box-shadow: inset 0 0 0 1px rgba(225, 29, 72, 0.12);
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-panel-wip {
|
||||
border-color: #fb7185;
|
||||
background: #fff1f3;
|
||||
}
|
||||
|
||||
[data-theme='light'] .feature-title-wip {
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-editor-backdrop {
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-editor-shell {
|
||||
border-color: #c8d4e2;
|
||||
background: #f8fbff;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-editor-header,
|
||||
[data-theme='light'] .report-editor-footer {
|
||||
border-color: #c8d4e2;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-editor-body {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-template-trigger,
|
||||
[data-theme='light'] .report-template-menu,
|
||||
[data-theme='light'] .report-rich-editor,
|
||||
[data-theme='light'] .report-rich-toolbar {
|
||||
border-color: #c8d4e2;
|
||||
background: #ffffff;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-rich-surface {
|
||||
background: #ffffff;
|
||||
caret-color: #000000;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-rich-toolbar select {
|
||||
border-color: #c8d4e2;
|
||||
background: #ffffff;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-rich-toolbar button {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-rich-toolbar button[aria-pressed='true'],
|
||||
[data-theme='light'] .report-rich-toolbar button:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.report-rich-surface {
|
||||
caret-color: #e5e5e5;
|
||||
cursor: text;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.report-rich-surface * {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.report-rich-surface.ProseMirror-focused {
|
||||
caret-color: #e5e5e5;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-rich-surface.ProseMirror-focused {
|
||||
caret-color: #000000;
|
||||
}
|
||||
|
||||
.report-rich-surface p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.report-rich-surface h2 {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-rich-surface h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.report-rich-surface ul,
|
||||
.report-rich-surface ol {
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
|
||||
.report-rich-surface .is-empty::before {
|
||||
color: #737373;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-theme='light'] .report-rich-toolbar button:hover,
|
||||
[data-theme='light'] .report-template-menu button:hover {
|
||||
background: #e8edf4;
|
||||
}
|
||||
|
||||
.agenda-calendar-shell {
|
||||
border-color: #3b3b3b;
|
||||
background: #202020;
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.agenda-calendar-header {
|
||||
border-color: #3b3b3b;
|
||||
}
|
||||
|
||||
.agenda-legend-pill {
|
||||
border-color: #404040;
|
||||
background: #171717;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.agenda-legend-free {
|
||||
border-color: #166534;
|
||||
background: #052e1a;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.agenda-legend-booked {
|
||||
border-color: #a16207;
|
||||
background: #422006;
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.agenda-day-grid {
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #3b3b3b;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1f2933 0,
|
||||
#1f2933 39px,
|
||||
#334155 40px
|
||||
);
|
||||
}
|
||||
|
||||
.agenda-slot {
|
||||
margin: 0;
|
||||
border-width: 1px;
|
||||
border-radius: 0;
|
||||
color: #e5e5e5;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.agenda-slot + .agenda-slot {
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.agenda-slot-free {
|
||||
border-color: #15803d;
|
||||
background: linear-gradient(180deg, #083d22 0%, #052e1a 100%);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
|
||||
.agenda-slot-waiting,
|
||||
.agenda-event-waiting {
|
||||
border-color: #b7791f;
|
||||
background: linear-gradient(180deg, #53350a 0%, #3f2a09 100%);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.agenda-slot-confirmed,
|
||||
.agenda-event-confirmed {
|
||||
border-color: #0891b2;
|
||||
background: linear-gradient(180deg, #083344 0%, #0c2636 100%);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.agenda-slot-triage,
|
||||
.agenda-event-triage {
|
||||
border-color: #9333ea;
|
||||
background: linear-gradient(180deg, #3b0764 0%, #2e0a4f 100%);
|
||||
color: #e9d5ff;
|
||||
}
|
||||
|
||||
.agenda-event-finished {
|
||||
border-color: #2563eb;
|
||||
background: linear-gradient(180deg, #172554 0%, #111c3d 100%);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.agenda-slot-cancelled,
|
||||
.agenda-event-cancelled {
|
||||
border-color: #b91c1c;
|
||||
background: linear-gradient(180deg, #4c0519 0%, #3b0713 100%);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.agenda-slot-blocked,
|
||||
.agenda-event-blocked {
|
||||
border-color: #525252;
|
||||
background: linear-gradient(180deg, #262626 0%, #1f1f1f 100%);
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.agenda-slot-chip,
|
||||
.agenda-slot-status {
|
||||
border-color: rgba(229, 229, 229, 0.12);
|
||||
background: rgba(0, 0, 0, 0.26);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agenda-slot-add {
|
||||
border-color: rgba(229, 229, 229, 0.18);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agenda-slot-add:hover {
|
||||
background: rgba(0, 0, 0, 0.46);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.agenda-week-day,
|
||||
.agenda-month-day {
|
||||
border-color: #3b3b3b;
|
||||
background: #1f1f1f;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agenda-month-day:nth-child(7n + 1),
|
||||
.agenda-month-day:nth-child(7n) {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.agenda-event,
|
||||
.agenda-month-event {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.agenda-event span,
|
||||
.agenda-month-event span {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-calendar-shell {
|
||||
border-color: #d7e2ec;
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-calendar-header {
|
||||
border-color: #dbe7f1;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-legend-pill {
|
||||
border-color: #d7e2ec;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-legend-free {
|
||||
border-color: #86c98a;
|
||||
background: #eaf9ea;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-legend-booked {
|
||||
border-color: #f0b23d;
|
||||
background: #fff5cf;
|
||||
color: #7a4a05;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-day-grid {
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e2ec;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
to bottom,
|
||||
#eef4f8 0,
|
||||
#eef4f8 39px,
|
||||
#dbe7f1 40px
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot {
|
||||
margin: 0;
|
||||
border-width: 1px;
|
||||
border-radius: 0;
|
||||
color: #334155;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot + .agenda-slot {
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-free {
|
||||
border-color: #97d39b;
|
||||
background: linear-gradient(180deg, #f2fff2 0%, #e6f7e7 100%);
|
||||
color: #14532d;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-waiting,
|
||||
[data-theme='light'] .agenda-event-waiting {
|
||||
border-color: #f0b23d;
|
||||
background: linear-gradient(180deg, #fff8d7 0%, #fff2b7 100%);
|
||||
color: #6f4700;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-confirmed,
|
||||
[data-theme='light'] .agenda-event-confirmed {
|
||||
border-color: #26b8ec;
|
||||
background: linear-gradient(180deg, #e5faff 0%, #cef3ff 100%);
|
||||
color: #075985;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-triage,
|
||||
[data-theme='light'] .agenda-event-triage {
|
||||
border-color: #b35cff;
|
||||
background: linear-gradient(180deg, #f8ddff 0%, #edc4ff 100%);
|
||||
color: #5b217f;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-event-finished {
|
||||
border-color: #60a5fa;
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-cancelled,
|
||||
[data-theme='light'] .agenda-event-cancelled {
|
||||
border-color: #fb7185;
|
||||
background: linear-gradient(180deg, #ffe4e6 0%, #fecdd3 100%);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-blocked,
|
||||
[data-theme='light'] .agenda-event-blocked {
|
||||
border-color: #cbd5e1;
|
||||
background: linear-gradient(180deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-chip,
|
||||
[data-theme='light'] .agenda-slot-status {
|
||||
border-color: rgba(51, 65, 85, 0.18);
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-add {
|
||||
border-color: rgba(30, 64, 175, 0.28);
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-slot-add:hover {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-week-day,
|
||||
[data-theme='light'] .agenda-month-day {
|
||||
border-color: #d7e2ec;
|
||||
background: #eef4f8;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-month-day:nth-child(7n + 1),
|
||||
[data-theme='light'] .agenda-month-day:nth-child(7n) {
|
||||
background: #e8f0f6;
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-event,
|
||||
[data-theme='light'] .agenda-month-event {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78), 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-theme='light'] .agenda-event span,
|
||||
[data-theme='light'] .agenda-month-event span {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const appointmentMapper = {
|
||||
cancelled: 'Cancelada',
|
||||
}
|
||||
|
||||
const rawStatus = (apiData.status || '').toLowerCase()
|
||||
const rawStatus = String(apiData.status || '').toLowerCase()
|
||||
const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando'
|
||||
|
||||
// Modalidade
|
||||
@@ -66,7 +66,16 @@ export const appointmentMapper = {
|
||||
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
|
||||
mode: mode,
|
||||
status: mappedStatus,
|
||||
notes: apiData.notes || apiData.observations || apiData.observacoes || apiData.observacao || apiData.description || '',
|
||||
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 ||
|
||||
'',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -80,8 +89,11 @@ export const appointmentMapper = {
|
||||
doctor_id: uiData.professionalId || null,
|
||||
scheduled_at: scheduledAt,
|
||||
appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial',
|
||||
status: uiData.status === 'Confirmada' ? 'confirmed' : 'requested',
|
||||
status: toApiStatus(uiData.status),
|
||||
notes: emptyToUndefined(uiData.notes),
|
||||
observations: emptyToUndefined(uiData.notes),
|
||||
duration_minutes: 30, // Padrao
|
||||
created_by: emptyToUndefined(uiData.createdBy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +106,38 @@ export const appointmentMapper = {
|
||||
mode: uiData.mode,
|
||||
status: uiData.status || 'Confirmada',
|
||||
room: uiData.room,
|
||||
notes: uiData.notes,
|
||||
created_by: uiData.createdBy,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function emptyToUndefined(value) {
|
||||
return value === '' || value === null ? undefined : value
|
||||
}
|
||||
|
||||
function toApiStatus(status) {
|
||||
const normalized = String(status || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
const statusMap = {
|
||||
confirmada: 'confirmed',
|
||||
confirmado: 'confirmed',
|
||||
em_triagem: 'checked_in',
|
||||
triagem: 'checked_in',
|
||||
aguardando: 'requested',
|
||||
solicitada: 'requested',
|
||||
solicitacao: 'requested',
|
||||
cancelada: 'cancelled',
|
||||
cancelado: 'cancelled',
|
||||
concluida: 'completed',
|
||||
concluido: 'completed',
|
||||
finalizada: 'completed',
|
||||
finalizado: 'completed',
|
||||
}
|
||||
|
||||
return statusMap[normalized.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')] || 'requested'
|
||||
}
|
||||
|
||||
@@ -36,8 +36,6 @@ export const reportMapper = {
|
||||
conclusion: emptyToUndefined(uiData.conclusion),
|
||||
content_html: emptyToUndefined(uiData.contentHtml),
|
||||
content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson,
|
||||
hide_date: Boolean(uiData.hideDate),
|
||||
hide_signature: Boolean(uiData.hideSignature),
|
||||
due_at: emptyToUndefined(uiData.dueAt),
|
||||
created_by: emptyToUndefined(uiData.createdBy),
|
||||
updated_by: emptyToUndefined(uiData.updatedBy),
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import {
|
||||
addDays,
|
||||
subDays,
|
||||
addWeeks,
|
||||
subWeeks,
|
||||
addMonths,
|
||||
subMonths,
|
||||
addWeeks,
|
||||
endOfWeek,
|
||||
format,
|
||||
startOfWeek,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
} from 'date-fns'
|
||||
import { ptBR } from 'date-fns/locale'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
||||
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
||||
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||
import { StethoscopeIcon } from '../components/Brand.jsx'
|
||||
import { useAgenda } from '../hooks/useAgenda.js'
|
||||
import { formatLocalDateInput, parseLocalDate } from '../utils/agendaDate.js'
|
||||
|
||||
@@ -23,6 +24,7 @@ const statusFilters = [
|
||||
{ label: 'Confirmadas', value: 'Confirmada' },
|
||||
{ label: 'Em triagem', value: 'Em triagem' },
|
||||
{ label: 'Aguardando', value: 'Aguardando' },
|
||||
{ label: 'Canceladas', value: 'Cancelada' },
|
||||
]
|
||||
|
||||
const viewFilters = [
|
||||
@@ -32,8 +34,9 @@ const viewFilters = [
|
||||
]
|
||||
|
||||
const appointmentTypeOptions = ['Retorno', 'Primeira consulta', 'Exame', 'Avaliação pre-op']
|
||||
const appointmentStatusOptions = ['Confirmada', 'Em triagem', 'Aguardando']
|
||||
|
||||
export function AgendaPage({ navigate }) {
|
||||
export function AgendaPage() {
|
||||
const [modalPatientSearch, setModalPatientSearch] = useState('')
|
||||
const [modalDoctorSearch, setModalDoctorSearch] = useState('')
|
||||
const {
|
||||
@@ -57,10 +60,14 @@ export function AgendaPage({ navigate }) {
|
||||
unitFilter,
|
||||
setUnitFilter,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
editingAppointment,
|
||||
form,
|
||||
updateForm,
|
||||
handleCreate,
|
||||
openCreateModal,
|
||||
openAppointmentModal,
|
||||
closeAppointmentModal,
|
||||
handleSubmitAppointment,
|
||||
handleCancelAppointment,
|
||||
visibleAppointments,
|
||||
availableSlots,
|
||||
slotsLoading,
|
||||
@@ -79,42 +86,41 @@ export function AgendaPage({ navigate }) {
|
||||
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
|
||||
const isDoctorScope = agendaScope === 'doctor'
|
||||
const unitOptions = [
|
||||
...new Set(
|
||||
professionals
|
||||
.map((professional) => professional.unit)
|
||||
.filter(Boolean),
|
||||
),
|
||||
...new Set(professionals.map((professional) => professional.unit).filter(Boolean)),
|
||||
].sort((a, b) => a.localeCompare(b, 'pt-BR'))
|
||||
const filteredPatients = (() => {
|
||||
const query = normalizeSearch(modalPatientSearch)
|
||||
if (!query) return patients
|
||||
|
||||
return patients.filter((patient) =>
|
||||
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.email]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
})()
|
||||
const filteredProfessionals = (() => {
|
||||
const query = normalizeSearch(modalDoctorSearch)
|
||||
if (!query) return professionals
|
||||
|
||||
return professionals.filter((professional) =>
|
||||
[professional.name, professional.email, professional.unit]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
})()
|
||||
const filteredPatients = filterBySearch(patients, modalPatientSearch, (patient) => [
|
||||
patient.name,
|
||||
patient.full_name,
|
||||
patient.nome,
|
||||
patient.cpf,
|
||||
patient.email,
|
||||
])
|
||||
const filteredProfessionals = filterBySearch(professionals, modalDoctorSearch, (professional) => [
|
||||
professional.name,
|
||||
professional.email,
|
||||
professional.unit,
|
||||
])
|
||||
const selectedPatient = patients.find((patient) => String(patient.id) === String(form.patientId))
|
||||
const selectedProfessional = professionals.find((professional) => String(professional.id) === String(form.professionalId))
|
||||
const timeOptions = getTimeOptions(form.time, availableSlots)
|
||||
|
||||
function openCreate(options = {}) {
|
||||
setModalPatientSearch('')
|
||||
setModalDoctorSearch('')
|
||||
openCreateModal(options)
|
||||
}
|
||||
|
||||
function openManage(appointment) {
|
||||
setModalPatientSearch('')
|
||||
setModalDoctorSearch('')
|
||||
openAppointmentModal(appointment)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalPatientSearch('')
|
||||
setModalDoctorSearch('')
|
||||
closeAppointmentModal()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
||||
@@ -169,7 +175,7 @@ export function AgendaPage({ navigate }) {
|
||||
<button
|
||||
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373] disabled:shadow-none"
|
||||
disabled={!canCreateAppointment}
|
||||
onClick={() => setModalOpen(true)}
|
||||
onClick={() => openCreate()}
|
||||
type="button"
|
||||
>
|
||||
+ Novo agendamento
|
||||
@@ -283,7 +289,7 @@ export function AgendaPage({ navigate }) {
|
||||
<AgendaWeeklyView
|
||||
baseDate={baseDate}
|
||||
appointments={visibleAppointments}
|
||||
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
|
||||
onAppointmentClick={openManage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -300,9 +306,11 @@ export function AgendaPage({ navigate }) {
|
||||
|
||||
{activeView === 'Dia' && (
|
||||
<AgendaDailyView
|
||||
baseDate={baseDate}
|
||||
appointments={visibleAppointments}
|
||||
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
|
||||
baseDate={baseDate}
|
||||
canCreateAppointment={canCreateAppointment}
|
||||
onAppointmentClick={openManage}
|
||||
onSlotCreate={(time) => openCreate({ time })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -310,54 +318,101 @@ export function AgendaPage({ navigate }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Novo agendamento">
|
||||
<form className="grid gap-4" onSubmit={handleCreate}>
|
||||
<DarkField label="Dia do agendamento">
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none [color-scheme:dark] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
const parsedDate = parseLocalDate(event.target.value)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
}}
|
||||
type="date"
|
||||
value={formatLocalDateInput(baseDate)}
|
||||
/>
|
||||
</DarkField>
|
||||
<DarkModal onClose={closeModal} open={modalOpen} title={editingAppointment ? 'Gerenciar agendamento' : 'Novo agendamento'}>
|
||||
<form className="grid gap-4" onSubmit={handleSubmitAppointment}>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid content-start gap-4">
|
||||
<DarkField label="Paciente">
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalPatientSearch(event.target.value)
|
||||
updateForm('patientId', '')
|
||||
}}
|
||||
placeholder="Pesquisar paciente"
|
||||
type="search"
|
||||
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
||||
/>
|
||||
{modalPatientSearch && !form.patientId ? (
|
||||
<SearchResults
|
||||
emptyText="Nenhum paciente encontrado."
|
||||
getLabel={getPatientLabel}
|
||||
items={filteredPatients.slice(0, 5)}
|
||||
onSelect={(patient) => {
|
||||
updateForm('patientId', patient.id)
|
||||
setModalPatientSearch(getPatientLabel(patient))
|
||||
}}
|
||||
selectedId={form.patientId}
|
||||
/>
|
||||
) : selectedPatient ? (
|
||||
<SelectedHint label={getPatientLabel(selectedPatient)} />
|
||||
) : null}
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Paciente">
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalPatientSearch(event.target.value)
|
||||
updateForm('patientId', '')
|
||||
}}
|
||||
placeholder="Pesquisar paciente"
|
||||
type="search"
|
||||
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum paciente encontrado."
|
||||
getLabel={getPatientLabel}
|
||||
items={filteredPatients.slice(0, 6)}
|
||||
onSelect={(patient) => {
|
||||
updateForm('patientId', patient.id)
|
||||
setModalPatientSearch(getPatientLabel(patient))
|
||||
}}
|
||||
selectedId={form.patientId}
|
||||
/>
|
||||
</DarkField>
|
||||
<DarkField label="Profissional">
|
||||
{isDoctorScope ? (
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
||||
disabled
|
||||
readOnly
|
||||
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalDoctorSearch(event.target.value)
|
||||
updateForm('professionalId', '')
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
type="search"
|
||||
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
||||
/>
|
||||
{modalDoctorSearch && !form.professionalId ? (
|
||||
<SearchResults
|
||||
emptyText="Nenhum médico encontrado."
|
||||
getDescription={(professional) => professional.unit || professional.email}
|
||||
getLabel={(professional) => professional.name}
|
||||
items={filteredProfessionals.slice(0, 5)}
|
||||
onSelect={(professional) => {
|
||||
updateForm('professionalId', professional.id)
|
||||
setModalDoctorSearch(professional.name)
|
||||
}}
|
||||
selectedId={form.professionalId}
|
||||
/>
|
||||
) : selectedProfessional ? (
|
||||
<SelectedHint label={selectedProfessional.name} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DarkField label="Horário">
|
||||
{availableSlots.length ? (
|
||||
<div className="grid content-start gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DarkField label="Dia">
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none [color-scheme:dark] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
const parsedDate = parseLocalDate(event.target.value)
|
||||
if (parsedDate) setBaseDate(parsedDate)
|
||||
}}
|
||||
type="date"
|
||||
value={formatLocalDateInput(baseDate)}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Horário">
|
||||
{timeOptions.length ? (
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('time', event.target.value)}
|
||||
value={form.time}
|
||||
>
|
||||
{availableSlots.map((slot) => (
|
||||
<option key={slot.time} value={slot.time}>
|
||||
{slot.time}
|
||||
{timeOptions.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -369,88 +424,100 @@ export function AgendaPage({ navigate }) {
|
||||
value={form.time}
|
||||
/>
|
||||
)}
|
||||
{slotsLoading ? (
|
||||
<span className="text-xs font-normal text-[#a3a3a3]">Calculando horários...</span>
|
||||
) : null}
|
||||
{slotsError ? (
|
||||
<span className="text-xs font-normal text-amber-400">{slotsError}</span>
|
||||
) : null}
|
||||
</DarkField>
|
||||
<DarkField label="Formato">
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('mode', event.target.value)}
|
||||
value={form.mode}
|
||||
>
|
||||
<option>Teleconsulta</option>
|
||||
<option>Presencial</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
{slotsLoading ? <span className="text-xs font-normal text-[#a3a3a3]">Calculando horários...</span> : null}
|
||||
{slotsError ? <span className="text-xs font-normal text-amber-400">{slotsError}</span> : null}
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DarkField label="Formato">
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('mode', event.target.value)}
|
||||
value={form.mode}
|
||||
>
|
||||
<option>Teleconsulta</option>
|
||||
<option>Presencial</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Status">
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('status', event.target.value)}
|
||||
value={form.status}
|
||||
>
|
||||
{!appointmentStatusOptions.includes(form.status) && form.status ? (
|
||||
<option value={form.status}>{form.status}</option>
|
||||
) : null}
|
||||
{appointmentStatusOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<DarkField label="Tipo de consulta">
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('type', event.target.value)}
|
||||
value={form.type}
|
||||
>
|
||||
{appointmentTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Observações">
|
||||
<textarea
|
||||
className="min-h-24 resize-y rounded-md border border-[#404040] bg-[#303030] px-3 py-2 text-sm leading-5 text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('notes', event.target.value)}
|
||||
placeholder="Observações sobre o agendamento"
|
||||
value={form.notes}
|
||||
/>
|
||||
</DarkField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DarkField label="Profissional">
|
||||
{isDoctorScope ? (
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
||||
disabled
|
||||
readOnly
|
||||
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className="h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||
onChange={(event) => {
|
||||
setModalDoctorSearch(event.target.value)
|
||||
updateForm('professionalId', '')
|
||||
}}
|
||||
placeholder="Pesquisar médico"
|
||||
type="search"
|
||||
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
||||
/>
|
||||
<SearchResults
|
||||
emptyText="Nenhum médico encontrado."
|
||||
getDescription={(professional) => professional.unit || professional.email}
|
||||
getLabel={(professional) => professional.name}
|
||||
items={filteredProfessionals.slice(0, 6)}
|
||||
onSelect={(professional) => {
|
||||
updateForm('professionalId', professional.id)
|
||||
setModalDoctorSearch(professional.name)
|
||||
}}
|
||||
selectedId={form.professionalId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Tipo de consulta">
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('type', event.target.value)}
|
||||
value={form.type}
|
||||
>
|
||||
{appointmentTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
{editingAppointment ? (
|
||||
<div className="rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
||||
<p>
|
||||
Agendamento de {selectedPatient ? getPatientLabel(selectedPatient) : 'paciente não informado'} às {form.time}.
|
||||
</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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-3 pt-2">
|
||||
{editingAppointment ? (
|
||||
<button
|
||||
className="mr-auto h-10 rounded-sm border border-red-500/40 bg-red-950/20 px-4 text-sm font-semibold text-red-200 transition hover:bg-red-950/35"
|
||||
onClick={handleCancelAppointment}
|
||||
type="button"
|
||||
>
|
||||
Cancelar agendamento
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||
onClick={() => setModalOpen(false)}
|
||||
onClick={closeModal}
|
||||
type="button"
|
||||
>
|
||||
Cancelar
|
||||
Fechar
|
||||
</button>
|
||||
<button
|
||||
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
|
||||
disabled={!canCreateAppointment}
|
||||
type="submit"
|
||||
>
|
||||
Salvar
|
||||
{editingAppointment ? 'Salvar alterações' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -469,15 +536,18 @@ function DarkField({ children, label }) {
|
||||
}
|
||||
|
||||
function DarkModal({ children, onClose, open, title }) {
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<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-xl 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">
|
||||
<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
|
||||
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]"
|
||||
@@ -487,12 +557,20 @@ function DarkModal({ children, onClose, open, title }) {
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
<div className="min-h-0 overflow-y-auto p-5">{children}</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 }) {
|
||||
return (
|
||||
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
|
||||
@@ -526,6 +604,30 @@ function getPatientLabel(patient) {
|
||||
return patient?.name || patient?.full_name || patient?.nome || ''
|
||||
}
|
||||
|
||||
function filterBySearch(items, search, getValues) {
|
||||
const query = normalizeSearch(search)
|
||||
if (!query) return items
|
||||
|
||||
return items.filter((item) =>
|
||||
getValues(item)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeOptions(selectedTime, slots) {
|
||||
return [
|
||||
...new Set([
|
||||
selectedTime,
|
||||
...slots.map((slot) => slot.time),
|
||||
].filter(Boolean)),
|
||||
].sort()
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
|
||||
@@ -43,7 +43,7 @@ export function LoginPage({ navigate }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a1628] text-white">
|
||||
<main className="auth-dark min-h-screen text-white">
|
||||
<div className="grid min-h-screen lg:grid-cols-2">
|
||||
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
||||
<img
|
||||
@@ -56,7 +56,7 @@ export function LoginPage({ navigate }) {
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(126.72deg, rgba(10, 22, 40, 0.9) 0%, rgba(10, 22, 40, 0.6) 50%, rgba(59, 130, 246, 0.3) 100%)',
|
||||
'linear-gradient(126.72deg, rgba(10, 10, 10, 0.92) 0%, rgba(23, 23, 23, 0.72) 52%, rgba(59, 130, 246, 0.28) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function LoginPage({ navigate }) {
|
||||
<LoginField htmlFor="login-email" label="E-mail">
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] px-4 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
||||
className={authInputClass}
|
||||
id="login-email"
|
||||
onChange={(event) => updateField('email', event.target.value)}
|
||||
placeholder="seu@email.com"
|
||||
@@ -132,7 +132,7 @@ export function LoginPage({ navigate }) {
|
||||
<div className="relative">
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
className="h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] py-2 pl-4 pr-11 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
||||
className={authPasswordInputClass}
|
||||
id="login-password"
|
||||
onChange={(event) => updateField('password', event.target.value)}
|
||||
placeholder="••••••••"
|
||||
@@ -162,7 +162,7 @@ export function LoginPage({ navigate }) {
|
||||
|
||||
<div className="absolute bottom-4 right-4">
|
||||
{credentialsOpen ? (
|
||||
<div className="mb-2 w-[292px] rounded-md border border-white/10 bg-[#0f1b2d] p-2 shadow-2xl">
|
||||
<div className="auth-menu mb-2 w-[292px] rounded-md border p-2 shadow-2xl">
|
||||
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||
Credenciais de acesso
|
||||
</p>
|
||||
@@ -188,7 +188,7 @@ export function LoginPage({ navigate }) {
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="flex h-[29px] items-center gap-1.5 rounded-sm border border-white/10 bg-white/[0.05] px-3 font-mono text-[10px] font-medium leading-[15px] text-white/30 transition hover:text-white/50"
|
||||
className="auth-menu flex h-[29px] items-center gap-1.5 rounded-sm border px-3 font-mono text-[10px] font-medium leading-[15px] transition"
|
||||
onClick={() => setCredentialsOpen((current) => !current)}
|
||||
title="Preencher credenciais de acesso"
|
||||
type="button"
|
||||
@@ -321,7 +321,7 @@ export function ForgotPasswordPage({ navigate }) {
|
||||
|
||||
function AuthLayout({ children, description, title }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[#0a1628] text-white">
|
||||
<main className="auth-dark min-h-screen text-white">
|
||||
<div className="grid min-h-screen lg:grid-cols-2">
|
||||
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
||||
<img alt="" className="absolute inset-0 h-full w-full object-cover" src={loginClinicImage} />
|
||||
@@ -330,7 +330,7 @@ function AuthLayout({ children, description, title }) {
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(126.72deg, rgba(10, 22, 40, 0.9) 0%, rgba(10, 22, 40, 0.6) 50%, rgba(59, 130, 246, 0.3) 100%)',
|
||||
'linear-gradient(126.72deg, rgba(10, 10, 10, 0.92) 0%, rgba(23, 23, 23, 0.72) 52%, rgba(59, 130, 246, 0.28) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
|
||||
@@ -351,7 +351,7 @@ function AuthLayout({ children, description, title }) {
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-screen items-center justify-center px-6 py-12 sm:px-10 lg:px-[60px] xl:px-[68px]">
|
||||
<div className="w-full max-w-[448px]">
|
||||
<div className="w-full max-w-[448px] lg:translate-y-3">
|
||||
<div className="mb-12 lg:hidden">
|
||||
<LoginLogo />
|
||||
</div>
|
||||
@@ -366,11 +366,13 @@ function AuthLayout({ children, description, title }) {
|
||||
}
|
||||
|
||||
const authInputClass =
|
||||
'h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] px-4 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||
'auth-input h-11 w-full rounded-[6px] border px-4 text-sm outline-none transition focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||
const authPasswordInputClass =
|
||||
'auth-input h-11 w-full rounded-[6px] border py-2 pl-4 pr-11 text-sm outline-none transition focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||
|
||||
function AuthField({ children, label }) {
|
||||
return (
|
||||
<label className="grid gap-1.5 text-xs font-medium leading-4 text-white/50">
|
||||
<label className="grid gap-1.5 text-xs font-medium leading-4 text-[#a3a3a3]">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
@@ -380,7 +382,7 @@ function AuthField({ children, label }) {
|
||||
function LoginField({ action, children, htmlFor, label }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="flex min-h-4 items-center justify-between gap-4 text-xs font-medium leading-4 text-white/50">
|
||||
<span className="flex min-h-4 items-center justify-between gap-4 text-xs font-medium leading-4 text-[#a3a3a3]">
|
||||
<label htmlFor={htmlFor}>{label}</label>
|
||||
{action}
|
||||
</span>
|
||||
|
||||
@@ -23,16 +23,6 @@ export function HomePage({ navigate }) {
|
||||
Bem-vindo, Dr. Henrique. Aqui está o resumo da sua clínica hoje.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||
onClick={() => navigate('/relatorios')}
|
||||
type="button"
|
||||
>
|
||||
Exportar
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { normalizeRole } from '../config/permissions.js'
|
||||
import { StethoscopeIcon } from '../components/Brand.jsx'
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||
import { communicationRepository } from '../repositories/communicationRepository.js'
|
||||
@@ -542,14 +543,16 @@ function TemplateCard({ onEdit, onUse, template }) {
|
||||
}
|
||||
|
||||
function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmit, patients, templates }) {
|
||||
const [patientSearch, setPatientSearch] = useState('')
|
||||
const [patientSearch, setPatientSearch] = useState(draft.patient || '')
|
||||
const filteredPatients = useMemo(() => {
|
||||
const query = patientSearch.trim().toLowerCase()
|
||||
const query = normalizeSearch(patientSearch)
|
||||
if (!query) return patients
|
||||
|
||||
return patients.filter((patient) =>
|
||||
[patient.name, patient.phone, patient.document]
|
||||
.join(' ')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
@@ -559,15 +562,14 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
function selectPatient(patientId) {
|
||||
const patient = patients.find((item) => item.id === patientId)
|
||||
|
||||
function selectPatient(patient) {
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
patientId,
|
||||
patientId: patient?.id || '',
|
||||
patient: patient?.name || '',
|
||||
phone: patient?.phone || current.phone,
|
||||
}))
|
||||
setPatientSearch(patient?.name || '')
|
||||
}
|
||||
|
||||
function applyTemplate(templateName) {
|
||||
@@ -587,33 +589,46 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalFrame onClose={onClose} title="Nova Mensagem">
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Paciente">
|
||||
<ModalFrame branded onClose={onClose} title="Nova Mensagem">
|
||||
<form className="space-y-5" onSubmit={onSubmit}>
|
||||
<DarkField label="Paciente">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => setPatientSearch(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setPatientSearch(event.target.value)
|
||||
onChange((current) => ({ ...current, patientId: '', patient: '' }))
|
||||
}}
|
||||
placeholder="Digite nome, CPF ou telefone"
|
||||
type="search"
|
||||
value={patientSearch}
|
||||
/>
|
||||
</DarkField>
|
||||
<DarkField label="Selecionar paciente">
|
||||
<select
|
||||
className={inputClass}
|
||||
onChange={(event) => selectPatient(event.target.value)}
|
||||
value={draft.patientId}
|
||||
>
|
||||
<option value="">Selecione um paciente</option>
|
||||
{filteredPatients.map((patient) => (
|
||||
<option key={patient.id} value={patient.id}>
|
||||
{patient.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
|
||||
{filteredPatients.length ? (
|
||||
filteredPatients.slice(0, 8).map((patient) => {
|
||||
const isSelected = String(patient.id) === String(draft.patientId)
|
||||
return (
|
||||
<button
|
||||
className={`block w-full px-3 py-2 text-left text-sm transition ${
|
||||
isSelected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={patient.id}
|
||||
onClick={() => selectPatient(patient)}
|
||||
type="button"
|
||||
>
|
||||
<span className="block font-semibold">{patient.name}</span>
|
||||
<span className="mt-0.5 block text-xs text-[#737373]">
|
||||
{[patient.document, patient.phone].filter(Boolean).join(' | ') || 'Sem documento informado'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-3 py-2 text-xs text-[#737373]">Nenhum paciente encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DarkField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Paciente selecionado">
|
||||
@@ -657,7 +672,7 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
||||
|
||||
<DarkField label="Mensagem">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
className={`${textareaClass} min-h-44`}
|
||||
onChange={(event) => update('content', event.target.value)}
|
||||
placeholder="Escreva a mensagem"
|
||||
value={draft.content}
|
||||
@@ -724,17 +739,24 @@ function TemplateEditor({ allowedChannelKeys, draft, onChange, onClose, onSubmit
|
||||
)
|
||||
}
|
||||
|
||||
function ModalFrame({ children, onClose, title }) {
|
||||
function ModalFrame({ branded = false, children, onClose, title }) {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<CommIcon className="size-5" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
<div className="min-h-0 overflow-y-auto p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -749,6 +771,14 @@ function DarkField({ children, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function CommIcon({ className = 'size-4', name }) {
|
||||
const common = {
|
||||
className,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { hasCapability } from '../config/permissions.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
@@ -179,18 +179,32 @@ export function PatientsPage({ navigate, role }) {
|
||||
try {
|
||||
if (isNew) {
|
||||
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 = {
|
||||
...patient,
|
||||
id: created?.id || patient.id,
|
||||
detailId: created?.id || patient.detailId || patient.id,
|
||||
avatarFile: undefined,
|
||||
avatarUrl: avatarResult?.avatarUrl || patient.avatarUrl,
|
||||
id: patientId,
|
||||
detailId: patientId || patient.detailId || patient.id,
|
||||
name: created?.full_name || created?.name || patient.name,
|
||||
phone: created?.phone_mobile || created?.phone || patient.phone,
|
||||
}
|
||||
setRows((currentRows) => [newRow, ...currentRows])
|
||||
} else {
|
||||
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) =>
|
||||
currentRows.map((item) => (item.id === patient.id ? patient : item))
|
||||
currentRows.map((item) => (item.id === patient.id ? nextPatient : item))
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -358,9 +372,13 @@ export function PatientsPage({ navigate, role }) {
|
||||
<tr className="transition hover:bg-[#303030]" key={patient.id}>
|
||||
<td className="px-6 py-4 align-top">
|
||||
<button className="flex items-center gap-3 text-left" onClick={() => openDetail(patient)} type="button">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
|
||||
{patient.name.charAt(0)}
|
||||
</span>
|
||||
{patient.avatarUrl ? (
|
||||
<img alt="" className="size-8 shrink-0 rounded-full border border-[#3b82f6]/30 object-cover" src={patient.avatarUrl} />
|
||||
) : (
|
||||
<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="block whitespace-normal break-words font-medium text-[#e5e5e5] transition hover:text-[#3b82f6]">
|
||||
{patient.name}
|
||||
@@ -376,7 +394,7 @@ export function PatientsPage({ navigate, role }) {
|
||||
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state || missingValue('Estado')}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.lastVisit || 'Ainda não houve atendimento'}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
|
||||
<td className="relative sticky right-0 bg-[#262626] px-6 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
||||
<td className="sticky right-0 bg-[#262626] px-4 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
||||
<button
|
||||
aria-label={`Ações de ${patient.name}`}
|
||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
|
||||
@@ -396,7 +414,7 @@ export function PatientsPage({ navigate, role }) {
|
||||
onClick={() => setOpenMenuId(null)}
|
||||
type="button"
|
||||
/>
|
||||
<div className="absolute right-4 top-12 z-50 w-48 rounded-md border border-[#404040] bg-[#262626] p-1 text-left shadow-lg">
|
||||
<div className="fixed right-8 z-50 w-48 rounded-md border border-[#404040] bg-[#262626] p-1 text-left shadow-lg">
|
||||
<ActionItem icon="file" label="Ver detalhes" onClick={() => openDetail(patient)} />
|
||||
{canEditPatients ? <ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} /> : null}
|
||||
<ActionItem
|
||||
@@ -509,7 +527,11 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
lastVisit: patient?.lastVisit || null,
|
||||
nextVisit: patient?.nextVisit || 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 isNewPatient = !patient
|
||||
|
||||
@@ -536,6 +558,15 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
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) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -584,6 +615,8 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
state: formData.state,
|
||||
address: formatAddress(formData),
|
||||
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}>
|
||||
<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="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>
|
||||
{avatarPreview ? (
|
||||
<img alt="" className="size-20 shrink-0 rounded-full border border-[#3b82f6]/30 object-cover" src={avatarPreview} />
|
||||
) : (
|
||||
<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
|
||||
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"
|
||||
>
|
||||
Carregar
|
||||
</button>
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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)
|
||||
try {
|
||||
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)
|
||||
} catch (err) {
|
||||
window.alert(`Erro ao salvar paciente: ${err.message}`)
|
||||
@@ -796,7 +849,7 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
||||
async function deletePatient() {
|
||||
if (!canHardDeletePatients) return
|
||||
|
||||
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente?')) {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente? Esta ação não poderá ser desfeita.')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -831,6 +884,9 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
||||
>
|
||||
<PatientIcon className="size-5" name="chevron-left" />
|
||||
</button>
|
||||
{localPatient.avatarUrl ? (
|
||||
<img alt="" className="mt-1 size-12 rounded-full border border-[#3b82f6]/30 object-cover" src={localPatient.avatarUrl} />
|
||||
) : null}
|
||||
<div>
|
||||
<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>
|
||||
@@ -900,21 +956,15 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
||||
</section>
|
||||
|
||||
{canHardDeletePatients ? (
|
||||
<section className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-red-300">Zona de exclusão</h2>
|
||||
<p className="mt-1 text-sm text-red-100/80">Remove definitivamente o paciente e seus dados locais carregados.</p>
|
||||
</div>
|
||||
<button
|
||||
className="h-10 rounded-sm border border-red-500/40 bg-red-500/10 px-4 text-sm font-semibold text-red-300 transition hover:bg-red-500/20"
|
||||
onClick={deletePatient}
|
||||
type="button"
|
||||
>
|
||||
Excluir paciente
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="h-10 rounded-sm border border-red-700 bg-red-600 px-4 text-sm font-semibold text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/40"
|
||||
onClick={deletePatient}
|
||||
type="button"
|
||||
>
|
||||
Excluir paciente
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messageShortcutOpen ? (
|
||||
@@ -1504,7 +1554,9 @@ function PatientIcon({ className = 'size-4', name }) {
|
||||
if (name === 'more') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
|
||||
<circle cx="5" cy="12" fill="currentColor" r="1.5" stroke="none" />
|
||||
<circle cx="12" cy="12" fill="currentColor" r="1.5" stroke="none" />
|
||||
<circle cx="19" cy="12" fill="currentColor" r="1.5" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
import { normalizeRole } from '../config/permissions.js'
|
||||
import { authRepository } from '../repositories/authRepository.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
|
||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||
const inputClass =
|
||||
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||
const readOnlyInputClass =
|
||||
'h-10 rounded-sm border border-[#404040] bg-[#1f1f1f] px-3 text-sm text-[#a3a3a3] outline-none'
|
||||
|
||||
export function ProfilePage({ navigate }) {
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -18,10 +19,13 @@ export function ProfilePage({ navigate }) {
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
profileRepository.getCurrentUserProfile().then(data => {
|
||||
setProfile(data)
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
profileRepository
|
||||
.getCurrentUserProfile()
|
||||
.then((data) => {
|
||||
setProfile(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function update(field, value) {
|
||||
@@ -56,31 +60,25 @@ export function ProfilePage({ navigate }) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center pt-20 text-[#a3a3a3]">Localizando dados do paciente...</div>
|
||||
return <div className="pt-20 text-center text-[#a3a3a3]">Localizando dados do perfil...</div>
|
||||
}
|
||||
|
||||
const normalizedRole = normalizeRole(profile.role)
|
||||
const canEditProfile = !['medico', 'secretaria'].includes(normalizedRole)
|
||||
const currentInputClass = canEditProfile ? inputClass : readOnlyInputClass
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<FeatureCallout
|
||||
description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente."
|
||||
status="partial"
|
||||
title="Perfil com persistência parcial"
|
||||
/>
|
||||
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1>
|
||||
<p className="mt-1 text-sm text-[#b8b8b8]">Dados locais do usuário logado e preferências básicas do shell.</p>
|
||||
<p className="mt-1 text-sm text-[#b8b8b8]">Dados do usuário logado e preferências básicas do shell.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<section className={`${cardClass} ${featurePanelClass('partial')} p-6`}>
|
||||
<section className={`${cardClass} p-6`}>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
{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} />
|
||||
) : (
|
||||
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
||||
{initials(profile.name)}
|
||||
@@ -112,42 +110,48 @@ export function ProfilePage({ navigate }) {
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setSaved(true)
|
||||
if (canEditProfile) setSaved(true)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Nome">
|
||||
<input className={inputClass} onChange={(event) => update('name', event.target.value)} value={profile.name} />
|
||||
<input className={currentInputClass} onChange={(event) => update('name', event.target.value)} readOnly={!canEditProfile} value={profile.name} />
|
||||
</Field>
|
||||
<Field label="Cargo">
|
||||
<input className={inputClass} onChange={(event) => update('role', event.target.value)} value={profile.role} />
|
||||
<input className={currentInputClass} onChange={(event) => update('role', event.target.value)} readOnly={!canEditProfile} value={profile.role} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="E-mail">
|
||||
<input className={inputClass} onChange={(event) => update('email', event.target.value)} type="email" value={profile.email} />
|
||||
<input className={currentInputClass} onChange={(event) => update('email', event.target.value)} readOnly={!canEditProfile} type="email" value={profile.email} />
|
||||
</Field>
|
||||
<Field label="Telefone">
|
||||
<input className={inputClass} onChange={(event) => update('phone', event.target.value)} value={profile.phone} />
|
||||
<input className={currentInputClass} onChange={(event) => update('phone', event.target.value)} readOnly={!canEditProfile} value={profile.phone} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Unidade padrão">
|
||||
<select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}>
|
||||
<option>Clínica Boa Vista</option>
|
||||
<option>Unidade Centro</option>
|
||||
<option>Unidade Sul</option>
|
||||
</select>
|
||||
{canEditProfile ? (
|
||||
<select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}>
|
||||
<option>Clínica Boa Vista</option>
|
||||
<option>Unidade Centro</option>
|
||||
<option>Unidade Sul</option>
|
||||
</select>
|
||||
) : (
|
||||
<input className={readOnlyInputClass} readOnly value={profile.unit} />
|
||||
)}
|
||||
</Field>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
|
||||
Salvar alterações
|
||||
</button>
|
||||
{saved ? <span className="rounded bg-amber-500/20 px-2.5 py-1 text-xs font-bold text-amber-300">Preferências salvas localmente</span> : null}
|
||||
</div>
|
||||
{canEditProfile ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
|
||||
Salvar alterações
|
||||
</button>
|
||||
{saved ? <span className="rounded bg-amber-500/20 px-2.5 py-1 text-xs font-bold text-amber-300">Preferências salvas localmente</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</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>
|
||||
<dl className="mt-5 grid gap-4 text-sm">
|
||||
<Info label="Perfil" value={profile.role} />
|
||||
@@ -155,9 +159,10 @@ export function ProfilePage({ navigate }) {
|
||||
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
||||
</dl>
|
||||
<div className="mt-8 border-t border-[#404040] pt-6">
|
||||
<button
|
||||
className="w-full h-10 rounded-sm border border-red-500/30 text-red-500 font-semibold text-sm transition hover:bg-red-500/10"
|
||||
<button
|
||||
className="h-10 w-full rounded-sm border border-red-500/30 text-sm font-semibold text-red-500 transition hover:bg-red-500/10"
|
||||
onClick={handleLogout}
|
||||
type="button"
|
||||
>
|
||||
Sair da conta
|
||||
</button>
|
||||
@@ -181,7 +186,7 @@ function Info({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
||||
<dt className="font-semibold text-[#a3a3a3]">{label}</dt>
|
||||
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
|
||||
<dd className="mt-1 text-[#e5e5e5]">{value || '-'}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
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 { professionalRepository } from '../repositories/professionalRepository.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
@@ -28,13 +32,6 @@ const orderOptions = [
|
||||
{ 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 textareaClass =
|
||||
'min-h-24 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 py-2 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 emptyEditor = {
|
||||
id: null,
|
||||
orderNumber: '',
|
||||
@@ -47,8 +44,6 @@ const emptyEditor = {
|
||||
conclusion: '',
|
||||
contentHtml: '',
|
||||
contentJson: undefined,
|
||||
hideDate: false,
|
||||
hideSignature: false,
|
||||
dueAt: '',
|
||||
}
|
||||
|
||||
@@ -236,6 +231,7 @@ export function ReportsPage({ role }) {
|
||||
setEditor({
|
||||
...emptyEditor,
|
||||
patientId: patientOptions[0]?.id || '',
|
||||
requestedBy: isDoctorRole ? currentProfessional?.name || viewerProfile?.name || '' : '',
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
@@ -253,32 +249,38 @@ export function ReportsPage({ role }) {
|
||||
conclusion: report.conclusion,
|
||||
contentHtml: report.contentHtml,
|
||||
contentJson: report.contentJson,
|
||||
hideDate: report.hideDate,
|
||||
hideSignature: report.hideSignature,
|
||||
dueAt: toDateTimeLocal(report.dueAt),
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editor.patientId) return
|
||||
if (!isReportEditorValid(editor)) {
|
||||
alert('Preencha todos os campos obrigatórios antes de salvar o relatório.')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
const plainContent = stripHtml(editor.contentHtml)
|
||||
const fallbackAuthor =
|
||||
currentProfessional?.name ||
|
||||
viewerProfile?.name ||
|
||||
viewerProfile?.email ||
|
||||
'Profissional MediConnect'
|
||||
|
||||
const payload = {
|
||||
orderNumber: editor.id ? editor.orderNumber : `REL-${Date.now()}`,
|
||||
patientId: editor.patientId,
|
||||
patientId: editor.patientId || patientOptions[0]?.id || '',
|
||||
status: editor.status,
|
||||
exam: editor.exam,
|
||||
requestedBy: editor.requestedBy,
|
||||
cidCode: editor.cidCode,
|
||||
diagnosis: editor.diagnosis,
|
||||
conclusion: editor.conclusion,
|
||||
exam: editor.exam || 'Relatório médico',
|
||||
requestedBy: editor.requestedBy || fallbackAuthor,
|
||||
cidCode: editor.cidCode || 'Z00.0',
|
||||
diagnosis: editor.diagnosis || plainContent.slice(0, 240) || 'Relatório médico registrado em prontuário.',
|
||||
conclusion: editor.conclusion || plainContent.slice(0, 240) || 'Relatório médico salvo no sistema.',
|
||||
contentHtml: editor.contentHtml,
|
||||
contentJson: editor.contentJson,
|
||||
hideDate: editor.hideDate,
|
||||
hideSignature: editor.hideSignature,
|
||||
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : '',
|
||||
dueAt: editor.dueAt ? new Date(editor.dueAt).toISOString() : new Date().toISOString(),
|
||||
createdBy: editor.id ? undefined : viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
updatedBy: viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||
}
|
||||
@@ -475,14 +477,17 @@ export function ReportsPage({ role }) {
|
||||
</section>
|
||||
|
||||
{editorOpen ? (
|
||||
<ReportEditorModal
|
||||
<ReportEditorModalV3
|
||||
currentProfessional={currentProfessional}
|
||||
editor={editor}
|
||||
isDoctorRole={isDoctorRole}
|
||||
onChange={setEditor}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={handleSave}
|
||||
patientOptions={patientOptions}
|
||||
professionalOptions={professionalOptions}
|
||||
saving={saving}
|
||||
viewerProfile={viewerProfile}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -523,195 +528,258 @@ function ReportRow({ onEdit, onView, report }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||
const isValid = Boolean(editor.patientId)
|
||||
const filteredRequesterOptions = professionalOptions
|
||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
||||
.slice(0, 6)
|
||||
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 [templatesOpen, setTemplatesOpen] = useState(false)
|
||||
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 query = normalizeSearch(templateSearch)
|
||||
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
||||
return matchesSearch
|
||||
})
|
||||
|
||||
function updateField(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) {
|
||||
setTemplatesOpen(false)
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
exam: current.exam || template.exam,
|
||||
cidCode: current.cidCode || template.cidCode,
|
||||
diagnosis: current.diagnosis || template.diagnosis,
|
||||
conclusion: current.conclusion || template.conclusion,
|
||||
contentHtml: current.contentHtml ? `${current.contentHtml}<hr>${template.contentHtml}` : template.contentHtml,
|
||||
contentJson: {
|
||||
templateId: template.id,
|
||||
templateTitle: template.title,
|
||||
appliedAt: new Date().toISOString(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||
<div className="report-editor-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3" onClick={onClose}>
|
||||
<div
|
||||
className="flex max-h-[92vh] w-full max-w-4xl flex-col rounded-2xl border border-[#404040] bg-[#262626] shadow-xl"
|
||||
className="report-editor-shell flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden 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">
|
||||
<h2 className="text-lg font-bold text-[#e5e5e5]">
|
||||
{editor.id ? 'Editar relatório' : 'Novo relatório'}
|
||||
</h2>
|
||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||
<div className="report-editor-header flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{editor.id ? 'Editar relatório' : 'Novo relatório'}</h2>
|
||||
<p className="text-xs text-[#a3a3a3]">Escolha um template opcional e edite o conteúdo do relatório.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4" name="x" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Paciente *">
|
||||
<select className={inputClass} onChange={(event) => updateField('patientId', event.target.value)} value={editor.patientId}>
|
||||
<option value="">Selecione um paciente</option>
|
||||
{patientOptions.map((patient) => (
|
||||
<option key={patient.id} value={patient.id}>
|
||||
{patient.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Status">
|
||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||
<div className="grid min-h-0 flex-1">
|
||||
<main className="report-editor-body min-h-0 overflow-y-auto p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<DarkField label="Status *">
|
||||
<select className={`${inputClass} md:w-52`} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="finalized">Finalizado</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
className="report-template-trigger inline-flex h-10 items-center gap-2 rounded-sm border border-[#404040] bg-[#171717] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||
onClick={() => setTemplatesOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-4" name="file" />
|
||||
Templates
|
||||
<ReportIcon className="size-4" name="chevron-right" />
|
||||
</button>
|
||||
|
||||
{templatesOpen ? (
|
||||
<div className="report-template-menu absolute right-0 top-12 z-10 w-[min(28rem,calc(100vw-2rem))] rounded-md border border-[#404040] bg-[#202020] p-3 shadow-2xl">
|
||||
<div className="relative mb-3">
|
||||
<ReportIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
|
||||
<input
|
||||
className="h-10 w-full rounded-sm border border-[#404040] bg-[#171717] pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6]"
|
||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
||||
placeholder="Buscar templates..."
|
||||
value={templateSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{filteredTemplates.length ? (
|
||||
filteredTemplates.map((template) => (
|
||||
<button
|
||||
className="block w-full rounded-sm border border-transparent px-3 py-3 text-left transition hover:border-[#3b82f6]/40 hover:bg-[#303030]"
|
||||
key={template.id}
|
||||
onClick={() => applyTemplate(template)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-[#f5f5f5]">{template.title}</span>
|
||||
{template.popular ? <span className="rounded bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold text-amber-300">Popular</span> : null}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-[#a3a3a3]">{template.description}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-4 text-sm text-[#a3a3a3]">Nenhum template encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="Exame">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('exam', event.target.value)}
|
||||
placeholder="Nome do exame"
|
||||
value={editor.exam}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Solicitante">
|
||||
<div className="space-y-2">
|
||||
<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="Pesquisar médico"
|
||||
placeholder="Digite o nome do médico solicitante"
|
||||
readOnly={isDoctorRole}
|
||||
type="search"
|
||||
value={requesterSearch}
|
||||
value={isDoctorRole ? doctorRequesterName : requesterSearch}
|
||||
/>
|
||||
<div className="max-h-36 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
||||
{filteredRequesterOptions.length ? (
|
||||
filteredRequesterOptions.map((professional) => (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
|
||||
editor.requestedBy === professional.name ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
||||
}`}
|
||||
key={professional.id || professional.createdByValue || professional.name}
|
||||
onClick={() => {
|
||||
setRequesterSearch(professional.name)
|
||||
updateField('requestedBy', professional.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{professional.name}</span>
|
||||
{editor.requestedBy === professional.name ? <ReportIcon className="size-3.5" name="check" /> : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="px-3 py-2 text-sm text-[#a3a3a3]">Nenhum médico encontrado.</p>
|
||||
)}
|
||||
</div>
|
||||
{!isDoctorRole && requesterSearch ? (
|
||||
<SearchMenu
|
||||
emptyText="Nenhum médico encontrado."
|
||||
items={filteredProfessionals.slice(0, 6)}
|
||||
onSelect={selectRequester}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DarkField label="CID-10">
|
||||
<input
|
||||
className={inputClass}
|
||||
onChange={(event) => updateField('cidCode', event.target.value)}
|
||||
placeholder="Ex: Z01.7"
|
||||
value={editor.cidCode}
|
||||
/>
|
||||
<DarkField label="Exame *">
|
||||
<input className={inputClass} onChange={(event) => updateField('exam', event.target.value)} value={editor.exam} />
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Prazo">
|
||||
<input
|
||||
className={`${inputClass} [color-scheme:dark]`}
|
||||
onChange={(event) => updateField('dueAt', event.target.value)}
|
||||
type="datetime-local"
|
||||
value={editor.dueAt}
|
||||
/>
|
||||
<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="Diagnóstico">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('diagnosis', event.target.value)}
|
||||
placeholder="Diagnóstico do relatório"
|
||||
value={editor.diagnosis}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Conclusão">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('conclusion', event.target.value)}
|
||||
placeholder="Conclusão do relatório"
|
||||
value={editor.conclusion}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Complemento">
|
||||
<textarea
|
||||
className={`${textareaClass} min-h-72`}
|
||||
onChange={(event) => updateField('contentHtml', event.target.value)}
|
||||
<DarkField label="Editor de texto">
|
||||
<RichTextEditor
|
||||
onChange={(value) => updateField('contentHtml', value)}
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||
<input
|
||||
checked={editor.hideDate}
|
||||
className="size-4 accent-[#3b82f6]"
|
||||
onChange={(event) => updateField('hideDate', event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
Ocultar data
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||
<input
|
||||
checked={editor.hideSignature}
|
||||
className="size-4 accent-[#3b82f6]"
|
||||
onChange={(event) => updateField('hideSignature', event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
Ocultar assinatura
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[#404040] px-6 py-4">
|
||||
<button
|
||||
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!isValid || saving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-3.5" name="save" />
|
||||
{saving ? 'Salvando...' : 'Salvar relatório'}
|
||||
</button>
|
||||
<div className="report-editor-footer flex flex-wrap items-center justify-between gap-3 border-t border-[#404040] px-6 py-4">
|
||||
<p className="text-xs font-semibold text-amber-300">
|
||||
{!isValid ? '* Preencha o editor de texto para salvar.' : 'Relatório pronto para salvar.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button className="rounded-sm border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]" onClick={onClose} type="button">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
|
||||
disabled={!isValid || saving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-3.5" name="save" />
|
||||
{saving ? 'Salvando...' : editor.status === 'finalized' ? 'Liberar relatório' : 'Salvar rascunho'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/\son\w+="[^"]*"/gi, '')
|
||||
}
|
||||
|
||||
function ReportViewModal({ onClose, report }) {
|
||||
const currentStatus = statusConfig[report.status] || statusConfig.draft
|
||||
|
||||
@@ -760,20 +828,13 @@ function ReportViewModal({ onClose, report }) {
|
||||
<DetailBlock label="Diagnóstico" value={report.diagnosis || '-'} />
|
||||
<DetailBlock label="Conclusão" value={report.conclusion || '-'} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-xs text-[#a3a3a3]">
|
||||
<span className="rounded-full border border-[#404040] px-3 py-1">
|
||||
{report.hideDate ? 'Data oculta' : 'Data visivel'}
|
||||
</span>
|
||||
<span className="rounded-full border border-[#404040] px-3 py-1">
|
||||
{report.hideSignature ? 'Assinatura oculta' : 'Assinatura visivel'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<p className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]">{report.contentHtml}</p>
|
||||
<div
|
||||
className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizePreviewHtml(report.contentHtml) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-[#a3a3a3]">Nenhum complemento informado.</p>
|
||||
)}
|
||||
@@ -793,12 +854,25 @@ function FilterField({ children, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DarkField({ children, label }) {
|
||||
function SearchMenu({ emptyText, items, onSelect }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className={labelClass}>{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
<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">
|
||||
{items.length ? (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -884,6 +958,27 @@ function uniqueValues(values) {
|
||||
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
function isReportEditorValid(editor) {
|
||||
return [
|
||||
editor.patientId,
|
||||
editor.requestedBy,
|
||||
editor.exam,
|
||||
editor.cidCode,
|
||||
editor.diagnosis,
|
||||
editor.conclusion,
|
||||
editor.status,
|
||||
stripHtml(editor.contentHtml),
|
||||
].every((value) => String(value || '').trim())
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
@@ -943,8 +1038,8 @@ function printReportAsPdf(report, status) {
|
||||
<p class="value">${escapeHtml(report.conclusion || '-')}</p>
|
||||
</div>
|
||||
<div class="section box">
|
||||
<p class="label">Complemento</p>
|
||||
<p class="value">${escapeHtml(report.contentHtml || 'Nenhum complemento informado.')}</p>
|
||||
<p class="label">Relatório</p>
|
||||
<div class="value">${report.contentHtml ? sanitizePreviewHtml(report.contentHtml) : 'Nenhum complemento informado.'}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -991,6 +1086,105 @@ function ReportIcon({ className = 'size-4', name }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'bolt') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m13 2-8 12h6l-1 8 8-12h-6l1-8Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'search') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'undo') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M9 7 5 11l4 4" />
|
||||
<path d="M5 11h9a5 5 0 0 1 5 5v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'redo') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m15 7 4 4-4 4" />
|
||||
<path d="M19 11h-9a5 5 0 0 0-5 5v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'bold') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 5h6a3 3 0 0 1 0 6H7zM7 11h7a3 3 0 0 1 0 6H7z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'italic') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M10 5h7M7 19h7M14 5l-4 14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'underline') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 5v6a5 5 0 0 0 10 0V5M5 21h14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'strike') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M5 12h14M8 17a5 5 0 0 0 4 2c2.8 0 5-1.4 5-3.5 0-4-9-2.5-9-7C8 6.6 9.8 5 12.5 5c1.6 0 3 .5 4 1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'align-left') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 6h16M4 10h10M4 14h16M4 18h10" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'align-center') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 6h16M7 10h10M4 14h16M7 18h10" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'align-right') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M4 6h16M10 10h10M4 14h16M10 18h10" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'list') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'file') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { settingsRepository } from '../repositories/settingsRepository.js'
|
||||
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
|
||||
|
||||
@@ -16,13 +15,6 @@ export function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<FeatureCallout
|
||||
className="mb-6"
|
||||
description="Preferências, integrações e backup ainda são protótipos locais, sem persistência real."
|
||||
status="mock"
|
||||
title="Configurações ainda estão em modo protótipo"
|
||||
/>
|
||||
|
||||
<header className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
|
||||
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>
|
||||
@@ -54,10 +46,7 @@ export function SettingsPage() {
|
||||
|
||||
<section className={`${cardClass} min-w-0 flex-1 p-6 lg:p-8`}>
|
||||
{activeSection === 'aparencia' ? <AppearanceSection /> : null}
|
||||
{activeSection === 'notificacoes' ? <NotificationsSection /> : null}
|
||||
{activeSection === 'privacidade' ? <PrivacySection /> : null}
|
||||
{activeSection === 'conta' ? <AccountSection /> : null}
|
||||
{activeSection === 'integracoes' ? <IntegrationsSection /> : null}
|
||||
{activeSection === 'dados' ? <DataSection /> : null}
|
||||
</section>
|
||||
</div>
|
||||
@@ -76,29 +65,33 @@ function AppearanceSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência">
|
||||
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência e Acessibilidade">
|
||||
<div className="mb-8">
|
||||
<p className="mb-4 text-sm font-semibold text-[#e5e5e5]">Tema da Interface</p>
|
||||
<div className="grid max-w-xl gap-4 sm:grid-cols-2">
|
||||
{[
|
||||
{ id: 'dark', label: 'Escuro', preview: 'bg-[#0a1628]' },
|
||||
{ id: 'dark', label: 'Escuro', preview: 'bg-[#0a0a0a]' },
|
||||
{ id: 'light', label: 'Claro', preview: 'bg-[#f4f7fb]' },
|
||||
].map((item) => (
|
||||
<button
|
||||
className={`rounded-2xl border-2 p-4 text-left transition ${
|
||||
theme === item.id ? 'border-[#3b82f6] bg-[#3b82f6]/5 shadow-md shadow-[#3b82f6]/20' : 'border-[#404040] bg-[#262626] hover:border-[#3b82f6]/40'
|
||||
theme === item.id
|
||||
? item.id === 'dark'
|
||||
? 'border-[#737373] bg-[#171717] shadow-md shadow-black/30'
|
||||
: 'border-[#3b82f6] bg-[#3b82f6]/5 shadow-md shadow-[#3b82f6]/20'
|
||||
: 'border-[#404040] bg-[#262626] hover:border-[#737373]'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => handleThemeChange(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`mb-3 flex h-20 flex-col gap-1.5 overflow-hidden rounded-xl border border-[#404040] p-2 ${item.preview}`}>
|
||||
<span className={`h-2.5 rounded ${item.id === 'dark' ? 'bg-[#1a3050]' : 'bg-white'}`} />
|
||||
<span className={`settings-theme-preview ${item.id === 'dark' ? 'settings-theme-preview-dark' : 'settings-theme-preview-light'} mb-3 flex h-20 flex-col gap-1.5 overflow-hidden rounded-xl border border-[#404040] p-2 ${item.preview}`}>
|
||||
<span className={`settings-theme-preview-bar h-2.5 rounded ${item.id === 'dark' ? 'bg-[#262626]' : 'bg-white'}`} />
|
||||
<span className="flex flex-1 gap-1">
|
||||
<span className={`w-8 rounded ${item.id === 'dark' ? 'bg-[#0f1f36]' : 'bg-white'}`} />
|
||||
<span className={`settings-theme-preview-side w-8 rounded ${item.id === 'dark' ? 'bg-[#171717]' : 'bg-white'}`} />
|
||||
<span className="flex flex-1 flex-col justify-center gap-1">
|
||||
<span className={`h-1.5 w-3/4 rounded-full ${item.id === 'dark' ? 'bg-[#1e3a5f]' : 'bg-[#dde8f7]'}`} />
|
||||
<span className={`h-1.5 w-1/2 rounded-full ${item.id === 'dark' ? 'bg-[#1e3a5f]' : 'bg-[#dde8f7]'}`} />
|
||||
<span className={`settings-theme-preview-line h-1.5 w-3/4 rounded-full ${item.id === 'dark' ? 'bg-[#525252]' : 'bg-[#dde8f7]'}`} />
|
||||
<span className={`settings-theme-preview-line h-1.5 w-1/2 rounded-full ${item.id === 'dark' ? 'bg-[#404040]' : 'bg-[#dde8f7]'}`} />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -134,50 +127,6 @@ function AppearanceSection() {
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationsSection() {
|
||||
const [settings, setSettings] = useState({
|
||||
email: true,
|
||||
sms: true,
|
||||
whatsapp: true,
|
||||
push: false,
|
||||
ai: true,
|
||||
appointment: true,
|
||||
report: true,
|
||||
noShow: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<SectionFrame description="Configure como e quando deseja receber alertas." title="Notificações">
|
||||
<Subsection title="Canais de Comunicação">
|
||||
<ToggleRow checked={settings.email} description="Receba resumos e alertas via e-mail" label="Notificações por E-mail" onChange={(value) => setSettings((current) => ({ ...current, email: value }))} />
|
||||
<ToggleRow checked={settings.sms} description="Alertas urgentes via mensagem de texto" label="SMS" onChange={(value) => setSettings((current) => ({ ...current, sms: value }))} />
|
||||
<ToggleRow checked={settings.whatsapp} description="Integração com WhatsApp Business para lembretes" label="WhatsApp" onChange={(value) => setSettings((current) => ({ ...current, whatsapp: value }))} />
|
||||
<ToggleRow checked={settings.push} description="Notificações no navegador em tempo real" label="Push (navegador)" onChange={(value) => setSettings((current) => ({ ...current, push: value }))} />
|
||||
</Subsection>
|
||||
|
||||
<Subsection title="Tipos de Alerta">
|
||||
<ToggleRow checked={settings.ai} description="Alerta preditivo quando paciente tem alto risco de faltar" label="Risco de No-Show (IA)" onChange={(value) => setSettings((current) => ({ ...current, ai: value }))} />
|
||||
<ToggleRow checked={settings.appointment} description="Lembre pacientes 24h e 1h antes da consulta" label="Lembrete de Consulta" onChange={(value) => setSettings((current) => ({ ...current, appointment: value }))} />
|
||||
<ToggleRow checked={settings.report} description="Notificar quando relatórios mensais estiverem prontos" label="Relatório Disponível" onChange={(value) => setSettings((current) => ({ ...current, report: value }))} />
|
||||
<ToggleRow checked={settings.noShow} description="Confirmar quando uma falta é registrada no sistema" label="No-Show registrado" onChange={(value) => setSettings((current) => ({ ...current, noShow: value }))} />
|
||||
</Subsection>
|
||||
|
||||
<Subsection title="Horário Silencioso">
|
||||
<SettingRow description="Sem notificações push entre 22h e 7h" label="Ativar horário silencioso">
|
||||
<ToggleSwitch checked onChange={() => {}} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Horário de início / fim">
|
||||
<div className="flex items-center gap-2">
|
||||
<input className={`${inputClass} w-28`} defaultValue="22:00" type="time" />
|
||||
<span className="text-sm text-[#a3a3a3]">até</span>
|
||||
<input className={`${inputClass} w-28`} defaultValue="07:00" type="time" />
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Subsection>
|
||||
</SectionFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacySection() {
|
||||
const [twoFactor, setTwoFactor] = useState(false)
|
||||
const [audit, setAudit] = useState(true)
|
||||
@@ -227,81 +176,6 @@ function PrivacySection() {
|
||||
)
|
||||
}
|
||||
|
||||
function AccountSection() {
|
||||
const [profile, setProfile] = useState({
|
||||
name: 'Dra. Ana Silva',
|
||||
email: 'ana.silva@mediconnect.com.br',
|
||||
role: 'Coordenação Médica',
|
||||
crm: 'CRM/SE 12345',
|
||||
})
|
||||
|
||||
function update(field, value) {
|
||||
setProfile((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionFrame description="Gerencie suas informações pessoais e credenciais." title="Conta & Perfil">
|
||||
<div className="mb-6 flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-5">
|
||||
<div className="grid size-16 place-items-center rounded-full border-2 border-[#3b82f6]/20 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
||||
AS
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-[#f5f5f5]">{profile.name}</p>
|
||||
<p className="text-xs text-[#a3a3a3]">{profile.role}</p>
|
||||
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
|
||||
Alterar foto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TextField label="Nome completo" onChange={(value) => update('name', value)} value={profile.name} />
|
||||
<TextField label="E-mail" onChange={(value) => update('email', value)} value={profile.email} />
|
||||
<TextField label="Cargo / Função" onChange={(value) => update('role', value)} value={profile.role} />
|
||||
<TextField label="CRM / Registro" onChange={(value) => update('crm', value)} value={profile.crm} />
|
||||
</div>
|
||||
|
||||
<Subsection title="Segurança">
|
||||
<SettingRow description="Última alteração há 45 dias" label="Alterar senha">
|
||||
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
|
||||
Alterar
|
||||
</button>
|
||||
</SettingRow>
|
||||
<SettingRow description="Gerenciar dispositivos conectados" label="Sessões ativas">
|
||||
<button className="text-sm font-semibold text-[#3b82f6]" type="button">
|
||||
Ver sessões
|
||||
</button>
|
||||
</SettingRow>
|
||||
</Subsection>
|
||||
</SectionFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function IntegrationsSection() {
|
||||
const integrations = settingsRepository.getIntegrations()
|
||||
|
||||
return (
|
||||
<SectionFrame description="Conecte o MediConnect com sistemas e serviços externos." title="Integrações">
|
||||
<div className="space-y-3">
|
||||
{integrations.map(([name, desc, connected, color]) => (
|
||||
<div className="flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-4" key={name}>
|
||||
<div className={`grid size-10 shrink-0 place-items-center rounded-lg ${color}`}>
|
||||
<SettingsIcon className="size-5 text-white" name="globe" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-[#f5f5f5]">{name}</p>
|
||||
<p className="text-xs text-[#a3a3a3]">{desc}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${connected ? 'bg-[#303030] text-[#d4d4d4]' : 'bg-[#303030] text-[#a3a3a3]'}`}>
|
||||
{connected ? 'Conectado' : 'Desconectado'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function DataSection() {
|
||||
return (
|
||||
<SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup">
|
||||
@@ -391,15 +265,6 @@ function SettingRow({ children, description, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TextField({ label, onChange, value }) {
|
||||
return (
|
||||
<label className="grid gap-2">
|
||||
<span className="text-xs font-semibold text-[#a3a3a3]">{label}</span>
|
||||
<input className={`${inputClass} w-full`} onChange={(event) => onChange(event.target.value)} value={value} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleSwitch({ checked, onChange }) {
|
||||
return (
|
||||
<button
|
||||
@@ -425,15 +290,6 @@ function SettingsIcon({ className = 'size-4', name }) {
|
||||
viewBox: '0 0 24 24',
|
||||
}
|
||||
|
||||
if (name === 'bell') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||
<path d="M10 21h4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'shield') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
@@ -442,23 +298,6 @@ function SettingsIcon({ className = 'size-4', name }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'user') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'globe') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'database') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { userRepository } from '../repositories/userRepository.js'
|
||||
|
||||
@@ -18,6 +19,11 @@ const authMethodOptions = [
|
||||
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 = {
|
||||
email: '',
|
||||
full_name: '',
|
||||
@@ -28,6 +34,8 @@ const initialUserForm = {
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
create_patient_record: false,
|
||||
crm: '',
|
||||
crm_uf: '',
|
||||
}
|
||||
|
||||
export function UsersPage({ role: currentRole }) {
|
||||
@@ -37,6 +45,8 @@ export function UsersPage({ role: currentRole }) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState(null)
|
||||
const [editingUserId, setEditingUserId] = useState(null)
|
||||
const [selectedUser, setSelectedUser] = useState(null)
|
||||
const [form, setForm] = useState(initialUserForm)
|
||||
const [roleFilter, setRoleFilter] = useState('Todos')
|
||||
|
||||
@@ -44,6 +54,7 @@ export function UsersPage({ role: currentRole }) {
|
||||
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
|
||||
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||
const isPasswordCreation = form.auth_method === 'password'
|
||||
const isDoctorForm = normalizeRole(form.role) === 'medico'
|
||||
const filterableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||
const filteredUsers = users.filter((user) => {
|
||||
if (roleFilter === 'Todos') return true
|
||||
@@ -72,6 +83,28 @@ export function UsersPage({ role: currentRole }) {
|
||||
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) {
|
||||
event.preventDefault()
|
||||
if (!canManageUsers) {
|
||||
@@ -84,7 +117,12 @@ export function UsersPage({ role: currentRole }) {
|
||||
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) {
|
||||
window.alert('Preencha a senha e a confirmação de senha.')
|
||||
return
|
||||
@@ -103,7 +141,11 @@ export function UsersPage({ role: currentRole }) {
|
||||
|
||||
setSaving(true)
|
||||
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)
|
||||
window.alert(`Usuário criado com email e senha para ${form.email}.`)
|
||||
} else {
|
||||
@@ -111,10 +153,11 @@ export function UsersPage({ role: currentRole }) {
|
||||
window.alert(`Usuário criado! Magic Link enviado para ${form.email}.`)
|
||||
}
|
||||
setModalOpen(false)
|
||||
setEditingUserId(null)
|
||||
setForm(initialUserForm)
|
||||
loadUsers()
|
||||
if (!editingUserId) loadUsers()
|
||||
} catch (err) {
|
||||
window.alert(`Erro ao criar usuário: ${err.message}`)
|
||||
window.alert(`Erro ao salvar usuário: ${err.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -160,7 +203,7 @@ export function UsersPage({ role: currentRole }) {
|
||||
</div>
|
||||
<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"
|
||||
onClick={() => setModalOpen(true)}
|
||||
onClick={openCreateModal}
|
||||
type="button"
|
||||
>
|
||||
+ Novo usuário
|
||||
@@ -212,7 +255,7 @@ export function UsersPage({ role: currentRole }) {
|
||||
filteredUsers.map((user) => {
|
||||
const userRole = getUserRole(user)
|
||||
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">
|
||||
<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]">
|
||||
@@ -238,7 +281,10 @@ export function UsersPage({ role: currentRole }) {
|
||||
<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"
|
||||
disabled={deletingId === user.id}
|
||||
onClick={() => handleDelete(user)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleDelete(user)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{deletingId === user.id ? 'Deletando...' : 'Deletar'}
|
||||
@@ -260,18 +306,32 @@ export function UsersPage({ role: currentRole }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUser ? (
|
||||
<UserDetailModal
|
||||
onClose={() => setSelectedUser(null)}
|
||||
onDelete={handleDelete}
|
||||
onEdit={openEditModal}
|
||||
user={selectedUser}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{modalOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setModalOpen(false)}>
|
||||
<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()}
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[#e5e5e5]">Novo Usuário</h2>
|
||||
<p className="mt-1 text-xs text-[#a3a3a3]">
|
||||
{isPasswordCreation ? 'Crie o acesso inicial com email e senha.' : 'Um Magic Link sera enviado para o email cadastrado.'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333]"
|
||||
@@ -282,7 +342,8 @@ export function UsersPage({ role: currentRole }) {
|
||||
</button>
|
||||
</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>
|
||||
<span className={darkLabel}>Criar usuário usando *</span>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -316,6 +377,7 @@ export function UsersPage({ role: currentRole }) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className={darkLabel}>Nome completo *</label>
|
||||
@@ -417,6 +479,38 @@ export function UsersPage({ role: currentRole }) {
|
||||
</select>
|
||||
</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]">
|
||||
<input
|
||||
checked={form.create_patient_record}
|
||||
@@ -428,7 +522,9 @@ export function UsersPage({ role: currentRole }) {
|
||||
Criar também um registro de paciente
|
||||
</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
|
||||
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||
disabled={saving}
|
||||
@@ -442,7 +538,7 @@ export function UsersPage({ role: currentRole }) {
|
||||
disabled={saving}
|
||||
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>
|
||||
</div>
|
||||
</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 }) {
|
||||
const styles = {
|
||||
admin: 'bg-purple-500/20 text-purple-400',
|
||||
|
||||
@@ -18,20 +18,20 @@ export function VisitsPage({ navigate }) {
|
||||
|
||||
const visibleQueue = useMemo(() => {
|
||||
if (activeTab === 'finalizadas') {
|
||||
return careQueue.filter((item) => item.status === 'Finalizada')
|
||||
return careQueue.filter((item) => isFinalizedStatus(item.status))
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
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: '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 (
|
||||
@@ -160,3 +160,21 @@ function PriorityPill({ priority }) {
|
||||
|
||||
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, '')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { appointmentMapper } from '../mappers/appointmentMapper.js'
|
||||
import { getResponseError, normalizeItem } from './repositoryUtils.js'
|
||||
|
||||
export const appointmentRepository = {
|
||||
async getAll({ doctorId } = {}) {
|
||||
@@ -9,7 +10,7 @@ export const appointmentRepository = {
|
||||
headers: getAuthenticatedHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar agendamentos.')
|
||||
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao buscar agendamentos.'))
|
||||
|
||||
const data = await response.json()
|
||||
return (Array.isArray(data) ? data : []).map(appointmentMapper.toUi)
|
||||
@@ -22,10 +23,26 @@ export const appointmentRepository = {
|
||||
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Falha ao criar o agendamento.')
|
||||
if (!response.ok) throw new Error(await getResponseError(response, 'Falha ao criar o agendamento.'))
|
||||
|
||||
const data = await response.json()
|
||||
const item = Array.isArray(data) ? data[0] : data
|
||||
return appointmentMapper.toUi(item)
|
||||
}
|
||||
return appointmentMapper.toUi(normalizeItem(data))
|
||||
},
|
||||
|
||||
async update(id, uiData) {
|
||||
const response = await fetch(`${apiConfig.restUrl}/appointments?id=eq.${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(await getResponseError(response, 'Falha ao atualizar o agendamento.'))
|
||||
|
||||
const data = await response.json()
|
||||
return appointmentMapper.toUi(normalizeItem(data))
|
||||
},
|
||||
|
||||
async cancel(id, uiData) {
|
||||
return this.update(id, { ...uiData, status: 'Cancelada' })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
hasAuthenticatedSession,
|
||||
saveAuthSession,
|
||||
} from '../config/api.js'
|
||||
import { translateErrorMessage } from './repositoryUtils.js'
|
||||
import { getResponseError } from './repositoryUtils.js'
|
||||
|
||||
export const authRepository = {
|
||||
async login({ email, password }) {
|
||||
@@ -60,6 +60,20 @@ export const authRepository = {
|
||||
return true
|
||||
},
|
||||
|
||||
async sendMagicLink(email) {
|
||||
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/otp`, {
|
||||
method: 'POST',
|
||||
headers: getAnonHeaders(),
|
||||
body: JSON.stringify({ email: email?.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao enviar Magic Link.'))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
async getUser() {
|
||||
const apiResponse = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/user-info`, {
|
||||
method: 'POST',
|
||||
@@ -118,8 +132,3 @@ export const authRepository = {
|
||||
function shouldFallback(response) {
|
||||
return [404, 405].includes(response.status)
|
||||
}
|
||||
|
||||
async function getResponseError(response, fallbackMessage) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
return translateErrorMessage(error.error_description || error.msg || error.message || error.error || fallbackMessage)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,308 @@
|
||||
const STORAGE_KEY = 'mediconnect.medicalRecords.v2'
|
||||
|
||||
const INITIAL_RECORDS = [
|
||||
{
|
||||
id: 'record-1',
|
||||
patientId: 'mock-carlos-eduardo',
|
||||
patient: 'Carlos Eduardo Santos',
|
||||
patientDocument: 'CPF nao informado',
|
||||
patientEmail: 'carlos.santos@example.com',
|
||||
patientPhone: '11999990001',
|
||||
dateTime: '2026-03-27T10:30',
|
||||
createdAt: '2026-03-27T10:30:00.000Z',
|
||||
updatedAt: '2026-03-27T10:30:00.000Z',
|
||||
doctor: 'Dra. Ana Silva',
|
||||
type: 'Consulta Retorno',
|
||||
cid: 'I10 - Hipertensao',
|
||||
status: 'completo',
|
||||
summary: 'Paciente relata melhora com medicacao. PA: 130/85. Mantida conduta.',
|
||||
diagnosticReasoning: 'Quadro compativel com hipertensao arterial sistemica em acompanhamento, com melhora apos adesao medicamentosa.',
|
||||
diagnosticHypotheses: 'HAS primaria; efeito de baixa adesao previa ao tratamento; risco cardiovascular global moderado.',
|
||||
definitiveDiagnosis: 'Hipertensao arterial sistemica controlada.',
|
||||
prescriptions: 'Manter losartana 50 mg de 12/12h e hidroclorotiazida 25 mg pela manha.',
|
||||
procedures: 'Afericao pressorica seriada e orientacao sobre automonitoramento domiciliar.',
|
||||
surgeries: 'Nao se aplica no atendimento atual.',
|
||||
orientations: 'Reduzir sodio, manter atividade fisica regular e retornar com diario pressorico.',
|
||||
labResults: 'Exames laboratoriais sem alteracoes relevantes no periodo.',
|
||||
imageResults: 'Sem exames de imagem novos para este atendimento.',
|
||||
multiprofessionalNotes: 'Enfermagem orientou tecnica correta de afericao de pressao arterial.',
|
||||
signature: 'Dra. Ana Silva - CRM 123456',
|
||||
professionalStamp: 'Assinado digitalmente por Dra. Ana Silva em 27/03/2026 10:30',
|
||||
},
|
||||
{
|
||||
id: 'record-2',
|
||||
patientId: 'mock-mariana-costa',
|
||||
patient: 'Mariana Costa',
|
||||
patientDocument: 'CPF nao informado',
|
||||
patientEmail: 'mariana.costa@example.com',
|
||||
patientPhone: '11999990002',
|
||||
dateTime: '2026-03-26T15:00',
|
||||
createdAt: '2026-03-26T15:00:00.000Z',
|
||||
updatedAt: '2026-03-26T15:00:00.000Z',
|
||||
doctor: 'Dra. Ana Silva',
|
||||
type: 'Exame',
|
||||
cid: 'Z01.7 - Exame laboratorial',
|
||||
status: 'completo',
|
||||
summary: 'Resultados de hemograma dentro da normalidade. Solicitar retorno em 6 meses.',
|
||||
diagnosticReasoning: 'Resultados laboratoriais analisados em conjunto com quadro clinico estavel.',
|
||||
diagnosticHypotheses: 'Acompanhamento preventivo sem sinais laboratoriais de alarme.',
|
||||
definitiveDiagnosis: 'Exame laboratorial sem alteracoes clinicamente significativas.',
|
||||
prescriptions: 'Sem nova prescricao medicamentosa.',
|
||||
procedures: 'Revisao de exames laboratoriais e comparacao com historico previo.',
|
||||
surgeries: 'Nao se aplica.',
|
||||
orientations: 'Manter rotina preventiva e retorno em 6 meses ou antes se houver sintomas.',
|
||||
labResults: 'Hemograma completo dentro dos parametros de referencia.',
|
||||
imageResults: 'Sem exames de imagem relacionados.',
|
||||
multiprofessionalNotes: 'Equipe administrativa orientou retirada de copia dos exames.',
|
||||
signature: 'Dra. Ana Silva - CRM 123456',
|
||||
professionalStamp: 'Assinado digitalmente por Dra. Ana Silva em 26/03/2026 15:00',
|
||||
},
|
||||
{
|
||||
id: 'record-3',
|
||||
patientId: 'mock-joao-pedro',
|
||||
patient: 'Joao Pedro Alves',
|
||||
patientDocument: 'CPF nao informado',
|
||||
patientEmail: 'joao.alves@example.com',
|
||||
patientPhone: '11999990003',
|
||||
dateTime: '2026-03-25T09:15',
|
||||
createdAt: '2026-03-25T09:15:00.000Z',
|
||||
updatedAt: '2026-03-25T09:15:00.000Z',
|
||||
doctor: 'Dr. Carlos Mendes',
|
||||
type: 'Primeira Consulta',
|
||||
cid: 'R10 - Dor abdominal',
|
||||
status: 'rascunho',
|
||||
summary: 'Queixa de dor abdominal ha 2 semanas. Solicitados exames complementares.',
|
||||
diagnosticReasoning: 'Dor abdominal subaguda, sem sinais de peritonite, em investigacao etiologica.',
|
||||
diagnosticHypotheses: 'Dispepsia funcional; gastrite; doenca biliar; sindrome do intestino irritavel.',
|
||||
definitiveDiagnosis: 'Diagnostico definitivo pendente de exames complementares.',
|
||||
prescriptions: 'Sintomatico conforme dor e orientacao de retorno se piora.',
|
||||
procedures: 'Exame fisico abdominal e solicitacao de exames complementares.',
|
||||
surgeries: 'Nao indicada ate o momento.',
|
||||
orientations: 'Retornar com exames, procurar urgencia se febre, vomitos persistentes ou dor intensa.',
|
||||
labResults: 'Hemograma, PCR e funcao hepatica solicitados.',
|
||||
imageResults: 'Ultrassonografia abdominal solicitada.',
|
||||
multiprofessionalNotes: 'Nutricionista podera ser acionada conforme resultado dos exames.',
|
||||
signature: 'Dr. Carlos Mendes - CRM 654321',
|
||||
professionalStamp: 'Rascunho criado por Dr. Carlos Mendes em 25/03/2026 09:15',
|
||||
},
|
||||
{
|
||||
id: 'record-4',
|
||||
patientId: 'mock-fernanda-lima',
|
||||
patient: 'Fernanda Lima',
|
||||
patientDocument: 'CPF nao informado',
|
||||
patientEmail: 'fernanda.lima@example.com',
|
||||
patientPhone: '11999990004',
|
||||
dateTime: '2026-03-24T11:00',
|
||||
createdAt: '2026-03-24T11:00:00.000Z',
|
||||
updatedAt: '2026-03-24T11:00:00.000Z',
|
||||
doctor: 'Dra. Ana Silva',
|
||||
type: 'Avaliacao Pre-Op',
|
||||
cid: 'K80 - Colelitiase',
|
||||
status: 'completo',
|
||||
summary: 'Apta para procedimento cirurgico. Exames pre-operatorios normais.',
|
||||
diagnosticReasoning: 'Colelitiase sintomatica com avaliacao clinica favoravel para procedimento proposto.',
|
||||
diagnosticHypotheses: 'Colelitiase sintomatica; baixo risco cardiopulmonar para cirurgia eletiva.',
|
||||
definitiveDiagnosis: 'Colelitiase com indicacao de abordagem cirurgica eletiva.',
|
||||
prescriptions: 'Manter medicacoes habituais conforme orientacao anestesica.',
|
||||
procedures: 'Avaliacao pre-operatoria e revisao de exames.',
|
||||
surgeries: 'Colecistectomia videolaparoscopica proposta pela equipe cirurgica.',
|
||||
orientations: 'Jejum e orientacoes pre-operatorias conforme protocolo institucional.',
|
||||
labResults: 'Hemograma, coagulograma e funcao renal sem contraindicacoes.',
|
||||
imageResults: 'Ultrassonografia com colelitíase, sem sinais de colecistite aguda.',
|
||||
multiprofessionalNotes: 'Anestesia orientou avaliacao pre-anestesica complementar.',
|
||||
signature: 'Dra. Ana Silva - CRM 123456',
|
||||
professionalStamp: 'Assinado digitalmente por Dra. Ana Silva em 24/03/2026 11:00',
|
||||
},
|
||||
{
|
||||
id: 'record-5',
|
||||
patientId: 'mock-roberto-campos',
|
||||
patient: 'Roberto Campos',
|
||||
patientDocument: 'CPF nao informado',
|
||||
patientEmail: 'roberto.campos@example.com',
|
||||
patientPhone: '11999990005',
|
||||
dateTime: '2026-03-22T16:20',
|
||||
createdAt: '2026-03-22T16:20:00.000Z',
|
||||
updatedAt: '2026-03-22T16:20:00.000Z',
|
||||
doctor: 'Dr. Roberto Nunes',
|
||||
type: 'Consulta Retorno',
|
||||
cid: 'E11 - DM Tipo 2',
|
||||
status: 'completo',
|
||||
summary: 'HbA1c: 7.2%. Ajuste de metformina. Retorno em 3 meses.',
|
||||
diagnosticReasoning: 'Diabetes mellitus tipo 2 com controle parcial, necessitando ajuste terapeutico.',
|
||||
diagnosticHypotheses: 'DM2 em controle parcial; risco metabolico associado.',
|
||||
definitiveDiagnosis: 'Diabetes mellitus tipo 2.',
|
||||
prescriptions: 'Ajuste de metformina conforme tolerancia e manutencao de medidas nao farmacologicas.',
|
||||
procedures: 'Revisao de exames metabolicos e avaliacao de adesao.',
|
||||
surgeries: 'Nao se aplica.',
|
||||
orientations: 'Dieta, atividade fisica, monitoramento glicemico e retorno em 3 meses.',
|
||||
labResults: 'HbA1c 7,2%; demais exames revisados em consulta.',
|
||||
imageResults: 'Sem exames de imagem novos.',
|
||||
multiprofessionalNotes: 'Encaminhado para orientacao nutricional.',
|
||||
signature: 'Dr. Roberto Nunes - CRM 778899',
|
||||
professionalStamp: 'Assinado digitalmente por Dr. Roberto Nunes em 22/03/2026 16:20',
|
||||
},
|
||||
]
|
||||
|
||||
export const medicalRecordRepository = {
|
||||
getRecordTypes() {
|
||||
return ['Consulta Retorno', 'Primeira Consulta', 'Exame', 'Avaliacao Pre-Op']
|
||||
return ['Consulta Retorno', 'Primeira Consulta', 'Exame', 'Avaliacao Pre-Op', 'Evolucao Clinica', 'Registro Multiprofissional']
|
||||
},
|
||||
|
||||
getInitialRecords() {
|
||||
return readRecords()
|
||||
},
|
||||
|
||||
getAll() {
|
||||
return readRecords()
|
||||
},
|
||||
|
||||
getById(recordId) {
|
||||
return readRecords().find((record) => String(record.id) === String(recordId)) || null
|
||||
},
|
||||
|
||||
create(data) {
|
||||
const records = readRecords()
|
||||
const now = new Date().toISOString()
|
||||
const record = normalizeRecord({
|
||||
...data,
|
||||
id: data.id || `record-${Date.now()}`,
|
||||
createdAt: data.createdAt || now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
writeRecords([record, ...records])
|
||||
return record
|
||||
},
|
||||
|
||||
update(recordId, data) {
|
||||
const records = readRecords()
|
||||
const now = new Date().toISOString()
|
||||
let updatedRecord = null
|
||||
const nextRecords = records.map((record) => {
|
||||
if (String(record.id) !== String(recordId)) return record
|
||||
updatedRecord = normalizeRecord({ ...record, ...data, id: record.id, updatedAt: now })
|
||||
return updatedRecord
|
||||
})
|
||||
|
||||
writeRecords(nextRecords)
|
||||
return updatedRecord
|
||||
},
|
||||
|
||||
getMockReportHistory(patientId, patientName) {
|
||||
const baseName = patientName || 'Paciente'
|
||||
return [
|
||||
{ id: 'record-1', patient: 'Carlos Eduardo Santos', date: '27/03/2026', doctor: 'Dra. Ana Silva', type: 'Consulta Retorno', cid: 'I10 - Hipertensao', status: 'completo', summary: 'Paciente relata melhora com medicacao. PA: 130/85. Mantida conduta.' },
|
||||
{ id: 'record-2', patient: 'Mariana Costa', date: '26/03/2026', doctor: 'Dra. Ana Silva', type: 'Exame', cid: 'Z01.7 - Exame laboratorial', status: 'completo', summary: 'Resultados de hemograma dentro da normalidade. Solicitar retorno em 6 meses.' },
|
||||
{ id: 'record-3', patient: 'Joao Pedro Alves', date: '25/03/2026', doctor: 'Dr. Carlos Mendes', type: 'Primeira Consulta', cid: 'R10 - Dor abdominal', status: 'rascunho', summary: 'Queixa de dor abdominal ha 2 semanas. Solicitados exames complementares.' },
|
||||
{ id: 'record-4', patient: 'Fernanda Lima', date: '24/03/2026', doctor: 'Dra. Ana Silva', type: 'Avaliacao Pre-Op', cid: 'K80 - Colelitiase', status: 'completo', summary: 'Apta para procedimento cirurgico. Exames pre-operatorios normais.' },
|
||||
{ id: 'record-5', patient: 'Roberto Campos', date: '22/03/2026', doctor: 'Dr. Roberto Nunes', type: 'Consulta Retorno', cid: 'E11 - DM Tipo 2', status: 'completo', summary: 'HbA1c: 7.2%. Ajuste de metformina. Retorno em 3 meses.' },
|
||||
{
|
||||
id: `${patientId || 'mock'}-report-1`,
|
||||
title: 'Relatorio de consulta medica',
|
||||
status: 'Finalizado',
|
||||
createdAt: '2026-03-27T13:30:00.000Z',
|
||||
author: 'Dra. Ana Silva',
|
||||
summary: `Resumo clinico recente de ${baseName}, com conduta registrada em prontuario.`,
|
||||
},
|
||||
{
|
||||
id: `${patientId || 'mock'}-report-2`,
|
||||
title: 'Laudo de exame',
|
||||
status: 'Finalizado',
|
||||
createdAt: '2026-03-20T09:00:00.000Z',
|
||||
author: 'Dr. Carlos Mendes',
|
||||
summary: 'Resultado complementar revisado pela equipe assistencial.',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
function readRecords() {
|
||||
if (typeof window === 'undefined') return INITIAL_RECORDS.map(normalizeRecord)
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
const initial = INITIAL_RECORDS.map(normalizeRecord)
|
||||
writeRecords(initial)
|
||||
return initial
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return INITIAL_RECORDS.map(normalizeRecord)
|
||||
return parsed.map(normalizeRecord).sort(sortByDateDesc)
|
||||
} catch {
|
||||
return INITIAL_RECORDS.map(normalizeRecord)
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecords(records) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records.map(normalizeRecord).sort(sortByDateDesc)))
|
||||
} catch {
|
||||
// Local persistence is best-effort while the module is still mock-backed.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecord(record) {
|
||||
const dateTime = record.dateTime || parseLegacyDate(record.date) || toLocalInputValue(new Date())
|
||||
const summary = record.summary || record.orientations || record.diagnosticReasoning || 'Registro de prontuario sem resumo.'
|
||||
|
||||
return {
|
||||
id: String(record.id || `record-${Date.now()}`),
|
||||
patientId: String(record.patientId || ''),
|
||||
patient: record.patient || 'Paciente sem nome',
|
||||
patientDocument: record.patientDocument || record.document || '',
|
||||
patientEmail: record.patientEmail || record.email || '',
|
||||
patientPhone: record.patientPhone || record.phone || '',
|
||||
dateTime,
|
||||
date: record.date || formatDateTime(dateTime),
|
||||
createdAt: record.createdAt || toIso(dateTime),
|
||||
updatedAt: record.updatedAt || record.createdAt || toIso(dateTime),
|
||||
doctor: record.doctor || record.professional || 'Profissional nao informado',
|
||||
type: record.type || 'Primeira Consulta',
|
||||
cid: record.cid || 'CID nao informado',
|
||||
status: record.status === 'rascunho' ? 'rascunho' : 'completo',
|
||||
summary,
|
||||
diagnosticReasoning: record.diagnosticReasoning || record.anamnesis || summary,
|
||||
diagnosticHypotheses: record.diagnosticHypotheses || record.cid || 'Hipoteses diagnosticas nao informadas.',
|
||||
definitiveDiagnosis: record.definitiveDiagnosis || record.cid || 'Diagnostico definitivo nao informado.',
|
||||
prescriptions: record.prescriptions || 'Prescricao nao informada.',
|
||||
procedures: record.procedures || record.physicalExam || 'Procedimentos nao informados.',
|
||||
surgeries: record.surgeries || 'Cirurgias nao informadas ou nao se aplica.',
|
||||
orientations: record.orientations || record.conduct || 'Orientacoes nao informadas.',
|
||||
labResults: record.labResults || 'Laudos laboratoriais nao informados.',
|
||||
imageResults: record.imageResults || 'Laudos de imagem nao informados.',
|
||||
multiprofessionalNotes: record.multiprofessionalNotes || 'Notas multiprofissionais nao informadas.',
|
||||
signature: record.signature || record.doctor || 'Assinatura nao informada.',
|
||||
professionalStamp: record.professionalStamp || `Registro assinado por ${record.doctor || 'profissional nao informado'}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function sortByDateDesc(a, b) {
|
||||
return new Date(b.dateTime || b.createdAt).getTime() - new Date(a.dateTime || a.createdAt).getTime()
|
||||
}
|
||||
|
||||
function parseLegacyDate(value) {
|
||||
const match = String(value || '').match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||
if (!match) return ''
|
||||
return `${match[3]}-${match[2]}-${match[1]}T09:00`
|
||||
}
|
||||
|
||||
function toIso(value) {
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString()
|
||||
}
|
||||
|
||||
function toLocalInputValue(date) {
|
||||
const parsed = date instanceof Date ? date : new Date(date)
|
||||
const safeDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed
|
||||
const year = safeDate.getFullYear()
|
||||
const month = String(safeDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(safeDate.getDate()).padStart(2, '0')
|
||||
const hours = String(safeDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(safeDate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return 'Data nao informada'
|
||||
return parsed.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { apiConfig, getAnonHeaders, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { getResponseError } from './repositoryUtils.js'
|
||||
|
||||
export const patientRepository = {
|
||||
@@ -10,11 +10,10 @@ export const patientRepository = {
|
||||
},
|
||||
|
||||
async getById(patientId) {
|
||||
const [patients, appointments] = await Promise.all([
|
||||
this.getAll(),
|
||||
const [patient, appointments] = await Promise.all([
|
||||
getPatientById(patientId),
|
||||
getAppointments().catch(() => []),
|
||||
])
|
||||
const patient = patients.find((p) => String(p.id) === String(patientId)) || null
|
||||
return patient ? mapPatientToDetail(patient, appointments) : null
|
||||
},
|
||||
|
||||
@@ -83,6 +82,29 @@ export const patientRepository = {
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async registerPublic(data) {
|
||||
const body = cleanPayload({
|
||||
full_name: data.name || data.full_name,
|
||||
cpf: data.cpf,
|
||||
email: data.email,
|
||||
phone_mobile: data.phone || data.phone_mobile,
|
||||
birth_date: data.birthDate || data.birth_date || null,
|
||||
redirect_url: data.redirectUrl || data.redirect_url,
|
||||
})
|
||||
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/register-patient`, {
|
||||
method: 'POST',
|
||||
headers: getAnonHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao realizar auto-cadastro de paciente.'))
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
// 4. Atualizar paciente
|
||||
async update(patientId, data) {
|
||||
const body = {
|
||||
@@ -103,6 +125,35 @@ export const patientRepository = {
|
||||
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
|
||||
async remove(patientId) {
|
||||
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
||||
@@ -115,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 = []) {
|
||||
const appointmentSummary = summarizeAppointments(patient.id, appointments)
|
||||
const city = getFirstValue(patient, ['city', 'cidade', 'address_city', 'municipio'], patient.address?.city)
|
||||
@@ -125,6 +190,7 @@ function mapPatientToDirectory(patient, appointments = []) {
|
||||
...patient,
|
||||
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
||||
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
|
||||
avatarUrl: normalizeAvatarUrl(patient.avatarUrl || patient.avatar_url || patient.avatar_path),
|
||||
detailId: patient.id,
|
||||
insurance: normalizeInsurance(insurance),
|
||||
city,
|
||||
@@ -160,6 +226,7 @@ function mapPatientToDetail(patient, appointments = []) {
|
||||
status: patient.status || 'Acompanhamento',
|
||||
risk: patient.risk || patient.risco || 'Baixo',
|
||||
email: patient.email || '',
|
||||
avatarUrl: directory.avatarUrl,
|
||||
address: formatAddress(directory) || patient.address || patient.endereco || 'Endereço não informado',
|
||||
team: patient.team || patient.equipe || [],
|
||||
notes: normalizeNotes(patient.notes || patient.observacoes || directory.notesText),
|
||||
@@ -309,6 +376,25 @@ function normalizeInsurance(value) {
|
||||
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) {
|
||||
if (!birthDate) return 0
|
||||
|
||||
@@ -325,3 +411,9 @@ function calculateAge(birthDate) {
|
||||
|
||||
return age
|
||||
}
|
||||
|
||||
function cleanPayload(payload) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { getResponseError, normalizeItem } from './repositoryUtils.js'
|
||||
|
||||
export const professionalRepository = {
|
||||
async getAll() {
|
||||
@@ -6,12 +7,37 @@ export const professionalRepository = {
|
||||
headers: getAuthenticatedHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar médicos.')
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao buscar médicos.'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return (Array.isArray(data) ? data : []).map(mapProfessional)
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/create-doctor`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify(cleanPayload({
|
||||
full_name: data.fullName || data.full_name || data.name,
|
||||
email: data.email,
|
||||
cpf: data.cpf,
|
||||
crm: data.crm,
|
||||
crm_uf: data.crmUf || data.crm_uf,
|
||||
phone_mobile: data.phoneMobile || data.phone_mobile || data.phone,
|
||||
specialty: data.specialty || data.specialidade,
|
||||
birth_date: data.birthDate || data.birth_date,
|
||||
})),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao criar médico.'))
|
||||
}
|
||||
|
||||
return mapProfessional(normalizeItem(await response.json(), ['doctor']))
|
||||
},
|
||||
|
||||
getCoverageMap() {
|
||||
return {
|
||||
slots: ['08-12', '09-13', '10-15', '13-18', '08-14'],
|
||||
@@ -52,3 +78,9 @@ function mapProfessional(doctor) {
|
||||
function normalizeValue(value) {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function cleanPayload(payload) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ export const profileRepository = {
|
||||
profile?.avatarUrl ||
|
||||
user?.avatarUrl ||
|
||||
user?.avatar_url ||
|
||||
profile?.avatar_path ||
|
||||
user?.avatar_path ||
|
||||
meta.avatar_url ||
|
||||
meta.avatar_path ||
|
||||
meta.picture ||
|
||||
''
|
||||
|
||||
@@ -28,7 +31,7 @@ export const profileRepository = {
|
||||
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',
|
||||
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,
|
||||
patientId: data?.patient_id || data?.patientId || null,
|
||||
roles,
|
||||
@@ -80,19 +83,45 @@ export const profileRepository = {
|
||||
}
|
||||
|
||||
return {
|
||||
avatarUrl: `${apiConfig.storageUrl}/object/public/avatars/${objectPath}`,
|
||||
avatarUrl: getAvatarUrl(objectPath),
|
||||
path: objectPath,
|
||||
}
|
||||
},
|
||||
|
||||
async downloadAvatar(path) {
|
||||
const objectPath = String(path || '').replace(/^\/+/, '')
|
||||
const response = await fetch(`${apiConfig.storageUrl}/object/avatars/${objectPath}`, {
|
||||
method: 'GET',
|
||||
headers: getAuthenticatedHeaders({ 'Content-Type': undefined }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao baixar avatar.'))
|
||||
}
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
contentType: response.headers.get('content-type') || 'application/octet-stream',
|
||||
path: objectPath,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function normalizeAvatarResponse(data) {
|
||||
const path = data.path || data.key || ''
|
||||
return {
|
||||
avatarUrl: data.avatarUrl || data.avatar_url || data.publicUrl || data.public_url || data.url || '',
|
||||
path: data.path || data.key || '',
|
||||
avatarUrl: data.avatarUrl || data.avatar_url || data.publicUrl || data.public_url || data.url || getAvatarUrl(path),
|
||||
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 }) {
|
||||
return [
|
||||
...(Array.isArray(data?.roles) ? data.roles : []),
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function fetchJsonWithFallback(requests, fallbackMessage) {
|
||||
}
|
||||
|
||||
if (lastError && !lastResponse) {
|
||||
throw new Error(translateErrorMessage(lastError.message || fallbackMessage))
|
||||
throw new Error(translateErrorMessage(lastError.message || fallbackMessage, fallbackMessage))
|
||||
}
|
||||
|
||||
throw new Error(await getResponseError(lastResponse, fallbackMessage))
|
||||
@@ -49,48 +49,54 @@ export function normalizeItem(data, keys = []) {
|
||||
return data || null
|
||||
}
|
||||
|
||||
export async function getResponseError(response, fallbackMessage) {
|
||||
if (!response) return fallbackMessage
|
||||
export async function getResponseError(response, fallbackMessage = 'Erro inesperado.') {
|
||||
if (!response) return translateErrorMessage(fallbackMessage)
|
||||
|
||||
const text = await response.text().catch(() => '')
|
||||
const error = parseErrorBody(text)
|
||||
const message = translateErrorMessage(
|
||||
error.error_description ||
|
||||
error.msg ||
|
||||
error.message ||
|
||||
error.error ||
|
||||
error.details ||
|
||||
error.hint ||
|
||||
text ||
|
||||
getErrorMessage(error, text) || fallbackMessage,
|
||||
fallbackMessage,
|
||||
)
|
||||
|
||||
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
|
||||
}
|
||||
|
||||
export function translateErrorMessage(message) {
|
||||
export function translateErrorMessage(message, fallbackMessage = 'Erro inesperado.') {
|
||||
const rawMessage = String(message || '').trim()
|
||||
const normalized = rawMessage.toLowerCase()
|
||||
|
||||
if (!rawMessage) return 'Erro inesperado.'
|
||||
if (!rawMessage) return fallbackMessage
|
||||
if (isPortugueseMessage(rawMessage)) return rawMessage
|
||||
|
||||
const translations = [
|
||||
[/failed to fetch|networkerror|load failed|network request failed/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
|
||||
[/fetch failed|failed sending request|connection refused|timeout|timed out/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
|
||||
[/invalid login credentials|invalid credentials/, 'E-mail ou senha inválidos.'],
|
||||
[/signup requires a valid password|password should be at least|weak password|invalid password/, 'Informe uma senha válida para continuar.'],
|
||||
[/email rate limit exceeded|rate limit exceeded|too many requests|for security purposes.*request this after/, 'Muitas tentativas em pouco tempo. Aguarde alguns minutos e tente novamente.'],
|
||||
[/invalid email|email address.*invalid|unable to validate email address/, 'Informe um e-mail válido.'],
|
||||
[/email not confirmed/, 'E-mail ainda não confirmado. Verifique sua caixa de entrada.'],
|
||||
[/user already registered|already registered/, 'Este e-mail já está cadastrado.'],
|
||||
[/user not found/, 'Usuário não encontrado.'],
|
||||
[/signup.*disabled|signups not allowed|user signups are disabled/, 'O cadastro de novos usuários está desabilitado no momento.'],
|
||||
[/database error saving new user|database error.*user/, 'Não foi possível salvar o usuário. Tente novamente ou contate o suporte.'],
|
||||
[/database error|unexpected failure|internal server error|server error/, 'A API encontrou um erro interno. Tente novamente ou contate o suporte.'],
|
||||
[/jwt expired|invalid jwt|jwt malformed|invalid token|token is expired/, 'Sessão expirada. Faça login novamente.'],
|
||||
[/missing required parameters?/, 'Parâmetros obrigatórios não foram enviados.'],
|
||||
[/required field|field .* is required|required parameter|missing .* field/, 'Campo obrigatório não preenchido.'],
|
||||
[/duplicate key value violates unique constraint/, 'Já existe um registro com essas informações.'],
|
||||
[/new row violates row-level security policy|row-level security policy|permission denied/, 'Você não tem permissão para realizar esta ação.'],
|
||||
[/new row violates row-level security policy|row-level security policy|permission denied|insufficient privileges|not authorized|unauthorized|forbidden/, 'Você não tem permissão para realizar esta ação.'],
|
||||
[/violates foreign key constraint/, 'Não foi possível salvar porque há um vínculo obrigatório ausente ou inválido.'],
|
||||
[/violates check constraint/, 'Os dados enviados não atendem às regras de validação.'],
|
||||
[/null value in column "([^"]+)".*violates not-null constraint/, 'Campo obrigatório não preenchido.'],
|
||||
[/invalid input value for enum ([^:]+): "([^"]+)"/, 'Valor inválido para uma opção do sistema.'],
|
||||
[/invalid input syntax for type uuid/, 'Identificador inválido enviado para a API.'],
|
||||
[/invalid input syntax for type (integer|bigint|numeric|date|timestamp|boolean)/, 'Valor inválido enviado para a API.'],
|
||||
[/value too long for type|too long/, 'Um dos campos excede o tamanho permitido.'],
|
||||
[/relation .* does not exist/, 'Recurso da API não encontrado.'],
|
||||
[/function .* does not exist/, 'Endpoint da API não encontrado.'],
|
||||
[/endpoint.*not found|not found/, 'Recurso da API não encontrado.'],
|
||||
[/cors|preflight/, 'A API bloqueou a requisição por configuração de CORS.'],
|
||||
]
|
||||
|
||||
@@ -98,7 +104,31 @@ export function translateErrorMessage(message) {
|
||||
if (pattern.test(normalized)) return translation
|
||||
}
|
||||
|
||||
return rawMessage
|
||||
return isLikelyEnglishMessage(rawMessage) ? fallbackMessage : rawMessage
|
||||
}
|
||||
|
||||
function getErrorMessage(error, text) {
|
||||
return error.error_description ||
|
||||
error.msg ||
|
||||
error.message ||
|
||||
error.error ||
|
||||
error.detail ||
|
||||
error.details ||
|
||||
error.hint ||
|
||||
formatFieldErrors(error.errors) ||
|
||||
text
|
||||
}
|
||||
|
||||
function formatFieldErrors(errors) {
|
||||
if (!errors || typeof errors !== 'object') return ''
|
||||
|
||||
const messages = Object.entries(errors)
|
||||
.flatMap(([field, fieldErrors]) => {
|
||||
const values = Array.isArray(fieldErrors) ? fieldErrors : [fieldErrors]
|
||||
return values.filter(Boolean).map((message) => `${field}: ${message}`)
|
||||
})
|
||||
|
||||
return messages.join('; ')
|
||||
}
|
||||
|
||||
function isPortugueseMessage(message) {
|
||||
@@ -106,6 +136,10 @@ function isPortugueseMessage(message) {
|
||||
/\b(erro|falha|não|nao|usuário|usuario|senha|campo|obrigatório|obrigatorio|sessão|sessao)\b/i.test(message)
|
||||
}
|
||||
|
||||
function isLikelyEnglishMessage(message) {
|
||||
return /[a-z]/i.test(message) && !/[ãõáéíóúâêôç]/i.test(message)
|
||||
}
|
||||
|
||||
function shouldFallback(response) {
|
||||
return [404, 405].includes(response.status)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
export const settingsRepository = {
|
||||
getIntegrations() {
|
||||
return [
|
||||
['WhatsApp Business', 'Envio automático de lembretes e confirmações', true, 'bg-[#3b82f6]'],
|
||||
['Google Calendar', 'Sincronizacao bidirecional de agenda', false, 'bg-blue-500'],
|
||||
['Stripe / PagSeguro', 'Pagamentos online e links de cobranca', true, 'bg-violet-500'],
|
||||
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
|
||||
['ANS - Planos de Saude', 'Integracao com tabela TUSS e convenios', false, 'bg-rose-500'],
|
||||
['API de IA Preditiva', 'Score de absenteísmo e predição de faltas', true, 'bg-[#3b82f6]'],
|
||||
]
|
||||
},
|
||||
|
||||
getSections() {
|
||||
return [
|
||||
{ id: 'aparencia', label: 'Aparência', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||
{ id: 'notificacoes', label: 'Notificações', description: 'Alertas e lembretes', icon: 'bell' },
|
||||
{ id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||
{ id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' },
|
||||
{ id: 'conta', label: 'Conta & Perfil', description: 'Informações pessoais', icon: 'user' },
|
||||
{ id: 'integracoes', label: 'Integrações', description: 'APIs e sistemas externos', icon: 'globe' },
|
||||
{ id: 'dados', label: 'Dados & Backup', description: 'Exportação e backup', icon: 'database' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -33,10 +33,9 @@ export const userRepository = {
|
||||
},
|
||||
|
||||
async getById(userId) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id`, {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id/${encodeURIComponent(userId)}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -79,11 +78,47 @@ export const userRepository = {
|
||||
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) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
body: JSON.stringify({ userId, user_id: userId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -101,6 +136,8 @@ function buildCreateUserBody(data) {
|
||||
phone: data.phone?.trim(),
|
||||
cpf: data.cpf?.trim(),
|
||||
role: data.role,
|
||||
crm: data.crm?.trim(),
|
||||
crm_uf: data.crm_uf?.trim() || data.crmUf?.trim(),
|
||||
}
|
||||
|
||||
if (data.create_patient_record) {
|
||||
@@ -117,5 +154,13 @@ function normalizeListedUser(user) {
|
||||
email: user.email || user.user_email || '',
|
||||
full_name: user.full_name || user.name || user.nome || '',
|
||||
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 !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export const THEME_STORAGE_KEY = 'mediconnect.theme'
|
||||
|
||||
export function getStoredTheme() {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
if (typeof window === 'undefined') return 'light'
|
||||
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return storedTheme === 'light' ? 'light' : 'dark'
|
||||
return storedTheme === 'dark' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function applyTheme(theme) {
|
||||
|
||||
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