forked from RiseUP/riseup_squad_03
new file: .gitignore
new file: src/App.css new file: src/App.jsx new file: src/assets/figma/login-clinic.png new file: src/assets/hero.png new file: src/assets/react.svg new file: src/assets/vite.svg new file: src/components/AppShell.jsx new file: src/components/Brand.jsx new file: src/components/ui.jsx new file: src/data/mockData.js new file: src/index.css new file: src/main.jsx new file: src/pages/AgendaPage.jsx new file: src/pages/AnalyticsPage.jsx new file: src/pages/AuthPages.jsx new file: src/pages/HomePage.jsx new file: src/pages/MedicalRecordsPage.jsx new file: src/pages/MessagesPage.jsx new file: src/pages/NotFoundPage.jsx new file: src/pages/PatientsPage.jsx new file: src/pages/ProfilePage.jsx new file: src/pages/ReportsPage.jsx new file: src/pages/SettingsPage.jsx new file: src/pages/TeamPage.jsx new file: src/pages/VisitsPage.jsx new file: src/repositories/analyticsRepository.js new file: src/repositories/appointmentRepository.js new file: src/repositories/communicationRepository.js new file: src/repositories/homeRepository.js new file: src/repositories/medicalRecordRepository.js new file: src/repositories/patientRepository.js new file: src/repositories/professionalRepository.js new file: src/repositories/profileRepository.js new file: src/repositories/reportRepository.js new file: src/repositories/settingsRepository.js new file: src/repositories/visitRepository.js new file: src/services/analyticsService.js new file: src/services/appointmentService.js new file: src/services/communicationService.js new file: src/services/homeService.js new file: src/services/medicalRecordService.js new file: src/services/patientService.js new file: src/services/professionalService.js new file: src/services/profileService.js new file: src/services/reportService.js new file: src/services/settingsService.js
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
projeto-figma
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
6
src/App.css
Normal file
6
src/App.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
213
src/App.jsx
Normal file
213
src/App.jsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import './App.css'
|
||||||
|
import { AppShell } from './components/AppShell.jsx'
|
||||||
|
import { AgendaPage } from './pages/AgendaPage.jsx'
|
||||||
|
import { AnalyticsPage } from './pages/AnalyticsPage.jsx'
|
||||||
|
import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx'
|
||||||
|
import { HomePage } from './pages/HomePage.jsx'
|
||||||
|
import { MedicalRecordsPage } from './pages/MedicalRecordsPage.jsx'
|
||||||
|
import { MessagesPage } from './pages/MessagesPage.jsx'
|
||||||
|
import { NotFoundPage } from './pages/NotFoundPage.jsx'
|
||||||
|
import { PatientDetailPage, PatientsPage } from './pages/PatientsPage.jsx'
|
||||||
|
import { ProfilePage } from './pages/ProfilePage.jsx'
|
||||||
|
import { ReportsPage } from './pages/ReportsPage.jsx'
|
||||||
|
import { SettingsPage } from './pages/SettingsPage.jsx'
|
||||||
|
import { TeamPage } from './pages/TeamPage.jsx'
|
||||||
|
import { VisitsPage } from './pages/VisitsPage.jsx'
|
||||||
|
import { patientRepository } from './repositories/patientRepository.js'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [location, setLocation] = useState(() => readLocation())
|
||||||
|
|
||||||
|
const navigate = useCallback((to, options = {}) => {
|
||||||
|
if (options.replace) {
|
||||||
|
window.history.replaceState({}, '', to)
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, '', to)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocation(readLocation())
|
||||||
|
const hash = to.split('#')[1]
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (hash) {
|
||||||
|
document.getElementById(hash)?.scrollIntoView({ block: 'start' })
|
||||||
|
} else {
|
||||||
|
window.scrollTo({ left: 0, top: 0 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handlePopState() {
|
||||||
|
setLocation(readLocation())
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState)
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate])
|
||||||
|
|
||||||
|
if (!route.withShell) {
|
||||||
|
return route.element
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
|
||||||
|
{route.element}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoute(pathname, navigate) {
|
||||||
|
if (pathname === '/' || pathname === '/login') {
|
||||||
|
return {
|
||||||
|
element: <LoginPage navigate={navigate} />,
|
||||||
|
title: 'Login',
|
||||||
|
withShell: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/cadastro') {
|
||||||
|
return {
|
||||||
|
element: <RegisterPage navigate={navigate} />,
|
||||||
|
title: 'Cadastro',
|
||||||
|
withShell: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/recuperar-senha') {
|
||||||
|
return {
|
||||||
|
element: <ForgotPasswordPage navigate={navigate} />,
|
||||||
|
title: 'Recuperar senha',
|
||||||
|
withShell: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/inicio' || pathname === '/home' || pathname === '/dashboard') {
|
||||||
|
return {
|
||||||
|
element: <HomePage navigate={navigate} />,
|
||||||
|
title: 'Painel',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/agenda') {
|
||||||
|
return {
|
||||||
|
element: <AgendaPage navigate={navigate} />,
|
||||||
|
title: 'Agenda',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/pacientes') {
|
||||||
|
return {
|
||||||
|
element: <PatientsPage navigate={navigate} />,
|
||||||
|
title: 'Pacientes',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/prontuario') {
|
||||||
|
return {
|
||||||
|
element: <MedicalRecordsPage navigate={navigate} />,
|
||||||
|
title: 'Prontuário',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/pacientes/')) {
|
||||||
|
const patientId = pathname.split('/')[2]
|
||||||
|
const patient = patientRepository.getById(patientId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: patient ? (
|
||||||
|
<PatientDetailPage navigate={navigate} patient={patient} />
|
||||||
|
) : (
|
||||||
|
<NotFoundPage navigate={navigate} />
|
||||||
|
),
|
||||||
|
title: patient?.name || 'Paciente nao encontrado',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/consultas') {
|
||||||
|
return {
|
||||||
|
element: <VisitsPage navigate={navigate} />,
|
||||||
|
title: 'Consultas',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/laudos') {
|
||||||
|
return {
|
||||||
|
element: <ReportsPage navigate={navigate} />,
|
||||||
|
title: 'Laudos',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/relatorios') {
|
||||||
|
return {
|
||||||
|
element: <AnalyticsPage />,
|
||||||
|
title: 'Relatórios',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/camunicacao' || pathname === '/comunicacao' || pathname === '/mensagens') {
|
||||||
|
return {
|
||||||
|
element: <MessagesPage navigate={navigate} />,
|
||||||
|
title: 'Comunicação',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/profissionais') {
|
||||||
|
return {
|
||||||
|
element: <TeamPage navigate={navigate} />,
|
||||||
|
title: 'Profissionais',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/perfil') {
|
||||||
|
return {
|
||||||
|
element: <ProfilePage navigate={navigate} />,
|
||||||
|
title: 'Perfil',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/configuracoes' || pathname === '/config') {
|
||||||
|
return {
|
||||||
|
element: <SettingsPage navigate={navigate} />,
|
||||||
|
title: 'Configurações',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: <NotFoundPage navigate={navigate} />,
|
||||||
|
title: 'Tela nao encontrada',
|
||||||
|
withShell: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocation() {
|
||||||
|
return {
|
||||||
|
pathname: normalizePath(window.location.pathname),
|
||||||
|
search: window.location.search,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(pathname) {
|
||||||
|
if (!pathname || pathname === '/') {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
BIN
src/assets/figma/login-clinic.png
Normal file
BIN
src/assets/figma/login-clinic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
333
src/components/AppShell.jsx
Normal file
333
src/components/AppShell.jsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { BrandLogo } from './Brand.jsx'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
|
||||||
|
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
||||||
|
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
||||||
|
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
|
||||||
|
{ href: '/laudos', label: 'Laudos', icon: 'clipboard' },
|
||||||
|
{
|
||||||
|
href: '/camunicacao',
|
||||||
|
label: 'Comunicação',
|
||||||
|
icon: 'message',
|
||||||
|
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
|
||||||
|
},
|
||||||
|
{ href: '/relatorios', label: 'Relatórios', icon: 'chart' },
|
||||||
|
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
'/inicio': 'Painel',
|
||||||
|
'/home': 'Painel',
|
||||||
|
'/dashboard': 'Painel',
|
||||||
|
'/agenda': 'Agenda',
|
||||||
|
'/consultas': 'Consultas',
|
||||||
|
'/laudos': 'Laudos',
|
||||||
|
'/pacientes': 'Pacientes',
|
||||||
|
'/prontuario': 'Prontuário',
|
||||||
|
'/camunicacao': 'Comunicação',
|
||||||
|
'/comunicacao': 'Comunicação',
|
||||||
|
'/mensagens': 'Comunicação',
|
||||||
|
'/relatorios': 'Relatórios',
|
||||||
|
'/profissionais': 'Profissionais',
|
||||||
|
'/perfil': 'Perfil',
|
||||||
|
'/configuracoes': 'Configurações',
|
||||||
|
'/config': 'Configurações',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [quickSearch, setQuickSearch] = useState('')
|
||||||
|
|
||||||
|
const pageTitle = useMemo(() => {
|
||||||
|
if (currentPath.startsWith('/pacientes/') && routeTitle) {
|
||||||
|
return routeTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
return routeTitle || titles[currentPath] || 'MediConnect'
|
||||||
|
}, [currentPath, routeTitle])
|
||||||
|
|
||||||
|
function goTo(path) {
|
||||||
|
setMenuOpen(false)
|
||||||
|
navigate(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
|
||||||
|
<a
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-[#262626] focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-[#3b82f6]"
|
||||||
|
href="#app-content"
|
||||||
|
>
|
||||||
|
Pular para conteudo
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-40 flex w-64 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-transform duration-200 lg:translate-x-0 ${
|
||||||
|
menuOpen ? 'translate-x-0' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-16 items-center border-b border-[#404040] px-3">
|
||||||
|
<BrandLogo
|
||||||
|
iconClassName="size-8 rounded-sm"
|
||||||
|
markClassName="size-5"
|
||||||
|
textClassName="text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto px-2 pt-4" aria-label="Principal">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavItem
|
||||||
|
active={isActive(currentPath, item)}
|
||||||
|
item={item}
|
||||||
|
key={`${item.label}-${item.href}`}
|
||||||
|
onNavigate={goTo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md border border-[#404040] bg-[#303030] px-3 py-2.5 text-left transition hover:border-[#525252] hover:bg-[#333333]"
|
||||||
|
onClick={() => goTo('/perfil')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<p className="truncate text-xs font-semibold text-[#e5e5e5]">Dr. Henrique Cardoso</p>
|
||||||
|
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">Médico Clínico Geral</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{menuOpen ? (
|
||||||
|
<button
|
||||||
|
aria-label="Fechar menu"
|
||||||
|
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setMenuOpen(false)}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="lg:pl-64">
|
||||||
|
<header className="sticky top-0 z-20 h-auto border-b border-[#404040] bg-[#262626] px-4 py-3 md:px-8 lg:h-16 lg:py-0">
|
||||||
|
<div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<button
|
||||||
|
aria-label="Abrir menu"
|
||||||
|
className="rounded-md border border-[#404040] bg-[#303030] px-3 py-2 text-sm font-semibold text-[#e5e5e5] lg:hidden"
|
||||||
|
onClick={() => setMenuOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Menu
|
||||||
|
</button>
|
||||||
|
<div className="relative w-full max-w-sm lg:w-96">
|
||||||
|
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" />
|
||||||
|
<input
|
||||||
|
aria-label="Busca rapida"
|
||||||
|
className="h-[38px] w-full rounded-sm border border-[#404040] bg-[#303030] py-2 pl-10 pr-4 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
||||||
|
onChange={(event) => setQuickSearch(event.target.value)}
|
||||||
|
placeholder="Buscar paciente, prontuário..."
|
||||||
|
value={quickSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<button
|
||||||
|
aria-label="Notificacoes"
|
||||||
|
className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<BellIcon className="size-5" />
|
||||||
|
<span className="absolute right-0 top-0 grid size-4 place-items-center rounded-full bg-[#ef4444] text-[10px] font-bold leading-none text-white">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="hidden h-6 w-px bg-[#404040] sm:block" aria-hidden="true" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex min-w-0 items-center gap-3 text-left"
|
||||||
|
onClick={() => goTo('/perfil')}
|
||||||
|
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]">
|
||||||
|
HC
|
||||||
|
</span>
|
||||||
|
<span className="hidden min-w-0 sm:block">
|
||||||
|
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
||||||
|
Dr. Henrique Cardoso
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
||||||
|
Médico(a)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{quickSearch ? (
|
||||||
|
<div className="mt-3 rounded-md border border-[#404040] bg-[#303030] px-4 py-3 text-sm text-[#a3a3a3] lg:absolute lg:left-8 lg:top-[52px] lg:w-96">
|
||||||
|
Busca local ativa por <strong className="text-[#e5e5e5]">{quickSearch}</strong>.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="w-full px-4 py-6 md:px-8 md:py-8" id="app-content">
|
||||||
|
<div className="sr-only" aria-live="polite">
|
||||||
|
{pageTitle}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ active, item, onNavigate }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={`flex h-9 items-center gap-3 rounded-sm px-2 text-sm font-medium transition ${
|
||||||
|
active ? 'bg-[#3b82f6]/10 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
href={item.href}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onNavigate(item.href)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppIcon className="size-5 shrink-0" name={item.icon} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(pathname, item) {
|
||||||
|
if (item.activePaths?.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.activePrefixes?.some((path) => pathname.startsWith(path))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.exact) {
|
||||||
|
return pathname === item.href
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathname === item.href || pathname.startsWith(`${item.href}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppIcon({ className = 'size-5', name }) {
|
||||||
|
const common = {
|
||||||
|
className,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'calendar') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'users') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM20 19a3 3 0 0 0-3-3M4 19a3 3 0 0 1 3-3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'file') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 3h7l4 4v14H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" />
|
||||||
|
<path d="M14 3v5h5M9 13h6M9 17h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'clipboard') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M9 5h6M9 5a3 3 0 0 1 6 0M8 6H6a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-2M8 13h8M8 17h5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'message') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M5 5h14v10H8l-4 4V6a1 1 0 0 1 1-1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'chart') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 17 9 11l4 4 7-9" />
|
||||||
|
<path d="M4 20h16" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'dollar') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 2v20M17 6.5C15.8 5.4 14.2 5 12.5 5 9.9 5 8 6.2 8 8s1.6 2.7 4.2 3.3C15 12 17 13 17 15.5S14.8 19 12 19c-2 0-3.8-.6-5-1.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'settings') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3v3M12 18v3M4.9 4.9 7 7M17 17l2.1 2.1M3 12h3M18 12h3M4.9 19.1 7 17M17 7l2.1-2.1" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 12h4l2-5 4 10 2-5h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BellIcon({ className = 'size-5' }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||||
|
<path d="M10 21h4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon({ 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="m21 21-4.3-4.3" />
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/Brand.jsx
Normal file
36
src/components/Brand.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export function BrandLogo({
|
||||||
|
className = '',
|
||||||
|
iconClassName = 'size-10 rounded-[6px]',
|
||||||
|
markClassName = 'size-6',
|
||||||
|
textClassName = 'text-2xl font-bold leading-8 tracking-[-0.025em] text-white',
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-3 ${className}`}>
|
||||||
|
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
|
||||||
|
<StethoscopeIcon className={markClassName} />
|
||||||
|
</div>
|
||||||
|
<p className={textClassName}>MediConnect</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StethoscopeIcon({ className = 'size-6' }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M11 2v2" />
|
||||||
|
<path d="M5 2v2" />
|
||||||
|
<path d="M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1" />
|
||||||
|
<path d="M8 15a6 6 0 0 0 12 0v-3" />
|
||||||
|
<circle cx="20" cy="10" r="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
205
src/components/ui.jsx
Normal file
205
src/components/ui.jsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
const toneClasses = {
|
||||||
|
blue: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||||
|
green: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||||
|
amber: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
|
red: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||||
|
slate: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||||
|
neutral: 'bg-white text-slate-700 border-slate-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonVariants = {
|
||||||
|
primary:
|
||||||
|
'border-sky-700 bg-sky-700 text-white hover:bg-sky-800 focus-visible:outline-sky-700',
|
||||||
|
secondary:
|
||||||
|
'border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus-visible:outline-slate-500',
|
||||||
|
ghost:
|
||||||
|
'border-transparent bg-transparent text-slate-600 hover:bg-slate-100 focus-visible:outline-slate-500',
|
||||||
|
danger:
|
||||||
|
'border-rose-600 bg-rose-600 text-white hover:bg-rose-700 focus-visible:outline-rose-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
variant = 'primary',
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`inline-flex min-h-10 items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${buttonVariants[variant]} ${className}`}
|
||||||
|
type={type}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = '' }) {
|
||||||
|
return (
|
||||||
|
<section className={`rounded-lg border border-slate-200 bg-white shadow-sm ${className}`}>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ children, tone = 'neutral', className = '' }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-semibold ${toneClasses[tone] || toneClasses.neutral} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ actions, description, eyebrow, title }) {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
{eyebrow ? (
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-[#3b82f6]">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<h1 className="mt-1 text-3xl font-bold tracking-tight text-[#e5e5e5] md:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-[#a3a3a3] md:text-base">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ helper, label, tone = 'slate', value }) {
|
||||||
|
return (
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500">{label}</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold text-slate-950">{value}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`h-3 w-3 rounded-sm ${dotTone(tone)}`} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-slate-600">{helper}</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ action, description, title }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-950">{title}</h3>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-slate-600">{description}</p>
|
||||||
|
{action ? <div className="mt-5 flex justify-center">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({ children, hint, label }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-2 text-sm font-semibold text-slate-700">
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
{hint ? <span className="text-xs font-normal text-slate-500">{hint}</span> : null}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput({ className = '', ...props }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={`min-h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-sky-600 focus:ring-2 focus:ring-sky-100 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectInput({ children, className = '', ...props }) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={`min-h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition focus:border-sky-600 focus:ring-2 focus:ring-sky-100 ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({ className = '', ...props }) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={`min-h-28 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-950 outline-none transition placeholder:text-slate-400 focus:border-sky-600 focus:ring-2 focus:ring-sky-100 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({ active, items, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 rounded-lg border border-slate-200 bg-white p-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
className={`rounded-md px-3 py-2 text-sm font-semibold transition ${
|
||||||
|
active === item.value
|
||||||
|
? 'bg-sky-700 text-white'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-950'
|
||||||
|
}`}
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => onChange(item.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ actions, children, onClose, open, title }) {
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/50 p-4 sm:items-center">
|
||||||
|
<div className="w-full max-w-xl rounded-lg border border-slate-200 bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
|
||||||
|
<button
|
||||||
|
aria-label="Fechar"
|
||||||
|
className="rounded-md px-2 py-1 text-xl leading-none text-slate-500 hover:bg-slate-100"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-5">{children}</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-200 px-5 py-4">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotTone(tone) {
|
||||||
|
const dots = {
|
||||||
|
blue: 'bg-sky-500',
|
||||||
|
green: 'bg-emerald-500',
|
||||||
|
amber: 'bg-amber-500',
|
||||||
|
red: 'bg-rose-500',
|
||||||
|
slate: 'bg-slate-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
return dots[tone] || dots.slate
|
||||||
|
}
|
||||||
328
src/data/mockData.js
Normal file
328
src/data/mockData.js
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
export const todayLabel = '07 abr 2026'
|
||||||
|
|
||||||
|
export const dashboardMetrics = [
|
||||||
|
{
|
||||||
|
label: 'Consultas hoje',
|
||||||
|
value: '18',
|
||||||
|
helper: '6 por teleconsulta',
|
||||||
|
tone: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Pacientes em acompanhamento',
|
||||||
|
value: '124',
|
||||||
|
helper: '12 com prioridade alta',
|
||||||
|
tone: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mensagens novas',
|
||||||
|
value: '9',
|
||||||
|
helper: '4 aguardando resposta',
|
||||||
|
tone: 'amber',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Documentos pendentes',
|
||||||
|
value: '7',
|
||||||
|
helper: 'Exames e receitas',
|
||||||
|
tone: 'slate',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const patients = [
|
||||||
|
{
|
||||||
|
id: 'ana-souza',
|
||||||
|
name: 'Ana Souza',
|
||||||
|
age: 42,
|
||||||
|
document: 'CPF 284.019.430-10',
|
||||||
|
plan: 'Unimed',
|
||||||
|
condition: 'Diabetes tipo 2',
|
||||||
|
status: 'Acompanhamento',
|
||||||
|
risk: 'Moderado',
|
||||||
|
phone: '(81) 98812-2301',
|
||||||
|
email: 'ana.souza@email.com',
|
||||||
|
address: 'Rua das Flores, 220',
|
||||||
|
lastVisit: '31 mar 2026',
|
||||||
|
nextVisit: '08 abr 2026, 10:00',
|
||||||
|
team: ['Dra. Marina Lopes', 'Enf. Paulo Reis'],
|
||||||
|
notes: [
|
||||||
|
'Paciente relatou melhora na rotina alimentar.',
|
||||||
|
'Solicitar retorno com glicemia de jejum atualizada.',
|
||||||
|
],
|
||||||
|
exams: ['Hemoglobina glicada', 'Glicemia de jejum', 'Perfil lipidico'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bruno-lima',
|
||||||
|
name: 'Bruno Lima',
|
||||||
|
age: 35,
|
||||||
|
document: 'CPF 031.762.880-04',
|
||||||
|
plan: 'SulAmerica',
|
||||||
|
condition: 'Hipertensao',
|
||||||
|
status: 'Retorno',
|
||||||
|
risk: 'Alto',
|
||||||
|
phone: '(81) 99744-9011',
|
||||||
|
email: 'bruno.lima@email.com',
|
||||||
|
address: 'Av. Norte, 1180',
|
||||||
|
lastVisit: '02 abr 2026',
|
||||||
|
nextVisit: '07 abr 2026, 14:30',
|
||||||
|
team: ['Dr. Rafael Nunes', 'Nutri. Clara Meireles'],
|
||||||
|
notes: [
|
||||||
|
'Pressao ainda oscilando no periodo da tarde.',
|
||||||
|
'Conferir adesao ao medicamento e orientar diario de pressao.',
|
||||||
|
],
|
||||||
|
exams: ['MAPA 24h', 'Eletrocardiograma', 'Creatinina'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'carla-mendes',
|
||||||
|
name: 'Carla Mendes',
|
||||||
|
age: 29,
|
||||||
|
document: 'CPF 740.991.112-80',
|
||||||
|
plan: 'Particular',
|
||||||
|
condition: 'Pre-natal',
|
||||||
|
status: 'Primeira consulta',
|
||||||
|
risk: 'Baixo',
|
||||||
|
phone: '(81) 98120-4477',
|
||||||
|
email: 'carla.mendes@email.com',
|
||||||
|
address: 'Rua Aurora, 90',
|
||||||
|
lastVisit: 'Sem historico',
|
||||||
|
nextVisit: '09 abr 2026, 08:30',
|
||||||
|
team: ['Dra. Marina Lopes'],
|
||||||
|
notes: [
|
||||||
|
'Primeiro atendimento cadastrado pela recepcao.',
|
||||||
|
'Confirmar exames iniciais e historico familiar.',
|
||||||
|
],
|
||||||
|
exams: ['Beta HCG', 'Ultrassom obstetrico', 'Hemograma'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'diego-alves',
|
||||||
|
name: 'Diego Alves',
|
||||||
|
age: 51,
|
||||||
|
document: 'CPF 607.113.904-18',
|
||||||
|
plan: 'Bradesco Saude',
|
||||||
|
condition: 'Pos-operatorio',
|
||||||
|
status: 'Monitoramento',
|
||||||
|
risk: 'Moderado',
|
||||||
|
phone: '(81) 98772-5330',
|
||||||
|
email: 'diego.alves@email.com',
|
||||||
|
address: 'Rua Imperial, 410',
|
||||||
|
lastVisit: '05 abr 2026',
|
||||||
|
nextVisit: '10 abr 2026, 16:00',
|
||||||
|
team: ['Dr. Rafael Nunes', 'Fisio. Jonas Pedro'],
|
||||||
|
notes: [
|
||||||
|
'Evolucao dentro do esperado no curativo.',
|
||||||
|
'Manter avaliacao de dor e mobilidade nos proximos contatos.',
|
||||||
|
],
|
||||||
|
exams: ['Raio X controle', 'Hemograma', 'PCR'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const appointments = [
|
||||||
|
{
|
||||||
|
id: 'apt-001',
|
||||||
|
date: '2026-04-07',
|
||||||
|
time: '08:00',
|
||||||
|
patient: 'Carla Mendes',
|
||||||
|
patientId: 'carla-mendes',
|
||||||
|
professional: 'Dra. Marina Lopes',
|
||||||
|
type: 'Consulta inicial',
|
||||||
|
room: 'Sala 01',
|
||||||
|
status: 'Confirmada',
|
||||||
|
mode: 'Presencial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apt-002',
|
||||||
|
date: '2026-04-07',
|
||||||
|
time: '09:30',
|
||||||
|
patient: 'Ana Souza',
|
||||||
|
patientId: 'ana-souza',
|
||||||
|
professional: 'Dra. Marina Lopes',
|
||||||
|
type: 'Retorno',
|
||||||
|
room: 'Sala virtual 1',
|
||||||
|
status: 'Em triagem',
|
||||||
|
mode: 'Teleconsulta',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apt-003',
|
||||||
|
date: '2026-04-07',
|
||||||
|
time: '11:00',
|
||||||
|
patient: 'Diego Alves',
|
||||||
|
patientId: 'diego-alves',
|
||||||
|
professional: 'Dr. Rafael Nunes',
|
||||||
|
type: 'Acompanhamento',
|
||||||
|
room: 'Sala 03',
|
||||||
|
status: 'Aguardando',
|
||||||
|
mode: 'Presencial',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apt-004',
|
||||||
|
date: '2026-04-07',
|
||||||
|
time: '14:30',
|
||||||
|
patient: 'Bruno Lima',
|
||||||
|
patientId: 'bruno-lima',
|
||||||
|
professional: 'Dr. Rafael Nunes',
|
||||||
|
type: 'Retorno',
|
||||||
|
room: 'Sala virtual 2',
|
||||||
|
status: 'Confirmada',
|
||||||
|
mode: 'Teleconsulta',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const careQueue = [
|
||||||
|
{
|
||||||
|
id: 'queue-001',
|
||||||
|
patient: 'Ana Souza',
|
||||||
|
patientId: 'ana-souza',
|
||||||
|
status: 'Em triagem',
|
||||||
|
priority: 'Moderada',
|
||||||
|
wait: '12 min',
|
||||||
|
reason: 'Controle glicemico',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'queue-002',
|
||||||
|
patient: 'Bruno Lima',
|
||||||
|
patientId: 'bruno-lima',
|
||||||
|
status: 'Aguardando medico',
|
||||||
|
priority: 'Alta',
|
||||||
|
wait: '25 min',
|
||||||
|
reason: 'Pressao elevada',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'queue-003',
|
||||||
|
patient: 'Carla Mendes',
|
||||||
|
patientId: 'carla-mendes',
|
||||||
|
status: 'Finalizada',
|
||||||
|
priority: 'Baixa',
|
||||||
|
wait: '0 min',
|
||||||
|
reason: 'Consulta inicial',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const conversations = [
|
||||||
|
{
|
||||||
|
id: 'conv-ana',
|
||||||
|
patient: 'Ana Souza',
|
||||||
|
patientId: 'ana-souza',
|
||||||
|
subject: 'Duvida sobre exame',
|
||||||
|
unread: 2,
|
||||||
|
lastMessage: 'Enviei o resultado pelo portal.',
|
||||||
|
status: 'Aguardando equipe',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
from: 'patient',
|
||||||
|
body: 'Bom dia, consegui enviar a hemoglobina glicada pelo app?',
|
||||||
|
time: '08:12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'team',
|
||||||
|
body: 'Sim, Ana. Recebemos o arquivo e a Dra. Marina vai revisar antes da consulta.',
|
||||||
|
time: '08:20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'patient',
|
||||||
|
body: 'Obrigada. Enviei o resultado pelo portal.',
|
||||||
|
time: '08:24',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conv-bruno',
|
||||||
|
patient: 'Bruno Lima',
|
||||||
|
patientId: 'bruno-lima',
|
||||||
|
subject: 'Pressao no fim do dia',
|
||||||
|
unread: 1,
|
||||||
|
lastMessage: 'Hoje marcou 15 por 9 novamente.',
|
||||||
|
status: 'Prioridade alta',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
from: 'patient',
|
||||||
|
body: 'Hoje marcou 15 por 9 novamente.',
|
||||||
|
time: '13:05',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'team',
|
||||||
|
body: 'Bruno, vamos acompanhar no retorno de hoje. Traga as medidas da semana.',
|
||||||
|
time: '13:12',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conv-carla',
|
||||||
|
patient: 'Carla Mendes',
|
||||||
|
patientId: 'carla-mendes',
|
||||||
|
subject: 'Confirmacao de horario',
|
||||||
|
unread: 0,
|
||||||
|
lastMessage: 'Confirmado para quinta as 08:30.',
|
||||||
|
status: 'Respondida',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
from: 'team',
|
||||||
|
body: 'Carla, sua consulta ficou confirmada para quinta as 08:30.',
|
||||||
|
time: '17:42',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const professionals = [
|
||||||
|
{
|
||||||
|
id: 'marina-lopes',
|
||||||
|
name: 'Dra. Marina Lopes',
|
||||||
|
role: 'Clinica geral',
|
||||||
|
schedule: 'Seg a sex, 08:00-16:00',
|
||||||
|
status: 'Disponivel',
|
||||||
|
nextSlot: 'Hoje, 15:30',
|
||||||
|
patients: 48,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rafael-nunes',
|
||||||
|
name: 'Dr. Rafael Nunes',
|
||||||
|
role: 'Cardiologista',
|
||||||
|
schedule: 'Ter e qui, 09:00-18:00',
|
||||||
|
status: 'Em atendimento',
|
||||||
|
nextSlot: 'Hoje, 17:00',
|
||||||
|
patients: 36,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clara-meireles',
|
||||||
|
name: 'Nutri. Clara Meireles',
|
||||||
|
role: 'Nutricionista',
|
||||||
|
schedule: 'Seg, qua e sex, 10:00-15:00',
|
||||||
|
status: 'Disponivel',
|
||||||
|
nextSlot: 'Amanha, 10:30',
|
||||||
|
patients: 21,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'paulo-reis',
|
||||||
|
name: 'Enf. Paulo Reis',
|
||||||
|
role: 'Enfermagem',
|
||||||
|
schedule: 'Seg a sex, 07:00-13:00',
|
||||||
|
status: 'Triagem',
|
||||||
|
nextSlot: 'Hoje, 12:10',
|
||||||
|
patients: 64,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const activityFeed = [
|
||||||
|
{
|
||||||
|
id: 'feed-001',
|
||||||
|
title: 'Receita enviada',
|
||||||
|
detail: 'Dra. Marina enviou orientacao para Ana Souza.',
|
||||||
|
time: '09:10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'feed-002',
|
||||||
|
title: 'Triagem aberta',
|
||||||
|
detail: 'Bruno Lima entrou na fila de atendimento.',
|
||||||
|
time: '09:45',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'feed-003',
|
||||||
|
title: 'Documento pendente',
|
||||||
|
detail: 'Exame de Diego Alves aguarda revisao.',
|
||||||
|
time: '10:05',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const reminders = [
|
||||||
|
'Confirmar retornos de alto risco ate 16:00.',
|
||||||
|
'Revisar documentos enviados pelos pacientes.',
|
||||||
|
'Atualizar fila de teleconsultas antes do plantao.',
|
||||||
|
]
|
||||||
45
src/index.css
Normal file
45
src/index.css
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
405
src/pages/AgendaPage.jsx
Normal file
405
src/pages/AgendaPage.jsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { appointmentRepository } from '../repositories/appointmentRepository.js'
|
||||||
|
import { patientRepository } from '../repositories/patientRepository.js'
|
||||||
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ label: 'Todos', value: 'Todos' },
|
||||||
|
{ label: 'Confirmadas', value: 'Confirmada' },
|
||||||
|
{ label: 'Em triagem', value: 'Em triagem' },
|
||||||
|
{ label: 'Aguardando', value: 'Aguardando' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const viewFilters = ['Dia', 'Semana', 'Mês']
|
||||||
|
|
||||||
|
|
||||||
|
export function AgendaPage({ navigate }) {
|
||||||
|
const [patients, setPatients] = useState([])
|
||||||
|
const professionals = professionalRepository.getAll()
|
||||||
|
const queue = appointmentRepository.getPredictiveQueueSummary()
|
||||||
|
const timeline = appointmentRepository.getTodayTimeline()
|
||||||
|
const weekDays = appointmentRepository.getWeekDays()
|
||||||
|
const [activeView, setActiveView] = useState('Dia')
|
||||||
|
const [status, setStatus] = useState('Todos')
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [localAppointments, setLocalAppointments] = useState(() => appointmentRepository.getAll())
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
patientId: '',
|
||||||
|
professional: professionals[0]?.name || '',
|
||||||
|
type: 'Retorno',
|
||||||
|
time: '15:30',
|
||||||
|
mode: 'Teleconsulta',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
patientRepository.getAll().then((data) => {
|
||||||
|
setPatients(data)
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
patientId: data[0]?.id || '',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const visibleAppointments = useMemo(() => {
|
||||||
|
if (status === 'Todos') {
|
||||||
|
return localAppointments
|
||||||
|
}
|
||||||
|
|
||||||
|
return localAppointments.filter((appointment) => appointment.status === status)
|
||||||
|
}, [localAppointments, status])
|
||||||
|
|
||||||
|
function updateForm(field, value) {
|
||||||
|
setForm((current) => ({ ...current, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const patient = patients.find((item) => item.id === form.patientId) || patients[0]
|
||||||
|
|
||||||
|
setLocalAppointments((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: `apt-local-${current.length + 1}`,
|
||||||
|
date: '2026-04-07',
|
||||||
|
patient: patient.name,
|
||||||
|
patientId: patient.id,
|
||||||
|
professional: form.professional,
|
||||||
|
room: form.mode === 'Teleconsulta' ? 'Sala virtual 3' : 'Sala 02',
|
||||||
|
status: 'Confirmada',
|
||||||
|
time: form.time,
|
||||||
|
type: form.type,
|
||||||
|
mode: form.mode,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
||||||
|
<section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
|
||||||
|
Agenda
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
|
||||||
|
Organize consultas, retornos e teleatendimentos do dia.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => setStatus('Todos')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Hoje
|
||||||
|
</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={() => setModalOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ Nova consulta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-5">
|
||||||
|
{weekDays.map((day) => (
|
||||||
|
<button
|
||||||
|
className={`rounded-2xl border p-4 text-left transition ${
|
||||||
|
day.active
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6]/10'
|
||||||
|
: 'border-[#404040] bg-[#262626] hover:border-[#525252]'
|
||||||
|
}`}
|
||||||
|
key={`${day.label}-${day.day}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="block text-xs font-semibold uppercase tracking-[0.16em] text-[#a3a3a3]">
|
||||||
|
{day.label}
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 block text-[32px] font-bold leading-8 text-[#e5e5e5]">{day.day}</span>
|
||||||
|
<span className="mt-3 block text-sm text-[#3b82f6]">{day.count} consultas</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[1.45fr_0.85fr]">
|
||||||
|
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">Terça, 07 abril</h2>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
|
||||||
|
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros no filtro
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{viewFilters.map((view) => (
|
||||||
|
<button
|
||||||
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
|
activeView === view
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
|
||||||
|
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={view}
|
||||||
|
onClick={() => setActiveView(view)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{view}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{statusFilters.map((filter) => (
|
||||||
|
<button
|
||||||
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
|
status === filter.value
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||||
|
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => setStatus(filter.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-3">
|
||||||
|
{visibleAppointments.length ? (
|
||||||
|
visibleAppointments.map((appointment) => (
|
||||||
|
<AgendaListItem
|
||||||
|
appointment={appointment}
|
||||||
|
key={appointment.id}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
|
||||||
|
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
||||||
|
Ajuste o filtro ou crie uma consulta mockada para este período.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||||
|
<h2 className="text-base font-bold text-[#e5e5e5]">Linha do tempo</h2>
|
||||||
|
<div className="mt-5 grid gap-1">
|
||||||
|
{timeline.map((item) => (
|
||||||
|
<button
|
||||||
|
className="grid grid-cols-[58px_1fr] gap-4 rounded-md px-2 py-3 text-left transition hover:bg-[#303030]"
|
||||||
|
disabled={!item.patientId}
|
||||||
|
key={`${item.hour}-${item.patient}`}
|
||||||
|
onClick={() => item.patientId && navigate(`/pacientes/${item.patientId}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-[#3b82f6]">{item.hour}</span>
|
||||||
|
<span className="border-l border-[#404040] pl-4">
|
||||||
|
<span className="block text-sm font-semibold text-[#e5e5e5]">{item.patient}</span>
|
||||||
|
<span className="mt-1 block text-xs text-[#a3a3a3]">{item.type}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||||
|
<h2 className="text-base font-bold text-[#e5e5e5]">Resumo preditivo</h2>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{queue.map((item) => (
|
||||||
|
<div className="flex items-center justify-between rounded-md bg-[#2a2a2a] px-4 py-3" key={item.label}>
|
||||||
|
<span className="text-sm font-medium text-[#a3a3a3]">{item.label}</span>
|
||||||
|
<span className={`text-lg font-bold ${queueTone(item.tone)}`}>{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="mt-5 h-9 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6] hover:text-[#3b82f6]"
|
||||||
|
onClick={() => navigate('/mensagens')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Confirmar presenças
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
|
||||||
|
<form className="grid gap-4" onSubmit={handleCreate}>
|
||||||
|
<DarkField label="Paciente">
|
||||||
|
<select
|
||||||
|
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => updateForm('patientId', event.target.value)}
|
||||||
|
value={form.patientId}
|
||||||
|
>
|
||||||
|
{patients.map((patient) => (
|
||||||
|
<option key={patient.id} value={patient.id}>
|
||||||
|
{patient.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<DarkField label="Horário">
|
||||||
|
<input
|
||||||
|
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => updateForm('time', event.target.value)}
|
||||||
|
type="time"
|
||||||
|
value={form.time}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Formato">
|
||||||
|
<select
|
||||||
|
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => updateForm('mode', event.target.value)}
|
||||||
|
value={form.mode}
|
||||||
|
>
|
||||||
|
<option>Teleconsulta</option>
|
||||||
|
<option>Presencial</option>
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DarkField label="Profissional">
|
||||||
|
<select
|
||||||
|
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => updateForm('professional', event.target.value)}
|
||||||
|
value={form.professional}
|
||||||
|
>
|
||||||
|
{professionals.map((professional) => (
|
||||||
|
<option key={professional.id}>{professional.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Tipo de consulta">
|
||||||
|
<input
|
||||||
|
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||||
|
onChange={(event) => updateForm('type', event.target.value)}
|
||||||
|
value={form.type}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed]"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Salvar consulta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DarkModal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgendaListItem({ appointment, navigate }) {
|
||||||
|
return (
|
||||||
|
<article className="grid gap-4 rounded-xl border border-[#404040] bg-[#1f1f1f] p-4 md:grid-cols-[72px_1fr_auto]">
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold leading-7 text-[#e5e5e5]">{appointment.time}</p>
|
||||||
|
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-[#737373]">
|
||||||
|
{appointment.mode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="text-left text-base font-bold text-[#e5e5e5] transition hover:text-[#3b82f6]"
|
||||||
|
onClick={() => navigate(`/pacientes/${appointment.patientId}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{appointment.patient}
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">
|
||||||
|
{appointment.type} com {appointment.professional}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs font-medium text-[#737373]">{appointment.room}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3 md:justify-end">
|
||||||
|
<StatusPill status={appointment.status} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DarkField({ children, label }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]">
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DarkModal({ children, onClose, open, title }) {
|
||||||
|
if (!open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
||||||
|
<div className="w-full max-w-xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-[#404040] px-5 py-4">
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">{title}</h2>
|
||||||
|
<button
|
||||||
|
aria-label="Fechar"
|
||||||
|
className="grid size-8 place-items-center rounded-sm text-xl leading-none text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }) {
|
||||||
|
const classes = {
|
||||||
|
Confirmada: 'border-[#14532d] bg-[#052e1a] text-[#10b981]',
|
||||||
|
'Em triagem': 'border-[#78350f] bg-[#2d1e05] text-[#f59e0b]',
|
||||||
|
Aguardando: 'border-[#404040] bg-[#303030] text-[#a3a3a3]',
|
||||||
|
Bloqueado: 'border-[#404040] bg-[#303030] text-[#737373]',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`rounded-full border px-3 py-1 text-xs font-bold ${classes[status] || classes.Aguardando}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueTone(tone) {
|
||||||
|
if (tone === 'red') {
|
||||||
|
return 'text-[#ef4444]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === 'amber') {
|
||||||
|
return 'text-[#f59e0b]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-[#3b82f6]'
|
||||||
|
}
|
||||||
385
src/pages/AnalyticsPage.jsx
Normal file
385
src/pages/AnalyticsPage.jsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { analyticsRepository } from '../repositories/analyticsRepository.js'
|
||||||
|
|
||||||
|
const periods = [
|
||||||
|
['1m', '1 Mes'],
|
||||||
|
['3m', '3 Meses'],
|
||||||
|
['6m', '6 Meses'],
|
||||||
|
['1a', '1 Ano'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
|
export function AnalyticsPage() {
|
||||||
|
const {
|
||||||
|
absenteeismData,
|
||||||
|
consultationsData,
|
||||||
|
doctorPerformance,
|
||||||
|
insuranceData,
|
||||||
|
kpis,
|
||||||
|
revenueData,
|
||||||
|
topPatients,
|
||||||
|
} = analyticsRepository.getDashboardData()
|
||||||
|
const [period, setPeriod] = useState('6m')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<section 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]">Relatórios & Analytics</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#b8b8b8]">Dashboard executivo com métricas de desempenho</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex overflow-hidden rounded-sm border border-[#404040] bg-[#171717]">
|
||||||
|
{periods.map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
className={`h-9 px-4 text-xs font-semibold transition ${
|
||||||
|
period === key ? 'bg-[#3b82f6] text-white' : 'text-[#b8b8b8] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={key}
|
||||||
|
onClick={() => setPeriod(key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-4 md:grid-cols-4" aria-label="Indicadores principais">
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<KpiCard key={kpi.label} kpi={kpi} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 lg:grid-cols-2" aria-label="Gráficos principais">
|
||||||
|
<ChartCard description="Evolução mensal vs meta" title="Taxa de Absenteísmo">
|
||||||
|
<AreaMetricChart data={absenteeismData} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard description="Agendadas vs realizadas" title="Consultas por Período">
|
||||||
|
<GroupedBarChart data={consultationsData} />
|
||||||
|
</ChartCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 lg:grid-cols-3" aria-label="Relatórios complementares">
|
||||||
|
<ChartCard description="Evolução de receita" title="Faturamento Mensal">
|
||||||
|
<RevenueChart data={revenueData} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard description="Distribuição de atendimentos" title="Convênios">
|
||||||
|
<InsuranceBreakdown insuranceData={insuranceData} />
|
||||||
|
</ChartCard>
|
||||||
|
|
||||||
|
<ChartCard description="Mais atendidos no período" title="Top Pacientes">
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
{topPatients.map((patient, index) => (
|
||||||
|
<div className="flex items-center gap-3" key={patient.name}>
|
||||||
|
<span className="w-4 text-xs font-bold text-[#a3a3a3]">{index + 1}.</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-semibold text-[#f5f5f5]">{patient.name}</p>
|
||||||
|
<p className="mt-0.5 text-[10px] text-[#a3a3a3]">
|
||||||
|
{patient.visits} visitas • R$ {patient.revenue.toLocaleString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-[#303030]">
|
||||||
|
<div className="h-full rounded-full bg-[#3b82f6]" style={{ width: `${(patient.visits / 12) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${cardClass} p-6`} aria-label="Performance por médico">
|
||||||
|
<h2 className="mb-4 text-sm font-bold text-[#f5f5f5]">Performance por Médico</h2>
|
||||||
|
<div className="overflow-x-auto rounded-sm border border-[#404040]">
|
||||||
|
<table className="w-full min-w-[760px] text-left text-sm">
|
||||||
|
<thead className="bg-[#171717] text-xs font-semibold uppercase tracking-[0.02em] text-[#b8b8b8]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Profissional</th>
|
||||||
|
<th className="px-4 py-3">Consultas</th>
|
||||||
|
<th className="px-4 py-3">No-Show</th>
|
||||||
|
<th className="px-4 py-3">Taxa No-Show</th>
|
||||||
|
<th className="px-4 py-3">Satisfação</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
||||||
|
{doctorPerformance.map((doctor) => {
|
||||||
|
const noShowRate = (doctor.noShow / doctor.consultas) * 100
|
||||||
|
return (
|
||||||
|
<tr className="transition hover:bg-[#303030]" key={doctor.name}>
|
||||||
|
<td className="px-4 py-3 font-semibold text-[#f5f5f5]">{doctor.name}</td>
|
||||||
|
<td className="px-4 py-3 text-[#e5e5e5]">{doctor.consultas}</td>
|
||||||
|
<td className="px-4 py-3 text-[#b8b8b8]">{doctor.noShow}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs font-semibold ${rateClass(noShowRate)}`}>{noShowRate.toFixed(1)}%</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="inline-flex items-center gap-1 text-sm font-semibold text-[#f5f5f5]">
|
||||||
|
<span className="text-amber-400">★</span>
|
||||||
|
{doctor.satisfacao}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ kpi }) {
|
||||||
|
return (
|
||||||
|
<article className={`${cardClass} p-5`}>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-xs font-medium text-[#a3a3a3]">{kpi.label}</p>
|
||||||
|
<AnalyticsIcon className="size-4 text-[#a3a3a3]" name={kpi.icon} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xl font-bold leading-none text-[#f5f5f5]">{kpi.value}</p>
|
||||||
|
<span className="mt-2 flex items-center gap-1 text-xs font-semibold text-emerald-500">
|
||||||
|
<AnalyticsIcon className="size-3.5" name={kpi.up ? 'arrow-up' : 'arrow-down'} />
|
||||||
|
{kpi.change} vs período anterior
|
||||||
|
</span>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartCard({ children, description, title }) {
|
||||||
|
return (
|
||||||
|
<article className={`${cardClass} p-6`}>
|
||||||
|
<h2 className="text-sm font-bold text-[#f5f5f5]">{title}</h2>
|
||||||
|
<p className="mt-1 text-xs text-[#a3a3a3]">{description}</p>
|
||||||
|
<div className="mt-4">{children}</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AreaMetricChart({ data }) {
|
||||||
|
const points = getLinePoints(data.map((item) => item.taxa), 0, 24)
|
||||||
|
const metaPoints = getLinePoints(data.map((item) => item.meta), 0, 24)
|
||||||
|
const area = `${points} 600,260 42,260`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="h-[250px] w-full overflow-visible" role="img" viewBox="0 0 640 300">
|
||||||
|
<ChartGrid labels={[24, 18, 12, 6, 0]} />
|
||||||
|
<polygon fill="#3b82f6" opacity="0.12" points={area} />
|
||||||
|
<polyline fill="none" points={metaPoints} stroke="#64748b" strokeDasharray="6 8" strokeWidth="2" />
|
||||||
|
<polyline fill="none" points={points} stroke="#3b82f6" strokeLinecap="round" strokeLinejoin="round" strokeWidth="4" />
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<text className="fill-[#94a3b8] text-[13px]" key={item.month} x={42 + index * 111.6} y="285" textAnchor="middle">
|
||||||
|
{item.month}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupedBarChart({ data }) {
|
||||||
|
return (
|
||||||
|
<svg className="h-[250px] w-full overflow-visible" role="img" viewBox="0 0 640 300">
|
||||||
|
<ChartGrid labels={[600, 450, 300, 150, 0]} />
|
||||||
|
{data.map((item, index) => {
|
||||||
|
const x = 58 + index * 94
|
||||||
|
const totalHeight = (item.total / 600) * 220
|
||||||
|
const doneHeight = (item.realizadas / 600) * 220
|
||||||
|
return (
|
||||||
|
<g key={item.month}>
|
||||||
|
<rect fill="#475569" height={totalHeight} rx="5" width="32" x={x} y={260 - totalHeight} />
|
||||||
|
<rect fill="#3b82f6" height={doneHeight} rx="5" width="32" x={x + 38} y={260 - doneHeight} />
|
||||||
|
<text className="fill-[#94a3b8] text-[13px]" textAnchor="middle" x={x + 35} y="285">
|
||||||
|
{item.month}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevenueChart({ data }) {
|
||||||
|
const points = getLinePoints(
|
||||||
|
data.map((item) => item.valor),
|
||||||
|
30000,
|
||||||
|
60000,
|
||||||
|
{ left: 32, top: 18, width: 270, height: 160 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="h-[200px] w-full overflow-visible" role="img" viewBox="0 0 340 220">
|
||||||
|
{[0, 1, 2, 3].map((line) => (
|
||||||
|
<line key={line} stroke="#1e3a5f" strokeDasharray="3 5" x1="32" x2="320" y1={20 + line * 50} y2={20 + line * 50} />
|
||||||
|
))}
|
||||||
|
<polyline fill="none" points={points} stroke="#10b981" strokeLinecap="round" strokeLinejoin="round" strokeWidth="4" />
|
||||||
|
{points.split(' ').map((point, index) => {
|
||||||
|
const [x, y] = point.split(',').map(Number)
|
||||||
|
return <circle cx={x} cy={y} fill="#10b981" key={point} r={4 + (index === data.length - 1 ? 1 : 0)} />
|
||||||
|
})}
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<text className="fill-[#94a3b8] text-[11px]" key={item.month} textAnchor="middle" x={32 + index * 54} y="205">
|
||||||
|
{item.month}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsuranceBreakdown({ insuranceData }) {
|
||||||
|
const radius = 42
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const segments = insuranceData.reduce((items, item) => {
|
||||||
|
const dash = (item.value / 100) * circumference
|
||||||
|
const previous = items.at(-1)
|
||||||
|
const offset = previous ? previous.offset + previous.dash + 4 : 0
|
||||||
|
|
||||||
|
return [...items, { ...item, dash, offset }]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<svg className="h-[160px] w-[160px]" viewBox="0 0 120 120">
|
||||||
|
<circle cx="60" cy="60" fill="none" r={radius} stroke="#303030" strokeWidth="18" />
|
||||||
|
{segments.map((item) => (
|
||||||
|
<circle
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
fill="none"
|
||||||
|
key={item.name}
|
||||||
|
r={radius}
|
||||||
|
stroke={item.color}
|
||||||
|
strokeDasharray={`${item.dash} ${circumference - item.dash}`}
|
||||||
|
strokeDashoffset={-item.offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="18"
|
||||||
|
transform="rotate(-90 60 60)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<circle cx="60" cy="60" fill="#262626" r="25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{insuranceData.map((item) => (
|
||||||
|
<div className="flex items-center justify-between text-xs" key={item.name}>
|
||||||
|
<span className="flex items-center gap-2 text-[#e5e5e5]">
|
||||||
|
<span className="size-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#a3a3a3]">{item.value}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartGrid({ labels }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{labels.map((label, index) => {
|
||||||
|
const y = 20 + index * 60
|
||||||
|
return (
|
||||||
|
<g key={label}>
|
||||||
|
<line stroke="#1e3a5f" strokeDasharray="3 5" x1="42" x2="600" y1={y} y2={y} />
|
||||||
|
<text className="fill-[#94a3b8] text-[13px]" textAnchor="end" x="24" y={y + 4}>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinePoints(values, min, max, box = { left: 42, top: 20, width: 558, height: 240 }) {
|
||||||
|
return values
|
||||||
|
.map((value, index) => {
|
||||||
|
const x = box.left + (index / Math.max(values.length - 1, 1)) * box.width
|
||||||
|
const y = box.top + ((max - value) / (max - min)) * box.height
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateClass(rate) {
|
||||||
|
if (rate > 15) {
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rate > 10) {
|
||||||
|
return 'text-amber-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text-emerald-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalyticsIcon({ className = 'size-4', name }) {
|
||||||
|
const common = {
|
||||||
|
className,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeWidth: 1.9,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'calendar') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'activity') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 12h4l2-6 4 12 2-6h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'dollar') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 2v20M17 6.5C15.8 5.4 14.2 5 12.5 5 9.9 5 8 6.2 8 8s1.6 2.7 4.2 3.3C15 12 17 13 17 15.5S14.8 19 12 19c-2 0-3.8-.6-5-1.8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'users') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM20 19a3 3 0 0 0-3-3M4 19a3 3 0 0 1 3-3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'arrow-up') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 17 17 7M8 7h9v9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'arrow-down') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 7 17 17M17 8v9H8" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 17 9 11l4 4 7-9" />
|
||||||
|
<path d="M4 20h16" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
344
src/pages/AuthPages.jsx
Normal file
344
src/pages/AuthPages.jsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { BrandLogo } from '../components/Brand.jsx'
|
||||||
|
import loginClinicImage from '../assets/figma/login-clinic.png'
|
||||||
|
|
||||||
|
export function LoginPage({ navigate }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
|
function updateField(field, value) {
|
||||||
|
setForm((current) => ({ ...current, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate('/inicio')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#0a1628] text-white">
|
||||||
|
<div className="grid min-h-screen lg:grid-cols-2">
|
||||||
|
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
src={loginClinicImage}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(126.72deg, rgba(10, 22, 40, 0.9) 0%, rgba(10, 22, 40, 0.6) 50%, rgba(59, 130, 246, 0.3) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
|
||||||
|
<LoginLogo />
|
||||||
|
|
||||||
|
<div className="max-w-[488px] pb-0">
|
||||||
|
<h1 className="text-[32px] font-bold leading-[40px] tracking-[-0.02em] xl:text-4xl xl:leading-[45px]">
|
||||||
|
Gestão clínica
|
||||||
|
<br />
|
||||||
|
<span className="text-[#3b82f6]">inteligente</span> com IA
|
||||||
|
<br />
|
||||||
|
preditiva.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-[352px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
|
||||||
|
Reduza o absenteísmo, organize sua agenda e melhore a experiência dos seus pacientes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="mt-[38px] flex flex-wrap gap-8">
|
||||||
|
<LoginMetric label="Acurácia IA" value="87%" />
|
||||||
|
<LoginMetric label="Absenteísmo" value="↓42%" />
|
||||||
|
<LoginMetric label="Clínicas" value="+2.8k" />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="relative flex min-h-screen items-center justify-center px-6 py-12 sm:px-10 lg:px-[60px] xl:px-[68px]">
|
||||||
|
<div className="w-full max-w-[448px] lg:translate-y-3">
|
||||||
|
<div className="mb-12 lg:hidden">
|
||||||
|
<LoginLogo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[30px] font-bold leading-9 text-white">Entrar</h2>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-white/40">
|
||||||
|
Bem-vindo(a) de volta! Acesse sua conta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="mt-8 grid gap-5" onSubmit={handleSubmit}>
|
||||||
|
<LoginField htmlFor="login-email" label="E-mail">
|
||||||
|
<input
|
||||||
|
autoComplete="email"
|
||||||
|
className="h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] px-4 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
||||||
|
id="login-email"
|
||||||
|
onChange={(event) => updateField('email', event.target.value)}
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
/>
|
||||||
|
</LoginField>
|
||||||
|
|
||||||
|
<LoginField
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
className="text-xs font-medium leading-4 text-[#3b82f6] transition hover:text-[#66a3ff]"
|
||||||
|
onClick={() => navigate('/recuperar-senha')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Esqueceu a senha?
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
htmlFor="login-password"
|
||||||
|
label="Senha"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] py-2 pl-4 pr-11 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
||||||
|
id="login-password"
|
||||||
|
onChange={(event) => updateField('password', event.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label={showPassword ? 'Ocultar senha' : 'Mostrar senha'}
|
||||||
|
className="absolute right-3 top-1/2 grid size-5 -translate-y-1/2 place-items-center text-white/30 transition hover:text-white/60"
|
||||||
|
onClick={() => setShowPassword((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</LoginField>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="inline-flex h-11 w-full items-center justify-center rounded-[6px] border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2),0_4px_6px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#3b82f6]"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Entrar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
onClick={() => {
|
||||||
|
setForm({
|
||||||
|
email: 'recepcao@mediconnect.com',
|
||||||
|
password: 'demo123',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
title="Preencher credenciais mockadas"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
dev · credenciais
|
||||||
|
<span aria-hidden="true" className="text-[9px]">
|
||||||
|
^
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterPage({ navigate }) {
|
||||||
|
const [role, setRole] = useState('Clinica')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
description="Crie um acesso mockado para navegar pelo ambiente da clínica."
|
||||||
|
title="Criar acesso"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="mt-8 grid gap-5"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
navigate('/inicio')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AuthField label="Nome da organização">
|
||||||
|
<input className={authInputClass} defaultValue="Clínica Boa Vista" />
|
||||||
|
</AuthField>
|
||||||
|
<AuthField label="Responsável">
|
||||||
|
<input className={authInputClass} defaultValue="Marina Lopes" />
|
||||||
|
</AuthField>
|
||||||
|
<AuthField label="Tipo de conta">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{['Clinica', 'Profissional'].map((option) => (
|
||||||
|
<button
|
||||||
|
className={`h-11 rounded-[6px] border px-3 text-sm font-semibold transition ${
|
||||||
|
role === option
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6]/15 text-[#3b82f6]'
|
||||||
|
: 'border-white/10 bg-white/[0.05] text-white/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
key={option}
|
||||||
|
onClick={() => setRole(option)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AuthField>
|
||||||
|
<button className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed]" type="submit">
|
||||||
|
Continuar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button className="mt-5 text-sm font-semibold text-[#3b82f6]" onClick={() => navigate('/login')} type="button">
|
||||||
|
Voltar para login
|
||||||
|
</button>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForgotPasswordPage({ navigate }) {
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
description="Informe o e-mail cadastrado para receber um link mockado."
|
||||||
|
title="Recuperar senha"
|
||||||
|
>
|
||||||
|
{sent ? (
|
||||||
|
<div className="mt-8 rounded-[6px] border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm leading-6 text-emerald-300">
|
||||||
|
Link de recuperação mockado enviado para o e-mail informado.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="mt-8 grid gap-5"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSent(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AuthField label="E-mail cadastrado">
|
||||||
|
<input autoComplete="email" className={authInputClass} defaultValue="recepcao@mediconnect.com" type="email" />
|
||||||
|
</AuthField>
|
||||||
|
<button className="inline-flex h-11 w-full items-center justify-center rounded-[6px] bg-[#3b82f6] text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.2)] transition hover:bg-[#3478ed]" type="submit">
|
||||||
|
Enviar link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<button className="mt-5 text-sm font-semibold text-[#3b82f6]" onClick={() => navigate('/login')} type="button">
|
||||||
|
Voltar para login
|
||||||
|
</button>
|
||||||
|
</AuthLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthLayout({ children, description, title }) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#0a1628] text-white">
|
||||||
|
<div className="grid min-h-screen lg:grid-cols-2">
|
||||||
|
<section className="relative hidden min-h-screen overflow-hidden lg:block">
|
||||||
|
<img alt="" className="absolute inset-0 h-full w-full object-cover" src={loginClinicImage} />
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(126.72deg, rgba(10, 22, 40, 0.9) 0%, rgba(10, 22, 40, 0.6) 50%, rgba(59, 130, 246, 0.3) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative flex min-h-screen flex-col justify-between px-[43px] py-[43px] xl:px-12 xl:py-12">
|
||||||
|
<LoginLogo />
|
||||||
|
<div className="max-w-[488px]">
|
||||||
|
<h1 className="text-[32px] font-bold leading-[40px] tracking-[-0.02em] xl:text-4xl xl:leading-[45px]">
|
||||||
|
Cuidado conectado
|
||||||
|
<br />
|
||||||
|
para equipes de
|
||||||
|
<br />
|
||||||
|
<span className="text-[#3b82f6]">saúde.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-[360px] text-sm leading-[23px] text-white/60 xl:text-base xl:leading-[26px]">
|
||||||
|
Fluxos de acesso simulados para manter a navegação ponta a ponta sem backend real.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex min-h-screen items-center justify-center px-6 py-12 sm:px-10 lg:px-[60px] xl:px-[68px]">
|
||||||
|
<div className="w-full max-w-[448px]">
|
||||||
|
<div className="mb-12 lg:hidden">
|
||||||
|
<LoginLogo />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[30px] font-bold leading-9 text-white">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-5 text-white/40">{description}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const authInputClass =
|
||||||
|
'h-11 w-full rounded-[6px] border border-white/10 bg-white/[0.05] px-4 text-sm text-white outline-none transition placeholder:text-white/30 focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
|
||||||
|
function AuthField({ children, label }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-1.5 text-xs font-medium leading-4 text-white/50">
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginField({ action, children, htmlFor, label }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<span className="flex min-h-4 items-center justify-between gap-4 text-xs font-medium leading-4 text-white/50">
|
||||||
|
<label htmlFor={htmlFor}>{label}</label>
|
||||||
|
{action}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginLogo() {
|
||||||
|
return (
|
||||||
|
<BrandLogo />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginMetric({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-[21px] font-bold leading-7 text-[#3b82f6] xl:text-2xl xl:leading-8">{value}</dt>
|
||||||
|
<dd className="mt-0.5 text-[11px] leading-4 text-white/50 xl:text-xs">{label}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M1.375 8.23c-.06-.16-.06-.34 0-.5a7.16 7.16 0 0 1 13.25 0c.06.16.06.34 0 .5a7.16 7.16 0 0 1-13.25 0Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.33"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.33"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
279
src/pages/HomePage.jsx
Normal file
279
src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import loginClinicImage from '../assets/figma/login-clinic.png'
|
||||||
|
import { homeRepository } from '../repositories/homeRepository.js'
|
||||||
|
|
||||||
|
export function HomePage({ navigate }) {
|
||||||
|
const { appointmentsToday, metrics, reportCards } = homeRepository.getDashboardOverview()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
|
||||||
|
<section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
|
||||||
|
Visão Geral da Clínica
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
|
||||||
|
Bem-vindo, Dr. Henrique. Aqui está o resumo da sua clínica hoje.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => navigate('/relatorios')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed]"
|
||||||
|
onClick={() => navigate('/agenda')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ Novo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{metrics.map((metric) => (
|
||||||
|
<MetricCard key={metric.label} metric={metric} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[1.7fr_0.9fr]">
|
||||||
|
<div className="rounded-2xl border border-[#3b82f6] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="grid size-12 shrink-0 place-items-center rounded-md bg-[#3b82f6] text-white">
|
||||||
|
<SparkLineIcon className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold leading-6 text-[#3b82f6]">Insights de IA</h2>
|
||||||
|
<p className="mt-1 text-sm font-medium leading-5 text-[#a3a3a3]">
|
||||||
|
Evolução de absenteísmo e risco da semana
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="grid size-8 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
|
||||||
|
<ChevronRightIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 h-[260px] rounded-lg bg-[#1f1f1f] px-4 py-5">
|
||||||
|
<LineChart />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||||
|
<h2 className="text-base font-bold text-[#e5e5e5]">Pacientes de hoje</h2>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
{appointmentsToday.map((item) => (
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-between gap-4 rounded-md bg-[#2a2a2a] px-4 py-3 text-left transition hover:bg-[#303030]"
|
||||||
|
key={`${item.time}-${item.name}`}
|
||||||
|
onClick={() => navigate(`/pacientes/${item.patientId}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-[#e5e5e5]">{item.name}</span>
|
||||||
|
<span className="mt-1 block text-xs text-[#a3a3a3]">{item.status}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-[#3b82f6]">{item.time}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
|
||||||
|
<h2 className="text-base font-bold text-[#e5e5e5]">Alerta preditivo</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
|
||||||
|
3 pacientes apresentam risco de falta. Recomenda-se confirmar presença antes das 16h.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="mt-5 h-9 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6] hover:text-[#3b82f6]"
|
||||||
|
onClick={() => navigate('/mensagens')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Abrir comunicação
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4" id="relatorios">
|
||||||
|
<h2 className="text-base font-bold text-[#e5e5e5]">Relatórios e Análises</h2>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<button
|
||||||
|
className="relative min-h-[164px] overflow-hidden rounded-2xl border border-[#3b82f6] bg-[#262626] p-5 text-left shadow-[0_1px_3px_rgba(0,0,0,0.2)]"
|
||||||
|
onClick={() => navigate('/relatorios')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img alt="" className="absolute inset-0 h-full w-full object-cover opacity-40" src={loginClinicImage} />
|
||||||
|
<span className="absolute inset-0 bg-[#0a1628]/60" aria-hidden="true" />
|
||||||
|
<span className="relative flex items-start gap-4">
|
||||||
|
<span className="grid size-12 place-items-center rounded-md bg-[#3b82f6] text-white">
|
||||||
|
<SparkLineIcon className="size-6" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block text-base font-bold text-[#3b82f6]">Evolução do Absenteísmo</span>
|
||||||
|
<span className="mt-1 block text-sm font-medium text-[#d4d4d4]">
|
||||||
|
Taxa de faltas e metas da semana
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{reportCards.slice(0, 2).map((card) => (
|
||||||
|
<ReportAction key={card.title} card={card} navigate={navigate} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{reportCards.slice(2).map((card) => (
|
||||||
|
<ReportAction key={card.title} card={card} navigate={navigate} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ metric }) {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${
|
||||||
|
metric.tone === 'violet' ? 'border-[#5b4b75]' : 'border-[#404040]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium leading-5 text-[#a3a3a3]">{metric.label}</p>
|
||||||
|
<p className="mt-3 text-[32px] font-bold leading-8 text-[#e5e5e5]">{metric.value}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`grid size-9 place-items-center rounded-md ${metricTone(metric.tone)}`}>
|
||||||
|
<SparkLineIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={`mt-4 text-sm font-semibold ${metric.change.startsWith('-') ? 'text-[#10b981]' : 'text-[#10b981]'}`}>
|
||||||
|
{metric.change}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportAction({ card, navigate }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex min-h-[90px] items-center justify-between gap-4 rounded-2xl border border-[#404040] bg-[#262626] px-5 py-4 text-left shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition hover:border-[#3b82f6]"
|
||||||
|
onClick={() => navigate(card.icon === 'calendar' ? '/agenda' : card.icon === 'users' ? '/pacientes' : '/relatorios')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-4">
|
||||||
|
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-[#2a2a2a] text-[#3b82f6]">
|
||||||
|
<ReportIcon className="size-6" name={card.icon} />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block text-base font-bold leading-6 text-[#e5e5e5]">{card.title}</span>
|
||||||
|
<span className="mt-0.5 block text-sm font-medium leading-5 text-[#a3a3a3]">{card.description}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
|
||||||
|
<ChevronRightIcon className="size-5" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineChart() {
|
||||||
|
return (
|
||||||
|
<svg aria-label="Grafico mockado de absenteismo" className="h-full w-full" role="img" viewBox="0 0 732 260">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="home-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.24" />
|
||||||
|
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{[48, 112, 176, 232].map((y) => (
|
||||||
|
<line key={y} stroke="#1d4ed8" strokeDasharray="3 5" strokeOpacity="0.45" x1="40" x2="710" y1={y} y2={y} />
|
||||||
|
))}
|
||||||
|
<text fill="#a3a3a3" fontSize="12" x="22" y="52">18</text>
|
||||||
|
<text fill="#a3a3a3" fontSize="12" x="22" y="116">12</text>
|
||||||
|
<text fill="#a3a3a3" fontSize="12" x="28" y="180">6</text>
|
||||||
|
<path
|
||||||
|
d="M40 128 C120 78 164 112 220 108 C290 104 302 34 360 34 C425 34 418 134 482 156 C560 182 610 154 710 188"
|
||||||
|
fill="none"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M40 128 C120 78 164 112 220 108 C290 104 302 34 360 34 C425 34 418 134 482 156 C560 182 610 154 710 188 L710 244 L40 244 Z"
|
||||||
|
fill="url(#home-chart-fill)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricTone(tone) {
|
||||||
|
if (tone === 'violet') {
|
||||||
|
return 'bg-[#322b3d] text-[#8b5cf6]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tone === 'green') {
|
||||||
|
return 'bg-[#123328] text-[#10b981]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-[#1d2f4f] text-[#3b82f6]'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportIcon({ className = 'size-6', name }) {
|
||||||
|
if (name === 'users') {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM20 19a3 3 0 0 0-3-3M4 19a3 3 0 0 1 3-3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'building') {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 21V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v16M8 8h1M8 12h1M8 16h1M15 8h1M15 12h1M15 16h1M3 21h18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'brand') {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 4H5a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V6a2 2 0 0 0-2-2h-1M3 9a6 6 0 0 0 12 0V4M18 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparkLineIcon({ className = 'size-6' }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 17 9 11l4 4 7-9" />
|
||||||
|
<path d="M15 6h5v5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronRightIcon({ className = 'size-5' }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" viewBox="0 0 24 24">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
436
src/pages/MedicalRecordsPage.jsx
Normal file
436
src/pages/MedicalRecordsPage.jsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { medicalRecordRepository } from '../repositories/medicalRecordRepository.js'
|
||||||
|
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||||
|
const labelClass = 'mb-1 block text-xs font-medium text-[#e5e5e5]'
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
|
export function MedicalRecordsPage() {
|
||||||
|
const recordTypes = medicalRecordRepository.getRecordTypes()
|
||||||
|
const [records, setRecords] = useState(() => medicalRecordRepository.getInitialRecords())
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [filterType, setFilterType] = useState('')
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
|
||||||
|
const filteredRecords = useMemo(() => {
|
||||||
|
return records.filter((record) => {
|
||||||
|
const matchesSearch = [record.patient, record.cid, record.doctor]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.toLowerCase())
|
||||||
|
const matchesType = !filterType || record.type === filterType
|
||||||
|
|
||||||
|
return matchesSearch && matchesType
|
||||||
|
})
|
||||||
|
}, [filterType, records, search])
|
||||||
|
|
||||||
|
function handleCreateRecord(record) {
|
||||||
|
setRecords((currentRecords) => [record, ...currentRecords])
|
||||||
|
setEditorOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
||||||
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Prontuário Médico</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">Registro de consultas, diagnósticos e evolução</p>
|
||||||
|
</div>
|
||||||
|
<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]"
|
||||||
|
onClick={() => setEditorOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RecordIcon name="plus" />
|
||||||
|
Nova Consulta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={`${cardClass} p-4`}>
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<RecordIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] py-2 pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Buscar por paciente ou CID..."
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredRecords.length ? (
|
||||||
|
filteredRecords.map((record) => <RecordCard key={record.id} record={record} />)
|
||||||
|
) : (
|
||||||
|
<div className={`${cardClass} p-8 text-center text-sm text-[#a3a3a3]`}>
|
||||||
|
Nenhum registro encontrado nos dados locais.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorOpen ? (
|
||||||
|
<RecordEditorModal
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSave={handleCreateRecord}
|
||||||
|
recordTypes={recordTypes}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordCard({ record }) {
|
||||||
|
const statusClass =
|
||||||
|
record.status === 'completo'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400'
|
||||||
|
: 'bg-amber-500/20 text-amber-400'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`${cardClass} cursor-pointer p-5 transition hover:border-[#3b82f6]/30`}>
|
||||||
|
<div className="flex flex-col justify-between gap-3 md:flex-row md:items-center">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="grid size-10 shrink-0 place-items-center rounded-full bg-[#3b82f6]/10 text-[#3b82f6]">
|
||||||
|
<RecordIcon className="size-5" name="file" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-sm font-bold text-[#e5e5e5]">{record.patient}</h2>
|
||||||
|
<span className={`rounded px-2 py-0.5 text-[10px] font-bold ${statusClass}`}>
|
||||||
|
{record.status === 'completo' ? 'Completo' : 'Rascunho'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-[#a3a3a3]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<RecordIcon className="size-3" name="calendar" />
|
||||||
|
{record.date}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<RecordIcon className="size-3" name="user" />
|
||||||
|
{record.doctor}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<RecordIcon className="size-3" name="activity" />
|
||||||
|
{record.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 inline-block rounded bg-[#1a1a1a] px-2 py-1 text-xs text-[#a3a3a3]">{record.cid}</p>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-[#a3a3a3]">{record.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-14 flex items-center gap-2 md:ml-0">
|
||||||
|
<IconButton label="Visualizar" name="eye" />
|
||||||
|
<IconButton label="Editar" name="edit" />
|
||||||
|
<IconButton label="Imprimir" name="printer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({ label, name }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className="grid size-9 place-items-center rounded-lg border border-[#404040] bg-[#1a1a1a] text-[#a3a3a3] transition hover:bg-[#2a2a2a] hover:text-[#e5e5e5]"
|
||||||
|
title={label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RecordIcon className="size-4" name={name} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
patient: '',
|
||||||
|
date: '',
|
||||||
|
type: 'Primeira Consulta',
|
||||||
|
cid: '',
|
||||||
|
anamnesis: '',
|
||||||
|
physicalExam: '',
|
||||||
|
conduct: '',
|
||||||
|
prescriptions: '',
|
||||||
|
returnDate: '',
|
||||||
|
status: 'completo',
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateField(event) {
|
||||||
|
const { name, value } = event.target
|
||||||
|
setFormData((currentData) => ({ ...currentData, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const submitter = event.nativeEvent.submitter
|
||||||
|
const status = submitter?.value || formData.status
|
||||||
|
|
||||||
|
onSave({
|
||||||
|
id: `record-${Date.now()}`,
|
||||||
|
patient: formData.patient || 'Paciente sem nome',
|
||||||
|
date: formData.date ? formatDate(formData.date) : '07/04/2026',
|
||||||
|
doctor: 'Dr. Henrique Cardoso',
|
||||||
|
type: formData.type,
|
||||||
|
cid: formData.cid || 'CID nao informado',
|
||||||
|
status,
|
||||||
|
summary: formData.conduct || formData.anamnesis || 'Registro criado localmente para simulação.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
className="max-h-[90vh] w-full max-w-3xl overflow-y-auto rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<h2 className="mb-6 text-lg font-bold text-[#e5e5e5]">Novo Registro de Consulta</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<DarkField label="Paciente">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
name="patient"
|
||||||
|
onChange={updateField}
|
||||||
|
placeholder="Buscar paciente..."
|
||||||
|
value={formData.patient}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Data da Consulta">
|
||||||
|
<input
|
||||||
|
className={`${inputClass} [color-scheme:dark]`}
|
||||||
|
name="date"
|
||||||
|
onChange={updateField}
|
||||||
|
type="date"
|
||||||
|
value={formData.date}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DarkField label="Anamnese">
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-24 py-2`}
|
||||||
|
name="anamnesis"
|
||||||
|
onChange={updateField}
|
||||||
|
placeholder="Queixa principal, história da doença atual..."
|
||||||
|
value={formData.anamnesis}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Exame Físico">
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-24 py-2`}
|
||||||
|
name="physicalExam"
|
||||||
|
onChange={updateField}
|
||||||
|
placeholder="Achados do exame físico..."
|
||||||
|
value={formData.physicalExam}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<DarkField label="Hipóteses Diagnósticas (CID-10)">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
name="cid"
|
||||||
|
onChange={updateField}
|
||||||
|
placeholder="Ex: I10, E11.9..."
|
||||||
|
value={formData.cid}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Tipo de Consulta">
|
||||||
|
<select className={inputClass} name="type" onChange={updateField} value={formData.type}>
|
||||||
|
{recordTypes.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DarkField label="Conduta Médica">
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-24 py-2`}
|
||||||
|
name="conduct"
|
||||||
|
onChange={updateField}
|
||||||
|
placeholder="Plano terapêutico, orientações..."
|
||||||
|
value={formData.conduct}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Prescrições">
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-20 py-2`}
|
||||||
|
name="prescriptions"
|
||||||
|
onChange={updateField}
|
||||||
|
placeholder="Medicamentos, posologia..."
|
||||||
|
value={formData.prescriptions}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Retorno Agendado">
|
||||||
|
<input
|
||||||
|
className={`${inputClass} [color-scheme:dark]`}
|
||||||
|
name="returnDate"
|
||||||
|
onChange={updateField}
|
||||||
|
type="date"
|
||||||
|
value={formData.returnDate}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-[#404040] bg-[#2a2a2a] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||||
|
type="submit"
|
||||||
|
value="rascunho"
|
||||||
|
>
|
||||||
|
Salvar Rascunho
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb]"
|
||||||
|
type="submit"
|
||||||
|
value="completo"
|
||||||
|
>
|
||||||
|
Finalizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DarkField({ children, label }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelClass}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
const [year, month, day] = value.split('-')
|
||||||
|
return `${day}/${month}/${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordIcon({ className = 'size-4', name }) {
|
||||||
|
const common = {
|
||||||
|
className,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'search') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'plus') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'file') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 3h7l4 4v14H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" />
|
||||||
|
<path d="M14 3v5h5M9 13h6M9 17h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'calendar') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M8 3v3M16 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'user') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M20 21a8 8 0 0 0-16 0M12 13a5 5 0 1 0 0-10 5 5 0 0 0 0 10Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'activity') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 12h4l2-5 4 10 2-5h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'eye') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'edit') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m16 3 5 5L8 21H3v-5L16 3Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'printer') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 8V3h10v5M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2" />
|
||||||
|
<path d="M7 14h10v7H7zM17 12h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
690
src/pages/MessagesPage.jsx
Normal file
690
src/pages/MessagesPage.jsx
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { communicationRepository } from '../repositories/communicationRepository.js'
|
||||||
|
|
||||||
|
const channels = {
|
||||||
|
whatsapp: { label: 'WhatsApp', className: 'bg-emerald-500/20 text-emerald-400', icon: 'message' },
|
||||||
|
email: { label: 'E-mail', className: 'bg-blue-500/20 text-blue-400', icon: 'mail' },
|
||||||
|
sms: { label: 'SMS', className: 'bg-purple-500/20 text-purple-400', icon: 'phone' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
entregue: { label: 'Entregue', className: 'text-emerald-400', icon: 'check' },
|
||||||
|
lida: { label: 'Lida', className: 'text-blue-400', icon: 'eye' },
|
||||||
|
falha: { label: 'Falha', className: 'text-red-400', icon: 'x-circle' },
|
||||||
|
pendente: { label: 'Pendente', className: 'text-amber-400', icon: 'clock' },
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const emptyMessage = {
|
||||||
|
patient: '',
|
||||||
|
channel: 'whatsapp',
|
||||||
|
template: 'Lembrete 48h',
|
||||||
|
content: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyTemplate = {
|
||||||
|
name: '',
|
||||||
|
channel: 'whatsapp',
|
||||||
|
category: 'Lembrete',
|
||||||
|
content: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
const inputClass =
|
||||||
|
'h-10 w-full rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
const textareaClass =
|
||||||
|
'min-h-28 w-full resize-y rounded-sm border border-[#404040] bg-[#171717] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
const labelClass = 'text-xs font-semibold uppercase tracking-[0.08em] text-[#a3a3a3]'
|
||||||
|
|
||||||
|
export function MessagesPage() {
|
||||||
|
const campaigns = communicationRepository.getCampaigns()
|
||||||
|
const [messages, setMessages] = useState(() => communicationRepository.getInitialMessages())
|
||||||
|
const [templates, setTemplates] = useState(() => communicationRepository.getInitialTemplates())
|
||||||
|
const [activeTab, setActiveTab] = useState('historico')
|
||||||
|
const [channelFilter, setChannelFilter] = useState('todos')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [composerOpen, setComposerOpen] = useState(false)
|
||||||
|
const [templateEditorOpen, setTemplateEditorOpen] = useState(false)
|
||||||
|
const [composer, setComposer] = useState(emptyMessage)
|
||||||
|
const [templateDraft, setTemplateDraft] = useState(emptyTemplate)
|
||||||
|
|
||||||
|
const filteredMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
messages.filter((message) => {
|
||||||
|
const matchesChannel = channelFilter === 'todos' || message.channel === channelFilter
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
const matchesSearch =
|
||||||
|
!query ||
|
||||||
|
[message.patient, message.template, channels[message.channel].label]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query)
|
||||||
|
|
||||||
|
return matchesChannel && matchesSearch
|
||||||
|
}),
|
||||||
|
[channelFilter, messages, search],
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: messages.length,
|
||||||
|
delivered: messages.filter((message) => message.status === 'entregue' || message.status === 'lida').length,
|
||||||
|
read: messages.filter((message) => message.status === 'lida').length,
|
||||||
|
failed: messages.filter((message) => message.status === 'falha').length,
|
||||||
|
}),
|
||||||
|
[messages],
|
||||||
|
)
|
||||||
|
|
||||||
|
function openTemplate(template) {
|
||||||
|
setComposer({
|
||||||
|
patient: '',
|
||||||
|
channel: template.channel,
|
||||||
|
template: template.name,
|
||||||
|
content: template.content,
|
||||||
|
})
|
||||||
|
setComposerOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitMessage(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!composer.patient.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((current) => [
|
||||||
|
{
|
||||||
|
id: `local-${Date.now()}`,
|
||||||
|
patient: composer.patient.trim(),
|
||||||
|
channel: composer.channel,
|
||||||
|
template: composer.template.trim() || 'Mensagem avulsa',
|
||||||
|
sentAt: 'Agora',
|
||||||
|
status: 'pendente',
|
||||||
|
response: '',
|
||||||
|
},
|
||||||
|
...current,
|
||||||
|
])
|
||||||
|
setComposer(emptyMessage)
|
||||||
|
setComposerOpen(false)
|
||||||
|
setActiveTab('historico')
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitTemplate(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!templateDraft.name.trim() || !templateDraft.content.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplates((current) => [
|
||||||
|
{
|
||||||
|
id: `template-${Date.now()}`,
|
||||||
|
name: templateDraft.name.trim(),
|
||||||
|
channel: templateDraft.channel,
|
||||||
|
content: templateDraft.content.trim(),
|
||||||
|
category: templateDraft.category.trim() || 'Personalizado',
|
||||||
|
},
|
||||||
|
...current,
|
||||||
|
])
|
||||||
|
setTemplateDraft(emptyTemplate)
|
||||||
|
setTemplateEditorOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#b8b8b8]">WhatsApp, E-mail e SMS - histórico e campanhas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => setActiveTab('campanha')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CommIcon className="size-4" name="send" />
|
||||||
|
Envio em Massa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-12 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
|
onClick={() => setComposerOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CommIcon className="size-4" name="plus" />
|
||||||
|
Nova Mensagem
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<StatCard label="Total Enviadas" value={stats.total} />
|
||||||
|
<StatCard label="Entregues" value={stats.delivered} valueClassName="text-emerald-400" />
|
||||||
|
<StatCard label="Lidas" value={stats.read} valueClassName="text-[#3b82f6]" />
|
||||||
|
<StatCard label="Falhas" value={stats.failed} valueClassName="text-red-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 border-b border-[#404040]">
|
||||||
|
{[
|
||||||
|
['historico', 'Histórico'],
|
||||||
|
['templates', 'Templates'],
|
||||||
|
['campanha', 'Campanhas'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
className={`border-b-2 px-2 pb-3 text-sm font-semibold transition ${
|
||||||
|
activeTab === key
|
||||||
|
? 'border-[#3b82f6] text-[#3b82f6]'
|
||||||
|
: 'border-transparent text-[#b8b8b8] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveTab(key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'historico' ? (
|
||||||
|
<section className={`${cardClass} p-5 md:p-6`} aria-label="Histórico de comunicação">
|
||||||
|
<div className="mb-6 flex flex-col gap-3 md:flex-row">
|
||||||
|
<label className="relative flex-1">
|
||||||
|
<span className="sr-only">Buscar comunicação</span>
|
||||||
|
<CommIcon
|
||||||
|
className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className={`${inputClass} h-12 pl-12`}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Buscar paciente..."
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
['todos', 'Todos'],
|
||||||
|
['whatsapp', 'Whatsapp'],
|
||||||
|
['email', 'E-mail'],
|
||||||
|
['sms', 'Sms'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<button
|
||||||
|
className={`h-12 rounded-sm border px-4 text-xs font-semibold transition ${
|
||||||
|
channelFilter === key
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
|
||||||
|
: 'border-[#404040] bg-[#171717] text-[#b8b8b8] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={key}
|
||||||
|
onClick={() => setChannelFilter(key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-sm border border-[#404040]">
|
||||||
|
<table className="w-full min-w-[920px] text-left text-sm">
|
||||||
|
<thead className="bg-[#171717] text-xs font-semibold uppercase tracking-[0.02em] text-[#b8b8b8]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-4">Paciente</th>
|
||||||
|
<th className="px-5 py-4">Canal</th>
|
||||||
|
<th className="px-5 py-4">Template</th>
|
||||||
|
<th className="px-5 py-4">Enviado em</th>
|
||||||
|
<th className="px-5 py-4">Status</th>
|
||||||
|
<th className="px-5 py-4">Resposta</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
||||||
|
{filteredMessages.map((message) => (
|
||||||
|
<MessageRow key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredMessages.length === 0 ? (
|
||||||
|
<div className="rounded-b-sm border-x border-b border-[#404040] bg-[#171717] px-4 py-8 text-center text-sm text-[#a3a3a3]">
|
||||||
|
Nenhuma comunicação encontrada com os filtros atuais.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'templates' ? (
|
||||||
|
<section className="space-y-4" aria-label="Templates de comunicação">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<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]"
|
||||||
|
onClick={() => setTemplateEditorOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CommIcon className="size-4" name="plus" />
|
||||||
|
Novo Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<TemplateCard key={template.id} onUse={openTemplate} template={template} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'campanha' ? (
|
||||||
|
<section className={`${cardClass} p-6`} aria-label="Campanhas inteligentes">
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 grid size-16 place-items-center rounded-full bg-[#303030]">
|
||||||
|
<CommIcon className="size-8 text-[#51a2ff]" name="send" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-[#f5f5f5]">Campanhas Inteligentes</h2>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[#a3a3a3]">
|
||||||
|
Crie campanhas segmentadas por perfil comportamental. A IA sugere os melhores horários e canais para
|
||||||
|
cada paciente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-6 grid max-w-2xl gap-4 md:grid-cols-3">
|
||||||
|
{campaigns.map((campaign) => (
|
||||||
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4 text-left" key={campaign.title}>
|
||||||
|
<h3 className="text-sm font-bold text-[#f5f5f5]">{campaign.title}</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[#a3a3a3]">{campaign.desc}</p>
|
||||||
|
<p className="mt-2 text-[10px] font-semibold text-[#51a2ff]">{campaign.count}</p>
|
||||||
|
<button
|
||||||
|
className="mt-3 h-8 w-full rounded-sm bg-[#3b82f6] text-xs font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
|
onClick={() => {
|
||||||
|
setComposer({
|
||||||
|
patient: campaign.count,
|
||||||
|
channel: 'whatsapp',
|
||||||
|
template: campaign.title,
|
||||||
|
content: campaign.desc,
|
||||||
|
})
|
||||||
|
setComposerOpen(true)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Disparar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="rounded bg-indigo-500/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.12em] text-indigo-400">
|
||||||
|
LGPD
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-[#f5f5f5]">Conformidade</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-6 text-[#a3a3a3]">
|
||||||
|
Todas as comunicações respeitam as preferências de Opt-in/Opt-out dos pacientes. Os pacientes podem
|
||||||
|
cancelar o recebimento de mensagens a qualquer momento, conforme exigido pela LGPD.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{composerOpen ? (
|
||||||
|
<MessageComposer
|
||||||
|
draft={composer}
|
||||||
|
onChange={setComposer}
|
||||||
|
onClose={() => {
|
||||||
|
setComposerOpen(false)
|
||||||
|
setComposer(emptyMessage)
|
||||||
|
}}
|
||||||
|
onSubmit={submitMessage}
|
||||||
|
templates={templates}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{templateEditorOpen ? (
|
||||||
|
<TemplateEditor
|
||||||
|
draft={templateDraft}
|
||||||
|
onChange={setTemplateDraft}
|
||||||
|
onClose={() => {
|
||||||
|
setTemplateEditorOpen(false)
|
||||||
|
setTemplateDraft(emptyTemplate)
|
||||||
|
}}
|
||||||
|
onSubmit={submitTemplate}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, valueClassName = 'text-[#f5f5f5]' }) {
|
||||||
|
return (
|
||||||
|
<div className={`${cardClass} p-5`}>
|
||||||
|
<p className="text-sm text-[#b8b8b8]">{label}</p>
|
||||||
|
<p className={`mt-2 text-3xl font-bold leading-none ${valueClassName}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageRow({ message }) {
|
||||||
|
const channel = channels[message.channel]
|
||||||
|
const status = statusConfig[message.status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="transition hover:bg-[#303030]">
|
||||||
|
<td className="px-5 py-4 font-semibold text-[#f5f5f5]">{message.patient}</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-1 text-[10px] font-bold ${channel.className}`}>
|
||||||
|
<CommIcon className="size-3.5" name={channel.icon} />
|
||||||
|
{channel.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-[#b8b8b8]">{message.template}</td>
|
||||||
|
<td className="px-5 py-4 text-[#b8b8b8]">{message.sentAt}</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${status.className}`}>
|
||||||
|
<CommIcon className="size-3.5" name={status.icon} />
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-[#b8b8b8]">{message.response || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateCard({ onUse, template }) {
|
||||||
|
const channel = channels[template.channel]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`${cardClass} p-5`}>
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-1 text-[10px] font-bold ${channel.className}`}>
|
||||||
|
<CommIcon className="size-3.5" name={channel.icon} />
|
||||||
|
{channel.label}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-[#303030] px-2 py-0.5 text-[10px] font-semibold text-[#a3a3a3]">
|
||||||
|
{template.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-bold text-[#f5f5f5]">{template.name}</h3>
|
||||||
|
<p className="mt-2 min-h-[72px] text-xs leading-6 text-[#a3a3a3]">{template.content}</p>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
className="h-9 flex-1 rounded-sm border border-[#404040] bg-[#171717] text-xs font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="h-9 flex-1 rounded-sm bg-[#3b82f6]/10 text-xs font-semibold text-[#3b82f6] transition hover:bg-[#3b82f6]/20"
|
||||||
|
onClick={() => onUse(template)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Usar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
||||||
|
function update(field, value) {
|
||||||
|
onChange((current) => ({ ...current, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(templateName) {
|
||||||
|
const template = templates.find((item) => item.name === templateName)
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
update('template', templateName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange((current) => ({
|
||||||
|
...current,
|
||||||
|
channel: template.channel,
|
||||||
|
template: template.name,
|
||||||
|
content: template.content,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalFrame onClose={onClose} title="Nova Mensagem">
|
||||||
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<DarkField label="Paciente">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => update('patient', event.target.value)}
|
||||||
|
placeholder="Nome do paciente"
|
||||||
|
value={draft.patient}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Canal">
|
||||||
|
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
<option value="email">E-mail</option>
|
||||||
|
<option value="sms">SMS</option>
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DarkField label="Template">
|
||||||
|
<select className={inputClass} onChange={(event) => applyTemplate(event.target.value)} value={draft.template}>
|
||||||
|
<option value="Mensagem avulsa">Mensagem avulsa</option>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.id} value={template.name}>
|
||||||
|
{template.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<DarkField label="Mensagem">
|
||||||
|
<textarea
|
||||||
|
className={textareaClass}
|
||||||
|
onChange={(event) => update('content', event.target.value)}
|
||||||
|
placeholder="Escreva a mensagem mockada..."
|
||||||
|
value={draft.content}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
|
||||||
|
<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={!draft.patient.trim()}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateEditor({ draft, onChange, onClose, onSubmit }) {
|
||||||
|
function update(field, value) {
|
||||||
|
onChange((current) => ({ ...current, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalFrame onClose={onClose} title="Novo Template">
|
||||||
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<DarkField label="Nome">
|
||||||
|
<input className={inputClass} onChange={(event) => update('name', event.target.value)} value={draft.name} />
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Canal">
|
||||||
|
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
<option value="email">E-mail</option>
|
||||||
|
<option value="sms">SMS</option>
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
<DarkField label="Categoria">
|
||||||
|
<input className={inputClass} onChange={(event) => update('category', event.target.value)} value={draft.category} />
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Conteúdo">
|
||||||
|
<textarea className={textareaClass} onChange={(event) => update('content', event.target.value)} value={draft.content} />
|
||||||
|
</DarkField>
|
||||||
|
<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={!draft.name.trim() || !draft.content.trim()}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Salvar Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalFrame({ children, onClose, title }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||||
|
<div className="w-full max-w-2xl rounded-2xl border border-[#404040] bg-[#262626] shadow-2xl">
|
||||||
|
<div className="flex 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">
|
||||||
|
<CommIcon className="size-5" name="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DarkField({ children, label }) {
|
||||||
|
return (
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className={labelClass}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommIcon({ className = 'size-4', name }) {
|
||||||
|
const common = {
|
||||||
|
className,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeWidth: 1.9,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'message') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M5 5h14v10H8l-4 4V6a1 1 0 0 1 1-1Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'mail') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 6h16v12H4z" />
|
||||||
|
<path d="m4 7 8 6 8-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'phone') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 4h10v16H7z" />
|
||||||
|
<path d="M11 17h2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'send') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m22 2-7 20-4-9-9-4 20-7Z" />
|
||||||
|
<path d="M22 2 11 13" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'plus') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'search') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'check') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="m8 12 2.5 2.5L16 9" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'eye') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
||||||
|
<circle cx="12" cy="12" r="2.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'x-circle') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="m9 9 6 6M15 9l-6 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'clock') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7v5l3 2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'x') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m18 6-12 12M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 12h4l2-5 4 10 2-5h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/pages/NotFoundPage.jsx
Normal file
25
src/pages/NotFoundPage.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Button, Card, PageHeader } from '../components/ui.jsx'
|
||||||
|
|
||||||
|
export function NotFoundPage({ navigate }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<PageHeader
|
||||||
|
description="A rota acessada nao faz parte do shell navegavel deste prototipo."
|
||||||
|
eyebrow="404"
|
||||||
|
title="Tela nao encontrada"
|
||||||
|
/>
|
||||||
|
<Card className="p-6">
|
||||||
|
<p className="max-w-2xl text-sm leading-6 text-slate-600">
|
||||||
|
Volte para o dashboard ou escolha uma area na navegacao lateral. Esta tela tambem ajuda a validar links
|
||||||
|
quebrados durante a evolucao do app.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<Button onClick={() => navigate('/dashboard')}>Ir para dashboard</Button>
|
||||||
|
<Button onClick={() => navigate('/login')} variant="secondary">
|
||||||
|
Voltar ao login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1279
src/pages/PatientsPage.jsx
Normal file
1279
src/pages/PatientsPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
108
src/pages/ProfilePage.jsx
Normal file
108
src/pages/ProfilePage.jsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { profileRepository } from '../repositories/profileRepository.js'
|
||||||
|
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
const inputClass =
|
||||||
|
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [profile, setProfile] = useState(() => profileRepository.getCurrentUserProfile())
|
||||||
|
|
||||||
|
function update(field, value) {
|
||||||
|
setSaved(false)
|
||||||
|
setProfile((current) => ({ ...current, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Perfil</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#b8b8b8]">Dados locais do usuário logado e preferências básicas do shell.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||||
|
<section className={`${cardClass} p-6`}>
|
||||||
|
<div className="mb-6 flex items-center gap-4">
|
||||||
|
<div className="grid size-16 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
||||||
|
HC
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[#f5f5f5]">{profile.name}</h2>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">{profile.role}</p>
|
||||||
|
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
|
||||||
|
Alterar foto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaved(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="Nome">
|
||||||
|
<input className={inputClass} onChange={(event) => update('name', event.target.value)} value={profile.name} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Cargo">
|
||||||
|
<input className={inputClass} onChange={(event) => update('role', event.target.value)} value={profile.role} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="E-mail">
|
||||||
|
<input className={inputClass} onChange={(event) => update('email', event.target.value)} type="email" value={profile.email} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Telefone">
|
||||||
|
<input className={inputClass} onChange={(event) => update('phone', event.target.value)} value={profile.phone} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Unidade padrão">
|
||||||
|
<select className={inputClass} onChange={(event) => update('unit', event.target.value)} value={profile.unit}>
|
||||||
|
<option>Clínica Boa Vista</option>
|
||||||
|
<option>Unidade Centro</option>
|
||||||
|
<option>Unidade Sul</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white" type="submit">
|
||||||
|
Salvar alterações
|
||||||
|
</button>
|
||||||
|
{saved ? <span className="rounded bg-emerald-500/20 px-2.5 py-1 text-xs font-bold text-emerald-400">Preferências salvas localmente</span> : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className={`${cardClass} p-6`}>
|
||||||
|
<h2 className="text-xl font-bold text-[#f5f5f5]">Resumo de acesso</h2>
|
||||||
|
<dl className="mt-5 grid gap-4 text-sm">
|
||||||
|
<Info label="Perfil" value="Administrador da clínica" />
|
||||||
|
<Info label="Último acesso" value="07 abr 2026, 09:15" />
|
||||||
|
<Info label="Permissões" value="Agenda, pacientes, comunicação e configurações" />
|
||||||
|
</dl>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ children, label }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-2">
|
||||||
|
<span className="text-xs font-semibold text-[#a3a3a3]">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[#404040] bg-[#171717] p-4">
|
||||||
|
<dt className="font-semibold text-[#a3a3a3]">{label}</dt>
|
||||||
|
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
936
src/pages/ReportsPage.jsx
Normal file
936
src/pages/ReportsPage.jsx
Normal file
@@ -0,0 +1,936 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { reportRepository } from '../repositories/reportRepository.js'
|
||||||
|
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
rascunho: {
|
||||||
|
label: 'Rascunho',
|
||||||
|
pill: 'bg-amber-500/20 text-amber-400',
|
||||||
|
stat: 'text-amber-400',
|
||||||
|
},
|
||||||
|
finalizado: {
|
||||||
|
label: 'Finalizado',
|
||||||
|
pill: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
stat: 'text-emerald-400',
|
||||||
|
},
|
||||||
|
enviado: {
|
||||||
|
label: 'Enviado',
|
||||||
|
pill: 'bg-blue-500/20 text-blue-400',
|
||||||
|
stat: 'text-blue-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUsers = reportRepository.getAdminUsers()
|
||||||
|
const currentUser = reportRepository.getCurrentUser()
|
||||||
|
const doctors = reportRepository.getDoctors()
|
||||||
|
const reportTypes = reportRepository.getReportTypes()
|
||||||
|
const templates = reportRepository.getTemplates()
|
||||||
|
const emptyEditor = {
|
||||||
|
id: null,
|
||||||
|
type: reportTypes[0],
|
||||||
|
patient: '',
|
||||||
|
doctor: doctors[0],
|
||||||
|
content: '',
|
||||||
|
showDate: true,
|
||||||
|
signDigital: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||||
|
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
|
export function ReportsPage() {
|
||||||
|
const [reports, setReports] = useState(() => reportRepository.getInitialReports())
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
|
const [openMenuId, setOpenMenuId] = useState(null)
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
const [templatesOpen, setTemplatesOpen] = useState(false)
|
||||||
|
const [historyReport, setHistoryReport] = useState(null)
|
||||||
|
const [confirmRelease, setConfirmRelease] = useState(null)
|
||||||
|
const [deliveryReport, setDeliveryReport] = useState(null)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(null)
|
||||||
|
const [deleteConfirmText, setDeleteConfirmText] = useState('')
|
||||||
|
const [preview, setPreview] = useState(false)
|
||||||
|
const [editor, setEditor] = useState(emptyEditor)
|
||||||
|
|
||||||
|
const filteredReports = useMemo(() => {
|
||||||
|
return reports.filter((report) => {
|
||||||
|
const matchesSearch = [report.patient, report.type]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.toLowerCase())
|
||||||
|
const matchesStatus = !filterStatus || report.status === filterStatus
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
}, [filterStatus, reports, search])
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Rascunhos', status: 'rascunho' },
|
||||||
|
{ label: 'Finalizados', status: 'finalizado' },
|
||||||
|
{ label: 'Enviados', status: 'enviado' },
|
||||||
|
].map((stat) => ({
|
||||||
|
...stat,
|
||||||
|
value: reports.filter((report) => report.status === stat.status).length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function openNew(template = null) {
|
||||||
|
setEditor({
|
||||||
|
...emptyEditor,
|
||||||
|
type: template?.type || emptyEditor.type,
|
||||||
|
content: template?.content || '',
|
||||||
|
})
|
||||||
|
setPreview(false)
|
||||||
|
setTemplatesOpen(false)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(report) {
|
||||||
|
setEditor({
|
||||||
|
id: report.id,
|
||||||
|
type: report.type,
|
||||||
|
patient: report.patient,
|
||||||
|
doctor: report.doctor,
|
||||||
|
content: report.content,
|
||||||
|
showDate: report.showDate,
|
||||||
|
signDigital: report.signDigital,
|
||||||
|
})
|
||||||
|
setOpenMenuId(null)
|
||||||
|
setPreview(false)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveReport(status) {
|
||||||
|
if (!editor.patient.trim() || !editor.content.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date().toLocaleDateString('pt-BR')
|
||||||
|
setReports((currentReports) => {
|
||||||
|
if (editor.id) {
|
||||||
|
return currentReports.map((report) =>
|
||||||
|
report.id === editor.id
|
||||||
|
? {
|
||||||
|
...report,
|
||||||
|
type: editor.type,
|
||||||
|
patient: editor.patient,
|
||||||
|
doctor: editor.doctor,
|
||||||
|
content: editor.content,
|
||||||
|
showDate: editor.showDate,
|
||||||
|
signDigital: editor.signDigital,
|
||||||
|
status,
|
||||||
|
versions: [
|
||||||
|
...report.versions,
|
||||||
|
{
|
||||||
|
version: report.versions.length + 1,
|
||||||
|
action: status === 'finalizado' ? 'Liberado' : 'Rascunho',
|
||||||
|
user: currentUser,
|
||||||
|
summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: report,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `report-${Date.now()}`,
|
||||||
|
type: editor.type,
|
||||||
|
patient: editor.patient,
|
||||||
|
doctor: editor.doctor,
|
||||||
|
date,
|
||||||
|
status,
|
||||||
|
content: editor.content,
|
||||||
|
showDate: editor.showDate,
|
||||||
|
signDigital: editor.signDigital,
|
||||||
|
versions: [
|
||||||
|
{ version: 1, action: 'Criado', user: currentUser, summary: 'Laudo criado localmente' },
|
||||||
|
{
|
||||||
|
version: 2,
|
||||||
|
action: status === 'finalizado' ? 'Liberado' : 'Rascunho',
|
||||||
|
user: currentUser,
|
||||||
|
summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...currentReports,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
setEditorOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseReport(reportId) {
|
||||||
|
setReports((currentReports) =>
|
||||||
|
currentReports.map((report) =>
|
||||||
|
report.id === reportId
|
||||||
|
? {
|
||||||
|
...report,
|
||||||
|
status: 'finalizado',
|
||||||
|
versions: [
|
||||||
|
...report.versions,
|
||||||
|
{ version: report.versions.length + 1, action: 'Liberado', user: currentUser, summary: 'Laudo liberado' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: report,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setConfirmRelease(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendReport(reportId) {
|
||||||
|
setReports((currentReports) =>
|
||||||
|
currentReports.map((report) =>
|
||||||
|
report.id === reportId
|
||||||
|
? {
|
||||||
|
...report,
|
||||||
|
status: 'enviado',
|
||||||
|
versions: [
|
||||||
|
...report.versions,
|
||||||
|
{ version: report.versions.length + 1, action: 'Enviado', user: currentUser, summary: 'Laudo enviado ao paciente' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: report,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteReport(reportId) {
|
||||||
|
setReports((currentReports) => currentReports.filter((report) => report.id !== reportId))
|
||||||
|
setConfirmDelete(null)
|
||||||
|
setDeleteConfirmText('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
||||||
|
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Gestão de Laudos</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||||
|
<button
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
||||||
|
onClick={() => setTemplatesOpen(true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReportIcon className="size-4 text-[#3b82f6]" name="template" />
|
||||||
|
Templates
|
||||||
|
</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]"
|
||||||
|
onClick={() => openNew()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReportIcon name="plus" />
|
||||||
|
Novo Laudo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div className={`${cardClass} p-4`} key={stat.status}>
|
||||||
|
<p className="text-xs font-semibold text-[#a3a3a3]">{stat.label}</p>
|
||||||
|
<p className={`mt-1 text-2xl font-bold ${statusConfig[stat.status].stat}`}>{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${cardClass} p-6`}>
|
||||||
|
<div className="mb-6 flex flex-col gap-4 md:flex-row">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<ReportIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
|
||||||
|
<input
|
||||||
|
className="h-10 w-full rounded-none border border-[#404040] bg-[#1a1a1a] py-2 pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Buscar por paciente ou tipo..."
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-none border border-[#404040] bg-[#1a1a1a] px-3 text-sm font-semibold text-[#e5e5e5] outline-none transition focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]"
|
||||||
|
onChange={(event) => setFilterStatus(event.target.value)}
|
||||||
|
value={filterStatus}
|
||||||
|
>
|
||||||
|
<option value="">Todos os Status</option>
|
||||||
|
<option value="rascunho">Rascunho</option>
|
||||||
|
<option value="finalizado">Finalizado</option>
|
||||||
|
<option value="enviado">Enviado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-none border border-[#404040]">
|
||||||
|
<table className="w-full whitespace-nowrap text-left text-sm">
|
||||||
|
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Tipo</th>
|
||||||
|
<th className="px-4 py-3">Paciente</th>
|
||||||
|
<th className="px-4 py-3">Médico</th>
|
||||||
|
<th className="px-4 py-3">Data</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Versões</th>
|
||||||
|
<th className="px-4 py-3 text-right">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
||||||
|
{filteredReports.length ? (
|
||||||
|
filteredReports.map((report) => (
|
||||||
|
<ReportRow
|
||||||
|
key={report.id}
|
||||||
|
onDelete={() => {
|
||||||
|
setConfirmDelete({ report })
|
||||||
|
setDeleteConfirmText('')
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
onDelivery={() => {
|
||||||
|
setDeliveryReport(report)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
onEdit={() => openEdit(report)}
|
||||||
|
onHistory={() => {
|
||||||
|
setHistoryReport(report)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
onPrint={() => {
|
||||||
|
window.print()
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
onRelease={() => {
|
||||||
|
setConfirmRelease(report)
|
||||||
|
setOpenMenuId(null)
|
||||||
|
}}
|
||||||
|
onSend={() => sendReport(report.id)}
|
||||||
|
open={openMenuId === report.id}
|
||||||
|
report={report}
|
||||||
|
setOpenMenuId={setOpenMenuId}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
||||||
|
Nenhum laudo encontrado.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{templatesOpen ? <TemplatesModal onClose={() => setTemplatesOpen(false)} onUseTemplate={openNew} /> : null}
|
||||||
|
{historyReport ? <HistoryModal onClose={() => setHistoryReport(null)} report={historyReport} /> : null}
|
||||||
|
{deliveryReport ? <DeliveryModal onClose={() => setDeliveryReport(null)} report={deliveryReport} /> : null}
|
||||||
|
{confirmRelease ? (
|
||||||
|
<ConfirmReleaseModal
|
||||||
|
onClose={() => setConfirmRelease(null)}
|
||||||
|
onConfirm={() => releaseReport(confirmRelease.id)}
|
||||||
|
report={confirmRelease}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{confirmDelete ? (
|
||||||
|
<DeleteModal
|
||||||
|
confirmText={deleteConfirmText}
|
||||||
|
onClose={() => setConfirmDelete(null)}
|
||||||
|
onConfirm={() => deleteReport(confirmDelete.report.id)}
|
||||||
|
report={confirmDelete.report}
|
||||||
|
setConfirmText={setDeleteConfirmText}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{editorOpen ? (
|
||||||
|
<ReportEditorModal
|
||||||
|
editor={editor}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSave={saveReport}
|
||||||
|
preview={preview}
|
||||||
|
setEditor={setEditor}
|
||||||
|
setPreview={setPreview}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportRow({
|
||||||
|
onDelete,
|
||||||
|
onDelivery,
|
||||||
|
onEdit,
|
||||||
|
onHistory,
|
||||||
|
onPrint,
|
||||||
|
onRelease,
|
||||||
|
onSend,
|
||||||
|
open,
|
||||||
|
report,
|
||||||
|
setOpenMenuId,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr className="transition hover:bg-[#303030]">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ReportIcon className="size-4 text-[#3b82f6]" name="file" />
|
||||||
|
<span className="font-medium text-[#e5e5e5]">{report.type}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[#e5e5e5]">{report.patient}</td>
|
||||||
|
<td className="px-4 py-3 text-[#a3a3a3]">{report.doctor}</td>
|
||||||
|
<td className="px-4 py-3 text-[#a3a3a3]">{report.date}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`rounded px-2 py-1 text-[10px] font-bold ${statusConfig[report.status].pill}`}>
|
||||||
|
{statusConfig[report.status].label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-[#2a2a2a] px-2 py-1 text-xs font-medium text-[#a3a3a3] transition hover:bg-[#3b82f6]/10 hover:text-[#3b82f6]"
|
||||||
|
onClick={onHistory}
|
||||||
|
title="Ver histórico de versões"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReportIcon className="size-3.5" name="history" />
|
||||||
|
v{report.versions.length}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="relative px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
aria-label={`Ações de ${report.type} de ${report.patient}`}
|
||||||
|
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#2a2a2a] hover:text-[#e5e5e5]"
|
||||||
|
onClick={() => setOpenMenuId(open ? null : report.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReportIcon className="size-5" name="more" />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
aria-label="Fechar menu"
|
||||||
|
className="fixed inset-0 z-10 cursor-default"
|
||||||
|
onClick={() => setOpenMenuId(null)}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-8 top-10 z-20 w-56 rounded-lg border border-[#404040] bg-[#303030] py-1 text-left shadow-lg">
|
||||||
|
<MenuItem icon="edit" label="Editar" onClick={onEdit} />
|
||||||
|
<MenuItem icon="history" label="Histórico de Versões" onClick={onHistory} />
|
||||||
|
<MenuItem icon="printer" label="Imprimir" onClick={onPrint} />
|
||||||
|
{(report.status === 'finalizado' || report.status === 'enviado') ? (
|
||||||
|
<MenuItem icon="clipboard" label="Protocolo de Entrega" onClick={onDelivery} />
|
||||||
|
) : null}
|
||||||
|
<div className="my-1 border-t border-[#404040]" />
|
||||||
|
{report.status === 'rascunho' ? <MenuItem icon="check" label="Liberar Laudo" onClick={onRelease} tone="green" /> : null}
|
||||||
|
{report.status === 'finalizado' ? <MenuItem icon="send" label="Enviar ao Paciente" onClick={onSend} tone="blue" /> : null}
|
||||||
|
<div className="my-1 border-t border-[#404040]" />
|
||||||
|
{canDelete(report) ? (
|
||||||
|
<MenuItem icon="trash" label="Excluir" onClick={onDelete} tone="danger" />
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full cursor-not-allowed items-center gap-2 px-4 py-2 text-sm text-[#737373]">
|
||||||
|
<ReportIcon className="size-4" name="shield-off" />
|
||||||
|
Excluir
|
||||||
|
<span className="ml-auto rounded bg-[#2a2a2a] px-1.5 py-0.5 text-[10px]">Sem permissão</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportEditorModal({ editor, onClose, onSave, preview, setEditor, setPreview }) {
|
||||||
|
const isValid = editor.patient.trim() && editor.content.trim()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="flex max-h-[92vh] w-full max-w-4xl flex-col rounded-2xl border border-[#404040] bg-[#262626] shadow-xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">{editor.id ? 'Editar Laudo' : 'Novo Laudo'}</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
|
||||||
|
preview
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||||
|
: 'border-[#404040] text-[#a3a3a3] hover:bg-[#2a2a2a]'
|
||||||
|
}`}
|
||||||
|
onClick={() => setPreview((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReportIcon className="size-3.5" name="eye" />
|
||||||
|
{preview ? 'Editar' : 'Pré-visualizar'}
|
||||||
|
</button>
|
||||||
|
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
|
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{preview ? (
|
||||||
|
<div className="min-h-[400px] rounded-xl bg-white p-8 text-gray-900 shadow-inner">
|
||||||
|
<div className="mb-6 border-b border-gray-200 pb-4 text-center">
|
||||||
|
<h3 className="text-xl font-bold">{editor.type}</h3>
|
||||||
|
{editor.showDate ? <p className="mt-1 text-sm text-gray-500">{new Date().toLocaleDateString('pt-BR')}</p> : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm"><strong>Paciente:</strong> {editor.patient || '-'}</p>
|
||||||
|
<p className="mt-1 text-sm"><strong>Médico(a):</strong> {editor.doctor}</p>
|
||||||
|
<p className="mt-6 whitespace-pre-wrap text-sm leading-6">
|
||||||
|
{editor.content || 'Nenhum conteúdo inserido.'}
|
||||||
|
</p>
|
||||||
|
{editor.signDigital ? (
|
||||||
|
<div className="mt-12 border-t border-gray-200 pt-6 text-center">
|
||||||
|
<p className="text-sm font-medium text-gray-700">{editor.doctor}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">Assinatura Digital - MediConnect</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<DarkField label="Tipo de Laudo *">
|
||||||
|
<select className={inputClass} onChange={(event) => setEditorValue(setEditor, 'type', event.target.value)} value={editor.type}>
|
||||||
|
{reportTypes.map((type) => (
|
||||||
|
<option key={type}>{type}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Paciente *">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
onChange={(event) => setEditorValue(setEditor, 'patient', event.target.value)}
|
||||||
|
placeholder="Digite o nome do paciente..."
|
||||||
|
value={editor.patient}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
<DarkField label="Médico Responsável">
|
||||||
|
<select className={inputClass} onChange={(event) => setEditorValue(setEditor, 'doctor', event.target.value)} value={editor.doctor}>
|
||||||
|
{doctors.map((doctor) => (
|
||||||
|
<option key={doctor}>{doctor}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Conteúdo *">
|
||||||
|
<textarea
|
||||||
|
className={`${inputClass} min-h-72 py-3 leading-6`}
|
||||||
|
onChange={(event) => setEditorValue(setEditor, 'content', event.target.value)}
|
||||||
|
placeholder="Digite o conteúdo do laudo aqui..."
|
||||||
|
value={editor.content}
|
||||||
|
/>
|
||||||
|
</DarkField>
|
||||||
|
<div className="flex flex-wrap items-center gap-6">
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||||
|
<input
|
||||||
|
checked={editor.showDate}
|
||||||
|
className="size-4 accent-[#3b82f6]"
|
||||||
|
onChange={(event) => setEditorValue(setEditor, 'showDate', event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Exibir data no laudo
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||||
|
<input
|
||||||
|
checked={editor.signDigital}
|
||||||
|
className="size-4 accent-[#3b82f6]"
|
||||||
|
onChange={(event) => setEditorValue(setEditor, 'signDigital', event.target.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Incluir assinatura digital
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!isValid ? <p className="text-xs text-amber-400">* Preencha o paciente e o conteúdo do laudo para salvar.</p> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-[#404040] px-6 py-4">
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#2a2a2a]"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-[#404040] bg-[#2a2a2a] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!isValid}
|
||||||
|
onClick={() => onSave('rascunho')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Salvar Rascunho
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!isValid}
|
||||||
|
onClick={() => onSave('finalizado')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ReportIcon className="size-3.5" name="lock" />
|
||||||
|
Liberar Laudo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplatesModal({ onClose, onUseTemplate }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-3xl rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Templates de Laudo</h2>
|
||||||
|
<p className="mt-1 text-xs text-[#a3a3a3]">Modelos locais para acelerar a escrita do laudo.</p>
|
||||||
|
</div>
|
||||||
|
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
|
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<button
|
||||||
|
className="rounded-xl border border-[#404040] bg-[#1a1a1a] p-4 text-left transition hover:border-[#3b82f6]/50 hover:bg-[#2a2a2a]"
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => onUseTemplate(template)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-bold text-[#e5e5e5]">{template.name}</p>
|
||||||
|
<p className="mt-1 text-xs font-medium text-[#3b82f6]">{template.type}</p>
|
||||||
|
<p className="mt-3 text-xs leading-5 text-[#a3a3a3]">{template.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryModal({ onClose, report }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Histórico de Versões</h2>
|
||||||
|
<p className="mt-1 text-xs text-[#a3a3a3]">{report.type} - {report.patient}</p>
|
||||||
|
</div>
|
||||||
|
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
|
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...report.versions].reverse().map((version) => (
|
||||||
|
<div className="rounded-xl border border-[#404040] bg-[#1a1a1a] p-4" key={version.version}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-bold text-[#e5e5e5]">v{version.version} - {version.action}</p>
|
||||||
|
<p className="text-xs text-[#a3a3a3]">{version.user}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[#a3a3a3]">{version.summary}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmReleaseModal({ onClose, onConfirm, report }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">
|
||||||
|
<ReportIcon className="size-5" name="check" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-[#e5e5e5]">Liberar Laudo?</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-[#a3a3a3]">{report.type} - {report.patient}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mb-5 text-sm leading-6 text-[#a3a3a3]">
|
||||||
|
Ao liberar, o laudo ficará com status <strong className="text-emerald-400">Finalizado</strong> e poderá ser impresso ou enviado.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button className="rounded-lg border border-[#404040] px-4 py-2 text-sm text-[#e5e5e5] transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white transition hover:bg-emerald-600" onClick={onConfirm} type="button">
|
||||||
|
Confirmar Liberação
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveryModal({ onClose, report }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<h2 className="text-lg font-bold text-[#e5e5e5]">Protocolo de Entrega</h2>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
||||||
|
Entrega mockada para {report.patient}, referente a {report.type} de {report.date}.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
<DarkField label="Canal">
|
||||||
|
<select className={inputClass} defaultValue="Portal do paciente">
|
||||||
|
<option>Portal do paciente</option>
|
||||||
|
<option>E-mail</option>
|
||||||
|
<option>Impresso</option>
|
||||||
|
</select>
|
||||||
|
</DarkField>
|
||||||
|
<DarkField label="Observação">
|
||||||
|
<textarea className={`${inputClass} min-h-20 py-2`} placeholder="Observação local do protocolo..." />
|
||||||
|
</DarkField>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button className="rounded-lg border border-[#404040] px-4 py-2 text-sm text-[#e5e5e5] transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb]" onClick={onClose} type="button">
|
||||||
|
Registrar Protocolo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteModal({ confirmText, onClose, onConfirm, report, setConfirmText }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div className="w-full max-w-sm rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="rounded-lg bg-[#ef4444]/10 p-2 text-[#ef4444]">
|
||||||
|
<ReportIcon className="size-5" name="trash" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-[#e5e5e5]">Excluir laudo?</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-[#a3a3a3]">{report.type} - {report.patient}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mb-3 text-sm leading-6 text-[#a3a3a3]">Para confirmar, digite EXCLUIR no campo abaixo.</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="mb-4 h-10 w-full rounded-lg border border-[#ef4444]/40 bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#ef4444]"
|
||||||
|
onChange={(event) => setConfirmText(event.target.value)}
|
||||||
|
placeholder="Digite EXCLUIR"
|
||||||
|
value={confirmText}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button className="rounded-lg border border-[#404040] px-4 py-2 text-sm text-[#e5e5e5] transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-[#ef4444] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#dc2626] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={confirmText !== 'EXCLUIR'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Excluir Permanentemente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ icon, label, onClick, tone = 'default' }) {
|
||||||
|
const colors = {
|
||||||
|
blue: 'text-blue-400 hover:bg-blue-500/10',
|
||||||
|
danger: 'text-[#ef4444] hover:bg-[#ef4444]/10',
|
||||||
|
default: 'text-[#e5e5e5] hover:bg-[#2a2a2a]',
|
||||||
|
green: 'text-emerald-400 hover:bg-emerald-500/10',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={`flex w-full items-center gap-2 px-4 py-2 text-sm transition ${colors[tone]}`} onClick={onClick} type="button">
|
||||||
|
<ReportIcon className="size-4" name={icon} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DarkField({ children, label }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelClass}>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditorValue(setEditor, key, value) {
|
||||||
|
setEditor((currentEditor) => ({ ...currentEditor, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDelete(report) {
|
||||||
|
return adminUsers.includes(currentUser) || (report.status === 'rascunho' && report.doctor === currentUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportIcon({ className = 'size-4', name }) {
|
||||||
|
const common = {
|
||||||
|
className,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'search') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'plus') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'file') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 3h7l4 4v14H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" />
|
||||||
|
<path d="M14 3v5h5M9 13h6M9 17h6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'template') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 5h16M4 12h7M13 12h7M4 19h16" />
|
||||||
|
<path d="M4 5v14M20 5v14M11 12v7M13 12V5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'history') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
|
||||||
|
<path d="M3 4v4h4M12 7v5l3 2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'more') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'edit') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m16 3 5 5L8 21H3v-5L16 3Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'printer') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 8V3h10v5M7 17H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2" />
|
||||||
|
<path d="M7 14h10v7H7zM17 12h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'clipboard') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M9 5h6M9 5a3 3 0 0 1 6 0M8 6H6a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-2M8 13h8M8 17h5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'check') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m5 12 4 4L19 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'send') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m22 2-7 20-4-9-9-4 20-7Z" />
|
||||||
|
<path d="M22 2 11 13" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'trash') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M3 6h18M8 6V4h8v2M6 6l1 15h10l1-15M10 11v6M14 11v6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'shield-off') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3 5 6v5c0 4.5 3 8.5 7 10 1.1-.4 2.1-1 3-1.7M19 13.5V6l-7-3-4.2 1.8M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'eye') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'x') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'lock') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect height="10" rx="2" width="16" x="4" y="11" />
|
||||||
|
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
481
src/pages/SettingsPage.jsx
Normal file
481
src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { settingsRepository } from '../repositories/settingsRepository.js'
|
||||||
|
|
||||||
|
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
const rowClass = 'flex items-center justify-between gap-6 border-b border-[#404040] py-4 last:border-0'
|
||||||
|
const inputClass =
|
||||||
|
'h-10 rounded-sm border border-[#404040] bg-[#171717] px-3 text-sm text-[#e5e5e5] outline-none transition focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const sections = settingsRepository.getSections()
|
||||||
|
const [activeSection, setActiveSection] = useState('aparencia')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row">
|
||||||
|
<nav className="lg:w-64" aria-label="Seções de configuração">
|
||||||
|
<div className={`${cardClass} flex flex-col gap-1 p-2`}>
|
||||||
|
{sections.map((item) => (
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-3 rounded-xl px-3 py-3 text-left transition ${
|
||||||
|
activeSection === item.id
|
||||||
|
? 'bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||||
|
: 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setActiveSection(item.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="size-4 shrink-0" name={item.icon} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold leading-none">{item.label}</span>
|
||||||
|
<span className="mt-1 block truncate text-[11px] opacity-70">{item.description}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className={`${cardClass} min-w-0 flex-1 p-6 lg:p-8`}>
|
||||||
|
{activeSection === 'aparencia' ? <AppearanceSection /> : null}
|
||||||
|
{activeSection === 'notificacoes' ? <NotificationsSection /> : null}
|
||||||
|
{activeSection === 'privacidade' ? <PrivacySection /> : null}
|
||||||
|
{activeSection === 'conta' ? <AccountSection /> : null}
|
||||||
|
{activeSection === 'integracoes' ? <IntegrationsSection /> : null}
|
||||||
|
{activeSection === 'dados' ? <DataSection /> : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceSection() {
|
||||||
|
const [theme, setTheme] = useState('dark')
|
||||||
|
const [compact, setCompact] = useState(false)
|
||||||
|
const [contrast, setContrast] = useState(false)
|
||||||
|
const [animations, setAnimations] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionFrame description="Personalize a interface do MediConnect." title="Aparência">
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="mb-4 text-sm font-semibold text-[#e5e5e5]">Tema da Interface</p>
|
||||||
|
<div className="grid max-w-xl gap-4 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
{ id: 'dark', label: 'Escuro', preview: 'bg-[#0a1628]' },
|
||||||
|
{ id: 'light', label: 'Claro', preview: 'bg-[#f4f7fb]' },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
className={`rounded-2xl border-2 p-4 text-left transition ${
|
||||||
|
theme === item.id ? 'border-[#3b82f6] bg-[#3b82f6]/5 shadow-md shadow-[#3b82f6]/20' : 'border-[#404040] bg-[#262626] hover:border-[#3b82f6]/40'
|
||||||
|
}`}
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setTheme(item.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`mb-3 flex h-20 flex-col gap-1.5 overflow-hidden rounded-xl border border-[#404040] p-2 ${item.preview}`}>
|
||||||
|
<span className={`h-2.5 rounded ${item.id === 'dark' ? 'bg-[#1a3050]' : 'bg-white'}`} />
|
||||||
|
<span className="flex flex-1 gap-1">
|
||||||
|
<span className={`w-8 rounded ${item.id === 'dark' ? 'bg-[#0f1f36]' : 'bg-white'}`} />
|
||||||
|
<span className="flex flex-1 flex-col justify-center gap-1">
|
||||||
|
<span className={`h-1.5 w-3/4 rounded-full ${item.id === 'dark' ? 'bg-[#1e3a5f]' : 'bg-[#dde8f7]'}`} />
|
||||||
|
<span className={`h-1.5 w-1/2 rounded-full ${item.id === 'dark' ? 'bg-[#1e3a5f]' : 'bg-[#dde8f7]'}`} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-[#e5e5e5]">{item.label}</span>
|
||||||
|
{theme === item.id ? <span className="grid size-5 place-items-center rounded-full bg-[#3b82f6] text-[11px] text-white">✓</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-[#a3a3a3]">A preferência de tema é salva localmente neste protótipo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsGroup>
|
||||||
|
<SettingRow description="Transições suaves entre telas e componentes" label="Animações de interface">
|
||||||
|
<ToggleSwitch checked={animations} onChange={setAnimations} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow description="Aumenta o contraste dos elementos para melhor acessibilidade" label="Modo de alto contraste">
|
||||||
|
<ToggleSwitch checked={contrast} onChange={setContrast} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow description="Reduz o espaçamento para exibir mais informações na tela" label="Densidade compacta">
|
||||||
|
<ToggleSwitch checked={compact} onChange={setCompact} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Idioma do sistema">
|
||||||
|
<select className={inputClass} defaultValue="pt-br">
|
||||||
|
<option value="pt-br">Português (BR)</option>
|
||||||
|
<option value="en-us">English (US)</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
</SectionFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationsSection() {
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
email: true,
|
||||||
|
sms: true,
|
||||||
|
whatsapp: true,
|
||||||
|
push: false,
|
||||||
|
ai: true,
|
||||||
|
appointment: true,
|
||||||
|
report: true,
|
||||||
|
noShow: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionFrame description="Configure como e quando deseja receber alertas." title="Notificações">
|
||||||
|
<Subsection title="Canais de Comunicação">
|
||||||
|
<ToggleRow checked={settings.email} description="Receba resumos e alertas via e-mail" label="Notificações por E-mail" onChange={(value) => setSettings((current) => ({ ...current, email: value }))} />
|
||||||
|
<ToggleRow checked={settings.sms} description="Alertas urgentes via mensagem de texto" label="SMS" onChange={(value) => setSettings((current) => ({ ...current, sms: value }))} />
|
||||||
|
<ToggleRow checked={settings.whatsapp} description="Integração com WhatsApp Business para lembretes" label="WhatsApp" onChange={(value) => setSettings((current) => ({ ...current, whatsapp: value }))} />
|
||||||
|
<ToggleRow checked={settings.push} description="Notificações no navegador em tempo real" label="Push (navegador)" onChange={(value) => setSettings((current) => ({ ...current, push: value }))} />
|
||||||
|
</Subsection>
|
||||||
|
|
||||||
|
<Subsection title="Tipos de Alerta">
|
||||||
|
<ToggleRow checked={settings.ai} description="Alerta preditivo quando paciente tem alto risco de faltar" label="Risco de No-Show (IA)" onChange={(value) => setSettings((current) => ({ ...current, ai: value }))} />
|
||||||
|
<ToggleRow checked={settings.appointment} description="Lembre pacientes 24h e 1h antes da consulta" label="Lembrete de Consulta" onChange={(value) => setSettings((current) => ({ ...current, appointment: value }))} />
|
||||||
|
<ToggleRow checked={settings.report} description="Notificar quando relatórios mensais estiverem prontos" label="Relatório Disponível" onChange={(value) => setSettings((current) => ({ ...current, report: value }))} />
|
||||||
|
<ToggleRow checked={settings.noShow} description="Confirmar quando uma falta é registrada no sistema" label="No-Show registrado" onChange={(value) => setSettings((current) => ({ ...current, noShow: value }))} />
|
||||||
|
</Subsection>
|
||||||
|
|
||||||
|
<Subsection title="Horário Silencioso">
|
||||||
|
<SettingRow description="Sem notificações push entre 22h e 7h" label="Ativar horário silencioso">
|
||||||
|
<ToggleSwitch checked onChange={() => {}} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Horário de início / fim">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input className={`${inputClass} w-28`} defaultValue="22:00" type="time" />
|
||||||
|
<span className="text-sm text-[#a3a3a3]">até</span>
|
||||||
|
<input className={`${inputClass} w-28`} defaultValue="07:00" type="time" />
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</Subsection>
|
||||||
|
</SectionFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivacySection() {
|
||||||
|
const [twoFactor, setTwoFactor] = useState(false)
|
||||||
|
const [audit, setAudit] = useState(true)
|
||||||
|
const [anonymous, setAnonymous] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionFrame description="Gerencie conformidade com a Lei Geral de Proteção de Dados." title="Privacidade & LGPD">
|
||||||
|
<div className="mb-6 flex gap-3 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4">
|
||||||
|
<SettingsIcon className="mt-0.5 size-5 shrink-0 text-amber-400" name="alert" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-amber-400">Conformidade LGPD Ativa</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[#a3a3a3]">
|
||||||
|
Dados de pacientes são tratados com finalidade legítima e armazenados com segurança neste protótipo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Subsection title="Segurança de Acesso">
|
||||||
|
<ToggleRow checked={twoFactor} description="Adiciona uma camada extra de segurança ao login" label="Autenticação de Dois Fatores (2FA)" onChange={setTwoFactor} />
|
||||||
|
<SettingRow description="Desconectar automaticamente após inatividade" label="Tempo de sessão">
|
||||||
|
<select className={inputClass} defaultValue="30">
|
||||||
|
<option value="30">30 minutos</option>
|
||||||
|
<option value="60">1 hora</option>
|
||||||
|
<option value="240">4 horas</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
<ToggleRow checked={audit} description="Registrar todas as ações realizadas no sistema" label="Log de auditoria" onChange={setAudit} />
|
||||||
|
</Subsection>
|
||||||
|
|
||||||
|
<Subsection title="Dados dos Pacientes">
|
||||||
|
<ToggleRow checked={anonymous} description="Ocultar dados pessoais identificáveis nos relatórios exportados" label="Anonimizar em relatórios" onChange={setAnonymous} />
|
||||||
|
<SettingRow description="Período de armazenamento de dados inativos" label="Retenção de dados">
|
||||||
|
<select className={inputClass} defaultValue="5">
|
||||||
|
<option value="1">1 ano</option>
|
||||||
|
<option value="3">3 anos</option>
|
||||||
|
<option value="5">5 anos (padrão)</option>
|
||||||
|
<option value="10">10 anos</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow description="Gerar relatório completo para atender solicitação de titular" label="Exportar dados do paciente">
|
||||||
|
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
</Subsection>
|
||||||
|
</SectionFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountSection() {
|
||||||
|
const [profile, setProfile] = useState({
|
||||||
|
name: 'Dra. Ana Silva',
|
||||||
|
email: 'ana.silva@mediconnect.com.br',
|
||||||
|
role: 'Coordenação Médica',
|
||||||
|
crm: 'CRM/SE 12345',
|
||||||
|
})
|
||||||
|
|
||||||
|
function update(field, value) {
|
||||||
|
setProfile((current) => ({ ...current, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionFrame description="Gerencie suas informações pessoais e credenciais." title="Conta & Perfil">
|
||||||
|
<div className="mb-6 flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-5">
|
||||||
|
<div className="grid size-16 place-items-center rounded-full border-2 border-[#3b82f6]/20 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
||||||
|
AS
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-[#f5f5f5]">{profile.name}</p>
|
||||||
|
<p className="text-xs text-[#a3a3a3]">{profile.role}</p>
|
||||||
|
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
|
||||||
|
Alterar foto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<TextField label="Nome completo" onChange={(value) => update('name', value)} value={profile.name} />
|
||||||
|
<TextField label="E-mail" onChange={(value) => update('email', value)} value={profile.email} />
|
||||||
|
<TextField label="Cargo / Função" onChange={(value) => update('role', value)} value={profile.role} />
|
||||||
|
<TextField label="CRM / Registro" onChange={(value) => update('crm', value)} value={profile.crm} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Subsection title="Segurança">
|
||||||
|
<SettingRow description="Última alteração há 45 dias" label="Alterar senha">
|
||||||
|
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
|
||||||
|
Alterar
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow description="Gerenciar dispositivos conectados" label="Sessões ativas">
|
||||||
|
<button className="text-sm font-semibold text-[#3b82f6]" type="button">
|
||||||
|
Ver sessões
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
</Subsection>
|
||||||
|
</SectionFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationsSection() {
|
||||||
|
const integrations = settingsRepository.getIntegrations()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionFrame description="Conecte o MediConnect com sistemas e serviços externos." title="Integrações">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{integrations.map(([name, desc, connected, color]) => (
|
||||||
|
<div className="flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-4" key={name}>
|
||||||
|
<div className={`grid size-10 shrink-0 place-items-center rounded-lg ${color}`}>
|
||||||
|
<SettingsIcon className="size-5 text-white" name="globe" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold text-[#f5f5f5]">{name}</p>
|
||||||
|
<p className="text-xs text-[#a3a3a3]">{desc}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${connected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-[#303030] text-[#a3a3a3]'}`}>
|
||||||
|
{connected ? 'Conectado' : 'Desconectado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataSection() {
|
||||||
|
return (
|
||||||
|
<SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup">
|
||||||
|
<Subsection title="Exportação de Dados">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
['Pacientes (CSV)', 'Lista completa com dados cadastrais'],
|
||||||
|
['Prontuários (PDF)', 'Registros médicos do período'],
|
||||||
|
['Relatório Geral (PDF)', 'Dashboard executivo completo'],
|
||||||
|
].map(([label, desc]) => (
|
||||||
|
<button className="flex items-center gap-3 rounded-xl border border-[#404040] bg-[#171717] p-4 text-left transition hover:border-[#3b82f6]/40" key={label} type="button">
|
||||||
|
<span className="grid size-9 place-items-center rounded-lg bg-[#3b82f6]/10 text-[#3b82f6]">
|
||||||
|
<SettingsIcon className="size-4" name="download" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-semibold text-[#f5f5f5]">{label}</span>
|
||||||
|
<span className="block text-xs text-[#a3a3a3]">{desc}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Subsection>
|
||||||
|
|
||||||
|
<Subsection title="Backup Automático">
|
||||||
|
<SettingRow description="Salvar snapshot diário dos dados" label="Backup automático">
|
||||||
|
<ToggleSwitch checked onChange={() => {}} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow description="Com que frequência o backup é realizado" label="Frequência">
|
||||||
|
<select className={inputClass} defaultValue="daily">
|
||||||
|
<option value="daily">Diário (00h)</option>
|
||||||
|
<option value="12h">A cada 12h</option>
|
||||||
|
<option value="weekly">Semanal</option>
|
||||||
|
</select>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow description="30/03/2026 às 00:15" label="Último backup">
|
||||||
|
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
|
||||||
|
Baixar
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
</Subsection>
|
||||||
|
</SectionFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionFrame({ children, description, title }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-[#f5f5f5]">{title}</h2>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Subsection({ children, title }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.12em] text-[#a3a3a3]">{title}</p>
|
||||||
|
<SettingsGroup>{children}</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsGroup({ children }) {
|
||||||
|
return <div className="rounded-xl border border-[#404040] bg-[#171717] px-6">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({ checked, description, label, onChange }) {
|
||||||
|
return (
|
||||||
|
<SettingRow description={description} label={label}>
|
||||||
|
<ToggleSwitch checked={checked} onChange={onChange} />
|
||||||
|
</SettingRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({ children, description, label }) {
|
||||||
|
return (
|
||||||
|
<div className={rowClass}>
|
||||||
|
<div className="min-w-0 flex-1 pr-4">
|
||||||
|
<p className="text-sm font-semibold text-[#e5e5e5]">{label}</p>
|
||||||
|
{description ? <p className="mt-1 text-xs leading-5 text-[#a3a3a3]">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ label, onChange, value }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-2">
|
||||||
|
<span className="text-xs font-semibold text-[#a3a3a3]">{label}</span>
|
||||||
|
<input className={`${inputClass} w-full`} onChange={(event) => onChange(event.target.value)} value={value} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSwitch({ checked, onChange }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-checked={checked}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition ${checked ? 'bg-[#3b82f6]' : 'bg-[#303030]'}`}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
role="switch"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={`inline-block size-4 rounded-full bg-white shadow-md transition-transform ${checked ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsIcon({ className = 'size-4', name }) {
|
||||||
|
const common = {
|
||||||
|
className,
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeWidth: 1.9,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'bell') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||||
|
<path d="M10 21h4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'shield') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3 5 6v5c0 4 3 7.5 7 10 4-2.5 7-6 7-10V6l-7-3Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'user') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'globe') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'database') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<ellipse cx="12" cy="5" rx="7" ry="3" />
|
||||||
|
<path d="M5 5v14c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12c0 1.7 3.1 3 7 3s7-1.3 7-3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'download') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'alert') {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3 2 21h20L12 3Z" />
|
||||||
|
<path d="M12 9v5M12 18h.01" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M12 3v3M12 18v3M4.9 4.9 7 7M17 17l2.1 2.1M3 12h3M18 12h3M4.9 19.1 7 17M17 7l2.1-2.1" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/pages/TeamPage.jsx
Normal file
125
src/pages/TeamPage.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||||
|
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
|
export function TeamPage({ navigate }) {
|
||||||
|
const professionals = professionalRepository.getAll()
|
||||||
|
const { slots, weekdays } = professionalRepository.getCoverageMap()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Profissionais</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#b8b8b8]">Equipe, agenda e cobertura operacional da clínica.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
|
onClick={() => navigate('/agenda')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Ver disponibilidade
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe médica">
|
||||||
|
{professionals.map((professional) => (
|
||||||
|
<article className={`${cardClass} p-5`} key={professional.id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="grid size-11 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 text-sm font-bold text-[#3b82f6]">
|
||||||
|
{initials(professional.name)}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-lg font-bold text-[#f5f5f5]">{professional.name}</h2>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">{professional.role}</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={professional.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-5 grid gap-3 text-sm">
|
||||||
|
<Info label="Agenda" value={professional.schedule} />
|
||||||
|
<Info label="Próximo horário" value={professional.nextSlot} />
|
||||||
|
<Info label="Pacientes ativos" value={professional.patients} />
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${cardClass} p-5`}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-[#f5f5f5]">Mapa de cobertura</h2>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">
|
||||||
|
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6]"
|
||||||
|
onClick={() => navigate('/configuracoes')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Configurar regras
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 overflow-x-auto rounded-sm border border-[#404040]">
|
||||||
|
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] bg-[#171717] text-xs font-bold uppercase tracking-[0.16em] text-[#a3a3a3]">
|
||||||
|
{['Profissional', ...weekdays].map((label) => (
|
||||||
|
<div className="border-b border-[#404040] px-4 py-3" key={label}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{professionals.map((professional, rowIndex) => (
|
||||||
|
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] text-sm" key={professional.id}>
|
||||||
|
<div className="border-b border-[#404040] px-4 py-3 font-semibold text-[#f5f5f5]">{professional.name}</div>
|
||||||
|
{slots.map((slot, index) => (
|
||||||
|
<div className="border-b border-[#404040] px-4 py-3 text-[#b8b8b8]" key={`${professional.id}-${slot}`}>
|
||||||
|
{shiftSlot(slot, rowIndex + index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-semibold text-[#737373]">{label}</dt>
|
||||||
|
<dd className="mt-1 text-[#e5e5e5]">{value}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }) {
|
||||||
|
const className =
|
||||||
|
status === 'Disponivel'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400'
|
||||||
|
: status === 'Em atendimento'
|
||||||
|
? 'bg-amber-500/20 text-amber-400'
|
||||||
|
: 'bg-blue-500/20 text-blue-400'
|
||||||
|
|
||||||
|
return <span className={`rounded px-2 py-1 text-[10px] font-bold ${className}`}>{status}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name) {
|
||||||
|
return name
|
||||||
|
.replace(/^(Dr\.|Dra\.|Nutri\.|Enf\.)\s+/i, '')
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftSlot(slot, index) {
|
||||||
|
if (index % 4 === 0) {
|
||||||
|
return 'Bloqueado'
|
||||||
|
}
|
||||||
|
|
||||||
|
return slot
|
||||||
|
}
|
||||||
155
src/pages/VisitsPage.jsx
Normal file
155
src/pages/VisitsPage.jsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { visitRepository } from '../repositories/visitRepository.js'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: 'Fila ativa', value: 'ativa' },
|
||||||
|
{ label: 'Em atendimento', value: 'atendimento' },
|
||||||
|
{ label: 'Finalizadas', value: 'finalizadas' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
|
export function VisitsPage({ navigate }) {
|
||||||
|
const careQueue = useMemo(() => visitRepository.getCareQueue(), [])
|
||||||
|
const stages = useMemo(() => visitRepository.getStages(), [])
|
||||||
|
const [activeTab, setActiveTab] = useState('ativa')
|
||||||
|
|
||||||
|
const visibleQueue = useMemo(() => {
|
||||||
|
if (activeTab === 'finalizadas') {
|
||||||
|
return careQueue.filter((item) => item.status === 'Finalizada')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab === 'atendimento') {
|
||||||
|
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando medico')
|
||||||
|
}
|
||||||
|
|
||||||
|
return careQueue.filter((item) => item.status !== 'Finalizada')
|
||||||
|
}, [activeTab, careQueue])
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
{ label: 'Na fila', value: careQueue.filter((item) => item.status !== 'Finalizada').length, tone: 'text-[#3b82f6]' },
|
||||||
|
{ label: 'Alta prioridade', value: careQueue.filter((item) => item.priority === 'Alta').length, tone: 'text-red-400' },
|
||||||
|
{ label: 'Finalizadas', value: careQueue.filter((item) => item.status === 'Finalizada').length, tone: 'text-emerald-400' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<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]">Consultas</h1>
|
||||||
|
<p className="mt-1 text-sm text-[#b8b8b8]">Fila de atendimento, triagem e acompanhamento clínico local.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||||
|
onClick={() => navigate('/agenda')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Abrir agenda
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="h-10 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||||
|
onClick={() => navigate('/prontuario')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Novo registro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3" aria-label="Resumo da fila">
|
||||||
|
{summary.map((item) => (
|
||||||
|
<article className={`${cardClass} p-5`} key={item.label}>
|
||||||
|
<p className="text-sm text-[#a3a3a3]">{item.label}</p>
|
||||||
|
<p className={`mt-2 text-3xl font-bold leading-none ${item.tone}`}>{item.value}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${cardClass} p-5`}>
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div className="flex flex-wrap gap-2 rounded-sm border border-[#404040] bg-[#171717] p-1">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
className={`h-9 rounded-sm px-3 text-sm font-semibold transition ${
|
||||||
|
activeTab === tab.value ? 'bg-[#3b82f6] text-white' : 'text-[#b8b8b8] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={tab.value}
|
||||||
|
onClick={() => setActiveTab(tab.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#a3a3a3]">{visibleQueue.length} registros no filtro atual</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{visibleQueue.map((item) => (
|
||||||
|
<article
|
||||||
|
className="grid gap-4 rounded-xl border border-[#404040] bg-[#171717] p-4 lg:grid-cols-[1fr_180px_160px_auto]"
|
||||||
|
key={item.id}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="text-left text-lg font-bold text-[#f5f5f5] transition hover:text-[#3b82f6]"
|
||||||
|
onClick={() => navigate(`/pacientes/${item.patientId}`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.patient}
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-sm text-[#a3a3a3]">{item.reason}</p>
|
||||||
|
</div>
|
||||||
|
<Info label="Status" value={item.status} />
|
||||||
|
<Info label="Espera" value={item.wait} />
|
||||||
|
<div className="flex items-start lg:justify-end">
|
||||||
|
<PriorityPill priority={item.priority} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visibleQueue.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-[#404040] bg-[#171717] p-8 text-center">
|
||||||
|
<h2 className="text-lg font-bold text-[#f5f5f5]">Fila vazia</h2>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[#a3a3a3]">
|
||||||
|
Nenhuma consulta caiu neste estado. Troque de aba para ver a fila mockada.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{stages.map((stage, index) => (
|
||||||
|
<article className={`${cardClass} p-5`} key={stage.title}>
|
||||||
|
<p className="text-sm font-bold uppercase tracking-[0.16em] text-[#3b82f6]">Etapa {index + 1}</p>
|
||||||
|
<h2 className="mt-2 text-xl font-bold text-[#f5f5f5]">{stage.title}</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">{stage.description}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Info({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.16em] text-[#737373]">{label}</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-[#e5e5e5]">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriorityPill({ priority }) {
|
||||||
|
const className =
|
||||||
|
priority === 'Alta'
|
||||||
|
? 'bg-red-500/20 text-red-400'
|
||||||
|
: priority === 'Baixa'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400'
|
||||||
|
: 'bg-amber-500/20 text-amber-400'
|
||||||
|
|
||||||
|
return <span className={`rounded px-2.5 py-1 text-xs font-bold ${className}`}>{priority}</span>
|
||||||
|
}
|
||||||
62
src/repositories/analyticsRepository.js
Normal file
62
src/repositories/analyticsRepository.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { patientRepository } from './patientRepository.js'
|
||||||
|
|
||||||
|
const fallbackTopPatients = [
|
||||||
|
{ id: 'ana-souza', name: 'Carlos Eduardo Santos', visits: 12, revenue: 4200 },
|
||||||
|
{ id: 'carla-mendes', name: 'Fernanda Lima', visits: 10, revenue: 6000 },
|
||||||
|
{ id: 'bruno-lima', name: 'Mariana Costa', visits: 8, revenue: 1600 },
|
||||||
|
{ id: 'roberto-campos', name: 'Roberto Campos', visits: 7, revenue: 2450 },
|
||||||
|
{ id: 'sandra-oliveira', name: 'Sandra Oliveira', visits: 6, revenue: 1500 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const analyticsRepository = {
|
||||||
|
getDashboardData() {
|
||||||
|
return {
|
||||||
|
absenteeismData: [
|
||||||
|
{ month: 'Out', taxa: 18, meta: 15 },
|
||||||
|
{ month: 'Nov', taxa: 16, meta: 15 },
|
||||||
|
{ month: 'Dez', taxa: 22, meta: 15 },
|
||||||
|
{ month: 'Jan', taxa: 14, meta: 15 },
|
||||||
|
{ month: 'Fev', taxa: 12, meta: 15 },
|
||||||
|
{ month: 'Mar', taxa: 14.2, meta: 15 },
|
||||||
|
],
|
||||||
|
consultationsData: [
|
||||||
|
{ month: 'Out', total: 380, realizadas: 312 },
|
||||||
|
{ month: 'Nov', total: 420, realizadas: 352 },
|
||||||
|
{ month: 'Dez', total: 350, realizadas: 273 },
|
||||||
|
{ month: 'Jan', total: 450, realizadas: 387 },
|
||||||
|
{ month: 'Fev', total: 400, realizadas: 352 },
|
||||||
|
{ month: 'Mar', total: 460, realizadas: 395 },
|
||||||
|
],
|
||||||
|
doctorPerformance: [
|
||||||
|
{ name: 'Dra. Ana Silva', consultas: 185, noShow: 12, satisfacao: 4.8 },
|
||||||
|
{ name: 'Dr. Carlos Mendes', consultas: 142, noShow: 18, satisfacao: 4.6 },
|
||||||
|
{ name: 'Dr. Roberto Nunes', consultas: 128, noShow: 22, satisfacao: 4.4 },
|
||||||
|
],
|
||||||
|
insuranceData: [
|
||||||
|
{ name: 'Particular', value: 35, color: '#3b82f6' },
|
||||||
|
{ name: 'Unimed', value: 28, color: '#10b981' },
|
||||||
|
{ name: 'Bradesco', value: 18, color: '#8b5cf6' },
|
||||||
|
{ name: 'Amil', value: 12, color: '#f59e0b' },
|
||||||
|
{ name: 'SUS', value: 7, color: '#ef4444' },
|
||||||
|
],
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Consultas Realizadas', value: '395', change: '+12%', up: true, icon: 'calendar' },
|
||||||
|
{ label: 'Taxa de No-Show', value: '14.2%', change: '-3.1%', up: false, icon: 'activity' },
|
||||||
|
{ label: 'Faturamento', value: 'R$ 56K', change: '+24%', up: true, icon: 'dollar' },
|
||||||
|
{ label: 'Pacientes Ativos', value: '1.247', change: '+8%', up: true, icon: 'users' },
|
||||||
|
],
|
||||||
|
revenueData: [
|
||||||
|
{ month: 'Out', valor: 42000 },
|
||||||
|
{ month: 'Nov', valor: 48000 },
|
||||||
|
{ month: 'Dez', valor: 38000 },
|
||||||
|
{ month: 'Jan', valor: 52000 },
|
||||||
|
{ month: 'Fev', valor: 45000 },
|
||||||
|
{ month: 'Mar', valor: 56000 },
|
||||||
|
],
|
||||||
|
topPatients: fallbackTopPatients.map((patient) => ({
|
||||||
|
...patient,
|
||||||
|
name: patientRepository.getById(patient.id)?.name || patient.name,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
35
src/repositories/appointmentRepository.js
Normal file
35
src/repositories/appointmentRepository.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { appointments as mockAppointments } from '../data/mockData.js'
|
||||||
|
|
||||||
|
export const appointmentRepository = {
|
||||||
|
getAll() {
|
||||||
|
return mockAppointments
|
||||||
|
},
|
||||||
|
|
||||||
|
getTodayTimeline() {
|
||||||
|
return [
|
||||||
|
{ hour: '08:00', patient: 'Carla Mendes', type: 'Consulta inicial', status: 'Confirmada', patientId: 'carla-mendes' },
|
||||||
|
{ hour: '09:30', patient: 'Ana Souza', type: 'Retorno clinico', status: 'Em triagem', patientId: 'ana-souza' },
|
||||||
|
{ hour: '11:00', patient: 'Diego Alves', type: 'Acompanhamento', status: 'Aguardando', patientId: 'diego-alves' },
|
||||||
|
{ hour: '14:30', patient: 'Bruno Lima', type: 'Teleconsulta', status: 'Confirmada', patientId: 'bruno-lima' },
|
||||||
|
{ hour: '16:00', patient: 'Horario protegido', type: 'Revisao de laudos', status: 'Bloqueado', patientId: null },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
getPredictiveQueueSummary() {
|
||||||
|
return [
|
||||||
|
{ label: 'Alta prioridade', value: 3, tone: 'red' },
|
||||||
|
{ label: 'A confirmar', value: 5, tone: 'amber' },
|
||||||
|
{ label: 'Teleconsultas', value: 6, tone: 'blue' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
getWeekDays() {
|
||||||
|
return [
|
||||||
|
{ label: 'Seg', day: '06', active: false, count: 6 },
|
||||||
|
{ label: 'Ter', day: '07', active: true, count: 18 },
|
||||||
|
{ label: 'Qua', day: '08', active: false, count: 12 },
|
||||||
|
{ label: 'Qui', day: '09', active: false, count: 9 },
|
||||||
|
{ label: 'Sex', day: '10', active: false, count: 15 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
33
src/repositories/communicationRepository.js
Normal file
33
src/repositories/communicationRepository.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const communicationRepository = {
|
||||||
|
getCampaigns() {
|
||||||
|
return [
|
||||||
|
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' },
|
||||||
|
{ title: 'Vacinacao 2026', desc: 'Campanha de vacinacao anual', count: '156 pacientes elegiveis' },
|
||||||
|
{ title: 'Retorno Pendente', desc: 'Pacientes com retorno atrasado', count: '42 pacientes elegiveis' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialMessages() {
|
||||||
|
return [
|
||||||
|
{ id: '1', patient: 'Carlos Eduardo Santos', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:00', status: 'lida', response: 'Confirmado!' },
|
||||||
|
{ id: '2', patient: 'Mariana Costa', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:05', status: 'entregue' },
|
||||||
|
{ id: '3', patient: 'Joao Pedro Alves', channel: 'whatsapp', template: 'Lembrete 4h', sentAt: '27/03/2026 05:00', status: 'pendente' },
|
||||||
|
{ id: '4', patient: 'Fernanda Lima', channel: 'email', template: 'Confirmacao de Agendamento', sentAt: '24/03/2026 15:30', status: 'lida' },
|
||||||
|
{ id: '5', patient: 'Roberto Campos', channel: 'whatsapp', template: 'Lembrete Extra (Risco Alto)', sentAt: '26/03/2026 10:00', status: 'entregue' },
|
||||||
|
{ id: '6', patient: 'Sandra Oliveira', channel: 'sms', template: 'Lembrete 48h', sentAt: '24/03/2026 08:00', status: 'falha' },
|
||||||
|
{ id: '7', patient: 'Lucia Ferreira', channel: 'email', template: 'Resultado de Exames', sentAt: '26/03/2026 14:00', status: 'lida' },
|
||||||
|
{ id: '8', patient: 'Paulo Ricardo', channel: 'whatsapp', template: 'Reagendamento Sugerido (IA)', sentAt: '27/03/2026 07:00', status: 'pendente' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialTemplates() {
|
||||||
|
return [
|
||||||
|
{ id: 't1', name: 'Lembrete 48h', channel: 'whatsapp', content: 'Ola {nome}! Lembramos que sua consulta esta agendada para {data} as {hora}. Confirme respondendo SIM.', category: 'Lembrete' },
|
||||||
|
{ id: 't2', name: 'Lembrete 4h', channel: 'whatsapp', content: 'Ola {nome}! Sua consulta e hoje as {hora}. Estamos te esperando!', category: 'Lembrete' },
|
||||||
|
{ id: 't3', name: 'Lembrete Extra (Risco Alto)', channel: 'whatsapp', content: 'Ola {nome}! Notamos que sua presenca e muito importante. Podemos confirmar sua consulta de {data}?', category: 'IA' },
|
||||||
|
{ id: 't4', name: 'Confirmacao de Agendamento', channel: 'email', content: 'Prezado(a) {nome}, confirmamos seu agendamento para {data} as {hora} com {medico}.', category: 'Agendamento' },
|
||||||
|
{ id: 't5', name: 'Resultado de Exames', channel: 'email', content: 'Prezado(a) {nome}, seus resultados de exames estao disponiveis. Acesse o portal do paciente.', category: 'Exames' },
|
||||||
|
{ id: 't6', name: 'Reagendamento Sugerido (IA)', channel: 'whatsapp', content: 'Ola {nome}! Que tal reagendar sua consulta para um horario mais conveniente? Temos vagas em {sugestoes}.', category: 'IA' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
22
src/repositories/homeRepository.js
Normal file
22
src/repositories/homeRepository.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const homeRepository = {
|
||||||
|
getDashboardOverview() {
|
||||||
|
return {
|
||||||
|
appointmentsToday: [
|
||||||
|
{ time: '09:00', name: 'Ana Souza', patientId: 'ana-souza', status: 'Risco moderado' },
|
||||||
|
{ time: '10:30', name: 'Bruno Lima', patientId: 'bruno-lima', status: 'Alta prioridade' },
|
||||||
|
{ time: '14:00', name: 'Carla Mendes', patientId: 'carla-mendes', status: 'Confirmada' },
|
||||||
|
],
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Consultas Hoje', value: '42', change: '+12%', tone: 'blue' },
|
||||||
|
{ label: 'Taxa de Ocupacao', value: '14.2%', change: '+8%', tone: 'violet' },
|
||||||
|
{ label: 'No-show', value: '35', change: '-3%', tone: 'green' },
|
||||||
|
],
|
||||||
|
reportCards: [
|
||||||
|
{ title: 'Proximos Pacientes', description: 'Agenda de hoje e status preditivo', icon: 'calendar' },
|
||||||
|
{ title: 'Pacientes Frequentes', description: 'Mais atendidos neste mes', icon: 'users' },
|
||||||
|
{ title: 'Produtividade Medica', description: 'Consultas realizadas e avaliacoes', icon: 'brand' },
|
||||||
|
{ title: 'Analise de Convenios', description: 'Distribuicao de atendimentos', icon: 'building' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
15
src/repositories/medicalRecordRepository.js
Normal file
15
src/repositories/medicalRecordRepository.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export const medicalRecordRepository = {
|
||||||
|
getRecordTypes() {
|
||||||
|
return ['Consulta Retorno', 'Primeira Consulta', 'Exame', 'Avaliacao Pre-Op']
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialRecords() {
|
||||||
|
return [
|
||||||
|
{ id: 'record-1', patient: 'Carlos Eduardo Santos', date: '27/03/2026', doctor: 'Dra. Ana Silva', type: 'Consulta Retorno', cid: 'I10 - Hipertensao', status: 'completo', summary: 'Paciente relata melhora com medicacao. PA: 130/85. Mantida conduta.' },
|
||||||
|
{ id: 'record-2', patient: 'Mariana Costa', date: '26/03/2026', doctor: 'Dra. Ana Silva', type: 'Exame', cid: 'Z01.7 - Exame laboratorial', status: 'completo', summary: 'Resultados de hemograma dentro da normalidade. Solicitar retorno em 6 meses.' },
|
||||||
|
{ id: 'record-3', patient: 'Joao Pedro Alves', date: '25/03/2026', doctor: 'Dr. Carlos Mendes', type: 'Primeira Consulta', cid: 'R10 - Dor abdominal', status: 'rascunho', summary: 'Queixa de dor abdominal ha 2 semanas. Solicitados exames complementares.' },
|
||||||
|
{ id: 'record-4', patient: 'Fernanda Lima', date: '24/03/2026', doctor: 'Dra. Ana Silva', type: 'Avaliacao Pre-Op', cid: 'K80 - Colelitiase', status: 'completo', summary: 'Apta para procedimento cirurgico. Exames pre-operatorios normais.' },
|
||||||
|
{ id: 'record-5', patient: 'Roberto Campos', date: '22/03/2026', doctor: 'Dr. Roberto Nunes', type: 'Consulta Retorno', cid: 'E11 - DM Tipo 2', status: 'completo', summary: 'HbA1c: 7.2%. Ajuste de metformina. Retorno em 3 meses.' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
122
src/repositories/patientRepository.js
Normal file
122
src/repositories/patientRepository.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
const BASE_URL = 'https://yuanqfswhberkoevtmfr.supabase.co/rest/v1'
|
||||||
|
const FUNCTIONS_URL = 'https://yuanqfswhberkoevtmfr.supabase.co/functions/v1'
|
||||||
|
const API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'apikey': API_KEY,
|
||||||
|
'Authorization': `Bearer ${API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const patientRepository = {
|
||||||
|
// 1. Listar pacientes
|
||||||
|
async getAll() {
|
||||||
|
const response = await fetch(`${BASE_URL}/patients?select=*`, { headers })
|
||||||
|
if (!response.ok) throw new Error('Erro ao buscar pacientes')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(patientId) {
|
||||||
|
const patients = await this.getAll()
|
||||||
|
return patients.find((p) => String(p.id) === String(patientId)) || null
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDirectoryRows() {
|
||||||
|
const patients = await this.getAll()
|
||||||
|
return patients.map((patient) => ({
|
||||||
|
...patient,
|
||||||
|
name: patient.full_name,
|
||||||
|
phone: patient.phone_mobile,
|
||||||
|
detailId: patient.id,
|
||||||
|
insurance: 'Particular',
|
||||||
|
city: 'Recife',
|
||||||
|
state: 'PE',
|
||||||
|
vip: false,
|
||||||
|
lastVisitIso: null,
|
||||||
|
lastVisit: 'Ainda nao houve atendimento',
|
||||||
|
nextVisit: 'Nenhum atendimento agendado',
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. Criar paciente (direto)
|
||||||
|
async create(data) {
|
||||||
|
const body = {
|
||||||
|
full_name: data.name,
|
||||||
|
cpf: data.cpf,
|
||||||
|
email: data.email,
|
||||||
|
phone_mobile: data.phone,
|
||||||
|
birth_date: data.birthDate || null,
|
||||||
|
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/patients`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Prefer': 'return=representation' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
console.error('Erro da API ao criar paciente:', error)
|
||||||
|
throw new Error(error.message || error.hint || JSON.stringify(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. Criar paciente com validação de CPF (Edge Function)
|
||||||
|
async createWithValidation(data) {
|
||||||
|
const body = {
|
||||||
|
full_name: data.name,
|
||||||
|
cpf: data.cpf,
|
||||||
|
email: data.email,
|
||||||
|
phone_mobile: data.phone,
|
||||||
|
birth_date: data.birthDate || null,
|
||||||
|
created_by: data.createdBy || '00000000-0000-0000-0000-000000000000',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${FUNCTIONS_URL}/create-patient`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(error.message || 'Erro ao criar paciente com validacao')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. Atualizar paciente
|
||||||
|
async update(patientId, data) {
|
||||||
|
const body = {
|
||||||
|
full_name: data.name,
|
||||||
|
cpf: data.cpf,
|
||||||
|
email: data.email,
|
||||||
|
phone_mobile: data.phone,
|
||||||
|
birth_date: data.birthDate || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}/patients?id=eq.${patientId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...headers, 'Prefer': 'return=representation' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Erro ao atualizar paciente')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. Deletar paciente
|
||||||
|
async remove(patientId) {
|
||||||
|
const response = await fetch(`${BASE_URL}/patients?id=eq.${patientId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Erro ao deletar paciente')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
14
src/repositories/professionalRepository.js
Normal file
14
src/repositories/professionalRepository.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { professionals as mockProfessionals } from '../data/mockData.js'
|
||||||
|
|
||||||
|
export const professionalRepository = {
|
||||||
|
getAll() {
|
||||||
|
return mockProfessionals
|
||||||
|
},
|
||||||
|
|
||||||
|
getCoverageMap() {
|
||||||
|
return {
|
||||||
|
slots: ['08-12', '09-13', '10-15', '13-18', '08-14'],
|
||||||
|
weekdays: ['Seg', 'Ter', 'Qua', 'Qui', 'Sex'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
11
src/repositories/profileRepository.js
Normal file
11
src/repositories/profileRepository.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export const profileRepository = {
|
||||||
|
getCurrentUserProfile() {
|
||||||
|
return {
|
||||||
|
email: 'henrique.cardoso@mediconnect.com.br',
|
||||||
|
name: 'Dr. Henrique Cardoso',
|
||||||
|
phone: '(81) 98888-0101',
|
||||||
|
role: 'Medico Clinico Geral',
|
||||||
|
unit: 'Clinica Boa Vista',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
121
src/repositories/reportRepository.js
Normal file
121
src/repositories/reportRepository.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
const reportTypes = [
|
||||||
|
'Atestado Medico',
|
||||||
|
'Laudo de Exame',
|
||||||
|
'Laudo de Imagem',
|
||||||
|
'Relatorio Cirurgico',
|
||||||
|
'Declaracao de Acompanhante',
|
||||||
|
'Encaminhamento',
|
||||||
|
]
|
||||||
|
|
||||||
|
const doctors = ['Dra. Ana Silva', 'Dr. Carlos Mendes', 'Dr. Roberto Nunes']
|
||||||
|
const currentUser = 'Dra. Ana Silva'
|
||||||
|
const adminUsers = ['Dr. Roberto Nunes']
|
||||||
|
|
||||||
|
export const reportRepository = {
|
||||||
|
getAdminUsers() {
|
||||||
|
return adminUsers
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentUser() {
|
||||||
|
return currentUser
|
||||||
|
},
|
||||||
|
|
||||||
|
getDoctors() {
|
||||||
|
return doctors
|
||||||
|
},
|
||||||
|
|
||||||
|
getInitialReports() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'report-1',
|
||||||
|
type: 'Atestado Medico',
|
||||||
|
patient: 'Carlos Eduardo Santos',
|
||||||
|
doctor: 'Dra. Ana Silva',
|
||||||
|
date: '27/03/2026',
|
||||||
|
status: 'finalizado',
|
||||||
|
content: 'Atesto que o paciente esteve em consulta medica nesta data, necessitando de repouso por 2 dias.',
|
||||||
|
showDate: true,
|
||||||
|
signDigital: true,
|
||||||
|
versions: [
|
||||||
|
{ version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' },
|
||||||
|
{ version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Ajuste no periodo de repouso' },
|
||||||
|
{ version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Laudo liberado e finalizado' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report-2',
|
||||||
|
type: 'Laudo de Exame',
|
||||||
|
patient: 'Mariana Costa',
|
||||||
|
doctor: 'Dra. Ana Silva',
|
||||||
|
date: '26/03/2026',
|
||||||
|
status: 'enviado',
|
||||||
|
content: 'Laudo referente ao exame de ecocardiograma. Resultado dentro dos parametros normais.',
|
||||||
|
showDate: true,
|
||||||
|
signDigital: true,
|
||||||
|
versions: [
|
||||||
|
{ version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Laudo criado' },
|
||||||
|
{ version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Adicao da data do exame' },
|
||||||
|
{ version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao incluida' },
|
||||||
|
{ version: 4, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report-3',
|
||||||
|
type: 'Relatorio Cirurgico',
|
||||||
|
patient: 'Fernanda Lima',
|
||||||
|
doctor: 'Dr. Carlos Mendes',
|
||||||
|
date: '25/03/2026',
|
||||||
|
status: 'rascunho',
|
||||||
|
content: 'Relatorio do procedimento de colecistectomia laparoscopica realizado sob anestesia geral.',
|
||||||
|
showDate: false,
|
||||||
|
signDigital: true,
|
||||||
|
versions: [
|
||||||
|
{ version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Relatorio criado' },
|
||||||
|
{ version: 2, action: 'Rascunho', user: 'Dr. Carlos Mendes', summary: 'Detalhamento do procedimento' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report-4',
|
||||||
|
type: 'Declaracao de Acompanhante',
|
||||||
|
patient: 'Joao Pedro Alves',
|
||||||
|
doctor: 'Dr. Roberto Nunes',
|
||||||
|
date: '24/03/2026',
|
||||||
|
status: 'finalizado',
|
||||||
|
content: 'Declaro que o acompanhante esteve presente durante todo o periodo de internacao.',
|
||||||
|
showDate: true,
|
||||||
|
signDigital: false,
|
||||||
|
versions: [
|
||||||
|
{ version: 1, action: 'Criado', user: 'Dr. Roberto Nunes', summary: 'Declaracao criada e liberada' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report-5',
|
||||||
|
type: 'Laudo de Imagem',
|
||||||
|
patient: 'Roberto Campos',
|
||||||
|
doctor: 'Dra. Ana Silva',
|
||||||
|
date: '22/03/2026',
|
||||||
|
status: 'enviado',
|
||||||
|
content: 'Ultrassonografia de abdomen total sem achados patologicos relevantes.',
|
||||||
|
showDate: true,
|
||||||
|
signDigital: true,
|
||||||
|
versions: [
|
||||||
|
{ version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' },
|
||||||
|
{ version: 2, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao adicionada' },
|
||||||
|
{ version: 3, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
getReportTypes() {
|
||||||
|
return reportTypes
|
||||||
|
},
|
||||||
|
|
||||||
|
getTemplates() {
|
||||||
|
return [
|
||||||
|
{ id: 'template-1', name: 'Atestado de Repouso Simples', type: 'Atestado Medico', description: 'Atestado padrao concedendo dias de repouso ao paciente.', content: 'Atesto, para os devidos fins, que o(a) paciente necessita de repouso pelo periodo indicado.' },
|
||||||
|
{ id: 'template-2', name: 'Laudo de Hemograma', type: 'Laudo de Exame', description: 'Resultado de hemograma completo com interpretacao clinica.', content: 'Laudo de hemograma completo com parametros avaliados e interpretacao clinica.' },
|
||||||
|
{ id: 'template-3', name: 'Relatorio Cirurgico', type: 'Relatorio Cirurgico', description: 'Relatorio padronizado para procedimento cirurgico.', content: 'Relatorio do procedimento cirurgico, achados, conduta e evolucao imediata.' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
23
src/repositories/settingsRepository.js
Normal file
23
src/repositories/settingsRepository.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export const settingsRepository = {
|
||||||
|
getIntegrations() {
|
||||||
|
return [
|
||||||
|
['WhatsApp Business', 'Envio automatico de lembretes e confirmacoes', true, 'bg-emerald-500'],
|
||||||
|
['Google Calendar', 'Sincronizacao bidirecional de agenda', false, 'bg-blue-500'],
|
||||||
|
['Stripe / PagSeguro', 'Pagamentos online e links de cobranca', true, 'bg-violet-500'],
|
||||||
|
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
|
||||||
|
['ANS - Planos de Saude', 'Integracao com tabela TUSS e convenios', false, 'bg-rose-500'],
|
||||||
|
['API de IA Preditiva', 'Score de absenteismo e predicao de faltas', true, 'bg-[#3b82f6]'],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
getSections() {
|
||||||
|
return [
|
||||||
|
{ id: 'aparencia', label: 'Aparencia', description: 'Tema, cores e exibicao', icon: 'palette' },
|
||||||
|
{ id: 'notificacoes', label: 'Notificacoes', description: 'Alertas e lembretes', icon: 'bell' },
|
||||||
|
{ id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' },
|
||||||
|
{ id: 'conta', label: 'Conta & Perfil', description: 'Informacoes pessoais', icon: 'user' },
|
||||||
|
{ id: 'integracoes', label: 'Integracoes', description: 'APIs e sistemas externos', icon: 'globe' },
|
||||||
|
{ id: 'dados', label: 'Dados & Backup', description: 'Exportacao e backup', icon: 'database' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
15
src/repositories/visitRepository.js
Normal file
15
src/repositories/visitRepository.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { careQueue as mockCareQueue } from '../data/mockData.js'
|
||||||
|
|
||||||
|
export const visitRepository = {
|
||||||
|
getCareQueue() {
|
||||||
|
return mockCareQueue
|
||||||
|
},
|
||||||
|
|
||||||
|
getStages() {
|
||||||
|
return [
|
||||||
|
{ title: 'Triagem', description: 'Sinais vitais, queixa principal e alerta de risco antes da chamada medica.' },
|
||||||
|
{ title: 'Atendimento medico', description: 'Consulta em andamento, conduta, prescricao e solicitacao de exames.' },
|
||||||
|
{ title: 'Pos-consulta', description: 'Orientacoes finais, documentos emitidos e retorno sugerido pela equipe.' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user