modified: index.html
modified: src/App.jsx modified: src/components/AppShell.jsx modified: src/components/featureStateStyles.js modified: src/config/permissions.js modified: src/hooks/useAgenda.js modified: src/mappers/reportMapper.js modified: src/pages/AgendaPage.jsx modified: src/pages/AnalyticsPage.jsx modified: src/pages/AuthPages.jsx modified: src/pages/HomePage.jsx modified: src/pages/MedicalRecordsPage.jsx modified: src/pages/MessagesPage.jsx modified: src/pages/PatientsPage.jsx modified: src/pages/ReportsPage.jsx modified: src/pages/SettingsPage.jsx deleted: src/pages/TeamPage.jsx modified: src/pages/UsersPage.jsx modified: src/repositories/availabilityRepository.js modified: src/repositories/patientRepository.js modified: src/repositories/professionalRepository.js modified: src/repositories/reportRepository.js modified: src/repositories/settingsRepository.js
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
33
src/App.jsx
33
src/App.jsx
@@ -15,11 +15,16 @@ 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 { 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 { isAuthenticated, role, loading: authLoading } = useAuth()
|
||||||
@@ -77,6 +82,12 @@ function App() {
|
|||||||
|
|
||||||
// Usuário autenticado mas sem permissão para a rota
|
// Usuário autenticado mas sem permissão para a rota
|
||||||
if (!role || !canAccess(role, location.pathname)) {
|
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 (
|
return (
|
||||||
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle="Sem acesso">
|
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle="Sem acesso">
|
||||||
<UnauthorizedPage navigate={navigate} />
|
<UnauthorizedPage navigate={navigate} />
|
||||||
@@ -151,7 +162,7 @@ function resolveRoute(pathname, navigate, role) {
|
|||||||
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,
|
||||||
}
|
}
|
||||||
@@ -167,8 +178,8 @@ function resolveRoute(pathname, navigate, role) {
|
|||||||
|
|
||||||
if (pathname === '/laudos') {
|
if (pathname === '/laudos') {
|
||||||
return {
|
return {
|
||||||
element: <ReportsPage navigate={navigate} />,
|
element: <ReportsPage navigate={navigate} role={role} />,
|
||||||
title: 'Relatórios médicos',
|
title: 'Relatórios',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +187,7 @@ function resolveRoute(pathname, navigate, role) {
|
|||||||
if (pathname === '/relatorios') {
|
if (pathname === '/relatorios') {
|
||||||
return {
|
return {
|
||||||
element: <AnalyticsPage />,
|
element: <AnalyticsPage />,
|
||||||
title: 'Relatórios',
|
title: 'Analytics',
|
||||||
withShell: true,
|
withShell: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,14 +209,6 @@ function resolveRoute(pathname, navigate, role) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/profissionais') {
|
|
||||||
return {
|
|
||||||
element: <TeamPage navigate={navigate} />,
|
|
||||||
title: 'Profissionais',
|
|
||||||
withShell: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === '/usuarios') {
|
if (pathname === '/usuarios') {
|
||||||
return {
|
return {
|
||||||
element: <UsersPage role={role} />,
|
element: <UsersPage role={role} />,
|
||||||
@@ -237,7 +240,7 @@ function resolveRoute(pathname, navigate, role) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -263,7 +266,7 @@ function PatientDetailRoute({ navigate, patientId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return patient ? (
|
return patient ? (
|
||||||
<PatientDetailPage navigate={navigate} patient={patient} />
|
<PatientDetailPage navigate={navigate} patient={patient} role={role} />
|
||||||
) : (
|
) : (
|
||||||
<NotFoundPage navigate={navigate} />
|
<NotFoundPage navigate={navigate} />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { ROLE_LABELS, ROLE_NAV_ITEMS } from '../config/permissions.js'
|
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'
|
||||||
|
|
||||||
@@ -10,15 +11,14 @@ const ALL_NAV_ITEMS = [
|
|||||||
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
||||||
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
||||||
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
|
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
|
||||||
{ href: '/laudos', label: 'Relatórios médicos', icon: 'clipboard' },
|
{ href: '/laudos', label: 'Relatórios', icon: 'clipboard' },
|
||||||
{
|
{
|
||||||
href: '/comunicacao',
|
href: '/comunicacao',
|
||||||
label: 'Comunicação',
|
label: 'Comunicação',
|
||||||
icon: 'message',
|
icon: 'message',
|
||||||
activePaths: ['/comunicacao', '/mensagens'],
|
activePaths: ['/comunicacao', '/mensagens'],
|
||||||
},
|
},
|
||||||
{ href: '/relatorios', label: 'Relatórios', icon: 'chart' },
|
{ href: '/relatorios', label: 'Analytics', icon: 'chart' },
|
||||||
{ href: '/profissionais', label: 'Profissionais', icon: 'users' },
|
|
||||||
{ href: '/usuarios', label: 'Usuários', icon: 'shield' },
|
{ href: '/usuarios', label: 'Usuários', icon: 'shield' },
|
||||||
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||||
]
|
]
|
||||||
@@ -29,13 +29,12 @@ const titles = {
|
|||||||
'/dashboard': 'Painel',
|
'/dashboard': 'Painel',
|
||||||
'/agenda': 'Agenda',
|
'/agenda': 'Agenda',
|
||||||
'/consultas': 'Consultas',
|
'/consultas': 'Consultas',
|
||||||
'/laudos': 'Relatórios médicos',
|
'/laudos': 'Relatórios',
|
||||||
'/pacientes': 'Pacientes',
|
'/pacientes': 'Pacientes',
|
||||||
'/prontuario': 'Prontuário',
|
'/prontuario': 'Prontuário',
|
||||||
'/comunicacao': 'Comunicação',
|
'/comunicacao': 'Comunicação',
|
||||||
'/mensagens': 'Comunicação',
|
'/mensagens': 'Comunicação',
|
||||||
'/relatorios': 'Relatórios',
|
'/relatorios': 'Analytics',
|
||||||
'/profissionais': 'Profissionais',
|
|
||||||
'/perfil': 'Perfil',
|
'/perfil': 'Perfil',
|
||||||
'/configuracoes': 'Configurações',
|
'/configuracoes': 'Configurações',
|
||||||
'/config': 'Configurações',
|
'/config': 'Configurações',
|
||||||
@@ -44,7 +43,8 @@ const titles = {
|
|||||||
|
|
||||||
export function AppShell({ children, currentPath, navigate, role, 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 [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
@@ -68,6 +68,22 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
)
|
)
|
||||||
}, [role])
|
}, [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
|
||||||
|
|
||||||
@@ -96,11 +112,33 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
}
|
}
|
||||||
}, [role])
|
}, [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
|
||||||
@@ -169,57 +207,132 @@ export function AppShell({ children, currentPath, navigate, role, 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 rápida"
|
|
||||||
className="h-[38px] w-full rounded-sm border border-[#404040] bg-[#303030] py-2 pl-10 pr-4 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
|
||||||
onChange={(event) => setQuickSearch(event.target.value)}
|
|
||||||
placeholder="Buscar paciente, prontuário..."
|
|
||||||
value={quickSearch}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="relative z-30">
|
||||||
<button
|
<button
|
||||||
|
aria-expanded={notificationsOpen}
|
||||||
|
aria-haspopup="menu"
|
||||||
aria-label="Notificações"
|
aria-label="Notificações"
|
||||||
className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]"
|
className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]"
|
||||||
|
onClick={() => {
|
||||||
|
setNotificationsOpen((open) => !open)
|
||||||
|
setProfileMenuOpen(false)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<BellIcon className="size-5" />
|
<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">
|
<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">
|
||||||
3
|
{mockNotifications.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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" />
|
||||||
|
|
||||||
|
<div className="relative z-30">
|
||||||
<button
|
<button
|
||||||
className="flex min-w-0 items-center gap-3 text-left"
|
aria-expanded={profileMenuOpen}
|
||||||
onClick={() => goTo('/perfil')}
|
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"
|
||||||
|
onClick={() => {
|
||||||
|
setProfileMenuOpen((open) => !open)
|
||||||
|
setNotificationsOpen(false)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]">
|
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]">
|
||||||
{getInitials(viewerProfile.name)}
|
{getInitials(viewerProfile.name)}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden min-w-0 sm:block">
|
<span className="hidden min-w-0 sm:block">
|
||||||
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
<span className="block max-w-40 truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
||||||
{viewerProfile.name}
|
{viewerProfile.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
<span className="mt-0.5 block max-w-40 truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
||||||
{viewerProfile.role}
|
{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>
|
||||||
</div>
|
|
||||||
</div>
|
{profileMenuOpen ? (
|
||||||
{quickSearch ? (
|
<div
|
||||||
<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">
|
aria-label="Menu do usuário"
|
||||||
Busca local ativa por <strong className="text-[#e5e5e5]">{quickSearch}</strong>.
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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">
|
||||||
@@ -362,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,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',
|
||||||
|
|||||||
@@ -40,30 +40,30 @@ const ROLE_ROUTES = {
|
|||||||
'/laudos',
|
'/laudos',
|
||||||
'/relatorios',
|
'/relatorios',
|
||||||
'/comunicacao', '/mensagens',
|
'/comunicacao', '/mensagens',
|
||||||
'/profissionais',
|
|
||||||
'/configuracoes', '/config',
|
'/configuracoes', '/config',
|
||||||
'/consultas',
|
'/consultas',
|
||||||
'/usuarios',
|
'/usuarios',
|
||||||
'/perfil',
|
'/perfil',
|
||||||
],
|
],
|
||||||
medico: [
|
medico: [
|
||||||
'/inicio', '/home', '/dashboard',
|
|
||||||
'/agenda',
|
'/agenda',
|
||||||
|
'/pacientes',
|
||||||
'/prontuario',
|
'/prontuario',
|
||||||
'/laudos',
|
'/laudos',
|
||||||
'/comunicacao', '/mensagens',
|
'/comunicacao', '/mensagens',
|
||||||
'/relatorios',
|
'/configuracoes', '/config',
|
||||||
'/perfil',
|
'/perfil',
|
||||||
],
|
],
|
||||||
secretaria: [
|
secretaria: [
|
||||||
'/inicio', '/home', '/dashboard',
|
|
||||||
'/agenda',
|
'/agenda',
|
||||||
'/pacientes',
|
'/pacientes',
|
||||||
'/comunicacao', '/mensagens',
|
'/comunicacao', '/mensagens',
|
||||||
|
'/configuracoes', '/config',
|
||||||
'/perfil',
|
'/perfil',
|
||||||
],
|
],
|
||||||
paciente: [
|
paciente: [
|
||||||
'/inicio', '/home', '/dashboard',
|
'/inicio', '/home', '/dashboard',
|
||||||
|
'/configuracoes', '/config',
|
||||||
'/perfil',
|
'/perfil',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -91,7 +91,7 @@ export const ROLE_CAPABILITIES = {
|
|||||||
medico: {
|
medico: {
|
||||||
manageUsers: false,
|
manageUsers: false,
|
||||||
hardDeletePatients: false,
|
hardDeletePatients: false,
|
||||||
accessSettings: false,
|
accessSettings: true,
|
||||||
ownAppointmentsOnly: true,
|
ownAppointmentsOnly: true,
|
||||||
canEditPatients: false,
|
canEditPatients: false,
|
||||||
canViewReports: true,
|
canViewReports: true,
|
||||||
@@ -100,7 +100,7 @@ export const ROLE_CAPABILITIES = {
|
|||||||
secretaria: {
|
secretaria: {
|
||||||
manageUsers: false,
|
manageUsers: false,
|
||||||
hardDeletePatients: false,
|
hardDeletePatients: false,
|
||||||
accessSettings: false,
|
accessSettings: true,
|
||||||
ownAppointmentsOnly: false,
|
ownAppointmentsOnly: false,
|
||||||
canEditPatients: true,
|
canEditPatients: true,
|
||||||
canViewReports: false,
|
canViewReports: false,
|
||||||
@@ -109,7 +109,7 @@ export const ROLE_CAPABILITIES = {
|
|||||||
paciente: {
|
paciente: {
|
||||||
manageUsers: false,
|
manageUsers: false,
|
||||||
hardDeletePatients: false,
|
hardDeletePatients: false,
|
||||||
accessSettings: false,
|
accessSettings: true,
|
||||||
ownAppointmentsOnly: false,
|
ownAppointmentsOnly: false,
|
||||||
canEditPatients: false,
|
canEditPatients: false,
|
||||||
canViewReports: false,
|
canViewReports: false,
|
||||||
@@ -124,10 +124,9 @@ export const ROLE_NAV_ITEMS = {
|
|||||||
{ path: '/agenda', label: 'Agenda' },
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
{ path: '/pacientes', label: 'Pacientes' },
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
{ path: '/prontuario', label: 'Prontuário' },
|
{ path: '/prontuario', label: 'Prontuário' },
|
||||||
{ path: '/laudos', label: 'Laudos' },
|
{ path: '/laudos', label: 'Relatórios' },
|
||||||
{ path: '/relatorios', label: 'Relatórios' },
|
{ path: '/relatorios', label: 'Analytics' },
|
||||||
{ path: '/comunicacao', label: 'Comunicação' },
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
{ path: '/profissionais', label: 'Profissionais' },
|
|
||||||
{ path: '/usuarios', label: 'Usuários' },
|
{ path: '/usuarios', label: 'Usuários' },
|
||||||
{ path: '/configuracoes', label: 'Configurações' },
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
],
|
],
|
||||||
@@ -136,29 +135,29 @@ export const ROLE_NAV_ITEMS = {
|
|||||||
{ path: '/agenda', label: 'Agenda' },
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
{ path: '/pacientes', label: 'Pacientes' },
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
{ path: '/prontuario', label: 'Prontuário' },
|
{ path: '/prontuario', label: 'Prontuário' },
|
||||||
{ path: '/laudos', label: 'Laudos' },
|
{ path: '/laudos', label: 'Relatórios' },
|
||||||
{ path: '/relatorios', label: 'Relatórios' },
|
{ path: '/relatorios', label: 'Analytics' },
|
||||||
{ path: '/comunicacao', label: 'Comunicação' },
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
{ path: '/profissionais', label: 'Profissionais' },
|
|
||||||
{ path: '/usuarios', label: 'Usuários' },
|
{ path: '/usuarios', label: 'Usuários' },
|
||||||
{ path: '/configuracoes', label: 'Configurações' },
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
],
|
],
|
||||||
medico: [
|
medico: [
|
||||||
{ path: '/inicio', label: 'Painel' },
|
|
||||||
{ path: '/agenda', label: 'Agenda' },
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
{ path: '/prontuario', label: 'Prontuário' },
|
{ path: '/prontuario', label: 'Prontuário' },
|
||||||
{ path: '/laudos', label: 'Laudos' },
|
{ path: '/laudos', label: 'Relatórios' },
|
||||||
{ path: '/comunicacao', label: 'Comunicação' },
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
{ path: '/relatorios', label: 'Relatórios' },
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
],
|
],
|
||||||
secretaria: [
|
secretaria: [
|
||||||
{ path: '/inicio', label: 'Painel' },
|
|
||||||
{ path: '/agenda', label: 'Agenda' },
|
{ path: '/agenda', label: 'Agenda' },
|
||||||
{ path: '/pacientes', label: 'Pacientes' },
|
{ path: '/pacientes', label: 'Pacientes' },
|
||||||
{ path: '/comunicacao', label: 'Comunicação' },
|
{ path: '/comunicacao', label: 'Comunicação' },
|
||||||
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
],
|
],
|
||||||
paciente: [
|
paciente: [
|
||||||
{ path: '/inicio', label: 'Painel' },
|
{ path: '/inicio', label: 'Painel' },
|
||||||
|
{ path: '/configuracoes', label: 'Configurações' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export function useAgenda() {
|
|||||||
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 [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -53,7 +56,7 @@ export function useAgenda() {
|
|||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
||||||
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global'
|
||||||
const resolvedProfessional = resolveCurrentProfessional(currentProfile, professionalsData)
|
const resolvedProfessional = professionalRepository.resolveCurrentProfessional(currentProfile, professionalsData)
|
||||||
const initialProfessionalId =
|
const initialProfessionalId =
|
||||||
agendaScope === 'doctor'
|
agendaScope === 'doctor'
|
||||||
? resolvedProfessional?.id || ''
|
? resolvedProfessional?.id || ''
|
||||||
@@ -128,6 +131,7 @@ export function useAgenda() {
|
|||||||
const slots = await availabilityRepository.getAvailableSlots({
|
const slots = await availabilityRepository.getAvailableSlots({
|
||||||
doctorId: targetProfessionalId,
|
doctorId: targetProfessionalId,
|
||||||
date: formatLocalDateInput(baseDate),
|
date: formatLocalDateInput(baseDate),
|
||||||
|
appointmentType: form.mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!active) return
|
if (!active) return
|
||||||
@@ -156,7 +160,7 @@ export function useAgenda() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [agendaScope, baseDate, currentProfessional?.id, form.professionalId, modalOpen])
|
}, [agendaScope, baseDate, currentProfessional?.id, form.mode, form.professionalId, modalOpen])
|
||||||
|
|
||||||
const visibleAppointments = useMemo(() => {
|
const visibleAppointments = useMemo(() => {
|
||||||
let filtered = localAppointments
|
let filtered = localAppointments
|
||||||
@@ -165,6 +169,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
|
||||||
@@ -177,7 +205,7 @@ export function useAgenda() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortAppointmentsByTime(filtered)
|
return sortAppointmentsByTime(filtered)
|
||||||
}, [localAppointments, status, activeView, baseDate])
|
}, [localAppointments, status, agendaScope, doctorFilter, doctorSearch, unitFilter, professionals, activeView, baseDate])
|
||||||
|
|
||||||
function updateForm(field, value) {
|
function updateForm(field, value) {
|
||||||
setForm((current) => ({ ...current, [field]: value }))
|
setForm((current) => ({ ...current, [field]: value }))
|
||||||
@@ -230,6 +258,12 @@ export function useAgenda() {
|
|||||||
setBaseDate,
|
setBaseDate,
|
||||||
status,
|
status,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
doctorFilter,
|
||||||
|
setDoctorFilter,
|
||||||
|
doctorSearch,
|
||||||
|
setDoctorSearch,
|
||||||
|
unitFilter,
|
||||||
|
setUnitFilter,
|
||||||
modalOpen,
|
modalOpen,
|
||||||
setModalOpen,
|
setModalOpen,
|
||||||
form,
|
form,
|
||||||
@@ -242,20 +276,6 @@ export function useAgenda() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -43,11 +43,16 @@ export const reportMapper = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
startOfWeek,
|
startOfWeek,
|
||||||
} 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 { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||||
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.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' },
|
||||||
@@ -30,6 +32,8 @@ const viewFilters = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function AgendaPage({ navigate }) {
|
export function AgendaPage({ navigate }) {
|
||||||
|
const [modalPatientSearch, setModalPatientSearch] = useState('')
|
||||||
|
const [modalDoctorSearch, setModalDoctorSearch] = useState('')
|
||||||
const {
|
const {
|
||||||
patients,
|
patients,
|
||||||
professionals,
|
professionals,
|
||||||
@@ -45,6 +49,11 @@ export function AgendaPage({ navigate }) {
|
|||||||
setBaseDate,
|
setBaseDate,
|
||||||
status,
|
status,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
setDoctorFilter,
|
||||||
|
doctorSearch,
|
||||||
|
setDoctorSearch,
|
||||||
|
unitFilter,
|
||||||
|
setUnitFilter,
|
||||||
modalOpen,
|
modalOpen,
|
||||||
setModalOpen,
|
setModalOpen,
|
||||||
form,
|
form,
|
||||||
@@ -67,6 +76,37 @@ 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 = (() => {
|
||||||
|
const query = modalPatientSearch.trim().toLowerCase()
|
||||||
|
if (!query) return patients
|
||||||
|
|
||||||
|
return patients.filter((patient) =>
|
||||||
|
[patient.name, patient.full_name, patient.nome, patient.cpf, patient.email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
const filteredProfessionals = (() => {
|
||||||
|
const query = modalDoctorSearch.trim().toLowerCase()
|
||||||
|
if (!query) return professionals
|
||||||
|
|
||||||
|
return professionals.filter((professional) =>
|
||||||
|
[professional.name, professional.email, professional.unit]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
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]">
|
||||||
@@ -76,9 +116,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>
|
||||||
|
|
||||||
@@ -126,7 +164,7 @@ export function AgendaPage({ navigate }) {
|
|||||||
onClick={() => setModalOpen(true)}
|
onClick={() => setModalOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
+ Nova consulta
|
+ Novo agendamento
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -174,7 +212,8 @@ 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">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
{statusFilters.map((filter) => (
|
{statusFilters.map((filter) => (
|
||||||
<button
|
<button
|
||||||
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
@@ -191,9 +230,43 @@ export function AgendaPage({ navigate }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{!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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -229,15 +302,34 @@ export function AgendaPage({ navigate }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
|
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Novo agendamento">
|
||||||
<form className="grid gap-4" onSubmit={handleCreate}>
|
<form className="grid gap-4" onSubmit={handleCreate}>
|
||||||
|
<DarkField label="Dia do agendamento">
|
||||||
|
<input
|
||||||
|
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none [color-scheme:dark] focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => {
|
||||||
|
const parsedDate = parseLocalDate(event.target.value)
|
||||||
|
if (parsedDate) setBaseDate(parsedDate)
|
||||||
|
}}
|
||||||
|
type="date"
|
||||||
|
value={formatLocalDateInput(baseDate)}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Paciente">
|
<DarkField label="Paciente">
|
||||||
|
<input
|
||||||
|
className="mb-2 h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => setModalPatientSearch(event.target.value)}
|
||||||
|
placeholder="Pesquisar paciente"
|
||||||
|
type="search"
|
||||||
|
value={modalPatientSearch}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
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-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
onChange={(event) => updateForm('patientId', event.target.value)}
|
onChange={(event) => updateForm('patientId', event.target.value)}
|
||||||
value={form.patientId}
|
value={form.patientId}
|
||||||
>
|
>
|
||||||
{patients.map((patient) => (
|
{filteredPatients.map((patient) => (
|
||||||
<option key={patient.id} value={patient.id}>
|
<option key={patient.id} value={patient.id}>
|
||||||
{patient.name || patient.full_name || patient.nome}
|
{patient.name || patient.full_name || patient.nome}
|
||||||
</option>
|
</option>
|
||||||
@@ -295,17 +387,26 @@ export function AgendaPage({ navigate }) {
|
|||||||
value={currentProfessional?.name || 'Médico não vinculado'}
|
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="mb-2 h-10 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => setModalDoctorSearch(event.target.value)}
|
||||||
|
placeholder="Pesquisar médico"
|
||||||
|
type="search"
|
||||||
|
value={modalDoctorSearch}
|
||||||
|
/>
|
||||||
<select
|
<select
|
||||||
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-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
onChange={(event) => updateForm('professionalId', event.target.value)}
|
onChange={(event) => updateForm('professionalId', event.target.value)}
|
||||||
value={form.professionalId}
|
value={form.professionalId}
|
||||||
>
|
>
|
||||||
{professionals.map((professional) => (
|
{filteredProfessionals.map((professional) => (
|
||||||
<option key={professional.id} value={professional.id}>
|
<option key={professional.id} value={professional.id}>
|
||||||
{professional.name}
|
{professional.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
@@ -330,7 +431,7 @@ export function AgendaPage({ navigate }) {
|
|||||||
disabled={!canCreateAppointment}
|
disabled={!canCreateAppointment}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
Salvar consulta
|
Salvar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { BrandLogo } from '../components/Brand.jsx'
|
|||||||
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
|
import { FeatureBadge, 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('')
|
||||||
|
|
||||||
@@ -152,23 +160,46 @@ export function LoginPage({ navigate }) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-4 right-4">
|
||||||
|
{credentialsOpen ? (
|
||||||
|
<div className="mb-2 w-[292px] rounded-md border border-white/10 bg-[#0f1b2d] p-2 shadow-2xl">
|
||||||
|
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-white/40">
|
||||||
|
Credenciais mockadas
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{mockCredentials.map((credential) => (
|
||||||
<button
|
<button
|
||||||
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"
|
className="rounded px-2 py-2 text-left text-xs text-white/70 transition hover:bg-white/10 hover:text-white"
|
||||||
|
key={credential.email}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setForm({
|
setForm({
|
||||||
email: 'recepcao@mediconnect.com',
|
email: credential.email,
|
||||||
password: 'demo123',
|
password: credential.password,
|
||||||
})
|
})
|
||||||
|
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="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"
|
||||||
|
onClick={() => setCredentialsOpen((current) => !current)}
|
||||||
title="Preencher credenciais mockadas"
|
title="Preencher credenciais mockadas"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
dev · credenciais
|
dev · credenciais
|
||||||
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" />
|
<FeatureBadge className="border-white/20 bg-white/10 text-white/70" status="mock" text="mock" />
|
||||||
<span aria-hidden="true" className="text-[9px]">
|
<span aria-hidden="true" className="text-[9px]">
|
||||||
^
|
{credentialsOpen ? 'v' : '^'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -32,13 +32,6 @@ export function HomePage({ navigate }) {
|
|||||||
>
|
>
|
||||||
Exportar
|
Exportar
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed]"
|
|
||||||
onClick={() => navigate('/agenda')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
+ Novo
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -120,7 +113,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">
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function MedicalRecordsPage() {
|
|||||||
const recordTypes = medicalRecordRepository.getRecordTypes()
|
const recordTypes = medicalRecordRepository.getRecordTypes()
|
||||||
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
|
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filterType, setFilterType] = useState('')
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
|
||||||
const filteredRecords = useMemo(() => {
|
const filteredRecords = useMemo(() => {
|
||||||
@@ -22,11 +21,9 @@ export function MedicalRecordsPage() {
|
|||||||
.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 +64,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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { normalizeRole } from '../config/permissions.js'
|
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' },
|
||||||
@@ -20,6 +21,7 @@ const statusConfig = {
|
|||||||
|
|
||||||
|
|
||||||
const emptyMessage = {
|
const emptyMessage = {
|
||||||
|
patientId: '',
|
||||||
patient: '',
|
patient: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
channel: 'whatsapp',
|
channel: 'whatsapp',
|
||||||
@@ -51,17 +53,47 @@ export function MessagesPage({ role }) {
|
|||||||
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(
|
const availableTemplates = useMemo(
|
||||||
() => templates.filter((template) => allowedChannelKeys.includes(template.channel)),
|
() => templates.filter((template) => allowedChannelKeys.includes(template.channel)),
|
||||||
[allowedChannelKeys, templates],
|
[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(
|
||||||
() =>
|
() =>
|
||||||
@@ -95,6 +127,7 @@ export function MessagesPage({ role }) {
|
|||||||
if (!allowedChannelKeys.includes(template.channel)) return
|
if (!allowedChannelKeys.includes(template.channel)) return
|
||||||
|
|
||||||
setComposer({
|
setComposer({
|
||||||
|
patientId: '',
|
||||||
patient: '',
|
patient: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
channel: template.channel,
|
channel: template.channel,
|
||||||
@@ -104,6 +137,26 @@ export function MessagesPage({ role }) {
|
|||||||
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()
|
||||||
|
|
||||||
@@ -160,16 +213,20 @@ export function MessagesPage({ role }) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -309,7 +366,7 @@ export function MessagesPage({ role }) {
|
|||||||
<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" />
|
||||||
@@ -319,7 +376,7 @@ export function MessagesPage({ role }) {
|
|||||||
|
|
||||||
<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">
|
||||||
{availableTemplates.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>
|
||||||
@@ -347,6 +404,7 @@ export function MessagesPage({ role }) {
|
|||||||
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',
|
||||||
@@ -389,6 +447,7 @@ export function MessagesPage({ role }) {
|
|||||||
setComposer(emptyMessage)
|
setComposer(emptyMessage)
|
||||||
}}
|
}}
|
||||||
onSubmit={submitMessage}
|
onSubmit={submitMessage}
|
||||||
|
patients={patientOptions}
|
||||||
templates={availableTemplates}
|
templates={availableTemplates}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -401,8 +460,10 @@ export function MessagesPage({ role }) {
|
|||||||
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>
|
||||||
@@ -444,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 (
|
||||||
@@ -463,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
|
||||||
@@ -479,11 +541,35 @@ function TemplateCard({ onUse, template }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmit, templates }) {
|
function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmit, patients, templates }) {
|
||||||
|
const [patientSearch, setPatientSearch] = useState('')
|
||||||
|
const filteredPatients = useMemo(() => {
|
||||||
|
const query = patientSearch.trim().toLowerCase()
|
||||||
|
if (!query) return patients
|
||||||
|
|
||||||
|
return patients.filter((patient) =>
|
||||||
|
[patient.name, patient.phone, patient.document]
|
||||||
|
.join(' ')
|
||||||
|
.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(patientId) {
|
||||||
|
const patient = patients.find((item) => item.id === patientId)
|
||||||
|
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
patientId,
|
||||||
|
patient: patient?.name || '',
|
||||||
|
phone: patient?.phone || current.phone,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function applyTemplate(templateName) {
|
function applyTemplate(templateName) {
|
||||||
const template = templates.find((item) => item.name === templateName)
|
const template = templates.find((item) => item.name === templateName)
|
||||||
|
|
||||||
@@ -505,10 +591,36 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
|||||||
<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="Paciente">
|
<DarkField label="Paciente">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => setPatientSearch(event.target.value)}
|
||||||
|
placeholder="Digite nome, CPF ou telefone"
|
||||||
|
type="search"
|
||||||
|
value={patientSearch}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Selecionar paciente">
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => selectPatient(event.target.value)}
|
||||||
|
value={draft.patientId}
|
||||||
|
>
|
||||||
|
<option value="">Selecione um paciente</option>
|
||||||
|
{filteredPatients.map((patient) => (
|
||||||
|
<option key={patient.id} value={patient.id}>
|
||||||
|
{patient.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<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>
|
||||||
@@ -547,7 +659,7 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
|||||||
<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>
|
||||||
@@ -569,13 +681,13 @@ function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateEditor({ allowedChannelKeys, 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">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import { hasCapability } from '../config/permissions.js'
|
import { hasCapability, normalizeRole } from '../config/permissions.js'
|
||||||
import { patientRepository } from '../repositories/patientRepository.js'
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
const ITEMS_PER_PAGE = 25
|
const ITEMS_PER_PAGE = 25
|
||||||
|
|
||||||
const darkInput =
|
const darkInput =
|
||||||
@@ -15,6 +17,38 @@ const patientTabs = [
|
|||||||
{ label: 'Documentos', value: 'documentos' },
|
{ label: 'Documentos', value: 'documentos' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const BRAZILIAN_STATES = [
|
||||||
|
{ value: 'AC', label: 'Acre' },
|
||||||
|
{ value: 'AL', label: 'Alagoas' },
|
||||||
|
{ value: 'AP', label: 'Amapá' },
|
||||||
|
{ value: 'AM', label: 'Amazonas' },
|
||||||
|
{ value: 'BA', label: 'Bahia' },
|
||||||
|
{ value: 'CE', label: 'Ceará' },
|
||||||
|
{ value: 'DF', label: 'Distrito Federal' },
|
||||||
|
{ value: 'ES', label: 'Espírito Santo' },
|
||||||
|
{ value: 'GO', label: 'Goiás' },
|
||||||
|
{ value: 'MA', label: 'Maranhão' },
|
||||||
|
{ value: 'MT', label: 'Mato Grosso' },
|
||||||
|
{ value: 'MS', label: 'Mato Grosso do Sul' },
|
||||||
|
{ value: 'MG', label: 'Minas Gerais' },
|
||||||
|
{ value: 'PA', label: 'Pará' },
|
||||||
|
{ value: 'PB', label: 'Paraíba' },
|
||||||
|
{ value: 'PR', label: 'Paraná' },
|
||||||
|
{ value: 'PE', label: 'Pernambuco' },
|
||||||
|
{ value: 'PI', label: 'Piauí' },
|
||||||
|
{ value: 'RJ', label: 'Rio de Janeiro' },
|
||||||
|
{ value: 'RN', label: 'Rio Grande do Norte' },
|
||||||
|
{ value: 'RS', label: 'Rio Grande do Sul' },
|
||||||
|
{ value: 'RO', label: 'Rondônia' },
|
||||||
|
{ value: 'RR', label: 'Roraima' },
|
||||||
|
{ value: 'SC', label: 'Santa Catarina' },
|
||||||
|
{ value: 'SP', label: 'São Paulo' },
|
||||||
|
{ value: 'SE', label: 'Sergipe' },
|
||||||
|
{ value: 'TO', label: 'Tocantins' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const INSURANCE_OPTIONS = ['Unimed', 'Bradesco Saúde', 'Amil']
|
||||||
|
|
||||||
export function PatientsPage({ navigate, role }) {
|
export function PatientsPage({ navigate, role }) {
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -36,22 +70,29 @@ export function PatientsPage({ navigate, role }) {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
buildPatientRows()
|
buildPatientRows(role)
|
||||||
.then((data) => setRows(data))
|
.then((data) => setRows(data))
|
||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [role])
|
||||||
|
|
||||||
const editingPatient = rows.find((patient) => patient.id === editingId)
|
const editingPatient = rows.find((patient) => patient.id === editingId)
|
||||||
const insuranceOptions = useMemo(() => [...new Set(rows.map((patient) => patient.insurance).filter(Boolean))], [rows])
|
|
||||||
const stateOptions = useMemo(() => [...new Set(rows.map((patient) => patient.state).filter(Boolean))], [rows])
|
|
||||||
const hasAdvancedFilters = city || state || ageMin || ageMax || lastVisitSince
|
const hasAdvancedFilters = city || state || ageMin || ageMax || lastVisitSince
|
||||||
const canEditPatients = hasCapability(role, 'canEditPatients')
|
const canEditPatients = hasCapability(role, 'canEditPatients')
|
||||||
const canHardDeletePatients = hasCapability(role, 'hardDeletePatients')
|
|
||||||
|
|
||||||
const filteredPatients = useMemo(() => {
|
const filteredPatients = useMemo(() => {
|
||||||
return rows.filter((patient) => {
|
return rows.filter((patient) => {
|
||||||
const haystack = [patient.name, patient.cpf, patient.document, patient.insurance, patient.phone]
|
const haystack = [
|
||||||
|
patient.name,
|
||||||
|
patient.cpf,
|
||||||
|
patient.document,
|
||||||
|
patient.insurance,
|
||||||
|
patient.phone,
|
||||||
|
patient.email,
|
||||||
|
patient.city,
|
||||||
|
patient.state,
|
||||||
|
patient.motherName,
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -60,7 +101,7 @@ export function PatientsPage({ navigate, role }) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (insurance && patient.insurance !== insurance) {
|
if (insurance && normalizeFilterValue(patient.insurance) !== normalizeFilterValue(insurance)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,15 +113,16 @@ export function PatientsPage({ navigate, role }) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (birthday === 'Hoje' && patient.birthday !== '07/04') {
|
const patientBirthday = getPatientBirthday(patient)
|
||||||
|
if (birthday === 'Hoje' && patientBirthday !== getTodayBirthday()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (birthday === 'Neste mes' && !patient.birthday?.endsWith('/04')) {
|
if (birthday === 'Neste mês' && !patientBirthday.endsWith(`/${getCurrentMonth()}`)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (city && !patient.city.toLowerCase().includes(city.toLowerCase())) {
|
if (city && !String(patient.city || '').toLowerCase().includes(city.toLowerCase())) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,15 +130,16 @@ export function PatientsPage({ navigate, role }) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ageMin && patient.age < Number(ageMin)) {
|
const patientAge = Number(patient.age) || 0
|
||||||
|
if (ageMin && patientAge < Number(ageMin)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ageMax && patient.age > Number(ageMax)) {
|
if (ageMax && patientAge > Number(ageMax)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastVisitSince && patient.lastVisitIso && patient.lastVisitIso < lastVisitSince) {
|
if (lastVisitSince && (!patient.lastVisitIso || patient.lastVisitIso < lastVisitSince)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,24 +207,6 @@ export function PatientsPage({ navigate, role }) {
|
|||||||
setView('list')
|
setView('list')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePatient(patientId) {
|
|
||||||
if (!canHardDeletePatients) {
|
|
||||||
window.alert('Você não tem permissão para excluir pacientes.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.confirm('Tem certeza que deseja excluir este paciente?')) {
|
|
||||||
try {
|
|
||||||
await patientRepository.remove(patientId)
|
|
||||||
setRows((currentRows) => currentRows.filter((patient) => patient.id !== patientId))
|
|
||||||
} catch (err) {
|
|
||||||
window.alert(`Erro ao excluir paciente: ${err.message}`)
|
|
||||||
}
|
|
||||||
setOpenMenuId(null)
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(patient) {
|
function openDetail(patient) {
|
||||||
setOpenMenuId(null)
|
setOpenMenuId(null)
|
||||||
if (patient.detailId) {
|
if (patient.detailId) {
|
||||||
@@ -258,7 +283,7 @@ async function deletePatient(patientId) {
|
|||||||
setInsurance(value)
|
setInsurance(value)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
options={insuranceOptions}
|
options={INSURANCE_OPTIONS}
|
||||||
value={insurance}
|
value={insurance}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -282,7 +307,7 @@ async function deletePatient(patientId) {
|
|||||||
setBirthday(value)
|
setBirthday(value)
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}}
|
}}
|
||||||
options={['Hoje', 'Neste mes']}
|
options={['Hoje', 'Neste mês']}
|
||||||
value={birthday}
|
value={birthday}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -357,7 +382,10 @@ async function deletePatient(patientId) {
|
|||||||
<button
|
<button
|
||||||
aria-label={`Ações de ${patient.name}`}
|
aria-label={`Ações de ${patient.name}`}
|
||||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
|
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
|
||||||
onClick={() => setOpenMenuId(openMenuId === patient.id ? null : patient.id)}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setOpenMenuId(openMenuId === patient.id ? null : patient.id)
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<PatientIcon className="size-5" name="more" />
|
<PatientIcon className="size-5" name="more" />
|
||||||
@@ -366,11 +394,11 @@ async function deletePatient(patientId) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
aria-label="Fechar menu"
|
aria-label="Fechar menu"
|
||||||
className="fixed inset-0 z-10 cursor-default"
|
className="fixed inset-0 z-40 cursor-default"
|
||||||
onClick={() => setOpenMenuId(null)}
|
onClick={() => setOpenMenuId(null)}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-8 top-10 z-20 w-48 rounded-lg border border-[#404040] bg-[#303030] py-1 text-left shadow-lg">
|
<div className="absolute right-4 top-12 z-50 w-48 rounded-lg border border-[#404040] bg-[#303030] py-1 text-left shadow-lg">
|
||||||
<ActionItem icon="file" label="Ver detalhes" onClick={() => openDetail(patient)} />
|
<ActionItem icon="file" label="Ver detalhes" onClick={() => openDetail(patient)} />
|
||||||
{canEditPatients ? <ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} /> : null}
|
{canEditPatients ? <ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} /> : null}
|
||||||
<ActionItem
|
<ActionItem
|
||||||
@@ -381,9 +409,6 @@ async function deletePatient(patientId) {
|
|||||||
navigate('/agenda')
|
navigate('/agenda')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{canHardDeletePatients ? (
|
|
||||||
<ActionItem danger icon="trash" label="Excluir" onClick={() => deletePatient(patient.id)} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -449,7 +474,7 @@ async function deletePatient(patientId) {
|
|||||||
setLastVisitSince={setLastVisitSince}
|
setLastVisitSince={setLastVisitSince}
|
||||||
setState={setState}
|
setState={setState}
|
||||||
state={state}
|
state={state}
|
||||||
stateOptions={stateOptions}
|
stateOptions={BRAZILIAN_STATES}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -462,8 +487,18 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
detailId: patient?.detailId || null,
|
detailId: patient?.detailId || null,
|
||||||
name: patient?.name || '',
|
name: patient?.name || '',
|
||||||
cpf: patient?.cpf || '',
|
cpf: patient?.cpf || '',
|
||||||
|
birthDate: patient?.birthDate || patient?.birth_date || '',
|
||||||
|
motherName: patient?.motherName || patient?.mother_name || '',
|
||||||
|
fatherName: patient?.fatherName || patient?.father_name || '',
|
||||||
|
ethnicity: patient?.ethnicity || '',
|
||||||
|
maritalStatus: patient?.maritalStatus || patient?.marital_status || '',
|
||||||
phone: patient?.phone || '',
|
phone: patient?.phone || '',
|
||||||
|
phoneSecondary: patient?.phoneSecondary || patient?.phone_secondary || '',
|
||||||
email: patient?.email || '',
|
email: patient?.email || '',
|
||||||
|
zipCode: patient?.zipCode || patient?.zip_code || '',
|
||||||
|
addressStreet: patient?.addressStreet || patient?.address_street || patient?.address || '',
|
||||||
|
addressNumber: patient?.addressNumber || patient?.address_number || '',
|
||||||
|
addressComplement: patient?.addressComplement || patient?.address_complement || '',
|
||||||
city: patient?.city || '',
|
city: patient?.city || '',
|
||||||
state: patient?.state || '',
|
state: patient?.state || '',
|
||||||
insurance: patient?.insurance || '',
|
insurance: patient?.insurance || '',
|
||||||
@@ -471,12 +506,14 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
age: patient?.age || '',
|
age: patient?.age || '',
|
||||||
condition: patient?.condition || '',
|
condition: patient?.condition || '',
|
||||||
birthday: patient?.birthday || '',
|
birthday: patient?.birthday || '',
|
||||||
|
notesText: patient?.notesText || patient?.notes_text || '',
|
||||||
vip: Boolean(patient?.vip),
|
vip: Boolean(patient?.vip),
|
||||||
lastVisit: patient?.lastVisit || null,
|
lastVisit: patient?.lastVisit || null,
|
||||||
nextVisit: patient?.nextVisit || null,
|
nextVisit: patient?.nextVisit || null,
|
||||||
lastVisitIso: patient?.lastVisitIso || null,
|
lastVisitIso: patient?.lastVisitIso || null,
|
||||||
}))
|
}))
|
||||||
const [attachmentsOpen, setAttachmentsOpen] = useState(false)
|
const [attachmentsOpen, setAttachmentsOpen] = useState(false)
|
||||||
|
const isNewPatient = !patient
|
||||||
|
|
||||||
function handleChange(event) {
|
function handleChange(event) {
|
||||||
const { checked, name, type, value } = event.target
|
const { checked, name, type, value } = event.target
|
||||||
@@ -490,6 +527,14 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
nextValue = maskPhone(value)
|
nextValue = maskPhone(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === 'phoneSecondary') {
|
||||||
|
nextValue = maskPhone(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'zipCode') {
|
||||||
|
nextValue = maskCEP(value)
|
||||||
|
}
|
||||||
|
|
||||||
setFormData((currentData) => ({ ...currentData, [name]: nextValue }))
|
setFormData((currentData) => ({ ...currentData, [name]: nextValue }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,19 +546,46 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requiredFields = [
|
||||||
|
['cpf', 'CPF'],
|
||||||
|
['age', 'idade'],
|
||||||
|
['birthDate', 'data de nascimento'],
|
||||||
|
['motherName', 'nome da mãe'],
|
||||||
|
['email', 'email'],
|
||||||
|
['phone', 'celular'],
|
||||||
|
['zipCode', 'CEP'],
|
||||||
|
['addressStreet', 'endereço'],
|
||||||
|
['addressNumber', 'número'],
|
||||||
|
['city', 'cidade'],
|
||||||
|
['state', 'estado'],
|
||||||
|
['plan', 'plano'],
|
||||||
|
]
|
||||||
|
if (isNewPatient) {
|
||||||
|
const missingFields = requiredFields
|
||||||
|
.filter(([field]) => !String(formData[field] || '').trim())
|
||||||
|
.map(([, label]) => label)
|
||||||
|
|
||||||
|
if (missingFields.length) {
|
||||||
|
window.alert(`Preencha os campos obrigatórios: ${missingFields.join(', ')}.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onSave({
|
onSave({
|
||||||
...formData,
|
...formData,
|
||||||
id: formData.id || uniqueSlug(formData.name, existingIds),
|
id: formData.id || uniqueSlug(formData.name, existingIds),
|
||||||
age: Number(formData.age) || 0,
|
age: Number(formData.age) || 0,
|
||||||
birthday: formData.birthday || '07/04',
|
birthday: formData.birthday || formatBirthday(formData.birthDate),
|
||||||
city: formData.city || 'Cidade não informada',
|
city: formData.city,
|
||||||
document: formData.cpf ? `CPF ${formData.cpf}` : 'CPF não informado',
|
document: formData.cpf ? `CPF ${formData.cpf}` : 'CPF não informado',
|
||||||
insurance: formData.insurance || 'Particular',
|
insurance: formData.insurance,
|
||||||
lastVisit: formData.lastVisit || 'Ainda não houve atendimento',
|
lastVisit: formData.lastVisit || 'Ainda não houve atendimento',
|
||||||
nextVisit: formData.nextVisit || null,
|
nextVisit: formData.nextVisit || null,
|
||||||
phone: formData.phone || 'Telefone não informado',
|
phone: formData.phone,
|
||||||
plan: formData.insurance || formData.plan || 'Particular',
|
plan: formData.plan,
|
||||||
state: formData.state || 'UF',
|
state: formData.state,
|
||||||
|
address: formatAddress(formData),
|
||||||
|
notes: formData.notesText ? [formData.notesText] : [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,43 +624,43 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
||||||
<DarkField className="md:col-span-6" label="Nome *">
|
<DarkField className="md:col-span-6" label="Nome *">
|
||||||
<input className={darkInput} name="name" onChange={handleChange} required value={formData.name} />
|
<input className={darkInput} name="name" onChange={handleChange} required={isNewPatient} value={formData.name} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-3" label="CPF">
|
<DarkField className="md:col-span-3" label={requiredLabel('CPF')}>
|
||||||
<input className={darkInput} maxLength={14} name="cpf" onChange={handleChange} value={formData.cpf} />
|
<input className={darkInput} maxLength={14} name="cpf" onChange={handleChange} required={isNewPatient} value={formData.cpf} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-3" label="Idade">
|
<DarkField className="md:col-span-3" label={requiredLabel('Idade')}>
|
||||||
<input className={darkInput} min="0" name="age" onChange={handleChange} type="number" value={formData.age} />
|
<input className={darkInput} min="0" name="age" onChange={handleChange} required={isNewPatient} type="number" value={formData.age} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-3" label="Data de Nascimento">
|
<DarkField className="md:col-span-3" label={requiredLabel('Data de Nascimento')}>
|
||||||
<input className={`${darkInput} [color-scheme:dark]`} type="date" />
|
<input className={`${darkInput} [color-scheme:dark]`} name="birthDate" onChange={handleChange} required={isNewPatient} type="date" value={formData.birthDate} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-3" label="Aniversario">
|
<DarkField className="md:col-span-3" label="Aniversário">
|
||||||
<input className={darkInput} maxLength={5} name="birthday" onChange={handleChange} placeholder="07/04" value={formData.birthday} />
|
<input className={darkInput} maxLength={5} name="birthday" onChange={handleChange} placeholder="07/04" value={formData.birthday} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-3" label="Etnia">
|
<DarkField className="md:col-span-3" label="Etnia">
|
||||||
<select className={darkInput} defaultValue="">
|
<select className={darkInput} name="ethnicity" onChange={handleChange} value={formData.ethnicity}>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
<option>Indígena</option>
|
<option>Indígena</option>
|
||||||
<option>Não Indígena</option>
|
<option>Não Indígena</option>
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-3" label="Estado civil">
|
<DarkField className="md:col-span-3" label="Estado civil">
|
||||||
<select className={darkInput} defaultValue="">
|
<select className={darkInput} name="maritalStatus" onChange={handleChange} value={formData.maritalStatus}>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
<option>Solteiro(a)</option>
|
<option>Solteiro(a)</option>
|
||||||
<option>Casado(a)</option>
|
<option>Casado(a)</option>
|
||||||
<option>Divorciado(a)</option>
|
<option>Divorciado(a)</option>
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-6" label="Nome da mae">
|
<DarkField className="md:col-span-6" label={requiredLabel('Nome da mãe')}>
|
||||||
<input className={darkInput} />
|
<input className={darkInput} name="motherName" onChange={handleChange} required={isNewPatient} value={formData.motherName} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-6" label="Nome do pai">
|
<DarkField className="md:col-span-6" label="Nome do pai">
|
||||||
<input className={darkInput} />
|
<input className={darkInput} name="fatherName" onChange={handleChange} value={formData.fatherName} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-12" label="Observacoes">
|
<DarkField className="md:col-span-12" label="Observacoes">
|
||||||
<textarea className={`${darkInput} min-h-24 py-2`} />
|
<textarea className={`${darkInput} min-h-24 py-2`} name="notesText" onChange={handleChange} value={formData.notesText} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<div className="md:col-span-12">
|
<div className="md:col-span-12">
|
||||||
<button
|
<button
|
||||||
@@ -610,14 +682,14 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
<section className={darkCard}>
|
<section className={darkCard}>
|
||||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Contato</h2>
|
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Contato</h2>
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
||||||
<DarkField className="md:col-span-4" label="E-mail">
|
<DarkField className="md:col-span-4" label={requiredLabel('E-mail')}>
|
||||||
<input className={darkInput} name="email" onChange={handleChange} type="email" value={formData.email} />
|
<input className={darkInput} name="email" onChange={handleChange} required={isNewPatient} type="email" value={formData.email} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-4" label="Celular">
|
<DarkField className="md:col-span-4" label={requiredLabel('Celular')}>
|
||||||
<input className={darkInput} maxLength={15} name="phone" onChange={handleChange} value={formData.phone} />
|
<input className={darkInput} maxLength={15} name="phone" onChange={handleChange} required={isNewPatient} value={formData.phone} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-4" label="Telefone 2">
|
<DarkField className="md:col-span-4" label="Telefone 2">
|
||||||
<input className={darkInput} />
|
<input className={darkInput} maxLength={15} name="phoneSecondary" onChange={handleChange} value={formData.phoneSecondary} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -625,22 +697,29 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
<section className={darkCard}>
|
<section className={darkCard}>
|
||||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Endereço</h2>
|
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Endereço</h2>
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
||||||
<DarkField className="md:col-span-3" label="CEP">
|
<DarkField className="md:col-span-3" label={requiredLabel('CEP')}>
|
||||||
<input className={darkInput} maxLength={9} onChange={maskCEPInput} placeholder="_____-___" />
|
<input className={darkInput} maxLength={9} name="zipCode" onChange={handleChange} placeholder="_____-___" required={isNewPatient} value={formData.zipCode} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-5" label="Endereço">
|
<DarkField className="md:col-span-5" label={requiredLabel('Endereço')}>
|
||||||
<input className={darkInput} />
|
<input className={darkInput} name="addressStreet" onChange={handleChange} required={isNewPatient} value={formData.addressStreet} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-4" label="Cidade">
|
<DarkField className="md:col-span-2" label={requiredLabel('Número')}>
|
||||||
<input className={darkInput} name="city" onChange={handleChange} value={formData.city} />
|
<input className={darkInput} name="addressNumber" onChange={handleChange} required={isNewPatient} value={formData.addressNumber} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-4" label="Estado">
|
<DarkField className="md:col-span-6" label="Complemento">
|
||||||
<select className={darkInput} name="state" onChange={handleChange} value={formData.state}>
|
<input className={darkInput} name="addressComplement" onChange={handleChange} value={formData.addressComplement} />
|
||||||
|
</DarkField>
|
||||||
|
<DarkField className="md:col-span-4" label={requiredLabel('Cidade')}>
|
||||||
|
<input className={darkInput} name="city" onChange={handleChange} required={isNewPatient} value={formData.city} />
|
||||||
|
</DarkField>
|
||||||
|
<DarkField className="md:col-span-4" label={requiredLabel('Estado')}>
|
||||||
|
<select className={darkInput} name="state" onChange={handleChange} required={isNewPatient} value={formData.state}>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
<option value="PE">Pernambuco</option>
|
{BRAZILIAN_STATES.map((stateOption) => (
|
||||||
<option value="SE">Sergipe</option>
|
<option key={stateOption.value} value={stateOption.value}>
|
||||||
<option value="SP">São Paulo</option>
|
{stateOption.label}
|
||||||
<option value="RJ">Rio de Janeiro</option>
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
</div>
|
</div>
|
||||||
@@ -649,17 +728,18 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
<section className={darkCard}>
|
<section className={darkCard}>
|
||||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Informações de convenio</h2>
|
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Informações de convenio</h2>
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
||||||
<DarkField className="md:col-span-6" label="Convenio">
|
<DarkField className="md:col-span-6" label="Convênio">
|
||||||
<select className={darkInput} name="insurance" onChange={handleChange} value={formData.insurance}>
|
<select className={darkInput} name="insurance" onChange={handleChange} value={formData.insurance}>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
<option value="Unimed">Unimed</option>
|
{INSURANCE_OPTIONS.map((option) => (
|
||||||
<option value="Bradesco Saude">Bradesco Saude</option>
|
<option key={option} value={option}>
|
||||||
<option value="Amil">Amil</option>
|
{option}
|
||||||
<option value="Particular">Particular</option>
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<DarkField className="md:col-span-6" label="Plano">
|
<DarkField className="md:col-span-6" label={requiredLabel('Plano')}>
|
||||||
<input className={darkInput} name="plan" onChange={handleChange} value={formData.plan} />
|
<input className={darkInput} name="plan" onChange={handleChange} required={isNewPatient} value={formData.plan} />
|
||||||
</DarkField>
|
</DarkField>
|
||||||
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-[#e5e5e5] md:col-span-12">
|
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-[#e5e5e5] md:col-span-12">
|
||||||
<input className="size-4 accent-[#3b82f6]" checked={formData.vip} name="vip" onChange={handleChange} type="checkbox" />
|
<input className="size-4 accent-[#3b82f6]" checked={formData.vip} name="vip" onChange={handleChange} type="checkbox" />
|
||||||
@@ -690,8 +770,57 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatientDetailPage({ navigate, patient }) {
|
export function PatientDetailPage({ navigate, patient, role }) {
|
||||||
const [activeTab, setActiveTab] = useState('resumo')
|
const [activeTab, setActiveTab] = useState('resumo')
|
||||||
|
const [localPatient, setLocalPatient] = useState(patient)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [messageShortcutOpen, setMessageShortcutOpen] = useState(false)
|
||||||
|
const [appointmentShortcutOpen, setAppointmentShortcutOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const canEditPatients = hasCapability(role, 'canEditPatients')
|
||||||
|
const canHardDeletePatients = hasCapability(role, 'hardDeletePatients')
|
||||||
|
|
||||||
|
async function savePatient(updatedPatient) {
|
||||||
|
if (!canEditPatients) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await patientRepository.update(updatedPatient.id, updatedPatient)
|
||||||
|
setLocalPatient((current) => ({ ...current, ...updatedPatient }))
|
||||||
|
setEditing(false)
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Erro ao salvar paciente: ${err.message}`)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePatient() {
|
||||||
|
if (!canHardDeletePatients) return
|
||||||
|
|
||||||
|
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await patientRepository.remove(localPatient.id)
|
||||||
|
navigate('/pacientes')
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(`Erro ao excluir paciente: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<PatientEditor
|
||||||
|
existingIds={[localPatient.id]}
|
||||||
|
onCancel={() => setEditing(false)}
|
||||||
|
onSave={savePatient}
|
||||||
|
patient={localPatient}
|
||||||
|
saving={saving}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
@@ -706,24 +835,33 @@ export function PatientDetailPage({ navigate, patient }) {
|
|||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#3b82f6]">Dados do Paciente</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[#3b82f6]">Dados do Paciente</p>
|
||||||
<h1 className="mt-1 text-2xl font-bold tracking-tight text-[#f5f5f5]">{patient.name}</h1>
|
<h1 className="mt-1 text-2xl font-bold tracking-tight text-[#f5f5f5]">{localPatient.name}</h1>
|
||||||
<p className="mt-1 text-sm text-[#b8b8b8]">
|
<p className="mt-1 text-sm text-[#b8b8b8]">
|
||||||
{patient.condition} • {patient.status} • {patient.document}
|
{localPatient.condition} • {localPatient.status} • {localPatient.document}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{canEditPatients ? (
|
||||||
<button
|
<button
|
||||||
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
onClick={() => navigate('/comunicacao')}
|
onClick={() => setEditing(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Editar dados
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => setMessageShortcutOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Enviar mensagem
|
Enviar mensagem
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
onClick={() => navigate('/agenda')}
|
onClick={() => setAppointmentShortcutOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Novo retorno
|
Novo retorno
|
||||||
@@ -732,10 +870,10 @@ export function PatientDetailPage({ navigate, patient }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<SummaryTile label="Idade" value={`${patient.age} anos`} />
|
<SummaryTile label="Idade" value={`${localPatient.age} anos`} />
|
||||||
<SummaryTile label="Risco" value={patient.risk} tone={riskColor(patient.risk)} />
|
<SummaryTile label="Risco" value={localPatient.risk} tone={riskColor(localPatient.risk)} />
|
||||||
<SummaryTile label="Última consulta" value={patient.lastVisit} />
|
<SummaryTile label="Última consulta" value={localPatient.lastVisit || 'Ainda não houve atendimento'} />
|
||||||
<SummaryTile label="Próxima consulta" value={patient.nextVisit} />
|
<SummaryTile label="Próxima consulta" value={localPatient.nextVisit || 'Nenhum atendimento agendado'} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={darkCard}>
|
<section className={darkCard}>
|
||||||
@@ -757,48 +895,235 @@ export function PatientDetailPage({ navigate, patient }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{activeTab === 'resumo' ? <PatientSummary patient={patient} /> : null}
|
{activeTab === 'resumo' ? <PatientSummary patient={localPatient} /> : null}
|
||||||
{activeTab === 'consultas' ? <PatientVisits navigate={navigate} patient={patient} /> : null}
|
{activeTab === 'consultas' ? <PatientVisits navigate={navigate} patient={localPatient} /> : null}
|
||||||
{activeTab === 'documentos' ? <PatientDocuments patient={patient} /> : null}
|
{activeTab === 'documentos' ? <PatientDocuments patient={localPatient} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{canHardDeletePatients ? (
|
||||||
|
<section className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-red-300">Zona de exclusão</h2>
|
||||||
|
<p className="mt-1 text-sm text-red-100/80">Remove definitivamente o paciente e seus dados locais carregados.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm border border-red-500/40 bg-red-500/10 px-4 text-sm font-semibold text-red-300 transition hover:bg-red-500/20"
|
||||||
|
onClick={deletePatient}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Excluir paciente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{messageShortcutOpen ? (
|
||||||
|
<PatientMessageShortcutModal
|
||||||
|
onClose={() => setMessageShortcutOpen(false)}
|
||||||
|
patient={localPatient}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{appointmentShortcutOpen ? (
|
||||||
|
<PatientAppointmentShortcutModal
|
||||||
|
onClose={() => setAppointmentShortcutOpen(false)}
|
||||||
|
patient={localPatient}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PatientSummary({ patient }) {
|
function PatientSummary({ patient }) {
|
||||||
|
const notes = Array.isArray(patient.notes) ? patient.notes : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo clínico</h2>
|
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo clínico</h2>
|
||||||
<div className="mt-4 grid gap-3">
|
<div className="grid gap-3">
|
||||||
{patient.notes.map((note) => (
|
{notes.length ? notes.map((note) => (
|
||||||
<p className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#b8b8b8]" key={note}>
|
<p className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#b8b8b8]" key={note}>
|
||||||
{note}
|
{note}
|
||||||
</p>
|
</p>
|
||||||
))}
|
)) : (
|
||||||
|
<p className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#b8b8b8]">
|
||||||
|
Nenhuma observação clínica registrada.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PatientInfoSection
|
||||||
|
items={[
|
||||||
|
['CPF', patient.cpf || patient.document],
|
||||||
|
['Data de nascimento', formatDisplayDate(patient.birthDate || patient.birth_date)],
|
||||||
|
['Nome da mãe', patient.motherName],
|
||||||
|
['Nome do pai', patient.fatherName],
|
||||||
|
['Etnia', patient.ethnicity],
|
||||||
|
['Estado civil', patient.maritalStatus],
|
||||||
|
]}
|
||||||
|
title="Dados pessoais"
|
||||||
|
/>
|
||||||
|
<PatientInfoSection
|
||||||
|
items={[
|
||||||
|
['CEP', patient.zipCode],
|
||||||
|
['Endereço', patient.addressStreet],
|
||||||
|
['Número', patient.addressNumber],
|
||||||
|
['Complemento', patient.addressComplement],
|
||||||
|
['Cidade', patient.city],
|
||||||
|
['Estado', patient.state],
|
||||||
|
]}
|
||||||
|
title="Endereço"
|
||||||
|
/>
|
||||||
|
<PatientInfoSection
|
||||||
|
items={[
|
||||||
|
['Convênio', patient.insurance],
|
||||||
|
['Plano', patient.plan],
|
||||||
|
['VIP', patient.vip ? 'Sim' : 'Não'],
|
||||||
|
]}
|
||||||
|
title="Informações de convênio"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
||||||
<h3 className="font-bold text-[#f5f5f5]">Contato e equipe</h3>
|
<h3 className="font-bold text-[#f5f5f5]">Contato e equipe</h3>
|
||||||
<dl className="mt-4 grid gap-3 text-sm">
|
<dl className="mt-4 grid gap-3 text-sm">
|
||||||
<InfoRow label="Telefone" value={patient.phone} />
|
<InfoRow label="Telefone" value={patient.phone} />
|
||||||
|
<InfoRow label="Telefone 2" value={patient.phoneSecondary} />
|
||||||
<InfoRow label="E-mail" value={patient.email} />
|
<InfoRow label="E-mail" value={patient.email} />
|
||||||
<InfoRow label="Endereço" value={patient.address} />
|
<InfoRow label="Endereço" value={patient.address} />
|
||||||
<InfoRow label="Equipe" value={patient.team.join(', ')} />
|
<InfoRow label="Equipe" value={(patient.team || []).join(', ')} />
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PatientMessageShortcutModal({ onClose, patient }) {
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [channel, setChannel] = useState('whatsapp')
|
||||||
|
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShortcutModal onClose={onClose} title="Nova mensagem">
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<DarkField label="Paciente">
|
||||||
|
<input className={darkInput} readOnly value={patient.name || ''} />
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Canal">
|
||||||
|
<select className={darkInput} onChange={(event) => setChannel(event.target.value)} value={channel}>
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
<option value="sms">SMS</option>
|
||||||
|
<option value="email">E-mail</option>
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Mensagem">
|
||||||
|
<textarea
|
||||||
|
className={`${darkInput} min-h-28 py-2`}
|
||||||
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
|
value={message}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
<ShortcutActions disabled={!message.trim()} onClose={onClose} submitLabel="Enviar" />
|
||||||
|
</form>
|
||||||
|
</ShortcutModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatientAppointmentShortcutModal({ onClose, patient }) {
|
||||||
|
const [date, setDate] = useState('')
|
||||||
|
const [time, setTime] = useState('')
|
||||||
|
const [type, setType] = useState('Retorno')
|
||||||
|
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShortcutModal onClose={onClose} title="Novo agendamento">
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<DarkField label="Paciente">
|
||||||
|
<input className={darkInput} readOnly value={patient.name || ''} />
|
||||||
|
</DarkField>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<DarkField label="Data">
|
||||||
|
<input className={`${darkInput} [color-scheme:dark]`} onChange={(event) => setDate(event.target.value)} type="date" value={date} />
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Horário">
|
||||||
|
<input className={`${darkInput} [color-scheme:dark]`} onChange={(event) => setTime(event.target.value)} type="time" value={time} />
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
<DarkField label="Tipo">
|
||||||
|
<input className={darkInput} onChange={(event) => setType(event.target.value)} value={type} />
|
||||||
|
</DarkField>
|
||||||
|
<ShortcutActions disabled={!date || !time} onClose={onClose} submitLabel="Salvar" />
|
||||||
|
</form>
|
||||||
|
</ShortcutModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortcutModal({ children, onClose, title }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||||
|
<div className="w-full max-w-xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-[#404040] px-5 py-4">
|
||||||
|
<h2 className="text-lg font-bold text-[#f5f5f5]">{title}</h2>
|
||||||
|
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] hover:bg-[#303030]" onClick={onClose} type="button">
|
||||||
|
<PatientIcon className="size-5" name="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortcutActions({ disabled, onClose, submitLabel }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-3 border-t border-[#404040] pt-4">
|
||||||
|
<button className="h-10 rounded-sm border border-[#404040] px-4 text-sm font-semibold text-[#e5e5e5]" onClick={onClose} type="button">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={disabled}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatientInfoSection({ items, title }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
||||||
|
<h3 className="font-bold text-[#f5f5f5]">{title}</h3>
|
||||||
|
<dl className="mt-4 grid gap-3 text-sm md:grid-cols-2">
|
||||||
|
{items.map(([label, value]) => (
|
||||||
|
<InfoRow key={label} label={label} value={value || 'Não informado'} />
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PatientVisits({ navigate, patient }) {
|
function PatientVisits({ navigate, patient }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{[
|
{[
|
||||||
{ date: patient.nextVisit, status: 'Agendada', description: `Retorno para ${patient.condition}` },
|
patient.nextVisit
|
||||||
{ date: patient.lastVisit, status: 'Finalizada', description: 'Consulta registrada no historico do paciente.' },
|
? { date: patient.nextVisit, status: 'Agendada', description: `Retorno para ${patient.condition}` }
|
||||||
].map((visit) => (
|
: null,
|
||||||
|
patient.lastVisit
|
||||||
|
? { date: patient.lastVisit, status: 'Finalizada', description: 'Consulta registrada no histórico do paciente.' }
|
||||||
|
: null,
|
||||||
|
].filter(Boolean).map((visit) => (
|
||||||
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={`${visit.date}-${visit.status}`}>
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={`${visit.date}-${visit.status}`}>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -815,6 +1140,11 @@ function PatientVisits({ navigate, patient }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{!patient.nextVisit && !patient.lastVisit ? (
|
||||||
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm text-[#a3a3a3]">
|
||||||
|
Nenhum agendamento encontrado para este paciente.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="h-10 justify-self-start rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6]"
|
className="h-10 justify-self-start rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6]"
|
||||||
onClick={() => navigate('/consultas')}
|
onClick={() => navigate('/consultas')}
|
||||||
@@ -827,9 +1157,11 @@ function PatientVisits({ navigate, patient }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PatientDocuments({ patient }) {
|
function PatientDocuments({ patient }) {
|
||||||
|
const exams = Array.isArray(patient.exams) ? patient.exams : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
{patient.exams.map((exam) => (
|
{exams.length ? exams.map((exam) => (
|
||||||
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={exam}>
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4" key={exam}>
|
||||||
<p className="font-semibold text-[#f5f5f5]">{exam}</p>
|
<p className="font-semibold text-[#f5f5f5]">{exam}</p>
|
||||||
<p className="mt-2 text-sm text-[#a3a3a3]">Pendente de revisão.</p>
|
<p className="mt-2 text-sm text-[#a3a3a3]">Pendente de revisão.</p>
|
||||||
@@ -837,7 +1169,11 @@ function PatientDocuments({ patient }) {
|
|||||||
A revisar
|
A revisar
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)) : (
|
||||||
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm text-[#a3a3a3]">
|
||||||
|
Nenhum documento encontrado.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -861,11 +1197,17 @@ function InfoRow({ label, value }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-semibold text-[#737373]">{label}</dt>
|
<dt className="font-semibold text-[#737373]">{label}</dt>
|
||||||
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
|
<dd className="mt-1 text-[#e5e5e5]">{value || 'Não informado'}</dd>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDisplayDate(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
const [year, month, day] = String(value).split('-')
|
||||||
|
return year && month && day ? `${day}/${month}/${year}` : value
|
||||||
|
}
|
||||||
|
|
||||||
function riskColor(risk) {
|
function riskColor(risk) {
|
||||||
if (risk === 'Alto') {
|
if (risk === 'Alto') {
|
||||||
return 'bg-red-500/20 text-red-400'
|
return 'bg-red-500/20 text-red-400'
|
||||||
@@ -878,6 +1220,32 @@ function riskColor(risk) {
|
|||||||
return 'bg-emerald-500/20 text-emerald-400'
|
return 'bg-emerald-500/20 text-emerald-400'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFilterValue(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPatientBirthday(patient) {
|
||||||
|
if (patient.birthday) return patient.birthday
|
||||||
|
const birthDate = patient.birthDate || patient.birth_date
|
||||||
|
if (!birthDate) return ''
|
||||||
|
|
||||||
|
const [, month, day] = String(birthDate).split('-')
|
||||||
|
return month && day ? `${day}/${month}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTodayBirthday() {
|
||||||
|
const today = new Date()
|
||||||
|
return `${String(today.getDate()).padStart(2, '0')}/${getCurrentMonth()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentMonth() {
|
||||||
|
return String(new Date().getMonth() + 1).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
function PatientSelect({ className = '', icon, label, onChange, options, value }) {
|
function PatientSelect({ className = '', icon, label, onChange, options, value }) {
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`}>
|
<div className={`relative ${className}`}>
|
||||||
@@ -940,6 +1308,14 @@ function ActionItem({ danger = false, icon, label, onClick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requiredLabel(label) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{label} <span className="text-red-400">*</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DarkField({ children, className = '', label }) {
|
function DarkField({ children, className = '', label }) {
|
||||||
return (
|
return (
|
||||||
<label className={`block ${className}`}>
|
<label className={`block ${className}`}>
|
||||||
@@ -1002,8 +1378,8 @@ function AdvancedFilterModal({
|
|||||||
<select className={darkInput} onChange={(event) => setState(event.target.value)} value={state}>
|
<select className={darkInput} onChange={(event) => setState(event.target.value)} value={state}>
|
||||||
<option value="">Todos</option>
|
<option value="">Todos</option>
|
||||||
{stateOptions.map((option) => (
|
{stateOptions.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option.value} value={option.value}>
|
||||||
{option}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -1015,7 +1391,6 @@ function AdvancedFilterModal({
|
|||||||
className={darkInput}
|
className={darkInput}
|
||||||
min="0"
|
min="0"
|
||||||
onChange={(event) => setAgeMin(event.target.value)}
|
onChange={(event) => setAgeMin(event.target.value)}
|
||||||
placeholder="0"
|
|
||||||
type="number"
|
type="number"
|
||||||
value={ageMin}
|
value={ageMin}
|
||||||
/>
|
/>
|
||||||
@@ -1025,7 +1400,6 @@ function AdvancedFilterModal({
|
|||||||
className={darkInput}
|
className={darkInput}
|
||||||
min="0"
|
min="0"
|
||||||
onChange={(event) => setAgeMax(event.target.value)}
|
onChange={(event) => setAgeMax(event.target.value)}
|
||||||
placeholder="120"
|
|
||||||
type="number"
|
type="number"
|
||||||
value={ageMax}
|
value={ageMax}
|
||||||
/>
|
/>
|
||||||
@@ -1246,10 +1620,24 @@ function PatientIcon({ className = 'size-4', name }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPatientRows() {
|
async function buildPatientRows(role) {
|
||||||
|
if (normalizeRole(role) !== 'medico') {
|
||||||
return patientRepository.getDirectoryRows()
|
return patientRepository.getDirectoryRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [profile, professionals] = await Promise.all([
|
||||||
|
profileRepository.getCurrentUserProfile(),
|
||||||
|
professionalRepository.getAll(),
|
||||||
|
])
|
||||||
|
const currentProfessional = professionalRepository.resolveCurrentProfessional(profile, professionals)
|
||||||
|
|
||||||
|
if (!currentProfessional?.id) {
|
||||||
|
throw new Error('Não foi possível vincular o médico logado a um profissional da base.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return patientRepository.getDirectoryRows({ doctorId: currentProfessional.id })
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueSlug(value, existingIds) {
|
function uniqueSlug(value, existingIds) {
|
||||||
const base = slugify(value) || `paciente-${Date.now()}`
|
const base = slugify(value) || `paciente-${Date.now()}`
|
||||||
let nextId = base
|
let nextId = base
|
||||||
@@ -1289,9 +1677,28 @@ function maskPhone(value) {
|
|||||||
.replace(/(-\d{4})\d+?$/, '$1')
|
.replace(/(-\d{4})\d+?$/, '$1')
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskCEPInput(event) {
|
function maskCEP(value) {
|
||||||
event.target.value = event.target.value
|
return value
|
||||||
.replace(/\D/g, '')
|
.replace(/\D/g, '')
|
||||||
.replace(/(\d{5})(\d)/, '$1-$2')
|
.replace(/(\d{5})(\d)/, '$1-$2')
|
||||||
.replace(/(-\d{3})\d+?$/, '$1')
|
.replace(/(-\d{3})\d+?$/, '$1')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBirthday(birthDate) {
|
||||||
|
if (!birthDate) return ''
|
||||||
|
const [, month, day] = birthDate.split('-')
|
||||||
|
return day && month ? `${day}/${month}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(patient) {
|
||||||
|
return [
|
||||||
|
patient.addressStreet,
|
||||||
|
patient.addressNumber,
|
||||||
|
patient.addressComplement,
|
||||||
|
patient.city,
|
||||||
|
patient.state,
|
||||||
|
patient.zipCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +14,11 @@ 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 = [
|
||||||
@@ -44,13 +51,17 @@ const emptyEditor = {
|
|||||||
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('')
|
||||||
@@ -121,6 +132,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 +147,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 +178,54 @@ export function ReportsPage() {
|
|||||||
setPage(1)
|
setPage(1)
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
console.error(loadError)
|
console.error(loadError)
|
||||||
setError(loadError.message || 'Erro ao carregar relatórios médicos.')
|
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)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [professionalData, currentProfile] = await Promise.all([
|
||||||
professionalRepository.getAll(),
|
professionalRepository.getAll(),
|
||||||
|
profileRepository.getCurrentUserProfile(),
|
||||||
])
|
])
|
||||||
.then(([patientData, professionalData]) => {
|
|
||||||
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()
|
||||||
@@ -238,7 +288,7 @@ export function ReportsPage() {
|
|||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
await loadReports()
|
await loadReports()
|
||||||
} catch (saveError) {
|
} catch (saveError) {
|
||||||
alert(saveError.message || 'Erro ao salvar relatório médico.')
|
alert(saveError.message || 'Erro ao salvar relatório.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -248,8 +298,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]">Relatórios médicos</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Relatórios</h1>
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">Consulta, criação e edição de relatórios médicos.</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]"
|
||||||
@@ -303,6 +353,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>
|
||||||
|
|
||||||
@@ -365,7 +416,7 @@ export function ReportsPage() {
|
|||||||
{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 relatórios médicos...
|
Carregando relatórios...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : paginatedReports.length ? (
|
) : paginatedReports.length ? (
|
||||||
@@ -438,6 +489,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 +504,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)]">
|
||||||
@@ -480,7 +533,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 relatório médico' : 'Novo relatório médico'}
|
{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" />
|
||||||
@@ -504,6 +557,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
<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>
|
||||||
@@ -574,11 +628,11 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
|||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
|
|
||||||
<DarkField label="Conteúdo 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>Conteúdo do relatório</p>"
|
placeholder="Complemento em texto simples"
|
||||||
value={editor.contentHtml}
|
value={editor.contentHtml}
|
||||||
/>
|
/>
|
||||||
</DarkField>
|
</DarkField>
|
||||||
@@ -631,6 +685,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,7 +695,7 @@ 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]">Relatório médico</h2>
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatório</h2>
|
||||||
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem número'} </p>
|
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem número'} </p>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
@@ -653,7 +709,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>
|
||||||
|
|
||||||
@@ -677,14 +733,11 @@ function ReportViewModal({ onClose, report }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 rounded-xl border border-[#404040] bg-[#1a1a1a] p-5">
|
<div className="mt-6 rounded-xl border border-[#404040] bg-[#1a1a1a] p-5">
|
||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Conteúdo 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 conteúdo HTML informado.</p>
|
<p className="text-sm text-[#a3a3a3]">Nenhum complemento informado.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -789,6 +842,10 @@ 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 ReportIcon({ className = 'size-4', name }) {
|
function ReportIcon({ className = 'size-4', name }) {
|
||||||
const common = {
|
const common = {
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -287,7 +287,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,204 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
|
|
||||||
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
|
|
||||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
|
||||||
import { availabilityRepository } from '../repositories/availabilityRepository.js'
|
|
||||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
|
||||||
|
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
|
||||||
const weekdays = [
|
|
||||||
{ label: 'Seg', value: 1 },
|
|
||||||
{ label: 'Ter', value: 2 },
|
|
||||||
{ label: 'Qua', value: 3 },
|
|
||||||
{ label: 'Qui', value: 4 },
|
|
||||||
{ label: 'Sex', value: 5 },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function TeamPage({ navigate }) {
|
|
||||||
const [professionals, setProfessionals] = useState([])
|
|
||||||
const [availability, setAvailability] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
|
|
||||||
async function loadTeam() {
|
|
||||||
try {
|
|
||||||
setError('')
|
|
||||||
const [professionalsData, availabilityData] = await Promise.all([
|
|
||||||
professionalRepository.getAll(),
|
|
||||||
availabilityRepository.getAll({ active: true }),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!active) return
|
|
||||||
|
|
||||||
setProfessionals(professionalsData)
|
|
||||||
setAvailability(availabilityData)
|
|
||||||
} catch (loadError) {
|
|
||||||
if (!active) return
|
|
||||||
setError(loadError.message || 'Erro ao carregar profissionais e disponibilidade.')
|
|
||||||
} finally {
|
|
||||||
if (active) setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadTeam()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const availabilityByDoctor = useMemo(() => groupAvailabilityByDoctor(availability), [availability])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
|
||||||
{error ? (
|
|
||||||
<FeatureCallout
|
|
||||||
description={error}
|
|
||||||
status="wip"
|
|
||||||
title="Não foi possível carregar disponibilidade"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="py-10 text-center text-sm text-[#a3a3a3]">Carregando profissionais...</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe medica">
|
|
||||||
{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="Proximo horario" value={professional.nextSlot} />
|
|
||||||
<Info label="Pacientes ativos" value={professional.patients} />
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className={`${cardClass} ${featurePanelClass('live')} 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="live" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm text-[#a3a3a3]">
|
|
||||||
Disponibilidades ativas cadastradas em /rest/v1/doctor_availability.
|
|
||||||
</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((weekday) => weekday.label)].map((label) => (
|
|
||||||
<div className="border-b border-[#404040] px-4 py-3" key={label}>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{professionals.map((professional) => (
|
|
||||||
<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>
|
|
||||||
{weekdays.map((weekday) => (
|
|
||||||
<div className="border-b border-[#404040] px-4 py-3 text-[#b8b8b8]" key={`${professional.id}-${weekday.value}`}>
|
|
||||||
{formatCoverage(availabilityByDoctor.get(String(professional.id))?.[weekday.value])}
|
|
||||||
</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 String(name || '')
|
|
||||||
.replace(/^(Dr\.|Dra\.|Nutri\.|Enf\.)\s+/i, '')
|
|
||||||
.split(' ')
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupAvailabilityByDoctor(items) {
|
|
||||||
const grouped = new Map()
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const doctorId = String(item.doctorId)
|
|
||||||
const current = grouped.get(doctorId) || {}
|
|
||||||
current[item.weekday] = [...(current[item.weekday] || []), item]
|
|
||||||
grouped.set(doctorId, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoverage(items = []) {
|
|
||||||
const activeItems = items.filter((item) => item.active !== false)
|
|
||||||
if (!activeItems.length) return 'Sem regra'
|
|
||||||
|
|
||||||
return activeItems
|
|
||||||
.map((item) => `${formatTime(item.startTime)}-${formatTime(item.endTime)}`)
|
|
||||||
.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(value) {
|
|
||||||
return String(value || '').slice(0, 5)
|
|
||||||
}
|
|
||||||
@@ -38,11 +38,17 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [deletingId, setDeletingId] = useState(null)
|
const [deletingId, setDeletingId] = useState(null)
|
||||||
const [form, setForm] = useState(initialUserForm)
|
const [form, setForm] = useState(initialUserForm)
|
||||||
|
const [roleFilter, setRoleFilter] = useState('Todos')
|
||||||
|
|
||||||
const normalizedRole = normalizeRole(currentRole)
|
const normalizedRole = normalizeRole(currentRole)
|
||||||
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
|
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
|
||||||
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||||
const isPasswordCreation = form.auth_method === 'password'
|
const isPasswordCreation = form.auth_method === 'password'
|
||||||
|
const 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(() => {
|
useEffect(() => {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
@@ -167,6 +173,29 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
<p className="py-10 text-center text-sm text-red-400">Erro ao carregar usuários: {error}</p>
|
<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="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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full whitespace-nowrap text-left text-sm">
|
<table className="w-full whitespace-nowrap text-left text-sm">
|
||||||
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
|
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
|
||||||
@@ -179,9 +208,9 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#404040]">
|
<tbody className="divide-y divide-[#404040]">
|
||||||
{users.length ? (
|
{filteredUsers.length ? (
|
||||||
users.map((user) => {
|
filteredUsers.map((user) => {
|
||||||
const userRole = Array.isArray(user.roles) ? user.roles[0] : (user.role ?? '—')
|
const userRole = getUserRole(user)
|
||||||
return (
|
return (
|
||||||
<tr className="transition hover:bg-[#303030]" key={user.id}>
|
<tr className="transition hover:bg-[#303030]" key={user.id}>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -221,7 +250,7 @@ export function UsersPage({ role: currentRole }) {
|
|||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-6 py-10 text-center text-[#a3a3a3]" colSpan={5}>
|
<td className="px-6 py-10 text-center text-[#a3a3a3]" colSpan={5}>
|
||||||
Nenhum usuário encontrado.
|
{users.length ? 'Nenhum usuário encontrado para o perfil selecionado.' : 'Nenhum usuário encontrado.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -439,3 +468,7 @@ function RoleBadge({ role }) {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserRole(user) {
|
||||||
|
return Array.isArray(user.roles) ? user.roles[0] : (user.role ?? '—')
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,14 +86,19 @@ export const availabilityRepository = {
|
|||||||
return mapException(normalizeItem(await response.json()))
|
return mapException(normalizeItem(await response.json()))
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAvailableSlots({ date, doctorId }) {
|
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`, {
|
const response = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/get-available-slots`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthenticatedHeaders(),
|
headers: getAuthenticatedHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
doctor_id: doctorId,
|
|
||||||
date,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -174,11 +179,19 @@ function mapException(item) {
|
|||||||
|
|
||||||
function mapSlot(slot) {
|
function mapSlot(slot) {
|
||||||
return {
|
return {
|
||||||
|
date: slot.date,
|
||||||
|
datetime: slot.datetime,
|
||||||
time: slot.time,
|
time: slot.time,
|
||||||
available: Boolean(slot.available),
|
available: Boolean(slot.available),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAppointmentType(type) {
|
||||||
|
// API enum documented for availability currently accepts "presencial".
|
||||||
|
void type
|
||||||
|
return 'presencial'
|
||||||
|
}
|
||||||
|
|
||||||
function cleanPayload(payload) {
|
function cleanPayload(payload) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(payload).filter(([, value]) => value !== undefined),
|
Object.entries(payload).filter(([, value]) => value !== undefined),
|
||||||
|
|||||||
@@ -9,14 +9,25 @@ export const patientRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
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().catch(() => []),
|
||||||
|
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 +37,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +63,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',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +88,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}`, {
|
||||||
@@ -102,41 +113,200 @@ export const patientRepository = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
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 não 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 não 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 || 'Endereço não informado',
|
address: formatAddress(directory) || patient.address || patient.endereco || 'Endereço não informado',
|
||||||
team: patient.team || patient.equipe || [],
|
team: patient.team || patient.equipe || [],
|
||||||
notes: 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
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ 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) {
|
||||||
@@ -26,6 +40,7 @@ function mapProfessional(doctor) {
|
|||||||
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 || 'Médico(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 || '',
|
||||||
|
unit: doctor.unit || doctor.unidade || doctor.clinic_unit || doctor.clinica || doctor.location || '',
|
||||||
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Médico(a)',
|
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',
|
||||||
@@ -33,3 +48,7 @@ function mapProfessional(doctor) {
|
|||||||
status: doctor.status || doctor.situacao || 'Disponivel',
|
status: doctor.status || doctor.situacao || 'Disponivel',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeValue(value) {
|
||||||
|
return String(value || '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()}`, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const settingsRepository = {
|
export const settingsRepository = {
|
||||||
getIntegrations() {
|
getIntegrations() {
|
||||||
return [
|
return [
|
||||||
['WhatsApp Business', 'Envio automático de lembretes e confirmações', 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', 'Sincronizacao 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 cobranca', true, 'bg-violet-500'],
|
||||||
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
|
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
|
||||||
|
|||||||
Reference in New Issue
Block a user