Compare commits
11 Commits
faeture/in
...
4c1b8ec43f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c1b8ec43f | |||
| 94dab58d85 | |||
| bc900fbdd4 | |||
| db7a2fe8f5 | |||
| 64d9527318 | |||
| efb942d5aa | |||
| 151aa4b76d | |||
| 9335e974eb | |||
| 666b3b5c0e | |||
| bb5200664a | |||
| 06acf8cc61 |
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>projeto-residencia</title>
|
<title>MediConnect</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
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 |
113
src/App.jsx
113
src/App.jsx
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { authRepository } from './repositories/authRepository.js'
|
|
||||||
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { AppShell } from './components/AppShell.jsx'
|
import { AppShell } from './components/AppShell.jsx'
|
||||||
|
import { canAccess } from './config/permissions.js'
|
||||||
|
import { useAuth } from './hooks/useAuth.js'
|
||||||
import { AgendaPage } from './pages/AgendaPage.jsx'
|
import { AgendaPage } from './pages/AgendaPage.jsx'
|
||||||
import { AnalyticsPage } from './pages/AnalyticsPage.jsx'
|
import { AnalyticsPage } from './pages/AnalyticsPage.jsx'
|
||||||
import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx'
|
import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx'
|
||||||
@@ -15,12 +15,19 @@ import { PatientDetailPage, PatientsPage } from './pages/PatientsPage.jsx'
|
|||||||
import { ProfilePage } from './pages/ProfilePage.jsx'
|
import { ProfilePage } from './pages/ProfilePage.jsx'
|
||||||
import { ReportsPage } from './pages/ReportsPage.jsx'
|
import { ReportsPage } from './pages/ReportsPage.jsx'
|
||||||
import { SettingsPage } from './pages/SettingsPage.jsx'
|
import { SettingsPage } from './pages/SettingsPage.jsx'
|
||||||
import { TeamPage } from './pages/TeamPage.jsx'
|
import { UsersPage } from './pages/UsersPage.jsx'
|
||||||
import { VisitsPage } from './pages/VisitsPage.jsx'
|
import { VisitsPage } from './pages/VisitsPage.jsx'
|
||||||
import { patientRepository } from './repositories/patientRepository.js'
|
import { patientRepository } from './repositories/patientRepository.js'
|
||||||
|
|
||||||
|
const PANEL_PATHS = ['/inicio', '/home', '/dashboard']
|
||||||
|
const ROLE_HOME_PATHS = {
|
||||||
|
medico: '/agenda',
|
||||||
|
secretaria: '/agenda',
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [location, setLocation] = useState(() => readLocation())
|
const [location, setLocation] = useState(() => readLocation())
|
||||||
|
const { isAuthenticated, role, loading: authLoading } = useAuth()
|
||||||
|
|
||||||
const navigate = useCallback((to, options = {}) => {
|
const navigate = useCallback((to, options = {}) => {
|
||||||
if (options.replace) {
|
if (options.replace) {
|
||||||
@@ -49,25 +56,53 @@ function App() {
|
|||||||
return () => window.removeEventListener('popstate', handlePopState)
|
return () => window.removeEventListener('popstate', handlePopState)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate])
|
const route = useMemo(
|
||||||
const isAuthenticated = authRepository.isAuthenticated()
|
() => resolveRoute(location.pathname, navigate, role),
|
||||||
|
[location.pathname, navigate, role],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tela de carregamento enquanto busca o role do usuário
|
||||||
|
if (authLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]">
|
||||||
|
<p className="text-sm text-[#a3a3a3]">Carregando...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotas públicas (sem shell)
|
||||||
if (!route.withShell) {
|
if (!route.withShell) {
|
||||||
return route.element
|
return route.element
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usuário não autenticado
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <LoginPage navigate={navigate} />
|
return <LoginPage navigate={navigate} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usuário autenticado mas sem permissão para a rota
|
||||||
|
if (!role || !canAccess(role, location.pathname)) {
|
||||||
|
const roleHomePath = ROLE_HOME_PATHS[role]
|
||||||
|
if (roleHomePath && PANEL_PATHS.includes(location.pathname)) {
|
||||||
|
navigate(roleHomePath, { replace: true })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle="Sem acesso">
|
||||||
|
<UnauthorizedPage navigate={navigate} />
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
|
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
||||||
{route.element}
|
{route.element}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRoute(pathname, navigate) {
|
function resolveRoute(pathname, navigate, role) {
|
||||||
if (pathname === '/' || pathname === '/login') {
|
if (pathname === '/' || pathname === '/login') {
|
||||||
return {
|
return {
|
||||||
element: <LoginPage navigate={navigate} />,
|
element: <LoginPage navigate={navigate} />,
|
||||||
@@ -102,7 +137,7 @@ function resolveRoute(pathname, navigate) {
|
|||||||
|
|
||||||
if (pathname === '/agenda') {
|
if (pathname === '/agenda') {
|
||||||
return {
|
return {
|
||||||
element: <AgendaPage navigate={navigate} />,
|
element: <AgendaPage navigate={navigate} role={role} />,
|
||||||
title: 'Agenda',
|
title: 'Agenda',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
@@ -110,7 +145,7 @@ function resolveRoute(pathname, navigate) {
|
|||||||
|
|
||||||
if (pathname === '/pacientes') {
|
if (pathname === '/pacientes') {
|
||||||
return {
|
return {
|
||||||
element: <PatientsPage navigate={navigate} />,
|
element: <PatientsPage navigate={navigate} role={role} />,
|
||||||
title: 'Pacientes',
|
title: 'Pacientes',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
@@ -126,9 +161,8 @@ function resolveRoute(pathname, navigate) {
|
|||||||
|
|
||||||
if (pathname.startsWith('/pacientes/')) {
|
if (pathname.startsWith('/pacientes/')) {
|
||||||
const patientId = pathname.split('/')[2]
|
const patientId = pathname.split('/')[2]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element: <PatientDetailRoute navigate={navigate} patientId={patientId} />,
|
element: <PatientDetailRoute navigate={navigate} patientId={patientId} role={role} />,
|
||||||
title: 'Paciente',
|
title: 'Paciente',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
@@ -144,8 +178,8 @@ function resolveRoute(pathname, navigate) {
|
|||||||
|
|
||||||
if (pathname === '/laudos') {
|
if (pathname === '/laudos') {
|
||||||
return {
|
return {
|
||||||
element: <ReportsPage navigate={navigate} />,
|
element: <ReportsPage navigate={navigate} role={role} />,
|
||||||
title: 'Relatorios medicos',
|
title: 'Relatórios',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,23 +187,32 @@ function resolveRoute(pathname, navigate) {
|
|||||||
if (pathname === '/relatorios') {
|
if (pathname === '/relatorios') {
|
||||||
return {
|
return {
|
||||||
element: <AnalyticsPage />,
|
element: <AnalyticsPage />,
|
||||||
title: 'Relatórios',
|
title: 'Analytics',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/camunicacao' || pathname === '/comunicacao' || pathname === '/mensagens') {
|
if (pathname === '/camunicacao') {
|
||||||
|
navigate('/comunicacao', { replace: true })
|
||||||
return {
|
return {
|
||||||
element: <MessagesPage navigate={navigate} />,
|
element: <MessagesPage navigate={navigate} role={role} />,
|
||||||
title: 'Comunicação',
|
title: 'Comunicação',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/profissionais') {
|
if (pathname === '/comunicacao' || pathname === '/mensagens') {
|
||||||
return {
|
return {
|
||||||
element: <TeamPage navigate={navigate} />,
|
element: <MessagesPage navigate={navigate} role={role} />,
|
||||||
title: 'Profissionais',
|
title: 'Comunicação',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/usuarios') {
|
||||||
|
return {
|
||||||
|
element: <UsersPage role={role} />,
|
||||||
|
title: 'Usuários',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,19 +235,20 @@ function resolveRoute(pathname, navigate) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
element: <NotFoundPage navigate={navigate} />,
|
element: <NotFoundPage navigate={navigate} />,
|
||||||
title: 'Tela nao encontrada',
|
title: 'Página não encontrada',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function PatientDetailRoute({ navigate, patientId }) {
|
function PatientDetailRoute({ navigate, patientId, role }) {
|
||||||
const [patient, setPatient] = useState(null)
|
const [patient, setPatient] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
patientRepository.getById(patientId)
|
patientRepository
|
||||||
|
.getById(patientId)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (active) setPatient(data)
|
if (active) setPatient(data)
|
||||||
})
|
})
|
||||||
@@ -221,7 +265,30 @@ function PatientDetailRoute({ navigate, patientId }) {
|
|||||||
return <div className="pt-10 text-sm text-[#a3a3a3]">Carregando paciente...</div>
|
return <div className="pt-10 text-sm text-[#a3a3a3]">Carregando paciente...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return patient ? <PatientDetailPage navigate={navigate} patient={patient} /> : <NotFoundPage navigate={navigate} />
|
return patient ? (
|
||||||
|
<PatientDetailPage navigate={navigate} patient={patient} role={role} />
|
||||||
|
) : (
|
||||||
|
<NotFoundPage navigate={navigate} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnauthorizedPage({ navigate }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<p className="text-5xl">🔒</p>
|
||||||
|
<h1 className="mt-4 text-2xl font-bold text-[#e5e5e5]">Acesso não permitido</h1>
|
||||||
|
<p className="mt-2 text-sm text-[#a3a3a3]">
|
||||||
|
Você não tem permissão para acessar esta página.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="mt-6 rounded-lg bg-[#3b82f6] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[#2563eb]"
|
||||||
|
onClick={() => navigate('/inicio')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Voltar ao painel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLocation() {
|
function readLocation() {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,22 +1,26 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { ROLE_LABELS, ROLE_NAV_ITEMS } from '../config/permissions.js'
|
||||||
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
import { profileRepository } from '../repositories/profileRepository.js'
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
import { BrandLogo } from './Brand.jsx'
|
import { BrandLogo } from './Brand.jsx'
|
||||||
|
|
||||||
const navItems = [
|
// Todos os itens de navegação com seus ícones e metadados
|
||||||
|
const ALL_NAV_ITEMS = [
|
||||||
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
|
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
|
||||||
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
||||||
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
||||||
{ href: '/prontuario', label: 'Prontuario', icon: 'file' },
|
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
|
||||||
{ href: '/laudos', label: 'Relatorios medicos', icon: 'clipboard' },
|
{ href: '/laudos', label: 'Relatórios', icon: 'clipboard' },
|
||||||
{
|
{
|
||||||
href: '/camunicacao',
|
href: '/comunicacao',
|
||||||
label: 'Comunicacao',
|
label: 'Comunicação',
|
||||||
icon: 'message',
|
icon: 'message',
|
||||||
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
|
activePaths: ['/comunicacao', '/mensagens'],
|
||||||
},
|
},
|
||||||
{ href: '/relatorios', label: 'Relatorios', icon: 'chart' },
|
{ href: '/relatorios', label: 'Analytics', icon: 'chart' },
|
||||||
{ href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
{ href: '/usuarios', label: 'Usuários', icon: 'shield' },
|
||||||
|
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const titles = {
|
const titles = {
|
||||||
@@ -25,23 +29,23 @@ const titles = {
|
|||||||
'/dashboard': 'Painel',
|
'/dashboard': 'Painel',
|
||||||
'/agenda': 'Agenda',
|
'/agenda': 'Agenda',
|
||||||
'/consultas': 'Consultas',
|
'/consultas': 'Consultas',
|
||||||
'/laudos': 'Relatorios medicos',
|
'/laudos': 'Relatórios',
|
||||||
'/pacientes': 'Pacientes',
|
'/pacientes': 'Pacientes',
|
||||||
'/prontuario': 'Prontuario',
|
'/prontuario': 'Prontuário',
|
||||||
'/camunicacao': 'Comunicacao',
|
'/comunicacao': 'Comunicação',
|
||||||
'/comunicacao': 'Comunicacao',
|
'/mensagens': 'Comunicação',
|
||||||
'/mensagens': 'Comunicacao',
|
'/relatorios': 'Analytics',
|
||||||
'/relatorios': 'Relatorios',
|
|
||||||
'/profissionais': 'Profissionais',
|
|
||||||
'/perfil': 'Perfil',
|
'/perfil': 'Perfil',
|
||||||
'/configuracoes': 'Configuracoes',
|
'/configuracoes': 'Configurações',
|
||||||
'/config': 'Configuracoes',
|
'/config': 'Configurações',
|
||||||
|
'/usuarios': 'Usuários',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [quickSearch, setQuickSearch] = useState('')
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false)
|
||||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' })
|
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||||
|
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (currentPath.startsWith('/pacientes/') && routeTitle) {
|
if (currentPath.startsWith('/pacientes/') && routeTitle) {
|
||||||
@@ -51,41 +55,101 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
|||||||
return routeTitle || titles[currentPath] || 'MediConnect'
|
return routeTitle || titles[currentPath] || 'MediConnect'
|
||||||
}, [currentPath, routeTitle])
|
}, [currentPath, routeTitle])
|
||||||
|
|
||||||
|
// Filtra os itens de navegação com base no role do usuário
|
||||||
|
const navItems = useMemo(() => {
|
||||||
|
if (!role) return []
|
||||||
|
|
||||||
|
const allowedPaths = ROLE_NAV_ITEMS[role]?.map((item) => item.path) ?? []
|
||||||
|
|
||||||
|
return ALL_NAV_ITEMS.filter((item) =>
|
||||||
|
allowedPaths.some(
|
||||||
|
(allowed) => item.href === allowed || item.activePaths?.includes(allowed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, [role])
|
||||||
|
|
||||||
|
const canOpenSettings = useMemo(
|
||||||
|
() =>
|
||||||
|
(ROLE_NAV_ITEMS[role] ?? []).some(
|
||||||
|
(item) => item.path === '/configuracoes' || item.path === '/config',
|
||||||
|
),
|
||||||
|
[role],
|
||||||
|
)
|
||||||
|
const mockNotifications = useMemo(
|
||||||
|
() => [
|
||||||
|
{ id: 'mock-1', title: 'Retorno agendado', detail: 'Paciente Ana Souza às 14:30', time: 'Agora' },
|
||||||
|
{ id: 'mock-2', title: 'Laudo pendente', detail: 'Hemograma aguardando revisão', time: '12 min' },
|
||||||
|
{ id: 'mock-3', title: 'Mensagem recebida', detail: 'Resposta via WhatsApp registrada', time: '35 min' },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
profileRepository.getCurrentUserProfile()
|
profileRepository
|
||||||
|
.getCurrentUserProfile()
|
||||||
.then((profile) => {
|
.then((profile) => {
|
||||||
if (!active || !profile) return
|
if (!active || !profile) return
|
||||||
|
|
||||||
setViewerProfile({
|
setViewerProfile({
|
||||||
name: profile.name || 'Usuario',
|
name: profile.name || 'Usuário',
|
||||||
role: profile.role || 'Usuario do Sistema',
|
role: ROLE_LABELS[role] || profile.role || 'Usuário do Sistema',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
// Fallback: usa o label do role diretamente
|
||||||
|
if (active && role) {
|
||||||
|
setViewerProfile((prev) => ({
|
||||||
|
...prev,
|
||||||
|
role: ROLE_LABELS[role] || 'Usuário do Sistema',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [role])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profileMenuOpen && !notificationsOpen) return undefined
|
||||||
|
|
||||||
|
function closeOnEscape(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setProfileMenuOpen(false)
|
||||||
|
setNotificationsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', closeOnEscape)
|
||||||
|
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||||
|
}, [notificationsOpen, profileMenuOpen])
|
||||||
|
|
||||||
function goTo(path) {
|
function goTo(path) {
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
|
setProfileMenuOpen(false)
|
||||||
|
setNotificationsOpen(false)
|
||||||
navigate(path)
|
navigate(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
setProfileMenuOpen(false)
|
||||||
|
await authRepository.logout()
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
|
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
|
||||||
<a
|
<a
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-[#262626] focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-[#3b82f6]"
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-[#262626] focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-[#3b82f6]"
|
||||||
href="#app-content"
|
href="#app-content"
|
||||||
>
|
>
|
||||||
Pular para conteudo
|
Pular para conteúdo
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<aside
|
<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-transform duration-200 lg:translate-x-0 ${
|
||||||
menuOpen ? 'translate-x-0' : ''
|
menuOpen ? 'translate-x-0' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -131,7 +195,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="lg:pl-64">
|
<div className="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">
|
<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 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">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
@@ -143,57 +207,132 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
|||||||
>
|
>
|
||||||
Menu
|
Menu
|
||||||
</button>
|
</button>
|
||||||
<div className="relative w-full max-w-sm lg:w-96">
|
|
||||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" />
|
|
||||||
<input
|
|
||||||
aria-label="Busca rapida"
|
|
||||||
className="h-[38px] w-full rounded-sm border border-[#404040] bg-[#303030] py-2 pl-10 pr-4 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
|
||||||
onChange={(event) => setQuickSearch(event.target.value)}
|
|
||||||
placeholder="Buscar paciente, prontuario..."
|
|
||||||
value={quickSearch}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<button
|
<div className="relative z-30">
|
||||||
aria-label="Notificacoes"
|
<button
|
||||||
className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]"
|
aria-expanded={notificationsOpen}
|
||||||
type="button"
|
aria-haspopup="menu"
|
||||||
>
|
aria-label="Notificações"
|
||||||
<BellIcon className="size-5" />
|
className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]"
|
||||||
<span className="absolute right-0 top-0 grid size-4 place-items-center rounded-full bg-[#ef4444] text-[10px] font-bold leading-none text-white">
|
onClick={() => {
|
||||||
3
|
setNotificationsOpen((open) => !open)
|
||||||
</span>
|
setProfileMenuOpen(false)
|
||||||
</button>
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BellIcon className="size-5" />
|
||||||
|
<span className="absolute right-0 top-0 grid size-4 place-items-center rounded-full bg-[#ef4444] text-[10px] font-bold leading-none text-white">
|
||||||
|
{mockNotifications.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{notificationsOpen ? (
|
||||||
|
<div
|
||||||
|
aria-label="Notificações mock"
|
||||||
|
className="absolute right-0 top-12 z-30 w-80 rounded-md border border-[#404040] bg-[#262626] p-2 shadow-2xl shadow-black/30"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
Mock
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{mockNotifications.map((notification) => (
|
||||||
|
<button
|
||||||
|
className="w-full rounded-sm border border-transparent px-2 py-2 text-left transition hover:border-[#404040] hover:bg-[#303030]"
|
||||||
|
key={notification.id}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex items-start justify-between gap-3">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-[#e5e5e5]">{notification.title}</span>
|
||||||
|
<span className="mt-0.5 block text-xs leading-5 text-[#a3a3a3]">{notification.detail}</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px] font-semibold text-[#51a2ff]">{notification.time}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="hidden h-6 w-px bg-[#404040] sm:block" aria-hidden="true" />
|
<span className="hidden h-6 w-px bg-[#404040] sm:block" aria-hidden="true" />
|
||||||
|
|
||||||
<button
|
<div className="relative z-30">
|
||||||
className="flex min-w-0 items-center gap-3 text-left"
|
<button
|
||||||
onClick={() => goTo('/perfil')}
|
aria-expanded={profileMenuOpen}
|
||||||
type="button"
|
aria-haspopup="menu"
|
||||||
>
|
className="flex min-w-0 items-center gap-3 rounded-sm px-1.5 py-1 text-left transition hover:bg-[#303030] focus:outline-none focus:ring-2 focus:ring-[#3b82f6]/40"
|
||||||
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]">
|
onClick={() => {
|
||||||
{getInitials(viewerProfile.name)}
|
setProfileMenuOpen((open) => !open)
|
||||||
</span>
|
setNotificationsOpen(false)
|
||||||
<span className="hidden min-w-0 sm:block">
|
}}
|
||||||
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
type="button"
|
||||||
{viewerProfile.name}
|
>
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]">
|
||||||
|
{getInitials(viewerProfile.name)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
<span className="hidden min-w-0 sm:block">
|
||||||
{viewerProfile.role}
|
<span className="block max-w-40 truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
||||||
|
{viewerProfile.name}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block max-w-40 truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
||||||
|
{viewerProfile.role}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
|
||||||
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
|
</button>
|
||||||
</button>
|
|
||||||
|
{profileMenuOpen ? (
|
||||||
|
<div
|
||||||
|
aria-label="Menu do usuário"
|
||||||
|
className="absolute right-0 top-12 z-30 w-56 rounded-md border border-[#404040] bg-[#262626] p-1 shadow-2xl shadow-black/30"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => goTo('/perfil')}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<UserIcon className="size-4 text-[#a3a3a3]" />
|
||||||
|
Ver perfil
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{canOpenSettings ? (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => goTo('/configuracoes')}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<AppIcon className="size-4 text-[#a3a3a3]" name="settings" />
|
||||||
|
Configurações
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="my-1 h-px bg-[#404040]" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm font-medium text-[#f87171] transition hover:bg-[#303030]"
|
||||||
|
onClick={handleLogout}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LogoutIcon className="size-4" />
|
||||||
|
Sair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{quickSearch ? (
|
|
||||||
<div className="mt-3 rounded-md border border-[#404040] bg-[#303030] px-4 py-3 text-sm text-[#a3a3a3] lg:absolute lg:left-8 lg:top-[52px] lg:w-96">
|
|
||||||
Busca local ativa por <strong className="text-[#e5e5e5]">{quickSearch}</strong>.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="w-full px-4 py-6 md:px-8 md:py-8" id="app-content">
|
<main className="w-full px-4 py-6 md:px-8 md:py-8" id="app-content">
|
||||||
@@ -303,10 +442,10 @@ function AppIcon({ className = 'size-5', name }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'dollar') {
|
if (name === 'shield') {
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<path d="M12 2v20M17 6.5C15.8 5.4 14.2 5 12.5 5 9.9 5 8 6.2 8 8s1.6 2.7 4.2 3.3C15 12 17 13 17 15.5S14.8 19 12 19c-2 0-3.8-.6-5-1.8" />
|
<path d="M12 3 5 6v5c0 4.5 3 8.5 7 10 4-1.5 7-5.5 7-10V6l-7-3Z" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -336,19 +475,29 @@ function BellIcon({ className = 'size-5' }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChevronDownIcon({ className = 'size-4' }) {
|
function UserIcon({ className = 'size-4' }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
<path d="m6 9 6 6 6-6" />
|
<path d="M20 21a8 8 0 0 0-16 0" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchIcon({ className = 'size-4' }) {
|
function LogoutIcon({ className = 'size-4' }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
<path d="m21 21-4.3-4.3" />
|
<path d="M10 17 15 12l-5-5" />
|
||||||
<circle cx="11" cy="11" r="7" />
|
<path d="M15 12H3" />
|
||||||
|
<path d="M21 3v18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronDownIcon({ className = 'size-4' }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import React from 'react'
|
|
||||||
import { format, isToday } from 'date-fns'
|
import { format, isToday } from 'date-fns'
|
||||||
import { ptBR } from 'date-fns/locale'
|
import { ptBR } from 'date-fns/locale'
|
||||||
|
|
||||||
import { sortAppointmentsByTime } from '../../utils/agendaDate.js'
|
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 dailyAppointments = sortAppointmentsByTime(appointments)
|
||||||
|
const appointmentsByTime = groupAppointmentsByTime(dailyAppointments)
|
||||||
|
const slots = mergeSlotsWithAppointmentTimes(generateSlots(DAY_START, DAY_END, SLOT_MINUTES), dailyAppointments)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
<div className="agenda-calendar-shell 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-header flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]">
|
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]">
|
||||||
Vista ampliada do dia
|
Grade de horários do dia
|
||||||
</span>
|
</span>
|
||||||
<h3 className="mt-2 text-xl font-bold text-[#e5e5e5]">
|
<h3 className="mt-2 text-xl font-bold text-[#e5e5e5]">
|
||||||
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
|
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
|
||||||
@@ -20,9 +25,15 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<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'}
|
{dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'}
|
||||||
</span>
|
</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) && (
|
{isToday(baseDate) && (
|
||||||
<span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]">
|
<span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]">
|
||||||
Hoje
|
Hoje
|
||||||
@@ -31,70 +42,134 @@ export function AgendaDailyView({ baseDate, appointments, onAppointmentClick })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dailyAppointments.length === 0 ? (
|
<div className="agenda-day-grid mt-4 grid gap-2">
|
||||||
<div className="mt-4 rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
|
{slots.map((time) => {
|
||||||
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
|
const slotAppointments = appointmentsByTime.get(time) || []
|
||||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
const primaryAppointment = slotAppointments[0]
|
||||||
Ajuste o filtro ou altere o período no calendário.
|
const isBooked = Boolean(primaryAppointment)
|
||||||
</p>
|
|
||||||
</div>
|
return (
|
||||||
) : (
|
|
||||||
<div className="mt-4 grid gap-3">
|
|
||||||
{dailyAppointments.map((appointment) => (
|
|
||||||
<article
|
<article
|
||||||
key={appointment.id}
|
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] ${
|
||||||
className={`grid gap-4 rounded-xl border p-4 md:grid-cols-[96px_1fr_auto] ${getStatusColors(appointment.status)}`}
|
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>
|
<div>
|
||||||
<p className="text-2xl font-bold leading-none">{appointment.time || '--:--'}</p>
|
<p className="text-xl font-bold leading-none">{time}</p>
|
||||||
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.14em] opacity-80">
|
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.12em] opacity-80">
|
||||||
{appointment.mode}
|
{isBooked ? 'Agendado' : 'Disponível'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{isBooked ? (
|
||||||
<button
|
<div>
|
||||||
className="text-left text-base font-bold transition hover:opacity-85"
|
<button
|
||||||
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
|
className="text-left text-sm font-bold transition hover:opacity-85"
|
||||||
type="button"
|
onClick={() => onAppointmentClick?.(primaryAppointment)}
|
||||||
>
|
type="button"
|
||||||
{appointment.patient}
|
>
|
||||||
</button>
|
{primaryAppointment.patient}
|
||||||
<p className="mt-1 text-sm opacity-90">
|
</button>
|
||||||
{appointment.type} com {appointment.professional}
|
<p className="mt-1 text-sm opacity-90">
|
||||||
</p>
|
{primaryAppointment.type} com {primaryAppointment.professional}
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium opacity-80">
|
</p>
|
||||||
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.room}</span>
|
<div className="mt-2 flex flex-wrap gap-2 text-xs font-medium opacity-80">
|
||||||
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.type}</span>
|
{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>
|
) : (
|
||||||
|
<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">
|
<div className="flex flex-wrap items-start justify-start gap-2 md:justify-end">
|
||||||
<span className="rounded-full border border-current/20 bg-black/10 px-3 py-1 text-xs font-bold">
|
<span className="agenda-slot-status rounded-full border border-current/30 bg-black/25 px-3 py-1 text-xs font-bold shadow-sm">
|
||||||
{appointment.status}
|
{isBooked ? primaryAppointment.status : 'Livre'}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
)
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColors(status) {
|
function getDailyToneClass(status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Confirmada':
|
case 'Confirmada':
|
||||||
return 'border-[#14532d] bg-[#052e1a] text-[#a7f3d0]'
|
return 'agenda-slot-confirmed'
|
||||||
case 'Em triagem':
|
case 'Em triagem':
|
||||||
return 'border-[#78350f] bg-[#2d1e05] text-[#fde68a]'
|
return 'agenda-slot-triage'
|
||||||
case 'Concluida':
|
|
||||||
case 'Concluída':
|
|
||||||
return 'border-[#1e3a8a] bg-[#172554] text-[#bfdbfe]'
|
|
||||||
case 'Cancelada':
|
case 'Cancelada':
|
||||||
return 'border-[#7f1d1d] bg-[#450a0a] text-[#fecaca]'
|
return 'agenda-slot-cancelled'
|
||||||
|
case 'Bloqueado':
|
||||||
|
return 'agenda-slot-blocked'
|
||||||
case 'Aguardando':
|
case 'Aguardando':
|
||||||
default:
|
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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
|||||||
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
|
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
<div className="agenda-calendar-shell 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-header grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
|
||||||
{weekDays.map((day) => (
|
{weekDays.map((day) => (
|
||||||
<div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]">
|
<div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]">
|
||||||
{day}
|
{day}
|
||||||
@@ -49,7 +49,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
|||||||
<button
|
<button
|
||||||
key={day.toISOString()}
|
key={day.toISOString()}
|
||||||
onClick={() => onDayClick && onDayClick(day)}
|
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
|
isCurrentMonth
|
||||||
? 'border-[#404040] bg-[#1f1f1f]'
|
? 'border-[#404040] bg-[#1f1f1f]'
|
||||||
: 'border-transparent bg-transparent opacity-40 hover:opacity-80'
|
: 'border-transparent bg-transparent opacity-40 hover:opacity-80'
|
||||||
@@ -69,7 +69,7 @@ export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
|
|||||||
{dayAppointments.slice(0, 3).map((appointment) => (
|
{dayAppointments.slice(0, 3).map((appointment) => (
|
||||||
<div
|
<div
|
||||||
key={appointment.id}
|
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={`h-1.5 w-1.5 shrink-0 rounded-full ${getDotColor(appointment.status)}`} />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
@@ -91,6 +91,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) {
|
function getDotColor(status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Confirmada':
|
case 'Confirmada':
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
<div className="agenda-calendar-shell 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-header grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const isWeekend = day.getDay() === 0
|
const isWeekend = day.getDay() === 0
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.toISOString()}
|
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 ? (
|
{dayAppointments.length === 0 ? (
|
||||||
<div className="flex h-full items-center justify-center p-4">
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
@@ -71,21 +71,21 @@ export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick })
|
|||||||
<button
|
<button
|
||||||
key={appointment.id}
|
key={appointment.id}
|
||||||
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
|
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">
|
<div className="mb-1 flex w-full min-w-0 items-center gap-1.5 overflow-hidden">
|
||||||
<span className="rounded bg-black/20 px-1.5 py-0.5 text-xs font-bold leading-none">
|
<span className="shrink-0 rounded bg-black/20 px-1.5 py-0.5 text-[10px] font-bold leading-none">
|
||||||
{appointment.time}
|
{appointment.time}
|
||||||
</span>
|
</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}
|
{appointment.mode}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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}
|
{appointment.patient}
|
||||||
</span>
|
</span>
|
||||||
<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}
|
title={appointment.professional}
|
||||||
>
|
>
|
||||||
Dr(a). {appointment.professional?.split(' ')[0]}
|
Dr(a). {appointment.professional?.split(' ')[0]}
|
||||||
@@ -101,6 +101,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) {
|
function getStatusColors(status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Confirmada':
|
case 'Confirmada':
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const featureStateStyles = {
|
export const featureStateStyles = {
|
||||||
live: {
|
live: {
|
||||||
badge: 'border-emerald-500/40 bg-emerald-500/15 text-emerald-300',
|
badge: 'hidden',
|
||||||
panel: 'border-emerald-500/35 bg-emerald-500/8',
|
panel: 'border-[#404040] bg-[#262626]',
|
||||||
title: 'text-emerald-300',
|
title: 'text-[#e5e5e5]',
|
||||||
label: 'Integrado',
|
label: '',
|
||||||
},
|
},
|
||||||
partial: {
|
partial: {
|
||||||
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300',
|
badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberk
|
|||||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
||||||
|
|
||||||
const AUTH_SESSION_KEY = 'mediconnect.auth.session'
|
const AUTH_SESSION_KEY = 'mediconnect.auth.session'
|
||||||
|
export const AUTH_SESSION_CHANGED_EVENT = 'mediconnect:auth-session-changed'
|
||||||
|
|
||||||
export const apiConfig = {
|
export const apiConfig = {
|
||||||
apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
||||||
@@ -34,12 +35,14 @@ export function getAuthSession() {
|
|||||||
export function saveAuthSession(session) {
|
export function saveAuthSession(session) {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||||
|
notifyAuthSessionChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuthSession() {
|
export function clearAuthSession() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||||
|
notifyAuthSessionChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,3 +88,7 @@ function cleanHeaders(headers) {
|
|||||||
Object.entries(headers).filter(([, value]) => value !== undefined && value !== null),
|
Object.entries(headers).filter(([, value]) => value !== undefined && value !== null),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyAuthSessionChanged() {
|
||||||
|
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
|
||||||
|
}
|
||||||
|
|||||||
208
src/config/permissions.js
Normal file
208
src/config/permissions.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// Roles disponíveis no sistema
|
||||||
|
export const ROLES = {
|
||||||
|
ADMIN: 'admin',
|
||||||
|
GESTOR: 'gestor',
|
||||||
|
MEDICO: 'medico',
|
||||||
|
SECRETARIA: 'secretaria',
|
||||||
|
PACIENTE: 'paciente',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_ALIASES = {
|
||||||
|
admin: ROLES.ADMIN,
|
||||||
|
administrador: ROLES.ADMIN,
|
||||||
|
administrator: ROLES.ADMIN,
|
||||||
|
gestor: ROLES.GESTOR,
|
||||||
|
gestao: ROLES.GESTOR,
|
||||||
|
gestao_coordenacao: ROLES.GESTOR,
|
||||||
|
coordenacao: ROLES.GESTOR,
|
||||||
|
coordenador: ROLES.GESTOR,
|
||||||
|
manager: ROLES.GESTOR,
|
||||||
|
medico: ROLES.MEDICO,
|
||||||
|
medica: ROLES.MEDICO,
|
||||||
|
doctor: ROLES.MEDICO,
|
||||||
|
physician: ROLES.MEDICO,
|
||||||
|
secretaria: ROLES.SECRETARIA,
|
||||||
|
secretario: ROLES.SECRETARIA,
|
||||||
|
secretary: ROLES.SECRETARIA,
|
||||||
|
receptionist: ROLES.SECRETARIA,
|
||||||
|
paciente: ROLES.PACIENTE,
|
||||||
|
patient: ROLES.PACIENTE,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotas permitidas por role ('*' = todas)
|
||||||
|
const ROLE_ROUTES = {
|
||||||
|
admin: '*',
|
||||||
|
gestor: [
|
||||||
|
'/inicio', '/home', '/dashboard',
|
||||||
|
'/agenda',
|
||||||
|
'/pacientes',
|
||||||
|
'/prontuario',
|
||||||
|
'/laudos',
|
||||||
|
'/relatorios',
|
||||||
|
'/comunicacao', '/mensagens',
|
||||||
|
'/configuracoes', '/config',
|
||||||
|
'/consultas',
|
||||||
|
'/usuarios',
|
||||||
|
'/perfil',
|
||||||
|
],
|
||||||
|
medico: [
|
||||||
|
'/agenda',
|
||||||
|
'/pacientes',
|
||||||
|
'/prontuario',
|
||||||
|
'/laudos',
|
||||||
|
'/comunicacao', '/mensagens',
|
||||||
|
'/configuracoes', '/config',
|
||||||
|
'/perfil',
|
||||||
|
],
|
||||||
|
secretaria: [
|
||||||
|
'/agenda',
|
||||||
|
'/pacientes',
|
||||||
|
'/comunicacao', '/mensagens',
|
||||||
|
'/configuracoes', '/config',
|
||||||
|
'/perfil',
|
||||||
|
],
|
||||||
|
paciente: [
|
||||||
|
'/inicio', '/home', '/dashboard',
|
||||||
|
'/configuracoes', '/config',
|
||||||
|
'/perfil',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacidades especiais por role
|
||||||
|
export const ROLE_CAPABILITIES = {
|
||||||
|
admin: {
|
||||||
|
manageUsers: true,
|
||||||
|
hardDeletePatients: true,
|
||||||
|
accessSettings: true,
|
||||||
|
ownAppointmentsOnly: false,
|
||||||
|
canEditPatients: true,
|
||||||
|
canViewReports: true,
|
||||||
|
canViewMedicalRecords: true,
|
||||||
|
},
|
||||||
|
gestor: {
|
||||||
|
manageUsers: true,
|
||||||
|
hardDeletePatients: true,
|
||||||
|
accessSettings: true,
|
||||||
|
ownAppointmentsOnly: false,
|
||||||
|
canEditPatients: true,
|
||||||
|
canViewReports: true,
|
||||||
|
canViewMedicalRecords: true,
|
||||||
|
},
|
||||||
|
medico: {
|
||||||
|
manageUsers: false,
|
||||||
|
hardDeletePatients: false,
|
||||||
|
accessSettings: true,
|
||||||
|
ownAppointmentsOnly: true,
|
||||||
|
canEditPatients: false,
|
||||||
|
canViewReports: true,
|
||||||
|
canViewMedicalRecords: true,
|
||||||
|
},
|
||||||
|
secretaria: {
|
||||||
|
manageUsers: false,
|
||||||
|
hardDeletePatients: false,
|
||||||
|
accessSettings: true,
|
||||||
|
ownAppointmentsOnly: false,
|
||||||
|
canEditPatients: true,
|
||||||
|
canViewReports: false,
|
||||||
|
canViewMedicalRecords: false,
|
||||||
|
},
|
||||||
|
paciente: {
|
||||||
|
manageUsers: false,
|
||||||
|
hardDeletePatients: false,
|
||||||
|
accessSettings: true,
|
||||||
|
ownAppointmentsOnly: false,
|
||||||
|
canEditPatients: false,
|
||||||
|
canViewReports: false,
|
||||||
|
canViewMedicalRecords: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Itens do menu por role (para o AppShell)
|
||||||
|
export const ROLE_NAV_ITEMS = {
|
||||||
|
admin: [
|
||||||
|
{ path: '/inicio', label: 'Painel' },
|
||||||
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
|
{ path: '/prontuario', label: 'Prontuário' },
|
||||||
|
{ path: '/laudos', label: 'Relatórios' },
|
||||||
|
{ path: '/relatorios', label: 'Analytics' },
|
||||||
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
|
{ path: '/usuarios', label: 'Usuários' },
|
||||||
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
|
],
|
||||||
|
gestor: [
|
||||||
|
{ path: '/inicio', label: 'Painel' },
|
||||||
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
|
{ path: '/prontuario', label: 'Prontuário' },
|
||||||
|
{ path: '/laudos', label: 'Relatórios' },
|
||||||
|
{ path: '/relatorios', label: 'Analytics' },
|
||||||
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
|
{ path: '/usuarios', label: 'Usuários' },
|
||||||
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
|
],
|
||||||
|
medico: [
|
||||||
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
|
{ path: '/prontuario', label: 'Prontuário' },
|
||||||
|
{ path: '/laudos', label: 'Relatórios' },
|
||||||
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
|
],
|
||||||
|
secretaria: [
|
||||||
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
|
],
|
||||||
|
paciente: [
|
||||||
|
{ path: '/inicio', label: 'Painel' },
|
||||||
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se um role pode acessar uma rota
|
||||||
|
export function canAccess(role, pathname) {
|
||||||
|
const normalizedRole = normalizeRole(role)
|
||||||
|
if (!normalizedRole) return false
|
||||||
|
const allowed = ROLE_ROUTES[normalizedRole]
|
||||||
|
if (allowed === '*') return true
|
||||||
|
if (!Array.isArray(allowed)) return false
|
||||||
|
return allowed.some((route) => pathname === route || pathname.startsWith(route + '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se um role tem uma capacidade específica
|
||||||
|
export function hasCapability(role, capability) {
|
||||||
|
const normalizedRole = normalizeRole(role)
|
||||||
|
return ROLE_CAPABILITIES[normalizedRole]?.[capability] ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRole(role) {
|
||||||
|
const normalized = normalizeRoleKey(role)
|
||||||
|
return ROLE_ALIASES[normalized] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoleKey(role) {
|
||||||
|
return String(role ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rótulos amigáveis para cada role
|
||||||
|
export const ROLE_LABELS = {
|
||||||
|
admin: 'Administrador',
|
||||||
|
gestor: 'Gestão / Coordenação',
|
||||||
|
medico: 'Médico',
|
||||||
|
secretaria: 'Secretária',
|
||||||
|
paciente: 'Paciente',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles que um gestor pode criar
|
||||||
|
export const GESTOR_CREATABLE_ROLES = ['medico', 'secretaria', 'paciente']
|
||||||
|
|
||||||
|
// Roles que um admin pode criar
|
||||||
|
export const ADMIN_CREATABLE_ROLES = ['admin', 'gestor', 'medico', 'secretaria', 'paciente']
|
||||||
@@ -65,8 +65,8 @@ export const patients = [
|
|||||||
nextVisit: '07 abr 2026, 14:30',
|
nextVisit: '07 abr 2026, 14:30',
|
||||||
team: ['Dr. Rafael Nunes', 'Nutri. Clara Meireles'],
|
team: ['Dr. Rafael Nunes', 'Nutri. Clara Meireles'],
|
||||||
notes: [
|
notes: [
|
||||||
'Pressao ainda oscilando no periodo da tarde.',
|
'Pressão ainda oscilando no período da tarde.',
|
||||||
'Conferir adesao ao medicamento e orientar diario de pressao.',
|
'Conferir adesão ao medicamento e orientar diário de pressão.',
|
||||||
],
|
],
|
||||||
exams: ['MAPA 24h', 'Eletrocardiograma', 'Creatinina'],
|
exams: ['MAPA 24h', 'Eletrocardiograma', 'Creatinina'],
|
||||||
},
|
},
|
||||||
@@ -179,10 +179,10 @@ export const careQueue = [
|
|||||||
id: 'queue-002',
|
id: 'queue-002',
|
||||||
patient: 'Bruno Lima',
|
patient: 'Bruno Lima',
|
||||||
patientId: 'bruno-lima',
|
patientId: 'bruno-lima',
|
||||||
status: 'Aguardando medico',
|
status: 'Aguardando médico',
|
||||||
priority: 'Alta',
|
priority: 'Alta',
|
||||||
wait: '25 min',
|
wait: '25 min',
|
||||||
reason: 'Pressao elevada',
|
reason: 'Pressão elevada',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'queue-003',
|
id: 'queue-003',
|
||||||
@@ -226,7 +226,7 @@ export const conversations = [
|
|||||||
id: 'conv-bruno',
|
id: 'conv-bruno',
|
||||||
patient: 'Bruno Lima',
|
patient: 'Bruno Lima',
|
||||||
patientId: 'bruno-lima',
|
patientId: 'bruno-lima',
|
||||||
subject: 'Pressao no fim do dia',
|
subject: 'Pressão no fim do dia',
|
||||||
unread: 1,
|
unread: 1,
|
||||||
lastMessage: 'Hoje marcou 15 por 9 novamente.',
|
lastMessage: 'Hoje marcou 15 por 9 novamente.',
|
||||||
status: 'Prioridade alta',
|
status: 'Prioridade alta',
|
||||||
@@ -247,7 +247,7 @@ export const conversations = [
|
|||||||
id: 'conv-carla',
|
id: 'conv-carla',
|
||||||
patient: 'Carla Mendes',
|
patient: 'Carla Mendes',
|
||||||
patientId: 'carla-mendes',
|
patientId: 'carla-mendes',
|
||||||
subject: 'Confirmacao de horario',
|
subject: 'Confirmação de horario',
|
||||||
unread: 0,
|
unread: 0,
|
||||||
lastMessage: 'Confirmado para quinta as 08:30.',
|
lastMessage: 'Confirmado para quinta as 08:30.',
|
||||||
status: 'Respondida',
|
status: 'Respondida',
|
||||||
@@ -265,7 +265,7 @@ export const professionals = [
|
|||||||
{
|
{
|
||||||
id: 'marina-lopes',
|
id: 'marina-lopes',
|
||||||
name: 'Dra. Marina Lopes',
|
name: 'Dra. Marina Lopes',
|
||||||
role: 'Clinica geral',
|
role: 'Clínica geral',
|
||||||
schedule: 'Seg a sex, 08:00-16:00',
|
schedule: 'Seg a sex, 08:00-16:00',
|
||||||
status: 'Disponivel',
|
status: 'Disponivel',
|
||||||
nextSlot: 'Hoje, 15:30',
|
nextSlot: 'Hoje, 15:30',
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { isSameDay } from 'date-fns'
|
import { isSameDay } from 'date-fns'
|
||||||
|
|
||||||
import { appointmentRepository } from '../repositories/appointmentRepository.js'
|
import { appointmentRepository } from '../repositories/appointmentRepository.js'
|
||||||
|
import { availabilityRepository } from '../repositories/availabilityRepository.js'
|
||||||
import { patientRepository } from '../repositories/patientRepository.js'
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
import { profileRepository } from '../repositories/profileRepository.js'
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
import { formatLocalDateInput, parseLocalDate, sortAppointmentsByTime } from '../utils/agendaDate.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() {
|
export function useAgenda() {
|
||||||
const [patients, setPatients] = useState([])
|
const [patients, setPatients] = useState([])
|
||||||
const [professionals, setProfessionals] = useState([])
|
const [professionals, setProfessionals] = useState([])
|
||||||
@@ -15,19 +26,24 @@ export function useAgenda() {
|
|||||||
const [localAppointments, setLocalAppointments] = useState([])
|
const [localAppointments, setLocalAppointments] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [availableSlots, setAvailableSlots] = useState([])
|
||||||
|
const [slotsLoading, setSlotsLoading] = useState(false)
|
||||||
|
const [slotsError, setSlotsError] = useState('')
|
||||||
|
|
||||||
const [activeView, setActiveView] = useState('Dia')
|
const [activeView, setActiveView] = useState('Dia')
|
||||||
const [baseDate, setBaseDate] = useState(new Date())
|
const [baseDate, setBaseDate] = useState(new Date())
|
||||||
const [status, setStatus] = useState('Todos')
|
const [status, setStatus] = useState('Todos')
|
||||||
|
const [doctorFilter, setDoctorFilter] = useState('Todos')
|
||||||
|
const [doctorSearch, setDoctorSearch] = useState('')
|
||||||
|
const [unitFilter, setUnitFilter] = useState('')
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingAppointment, setEditingAppointment] = useState(null)
|
||||||
|
const [form, setForm] = useState(initialForm)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
|
||||||
patientId: '',
|
const canCreateAppointment = agendaScope === 'doctor'
|
||||||
professionalId: '',
|
? Boolean(currentProfessional?.id)
|
||||||
type: 'Retorno',
|
: professionals.length > 0
|
||||||
time: '15:30',
|
|
||||||
mode: 'Teleconsulta',
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@@ -44,10 +60,10 @@ export function useAgenda() {
|
|||||||
|
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
||||||
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
const currentScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
||||||
const resolvedProfessional = resolveCurrentProfessional(currentProfile, professionalsData)
|
const resolvedProfessional = professionalRepository.resolveCurrentProfessional(currentProfile, professionalsData)
|
||||||
const initialProfessionalId =
|
const initialProfessionalId =
|
||||||
agendaScope === 'doctor'
|
currentScope === 'doctor'
|
||||||
? resolvedProfessional?.id || ''
|
? resolvedProfessional?.id || ''
|
||||||
: professionalsData?.[0]?.id || ''
|
: professionalsData?.[0]?.id || ''
|
||||||
|
|
||||||
@@ -61,20 +77,20 @@ export function useAgenda() {
|
|||||||
professionalId: initialProfessionalId,
|
professionalId: initialProfessionalId,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (agendaScope === 'doctor' && !resolvedProfessional) {
|
if (currentScope === 'doctor' && !resolvedProfessional) {
|
||||||
setLocalAppointments([])
|
setLocalAppointments([])
|
||||||
setError('Nao foi possivel vincular o medico logado a um profissional da base.')
|
setError('Não foi possível vincular o médico logado a um profissional da base.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const appointmentsData = await appointmentRepository.getAll({
|
const appointmentsData = await appointmentRepository.getAll({
|
||||||
doctorId: agendaScope === 'doctor' ? resolvedProfessional?.id : undefined,
|
doctorId: currentScope === 'doctor' ? resolvedProfessional?.id : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
||||||
setLocalAppointments(
|
setLocalAppointments(
|
||||||
agendaScope === 'doctor' && resolvedProfessional
|
currentScope === 'doctor' && resolvedProfessional
|
||||||
? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id)
|
? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id)
|
||||||
: sortAppointmentsByTime(appointmentsData || []),
|
: sortAppointmentsByTime(appointmentsData || []),
|
||||||
)
|
)
|
||||||
@@ -84,9 +100,7 @@ export function useAgenda() {
|
|||||||
console.error(loadError)
|
console.error(loadError)
|
||||||
setError(loadError.message || 'Erro ao carregar agenda.')
|
setError(loadError.message || 'Erro ao carregar agenda.')
|
||||||
} finally {
|
} finally {
|
||||||
if (active) {
|
if (active) setLoading(false)
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +111,60 @@ export function useAgenda() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen || editingAppointment) return
|
||||||
|
|
||||||
|
const targetProfessionalId = agendaScope === 'doctor'
|
||||||
|
? currentProfessional?.id
|
||||||
|
: form.professionalId
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
async function loadAvailableSlots() {
|
||||||
|
if (!targetProfessionalId) {
|
||||||
|
setAvailableSlots([])
|
||||||
|
setSlotsError('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSlotsLoading(true)
|
||||||
|
setSlotsError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const slots = await availabilityRepository.getAvailableSlots({
|
||||||
|
doctorId: targetProfessionalId,
|
||||||
|
date: formatLocalDateInput(baseDate),
|
||||||
|
appointmentType: form.mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
const activeSlots = slots.filter((slot) => slot.available)
|
||||||
|
setAvailableSlots(activeSlots)
|
||||||
|
|
||||||
|
if (activeSlots.length) {
|
||||||
|
setForm((current) =>
|
||||||
|
activeSlots.some((slot) => slot.time === current.time)
|
||||||
|
? current
|
||||||
|
: { ...current, time: activeSlots[0].time },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
if (!active) return
|
||||||
|
setAvailableSlots([])
|
||||||
|
setSlotsError(loadError.message || 'Não foi possível calcular horários disponíveis.')
|
||||||
|
} finally {
|
||||||
|
if (active) setSlotsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAvailableSlots()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [agendaScope, baseDate, currentProfessional?.id, editingAppointment, form.mode, form.professionalId, modalOpen])
|
||||||
|
|
||||||
const visibleAppointments = useMemo(() => {
|
const visibleAppointments = useMemo(() => {
|
||||||
let filtered = localAppointments
|
let filtered = localAppointments
|
||||||
|
|
||||||
@@ -104,6 +172,30 @@ export function useAgenda() {
|
|||||||
filtered = filtered.filter((appointment) => appointment.status === status)
|
filtered = filtered.filter((appointment) => appointment.status === status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (agendaScope !== 'doctor' && doctorFilter !== 'Todos') {
|
||||||
|
filtered = filterAppointmentsByProfessional(filtered, doctorFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agendaScope !== 'doctor') {
|
||||||
|
const normalizedDoctorSearch = normalizeValue(doctorSearch)
|
||||||
|
const normalizedUnit = normalizeValue(unitFilter)
|
||||||
|
|
||||||
|
if (normalizedDoctorSearch || normalizedUnit) {
|
||||||
|
filtered = filtered.filter((appointment) => {
|
||||||
|
const professional = professionals.find(
|
||||||
|
(item) => normalizeValue(item.id) === normalizeValue(appointment.professionalId),
|
||||||
|
)
|
||||||
|
const professionalName = normalizeValue(professional?.name || appointment.professional)
|
||||||
|
const professionalUnit = normalizeValue(professional?.unit || appointment.unit)
|
||||||
|
|
||||||
|
return (
|
||||||
|
(!normalizedDoctorSearch || professionalName.includes(normalizedDoctorSearch)) &&
|
||||||
|
(!normalizedUnit || professionalUnit === normalizedUnit)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (activeView === 'Dia') {
|
if (activeView === 'Dia') {
|
||||||
filtered = filtered.filter((appointment) => {
|
filtered = filtered.filter((appointment) => {
|
||||||
if (!appointment.date) return false
|
if (!appointment.date) return false
|
||||||
@@ -116,46 +208,154 @@ export function useAgenda() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortAppointmentsByTime(filtered)
|
return sortAppointmentsByTime(filtered)
|
||||||
}, [localAppointments, status, activeView, baseDate])
|
}, [activeView, agendaScope, baseDate, doctorFilter, doctorSearch, localAppointments, professionals, status, unitFilter])
|
||||||
|
|
||||||
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
|
|
||||||
const canCreateAppointment = agendaScope === 'doctor'
|
|
||||||
? Boolean(currentProfessional?.id)
|
|
||||||
: professionals.length > 0
|
|
||||||
|
|
||||||
function updateForm(field, value) {
|
function updateForm(field, value) {
|
||||||
setForm((current) => ({ ...current, [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()
|
event.preventDefault()
|
||||||
|
|
||||||
|
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'
|
const targetProfessionalId = agendaScope === 'doctor'
|
||||||
? currentProfessional?.id
|
? currentProfessional?.id
|
||||||
: form.professionalId
|
: form.professionalId
|
||||||
|
|
||||||
if (!targetProfessionalId) {
|
if (!targetProfessionalId) {
|
||||||
alert('Nao foi possivel identificar o profissional da consulta.')
|
alert('Não foi possível identificar o profissional da consulta.')
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateStr = formatLocalDateInput(baseDate)
|
return {
|
||||||
|
patientId: form.patientId,
|
||||||
try {
|
date: formatLocalDateInput(baseDate),
|
||||||
const created = await appointmentRepository.create({
|
time: form.time,
|
||||||
patientId: form.patientId,
|
type: form.type,
|
||||||
date: dateStr,
|
mode: form.mode,
|
||||||
time: form.time,
|
status: form.status,
|
||||||
type: form.type,
|
notes: form.notes,
|
||||||
mode: form.mode,
|
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
||||||
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
|
professionalId: targetProfessionalId,
|
||||||
professionalId: targetProfessionalId,
|
...overrides,
|
||||||
})
|
|
||||||
|
|
||||||
setLocalAppointments((current) => sortAppointmentsByTime([...current, created]))
|
|
||||||
setModalOpen(false)
|
|
||||||
} catch (createError) {
|
|
||||||
alert(createError.message || 'Erro ao criar agendamento.')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,29 +374,28 @@ export function useAgenda() {
|
|||||||
setBaseDate,
|
setBaseDate,
|
||||||
status,
|
status,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
doctorFilter,
|
||||||
|
setDoctorFilter,
|
||||||
|
doctorSearch,
|
||||||
|
setDoctorSearch,
|
||||||
|
unitFilter,
|
||||||
|
setUnitFilter,
|
||||||
modalOpen,
|
modalOpen,
|
||||||
setModalOpen,
|
editingAppointment,
|
||||||
form,
|
form,
|
||||||
updateForm,
|
updateForm,
|
||||||
handleCreate,
|
openCreateModal,
|
||||||
|
openAppointmentModal,
|
||||||
|
closeAppointmentModal,
|
||||||
|
handleSubmitAppointment,
|
||||||
|
handleCancelAppointment,
|
||||||
visibleAppointments,
|
visibleAppointments,
|
||||||
|
availableSlots,
|
||||||
|
slotsLoading,
|
||||||
|
slotsError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCurrentProfessional(profile, professionals) {
|
|
||||||
const doctorId = normalizeValue(profile?.doctorId)
|
|
||||||
const userId = normalizeValue(profile?.id)
|
|
||||||
const email = normalizeValue(profile?.email)
|
|
||||||
|
|
||||||
return (
|
|
||||||
professionals.find((professional) => normalizeValue(professional.id) === doctorId) ||
|
|
||||||
professionals.find((professional) => normalizeValue(professional.userId) === userId) ||
|
|
||||||
professionals.find((professional) => normalizeValue(professional.id) === userId) ||
|
|
||||||
professionals.find((professional) => normalizeValue(professional.email) === email) ||
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterAppointmentsByProfessional(appointments, professionalId) {
|
function filterAppointmentsByProfessional(appointments, professionalId) {
|
||||||
const normalizedProfessionalId = normalizeValue(professionalId)
|
const normalizedProfessionalId = normalizeValue(professionalId)
|
||||||
|
|
||||||
@@ -205,6 +404,26 @@ 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeValue(value) {
|
function normalizeValue(value) {
|
||||||
return String(value || '').trim().toLowerCase()
|
return String(value || '').trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/hooks/useAuth.js
Normal file
101
src/hooks/useAuth.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { AUTH_SESSION_CHANGED_EVENT, getAuthSession, saveAuthSession } from '../config/api.js'
|
||||||
|
import { normalizeRole } from '../config/permissions.js'
|
||||||
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [state, setState] = useState(() => getStateFromSession(getAuthSession()))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function syncSession() {
|
||||||
|
setState(getStateFromSession(getAuthSession()))
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||||
|
return () => window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.isAuthenticated || state.role) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
authRepository
|
||||||
|
.getUser()
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled || !data) return
|
||||||
|
|
||||||
|
const profile = data.profile ?? data.perfil ?? null
|
||||||
|
const user = data.user ?? data.usuario ?? data ?? null
|
||||||
|
const role = resolveRole(data)
|
||||||
|
const session = getAuthSession()
|
||||||
|
|
||||||
|
saveAuthSession({ ...session, role, profile, user: user || session?.user })
|
||||||
|
setState((current) => ({ ...current, role, profile, user: user || current.user, loading: false }))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState((current) => ({ ...current, loading: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [state.isAuthenticated, state.role])
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateFromSession(session) {
|
||||||
|
const role = normalizeRole(session?.role)
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: session?.user ?? null,
|
||||||
|
role,
|
||||||
|
profile: session?.profile ?? null,
|
||||||
|
isAuthenticated: !!session?.access_token,
|
||||||
|
loading: !!session?.access_token && !role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRole(data) {
|
||||||
|
const user = data?.user ?? data?.usuario ?? {}
|
||||||
|
const profile = data?.profile ?? data?.perfil ?? {}
|
||||||
|
const metadata = {
|
||||||
|
...user?.user_metadata,
|
||||||
|
...user?.app_metadata,
|
||||||
|
...user?.metadata,
|
||||||
|
...data?.user_metadata,
|
||||||
|
...data?.app_metadata,
|
||||||
|
...data?.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
...(Array.isArray(data?.roles) ? data.roles : []),
|
||||||
|
...(Array.isArray(user?.roles) ? user.roles : []),
|
||||||
|
data?.role,
|
||||||
|
data?.cargo,
|
||||||
|
profile?.role,
|
||||||
|
profile?.cargo,
|
||||||
|
user?.role,
|
||||||
|
user?.cargo,
|
||||||
|
metadata.role,
|
||||||
|
metadata.cargo,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const role = normalizeRole(candidate)
|
||||||
|
if (role) return role
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = data?.permissions ?? data?.permissoes ?? {}
|
||||||
|
if (permissions.isAdmin) return 'admin'
|
||||||
|
if (permissions.isManager) return 'gestor'
|
||||||
|
if (permissions.isDoctor) return 'medico'
|
||||||
|
if (permissions.isSecretary) return 'secretaria'
|
||||||
|
if (permissions.isPatient) return 'paciente'
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
495
src/index.css
495
src/index.css
@@ -43,3 +43,498 @@ button:disabled {
|
|||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
color: #333333;
|
||||||
|
background: #cfd7e0;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] body {
|
||||||
|
background: #cfd7e0;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] input,
|
||||||
|
[data-theme='light'] select,
|
||||||
|
[data-theme='light'] textarea {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] aside.bg-\[\#262626\] {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .bg-\[\#0a0a0a\],
|
||||||
|
[data-theme='light'] .bg-\[\#171717\] {
|
||||||
|
background-color: #cfd7e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .bg-\[\#1a1a1a\] {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .bg-\[\#262626\] {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .bg-\[\#1f1f1f\],
|
||||||
|
[data-theme='light'] .bg-\[\#202020\] {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .bg-\[\#2a2a2a\],
|
||||||
|
[data-theme='light'] .bg-\[\#303030\],
|
||||||
|
[data-theme='light'] .bg-\[\#333333\] {
|
||||||
|
background-color: #e8edf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .bg-\[\#3b82f6\] {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .bg-\[\#2563eb\],
|
||||||
|
[data-theme='light'] .hover\:bg-\[\#2563eb\]:hover,
|
||||||
|
[data-theme='light'] .hover\:bg-\[\#3478ed\]:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .hover\:bg-\[\#2a2a2a\]:hover,
|
||||||
|
[data-theme='light'] .hover\:bg-\[\#303030\]:hover,
|
||||||
|
[data-theme='light'] .hover\:bg-\[\#333333\]:hover {
|
||||||
|
background-color: #e8edf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .disabled\:bg-\[\#303030\]:disabled {
|
||||||
|
background-color: #cfd7e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .border-\[\#404040\],
|
||||||
|
[data-theme='light'] .divide-\[\#404040\] > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
border-color: #d6dee8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .border-\[\#525252\],
|
||||||
|
[data-theme='light'] .hover\:border-\[\#525252\]:hover {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .hover\:border-\[\#404040\]:hover,
|
||||||
|
[data-theme='light'] .disabled\:border-\[\#404040\]:disabled {
|
||||||
|
border-color: #d6dee8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .text-\[\#f5f5f5\],
|
||||||
|
[data-theme='light'] .text-\[\#e5e5e5\],
|
||||||
|
[data-theme='light'] .hover\:text-\[\#e5e5e5\]:hover {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .text-\[\#d4d4d4\],
|
||||||
|
[data-theme='light'] .text-\[\#b8b8b8\] {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .text-\[\#a3a3a3\],
|
||||||
|
[data-theme='light'] .text-\[\#737373\],
|
||||||
|
[data-theme='light'] .disabled\:text-\[\#737373\]:disabled {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .text-\[\#51a2ff\] {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .placeholder\:text-\[\#737373\]::placeholder,
|
||||||
|
[data-theme='light'] .placeholder\:text-\[\#a3a3a3\]::placeholder {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] [class*='[color-scheme:dark]'] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] svg [stroke='#303030'] {
|
||||||
|
stroke: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] svg [stroke='#1d4ed8'] {
|
||||||
|
stroke: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] svg [fill='#262626'] {
|
||||||
|
fill: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] svg [fill='#a3a3a3'] {
|
||||||
|
fill: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
import { applyTheme, getStoredTheme } from './utils/theme.js'
|
||||||
|
|
||||||
|
applyTheme(getStoredTheme())
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const appointmentMapper = {
|
|||||||
cancelled: 'Cancelada',
|
cancelled: 'Cancelada',
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawStatus = (apiData.status || '').toLowerCase()
|
const rawStatus = String(apiData.status || '').toLowerCase()
|
||||||
const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando'
|
const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando'
|
||||||
|
|
||||||
// Modalidade
|
// Modalidade
|
||||||
@@ -60,12 +60,13 @@ export const appointmentMapper = {
|
|||||||
professional.full_name ||
|
professional.full_name ||
|
||||||
professional.name ||
|
professional.name ||
|
||||||
professional.nome ||
|
professional.nome ||
|
||||||
'Medico(a)',
|
'Médico(a)',
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
time: timeStr,
|
time: timeStr,
|
||||||
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
|
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
|
||||||
mode: mode,
|
mode: mode,
|
||||||
status: mappedStatus,
|
status: mappedStatus,
|
||||||
|
notes: apiData.notes || apiData.observations || apiData.observacoes || apiData.observacao || apiData.description || '',
|
||||||
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
|
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -80,7 +81,9 @@ export const appointmentMapper = {
|
|||||||
doctor_id: uiData.professionalId || null,
|
doctor_id: uiData.professionalId || null,
|
||||||
scheduled_at: scheduledAt,
|
scheduled_at: scheduledAt,
|
||||||
appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial',
|
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
|
duration_minutes: 30, // Padrao
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,37 @@ export const appointmentMapper = {
|
|||||||
mode: uiData.mode,
|
mode: uiData.mode,
|
||||||
status: uiData.status || 'Confirmada',
|
status: uiData.status || 'Confirmada',
|
||||||
room: uiData.room,
|
room: uiData.room,
|
||||||
|
notes: uiData.notes,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const reportMapper = {
|
|||||||
toApi(uiData) {
|
toApi(uiData) {
|
||||||
return cleanPayload({
|
return cleanPayload({
|
||||||
patient_id: uiData.patientId,
|
patient_id: uiData.patientId,
|
||||||
|
order_number: emptyToUndefined(uiData.orderNumber),
|
||||||
status: normalizeApiStatus(uiData.status),
|
status: normalizeApiStatus(uiData.status),
|
||||||
exam: emptyToUndefined(uiData.exam),
|
exam: emptyToUndefined(uiData.exam),
|
||||||
requested_by: emptyToUndefined(uiData.requestedBy),
|
requested_by: emptyToUndefined(uiData.requestedBy),
|
||||||
@@ -35,19 +36,24 @@ export const reportMapper = {
|
|||||||
conclusion: emptyToUndefined(uiData.conclusion),
|
conclusion: emptyToUndefined(uiData.conclusion),
|
||||||
content_html: emptyToUndefined(uiData.contentHtml),
|
content_html: emptyToUndefined(uiData.contentHtml),
|
||||||
content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson,
|
content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson,
|
||||||
hide_date: Boolean(uiData.hideDate),
|
|
||||||
hide_signature: Boolean(uiData.hideSignature),
|
|
||||||
due_at: emptyToUndefined(uiData.dueAt),
|
due_at: emptyToUndefined(uiData.dueAt),
|
||||||
|
created_by: emptyToUndefined(uiData.createdBy),
|
||||||
|
updated_by: emptyToUndefined(uiData.updatedBy),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(status) {
|
function normalizeStatus(status) {
|
||||||
return status === 'draft' ? 'draft' : 'draft'
|
const normalized = String(status || '').toLowerCase()
|
||||||
|
if (['finalized', 'finalizado', 'finished', 'completed', 'done'].includes(normalized)) {
|
||||||
|
return 'finalized'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'draft'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeApiStatus(status) {
|
function normalizeApiStatus(status) {
|
||||||
return status === 'draft' ? 'draft' : 'draft'
|
return status === 'finalized' ? 'finalized' : 'draft'
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyToUndefined(value) {
|
function emptyToUndefined(value) {
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
subDays,
|
|
||||||
addWeeks,
|
|
||||||
subWeeks,
|
|
||||||
addMonths,
|
addMonths,
|
||||||
subMonths,
|
addWeeks,
|
||||||
endOfWeek,
|
endOfWeek,
|
||||||
format,
|
format,
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
|
subDays,
|
||||||
|
subMonths,
|
||||||
|
subWeeks,
|
||||||
} from 'date-fns'
|
} from 'date-fns'
|
||||||
import { ptBR } from 'date-fns/locale'
|
import { ptBR } from 'date-fns/locale'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
||||||
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
|
||||||
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
||||||
|
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||||
import { useAgenda } from '../hooks/useAgenda.js'
|
import { useAgenda } from '../hooks/useAgenda.js'
|
||||||
|
import { formatLocalDateInput, parseLocalDate } from '../utils/agendaDate.js'
|
||||||
|
|
||||||
const statusFilters = [
|
const statusFilters = [
|
||||||
{ label: 'Todos', value: 'Todos' },
|
{ label: 'Todos', value: 'Todos' },
|
||||||
{ label: 'Confirmadas', value: 'Confirmada' },
|
{ label: 'Confirmadas', value: 'Confirmada' },
|
||||||
{ label: 'Em triagem', value: 'Em triagem' },
|
{ label: 'Em triagem', value: 'Em triagem' },
|
||||||
{ label: 'Aguardando', value: 'Aguardando' },
|
{ label: 'Aguardando', value: 'Aguardando' },
|
||||||
|
{ label: 'Canceladas', value: 'Cancelada' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const viewFilters = [
|
const viewFilters = [
|
||||||
@@ -29,7 +32,12 @@ const viewFilters = [
|
|||||||
{ label: 'Mês', value: 'Mes' },
|
{ label: 'Mês', value: 'Mes' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AgendaPage({ navigate }) {
|
const appointmentTypeOptions = ['Retorno', 'Primeira consulta', 'Exame', 'Avaliação pre-op']
|
||||||
|
const appointmentStatusOptions = ['Confirmada', 'Em triagem', 'Aguardando']
|
||||||
|
|
||||||
|
export function AgendaPage() {
|
||||||
|
const [modalPatientSearch, setModalPatientSearch] = useState('')
|
||||||
|
const [modalDoctorSearch, setModalDoctorSearch] = useState('')
|
||||||
const {
|
const {
|
||||||
patients,
|
patients,
|
||||||
professionals,
|
professionals,
|
||||||
@@ -45,12 +53,24 @@ export function AgendaPage({ navigate }) {
|
|||||||
setBaseDate,
|
setBaseDate,
|
||||||
status,
|
status,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
setDoctorFilter,
|
||||||
|
doctorSearch,
|
||||||
|
setDoctorSearch,
|
||||||
|
unitFilter,
|
||||||
|
setUnitFilter,
|
||||||
modalOpen,
|
modalOpen,
|
||||||
setModalOpen,
|
editingAppointment,
|
||||||
form,
|
form,
|
||||||
updateForm,
|
updateForm,
|
||||||
handleCreate,
|
openCreateModal,
|
||||||
|
openAppointmentModal,
|
||||||
|
closeAppointmentModal,
|
||||||
|
handleSubmitAppointment,
|
||||||
|
handleCancelAppointment,
|
||||||
visibleAppointments,
|
visibleAppointments,
|
||||||
|
availableSlots,
|
||||||
|
slotsLoading,
|
||||||
|
slotsError,
|
||||||
} = useAgenda()
|
} = useAgenda()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -64,6 +84,42 @@ export function AgendaPage({ navigate }) {
|
|||||||
const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
|
const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
|
||||||
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
|
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
|
||||||
const isDoctorScope = agendaScope === 'doctor'
|
const isDoctorScope = agendaScope === 'doctor'
|
||||||
|
const unitOptions = [
|
||||||
|
...new Set(professionals.map((professional) => professional.unit).filter(Boolean)),
|
||||||
|
].sort((a, b) => a.localeCompare(b, 'pt-BR'))
|
||||||
|
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 (
|
return (
|
||||||
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
||||||
@@ -73,9 +129,7 @@ export function AgendaPage({ navigate }) {
|
|||||||
Agenda
|
Agenda
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
|
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
|
||||||
{isDoctorScope
|
Perfil atual: {viewerProfile?.role || (isDoctorScope ? 'Médico' : 'Usuário')}
|
||||||
? `Agenda restrita ao médico logado: ${currentProfessional?.name || viewerProfile?.name || 'Médico atual'}.`
|
|
||||||
: 'Visualização completa da agenda com todos os médicos.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,10 +174,10 @@ export function AgendaPage({ navigate }) {
|
|||||||
<button
|
<button
|
||||||
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373] disabled:shadow-none"
|
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}
|
disabled={!canCreateAppointment}
|
||||||
onClick={() => setModalOpen(true)}
|
onClick={() => openCreate()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
+ Nova consulta
|
+ Novo agendamento
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -131,10 +185,10 @@ export function AgendaPage({ navigate }) {
|
|||||||
{error ? (
|
{error ? (
|
||||||
<section className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
|
<section className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
|
||||||
<div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
|
<div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
|
||||||
<h2 className="text-base font-bold text-[#fecaca]">Nao foi possivel liberar a agenda</h2>
|
<h2 className="text-base font-bold text-[#fecaca]">Não foi possível liberar a agenda</h2>
|
||||||
<p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
|
<p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
|
||||||
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
|
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
|
||||||
Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico.
|
Enquanto esse vínculo não existir na API, a tela fica bloqueada para evitar exibir consultas de outro médico.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -171,26 +225,61 @@ export function AgendaPage({ navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
<div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
{statusFilters.map((filter) => (
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
{statusFilters.map((filter) => (
|
||||||
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
<button
|
||||||
status === filter.value
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
status === filter.value
|
||||||
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||||
}`}
|
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||||
key={filter.value}
|
}`}
|
||||||
onClick={() => setStatus(filter.value)}
|
key={filter.value}
|
||||||
type="button"
|
onClick={() => setStatus(filter.value)}
|
||||||
>
|
type="button"
|
||||||
{filter.label}
|
>
|
||||||
</button>
|
{filter.label}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isDoctorScope ? (
|
||||||
|
<div className="grid gap-3 sm:min-w-[32rem] sm:grid-cols-2">
|
||||||
|
<label className="grid gap-1.5 text-xs font-semibold text-[#a3a3a3]">
|
||||||
|
<span>Médico</span>
|
||||||
|
<input
|
||||||
|
className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-medium text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => {
|
||||||
|
setDoctorFilter('Todos')
|
||||||
|
setDoctorSearch(event.target.value)
|
||||||
|
}}
|
||||||
|
placeholder="Pesquisar médico pelo nome"
|
||||||
|
type="search"
|
||||||
|
value={doctorSearch}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5 text-xs font-semibold text-[#a3a3a3]">
|
||||||
|
<span>Unidade</span>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-medium text-[#e5e5e5] outline-none transition focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => setUnitFilter(event.target.value)}
|
||||||
|
value={unitFilter}
|
||||||
|
>
|
||||||
|
<option value="">Todas as unidades</option>
|
||||||
|
{unitOptions.map((unit) => (
|
||||||
|
<option key={unit} value={unit}>
|
||||||
|
{unit}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isDoctorScope && (
|
{!isDoctorScope && (
|
||||||
<div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
<div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
||||||
Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais.
|
Perfil atual: {viewerProfile?.role || 'Administrador'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -199,7 +288,7 @@ export function AgendaPage({ navigate }) {
|
|||||||
<AgendaWeeklyView
|
<AgendaWeeklyView
|
||||||
baseDate={baseDate}
|
baseDate={baseDate}
|
||||||
appointments={visibleAppointments}
|
appointments={visibleAppointments}
|
||||||
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
|
onAppointmentClick={openManage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -216,9 +305,11 @@ export function AgendaPage({ navigate }) {
|
|||||||
|
|
||||||
{activeView === 'Dia' && (
|
{activeView === 'Dia' && (
|
||||||
<AgendaDailyView
|
<AgendaDailyView
|
||||||
baseDate={baseDate}
|
|
||||||
appointments={visibleAppointments}
|
appointments={visibleAppointments}
|
||||||
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
|
baseDate={baseDate}
|
||||||
|
canCreateAppointment={canCreateAppointment}
|
||||||
|
onAppointmentClick={openManage}
|
||||||
|
onSlotCreate={(time) => openCreate({ time })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -226,88 +317,197 @@ export function AgendaPage({ navigate }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
|
<DarkModal onClose={closeModal} open={modalOpen} title={editingAppointment ? 'Gerenciar agendamento' : 'Novo agendamento'}>
|
||||||
<form className="grid gap-4" onSubmit={handleCreate}>
|
<form className="grid gap-4" onSubmit={handleSubmitAppointment}>
|
||||||
<DarkField label="Paciente">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<select
|
<div className="grid content-start gap-4">
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
<DarkField label="Paciente">
|
||||||
onChange={(event) => updateForm('patientId', event.target.value)}
|
<input
|
||||||
value={form.patientId}
|
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) => {
|
||||||
{patients.map((patient) => (
|
setModalPatientSearch(event.target.value)
|
||||||
<option key={patient.id} value={patient.id}>
|
updateForm('patientId', '')
|
||||||
{patient.name || patient.full_name || patient.nome}
|
}}
|
||||||
</option>
|
placeholder="Pesquisar paciente"
|
||||||
))}
|
type="search"
|
||||||
</select>
|
value={modalPatientSearch || getPatientLabel(selectedPatient)}
|
||||||
</DarkField>
|
/>
|
||||||
|
<SearchResults
|
||||||
|
emptyText="Nenhum paciente encontrado."
|
||||||
|
getLabel={getPatientLabel}
|
||||||
|
items={filteredPatients.slice(0, 5)}
|
||||||
|
onSelect={(patient) => {
|
||||||
|
updateForm('patientId', patient.id)
|
||||||
|
setModalPatientSearch(getPatientLabel(patient))
|
||||||
|
}}
|
||||||
|
selectedId={form.patientId}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<DarkField label="Profissional">
|
||||||
<DarkField label="Horário">
|
{isDoctorScope ? (
|
||||||
<input
|
<input
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
||||||
onChange={(event) => updateForm('time', event.target.value)}
|
disabled
|
||||||
type="time"
|
readOnly
|
||||||
value={form.time}
|
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||||
/>
|
/>
|
||||||
</DarkField>
|
) : (
|
||||||
<DarkField label="Formato">
|
<>
|
||||||
<select
|
<input
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
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) => updateForm('mode', event.target.value)}
|
onChange={(event) => {
|
||||||
value={form.mode}
|
setModalDoctorSearch(event.target.value)
|
||||||
>
|
updateForm('professionalId', '')
|
||||||
<option>Teleconsulta</option>
|
}}
|
||||||
<option>Presencial</option>
|
placeholder="Pesquisar médico"
|
||||||
</select>
|
type="search"
|
||||||
</DarkField>
|
value={modalDoctorSearch || selectedProfessional?.name || ''}
|
||||||
|
/>
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
{timeOptions.map((time) => (
|
||||||
|
<option key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
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)}
|
||||||
|
type="time"
|
||||||
|
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>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<DarkField label="Profissional">
|
{editingAppointment ? (
|
||||||
{isDoctorScope ? (
|
<div className="rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
||||||
<input
|
<p>
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
Agendamento de {selectedPatient ? getPatientLabel(selectedPatient) : 'paciente não informado'} às {form.time}.
|
||||||
disabled
|
</p>
|
||||||
readOnly
|
<p className="mt-1">Status atual: {form.status}</p>
|
||||||
value={currentProfessional?.name || 'Médico não vinculado'}
|
{form.notes ? <p className="mt-1">Observações: {form.notes}</p> : null}
|
||||||
/>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<select
|
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
|
||||||
onChange={(event) => updateForm('professionalId', event.target.value)}
|
|
||||||
value={form.professionalId}
|
|
||||||
>
|
|
||||||
{professionals.map((professional) => (
|
|
||||||
<option key={professional.id} value={professional.id}>
|
|
||||||
{professional.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Tipo de consulta">
|
|
||||||
<input
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-end gap-3 pt-2">
|
<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
|
<button
|
||||||
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#333333]"
|
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"
|
type="button"
|
||||||
>
|
>
|
||||||
Cancelar
|
Fechar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
|
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}
|
disabled={!canCreateAppointment}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Salvar consulta
|
{editingAppointment ? 'Salvar alterações' : 'Salvar'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -326,13 +526,11 @@ function DarkField({ children, label }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DarkModal({ children, onClose, open, title }) {
|
function DarkModal({ children, onClose, open, title }) {
|
||||||
if (!open) {
|
if (!open) return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
||||||
<div className="w-full max-w-xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
|
<div className="w-full max-w-4xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
|
||||||
<div className="flex items-center justify-between gap-4 border-b border-[#404040] px-5 py-4">
|
<div className="flex items-center justify-between gap-4 border-b border-[#404040] px-5 py-4">
|
||||||
<h2 className="text-lg font-bold text-[#e5e5e5]">{title}</h2>
|
<h2 className="text-lg font-bold text-[#e5e5e5]">{title}</h2>
|
||||||
<button
|
<button
|
||||||
@@ -349,3 +547,68 @@ function DarkModal({ children, onClose, open, title }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SearchResults({ emptyText, getDescription, getLabel, items, onSelect, selectedId }) {
|
||||||
|
return (
|
||||||
|
<div className="max-h-44 overflow-y-auto rounded-md border border-[#404040] bg-[#1f1f1f]">
|
||||||
|
{items.length ? (
|
||||||
|
items.map((item) => {
|
||||||
|
const isSelected = String(item.id) === String(selectedId)
|
||||||
|
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={item.id}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">{getLabel(item)}</span>
|
||||||
|
{getDescription?.(item) ? (
|
||||||
|
<span className="mt-0.5 block text-xs text-[#737373]">{getDescription(item)}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-2 text-xs text-[#737373]">{emptyText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function AnalyticsPage() {
|
|||||||
|
|
||||||
<section className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<section className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Relatórios & Analytics</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Analytics</h1>
|
||||||
<p className="mt-1 text-sm text-[#b8b8b8]">Dashboard executivo com métricas de desempenho</p>
|
<p className="mt-1 text-sm text-[#b8b8b8]">Dashboard executivo com métricas de desempenho</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export function AnalyticsPage() {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-3" aria-label="Relatórios complementares">
|
<section className="grid gap-6 lg:grid-cols-3" aria-label="Analytics complementares">
|
||||||
<ChartCard description="Evolução de receita" title="Faturamento Mensal">
|
<ChartCard description="Evolução de receita" title="Faturamento Mensal">
|
||||||
<RevenueChart data={revenueData} />
|
<RevenueChart data={revenueData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|||||||
@@ -3,15 +3,23 @@ import { useState } from 'react'
|
|||||||
import { authRepository } from '../repositories/authRepository.js'
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
|
|
||||||
import { BrandLogo } from '../components/Brand.jsx'
|
import { BrandLogo } from '../components/Brand.jsx'
|
||||||
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import loginClinicImage from '../assets/figma/login-clinic.png'
|
import loginClinicImage from '../assets/figma/login-clinic.png'
|
||||||
|
|
||||||
|
const mockCredentials = [
|
||||||
|
{ label: 'Admin', email: 'hugo@popcode.com.br', password: 'hdoria' },
|
||||||
|
{ label: 'Médico', email: 'leticia.lacerda@souunit.com.br', password: 'Senha@123' },
|
||||||
|
{ label: 'Secretária', email: 'recepcao@mediconnect.com', password: 'demo12345' },
|
||||||
|
{ label: 'Gestor', email: 'gestao@mediconnect.com', password: '12345678' },
|
||||||
|
]
|
||||||
|
|
||||||
export function LoginPage({ navigate }) {
|
export function LoginPage({ navigate }) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [credentialsOpen, setCredentialsOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -35,7 +43,7 @@ export function LoginPage({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid min-h-screen lg:grid-cols-2">
|
||||||
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
||||||
<img
|
<img
|
||||||
@@ -48,7 +56,7 @@ export function LoginPage({ navigate }) {
|
|||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background:
|
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%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -99,7 +107,7 @@ export function LoginPage({ navigate }) {
|
|||||||
<LoginField htmlFor="login-email" label="E-mail">
|
<LoginField htmlFor="login-email" label="E-mail">
|
||||||
<input
|
<input
|
||||||
autoComplete="email"
|
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"
|
id="login-email"
|
||||||
onChange={(event) => updateField('email', event.target.value)}
|
onChange={(event) => updateField('email', event.target.value)}
|
||||||
placeholder="seu@email.com"
|
placeholder="seu@email.com"
|
||||||
@@ -124,7 +132,7 @@ export function LoginPage({ navigate }) {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
autoComplete="current-password"
|
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"
|
id="login-password"
|
||||||
onChange={(event) => updateField('password', event.target.value)}
|
onChange={(event) => updateField('password', event.target.value)}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
@@ -152,23 +160,45 @@ export function LoginPage({ navigate }) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="absolute bottom-4 right-4">
|
||||||
className="absolute bottom-4 right-4 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"
|
{credentialsOpen ? (
|
||||||
onClick={() => {
|
<div className="auth-menu mb-2 w-[292px] rounded-md border p-2 shadow-2xl">
|
||||||
setForm({
|
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||||
email: 'recepcao@mediconnect.com',
|
Credenciais de acesso
|
||||||
password: 'demo123',
|
</p>
|
||||||
})
|
<div className="grid gap-1">
|
||||||
}}
|
{mockCredentials.map((credential) => (
|
||||||
title="Preencher credenciais mockadas"
|
<button
|
||||||
type="button"
|
className="rounded px-2 py-2 text-left text-xs text-white/70 transition hover:bg-white/10 hover:text-white"
|
||||||
>
|
key={credential.email}
|
||||||
dev · credenciais
|
onClick={() => {
|
||||||
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" />
|
setForm({
|
||||||
<span aria-hidden="true" className="text-[9px]">
|
email: credential.email,
|
||||||
^
|
password: credential.password,
|
||||||
</span>
|
})
|
||||||
</button>
|
setCredentialsOpen(false)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">{credential.label}</span>
|
||||||
|
<span className="block font-mono text-[11px] text-white/40">{credential.email}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
dev · credenciais
|
||||||
|
<span aria-hidden="true" className="text-[9px]">
|
||||||
|
{credentialsOpen ? 'v' : '^'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -176,7 +206,7 @@ export function LoginPage({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterPage({ navigate }) {
|
export function RegisterPage({ navigate }) {
|
||||||
const [role, setRole] = useState('Clinica')
|
const [role, setRole] = useState('Clínica')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout
|
||||||
@@ -204,7 +234,7 @@ export function RegisterPage({ navigate }) {
|
|||||||
</AuthField>
|
</AuthField>
|
||||||
<AuthField label="Tipo de conta">
|
<AuthField label="Tipo de conta">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{['Clinica', 'Profissional'].map((option) => (
|
{['Clínica', 'Profissional'].map((option) => (
|
||||||
<button
|
<button
|
||||||
className={`h-11 rounded-[6px] border px-3 text-sm font-semibold transition ${
|
className={`h-11 rounded-[6px] border px-3 text-sm font-semibold transition ${
|
||||||
role === option
|
role === option
|
||||||
@@ -291,7 +321,7 @@ export function ForgotPasswordPage({ navigate }) {
|
|||||||
|
|
||||||
function AuthLayout({ children, description, title }) {
|
function AuthLayout({ children, description, title }) {
|
||||||
return (
|
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">
|
<div className="grid min-h-screen lg:grid-cols-2">
|
||||||
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
<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} />
|
<img alt="" className="absolute inset-0 h-full w-full object-cover" src={loginClinicImage} />
|
||||||
@@ -300,7 +330,7 @@ function AuthLayout({ children, description, title }) {
|
|||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
background:
|
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">
|
<div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
|
||||||
@@ -314,14 +344,14 @@ function AuthLayout({ children, description, title }) {
|
|||||||
<span className="text-[#3b82f6]">saúde.</span>
|
<span className="text-[#3b82f6]">saúde.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
|
<p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
|
||||||
Fluxos de acesso simulados para manter a navegação ponta a ponta sem backend real.
|
Segurança e continuidade para equipes de saúde.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex min-h-screen items-center justify-center px-6 py-12 sm:px-10 lg:px-[60px] xl:px-[68px]">
|
<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">
|
<div className="mb-12 lg:hidden">
|
||||||
<LoginLogo />
|
<LoginLogo />
|
||||||
</div>
|
</div>
|
||||||
@@ -336,11 +366,13 @@ function AuthLayout({ children, description, title }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authInputClass =
|
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 }) {
|
function AuthField({ children, label }) {
|
||||||
return (
|
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>
|
<span>{label}</span>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
@@ -350,7 +382,7 @@ function AuthField({ children, label }) {
|
|||||||
function LoginField({ action, children, htmlFor, label }) {
|
function LoginField({ action, children, htmlFor, label }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1.5">
|
<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>
|
<label htmlFor={htmlFor}>{label}</label>
|
||||||
{action}
|
{action}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -23,23 +23,6 @@ export function HomePage({ navigate }) {
|
|||||||
Bem-vindo, Dr. Henrique. Aqui está o resumo da sua clínica hoje.
|
Bem-vindo, Dr. Henrique. Aqui está o resumo da sua clínica hoje.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
|
||||||
<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]"
|
|
||||||
onClick={() => navigate('/agenda')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
+ Novo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-6 lg:grid-cols-3">
|
<section className="grid gap-6 lg:grid-cols-3">
|
||||||
@@ -120,7 +103,7 @@ export function HomePage({ navigate }) {
|
|||||||
|
|
||||||
<section className="grid gap-4" id="relatorios">
|
<section className="grid gap-4" id="relatorios">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-base font-bold text-[#e5e5e5]">Relatórios e Análises</h2>
|
<h2 className="text-base font-bold text-[#e5e5e5]">Analytics</h2>
|
||||||
<FeatureBadge status="mock" />
|
<FeatureBadge status="mock" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
@@ -209,7 +192,7 @@ function ReportAction({ card, navigate }) {
|
|||||||
|
|
||||||
function LineChart() {
|
function LineChart() {
|
||||||
return (
|
return (
|
||||||
<svg aria-label="Grafico mockado de absenteismo" className="h-full w-full" role="img" viewBox="0 0 732 260">
|
<svg aria-label="Gráfico mockado de absenteísmo" className="h-full w-full" role="img" viewBox="0 0 732 260">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="home-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
<linearGradient id="home-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.24" />
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.24" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
||||||
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
|
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
@@ -12,21 +13,36 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
|||||||
export function MedicalRecordsPage() {
|
export function MedicalRecordsPage() {
|
||||||
const recordTypes = medicalRecordRepository.getRecordTypes()
|
const recordTypes = medicalRecordRepository.getRecordTypes()
|
||||||
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
|
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
|
||||||
|
const [patients, setPatients] = useState([])
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filterType, setFilterType] = useState('')
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
patientRepository
|
||||||
|
.getDirectoryRows()
|
||||||
|
.then((data) => {
|
||||||
|
if (active) setPatients(data || [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) setPatients([])
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filteredRecords = useMemo(() => {
|
const filteredRecords = useMemo(() => {
|
||||||
return records.filter((record) => {
|
return records.filter((record) => {
|
||||||
const matchesSearch = [record.patient, record.cid, record.doctor]
|
const matchesSearch = [record.patient, record.cid, record.doctor]
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(search.toLowerCase())
|
.includes(search.toLowerCase())
|
||||||
const matchesType = !filterType || record.type === filterType
|
return matchesSearch
|
||||||
|
|
||||||
return matchesSearch && matchesType
|
|
||||||
})
|
})
|
||||||
}, [filterType, records, search])
|
}, [records, search])
|
||||||
|
|
||||||
function handleCreateRecord(record) {
|
function handleCreateRecord(record) {
|
||||||
setRecords((currentRecords) => [record, ...currentRecords])
|
setRecords((currentRecords) => [record, ...currentRecords])
|
||||||
@@ -67,21 +83,6 @@ export function MedicalRecordsPage() {
|
|||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative min-w-48">
|
|
||||||
<select
|
|
||||||
className="h-10 w-full appearance-none rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 pr-9 text-sm font-semibold text-[#e5e5e5] outline-none transition focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
|
|
||||||
onChange={(event) => setFilterType(event.target.value)}
|
|
||||||
value={filterType}
|
|
||||||
>
|
|
||||||
<option value="">Todos os Tipos</option>
|
|
||||||
{recordTypes.map((type) => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{type}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<RecordIcon className="pointer-events-none absolute right-3 top-3 size-4 text-[#a3a3a3]" name="chevron-down" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ export function MedicalRecordsPage() {
|
|||||||
<RecordEditorModal
|
<RecordEditorModal
|
||||||
onClose={() => setEditorOpen(false)}
|
onClose={() => setEditorOpen(false)}
|
||||||
onSave={handleCreateRecord}
|
onSave={handleCreateRecord}
|
||||||
|
patients={patients}
|
||||||
recordTypes={recordTypes}
|
recordTypes={recordTypes}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -167,8 +169,10 @@ function IconButton({ label, name }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
function RecordEditorModal({ onClose, onSave, patients, recordTypes }) {
|
||||||
|
const [patientSearch, setPatientSearch] = useState('')
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
patientId: '',
|
||||||
patient: '',
|
patient: '',
|
||||||
date: '',
|
date: '',
|
||||||
type: 'Primeira Consulta',
|
type: 'Primeira Consulta',
|
||||||
@@ -186,6 +190,31 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
|||||||
setFormData((currentData) => ({ ...currentData, [name]: value }))
|
setFormData((currentData) => ({ ...currentData, [name]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredPatients = (() => {
|
||||||
|
const query = normalizeSearch(patientSearch)
|
||||||
|
if (!query) return patients
|
||||||
|
|
||||||
|
return patients.filter((patient) =>
|
||||||
|
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.document, patient.email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
|
function selectPatient(patient) {
|
||||||
|
const name = getPatientName(patient)
|
||||||
|
setFormData((currentData) => ({
|
||||||
|
...currentData,
|
||||||
|
patientId: patient.id,
|
||||||
|
patient: name,
|
||||||
|
}))
|
||||||
|
setPatientSearch(name)
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit(event) {
|
function handleSubmit(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const submitter = event.nativeEvent.submitter
|
const submitter = event.nativeEvent.submitter
|
||||||
@@ -197,7 +226,7 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
|||||||
date: formData.date ? formatDate(formData.date) : '07/04/2026',
|
date: formData.date ? formatDate(formData.date) : '07/04/2026',
|
||||||
doctor: 'Dr. Henrique Cardoso',
|
doctor: 'Dr. Henrique Cardoso',
|
||||||
type: formData.type,
|
type: formData.type,
|
||||||
cid: formData.cid || 'CID nao informado',
|
cid: formData.cid || 'CID não informado',
|
||||||
status,
|
status,
|
||||||
summary: formData.conduct || formData.anamnesis || 'Registro criado localmente para simulação.',
|
summary: formData.conduct || formData.anamnesis || 'Registro criado localmente para simulação.',
|
||||||
})
|
})
|
||||||
@@ -217,11 +246,36 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
|||||||
<DarkField label="Paciente">
|
<DarkField label="Paciente">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
name="patient"
|
onChange={(event) => {
|
||||||
onChange={updateField}
|
setPatientSearch(event.target.value)
|
||||||
|
setFormData((currentData) => ({ ...currentData, patientId: '', patient: '' }))
|
||||||
|
}}
|
||||||
placeholder="Buscar paciente..."
|
placeholder="Buscar paciente..."
|
||||||
value={formData.patient}
|
type="search"
|
||||||
|
value={patientSearch || formData.patient}
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-2 max-h-44 overflow-y-auto rounded-lg border border-[#404040] bg-[#1a1a1a]">
|
||||||
|
{filteredPatients.length ? (
|
||||||
|
filteredPatients.slice(0, 6).map((patient) => {
|
||||||
|
const selected = String(patient.id) === String(formData.patientId)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`block w-full px-3 py-2 text-left text-sm transition ${
|
||||||
|
selected ? 'bg-[#3b82f6]/20 text-[#e5e5e5]' : 'text-[#a3a3a3] hover:bg-[#2a2a2a] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={patient.id}
|
||||||
|
onClick={() => selectPatient(patient)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="block font-semibold">{getPatientName(patient)}</span>
|
||||||
|
<span className="mt-0.5 block text-xs text-[#737373]">{patient.cpf || patient.document || patient.email || 'Sem documento'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-2 text-xs text-[#737373]">Nenhum paciente encontrado.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField label="Data da Consulta">
|
<DarkField label="Data da Consulta">
|
||||||
<input
|
<input
|
||||||
@@ -348,6 +402,18 @@ function formatDate(value) {
|
|||||||
return `${day}/${month}/${year}`
|
return `${day}/${month}/${year}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPatientName(patient) {
|
||||||
|
return patient?.name || patient?.full_name || patient?.nome || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function RecordIcon({ className = 'size-4', name }) {
|
function RecordIcon({ className = 'size-4', name }) {
|
||||||
const common = {
|
const common = {
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { normalizeRole } from '../config/permissions.js'
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||||
import { communicationRepository } from '../repositories/communicationRepository.js'
|
import { communicationRepository } from '../repositories/communicationRepository.js'
|
||||||
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
|
|
||||||
const channels = {
|
const channels = {
|
||||||
whatsapp: { label: 'WhatsApp', className: 'bg-emerald-500/20 text-emerald-400', icon: 'message' },
|
whatsapp: { label: 'WhatsApp', className: 'bg-emerald-500/20 text-emerald-400', icon: 'message' },
|
||||||
@@ -19,6 +21,7 @@ const statusConfig = {
|
|||||||
|
|
||||||
|
|
||||||
const emptyMessage = {
|
const emptyMessage = {
|
||||||
|
patientId: '',
|
||||||
patient: '',
|
patient: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
@@ -40,21 +43,62 @@ const textareaClass =
|
|||||||
'min-h-28 w-full resize-y rounded-sm border border-[#404040] bg-[#171717] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
'min-h-28 w-full resize-y rounded-sm border border-[#404040] bg-[#171717] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
const labelClass = 'text-xs font-semibold uppercase tracking-[0.08em] text-[#a3a3a3]'
|
const labelClass = 'text-xs font-semibold uppercase tracking-[0.08em] text-[#a3a3a3]'
|
||||||
|
|
||||||
export function MessagesPage() {
|
export function MessagesPage({ role }) {
|
||||||
|
const normalizedRole = normalizeRole(role)
|
||||||
|
const isSecretary = normalizedRole === 'secretaria'
|
||||||
|
const allowedChannelKeys = useMemo(
|
||||||
|
() => (isSecretary ? ['whatsapp', 'sms'] : Object.keys(channels)),
|
||||||
|
[isSecretary],
|
||||||
|
)
|
||||||
const campaigns = communicationRepository.getCampaigns()
|
const campaigns = communicationRepository.getCampaigns()
|
||||||
const [messages, setMessages] = useState(() => communicationRepository.getInitialMessages())
|
const [messages, setMessages] = useState(() => communicationRepository.getInitialMessages())
|
||||||
const [templates, setTemplates] = useState(() => communicationRepository.getInitialTemplates())
|
const [templates, setTemplates] = useState(() => communicationRepository.getInitialTemplates())
|
||||||
|
const [patients, setPatients] = useState([])
|
||||||
const [activeTab, setActiveTab] = useState('historico')
|
const [activeTab, setActiveTab] = useState('historico')
|
||||||
const [channelFilter, setChannelFilter] = useState('todos')
|
const [channelFilter, setChannelFilter] = useState('todos')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [composerOpen, setComposerOpen] = useState(false)
|
const [composerOpen, setComposerOpen] = useState(false)
|
||||||
const [templateEditorOpen, setTemplateEditorOpen] = useState(false)
|
const [templateEditorOpen, setTemplateEditorOpen] = useState(false)
|
||||||
|
const [editingTemplateId, setEditingTemplateId] = useState(null)
|
||||||
const [composer, setComposer] = useState(emptyMessage)
|
const [composer, setComposer] = useState(emptyMessage)
|
||||||
const [templateDraft, setTemplateDraft] = useState(emptyTemplate)
|
const [templateDraft, setTemplateDraft] = useState(emptyTemplate)
|
||||||
|
const availableTemplates = useMemo(
|
||||||
|
() => templates.filter((template) => allowedChannelKeys.includes(template.channel)),
|
||||||
|
[allowedChannelKeys, templates],
|
||||||
|
)
|
||||||
|
const patientOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
patients.map((patient) => ({
|
||||||
|
id: String(patient.detailId || patient.id || ''),
|
||||||
|
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
||||||
|
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
|
||||||
|
document: patient.cpf || patient.document || '',
|
||||||
|
})),
|
||||||
|
[patients],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
patientRepository
|
||||||
|
.getDirectoryRows()
|
||||||
|
.then((data) => {
|
||||||
|
if (active) setPatients(data || [])
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
console.error(loadError)
|
||||||
|
if (active) setPatients([])
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const filteredMessages = useMemo(
|
const filteredMessages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
messages.filter((message) => {
|
messages.filter((message) => {
|
||||||
|
const isAllowedChannel = allowedChannelKeys.includes(message.channel)
|
||||||
const matchesChannel = channelFilter === 'todos' || message.channel === channelFilter
|
const matchesChannel = channelFilter === 'todos' || message.channel === channelFilter
|
||||||
const query = search.trim().toLowerCase()
|
const query = search.trim().toLowerCase()
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
@@ -64,23 +108,26 @@ export function MessagesPage() {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(query)
|
.includes(query)
|
||||||
|
|
||||||
return matchesChannel && matchesSearch
|
return isAllowedChannel && matchesChannel && matchesSearch
|
||||||
}),
|
}),
|
||||||
[channelFilter, messages, search],
|
[allowedChannelKeys, channelFilter, messages, search],
|
||||||
)
|
)
|
||||||
|
|
||||||
const stats = useMemo(
|
const stats = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
total: messages.length,
|
total: messages.filter((message) => allowedChannelKeys.includes(message.channel)).length,
|
||||||
delivered: messages.filter((message) => message.status === 'entregue' || message.status === 'lida').length,
|
delivered: messages.filter((message) => allowedChannelKeys.includes(message.channel) && (message.status === 'entregue' || message.status === 'lida')).length,
|
||||||
read: messages.filter((message) => message.status === 'lida').length,
|
read: messages.filter((message) => allowedChannelKeys.includes(message.channel) && message.status === 'lida').length,
|
||||||
failed: messages.filter((message) => message.status === 'falha').length,
|
failed: messages.filter((message) => allowedChannelKeys.includes(message.channel) && message.status === 'falha').length,
|
||||||
}),
|
}),
|
||||||
[messages],
|
[allowedChannelKeys, messages],
|
||||||
)
|
)
|
||||||
|
|
||||||
function openTemplate(template) {
|
function openTemplate(template) {
|
||||||
|
if (!allowedChannelKeys.includes(template.channel)) return
|
||||||
|
|
||||||
setComposer({
|
setComposer({
|
||||||
|
patientId: '',
|
||||||
patient: '',
|
patient: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
channel: template.channel,
|
channel: template.channel,
|
||||||
@@ -90,6 +137,26 @@ export function MessagesPage() {
|
|||||||
setComposerOpen(true)
|
setComposerOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTemplateEditor(template = null) {
|
||||||
|
if (template && !allowedChannelKeys.includes(template.channel)) return
|
||||||
|
|
||||||
|
setEditingTemplateId(template?.id || null)
|
||||||
|
setTemplateDraft(
|
||||||
|
template
|
||||||
|
? {
|
||||||
|
name: template.name || '',
|
||||||
|
channel: template.channel || allowedChannelKeys[0],
|
||||||
|
category: template.category || 'Personalizado',
|
||||||
|
content: template.content || '',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...emptyTemplate,
|
||||||
|
channel: allowedChannelKeys[0] || 'whatsapp',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
setTemplateEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
async function submitMessage(event) {
|
async function submitMessage(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -97,6 +164,11 @@ export function MessagesPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!allowedChannelKeys.includes(composer.channel)) {
|
||||||
|
alert('Canal indisponivel para o seu perfil.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let smsSent = false
|
let smsSent = false
|
||||||
|
|
||||||
if (composer.channel === 'sms') {
|
if (composer.channel === 'sms') {
|
||||||
@@ -141,16 +213,20 @@ export function MessagesPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTemplates((current) => [
|
const nextTemplate = {
|
||||||
{
|
id: editingTemplateId || `template-${Date.now()}`,
|
||||||
id: `template-${Date.now()}`,
|
name: templateDraft.name.trim(),
|
||||||
name: templateDraft.name.trim(),
|
channel: templateDraft.channel,
|
||||||
channel: templateDraft.channel,
|
content: templateDraft.content.trim(),
|
||||||
content: templateDraft.content.trim(),
|
category: templateDraft.category.trim() || 'Personalizado',
|
||||||
category: templateDraft.category.trim() || 'Personalizado',
|
}
|
||||||
},
|
|
||||||
...current,
|
setTemplates((current) =>
|
||||||
])
|
editingTemplateId
|
||||||
|
? current.map((template) => (template.id === editingTemplateId ? nextTemplate : template))
|
||||||
|
: [nextTemplate, ...current],
|
||||||
|
)
|
||||||
|
setEditingTemplateId(null)
|
||||||
setTemplateDraft(emptyTemplate)
|
setTemplateDraft(emptyTemplate)
|
||||||
setTemplateEditorOpen(false)
|
setTemplateEditorOpen(false)
|
||||||
}
|
}
|
||||||
@@ -158,26 +234,28 @@ export function MessagesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<FeatureCallout
|
<FeatureCallout
|
||||||
description="Envio de SMS usa API. Histórico, templates e campanhas ainda são dados locais de demonstração."
|
description={isSecretary ? 'Perfil Secretária limitado a comunicação básica por WhatsApp e SMS.' : 'Envio de SMS usa API. Histórico, templates e campanhas ainda são dados locais de demonstração.'}
|
||||||
status="partial"
|
status="partial"
|
||||||
title="Mensageria híbrida"
|
title={isSecretary ? 'Comunicação basica' : 'Mensageria hibrida'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
|
||||||
<p className="mt-1 text-sm text-[#b8b8b8]">WhatsApp, E-mail e SMS - histórico e campanhas</p>
|
<p className="mt-1 text-sm text-[#b8b8b8]">{isSecretary ? 'WhatsApp e SMS para contato operacional com pacientes' : 'WhatsApp, E-mail e SMS - historico e campanhas'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
{!isSecretary ? (
|
||||||
className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
<button
|
||||||
onClick={() => setActiveTab('campanha')}
|
className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
type="button"
|
onClick={() => setActiveTab('campanha')}
|
||||||
>
|
type="button"
|
||||||
<CommIcon className="size-4" name="send" />
|
>
|
||||||
Envio em Massa
|
<CommIcon className="size-4" name="send" />
|
||||||
</button>
|
Envio em Massa
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-12 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
className="inline-flex h-12 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
onClick={() => setComposerOpen(true)}
|
onClick={() => setComposerOpen(true)}
|
||||||
@@ -199,8 +277,7 @@ export function MessagesPage() {
|
|||||||
<div className="flex gap-4 border-b border-[#404040]">
|
<div className="flex gap-4 border-b border-[#404040]">
|
||||||
{[
|
{[
|
||||||
['historico', 'Histórico'],
|
['historico', 'Histórico'],
|
||||||
['templates', 'Templates'],
|
...(!isSecretary ? [['templates', 'Templates'], ['campanha', 'Campanhas']] : []),
|
||||||
['campanha', 'Campanhas'],
|
|
||||||
].map(([key, label]) => (
|
].map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
className={`border-b-2 px-2 pb-3 text-sm font-semibold transition ${
|
className={`border-b-2 px-2 pb-3 text-sm font-semibold transition ${
|
||||||
@@ -238,9 +315,7 @@ export function MessagesPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{[
|
||||||
['todos', 'Todos'],
|
['todos', 'Todos'],
|
||||||
['whatsapp', 'Whatsapp'],
|
...allowedChannelKeys.map((key) => [key, channels[key].label]),
|
||||||
['email', 'E-mail'],
|
|
||||||
['sms', 'Sms'],
|
|
||||||
].map(([key, label]) => (
|
].map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
className={`h-12 rounded-sm border px-4 text-xs font-semibold transition ${
|
className={`h-12 rounded-sm border px-4 text-xs font-semibold transition ${
|
||||||
@@ -291,7 +366,7 @@ export function MessagesPage() {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-10 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
className="inline-flex h-10 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
onClick={() => setTemplateEditorOpen(true)}
|
onClick={() => openTemplateEditor()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<CommIcon className="size-4" name="plus" />
|
<CommIcon className="size-4" name="plus" />
|
||||||
@@ -300,8 +375,8 @@ export function MessagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{templates.map((template) => (
|
{availableTemplates.map((template) => (
|
||||||
<TemplateCard key={template.id} onUse={openTemplate} template={template} />
|
<TemplateCard key={template.id} onEdit={openTemplateEditor} onUse={openTemplate} template={template} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -329,6 +404,7 @@ export function MessagesPage() {
|
|||||||
className="mt-3 h-8 w-full rounded-sm bg-[#3b82f6] text-xs font-semibold text-white transition hover:bg-[#2563eb]"
|
className="mt-3 h-8 w-full rounded-sm bg-[#3b82f6] text-xs font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setComposer({
|
setComposer({
|
||||||
|
patientId: '',
|
||||||
patient: campaign.count,
|
patient: campaign.count,
|
||||||
phone: '',
|
phone: '',
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
@@ -363,6 +439,7 @@ export function MessagesPage() {
|
|||||||
|
|
||||||
{composerOpen ? (
|
{composerOpen ? (
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
|
allowedChannelKeys={allowedChannelKeys}
|
||||||
draft={composer}
|
draft={composer}
|
||||||
onChange={setComposer}
|
onChange={setComposer}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -370,19 +447,23 @@ export function MessagesPage() {
|
|||||||
setComposer(emptyMessage)
|
setComposer(emptyMessage)
|
||||||
}}
|
}}
|
||||||
onSubmit={submitMessage}
|
onSubmit={submitMessage}
|
||||||
templates={templates}
|
patients={patientOptions}
|
||||||
|
templates={availableTemplates}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{templateEditorOpen ? (
|
{templateEditorOpen ? (
|
||||||
<TemplateEditor
|
<TemplateEditor
|
||||||
|
allowedChannelKeys={allowedChannelKeys}
|
||||||
draft={templateDraft}
|
draft={templateDraft}
|
||||||
onChange={setTemplateDraft}
|
onChange={setTemplateDraft}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setTemplateEditorOpen(false)
|
setTemplateEditorOpen(false)
|
||||||
setTemplateDraft(emptyTemplate)
|
setTemplateDraft(emptyTemplate)
|
||||||
|
setEditingTemplateId(null)
|
||||||
}}
|
}}
|
||||||
onSubmit={submitTemplate}
|
onSubmit={submitTemplate}
|
||||||
|
title={editingTemplateId ? 'Editar Template' : 'Novo Template'}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -424,7 +505,7 @@ function MessageRow({ message }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateCard({ onUse, template }) {
|
function TemplateCard({ onEdit, onUse, template }) {
|
||||||
const channel = channels[template.channel]
|
const channel = channels[template.channel]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -443,6 +524,7 @@ function TemplateCard({ onUse, template }) {
|
|||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="h-9 flex-1 rounded-sm border border-[#404040] bg-[#171717] text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
className="h-9 flex-1 rounded-sm border border-[#404040] bg-[#171717] text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => onEdit(template)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Editar
|
Editar
|
||||||
@@ -459,11 +541,36 @@ function TemplateCard({ onUse, template }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmit, patients, templates }) {
|
||||||
|
const [patientSearch, setPatientSearch] = useState(draft.patient || '')
|
||||||
|
const filteredPatients = useMemo(() => {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}, [patientSearch, patients])
|
||||||
|
|
||||||
function update(field, value) {
|
function update(field, value) {
|
||||||
onChange((current) => ({ ...current, [field]: value }))
|
onChange((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectPatient(patient) {
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
patientId: patient?.id || '',
|
||||||
|
patient: patient?.name || '',
|
||||||
|
phone: patient?.phone || current.phone,
|
||||||
|
}))
|
||||||
|
setPatientSearch(patient?.name || '')
|
||||||
|
}
|
||||||
|
|
||||||
function applyTemplate(templateName) {
|
function applyTemplate(templateName) {
|
||||||
const template = templates.find((item) => item.name === templateName)
|
const template = templates.find((item) => item.name === templateName)
|
||||||
|
|
||||||
@@ -483,20 +590,59 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
|||||||
return (
|
return (
|
||||||
<ModalFrame onClose={onClose} title="Nova Mensagem">
|
<ModalFrame onClose={onClose} title="Nova Mensagem">
|
||||||
<form className="space-y-4" onSubmit={onSubmit}>
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
|
<DarkField label="Paciente">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPatientSearch(event.target.value)
|
||||||
|
onChange((current) => ({ ...current, patientId: '', patient: '' }))
|
||||||
|
}}
|
||||||
|
placeholder="Digite nome, CPF ou telefone"
|
||||||
|
type="search"
|
||||||
|
value={patientSearch}
|
||||||
|
/>
|
||||||
|
<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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<DarkField label="Paciente">
|
<DarkField label="Paciente selecionado">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
onChange={(event) => update('patient', event.target.value)}
|
onChange={(event) => update('patient', event.target.value)}
|
||||||
placeholder="Nome do paciente"
|
readOnly
|
||||||
value={draft.patient}
|
value={draft.patient}
|
||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField label="Canal">
|
<DarkField label="Canal">
|
||||||
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
||||||
<option value="whatsapp">WhatsApp</option>
|
{allowedChannelKeys.map((key) => (
|
||||||
<option value="email">E-mail</option>
|
<option key={key} value={key}>{channels[key].label}</option>
|
||||||
<option value="sms">SMS</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
@@ -527,7 +673,7 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
|||||||
<textarea
|
<textarea
|
||||||
className={textareaClass}
|
className={textareaClass}
|
||||||
onChange={(event) => update('content', event.target.value)}
|
onChange={(event) => update('content', event.target.value)}
|
||||||
placeholder="Escreva a mensagem mockada..."
|
placeholder="Escreva a mensagem"
|
||||||
value={draft.content}
|
value={draft.content}
|
||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
@@ -549,13 +695,13 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateEditor({ draft, onChange, onClose, onSubmit }) {
|
function TemplateEditor({ allowedChannelKeys, draft, onChange, onClose, onSubmit, title }) {
|
||||||
function update(field, value) {
|
function update(field, value) {
|
||||||
onChange((current) => ({ ...current, [field]: value }))
|
onChange((current) => ({ ...current, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalFrame onClose={onClose} title="Novo Template">
|
<ModalFrame onClose={onClose} title={title}>
|
||||||
<form className="space-y-4" onSubmit={onSubmit}>
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<DarkField label="Nome">
|
<DarkField label="Nome">
|
||||||
@@ -563,9 +709,9 @@ function TemplateEditor({ draft, onChange, onClose, onSubmit }) {
|
|||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField label="Canal">
|
<DarkField label="Canal">
|
||||||
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
||||||
<option value="whatsapp">WhatsApp</option>
|
{allowedChannelKeys.map((key) => (
|
||||||
<option value="email">E-mail</option>
|
<option key={key} value={key}>{channels[key].label}</option>
|
||||||
<option value="sms">SMS</option>
|
))}
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
@@ -617,6 +763,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 }) {
|
function CommIcon({ className = 'size-4', name }) {
|
||||||
const common = {
|
const common = {
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ export function NotFoundPage({ navigate }) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
description="A rota acessada nao faz parte do shell navegavel deste prototipo."
|
description="A rota acessada não faz parte do shell navegável deste protótipo."
|
||||||
eyebrow="404"
|
eyebrow="404"
|
||||||
title="Tela nao encontrada"
|
title="Tela não encontrada"
|
||||||
/>
|
/>
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<p className="max-w-2xl text-sm leading-6 text-slate-600">
|
<p className="max-w-2xl text-sm leading-6 text-slate-600">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,16 @@
|
|||||||
import { useRef, useState, useEffect } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||||
import { profileRepository } from '../repositories/profileRepository.js'
|
import { normalizeRole } from '../config/permissions.js'
|
||||||
import { authRepository } from '../repositories/authRepository.js'
|
import { authRepository } from '../repositories/authRepository.js'
|
||||||
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
const readOnlyInputClass =
|
||||||
|
'h-10 rounded-sm border border-[#404040] bg-[#1f1f1f] px-3 text-sm text-[#a3a3a3] outline-none'
|
||||||
|
|
||||||
export function ProfilePage({ navigate }) {
|
export function ProfilePage({ navigate }) {
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
@@ -18,10 +21,13 @@ export function ProfilePage({ navigate }) {
|
|||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
profileRepository.getCurrentUserProfile().then(data => {
|
profileRepository
|
||||||
setProfile(data)
|
.getCurrentUserProfile()
|
||||||
setLoading(false)
|
.then((data) => {
|
||||||
}).catch(() => setLoading(false))
|
setProfile(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function update(field, value) {
|
function update(field, value) {
|
||||||
@@ -56,31 +62,33 @@ export function ProfilePage({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6">
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
<FeatureCallout
|
{canEditProfile ? (
|
||||||
description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente."
|
<FeatureCallout
|
||||||
status="partial"
|
description="Carregar perfil, avatar e logout usam integração. O botão de salvar preferências desta tela ainda grava só localmente."
|
||||||
title="Perfil com persistência parcial"
|
status="partial"
|
||||||
/>
|
title="Perfil com persistência parcial"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1>
|
||||||
<p className="mt-1 text-sm text-[#b8b8b8]">Dados 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>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
<section className={`${cardClass} ${featurePanelClass('partial')} p-6`}>
|
<section className={`${cardClass} ${featurePanelClass(canEditProfile ? 'partial' : 'live')} p-6`}>
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
{profile.avatarUrl ? (
|
{profile.avatarUrl ? (
|
||||||
<img
|
<img alt="" className="size-16 rounded-full border border-[#3b82f6]/30 object-cover" src={profile.avatarUrl} />
|
||||||
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]">
|
<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)}
|
{initials(profile.name)}
|
||||||
@@ -89,21 +97,25 @@ export function ProfilePage({ navigate }) {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
|
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
|
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
|
||||||
<button
|
{canEditProfile ? (
|
||||||
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
|
<>
|
||||||
disabled={uploadingAvatar}
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
className="mt-1 text-xs font-semibold text-[#3b82f6] disabled:opacity-60"
|
||||||
type="button"
|
disabled={uploadingAvatar}
|
||||||
>
|
onClick={() => fileInputRef.current?.click()}
|
||||||
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
type="button"
|
||||||
</button>
|
>
|
||||||
<input
|
{uploadingAvatar ? 'Enviando...' : 'Alterar foto'}
|
||||||
accept="image/*"
|
</button>
|
||||||
className="hidden"
|
<input
|
||||||
onChange={handleAvatarChange}
|
accept="image/*"
|
||||||
ref={fileInputRef}
|
className="hidden"
|
||||||
type="file"
|
onChange={handleAvatarChange}
|
||||||
/>
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
|
{avatarError ? <p className="mt-1 text-xs font-semibold text-red-400">{avatarError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,38 +124,44 @@ export function ProfilePage({ navigate }) {
|
|||||||
className="grid gap-4"
|
className="grid gap-4"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSaved(true)
|
if (canEditProfile) setSaved(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Nome">
|
<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>
|
||||||
<Field label="Cargo">
|
<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>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="E-mail">
|
<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>
|
||||||
<Field label="Telefone">
|
<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>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Unidade padrão">
|
<Field label="Unidade padrão">
|
||||||
<select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}>
|
{canEditProfile ? (
|
||||||
<option>Clínica Boa Vista</option>
|
<select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}>
|
||||||
<option>Unidade Centro</option>
|
<option>Clínica Boa Vista</option>
|
||||||
<option>Unidade Sul</option>
|
<option>Unidade Centro</option>
|
||||||
</select>
|
<option>Unidade Sul</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input className={readOnlyInputClass} readOnly value={profile.unit} />
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
{canEditProfile ? (
|
||||||
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
Salvar alterações
|
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
|
||||||
</button>
|
Salvar alterações
|
||||||
{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}
|
</button>
|
||||||
</div>
|
{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>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -155,9 +173,10 @@ export function ProfilePage({ navigate }) {
|
|||||||
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
||||||
</dl>
|
</dl>
|
||||||
<div className="mt-8 border-t border-[#404040] pt-6">
|
<div className="mt-8 border-t border-[#404040] pt-6">
|
||||||
<button
|
<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"
|
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}
|
onClick={handleLogout}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
Sair da conta
|
Sair da conta
|
||||||
</button>
|
</button>
|
||||||
@@ -181,7 +200,7 @@ function Info({ label, value }) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
||||||
<dt className="font-semibold text-[#a3a3a3]">{label}</dt>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { normalizeRole } from '../config/permissions.js'
|
||||||
import { patientRepository } from '../repositories/patientRepository.js'
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
import { reportRepository } from '../repositories/reportRepository.js'
|
import { reportRepository } from '../repositories/reportRepository.js'
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 25
|
const ITEMS_PER_PAGE = 25
|
||||||
@@ -12,11 +14,16 @@ const statusConfig = {
|
|||||||
pill: 'bg-amber-500/20 text-amber-400',
|
pill: 'bg-amber-500/20 text-amber-400',
|
||||||
stat: 'text-amber-400',
|
stat: 'text-amber-400',
|
||||||
},
|
},
|
||||||
|
finalized: {
|
||||||
|
label: 'Finalizado',
|
||||||
|
pill: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
stat: 'text-emerald-400',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderOptions = [
|
const orderOptions = [
|
||||||
{ label: 'Criacao mais recente', value: 'created_at.desc' },
|
{ label: 'Criação mais recente', value: 'created_at.desc' },
|
||||||
{ label: 'Criacao mais antiga', value: 'created_at.asc' },
|
{ label: 'Criação mais antiga', value: 'created_at.asc' },
|
||||||
{ label: 'Prazo mais proximo', value: 'due_at.asc' },
|
{ label: 'Prazo mais proximo', value: 'due_at.asc' },
|
||||||
{ label: 'Prazo mais distante', value: 'due_at.desc' },
|
{ label: 'Prazo mais distante', value: 'due_at.desc' },
|
||||||
]
|
]
|
||||||
@@ -30,6 +37,7 @@ const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
|||||||
|
|
||||||
const emptyEditor = {
|
const emptyEditor = {
|
||||||
id: null,
|
id: null,
|
||||||
|
orderNumber: '',
|
||||||
patientId: '',
|
patientId: '',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
exam: '',
|
exam: '',
|
||||||
@@ -39,18 +47,20 @@ const emptyEditor = {
|
|||||||
conclusion: '',
|
conclusion: '',
|
||||||
contentHtml: '',
|
contentHtml: '',
|
||||||
contentJson: undefined,
|
contentJson: undefined,
|
||||||
hideDate: false,
|
|
||||||
hideSignature: false,
|
|
||||||
dueAt: '',
|
dueAt: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportsPage() {
|
export function ReportsPage({ role }) {
|
||||||
const [reports, setReports] = useState([])
|
const [reports, setReports] = useState([])
|
||||||
const [patients, setPatients] = useState([])
|
const [patients, setPatients] = useState([])
|
||||||
const [professionals, setProfessionals] = useState([])
|
const [professionals, setProfessionals] = useState([])
|
||||||
|
const [viewerProfile, setViewerProfile] = useState(null)
|
||||||
|
const [currentProfessional, setCurrentProfessional] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [scopeLoading, setScopeLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const isDoctorRole = normalizeRole(role) === 'medico'
|
||||||
|
|
||||||
const [filterPatientId, setFilterPatientId] = useState('')
|
const [filterPatientId, setFilterPatientId] = useState('')
|
||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
@@ -80,7 +90,7 @@ export function ReportsPage() {
|
|||||||
return {
|
return {
|
||||||
id: String(professional.id || ''),
|
id: String(professional.id || ''),
|
||||||
createdByValue,
|
createdByValue,
|
||||||
name: professional.name || 'Medico(a)',
|
name: professional.name || 'Médico(a)',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((professional) => {
|
.filter((professional) => {
|
||||||
@@ -107,7 +117,7 @@ export function ReportsPage() {
|
|||||||
() =>
|
() =>
|
||||||
reports.map((report) => ({
|
reports.map((report) => ({
|
||||||
...report,
|
...report,
|
||||||
patientName: patientNameById[String(report.patientId || '')] || 'Paciente nao encontrado',
|
patientName: patientNameById[String(report.patientId || '')] || 'Paciente não encontrado',
|
||||||
createdByName: professionalNameByCreatedBy[String(report.createdBy || '')] || report.createdBy || 'Sistema',
|
createdByName: professionalNameByCreatedBy[String(report.createdBy || '')] || report.createdBy || 'Sistema',
|
||||||
})),
|
})),
|
||||||
[patientNameById, professionalNameByCreatedBy, reports],
|
[patientNameById, professionalNameByCreatedBy, reports],
|
||||||
@@ -121,6 +131,11 @@ export function ReportsPage() {
|
|||||||
value: enrichedReports.filter((report) => report.status === 'draft').length,
|
value: enrichedReports.filter((report) => report.status === 'draft').length,
|
||||||
className: statusConfig.draft.stat,
|
className: statusConfig.draft.stat,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Finalizados',
|
||||||
|
value: enrichedReports.filter((report) => report.status === 'finalized').length,
|
||||||
|
className: statusConfig.finalized.stat,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[enrichedReports],
|
[enrichedReports],
|
||||||
)
|
)
|
||||||
@@ -131,14 +146,30 @@ export function ReportsPage() {
|
|||||||
const paginatedReports = enrichedReports.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
const paginatedReports = enrichedReports.slice(startIndex, startIndex + ITEMS_PER_PAGE)
|
||||||
|
|
||||||
const loadReports = useCallback(async () => {
|
const loadReports = useCallback(async () => {
|
||||||
|
if (scopeLoading) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const doctorPatientIds = isDoctorRole
|
||||||
|
? patientOptions.map((patient) => patient.id).filter(Boolean)
|
||||||
|
: []
|
||||||
|
const createdByValues = isDoctorRole
|
||||||
|
? uniqueValues([
|
||||||
|
viewerProfile?.id,
|
||||||
|
viewerProfile?.doctorId,
|
||||||
|
currentProfessional?.userId,
|
||||||
|
currentProfessional?.id,
|
||||||
|
])
|
||||||
|
: []
|
||||||
|
|
||||||
const data = await reportRepository.getInitialReports({
|
const data = await reportRepository.getInitialReports({
|
||||||
patientId: filterPatientId || undefined,
|
patientId: filterPatientId || undefined,
|
||||||
|
patientIds: !filterPatientId && doctorPatientIds.length ? doctorPatientIds : undefined,
|
||||||
status: filterStatus || undefined,
|
status: filterStatus || undefined,
|
||||||
createdBy: filterCreatedBy || undefined,
|
createdBy: !isDoctorRole ? filterCreatedBy || undefined : undefined,
|
||||||
|
createdByValues: isDoctorRole && !doctorPatientIds.length ? createdByValues : undefined,
|
||||||
order: filterOrder,
|
order: filterOrder,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -146,36 +177,54 @@ export function ReportsPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
console.error(loadError)
|
console.error(loadError)
|
||||||
setError(loadError.message || 'Erro ao carregar relatorios medicos.')
|
setError(loadError.message || 'Erro ao carregar relatórios.')
|
||||||
setReports([])
|
setReports([])
|
||||||
setPage(1)
|
setPage(1)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [filterCreatedBy, filterOrder, filterPatientId, filterStatus])
|
}, [currentProfessional, filterCreatedBy, filterOrder, filterPatientId, filterStatus, isDoctorRole, patientOptions, scopeLoading, viewerProfile])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
Promise.all([
|
async function loadAuxiliaryData() {
|
||||||
patientRepository.getAll(),
|
setScopeLoading(true)
|
||||||
professionalRepository.getAll(),
|
|
||||||
])
|
try {
|
||||||
.then(([patientData, professionalData]) => {
|
const [professionalData, currentProfile] = await Promise.all([
|
||||||
|
professionalRepository.getAll(),
|
||||||
|
profileRepository.getCurrentUserProfile(),
|
||||||
|
])
|
||||||
|
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
||||||
|
const resolvedProfessional = professionalRepository.resolveCurrentProfessional(currentProfile, professionalData || [])
|
||||||
|
const patientData = isDoctorRole && resolvedProfessional?.id
|
||||||
|
? await patientRepository.getDirectoryRows({ doctorId: resolvedProfessional.id })
|
||||||
|
: await patientRepository.getAll()
|
||||||
|
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
setViewerProfile(currentProfile)
|
||||||
|
setCurrentProfessional(resolvedProfessional)
|
||||||
setPatients(patientData || [])
|
setPatients(patientData || [])
|
||||||
setProfessionals(professionalData || [])
|
setProfessionals(professionalData || [])
|
||||||
})
|
} catch (loadError) {
|
||||||
.catch((loadError) => {
|
|
||||||
if (!active) return
|
if (!active) return
|
||||||
console.error(loadError)
|
console.error(loadError)
|
||||||
setError(loadError.message || 'Erro ao carregar dados auxiliares.')
|
setError(loadError.message || 'Erro ao carregar dados auxiliares.')
|
||||||
})
|
} finally {
|
||||||
|
if (active) setScopeLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAuxiliaryData()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isDoctorRole])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadReports()
|
loadReports()
|
||||||
@@ -192,6 +241,7 @@ export function ReportsPage() {
|
|||||||
function openEdit(report) {
|
function openEdit(report) {
|
||||||
setEditor({
|
setEditor({
|
||||||
id: report.id,
|
id: report.id,
|
||||||
|
orderNumber: report.orderNumber,
|
||||||
patientId: String(report.patientId || ''),
|
patientId: String(report.patientId || ''),
|
||||||
status: report.status,
|
status: report.status,
|
||||||
exam: report.exam,
|
exam: report.exam,
|
||||||
@@ -201,19 +251,21 @@ export function ReportsPage() {
|
|||||||
conclusion: report.conclusion,
|
conclusion: report.conclusion,
|
||||||
contentHtml: report.contentHtml,
|
contentHtml: report.contentHtml,
|
||||||
contentJson: report.contentJson,
|
contentJson: report.contentJson,
|
||||||
hideDate: report.hideDate,
|
|
||||||
hideSignature: report.hideSignature,
|
|
||||||
dueAt: toDateTimeLocal(report.dueAt),
|
dueAt: toDateTimeLocal(report.dueAt),
|
||||||
})
|
})
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
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)
|
setSaving(true)
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
orderNumber: editor.id ? editor.orderNumber : `REL-${Date.now()}`,
|
||||||
patientId: editor.patientId,
|
patientId: editor.patientId,
|
||||||
status: editor.status,
|
status: editor.status,
|
||||||
exam: editor.exam,
|
exam: editor.exam,
|
||||||
@@ -223,9 +275,9 @@ export function ReportsPage() {
|
|||||||
conclusion: editor.conclusion,
|
conclusion: editor.conclusion,
|
||||||
contentHtml: editor.contentHtml,
|
contentHtml: editor.contentHtml,
|
||||||
contentJson: editor.contentJson,
|
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() : '',
|
||||||
|
createdBy: editor.id ? undefined : viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||||
|
updatedBy: viewerProfile?.id || currentProfessional?.userId || currentProfessional?.id || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -238,7 +290,7 @@ export function ReportsPage() {
|
|||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
await loadReports()
|
await loadReports()
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
alert(saveError.message || 'Erro ao salvar relatorio medico.')
|
alert(saveError.message || 'Erro ao salvar relatório.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -248,8 +300,8 @@ export function ReportsPage() {
|
|||||||
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
||||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Relatorios medicos</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Relatórios</h1>
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">Consulta, criacao e edicao de relatorios medicos.</p>
|
<p className="mt-1 text-sm text-[#a3a3a3]">Consulta, criação e edição de relatórios.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white transition hover:bg-[#2563eb]"
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white transition hover:bg-[#2563eb]"
|
||||||
@@ -257,7 +309,7 @@ export function ReportsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ReportIcon name="plus" />
|
<ReportIcon name="plus" />
|
||||||
Novo relatorio
|
Novo relatório
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -303,6 +355,7 @@ export function ReportsPage() {
|
|||||||
>
|
>
|
||||||
<option value="">Todos os status</option>
|
<option value="">Todos os status</option>
|
||||||
<option value="draft">Rascunho</option>
|
<option value="draft">Rascunho</option>
|
||||||
|
<option value="finalized">Finalizado</option>
|
||||||
</select>
|
</select>
|
||||||
</FilterField>
|
</FilterField>
|
||||||
|
|
||||||
@@ -324,7 +377,7 @@ export function ReportsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</FilterField>
|
</FilterField>
|
||||||
|
|
||||||
<FilterField label="Ordenacao">
|
<FilterField label="Ordenação">
|
||||||
<select
|
<select
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@@ -358,14 +411,14 @@ export function ReportsPage() {
|
|||||||
<th className="w-[18%] px-4 py-3">Solicitante</th>
|
<th className="w-[18%] px-4 py-3">Solicitante</th>
|
||||||
<th className="w-[14%] px-4 py-3">Criado em</th>
|
<th className="w-[14%] px-4 py-3">Criado em</th>
|
||||||
<th className="w-[10%] px-4 py-3">Status</th>
|
<th className="w-[10%] px-4 py-3">Status</th>
|
||||||
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-4 py-3 text-right">Acoes</th>
|
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-4 py-3 text-right">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
||||||
Carregando relatorios medicos...
|
Carregando relatórios...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : paginatedReports.length ? (
|
) : paginatedReports.length ? (
|
||||||
@@ -380,7 +433,7 @@ export function ReportsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
||||||
Nenhum relatorio encontrado com os filtros atuais.
|
Nenhum relatório encontrado com os filtros atuais.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -391,7 +444,7 @@ export function ReportsPage() {
|
|||||||
<div className="mt-4 flex flex-col gap-4 border-t border-[#404040] pt-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mt-4 flex flex-col gap-4 border-t border-[#404040] pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-xs text-[#a3a3a3]">
|
<p className="text-xs text-[#a3a3a3]">
|
||||||
Mostrando {enrichedReports.length ? startIndex + 1 : 0}-{Math.min(startIndex + ITEMS_PER_PAGE, enrichedReports.length)} de{' '}
|
Mostrando {enrichedReports.length ? startIndex + 1 : 0}-{Math.min(startIndex + ITEMS_PER_PAGE, enrichedReports.length)} de{' '}
|
||||||
{enrichedReports.length} relatorios
|
{enrichedReports.length} relatórios
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PageButton disabled={currentPage === 1} onClick={() => setPage(currentPage - 1)}>
|
<PageButton disabled={currentPage === 1} onClick={() => setPage(currentPage - 1)}>
|
||||||
@@ -438,6 +491,8 @@ export function ReportsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReportRow({ onEdit, onView, report }) {
|
function ReportRow({ onEdit, onView, report }) {
|
||||||
|
const currentStatus = statusConfig[report.status] || statusConfig.draft
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="transition hover:bg-[#303030]">
|
<tr className="transition hover:bg-[#303030]">
|
||||||
<td className="px-4 py-3 align-top text-[#a3a3a3]">{report.orderNumber || '-'}</td>
|
<td className="px-4 py-3 align-top text-[#a3a3a3]">{report.orderNumber || '-'}</td>
|
||||||
@@ -451,8 +506,8 @@ function ReportRow({ onEdit, onView, report }) {
|
|||||||
<td className="px-4 py-3 align-top whitespace-normal break-words text-[#a3a3a3]">{report.requestedBy || '-'}</td>
|
<td className="px-4 py-3 align-top whitespace-normal break-words text-[#a3a3a3]">{report.requestedBy || '-'}</td>
|
||||||
<td className="px-4 py-3 align-top text-[#a3a3a3]">{formatDate(report.createdAt)}</td>
|
<td className="px-4 py-3 align-top text-[#a3a3a3]">{formatDate(report.createdAt)}</td>
|
||||||
<td className="px-4 py-3 align-top">
|
<td className="px-4 py-3 align-top">
|
||||||
<span className={`rounded px-2 py-1 text-[10px] font-bold ${statusConfig[report.status].pill}`}>
|
<span className={`rounded px-2 py-1 text-[10px] font-bold ${currentStatus.pill}`}>
|
||||||
{statusConfig[report.status].label}
|
{currentStatus.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="sticky right-0 bg-[#262626] px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
<td className="sticky right-0 bg-[#262626] px-4 py-3 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
||||||
@@ -466,7 +521,11 @@ function ReportRow({ onEdit, onView, report }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
||||||
const isValid = Boolean(editor.patientId)
|
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
||||||
|
const isValid = isReportEditorValid(editor)
|
||||||
|
const filteredRequesterOptions = professionalOptions
|
||||||
|
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
function updateField(field, value) {
|
function updateField(field, value) {
|
||||||
onChange((current) => ({ ...current, [field]: value }))
|
onChange((current) => ({ ...current, [field]: value }))
|
||||||
@@ -480,7 +539,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||||
<h2 className="text-lg font-bold text-[#e5e5e5]">
|
<h2 className="text-lg font-bold text-[#e5e5e5]">
|
||||||
{editor.id ? 'Editar relatorio medico' : 'Novo relatorio medico'}
|
{editor.id ? 'Editar relatório' : 'Novo relatório'}
|
||||||
</h2>
|
</h2>
|
||||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||||
@@ -501,15 +560,16 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Status">
|
<DarkField label="Status *">
|
||||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
||||||
<option value="draft">Rascunho</option>
|
<option value="draft">Rascunho</option>
|
||||||
|
<option value="finalized">Finalizado</option>
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<DarkField label="Exame">
|
<DarkField label="Exame *">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
onChange={(event) => updateField('exam', event.target.value)}
|
onChange={(event) => updateField('exam', event.target.value)}
|
||||||
@@ -518,26 +578,46 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Solicitante">
|
<DarkField label="Solicitante *">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
list="report-requested-by-suggestions"
|
onChange={(event) => {
|
||||||
onChange={(event) => updateField('requestedBy', event.target.value)}
|
setRequesterSearch(event.target.value)
|
||||||
placeholder="Nome do solicitante"
|
updateField('requestedBy', event.target.value)
|
||||||
value={editor.requestedBy}
|
}}
|
||||||
|
placeholder="Pesquisar médico"
|
||||||
|
type="search"
|
||||||
|
value={requesterSearch}
|
||||||
/>
|
/>
|
||||||
<datalist id="report-requested-by-suggestions">
|
<div className="max-h-36 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
||||||
{professionalOptions.map((professional) => (
|
{filteredRequesterOptions.length ? (
|
||||||
<option key={professional.id} value={professional.name} />
|
filteredRequesterOptions.map((professional) => (
|
||||||
))}
|
<button
|
||||||
</datalist>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<DarkField label="CID-10">
|
<DarkField label="CID-10 *">
|
||||||
<input
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
onChange={(event) => updateField('cidCode', event.target.value)}
|
onChange={(event) => updateField('cidCode', event.target.value)}
|
||||||
@@ -546,7 +626,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Prazo">
|
<DarkField label="Prazo *">
|
||||||
<input
|
<input
|
||||||
className={`${inputClass} [color-scheme:dark]`}
|
className={`${inputClass} [color-scheme:dark]`}
|
||||||
onChange={(event) => updateField('dueAt', event.target.value)}
|
onChange={(event) => updateField('dueAt', event.target.value)}
|
||||||
@@ -556,54 +636,31 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DarkField label="Diagnostico">
|
<DarkField label="Diagnóstico *">
|
||||||
<textarea
|
<textarea
|
||||||
className={textareaClass}
|
className={textareaClass}
|
||||||
onChange={(event) => updateField('diagnosis', event.target.value)}
|
onChange={(event) => updateField('diagnosis', event.target.value)}
|
||||||
placeholder="Diagnostico do relatorio"
|
placeholder="Diagnóstico do relatório"
|
||||||
value={editor.diagnosis}
|
value={editor.diagnosis}
|
||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Conclusao">
|
<DarkField label="Conclusão *">
|
||||||
<textarea
|
<textarea
|
||||||
className={textareaClass}
|
className={textareaClass}
|
||||||
onChange={(event) => updateField('conclusion', event.target.value)}
|
onChange={(event) => updateField('conclusion', event.target.value)}
|
||||||
placeholder="Conclusao do relatorio"
|
placeholder="Conclusão do relatório"
|
||||||
value={editor.conclusion}
|
value={editor.conclusion}
|
||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Conteudo HTML">
|
<DarkField label="Complemento">
|
||||||
<textarea
|
<textarea
|
||||||
className={`${textareaClass} min-h-72`}
|
className={`${textareaClass} min-h-72`}
|
||||||
onChange={(event) => updateField('contentHtml', event.target.value)}
|
onChange={(event) => updateField('contentHtml', event.target.value)}
|
||||||
placeholder="<p>Conteudo do relatorio</p>"
|
|
||||||
value={editor.contentHtml}
|
value={editor.contentHtml}
|
||||||
/>
|
/>
|
||||||
</DarkField>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -622,7 +679,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ReportIcon className="size-3.5" name="save" />
|
<ReportIcon className="size-3.5" name="save" />
|
||||||
{saving ? 'Salvando...' : 'Salvar relatorio'}
|
{saving ? 'Salvando...' : 'Salvar relatório'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -631,6 +688,8 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReportViewModal({ onClose, report }) {
|
function ReportViewModal({ onClose, report }) {
|
||||||
|
const currentStatus = statusConfig[report.status] || statusConfig.draft
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
@@ -639,12 +698,22 @@ function ReportViewModal({ onClose, report }) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatorio medico</h2>
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatório</h2>
|
||||||
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem numero'} </p>
|
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem número'} </p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="inline-flex h-9 items-center gap-2 rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
||||||
|
onClick={() => printReportAsPdf(report, currentStatus)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReportIcon className="size-4" name="print" />
|
||||||
|
Imprimir PDF
|
||||||
|
</button>
|
||||||
|
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
|
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
|
||||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
@@ -653,7 +722,7 @@ function ReportViewModal({ onClose, report }) {
|
|||||||
<DetailCard label="Solicitante" value={report.requestedBy || '-'} />
|
<DetailCard label="Solicitante" value={report.requestedBy || '-'} />
|
||||||
<DetailCard label="Criado em" value={formatDate(report.createdAt)} />
|
<DetailCard label="Criado em" value={formatDate(report.createdAt)} />
|
||||||
<DetailCard label="Criado por" value={report.createdByName} />
|
<DetailCard label="Criado por" value={report.createdByName} />
|
||||||
<DetailCard label="Status" value={statusConfig[report.status].label} />
|
<DetailCard label="Status" value={currentStatus.label} />
|
||||||
<DetailCard label="Prazo" value={formatDateTime(report.dueAt)} />
|
<DetailCard label="Prazo" value={formatDateTime(report.dueAt)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -663,28 +732,15 @@ function ReportViewModal({ onClose, report }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<DetailBlock label="Diagnostico" value={report.diagnosis || '-'} />
|
<DetailBlock label="Diagnóstico" value={report.diagnosis || '-'} />
|
||||||
<DetailBlock label="Conclusao" value={report.conclusion || '-'} />
|
<DetailBlock label="Conclusão" value={report.conclusion || '-'} />
|
||||||
</div>
|
</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">
|
<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]">Conteudo HTML</p>
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Complemento</p>
|
||||||
{report.contentHtml ? (
|
{report.contentHtml ? (
|
||||||
<div
|
<p className="whitespace-pre-wrap text-sm leading-6 text-[#e5e5e5]">{report.contentHtml}</p>
|
||||||
className="prose prose-invert max-w-none text-sm text-[#e5e5e5]"
|
|
||||||
dangerouslySetInnerHTML={{ __html: report.contentHtml }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-[#a3a3a3]">Nenhum conteudo HTML informado.</p>
|
<p className="text-sm text-[#a3a3a3]">Nenhum complemento informado.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -789,6 +845,111 @@ function toDateTimeLocal(value) {
|
|||||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values) {
|
||||||
|
return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReportEditorValid(editor) {
|
||||||
|
return [
|
||||||
|
editor.patientId,
|
||||||
|
editor.status,
|
||||||
|
editor.exam,
|
||||||
|
editor.requestedBy,
|
||||||
|
editor.cidCode,
|
||||||
|
editor.diagnosis,
|
||||||
|
editor.conclusion,
|
||||||
|
editor.dueAt,
|
||||||
|
].every((value) => String(value || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearch(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function printReportAsPdf(report, status) {
|
||||||
|
const printWindow = window.open('', '_blank', 'noopener,noreferrer,width=900,height=1100')
|
||||||
|
|
||||||
|
if (!printWindow) {
|
||||||
|
window.print()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Relatório ${escapeHtml(report.orderNumber || '')}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { color: #171717; font-family: Arial, sans-serif; margin: 40px; }
|
||||||
|
h1 { font-size: 24px; margin: 0 0 4px; }
|
||||||
|
.muted { color: #525252; font-size: 12px; }
|
||||||
|
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 24px; }
|
||||||
|
.box { border: 1px solid #d4d4d4; border-radius: 8px; padding: 12px; }
|
||||||
|
.label { color: #525252; font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.value { font-size: 13px; margin-top: 6px; white-space: pre-wrap; }
|
||||||
|
.section { margin-top: 20px; }
|
||||||
|
@media print { body { margin: 24mm; } button { display: none; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Relatório</h1>
|
||||||
|
<p class="muted">${escapeHtml(report.orderNumber || 'Sem número')}</p>
|
||||||
|
<div class="grid">
|
||||||
|
${printDetail('Paciente', report.patientName)}
|
||||||
|
${printDetail('Solicitante', report.requestedBy || '-')}
|
||||||
|
${printDetail('Criado em', formatDate(report.createdAt))}
|
||||||
|
${printDetail('Criado por', report.createdByName)}
|
||||||
|
${printDetail('Status', status.label)}
|
||||||
|
${printDetail('Prazo', formatDateTime(report.dueAt))}
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
${printDetail('Exame', report.exam || '-')}
|
||||||
|
${printDetail('CID-10', report.cidCode || '-')}
|
||||||
|
</div>
|
||||||
|
<div class="section box">
|
||||||
|
<p class="label">Diagnóstico</p>
|
||||||
|
<p class="value">${escapeHtml(report.diagnosis || '-')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="section box">
|
||||||
|
<p class="label">Conclusão</p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
printWindow.document.close()
|
||||||
|
printWindow.focus()
|
||||||
|
printWindow.print()
|
||||||
|
}
|
||||||
|
|
||||||
|
function printDetail(label, value) {
|
||||||
|
return `
|
||||||
|
<div class="box">
|
||||||
|
<p class="label">${escapeHtml(label)}</p>
|
||||||
|
<p class="value">${escapeHtml(value || '-')}</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
function ReportIcon({ className = 'size-4', name }) {
|
function ReportIcon({ className = 'size-4', name }) {
|
||||||
const common = {
|
const common = {
|
||||||
className,
|
className,
|
||||||
@@ -867,6 +1028,24 @@ function ReportIcon({ className = 'size-4', name }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'print') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 8V4h10v4" />
|
||||||
|
<path d="M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2" />
|
||||||
|
<path d="M7 14h10v7H7zM17 12h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'check') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m5 12 4 4L19 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
<path d="m6 9 6 6 6-6" />
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||||
import { settingsRepository } from '../repositories/settingsRepository.js'
|
import { settingsRepository } from '../repositories/settingsRepository.js'
|
||||||
|
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
|
||||||
|
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
@@ -53,10 +54,7 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
<section className={`${cardClass} min-w-0 flex-1 p-6 lg:p-8`}>
|
<section className={`${cardClass} min-w-0 flex-1 p-6 lg:p-8`}>
|
||||||
{activeSection === 'aparencia' ? <AppearanceSection /> : null}
|
{activeSection === 'aparencia' ? <AppearanceSection /> : null}
|
||||||
{activeSection === 'notificacoes' ? <NotificationsSection /> : null}
|
|
||||||
{activeSection === 'privacidade' ? <PrivacySection /> : null}
|
{activeSection === 'privacidade' ? <PrivacySection /> : null}
|
||||||
{activeSection === 'conta' ? <AccountSection /> : null}
|
|
||||||
{activeSection === 'integracoes' ? <IntegrationsSection /> : null}
|
|
||||||
{activeSection === 'dados' ? <DataSection /> : null}
|
{activeSection === 'dados' ? <DataSection /> : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,35 +63,43 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppearanceSection() {
|
function AppearanceSection() {
|
||||||
const [theme, setTheme] = useState('dark')
|
const [theme, setTheme] = useState(() => getStoredTheme())
|
||||||
const [compact, setCompact] = useState(false)
|
const [compact, setCompact] = useState(false)
|
||||||
const [contrast, setContrast] = useState(false)
|
const [contrast, setContrast] = useState(false)
|
||||||
const [animations, setAnimations] = useState(true)
|
const [animations, setAnimations] = useState(true)
|
||||||
|
|
||||||
|
function handleThemeChange(nextTheme) {
|
||||||
|
setTheme(setStoredTheme(nextTheme))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mb-8">
|
||||||
<p className="mb-4 text-sm font-semibold text-[#e5e5e5]">Tema da Interface</p>
|
<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">
|
<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]' },
|
{ id: 'light', label: 'Claro', preview: 'bg-[#f4f7fb]' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
className={`rounded-2xl border-2 p-4 text-left transition ${
|
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}
|
key={item.id}
|
||||||
onClick={() => setTheme(item.id)}
|
onClick={() => handleThemeChange(item.id)}
|
||||||
type="button"
|
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={`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={`h-2.5 rounded ${item.id === 'dark' ? 'bg-[#1a3050]' : 'bg-white'}`} />
|
<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="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="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={`settings-theme-preview-line h-1.5 w-3/4 rounded-full ${item.id === 'dark' ? 'bg-[#525252]' : '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-1/2 rounded-full ${item.id === 'dark' ? 'bg-[#404040]' : 'bg-[#dde8f7]'}`} />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -287,7 +293,7 @@ function IntegrationsSection() {
|
|||||||
<p className="text-sm font-semibold text-[#f5f5f5]">{name}</p>
|
<p className="text-sm font-semibold text-[#f5f5f5]">{name}</p>
|
||||||
<p className="text-xs text-[#a3a3a3]">{desc}</p>
|
<p className="text-xs text-[#a3a3a3]">{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${connected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-[#303030] text-[#a3a3a3]'}`}>
|
<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'}
|
{connected ? 'Conectado' : 'Desconectado'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
|
|
||||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
|
||||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
|
||||||
|
|
||||||
export function TeamPage({ navigate }) {
|
|
||||||
const [professionals, setProfessionals] = useState([])
|
|
||||||
const { slots, weekdays } = professionalRepository.getCoverageMap()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
professionalRepository.getAll().then(setProfessionals).catch(console.error)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
|
||||||
<FeatureCallout
|
|
||||||
description="A listagem de profissionais usa API, mas o mapa de cobertura e parte da disponibilidade ainda são simulados."
|
|
||||||
status="partial"
|
|
||||||
title="Tela híbrida: parte real, parte mockada"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Profissionais</h1>
|
|
||||||
<p className="mt-1 text-sm text-[#b8b8b8]">Equipe, agenda e cobertura operacional da clínica.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
|
||||||
onClick={() => navigate('/agenda')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Ver disponibilidade
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe médica">
|
|
||||||
{professionals.map((professional) => (
|
|
||||||
<article className={`${cardClass} ${featurePanelClass('live')} p-5`} key={professional.id}>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="grid size-11 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-sm font-bold text-[#3b82f6]">
|
|
||||||
{initials(professional.name)}
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-4 text-lg font-bold text-[#f5f5f5]">{professional.name}</h2>
|
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">{professional.role}</p>
|
|
||||||
</div>
|
|
||||||
<StatusPill status={professional.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="mt-5 grid gap-3 text-sm">
|
|
||||||
<Info label="Agenda" value={professional.schedule} />
|
|
||||||
<Info label="Próximo horário" value={professional.nextSlot} />
|
|
||||||
<Info label="Pacientes ativos" value={professional.patients} />
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={`${cardClass} ${featurePanelClass('mock')} p-5`}>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h2 className="text-xl font-bold text-[#f5f5f5]">Mapa de cobertura</h2>
|
|
||||||
<FeatureBadge status="mock" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">
|
|
||||||
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6]"
|
|
||||||
onClick={() => navigate('/configuracoes')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Configurar regras
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 overflow-x-auto rounded-sm border border-[#404040]">
|
|
||||||
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] bg-[#171717] text-xs font-bold uppercase tracking-[0.16em] text-[#a3a3a3]">
|
|
||||||
{['Profissional', ...weekdays].map((label) => (
|
|
||||||
<div className="border-b border-[#404040] px-4 py-3" key={label}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{professionals.map((professional, rowIndex) => (
|
|
||||||
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] text-sm" key={professional.id}>
|
|
||||||
<div className="border-b border-[#404040] px-4 py-3 font-semibold text-[#f5f5f5]">{professional.name}</div>
|
|
||||||
{slots.map((slot, index) => (
|
|
||||||
<div className="border-b border-[#404040] px-4 py-3 text-[#b8b8b8]" key={`${professional.id}-${slot}`}>
|
|
||||||
{shiftSlot(slot, rowIndex + index)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Info({ label, value }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-semibold text-[#737373]">{label}</dt>
|
|
||||||
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ status }) {
|
|
||||||
const className =
|
|
||||||
status === 'Disponivel'
|
|
||||||
? 'bg-emerald-500/20 text-emerald-400'
|
|
||||||
: status === 'Em atendimento'
|
|
||||||
? 'bg-amber-500/20 text-amber-400'
|
|
||||||
: 'bg-blue-500/20 text-blue-400'
|
|
||||||
|
|
||||||
return <span className={`rounded px-2 py-1 text-[10px] font-bold ${className}`}>{status}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(name) {
|
|
||||||
return name
|
|
||||||
.replace(/^(Dr\.|Dra\.|Nutri\.|Enf\.)\s+/i, '')
|
|
||||||
.split(' ')
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function shiftSlot(slot, index) {
|
|
||||||
if (index % 4 === 0) {
|
|
||||||
return 'Bloqueado'
|
|
||||||
}
|
|
||||||
|
|
||||||
return slot
|
|
||||||
}
|
|
||||||
474
src/pages/UsersPage.jsx
Normal file
474
src/pages/UsersPage.jsx
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { ADMIN_CREATABLE_ROLES, GESTOR_CREATABLE_ROLES, hasCapability, normalizeRole, ROLE_LABELS } from '../config/permissions.js'
|
||||||
|
import { userRepository } from '../repositories/userRepository.js'
|
||||||
|
|
||||||
|
const darkInput =
|
||||||
|
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||||
|
const darkLabel = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||||
|
const authMethodOptions = [
|
||||||
|
{
|
||||||
|
value: 'magic_link',
|
||||||
|
label: 'Magic Link',
|
||||||
|
description: 'Enviar link de acesso por email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'password',
|
||||||
|
label: 'Email e senha',
|
||||||
|
description: 'Definir senha inicial agora',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const initialUserForm = {
|
||||||
|
email: '',
|
||||||
|
full_name: '',
|
||||||
|
phone: '',
|
||||||
|
cpf: '',
|
||||||
|
role: '',
|
||||||
|
auth_method: 'magic_link',
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
create_patient_record: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage({ role: currentRole }) {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deletingId, setDeletingId] = useState(null)
|
||||||
|
const [form, setForm] = useState(initialUserForm)
|
||||||
|
const [roleFilter, setRoleFilter] = useState('Todos')
|
||||||
|
|
||||||
|
const normalizedRole = normalizeRole(currentRole)
|
||||||
|
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
|
||||||
|
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||||
|
const isPasswordCreation = form.auth_method === 'password'
|
||||||
|
const filterableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||||
|
const filteredUsers = users.filter((user) => {
|
||||||
|
if (roleFilter === 'Todos') return true
|
||||||
|
return normalizeRole(getUserRole(user)) === roleFilter
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await userRepository.getAll()
|
||||||
|
setUsers(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFormChange(event) {
|
||||||
|
const { checked, name, type, value } = event.target
|
||||||
|
setForm((current) => ({ ...current, [name]: type === 'checkbox' ? checked : value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!canManageUsers) {
|
||||||
|
window.alert('Você não tem permissão para criar usuários.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.email || !form.full_name || !form.phone || !form.cpf || !form.role) {
|
||||||
|
window.alert('Preencha email, nome completo, celular, CPF e perfil.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPasswordCreation) {
|
||||||
|
if (!form.password || !form.confirm_password) {
|
||||||
|
window.alert('Preencha a senha e a confirmação de senha.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
window.alert('A senha deve ter pelo menos 8 caracteres.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password !== form.confirm_password) {
|
||||||
|
window.alert('A confirmação de senha não confere.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (isPasswordCreation) {
|
||||||
|
await userRepository.createWithPassword(form)
|
||||||
|
window.alert(`Usuário criado com email e senha para ${form.email}.`)
|
||||||
|
} else {
|
||||||
|
await userRepository.create(form)
|
||||||
|
window.alert(`Usuário criado! Magic Link enviado para ${form.email}.`)
|
||||||
|
}
|
||||||
|
setModalOpen(false)
|
||||||
|
setForm(initialUserForm)
|
||||||
|
loadUsers()
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Erro ao criar usuário: ${err.message}`)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(user) {
|
||||||
|
if (!canManageUsers) {
|
||||||
|
window.alert('Você não tem permissão para deletar usuários.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`⚠️ ATENÇÃO: Esta operação é IRREVERSÍVEL!\n\nO usuário "${user.full_name || user.email}" e TODOS os dados relacionados (perfil, agendamentos, registros) serão deletados permanentemente.\n\nDeseja continuar?`
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setDeletingId(user.id)
|
||||||
|
try {
|
||||||
|
await userRepository.remove(user.id)
|
||||||
|
setUsers((current) => current.filter((u) => u.id !== user.id))
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Erro ao deletar usuário: ${err.message}`)
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageUsers) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl rounded-2xl border border-[#404040] bg-[#262626] p-8 text-center text-[#e5e5e5]">
|
||||||
|
<h1 className="text-xl font-bold">Acesso não permitido</h1>
|
||||||
|
<p className="mt-2 text-sm text-[#a3a3a3]">Somente Administrador e Gestão/Coordenação podem gerenciar usuários.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
||||||
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Usuários do Sistema</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">Gerencie os usuários e seus perfis de acesso</p>
|
||||||
|
</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)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ Novo usuário
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-[#a3a3a3]">Carregando usuários...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-10 text-center text-sm text-red-400">Erro ao carregar usuários: {error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-[#404040] bg-[#262626] shadow-sm">
|
||||||
|
<div className="flex flex-col gap-3 border-b border-[#404040] px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-[#e5e5e5]">Filtros</p>
|
||||||
|
<p className="mt-1 text-xs text-[#a3a3a3]">
|
||||||
|
{filteredUsers.length} de {users.length} usuários exibidos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="grid gap-1.5 text-xs font-semibold text-[#a3a3a3] sm:min-w-56">
|
||||||
|
<span>Perfil</span>
|
||||||
|
<select
|
||||||
|
className={darkInput}
|
||||||
|
onChange={(event) => setRoleFilter(event.target.value)}
|
||||||
|
value={roleFilter}
|
||||||
|
>
|
||||||
|
<option value="Todos">Todos os perfis</option>
|
||||||
|
{filterableRoles.map((role) => (
|
||||||
|
<option key={`filter-role-${role}`} value={role}>
|
||||||
|
{ROLE_LABELS[role]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full whitespace-nowrap text-left text-sm">
|
||||||
|
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4">Nome</th>
|
||||||
|
<th className="px-6 py-4">Email</th>
|
||||||
|
<th className="px-6 py-4">Perfil</th>
|
||||||
|
<th className="px-6 py-4">Status</th>
|
||||||
|
<th className="px-6 py-4 text-right">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#404040]">
|
||||||
|
{filteredUsers.length ? (
|
||||||
|
filteredUsers.map((user) => {
|
||||||
|
const userRole = getUserRole(user)
|
||||||
|
return (
|
||||||
|
<tr className="transition hover:bg-[#303030]" key={user.id}>
|
||||||
|
<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]">
|
||||||
|
{(user.full_name || user.email || '?').charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-[#e5e5e5]">{user.full_name || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-[#a3a3a3]">{user.email}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<RoleBadge role={userRole} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
|
||||||
|
user.email_confirmed_at
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400'
|
||||||
|
: 'bg-amber-500/20 text-amber-400'
|
||||||
|
}`}>
|
||||||
|
{user.email_confirmed_at ? 'Ativo' : 'Pendente'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<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)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{deletingId === user.id ? 'Deletando...' : 'Deletar'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="px-6 py-10 text-center text-[#a3a3a3]" colSpan={5}>
|
||||||
|
{users.length ? 'Nenhum usuário encontrado para o perfil selecionado.' : 'Nenhum usuário encontrado.'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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"
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333]"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={handleCreate}>
|
||||||
|
<div>
|
||||||
|
<span className={darkLabel}>Criar usuário usando *</span>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{authMethodOptions.map((option) => {
|
||||||
|
const selected = form.auth_method === option.value
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`cursor-pointer rounded-lg border p-3 transition ${
|
||||||
|
selected
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6]/15 text-[#e5e5e5]'
|
||||||
|
: 'border-[#404040] bg-[#1a1a1a] text-[#a3a3a3] hover:border-[#525252] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={option.value}
|
||||||
|
>
|
||||||
|
<span className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
checked={selected}
|
||||||
|
className="mt-1 size-4 accent-[#3b82f6]"
|
||||||
|
name="auth_method"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
type="radio"
|
||||||
|
value={option.value}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold">{option.label}</span>
|
||||||
|
<span className="mt-1 block text-xs text-[#a3a3a3]">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>Nome completo *</label>
|
||||||
|
<input
|
||||||
|
className={darkInput}
|
||||||
|
name="full_name"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="Ex: João da Silva"
|
||||||
|
required
|
||||||
|
value={form.full_name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>Email *</label>
|
||||||
|
<input
|
||||||
|
className={darkInput}
|
||||||
|
name="email"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="email@exemplo.com"
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>Celular *</label>
|
||||||
|
<input
|
||||||
|
className={darkInput}
|
||||||
|
maxLength={15}
|
||||||
|
name="phone"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="(00) 00000-0000"
|
||||||
|
required
|
||||||
|
value={form.phone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>CPF *</label>
|
||||||
|
<input
|
||||||
|
className={darkInput}
|
||||||
|
maxLength={14}
|
||||||
|
name="cpf"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="000.000.000-00"
|
||||||
|
required
|
||||||
|
value={form.cpf}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPasswordCreation ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>Senha *</label>
|
||||||
|
<input
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={darkInput}
|
||||||
|
minLength={8}
|
||||||
|
name="password"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="Mínimo 8 caracteres"
|
||||||
|
required={isPasswordCreation}
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>Confirmar senha *</label>
|
||||||
|
<input
|
||||||
|
autoComplete="new-password"
|
||||||
|
className={darkInput}
|
||||||
|
minLength={8}
|
||||||
|
name="confirm_password"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
placeholder="Repita a senha"
|
||||||
|
required={isPasswordCreation}
|
||||||
|
type="password"
|
||||||
|
value={form.confirm_password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={darkLabel}>Perfil de acesso *</label>
|
||||||
|
<select
|
||||||
|
className={darkInput}
|
||||||
|
name="role"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
required
|
||||||
|
value={form.role}
|
||||||
|
>
|
||||||
|
<option value="">Selecione um perfil</option>
|
||||||
|
{creatableRoles.map((r) => (
|
||||||
|
<option key={`role-option-${r}`} value={r}>{ROLE_LABELS[r]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||||
|
<input
|
||||||
|
checked={form.create_patient_record}
|
||||||
|
className="size-4 accent-[#3b82f6]"
|
||||||
|
name="create_patient_record"
|
||||||
|
onChange={handleFormChange}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Criar também um registro de paciente
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<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}
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:opacity-60"
|
||||||
|
disabled={saving}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{saving ? 'Criando...' : isPasswordCreation ? 'Criar com senha' : 'Criar e enviar Magic Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleBadge({ role }) {
|
||||||
|
const styles = {
|
||||||
|
admin: 'bg-purple-500/20 text-purple-400',
|
||||||
|
gestor: 'bg-blue-500/20 text-blue-400',
|
||||||
|
medico: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
secretaria: 'bg-amber-500/20 text-amber-400',
|
||||||
|
paciente: 'bg-[#303030] text-[#a3a3a3]',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${styles[role] || styles.paciente}`}>
|
||||||
|
{ROLE_LABELS[role] || role}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserRole(user) {
|
||||||
|
return Array.isArray(user.roles) ? user.roles[0] : (user.role ?? '—')
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function VisitsPage({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab === 'atendimento') {
|
if (activeTab === 'atendimento') {
|
||||||
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando medico')
|
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando médico')
|
||||||
}
|
}
|
||||||
|
|
||||||
return careQueue.filter((item) => item.status !== 'Finalizada')
|
return careQueue.filter((item) => item.status !== 'Finalizada')
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export const analyticsRepository = {
|
|||||||
getDashboardData() {
|
getDashboardData() {
|
||||||
return {
|
return {
|
||||||
absenteeismData: [
|
absenteeismData: [
|
||||||
{ month: 'Out', taxa: 18, meta: 15 },
|
{ month: 'Out/2025', taxa: 18, meta: 15 },
|
||||||
{ month: 'Nov', taxa: 16, meta: 15 },
|
{ month: 'Nov/2025', taxa: 16, meta: 15 },
|
||||||
{ month: 'Dez', taxa: 22, meta: 15 },
|
{ month: 'Dez/2025', taxa: 22, meta: 15 },
|
||||||
{ month: 'Jan', taxa: 14, meta: 15 },
|
{ month: 'Jan/2026', taxa: 14, meta: 15 },
|
||||||
{ month: 'Fev', taxa: 12, meta: 15 },
|
{ month: 'Fev/2026', taxa: 12, meta: 15 },
|
||||||
{ month: 'Mar', taxa: 14.2, meta: 15 },
|
{ month: 'Mar/2026', taxa: 14.2, meta: 15 },
|
||||||
],
|
],
|
||||||
consultationsData: [
|
consultationsData: [
|
||||||
{ month: 'Out', total: 380, realizadas: 312 },
|
{ month: 'Out', total: 380, realizadas: 312 },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
import { appointmentMapper } from '../mappers/appointmentMapper.js'
|
import { appointmentMapper } from '../mappers/appointmentMapper.js'
|
||||||
|
import { getResponseError, normalizeItem } from './repositoryUtils.js'
|
||||||
|
|
||||||
export const appointmentRepository = {
|
export const appointmentRepository = {
|
||||||
async getAll({ doctorId } = {}) {
|
async getAll({ doctorId } = {}) {
|
||||||
@@ -9,7 +10,7 @@ export const appointmentRepository = {
|
|||||||
headers: getAuthenticatedHeaders()
|
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()
|
const data = await response.json()
|
||||||
return (Array.isArray(data) ? data : []).map(appointmentMapper.toUi)
|
return (Array.isArray(data) ? data : []).map(appointmentMapper.toUi)
|
||||||
@@ -22,10 +23,26 @@ export const appointmentRepository = {
|
|||||||
body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')),
|
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 data = await response.json()
|
||||||
const item = Array.isArray(data) ? data[0] : data
|
return appointmentMapper.toUi(normalizeItem(data))
|
||||||
return appointmentMapper.toUi(item)
|
},
|
||||||
}
|
|
||||||
|
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,6 +8,7 @@ import {
|
|||||||
hasAuthenticatedSession,
|
hasAuthenticatedSession,
|
||||||
saveAuthSession,
|
saveAuthSession,
|
||||||
} from '../config/api.js'
|
} from '../config/api.js'
|
||||||
|
import { translateErrorMessage } from './repositoryUtils.js'
|
||||||
|
|
||||||
export const authRepository = {
|
export const authRepository = {
|
||||||
async login({ email, password }) {
|
async login({ email, password }) {
|
||||||
@@ -18,12 +19,12 @@ export const authRepository = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await getResponseError(response, 'Erro de autenticacao.'))
|
throw new Error(await getResponseError(response, 'Erro de autenticação.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await response.json()
|
const session = await response.json()
|
||||||
if (!session?.access_token) {
|
if (!session?.access_token) {
|
||||||
throw new Error('Falha no login. Token nao recebido.')
|
throw new Error('Falha no login. Token não recebido.')
|
||||||
}
|
}
|
||||||
|
|
||||||
saveAuthSession(session)
|
saveAuthSession(session)
|
||||||
@@ -32,7 +33,7 @@ export const authRepository = {
|
|||||||
|
|
||||||
async requestPasswordReset(email) {
|
async requestPasswordReset(email) {
|
||||||
const payload = { email: email?.trim() }
|
const payload = { email: email?.trim() }
|
||||||
const apiResponse = await fetch(apiEndpoint('/solicitar-reset-de-senha'), {
|
const apiResponse = await fetch(apiEndpoint('/request-password-reset'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAnonHeaders(),
|
headers: getAnonHeaders(),
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -60,24 +61,17 @@ export const authRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getUser() {
|
async getUser() {
|
||||||
const apiEndpoints = [
|
const apiResponse = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/user-info`, {
|
||||||
apiEndpoint('/user-info'),
|
method: 'POST',
|
||||||
apiEndpoint('/informacoes-do-usuario-autenticado'),
|
headers: getAuthenticatedHeaders(),
|
||||||
]
|
}).catch(() => null)
|
||||||
|
|
||||||
for (const url of apiEndpoints) {
|
if (apiResponse?.ok) {
|
||||||
const apiResponse = await fetch(url, {
|
return apiResponse.json()
|
||||||
method: 'GET',
|
}
|
||||||
headers: getAuthenticatedHeaders(),
|
|
||||||
}).catch(() => null)
|
|
||||||
|
|
||||||
if (apiResponse?.ok) {
|
if (apiResponse && !shouldFallback(apiResponse)) {
|
||||||
return apiResponse.json()
|
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuário.'))
|
||||||
}
|
|
||||||
|
|
||||||
if (apiResponse && !shouldFallback(apiResponse)) {
|
|
||||||
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.'))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, {
|
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, {
|
||||||
@@ -86,7 +80,7 @@ export const authRepository = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuario.'))
|
throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuário.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -114,7 +108,7 @@ export const authRepository = {
|
|||||||
headers: getAuthenticatedHeaders(),
|
headers: getAuthenticatedHeaders(),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// A sessao local precisa ser removida mesmo quando o backend nao responde.
|
// A sessão local precisa ser removida mesmo quando o backend não responde.
|
||||||
} finally {
|
} finally {
|
||||||
clearAuthSession()
|
clearAuthSession()
|
||||||
}
|
}
|
||||||
@@ -127,5 +121,5 @@ function shouldFallback(response) {
|
|||||||
|
|
||||||
async function getResponseError(response, fallbackMessage) {
|
async function getResponseError(response, fallbackMessage) {
|
||||||
const error = await response.json().catch(() => ({}))
|
const error = await response.json().catch(() => ({}))
|
||||||
return error.error_description || error.msg || error.message || error.error || fallbackMessage
|
return translateErrorMessage(error.error_description || error.msg || error.message || error.error || fallbackMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
199
src/repositories/availabilityRepository.js
Normal file
199
src/repositories/availabilityRepository.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
import { getResponseError, normalizeCollection, normalizeItem } from './repositoryUtils.js'
|
||||||
|
|
||||||
|
const availabilityBaseUrl = `${apiConfig.restUrl}/doctor_availability`
|
||||||
|
const exceptionsBaseUrl = `${apiConfig.restUrl}/doctor_exceptions`
|
||||||
|
|
||||||
|
export const availabilityRepository = {
|
||||||
|
async getAll(filters = {}) {
|
||||||
|
const query = buildRestQuery(filters)
|
||||||
|
const response = await fetch(`${availabilityBaseUrl}?${query.toString()}`, {
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao listar disponibilidades.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeCollection(await response.json(), []).map(mapAvailability)
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data) {
|
||||||
|
const response = await fetch(availabilityBaseUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
|
body: JSON.stringify(toAvailabilityPayload(data)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao criar disponibilidade.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapAvailability(normalizeItem(await response.json()))
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id, data) {
|
||||||
|
const response = await fetch(`${availabilityBaseUrl}?id=eq.${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
|
body: JSON.stringify(toAvailabilityPayload(data)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao atualizar disponibilidade.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapAvailability(normalizeItem(await response.json()))
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id) {
|
||||||
|
const response = await fetch(`${availabilityBaseUrl}?id=eq.${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao deletar disponibilidade.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
async getExceptions(filters = {}) {
|
||||||
|
const query = buildRestQuery(filters)
|
||||||
|
const response = await fetch(`${exceptionsBaseUrl}?${query.toString()}`, {
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao listar excecoes de agenda.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeCollection(await response.json(), []).map(mapException)
|
||||||
|
},
|
||||||
|
|
||||||
|
async createException(data) {
|
||||||
|
const response = await fetch(exceptionsBaseUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
|
body: JSON.stringify(toExceptionPayload(data)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao criar excecao de agenda.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapException(normalizeItem(await response.json()))
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAvailableSlots({ appointmentType, date, doctorId }) {
|
||||||
|
const payload = {
|
||||||
|
doctor_id: doctorId,
|
||||||
|
date,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
appointment_type: normalizeAppointmentType(appointmentType),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/get-available-slots`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Falha ao calcular slots disponíveis.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return normalizeCollection(data, ['slots']).map(mapSlot)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRestQuery(filters) {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.set('select', '*')
|
||||||
|
|
||||||
|
if (filters.doctorId) query.set('doctor_id', `eq.${filters.doctorId}`)
|
||||||
|
if (filters.weekday !== undefined) query.set('weekday', `eq.${filters.weekday}`)
|
||||||
|
if (filters.active !== undefined) query.set('active', `eq.${filters.active}`)
|
||||||
|
if (filters.appointmentType) query.set('appointment_type', `eq.${filters.appointmentType}`)
|
||||||
|
if (filters.date) query.set('date', `eq.${filters.date}`)
|
||||||
|
if (filters.kind) query.set('kind', `eq.${filters.kind}`)
|
||||||
|
if (filters.order) query.set('order', filters.order)
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAvailabilityPayload(data) {
|
||||||
|
return cleanPayload({
|
||||||
|
doctor_id: data.doctorId,
|
||||||
|
weekday: data.weekday,
|
||||||
|
start_time: data.startTime,
|
||||||
|
end_time: data.endTime,
|
||||||
|
slot_minutes: data.slotMinutes,
|
||||||
|
appointment_type: data.appointmentType,
|
||||||
|
active: data.active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toExceptionPayload(data) {
|
||||||
|
return cleanPayload({
|
||||||
|
doctor_id: data.doctorId,
|
||||||
|
date: data.date,
|
||||||
|
kind: data.kind,
|
||||||
|
start_time: data.startTime,
|
||||||
|
end_time: data.endTime,
|
||||||
|
reason: data.reason,
|
||||||
|
created_by: data.createdBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAvailability(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
doctorId: item.doctor_id,
|
||||||
|
weekday: item.weekday,
|
||||||
|
startTime: item.start_time,
|
||||||
|
endTime: item.end_time,
|
||||||
|
slotMinutes: item.slot_minutes,
|
||||||
|
appointmentType: item.appointment_type,
|
||||||
|
active: item.active,
|
||||||
|
createdAt: item.created_at,
|
||||||
|
updatedAt: item.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapException(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
doctorId: item.doctor_id,
|
||||||
|
date: item.date,
|
||||||
|
kind: item.kind,
|
||||||
|
startTime: item.start_time,
|
||||||
|
endTime: item.end_time,
|
||||||
|
reason: item.reason,
|
||||||
|
createdBy: item.created_by,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSlot(slot) {
|
||||||
|
return {
|
||||||
|
date: slot.date,
|
||||||
|
datetime: slot.datetime,
|
||||||
|
time: slot.time,
|
||||||
|
available: Boolean(slot.available),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppointmentType(type) {
|
||||||
|
// API enum documented for availability currently accepts "presencial".
|
||||||
|
void type
|
||||||
|
return 'presencial'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPayload(payload) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(payload).filter(([, value]) => value !== undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
import { fetchJsonWithFallback } from './repositoryUtils.js'
|
import { fetchJsonWithFallback } from './repositoryUtils.js'
|
||||||
|
|
||||||
export const communicationRepository = {
|
export const communicationRepository = {
|
||||||
async sendSms({ patientName, phone, content }) {
|
async sendSms({ patientId, patientName, phone, content }) {
|
||||||
const message = `[MediConnect] Ola ${patientName}, ${content}`
|
const message = `[MediConnect] Ola ${patientName}, ${content}`
|
||||||
const payload = {
|
const payload = {
|
||||||
telefone: normalizePhone(phone),
|
phone_number: normalizePhone(phone),
|
||||||
phone: normalizePhone(phone),
|
|
||||||
mensagem: message,
|
|
||||||
message,
|
message,
|
||||||
paciente: patientName,
|
patient_id: patientId || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchJsonWithFallback(
|
await fetchJsonWithFallback(
|
||||||
[
|
[
|
||||||
{
|
|
||||||
url: apiEndpoint('/enviar-sms-via-twilio'),
|
|
||||||
options: {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthenticatedHeaders(),
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`,
|
url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`,
|
||||||
options: {
|
options: {
|
||||||
@@ -39,9 +29,9 @@ export const communicationRepository = {
|
|||||||
|
|
||||||
getCampaigns() {
|
getCampaigns() {
|
||||||
return [
|
return [
|
||||||
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' },
|
{ title: 'Lembretes Anti-Falta', desc: 'Envio automático 48h e 4h antes', count: '324 pacientes elegíveis' },
|
||||||
{ title: 'Vacinacao 2026', desc: 'Campanha de vacinacao anual', count: '156 pacientes elegiveis' },
|
{ title: 'Vacinação 2026', desc: 'Campanha de vacinação anual', count: '156 pacientes elegíveis' },
|
||||||
{ title: 'Retorno Pendente', desc: 'Pacientes com retorno atrasado', count: '42 pacientes elegiveis' },
|
{ title: 'Retorno Pendente', desc: 'Pacientes com retorno atrasado', count: '42 pacientes elegíveis' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -50,7 +40,7 @@ export const communicationRepository = {
|
|||||||
{ id: '1', patient: 'Carlos Eduardo Santos', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:00', status: 'lida', response: 'Confirmado!' },
|
{ id: '1', patient: 'Carlos Eduardo Santos', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:00', status: 'lida', response: 'Confirmado!' },
|
||||||
{ id: '2', patient: 'Mariana Costa', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:05', status: 'entregue' },
|
{ id: '2', patient: 'Mariana Costa', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:05', status: 'entregue' },
|
||||||
{ id: '3', patient: 'Joao Pedro Alves', channel: 'whatsapp', template: 'Lembrete 4h', sentAt: '27/03/2026 05:00', status: 'pendente' },
|
{ id: '3', patient: 'Joao Pedro Alves', channel: 'whatsapp', template: 'Lembrete 4h', sentAt: '27/03/2026 05:00', status: 'pendente' },
|
||||||
{ id: '4', patient: 'Fernanda Lima', channel: 'email', template: 'Confirmacao de Agendamento', sentAt: '24/03/2026 15:30', status: 'lida' },
|
{ id: '4', patient: 'Fernanda Lima', channel: 'email', template: 'Confirmação de Agendamento', sentAt: '24/03/2026 15:30', status: 'lida' },
|
||||||
{ id: '5', patient: 'Roberto Campos', channel: 'whatsapp', template: 'Lembrete Extra (Risco Alto)', sentAt: '26/03/2026 10:00', status: 'entregue' },
|
{ id: '5', patient: 'Roberto Campos', channel: 'whatsapp', template: 'Lembrete Extra (Risco Alto)', sentAt: '26/03/2026 10:00', status: 'entregue' },
|
||||||
{ id: '6', patient: 'Sandra Oliveira', channel: 'sms', template: 'Lembrete 48h', sentAt: '24/03/2026 08:00', status: 'falha' },
|
{ id: '6', patient: 'Sandra Oliveira', channel: 'sms', template: 'Lembrete 48h', sentAt: '24/03/2026 08:00', status: 'falha' },
|
||||||
{ id: '7', patient: 'Lucia Ferreira', channel: 'email', template: 'Resultado de Exames', sentAt: '26/03/2026 14:00', status: 'lida' },
|
{ id: '7', patient: 'Lucia Ferreira', channel: 'email', template: 'Resultado de Exames', sentAt: '26/03/2026 14:00', status: 'lida' },
|
||||||
@@ -60,11 +50,11 @@ export const communicationRepository = {
|
|||||||
|
|
||||||
getInitialTemplates() {
|
getInitialTemplates() {
|
||||||
return [
|
return [
|
||||||
{ id: 't1', name: 'Lembrete 48h', channel: 'whatsapp', content: 'Ola {nome}! Lembramos que sua consulta esta agendada para {data} as {hora}. Confirme respondendo SIM.', category: 'Lembrete' },
|
{ id: 't1', name: 'Lembrete 48h', channel: 'whatsapp', content: 'Ola {nome}! Lembramos que sua consulta esta agendada para {data} às {hora}. Confirme respondendo SIM.', category: 'Lembrete' },
|
||||||
{ id: 't2', name: 'Lembrete 4h', channel: 'whatsapp', content: 'Ola {nome}! Sua consulta e hoje as {hora}. Estamos te esperando!', category: 'Lembrete' },
|
{ id: 't2', name: 'Lembrete 4h', channel: 'whatsapp', content: 'Ola {nome}! Sua consulta e hoje às {hora}. Estamos te esperando!', category: 'Lembrete' },
|
||||||
{ id: 't3', name: 'Lembrete Extra (Risco Alto)', channel: 'whatsapp', content: 'Ola {nome}! Notamos que sua presenca e muito importante. Podemos confirmar sua consulta de {data}?', category: 'IA' },
|
{ id: 't3', name: 'Lembrete Extra (Risco Alto)', channel: 'whatsapp', content: 'Ola {nome}! Notamos que sua presenca e muito importante. Podemos confirmar sua consulta de {data}?', category: 'IA' },
|
||||||
{ id: 't4', name: 'Confirmacao de Agendamento', channel: 'email', content: 'Prezado(a) {nome}, confirmamos seu agendamento para {data} as {hora} com {medico}.', category: 'Agendamento' },
|
{ id: 't4', name: 'Confirmação de Agendamento', channel: 'email', content: 'Prezado(a) {nome}, confirmamos seu agendamento para {data} às {hora} com {medico}.', category: 'Agendamento' },
|
||||||
{ id: 't5', name: 'Resultado de Exames', channel: 'email', content: 'Prezado(a) {nome}, seus resultados de exames estao disponiveis. Acesse o portal do paciente.', category: 'Exames' },
|
{ id: 't5', name: 'Resultado de Exames', channel: 'email', content: 'Prezado(a) {nome}, seus resultados de exames estão disponíveis. Acesse o portal do paciente.', category: 'Exames' },
|
||||||
{ id: 't6', name: 'Reagendamento Sugerido (IA)', channel: 'whatsapp', content: 'Ola {nome}! Que tal reagendar sua consulta para um horario mais conveniente? Temos vagas em {sugestoes}.', category: 'IA' },
|
{ id: 't6', name: 'Reagendamento Sugerido (IA)', channel: 'whatsapp', content: 'Ola {nome}! Que tal reagendar sua consulta para um horario mais conveniente? Temos vagas em {sugestoes}.', category: 'IA' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
import { getResponseError } from './repositoryUtils.js'
|
||||||
|
|
||||||
export const patientRepository = {
|
export const patientRepository = {
|
||||||
// 1. Listar pacientes
|
// 1. Listar pacientes
|
||||||
async getAll() {
|
async getAll() {
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() })
|
const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() })
|
||||||
if (!response.ok) throw new Error('Erro ao buscar pacientes')
|
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao buscar pacientes.'))
|
||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
async getById(patientId) {
|
async getById(patientId) {
|
||||||
const patients = await this.getAll()
|
const [patients, appointments] = await Promise.all([
|
||||||
|
this.getAll(),
|
||||||
|
getAppointments().catch(() => []),
|
||||||
|
])
|
||||||
const patient = patients.find((p) => String(p.id) === String(patientId)) || null
|
const patient = patients.find((p) => String(p.id) === String(patientId)) || null
|
||||||
return patient ? mapPatientToDetail(patient) : null
|
return patient ? mapPatientToDetail(patient, appointments) : null
|
||||||
},
|
},
|
||||||
|
|
||||||
async getDirectoryRows() {
|
async getDirectoryRows({ doctorId } = {}) {
|
||||||
const patients = await this.getAll()
|
const [patients, appointments] = await Promise.all([
|
||||||
return patients.map(mapPatientToDirectory)
|
this.getAll(),
|
||||||
|
getAppointments({ doctorId }).catch(() => []),
|
||||||
|
])
|
||||||
|
|
||||||
|
const visiblePatients = doctorId
|
||||||
|
? getPatientsFromDoctorAppointments(patients, appointments)
|
||||||
|
: patients
|
||||||
|
|
||||||
|
return visiblePatients.map((patient) => mapPatientToDirectory(patient, appointments))
|
||||||
},
|
},
|
||||||
|
|
||||||
// 2. Criar paciente (direto)
|
// 2. Criar paciente (direto)
|
||||||
@@ -26,7 +38,7 @@ export const patientRepository = {
|
|||||||
cpf: data.cpf,
|
cpf: data.cpf,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone_mobile: data.phone,
|
phone_mobile: data.phone,
|
||||||
birth_date: data.birthDate || null,
|
birth_date: data.birthDate || data.birth_date || null,
|
||||||
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
|
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +49,11 @@ export const patientRepository = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({}))
|
if ([401, 403].includes(response.status)) {
|
||||||
console.error('Erro da API ao criar paciente:', error)
|
return this.createWithValidation(data)
|
||||||
throw new Error(error.message || error.hint || JSON.stringify(error))
|
}
|
||||||
|
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao criar paciente.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -52,7 +66,7 @@ export const patientRepository = {
|
|||||||
cpf: data.cpf,
|
cpf: data.cpf,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone_mobile: data.phone,
|
phone_mobile: data.phone,
|
||||||
birth_date: data.birthDate || null,
|
birth_date: data.birthDate || data.birth_date || null,
|
||||||
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
|
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +77,7 @@ export const patientRepository = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({}))
|
throw new Error(await getResponseError(response, 'Erro ao criar paciente com validação.'))
|
||||||
throw new Error(error.message || 'Erro ao criar paciente com validacao')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -77,7 +90,7 @@ export const patientRepository = {
|
|||||||
cpf: data.cpf,
|
cpf: data.cpf,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
phone_mobile: data.phone,
|
phone_mobile: data.phone,
|
||||||
birth_date: data.birthDate || null,
|
birth_date: data.birthDate || data.birth_date || null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, {
|
||||||
@@ -86,7 +99,7 @@ export const patientRepository = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao atualizar paciente')
|
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao atualizar paciente.'))
|
||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -97,46 +110,205 @@ export const patientRepository = {
|
|||||||
headers: getAuthenticatedHeaders(),
|
headers: getAuthenticatedHeaders(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao deletar paciente')
|
if (!response.ok) throw new Error(await getResponseError(response, 'Erro ao deletar paciente.'))
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapPatientToDirectory(patient) {
|
function mapPatientToDirectory(patient, appointments = []) {
|
||||||
|
const appointmentSummary = summarizeAppointments(patient.id, appointments)
|
||||||
|
const city = getFirstValue(patient, ['city', 'cidade', 'address_city', 'municipio'], patient.address?.city)
|
||||||
|
const state = getFirstValue(patient, ['state', 'uf', 'address_state', 'estado'], patient.address?.state)
|
||||||
|
const insurance = getFirstValue(patient, ['insurance', 'convenio', 'health_insurance', 'insurance_name'])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...patient,
|
...patient,
|
||||||
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
name: patient.name || patient.full_name || patient.nome || 'Paciente',
|
||||||
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
|
phone: patient.phone || patient.phone_mobile || patient.telefone || '',
|
||||||
detailId: patient.id,
|
detailId: patient.id,
|
||||||
insurance: patient.insurance || patient.convenio || 'Particular',
|
insurance: normalizeInsurance(insurance),
|
||||||
city: patient.city || patient.cidade || 'Recife',
|
city,
|
||||||
state: patient.state || patient.uf || 'PE',
|
state,
|
||||||
vip: Boolean(patient.vip),
|
vip: Boolean(patient.vip),
|
||||||
lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || null,
|
birthDate: patient.birthDate || patient.birth_date || '',
|
||||||
lastVisit: patient.lastVisit || patient.last_visit || 'Ainda nao houve atendimento',
|
motherName: patient.motherName || patient.mother_name || patient.nome_mae || '',
|
||||||
nextVisit: patient.nextVisit || patient.next_visit || 'Nenhum atendimento agendado',
|
fatherName: patient.fatherName || patient.father_name || patient.nome_pai || '',
|
||||||
|
ethnicity: patient.ethnicity || patient.etnia || '',
|
||||||
|
maritalStatus: patient.maritalStatus || patient.marital_status || patient.estado_civil || '',
|
||||||
|
phoneSecondary: patient.phoneSecondary || patient.phone_secondary || patient.phone_home || '',
|
||||||
|
zipCode: patient.zipCode || patient.zip_code || patient.cep || '',
|
||||||
|
addressStreet: patient.addressStreet || patient.address_street || patient.street || patient.logradouro || patient.address || '',
|
||||||
|
addressNumber: patient.addressNumber || patient.address_number || patient.numero || '',
|
||||||
|
addressComplement: patient.addressComplement || patient.address_complement || patient.complemento || '',
|
||||||
|
plan: patient.plan || patient.plano || patient.insurance_plan || '',
|
||||||
|
notesText: patient.notesText || patient.notes_text || patient.observations || patient.observacoes || '',
|
||||||
|
lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || appointmentSummary.lastVisitIso || null,
|
||||||
|
lastVisit: patient.lastVisit || patient.last_visit || appointmentSummary.lastVisit || '',
|
||||||
|
nextVisit: patient.nextVisit || patient.next_visit || appointmentSummary.nextVisit || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapPatientToDetail(patient) {
|
function mapPatientToDetail(patient, appointments = []) {
|
||||||
const directory = mapPatientToDirectory(patient)
|
const directory = mapPatientToDirectory(patient, appointments)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...directory,
|
...directory,
|
||||||
age: patient.age || patient.idade || calculateAge(patient.birth_date),
|
age: patient.age || patient.idade || calculateAge(patient.birth_date),
|
||||||
document: patient.document || patient.cpf || 'CPF nao informado',
|
document: patient.document || patient.cpf || 'CPF não informado',
|
||||||
plan: directory.insurance,
|
plan: directory.plan || directory.insurance,
|
||||||
condition: patient.condition || patient.condicao || 'Sem condicao principal',
|
condition: patient.condition || patient.condicao || 'Sem condicao principal',
|
||||||
status: patient.status || 'Acompanhamento',
|
status: patient.status || 'Acompanhamento',
|
||||||
risk: patient.risk || patient.risco || 'Baixo',
|
risk: patient.risk || patient.risco || 'Baixo',
|
||||||
email: patient.email || '',
|
email: patient.email || '',
|
||||||
address: patient.address || patient.endereco || 'Endereco nao informado',
|
address: formatAddress(directory) || patient.address || patient.endereco || 'Endereço não informado',
|
||||||
team: patient.team || patient.equipe || [],
|
team: patient.team || patient.equipe || [],
|
||||||
notes: patient.notes || patient.observacoes || [],
|
notes: normalizeNotes(patient.notes || patient.observacoes || directory.notesText),
|
||||||
exams: patient.exams || patient.exames || [],
|
exams: patient.exams || patient.exames || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getAppointments({ doctorId } = {}) {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.set('select', '*,patients(*)')
|
||||||
|
if (doctorId) {
|
||||||
|
query.set('doctor_id', `eq.${doctorId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiConfig.restUrl}/appointments?${query.toString()}`, {
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return []
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPatientsFromDoctorAppointments(patients, appointments) {
|
||||||
|
const patientById = new Map(
|
||||||
|
patients
|
||||||
|
.map((patient) => [normalizeId(patient.id), patient])
|
||||||
|
.filter(([id]) => id),
|
||||||
|
)
|
||||||
|
const visibleIds = new Set()
|
||||||
|
|
||||||
|
for (const appointment of appointments) {
|
||||||
|
const patientId = normalizeId(
|
||||||
|
appointment.patient_id ||
|
||||||
|
appointment.patientId ||
|
||||||
|
appointment.paciente_id ||
|
||||||
|
appointment.patients?.id ||
|
||||||
|
appointment.patient?.id ||
|
||||||
|
appointment.paciente?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!patientId) continue
|
||||||
|
|
||||||
|
visibleIds.add(patientId)
|
||||||
|
|
||||||
|
if (!patientById.has(patientId)) {
|
||||||
|
const embeddedPatient = appointment.patients || appointment.patient || appointment.paciente
|
||||||
|
if (embeddedPatient) {
|
||||||
|
patientById.set(patientId, { ...embeddedPatient, id: embeddedPatient.id || patientId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...visibleIds]
|
||||||
|
.map((patientId) => patientById.get(patientId))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeAppointments(patientId, appointments) {
|
||||||
|
const now = new Date()
|
||||||
|
const normalizedPatientId = String(patientId)
|
||||||
|
const patientAppointments = appointments
|
||||||
|
.filter((appointment) => String(appointment.patient_id || appointment.patientId || appointment.paciente_id || '') === normalizedPatientId)
|
||||||
|
.map((appointment) => ({
|
||||||
|
...appointment,
|
||||||
|
date: getAppointmentDate(appointment),
|
||||||
|
}))
|
||||||
|
.filter((appointment) => appointment.date)
|
||||||
|
.sort((a, b) => a.date - b.date)
|
||||||
|
|
||||||
|
const past = patientAppointments.filter((appointment) => appointment.date < now)
|
||||||
|
const future = patientAppointments.filter((appointment) => appointment.date >= now)
|
||||||
|
const last = past.at(-1)
|
||||||
|
const next = future[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastVisitIso: last ? formatDateInput(last.date) : null,
|
||||||
|
lastVisit: last ? formatAppointmentLabel(last.date) : '',
|
||||||
|
nextVisit: next ? formatAppointmentLabel(next.date) : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppointmentDate(appointment) {
|
||||||
|
if (appointment.scheduled_at) {
|
||||||
|
const date = new Date(appointment.scheduled_at)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateValue = appointment.date || appointment.appointment_date || appointment.data
|
||||||
|
const timeValue = appointment.time || appointment.appointment_time || appointment.hora || '00:00'
|
||||||
|
if (!dateValue) return null
|
||||||
|
|
||||||
|
const date = new Date(`${dateValue}T${timeValue}`)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAppointmentLabel(date) {
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateInput(date) {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstValue(source, keys, fallback = '') {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (source?.[key]) return source[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeId(value) {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(patient) {
|
||||||
|
return [
|
||||||
|
patient.addressStreet,
|
||||||
|
patient.addressNumber,
|
||||||
|
patient.addressComplement,
|
||||||
|
patient.city,
|
||||||
|
patient.state,
|
||||||
|
patient.zipCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotes(notes) {
|
||||||
|
if (Array.isArray(notes)) return notes
|
||||||
|
if (!notes) return []
|
||||||
|
return [String(notes)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInsurance(value) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (normalized.toLowerCase() === 'bradesco saude') return 'Bradesco Saúde'
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
function calculateAge(birthDate) {
|
function calculateAge(birthDate) {
|
||||||
if (!birthDate) return 0
|
if (!birthDate) return 0
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const professionalRepository = {
|
|||||||
headers: getAuthenticatedHeaders()
|
headers: getAuthenticatedHeaders()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao buscar medicos.')
|
if (!response.ok) throw new Error('Erro ao buscar médicos.')
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return (Array.isArray(data) ? data : []).map(mapProfessional)
|
return (Array.isArray(data) ? data : []).map(mapProfessional)
|
||||||
@@ -18,18 +18,37 @@ export const professionalRepository = {
|
|||||||
weekdays: ['Seg', 'Ter', 'Qua', 'Qui', 'Sex'],
|
weekdays: ['Seg', 'Ter', 'Qua', 'Qui', 'Sex'],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resolveCurrentProfessional(profile, professionals = []) {
|
||||||
|
const doctorId = normalizeValue(profile?.doctorId)
|
||||||
|
const userId = normalizeValue(profile?.id)
|
||||||
|
const email = normalizeValue(profile?.email)
|
||||||
|
|
||||||
|
return (
|
||||||
|
professionals.find((professional) => normalizeValue(professional.id) === doctorId) ||
|
||||||
|
professionals.find((professional) => normalizeValue(professional.userId) === userId) ||
|
||||||
|
professionals.find((professional) => normalizeValue(professional.id) === userId) ||
|
||||||
|
professionals.find((professional) => normalizeValue(professional.email) === email) ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapProfessional(doctor) {
|
function mapProfessional(doctor) {
|
||||||
return {
|
return {
|
||||||
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome),
|
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome),
|
||||||
userId: doctor.user_id || doctor.userId || doctor.usuario_id || doctor.auth_user_id || null,
|
userId: doctor.user_id || doctor.userId || doctor.usuario_id || doctor.auth_user_id || null,
|
||||||
name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)',
|
name: doctor.name || doctor.nome || doctor.full_name || 'Médico(a)',
|
||||||
email: doctor.email || doctor.user_email || doctor.usuario_email || '',
|
email: doctor.email || doctor.user_email || doctor.usuario_email || '',
|
||||||
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Medico(a)',
|
unit: doctor.unit || doctor.unidade || doctor.clinic_unit || doctor.clinica || doctor.location || '',
|
||||||
|
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Médico(a)',
|
||||||
schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h',
|
schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h',
|
||||||
nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente',
|
nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente',
|
||||||
patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0,
|
patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0,
|
||||||
status: doctor.status || doctor.situacao || 'Disponivel',
|
status: doctor.status || doctor.situacao || 'Disponivel',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeValue(value) {
|
||||||
|
return String(value || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authRepository } from './authRepository.js'
|
import { authRepository } from './authRepository.js'
|
||||||
|
import { normalizeRole, ROLE_LABELS } from '../config/permissions.js'
|
||||||
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
import { getResponseError } from './repositoryUtils.js'
|
import { getResponseError } from './repositoryUtils.js'
|
||||||
|
|
||||||
@@ -9,7 +10,8 @@ export const profileRepository = {
|
|||||||
const user = data?.user || data?.usuario || profile || data
|
const user = data?.user || data?.usuario || profile || data
|
||||||
const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {}
|
const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {}
|
||||||
const permissions = data?.permissions || {}
|
const permissions = data?.permissions || {}
|
||||||
const roles = Array.isArray(data?.roles) ? data.roles : []
|
const roles = collectRoles({ data, meta, profile, user })
|
||||||
|
const normalizedRole = resolveNormalizedRole({ permissions, roles, user, meta })
|
||||||
const avatarUrl =
|
const avatarUrl =
|
||||||
profile?.avatar_url ||
|
profile?.avatar_url ||
|
||||||
profile?.avatarUrl ||
|
profile?.avatarUrl ||
|
||||||
@@ -22,17 +24,19 @@ export const profileRepository = {
|
|||||||
return {
|
return {
|
||||||
id: profile?.id || user?.id || user?.user_id || user?.uid || '',
|
id: profile?.id || user?.id || user?.user_id || user?.uid || '',
|
||||||
email: profile?.email || user?.email || meta.email || '',
|
email: profile?.email || user?.email || meta.email || '',
|
||||||
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
|
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuário',
|
||||||
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
||||||
role: resolveProfileRole({ permissions, roles, user, meta }),
|
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 || 'Clinica Boa Vista',
|
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clínica Boa Vista',
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
doctorId: data?.doctor_id || data?.doctorId || null,
|
doctorId: data?.doctor_id || data?.doctorId || null,
|
||||||
patientId: data?.patient_id || data?.patientId || null,
|
patientId: data?.patient_id || data?.patientId || null,
|
||||||
roles,
|
roles,
|
||||||
permissions,
|
permissions,
|
||||||
isDoctor: Boolean(permissions.isDoctor || roles.includes('doctor') || data?.doctor_id),
|
isDoctor: normalizedRole === 'medico',
|
||||||
isAdmin: Boolean(permissions.isAdmin || roles.includes('admin')),
|
isAdmin: normalizedRole === 'admin',
|
||||||
|
isManager: normalizedRole === 'gestor',
|
||||||
|
isSecretary: normalizedRole === 'secretaria',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ export const profileRepository = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!profile.id) {
|
if (!profile.id) {
|
||||||
throw new Error('Nao foi possivel identificar o usuario para enviar o avatar.')
|
throw new Error('Não foi possível identificar o usuário para enviar o avatar.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const extension = file.name?.split('.').pop() || 'jpg'
|
const extension = file.name?.split('.').pop() || 'jpg'
|
||||||
@@ -89,12 +93,31 @@ function normalizeAvatarResponse(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProfileRole({ permissions, roles, user, meta }) {
|
function collectRoles({ data, meta, profile, user }) {
|
||||||
if (permissions.isAdmin || roles.includes('admin')) return 'Administrador'
|
return [
|
||||||
if (permissions.isManager || roles.includes('manager')) return 'Gestor'
|
...(Array.isArray(data?.roles) ? data.roles : []),
|
||||||
if (permissions.isDoctor || roles.includes('doctor')) return 'Medico(a)'
|
...(Array.isArray(user?.roles) ? user.roles : []),
|
||||||
if (permissions.isSecretary || roles.includes('secretary')) return 'Secretaria'
|
data?.role,
|
||||||
if (permissions.isPatient || roles.includes('patient')) return 'Paciente'
|
data?.cargo,
|
||||||
|
profile?.role,
|
||||||
return user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema'
|
profile?.cargo,
|
||||||
|
user?.role,
|
||||||
|
user?.cargo,
|
||||||
|
meta.role,
|
||||||
|
meta.cargo,
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNormalizedRole({ permissions, roles, user, meta }) {
|
||||||
|
for (const role of roles) {
|
||||||
|
const normalized = normalizeRole(role)
|
||||||
|
if (normalized) return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions.isAdmin) return 'admin'
|
||||||
|
if (permissions.isManager) return 'gestor'
|
||||||
|
if (permissions.isDoctor) return 'medico'
|
||||||
|
if (permissions.isSecretary) return 'secretaria'
|
||||||
|
|
||||||
|
return normalizeRole(user?.role || user?.cargo || meta.role || meta.cargo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export const reportRepository = {
|
|||||||
|
|
||||||
if (filters.patientId) {
|
if (filters.patientId) {
|
||||||
query.set('patient_id', `eq.${filters.patientId}`)
|
query.set('patient_id', `eq.${filters.patientId}`)
|
||||||
|
} else if (filters.patientIds?.length) {
|
||||||
|
query.set('patient_id', `in.(${filters.patientIds.join(',')})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
@@ -18,6 +20,10 @@ export const reportRepository = {
|
|||||||
|
|
||||||
if (filters.createdBy) {
|
if (filters.createdBy) {
|
||||||
query.set('created_by', `eq.${filters.createdBy}`)
|
query.set('created_by', `eq.${filters.createdBy}`)
|
||||||
|
} else if (filters.createdByValues?.length === 1) {
|
||||||
|
query.set('created_by', `eq.${filters.createdByValues[0]}`)
|
||||||
|
} else if (filters.createdByValues?.length > 1) {
|
||||||
|
query.set('created_by', `in.(${filters.createdByValues.join(',')})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiConfig.restUrl}/reports?${query.toString()}`, {
|
const response = await fetch(`${apiConfig.restUrl}/reports?${query.toString()}`, {
|
||||||
@@ -25,7 +31,7 @@ export const reportRepository = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await getResponseError(response, 'Falha ao buscar relatorios medicos.'))
|
throw new Error(await getResponseError(response, 'Falha ao buscar relatórios médicos.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -33,18 +39,28 @@ export const reportRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async create(uiData) {
|
async create(uiData) {
|
||||||
const response = await fetch(`${apiConfig.restUrl}/reports`, {
|
let lastResponse = null
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
|
||||||
body: JSON.stringify(reportMapper.toApi(uiData)),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
for (const payload of buildCreatePayloads(reportMapper.toApi(uiData))) {
|
||||||
throw new Error(await getResponseError(response, 'Falha ao criar relatorio medico.'))
|
const response = await fetch(`${apiConfig.restUrl}/reports`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return reportMapper.toUi(normalizeItem(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastResponse = response
|
||||||
|
|
||||||
|
if (response.status !== 400) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
throw new Error(await getResponseError(lastResponse, 'Falha ao criar relatório médico.'))
|
||||||
return reportMapper.toUi(normalizeItem(data))
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id, uiData) {
|
async update(id, uiData) {
|
||||||
@@ -55,10 +71,45 @@ export const reportRepository = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await getResponseError(response, 'Falha ao atualizar relatorio medico.'))
|
throw new Error(await getResponseError(response, 'Falha ao atualizar relatório médico.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return reportMapper.toUi(normalizeItem(data))
|
return reportMapper.toUi(normalizeItem(data))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCreatePayloads(payload) {
|
||||||
|
return uniquePayloads([
|
||||||
|
omitFields(payload, ['order_number', 'created_by', 'updated_by']),
|
||||||
|
omitFields(payload, ['order_number', 'created_by', 'updated_by', 'content_json']),
|
||||||
|
omitFields(payload, ['order_number', 'created_by', 'updated_by', 'content_json', 'hide_date', 'hide_signature', 'due_at']),
|
||||||
|
pickFields(payload, ['patient_id', 'status', 'exam', 'requested_by', 'cid_code', 'diagnosis', 'conclusion', 'content_html']),
|
||||||
|
payload,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitFields(payload, fields) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(payload).filter(([field]) => !fields.includes(field)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFields(payload, fields) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
fields
|
||||||
|
.filter((field) => payload[field] !== undefined)
|
||||||
|
.map((field) => [field, payload[field]]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniquePayloads(payloads) {
|
||||||
|
const seen = new Set()
|
||||||
|
|
||||||
|
return payloads.filter((payload) => {
|
||||||
|
const signature = JSON.stringify(payload)
|
||||||
|
if (seen.has(signature)) return false
|
||||||
|
seen.add(signature)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function fetchJsonWithFallback(requests, fallbackMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastError && !lastResponse) {
|
if (lastError && !lastResponse) {
|
||||||
throw new Error(lastError.message || fallbackMessage)
|
throw new Error(translateErrorMessage(lastError.message || fallbackMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(await getResponseError(lastResponse, fallbackMessage))
|
throw new Error(await getResponseError(lastResponse, fallbackMessage))
|
||||||
@@ -52,8 +52,58 @@ export function normalizeItem(data, keys = []) {
|
|||||||
export async function getResponseError(response, fallbackMessage) {
|
export async function getResponseError(response, fallbackMessage) {
|
||||||
if (!response) return fallbackMessage
|
if (!response) return fallbackMessage
|
||||||
|
|
||||||
const error = await response.json().catch(() => ({}))
|
const text = await response.text().catch(() => '')
|
||||||
return error.error_description || error.msg || error.message || error.error || fallbackMessage
|
const error = parseErrorBody(text)
|
||||||
|
const message = translateErrorMessage(
|
||||||
|
error.error_description ||
|
||||||
|
error.msg ||
|
||||||
|
error.message ||
|
||||||
|
error.error ||
|
||||||
|
error.details ||
|
||||||
|
error.hint ||
|
||||||
|
text ||
|
||||||
|
fallbackMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateErrorMessage(message) {
|
||||||
|
const rawMessage = String(message || '').trim()
|
||||||
|
const normalized = rawMessage.toLowerCase()
|
||||||
|
|
||||||
|
if (!rawMessage) return 'Erro inesperado.'
|
||||||
|
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.'],
|
||||||
|
[/invalid login credentials|invalid credentials/, 'E-mail ou senha inválidos.'],
|
||||||
|
[/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.'],
|
||||||
|
[/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.'],
|
||||||
|
[/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.'],
|
||||||
|
[/violates foreign key constraint/, 'Não foi possível salvar porque há um vínculo obrigatório ausente ou inválido.'],
|
||||||
|
[/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.'],
|
||||||
|
[/relation .* does not exist/, 'Recurso da API não encontrado.'],
|
||||||
|
[/function .* does not exist/, 'Endpoint da API não encontrado.'],
|
||||||
|
[/cors|preflight/, 'A API bloqueou a requisição por configuração de CORS.'],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [pattern, translation] of translations) {
|
||||||
|
if (pattern.test(normalized)) return translation
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPortugueseMessage(message) {
|
||||||
|
return /[ãõáéíóúâêôç]/i.test(message) ||
|
||||||
|
/\b(erro|falha|não|nao|usuário|usuario|senha|campo|obrigatório|obrigatorio|sessão|sessao)\b/i.test(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallback(response) {
|
function shouldFallback(response) {
|
||||||
@@ -72,3 +122,13 @@ async function parseJsonResponse(response) {
|
|||||||
return { message: text }
|
return { message: text }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseErrorBody(text) {
|
||||||
|
if (!text) return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return { message: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
export const settingsRepository = {
|
export const settingsRepository = {
|
||||||
getIntegrations() {
|
getIntegrations() {
|
||||||
return [
|
return [
|
||||||
['WhatsApp Business', 'Envio automatico de lembretes e confirmacoes', true, 'bg-emerald-500'],
|
['WhatsApp Business', 'Envio automático de lembretes e confirmações', true, 'bg-[#3b82f6]'],
|
||||||
['Google Calendar', 'Sincronizacao bidirecional de agenda', false, 'bg-blue-500'],
|
['Google Calendar', 'Sincronização bidirecional de agenda', false, 'bg-blue-500'],
|
||||||
['Stripe / PagSeguro', 'Pagamentos online e links de cobranca', true, 'bg-violet-500'],
|
['Stripe / PagSeguro', 'Pagamentos online e links de cobrança', true, 'bg-violet-500'],
|
||||||
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
|
['CFM - Conselho Federal de Medicina', 'Validação automática de CRM', false, 'bg-amber-500'],
|
||||||
['ANS - Planos de Saude', 'Integracao com tabela TUSS e convenios', false, 'bg-rose-500'],
|
['ANS - Planos de Saúde', 'Integração com tabela TUSS e convênios', false, 'bg-rose-500'],
|
||||||
['API de IA Preditiva', 'Score de absenteismo e predicao de faltas', true, 'bg-[#3b82f6]'],
|
['API de IA Preditiva', 'Score de absenteísmo e predição de faltas', true, 'bg-[#3b82f6]'],
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
getSections() {
|
getSections() {
|
||||||
return [
|
return [
|
||||||
{ id: 'aparencia', label: 'Aparencia', description: 'Tema, cores e exibicao', icon: 'palette' },
|
{ id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||||
{ id: 'notificacoes', label: 'Notificacoes', description: 'Alertas e lembretes', icon: 'bell' },
|
|
||||||
{ id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' },
|
{ id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' },
|
||||||
{ id: 'conta', label: 'Conta & Perfil', description: 'Informacoes pessoais', icon: 'user' },
|
{ id: 'dados', label: 'Dados & Backup', description: 'Exportação e backup', icon: 'database' },
|
||||||
{ id: 'integracoes', label: 'Integracoes', description: 'APIs e sistemas externos', icon: 'globe' },
|
|
||||||
{ id: 'dados', label: 'Dados & Backup', description: 'Exportacao e backup', icon: 'database' },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/repositories/userRepository.js
Normal file
121
src/repositories/userRepository.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||||
|
import { getResponseError, normalizeCollection } from './repositoryUtils.js'
|
||||||
|
|
||||||
|
const USER_PROFILE_TABLES = ['profiles', 'user_profiles']
|
||||||
|
const USER_LIST_KEYS = ['users', 'usuarios', 'data', 'items', 'results']
|
||||||
|
|
||||||
|
export const userRepository = {
|
||||||
|
async getAll() {
|
||||||
|
let lastResponse = null
|
||||||
|
|
||||||
|
for (const table of USER_PROFILE_TABLES) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
select: '*',
|
||||||
|
})
|
||||||
|
const response = await fetch(`${apiConfig.restUrl}/${table}?${query.toString()}`, {
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (!response) continue
|
||||||
|
lastResponse = response
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
return normalizeCollection(data, USER_LIST_KEYS).map(normalizeListedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (![404, 406].includes(response.status)) {
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao listar usuários.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(await getResponseError(lastResponse, 'Tabela de perfis de usuários não encontrada.'))
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(userId) {
|
||||||
|
const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
body: JSON.stringify({ user_id: userId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao buscar usuário.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data) {
|
||||||
|
const response = await fetch(`${apiConfig.functionsUrl}/create-user`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
body: JSON.stringify(buildCreateUserBody(data)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao criar usuário.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async createWithPassword(data) {
|
||||||
|
const body = {
|
||||||
|
...buildCreateUserBody(data),
|
||||||
|
password: data.password,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${apiConfig.functionsUrl}/create-user-with-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao criar usuário com senha.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(userId) {
|
||||||
|
const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
body: JSON.stringify({ user_id: userId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao deletar usuário.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreateUserBody(data) {
|
||||||
|
const body = {
|
||||||
|
email: data.email?.trim(),
|
||||||
|
full_name: data.full_name?.trim(),
|
||||||
|
phone: data.phone?.trim(),
|
||||||
|
cpf: data.cpf?.trim(),
|
||||||
|
role: data.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.create_patient_record) {
|
||||||
|
body.create_patient_record = true
|
||||||
|
body.phone_mobile = data.phone_mobile?.trim() || data.phone?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeListedUser(user) {
|
||||||
|
return {
|
||||||
|
...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 || ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export const visitRepository = {
|
|||||||
getStages() {
|
getStages() {
|
||||||
return [
|
return [
|
||||||
{ title: 'Triagem', description: 'Sinais vitais, queixa principal e alerta de risco antes da chamada medica.' },
|
{ title: 'Triagem', description: 'Sinais vitais, queixa principal e alerta de risco antes da chamada medica.' },
|
||||||
{ title: 'Atendimento medico', description: 'Consulta em andamento, conduta, prescricao e solicitacao de exames.' },
|
{ title: 'Atendimento médico', description: 'Consulta em andamento, conduta, prescrição e solicitação de exames.' },
|
||||||
{ title: 'Pos-consulta', description: 'Orientacoes finais, documentos emitidos e retorno sugerido pela equipe.' },
|
{ title: 'Pos-consulta', description: 'Orientacoes finais, documentos emitidos e retorno sugerido pela equipe.' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
27
src/utils/theme.js
Normal file
27
src/utils/theme.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export const THEME_STORAGE_KEY = 'mediconnect.theme'
|
||||||
|
|
||||||
|
export function getStoredTheme() {
|
||||||
|
if (typeof window === 'undefined') return 'dark'
|
||||||
|
|
||||||
|
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
return storedTheme === 'light' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
|
const normalizedTheme = theme === 'light' ? 'light' : 'dark'
|
||||||
|
document.documentElement.dataset.theme = normalizedTheme
|
||||||
|
document.documentElement.style.colorScheme = normalizedTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredTheme(theme) {
|
||||||
|
const normalizedTheme = theme === 'light' ? 'light' : 'dark'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, normalizedTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(normalizedTheme)
|
||||||
|
return normalizedTheme
|
||||||
|
}
|
||||||
8
vercel.json
Normal file
8
vercel.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"destination": "/index.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user