modified: src/App.jsx
modified: src/components/AppShell.jsx modified: src/components/Brand.jsx modified: src/index.css modified: src/pages/MedicalRecordsPage.jsx modified: src/pages/PatientsPage.jsx modified: src/pages/ReportsPage.jsx modified: src/pages/SettingsPage.jsx modified: src/repositories/authRepository.js modified: src/repositories/professionalRepository.js modified: src/repositories/repositoryUtils.js modified: src/repositories/settingsRepository.js modified: src/utils/theme.js
This commit is contained in:
50
src/App.jsx
50
src/App.jsx
@@ -1,30 +1,36 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import { AppShell } from './components/AppShell.jsx'
|
import { AppShell } from './components/AppShell.jsx'
|
||||||
import { canAccess } from './config/permissions.js'
|
import { canAccess } from './config/permissions.js'
|
||||||
import { useAuth } from './hooks/useAuth.js'
|
import { useAuth } from './hooks/useAuth.js'
|
||||||
import { AgendaPage } from './pages/AgendaPage.jsx'
|
|
||||||
import { AnalyticsPage } from './pages/AnalyticsPage.jsx'
|
|
||||||
import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx'
|
import { 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 { NotFoundPage } from './pages/NotFoundPage.jsx'
|
||||||
import { PatientDetailPage, PatientsPage } from './pages/PatientsPage.jsx'
|
|
||||||
import { ProfilePage } from './pages/ProfilePage.jsx'
|
|
||||||
import { ReportsPage } from './pages/ReportsPage.jsx'
|
|
||||||
import { SettingsPage } from './pages/SettingsPage.jsx'
|
|
||||||
import { UsersPage } from './pages/UsersPage.jsx'
|
|
||||||
import { VisitsPage } from './pages/VisitsPage.jsx'
|
|
||||||
import { patientRepository } from './repositories/patientRepository.js'
|
import { patientRepository } from './repositories/patientRepository.js'
|
||||||
|
|
||||||
|
const AgendaPage = lazyPage(() => import('./pages/AgendaPage.jsx'), 'AgendaPage')
|
||||||
|
const AnalyticsPage = lazyPage(() => import('./pages/AnalyticsPage.jsx'), 'AnalyticsPage')
|
||||||
|
const HomePage = lazyPage(() => import('./pages/HomePage.jsx'), 'HomePage')
|
||||||
|
const MedicalRecordsPage = lazyPage(() => import('./pages/MedicalRecordsPage.jsx'), 'MedicalRecordsPage')
|
||||||
|
const MessagesPage = lazyPage(() => import('./pages/MessagesPage.jsx'), 'MessagesPage')
|
||||||
|
const PatientDetailPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientDetailPage')
|
||||||
|
const PatientsPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientsPage')
|
||||||
|
const ProfilePage = lazyPage(() => import('./pages/ProfilePage.jsx'), 'ProfilePage')
|
||||||
|
const ReportsPage = lazyPage(() => import('./pages/ReportsPage.jsx'), 'ReportsPage')
|
||||||
|
const SettingsPage = lazyPage(() => import('./pages/SettingsPage.jsx'), 'SettingsPage')
|
||||||
|
const UsersPage = lazyPage(() => import('./pages/UsersPage.jsx'), 'UsersPage')
|
||||||
|
const VisitsPage = lazyPage(() => import('./pages/VisitsPage.jsx'), 'VisitsPage')
|
||||||
|
|
||||||
const PANEL_PATHS = ['/inicio', '/home', '/dashboard']
|
const PANEL_PATHS = ['/inicio', '/home', '/dashboard']
|
||||||
const ROLE_HOME_PATHS = {
|
const ROLE_HOME_PATHS = {
|
||||||
medico: '/agenda',
|
medico: '/agenda',
|
||||||
secretaria: '/agenda',
|
secretaria: '/agenda',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lazyPage(loader, exportName) {
|
||||||
|
return lazy(() => loader().then((module) => ({ default: module[exportName] })))
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [location, setLocation] = useState(() => readLocation())
|
const [location, setLocation] = useState(() => readLocation())
|
||||||
const { isAuthenticated, role, loading: authLoading } = useAuth()
|
const { isAuthenticated, role, loading: authLoading } = useAuth()
|
||||||
@@ -72,7 +78,7 @@ function App() {
|
|||||||
|
|
||||||
// Rotas públicas (sem shell)
|
// Rotas públicas (sem shell)
|
||||||
if (!route.withShell) {
|
if (!route.withShell) {
|
||||||
return route.element
|
return <RouteSuspense>{route.element}</RouteSuspense>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usuário não autenticado
|
// Usuário não autenticado
|
||||||
@@ -97,11 +103,27 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
||||||
{route.element}
|
<RouteSuspense>{route.element}</RouteSuspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteSuspense({ children }) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<RouteFallback />}>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[40vh] items-center justify-center">
|
||||||
|
<p className="text-sm text-[#a3a3a3]">Carregando...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRoute(pathname, navigate, role) {
|
function resolveRoute(pathname, navigate, role) {
|
||||||
if (pathname === '/' || pathname === '/login') {
|
if (pathname === '/' || pathname === '/login') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const titles = {
|
|||||||
|
|
||||||
export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
|
export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
const [profileMenuOpen, setProfileMenuOpen] = useState(false)
|
const [profileMenuOpen, setProfileMenuOpen] = useState(false)
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
const [notificationsOpen, setNotificationsOpen] = useState(false)
|
||||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
||||||
@@ -139,6 +140,14 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
navigate('/login', { replace: true })
|
navigate('/login', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSidebarCollapsed() {
|
||||||
|
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSidebarCollapsed((current) => !current)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
|
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
|
||||||
<a
|
<a
|
||||||
@@ -149,15 +158,19 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={`fixed inset-y-0 left-0 z-40 flex w-56 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-transform duration-200 lg:translate-x-0 ${
|
className={`fixed inset-y-0 left-0 z-40 flex w-56 -translate-x-full flex-col border-r border-[#404040] bg-[#262626] transition-all duration-200 lg:translate-x-0 ${
|
||||||
|
sidebarCollapsed ? 'lg:w-16' : 'lg:w-56'
|
||||||
|
} ${
|
||||||
menuOpen ? 'translate-x-0' : ''
|
menuOpen ? 'translate-x-0' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex h-16 items-center border-b border-[#404040] px-3">
|
<div className={`flex h-16 items-center border-b border-[#404040] px-3 ${sidebarCollapsed ? 'lg:justify-center' : ''}`}>
|
||||||
<BrandLogo
|
<BrandLogo
|
||||||
iconClassName="size-8 rounded-sm"
|
iconClassName="size-8 rounded-sm"
|
||||||
|
iconButtonLabel={sidebarCollapsed ? 'Expandir sidebar' : 'Recolher sidebar'}
|
||||||
markClassName="size-5"
|
markClassName="size-5"
|
||||||
textClassName="text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5]"
|
onIconClick={toggleSidebarCollapsed}
|
||||||
|
textClassName={`text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5] ${sidebarCollapsed ? 'lg:hidden' : ''}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,6 +182,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
item={item}
|
item={item}
|
||||||
key={`${item.label}-${item.href}`}
|
key={`${item.label}-${item.href}`}
|
||||||
onNavigate={goTo}
|
onNavigate={goTo}
|
||||||
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -176,12 +190,21 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
|
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<button
|
<button
|
||||||
className="w-full rounded-md border border-[#404040] bg-[#303030] px-3 py-2.5 text-left transition hover:border-[#525252] hover:bg-[#333333]"
|
className={`w-full rounded-md border border-[#404040] bg-[#303030] text-left transition hover:border-[#525252] hover:bg-[#333333] ${
|
||||||
|
sidebarCollapsed ? 'grid h-10 place-items-center px-0 py-0 lg:rounded-full' : 'px-3 py-2.5'
|
||||||
|
}`}
|
||||||
onClick={() => goTo('/perfil')}
|
onClick={() => goTo('/perfil')}
|
||||||
|
title={sidebarCollapsed ? `${viewerProfile.name} - ${viewerProfile.role}` : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
|
{sidebarCollapsed ? (
|
||||||
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
|
<span className="text-xs font-bold text-[#3b82f6]">{getInitials(viewerProfile.name)}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
|
||||||
|
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -195,7 +218,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="lg:pl-56">
|
<div className={`transition-[padding] duration-200 ${sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-56'}`}>
|
||||||
<header className="sticky top-0 z-20 h-auto border-b border-[#404040] bg-[#262626] px-4 py-3 md:px-8 lg:h-16 lg:py-0">
|
<header className="sticky top-0 z-20 h-auto border-b border-[#404040] bg-[#262626] px-4 py-3 md:px-8 lg:h-16 lg:py-0">
|
||||||
<div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
@@ -236,7 +259,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-2 py-2">
|
<div className="flex items-center justify-between px-2 py-2">
|
||||||
<p className="text-sm font-semibold text-[#e5e5e5]">Notificações</p>
|
<p className="text-sm font-semibold text-[#e5e5e5]">Notificações</p>
|
||||||
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
|
<span className="feature-badge-mock rounded border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
|
||||||
Mock
|
Mock
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,10 +369,11 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({ active, item, onNavigate }) {
|
function NavItem({ active, item, onNavigate, sidebarCollapsed = false }) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
|
aria-label={sidebarCollapsed ? item.label : undefined}
|
||||||
className={`flex h-9 items-center gap-3 rounded-sm px-2 text-sm font-medium transition ${
|
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]'
|
active ? 'bg-[#3b82f6]/10 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
||||||
}`}
|
}`}
|
||||||
@@ -358,9 +382,10 @@ function NavItem({ active, item, onNavigate }) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
onNavigate(item.href)
|
onNavigate(item.href)
|
||||||
}}
|
}}
|
||||||
|
title={sidebarCollapsed ? item.label : undefined}
|
||||||
>
|
>
|
||||||
<AppIcon className="size-5 shrink-0" name={item.icon} />
|
<AppIcon className={`size-5 shrink-0 ${sidebarCollapsed ? 'lg:mx-auto' : ''}`} name={item.icon} />
|
||||||
<span>{item.label}</span>
|
<span className={sidebarCollapsed ? 'lg:hidden' : ''}>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
export function BrandLogo({
|
export function BrandLogo({
|
||||||
className = '',
|
className = '',
|
||||||
iconClassName = 'size-10 rounded-[6px]',
|
iconClassName = 'size-10 rounded-[6px]',
|
||||||
|
iconButtonLabel = 'MediConnect',
|
||||||
markClassName = 'size-6',
|
markClassName = 'size-6',
|
||||||
|
onIconClick,
|
||||||
textClassName = 'text-2xl font-bold leading-8 tracking-[-0.025em] text-white',
|
textClassName = 'text-2xl font-bold leading-8 tracking-[-0.025em] text-white',
|
||||||
}) {
|
}) {
|
||||||
|
const icon = (
|
||||||
|
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
|
||||||
|
<StethoscopeIcon className={markClassName} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-3 ${className}`}>
|
<div className={`flex items-center gap-3 ${className}`}>
|
||||||
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
|
{onIconClick ? (
|
||||||
<StethoscopeIcon className={markClassName} />
|
<button
|
||||||
</div>
|
aria-label={iconButtonLabel}
|
||||||
|
className="shrink-0 rounded-sm transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-[#3b82f6]/50"
|
||||||
|
onClick={onIconClick}
|
||||||
|
title={iconButtonLabel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
icon
|
||||||
|
)}
|
||||||
<p className={textClassName}>MediConnect</p>
|
<p className={textClassName}>MediConnect</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -336,11 +336,6 @@ button:disabled {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'] .report-editor-sidebar {
|
|
||||||
border-color: #c8d4e2;
|
|
||||||
background: #edf4fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='light'] .report-editor-body {
|
[data-theme='light'] .report-editor-body {
|
||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,25 +575,18 @@ function PatientPickList({ items, onSelect, selectedId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PatientReportHistory({ fallbackReports = [], patientId, patientName }) {
|
function PatientReportHistory({ fallbackReports = [], patientId, patientName }) {
|
||||||
const [reports, setReports] = useState(fallbackReports)
|
const [reportState, setReportState] = useState({ patientId: '', reports: [] })
|
||||||
const [loading, setLoading] = useState(Boolean(patientId))
|
const reports = patientId && reportState.patientId === patientId ? reportState.reports : fallbackReports
|
||||||
|
const loading = Boolean(patientId && reportState.patientId !== patientId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!patientId) return undefined
|
||||||
|
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
if (!patientId) {
|
|
||||||
setReports(fallbackReports)
|
|
||||||
setLoading(false)
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
loadReportsForPatient(patientId, patientName).then((data) => {
|
loadReportsForPatient(patientId, patientName).then((data) => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setReports(data.length ? data : fallbackReports)
|
setReportState({ patientId, reports: data.length ? data : fallbackReports })
|
||||||
setLoading(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
|||||||
async function deletePatient() {
|
async function deletePatient() {
|
||||||
if (!canHardDeletePatients) return
|
if (!canHardDeletePatients) return
|
||||||
|
|
||||||
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente?')) {
|
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente? Esta ação não poderá ser desfeita.')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,21 +900,15 @@ export function PatientDetailPage({ navigate, patient, role }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{canHardDeletePatients ? (
|
{canHardDeletePatients ? (
|
||||||
<section className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6">
|
<div className="flex justify-end">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<button
|
||||||
<div>
|
className="h-10 rounded-sm border border-red-700 bg-red-600 px-4 text-sm font-semibold text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/40"
|
||||||
<h2 className="text-sm font-bold text-red-300">Zona de exclusão</h2>
|
onClick={deletePatient}
|
||||||
<p className="mt-1 text-sm text-red-100/80">Remove definitivamente o paciente e seus dados locais carregados.</p>
|
type="button"
|
||||||
</div>
|
>
|
||||||
<button
|
Excluir paciente
|
||||||
className="h-10 rounded-sm border border-red-500/40 bg-red-500/10 px-4 text-sm font-semibold text-red-300 transition hover:bg-red-500/20"
|
</button>
|
||||||
onClick={deletePatient}
|
</div>
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Excluir paciente
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{messageShortcutOpen ? (
|
{messageShortcutOpen ? (
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ const orderOptions = [
|
|||||||
|
|
||||||
const inputClass =
|
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]'
|
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||||
const textareaClass =
|
|
||||||
'min-h-24 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 py-2 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
|
||||||
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||||
|
|
||||||
@@ -123,8 +121,6 @@ const reportTemplates = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const templateCategories = ['Todos', ...Array.from(new Set(reportTemplates.map((template) => template.category)))]
|
|
||||||
|
|
||||||
const emptyEditor = {
|
const emptyEditor = {
|
||||||
id: null,
|
id: null,
|
||||||
orderNumber: '',
|
orderNumber: '',
|
||||||
@@ -618,16 +614,13 @@ function ReportRow({ onEdit, onView, report }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
||||||
const [templateCategory, setTemplateCategory] = useState('Todos')
|
|
||||||
const [templateSearch, setTemplateSearch] = useState('')
|
const [templateSearch, setTemplateSearch] = useState('')
|
||||||
const [templatesOpen, setTemplatesOpen] = useState(false)
|
const [templatesOpen, setTemplatesOpen] = useState(false)
|
||||||
const [categoriesOpen, setCategoriesOpen] = useState(true)
|
|
||||||
const isValid = isReportEditorValid(editor)
|
const isValid = isReportEditorValid(editor)
|
||||||
const filteredTemplates = reportTemplates.filter((template) => {
|
const filteredTemplates = reportTemplates.filter((template) => {
|
||||||
const matchesCategory = templateCategory === 'Todos' || template.category === templateCategory
|
|
||||||
const query = normalizeSearch(templateSearch)
|
const query = normalizeSearch(templateSearch)
|
||||||
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
||||||
return matchesCategory && matchesSearch
|
return matchesSearch
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateField(field, value) {
|
function updateField(field, value) {
|
||||||
@@ -672,40 +665,7 @@ function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`grid min-h-0 flex-1 ${categoriesOpen ? 'lg:grid-cols-[230px_minmax(0,1fr)]' : 'lg:grid-cols-[56px_minmax(0,1fr)]'}`}>
|
<div className="grid min-h-0 flex-1">
|
||||||
<aside className="report-editor-sidebar min-h-0 border-b border-[#404040] bg-[#202020] p-3 lg:border-b-0 lg:border-r">
|
|
||||||
<button
|
|
||||||
className="mb-3 flex h-9 w-full items-center justify-between rounded-sm border border-[#404040] bg-[#171717] px-2 text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
|
|
||||||
onClick={() => setCategoriesOpen((current) => !current)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{categoriesOpen ? <span>Categorias</span> : null}
|
|
||||||
<ReportIcon className="size-4" name={categoriesOpen ? 'chevron-left' : 'chevron-right'} />
|
|
||||||
</button>
|
|
||||||
{categoriesOpen ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{templateCategories.map((category) => {
|
|
||||||
const count = category === 'Todos' ? reportTemplates.length : reportTemplates.filter((template) => template.category === category).length
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm font-semibold transition ${
|
|
||||||
templateCategory === category
|
|
||||||
? 'bg-[#3b82f6]/15 text-[#3b82f6]'
|
|
||||||
: 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
|
||||||
}`}
|
|
||||||
key={category}
|
|
||||||
onClick={() => setTemplateCategory(category)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>{category}</span>
|
|
||||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{count}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="report-editor-body min-h-0 overflow-y-auto p-5">
|
<main className="report-editor-body min-h-0 overflow-y-auto p-5">
|
||||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
<DarkField label="Status *">
|
<DarkField label="Status *">
|
||||||
@@ -795,486 +755,6 @@ function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReportEditorModalV2({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
|
||||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
|
||||||
const [patientSearch, setPatientSearch] = useState('')
|
|
||||||
const [templateSearch, setTemplateSearch] = useState('')
|
|
||||||
const [templateCategory, setTemplateCategory] = useState('Todos')
|
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false)
|
|
||||||
const isValid = isReportEditorValid(editor)
|
|
||||||
const selectedPatient = patientOptions.find((patient) => patient.id === String(editor.patientId))
|
|
||||||
const filteredPatients = patientOptions
|
|
||||||
.filter((patient) => normalizeSearch(patient.name).includes(normalizeSearch(patientSearch)))
|
|
||||||
.slice(0, 5)
|
|
||||||
const filteredRequesterOptions = professionalOptions
|
|
||||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
|
||||||
.slice(0, 5)
|
|
||||||
const filteredTemplates = reportTemplates.filter((template) => {
|
|
||||||
const matchesCategory = templateCategory === 'Todos' || template.category === templateCategory
|
|
||||||
const query = normalizeSearch(templateSearch)
|
|
||||||
const matchesSearch = !query || normalizeSearch([template.title, template.description, template.tags.join(' ')].join(' ')).includes(query)
|
|
||||||
return matchesCategory && matchesSearch
|
|
||||||
})
|
|
||||||
const selectedTemplate = reportTemplates.find((template) => template.id === selectedTemplateId)
|
|
||||||
|
|
||||||
function updateField(field, value) {
|
|
||||||
onChange((current) => ({ ...current, [field]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTemplate(template) {
|
|
||||||
setSelectedTemplateId(template.id)
|
|
||||||
setPreviewOpen(true)
|
|
||||||
onChange((current) => ({
|
|
||||||
...current,
|
|
||||||
exam: template.exam,
|
|
||||||
cidCode: template.cidCode,
|
|
||||||
diagnosis: template.diagnosis,
|
|
||||||
conclusion: template.conclusion,
|
|
||||||
contentHtml: template.contentHtml,
|
|
||||||
contentJson: {
|
|
||||||
templateId: template.id,
|
|
||||||
templateTitle: template.title,
|
|
||||||
appliedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3" onClick={onClose}>
|
|
||||||
<div
|
|
||||||
className="flex max-h-[94vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-[#404040] bg-[#242424] shadow-2xl"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="grid size-9 place-items-center rounded-sm bg-[#0f2f66] text-[#3b82f6]">
|
|
||||||
<ReportIcon className="size-5" name="bolt" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold text-[#f5f5f5]">{editor.id ? 'Editar relatório' : 'Novo relatório'}</h2>
|
|
||||||
<p className="text-xs text-[#a3a3a3]">Selecione um template e finalize o conteúdo no editor rico.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-sm border border-[#404040] bg-[#1a1a1a] px-3 text-sm font-semibold text-[#d4d4d4] transition hover:bg-[#303030]"
|
|
||||||
onClick={() => setPreviewOpen((current) => !current)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReportIcon className="size-4" name="eye" />
|
|
||||||
Pré-visualizar
|
|
||||||
</button>
|
|
||||||
<button className="grid size-9 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]" onClick={onClose} type="button">
|
|
||||||
<ReportIcon className="size-4" name="x" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 lg:grid-cols-[230px_minmax(0,1fr)_300px]">
|
|
||||||
<aside className="min-h-0 border-b border-[#404040] bg-[#202020] p-4 lg:border-b-0 lg:border-r">
|
|
||||||
<p className="mb-3 text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Categorias</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{templateCategories.map((category) => {
|
|
||||||
const count = category === 'Todos' ? reportTemplates.length : reportTemplates.filter((template) => template.category === category).length
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm font-semibold transition ${
|
|
||||||
templateCategory === category
|
|
||||||
? 'bg-[#3b82f6]/15 text-[#3b82f6]'
|
|
||||||
: 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
|
|
||||||
}`}
|
|
||||||
key={category}
|
|
||||||
onClick={() => setTemplateCategory(category)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span>{category}</span>
|
|
||||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{count}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="min-h-0 overflow-y-auto p-5">
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="relative">
|
|
||||||
<ReportIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" name="search" />
|
|
||||||
<input
|
|
||||||
className="h-10 w-full rounded-sm border border-[#404040] bg-[#171717] pl-10 pr-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6]"
|
|
||||||
onChange={(event) => setTemplateSearch(event.target.value)}
|
|
||||||
placeholder="Buscar templates..."
|
|
||||||
value={templateSearch}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-5 grid gap-3 md:grid-cols-2">
|
|
||||||
{filteredTemplates.map((template) => (
|
|
||||||
<button
|
|
||||||
className={`min-h-[132px] rounded-md border p-4 text-left transition hover:border-[#3b82f6] ${
|
|
||||||
selectedTemplateId === template.id ? 'border-[#3b82f6] bg-[#2a2f3a]' : 'border-[#404040] bg-[#262626]'
|
|
||||||
}`}
|
|
||||||
key={template.id}
|
|
||||||
onClick={() => applyTemplate(template)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="flex items-start justify-between gap-3">
|
|
||||||
<span className="text-sm font-bold leading-5 text-[#f5f5f5]">{template.title}</span>
|
|
||||||
{template.popular ? (
|
|
||||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold text-amber-300">Popular</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="mt-2 block text-xs leading-5 text-[#b8b8b8]">{template.description}</span>
|
|
||||||
<span className="mt-3 flex flex-wrap gap-1.5">
|
|
||||||
{template.tags.map((tag) => (
|
|
||||||
<span className="rounded bg-[#1f1f1f] px-2 py-1 text-[10px] font-semibold text-[#a3a3a3]" key={tag}>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 border-t border-[#404040] pt-5">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<DarkField label="Tipo de relatório *">
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
onChange={(event) => updateField('exam', event.target.value)}
|
|
||||||
placeholder="Ex: Relatório de consulta médica"
|
|
||||||
value={editor.exam}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Paciente *">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
onChange={(event) => setPatientSearch(event.target.value)}
|
|
||||||
placeholder="Digite o nome do paciente..."
|
|
||||||
value={patientSearch || selectedPatient?.name || ''}
|
|
||||||
/>
|
|
||||||
<SearchPickList
|
|
||||||
emptyText="Nenhum paciente encontrado."
|
|
||||||
items={filteredPatients}
|
|
||||||
labelKey="name"
|
|
||||||
onSelect={(patient) => {
|
|
||||||
updateField('patientId', patient.id)
|
|
||||||
setPatientSearch(patient.name)
|
|
||||||
}}
|
|
||||||
selectedValue={editor.patientId}
|
|
||||||
valueKey="id"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[1fr_160px]">
|
|
||||||
<DarkField label="Médico responsável *">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
onChange={(event) => {
|
|
||||||
setRequesterSearch(event.target.value)
|
|
||||||
updateField('requestedBy', event.target.value)
|
|
||||||
}}
|
|
||||||
placeholder="Pesquisar médico"
|
|
||||||
value={requesterSearch}
|
|
||||||
/>
|
|
||||||
<SearchPickList
|
|
||||||
emptyText="Nenhum médico encontrado."
|
|
||||||
items={filteredRequesterOptions}
|
|
||||||
labelKey="name"
|
|
||||||
onSelect={(professional) => {
|
|
||||||
setRequesterSearch(professional.name)
|
|
||||||
updateField('requestedBy', professional.name)
|
|
||||||
}}
|
|
||||||
selectedValue={editor.requestedBy}
|
|
||||||
valueKey="name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Status *">
|
|
||||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
|
||||||
<option value="draft">Rascunho</option>
|
|
||||||
<option value="finalized">Finalizado</option>
|
|
||||||
</select>
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<DarkField label="CID-10 *">
|
|
||||||
<input className={inputClass} onChange={(event) => updateField('cidCode', event.target.value)} placeholder="Ex: Z01.7" value={editor.cidCode} />
|
|
||||||
</DarkField>
|
|
||||||
<DarkField label="Prazo *">
|
|
||||||
<input className={`${inputClass} [color-scheme:dark]`} onChange={(event) => updateField('dueAt', event.target.value)} type="datetime-local" value={editor.dueAt} />
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<DarkField label="Diagnóstico *">
|
|
||||||
<textarea className={textareaClass} onChange={(event) => updateField('diagnosis', event.target.value)} value={editor.diagnosis} />
|
|
||||||
</DarkField>
|
|
||||||
<DarkField label="Conclusão *">
|
|
||||||
<textarea className={textareaClass} onChange={(event) => updateField('conclusion', event.target.value)} value={editor.conclusion} />
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DarkField label="Conteúdo">
|
|
||||||
<RichTextEditor
|
|
||||||
onChange={(value) => updateField('contentHtml', value)}
|
|
||||||
value={editor.contentHtml}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside className="hidden min-h-0 border-l border-[#404040] bg-[#202020] p-5 lg:block">
|
|
||||||
{previewOpen || selectedTemplate ? (
|
|
||||||
<div className="h-full overflow-y-auto">
|
|
||||||
<p className="text-xs font-bold uppercase tracking-[0.12em] text-[#a3a3a3]">Pré-visualização</p>
|
|
||||||
<h3 className="mt-4 text-lg font-bold text-[#f5f5f5]">{editor.exam || selectedTemplate?.title || 'Relatório médico'}</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
|
||||||
{selectedTemplate?.description || 'Use o editor para preencher o conteúdo do relatório.'}
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 rounded-xl border border-[#404040] bg-[#171717] p-4 text-sm leading-6 text-[#d4d4d4]">
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: sanitizePreviewHtml(editor.contentHtml || selectedTemplate?.contentHtml || '') }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
|
||||||
<span className="grid size-16 place-items-center rounded-full bg-[#2a2a2a] text-[#a3a3a3]">
|
|
||||||
<ReportIcon className="size-8" name="file" />
|
|
||||||
</span>
|
|
||||||
<h3 className="mt-4 text-base font-bold text-[#f5f5f5]">Selecione um template</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">Clique em qualquer modelo para preencher o editor automaticamente.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-[#404040] px-6 py-4">
|
|
||||||
<p className="text-xs font-semibold text-amber-300">
|
|
||||||
{!isValid ? '* Preencha paciente, tipo, médico, CID, prazo, diagnóstico e conclusão para salvar.' : 'Relatório pronto para salvar.'}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button className="rounded-sm border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]" onClick={onClose} type="button">
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="inline-flex items-center gap-2 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
|
|
||||||
disabled={!isValid || saving}
|
|
||||||
onClick={onSave}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReportIcon className="size-3.5" name="save" />
|
|
||||||
{saving ? 'Salvando...' : editor.status === 'finalized' ? 'Liberar relatório' : 'Salvar rascunho'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) {
|
|
||||||
const [requesterSearch, setRequesterSearch] = useState(editor.requestedBy || '')
|
|
||||||
const isValid = isReportEditorValid(editor)
|
|
||||||
const filteredRequesterOptions = professionalOptions
|
|
||||||
.filter((professional) => normalizeSearch(professional.name).includes(normalizeSearch(requesterSearch)))
|
|
||||||
.slice(0, 6)
|
|
||||||
|
|
||||||
function updateField(field, value) {
|
|
||||||
onChange((current) => ({ ...current, [field]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
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 relatório' : 'Novo relatório'}
|
|
||||||
</h2>
|
|
||||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
|
||||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<DarkField label="Paciente *">
|
|
||||||
<select className={inputClass} onChange={(event) => updateField('patientId', event.target.value)} value={editor.patientId}>
|
|
||||||
<option value="">Selecione um paciente</option>
|
|
||||||
{patientOptions.map((patient) => (
|
|
||||||
<option key={patient.id} value={patient.id}>
|
|
||||||
{patient.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Status *">
|
|
||||||
<select className={inputClass} onChange={(event) => updateField('status', event.target.value)} value={editor.status}>
|
|
||||||
<option value="draft">Rascunho</option>
|
|
||||||
<option value="finalized">Finalizado</option>
|
|
||||||
</select>
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<DarkField label="Exame *">
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
onChange={(event) => updateField('exam', event.target.value)}
|
|
||||||
placeholder="Nome do exame"
|
|
||||||
value={editor.exam}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Solicitante *">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
onChange={(event) => {
|
|
||||||
setRequesterSearch(event.target.value)
|
|
||||||
updateField('requestedBy', event.target.value)
|
|
||||||
}}
|
|
||||||
placeholder="Pesquisar médico"
|
|
||||||
type="search"
|
|
||||||
value={requesterSearch}
|
|
||||||
/>
|
|
||||||
<div className="max-h-36 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
|
||||||
{filteredRequesterOptions.length ? (
|
|
||||||
filteredRequesterOptions.map((professional) => (
|
|
||||||
<button
|
|
||||||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
|
|
||||||
editor.requestedBy === professional.name ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
|
||||||
}`}
|
|
||||||
key={professional.id || professional.createdByValue || professional.name}
|
|
||||||
onClick={() => {
|
|
||||||
setRequesterSearch(professional.name)
|
|
||||||
updateField('requestedBy', professional.name)
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="truncate">{professional.name}</span>
|
|
||||||
{editor.requestedBy === professional.name ? <ReportIcon className="size-3.5" name="check" /> : null}
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="px-3 py-2 text-sm text-[#a3a3a3]">Nenhum médico encontrado.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<DarkField label="CID-10 *">
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
onChange={(event) => updateField('cidCode', event.target.value)}
|
|
||||||
placeholder="Ex: Z01.7"
|
|
||||||
value={editor.cidCode}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Prazo *">
|
|
||||||
<input
|
|
||||||
className={`${inputClass} [color-scheme:dark]`}
|
|
||||||
onChange={(event) => updateField('dueAt', event.target.value)}
|
|
||||||
type="datetime-local"
|
|
||||||
value={editor.dueAt}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DarkField label="Diagnóstico *">
|
|
||||||
<textarea
|
|
||||||
className={textareaClass}
|
|
||||||
onChange={(event) => updateField('diagnosis', event.target.value)}
|
|
||||||
placeholder="Diagnóstico do relatório"
|
|
||||||
value={editor.diagnosis}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Conclusão *">
|
|
||||||
<textarea
|
|
||||||
className={textareaClass}
|
|
||||||
onChange={(event) => updateField('conclusion', event.target.value)}
|
|
||||||
placeholder="Conclusão do relatório"
|
|
||||||
value={editor.conclusion}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
|
|
||||||
<DarkField label="Complemento">
|
|
||||||
<textarea
|
|
||||||
className={`${textareaClass} min-h-72`}
|
|
||||||
onChange={(event) => updateField('contentHtml', event.target.value)}
|
|
||||||
value={editor.contentHtml}
|
|
||||||
/>
|
|
||||||
</DarkField>
|
|
||||||
</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>
|
|
||||||
<button
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
disabled={!isValid || saving}
|
|
||||||
onClick={onSave}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ReportIcon className="size-3.5" name="save" />
|
|
||||||
{saving ? 'Salvando...' : 'Salvar relatório'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchPickList({ emptyText, items, labelKey, onSelect, selectedValue, valueKey }) {
|
|
||||||
return (
|
|
||||||
<div className="max-h-32 overflow-y-auto rounded-md border border-[#404040] bg-[#1a1a1a] p-1">
|
|
||||||
{items.length ? (
|
|
||||||
items.map((item) => {
|
|
||||||
const value = String(item[valueKey] || '')
|
|
||||||
const selected = String(selectedValue || '') === value
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={`flex w-full items-center justify-between gap-3 rounded-sm px-3 py-2 text-left text-sm font-medium transition hover:bg-[#303030] ${
|
|
||||||
selected ? 'text-[#51a2ff]' : 'text-[#e5e5e5]'
|
|
||||||
}`}
|
|
||||||
key={value || item[labelKey]}
|
|
||||||
onClick={() => onSelect(item)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="truncate">{item[labelKey]}</span>
|
|
||||||
{selected ? <ReportIcon className="size-3.5" name="check" /> : null}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<p className="px-3 py-2 text-sm text-[#a3a3a3]">{emptyText}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RichTextEditor({ onChange, value }) {
|
function RichTextEditor({ onChange, value }) {
|
||||||
const lastSyncedHtmlRef = useRef(value || '')
|
const lastSyncedHtmlRef = useRef(value || '')
|
||||||
const applyingExternalContentRef = useRef(false)
|
const applyingExternalContentRef = useRef(false)
|
||||||
@@ -1322,16 +802,6 @@ function RichTextEditor({ onChange, value }) {
|
|||||||
lastSyncedHtmlRef.current = nextValue
|
lastSyncedHtmlRef.current = nextValue
|
||||||
}, [tiptapEditor, value])
|
}, [tiptapEditor, value])
|
||||||
|
|
||||||
function insertToken(token) {
|
|
||||||
const values = {
|
|
||||||
patient: '[Paciente]',
|
|
||||||
date: new Date().toLocaleDateString('pt-BR'),
|
|
||||||
doctor: '[Medico]',
|
|
||||||
}
|
|
||||||
|
|
||||||
tiptapEditor?.chain().focus().insertContent(values[token] || '').run()
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockFormat = tiptapEditor?.isActive('heading', { level: 2 })
|
const blockFormat = tiptapEditor?.isActive('heading', { level: 2 })
|
||||||
? 'h2'
|
? 'h2'
|
||||||
: tiptapEditor?.isActive('heading', { level: 3 })
|
: tiptapEditor?.isActive('heading', { level: 3 })
|
||||||
@@ -1376,18 +846,6 @@ function RichTextEditor({ onChange, value }) {
|
|||||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'center' })} label="Centralizar" name="align-center" onClick={() => tiptapEditor?.chain().focus().setTextAlign('center').run()} />
|
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'center' })} label="Centralizar" name="align-center" onClick={() => tiptapEditor?.chain().focus().setTextAlign('center').run()} />
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'right' })} label="Alinhar a direita" name="align-right" onClick={() => tiptapEditor?.chain().focus().setTextAlign('right').run()} />
|
<TipTapToolbarButton active={tiptapEditor?.isActive({ textAlign: 'right' })} label="Alinhar a direita" name="align-right" onClick={() => tiptapEditor?.chain().focus().setTextAlign('right').run()} />
|
||||||
<TipTapToolbarButton active={tiptapEditor?.isActive('bulletList')} label="Lista" name="list" onClick={() => tiptapEditor?.chain().focus().toggleBulletList().run()} />
|
<TipTapToolbarButton active={tiptapEditor?.isActive('bulletList')} label="Lista" name="list" onClick={() => tiptapEditor?.chain().focus().toggleBulletList().run()} />
|
||||||
<div className="ml-auto flex items-center gap-1">
|
|
||||||
<span className="mr-1 text-[11px] text-[#a3a3a3]">Inserir:</span>
|
|
||||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => insertToken('patient')} type="button">
|
|
||||||
+ Paciente
|
|
||||||
</button>
|
|
||||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => insertToken('date')} type="button">
|
|
||||||
+ Data
|
|
||||||
</button>
|
|
||||||
<button className="h-8 rounded-sm border border-[#3b82f6]/40 px-2 text-xs font-semibold text-[#3b82f6] hover:bg-[#3b82f6]/10" onClick={() => insertToken('doctor')} type="button">
|
|
||||||
+ Medico
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<EditorContent editor={tiptapEditor} />
|
<EditorContent editor={tiptapEditor} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
|
||||||
import { settingsRepository } from '../repositories/settingsRepository.js'
|
import { settingsRepository } from '../repositories/settingsRepository.js'
|
||||||
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
|
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
|
||||||
|
|
||||||
@@ -16,13 +15,6 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
<FeatureCallout
|
|
||||||
className="mb-6"
|
|
||||||
description="Preferências, integrações e backup ainda são protótipos locais, sem persistência real."
|
|
||||||
status="mock"
|
|
||||||
title="Configurações ainda estão em modo protótipo"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<header className="mb-8">
|
<header className="mb-8">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
|
<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>
|
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>
|
||||||
@@ -135,50 +127,6 @@ function AppearanceSection() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationsSection() {
|
|
||||||
const [settings, setSettings] = useState({
|
|
||||||
email: true,
|
|
||||||
sms: true,
|
|
||||||
whatsapp: true,
|
|
||||||
push: false,
|
|
||||||
ai: true,
|
|
||||||
appointment: true,
|
|
||||||
report: true,
|
|
||||||
noShow: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionFrame description="Configure como e quando deseja receber alertas." title="Notificações">
|
|
||||||
<Subsection title="Canais de Comunicação">
|
|
||||||
<ToggleRow checked={settings.email} description="Receba resumos e alertas via e-mail" label="Notificações por E-mail" onChange={(value) => setSettings((current) => ({ ...current, email: value }))} />
|
|
||||||
<ToggleRow checked={settings.sms} description="Alertas urgentes via mensagem de texto" label="SMS" onChange={(value) => setSettings((current) => ({ ...current, sms: value }))} />
|
|
||||||
<ToggleRow checked={settings.whatsapp} description="Integração com WhatsApp Business para lembretes" label="WhatsApp" onChange={(value) => setSettings((current) => ({ ...current, whatsapp: value }))} />
|
|
||||||
<ToggleRow checked={settings.push} description="Notificações no navegador em tempo real" label="Push (navegador)" onChange={(value) => setSettings((current) => ({ ...current, push: value }))} />
|
|
||||||
</Subsection>
|
|
||||||
|
|
||||||
<Subsection title="Tipos de Alerta">
|
|
||||||
<ToggleRow checked={settings.ai} description="Alerta preditivo quando paciente tem alto risco de faltar" label="Risco de No-Show (IA)" onChange={(value) => setSettings((current) => ({ ...current, ai: value }))} />
|
|
||||||
<ToggleRow checked={settings.appointment} description="Lembre pacientes 24h e 1h antes da consulta" label="Lembrete de Consulta" onChange={(value) => setSettings((current) => ({ ...current, appointment: value }))} />
|
|
||||||
<ToggleRow checked={settings.report} description="Notificar quando relatórios mensais estiverem prontos" label="Relatório Disponível" onChange={(value) => setSettings((current) => ({ ...current, report: value }))} />
|
|
||||||
<ToggleRow checked={settings.noShow} description="Confirmar quando uma falta é registrada no sistema" label="No-Show registrado" onChange={(value) => setSettings((current) => ({ ...current, noShow: value }))} />
|
|
||||||
</Subsection>
|
|
||||||
|
|
||||||
<Subsection title="Horário Silencioso">
|
|
||||||
<SettingRow description="Sem notificações push entre 22h e 7h" label="Ativar horário silencioso">
|
|
||||||
<ToggleSwitch checked onChange={() => {}} />
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow label="Horário de início / fim">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input className={`${inputClass} w-28`} defaultValue="22:00" type="time" />
|
|
||||||
<span className="text-sm text-[#a3a3a3]">até</span>
|
|
||||||
<input className={`${inputClass} w-28`} defaultValue="07:00" type="time" />
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
</Subsection>
|
|
||||||
</SectionFrame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PrivacySection() {
|
function PrivacySection() {
|
||||||
const [twoFactor, setTwoFactor] = useState(false)
|
const [twoFactor, setTwoFactor] = useState(false)
|
||||||
const [audit, setAudit] = useState(true)
|
const [audit, setAudit] = useState(true)
|
||||||
@@ -228,81 +176,6 @@ function PrivacySection() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountSection() {
|
|
||||||
const [profile, setProfile] = useState({
|
|
||||||
name: 'Dra. Ana Silva',
|
|
||||||
email: 'ana.silva@mediconnect.com.br',
|
|
||||||
role: 'Coordenação Médica',
|
|
||||||
crm: 'CRM/SE 12345',
|
|
||||||
})
|
|
||||||
|
|
||||||
function update(field, value) {
|
|
||||||
setProfile((current) => ({ ...current, [field]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionFrame description="Gerencie suas informações pessoais e credenciais." title="Conta & Perfil">
|
|
||||||
<div className="mb-6 flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-5">
|
|
||||||
<div className="grid size-16 place-items-center rounded-full border-2 border-[#3b82f6]/20 bg-[#3b82f6]/10 text-xl font-bold text-[#3b82f6]">
|
|
||||||
AS
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-bold text-[#f5f5f5]">{profile.name}</p>
|
|
||||||
<p className="text-xs text-[#a3a3a3]">{profile.role}</p>
|
|
||||||
<button className="mt-1 text-xs font-semibold text-[#3b82f6]" type="button">
|
|
||||||
Alterar foto
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<TextField label="Nome completo" onChange={(value) => update('name', value)} value={profile.name} />
|
|
||||||
<TextField label="E-mail" onChange={(value) => update('email', value)} value={profile.email} />
|
|
||||||
<TextField label="Cargo / Função" onChange={(value) => update('role', value)} value={profile.role} />
|
|
||||||
<TextField label="CRM / Registro" onChange={(value) => update('crm', value)} value={profile.crm} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Subsection title="Segurança">
|
|
||||||
<SettingRow description="Última alteração há 45 dias" label="Alterar senha">
|
|
||||||
<button className="h-9 rounded-sm border border-[#404040] bg-[#303030] px-3 text-sm font-semibold text-[#e5e5e5]" type="button">
|
|
||||||
Alterar
|
|
||||||
</button>
|
|
||||||
</SettingRow>
|
|
||||||
<SettingRow description="Gerenciar dispositivos conectados" label="Sessões ativas">
|
|
||||||
<button className="text-sm font-semibold text-[#3b82f6]" type="button">
|
|
||||||
Ver sessões
|
|
||||||
</button>
|
|
||||||
</SettingRow>
|
|
||||||
</Subsection>
|
|
||||||
</SectionFrame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function IntegrationsSection() {
|
|
||||||
const integrations = settingsRepository.getIntegrations()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionFrame description="Conecte o MediConnect com sistemas e serviços externos." title="Integrações">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{integrations.map(([name, desc, connected, color]) => (
|
|
||||||
<div className="flex items-center gap-4 rounded-xl border border-[#404040] bg-[#171717] p-4" key={name}>
|
|
||||||
<div className={`grid size-10 shrink-0 place-items-center rounded-lg ${color}`}>
|
|
||||||
<SettingsIcon className="size-5 text-white" name="globe" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-semibold text-[#f5f5f5]">{name}</p>
|
|
||||||
<p className="text-xs text-[#a3a3a3]">{desc}</p>
|
|
||||||
</div>
|
|
||||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${connected ? 'bg-[#303030] text-[#d4d4d4]' : 'bg-[#303030] text-[#a3a3a3]'}`}>
|
|
||||||
{connected ? 'Conectado' : 'Desconectado'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SectionFrame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DataSection() {
|
function DataSection() {
|
||||||
return (
|
return (
|
||||||
<SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup">
|
<SectionFrame description="Exporte, importe e gerencie backups do sistema." title="Dados & Backup">
|
||||||
@@ -392,15 +265,6 @@ function SettingRow({ children, description, label }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextField({ label, onChange, value }) {
|
|
||||||
return (
|
|
||||||
<label className="grid gap-2">
|
|
||||||
<span className="text-xs font-semibold text-[#a3a3a3]">{label}</span>
|
|
||||||
<input className={`${inputClass} w-full`} onChange={(event) => onChange(event.target.value)} value={value} />
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleSwitch({ checked, onChange }) {
|
function ToggleSwitch({ checked, onChange }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -426,15 +290,6 @@ function SettingsIcon({ className = 'size-4', name }) {
|
|||||||
viewBox: '0 0 24 24',
|
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') {
|
if (name === 'shield') {
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
@@ -443,23 +298,6 @@ function SettingsIcon({ className = 'size-4', name }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'user') {
|
|
||||||
return (
|
|
||||||
<svg {...common}>
|
|
||||||
<path d="M16 19a4 4 0 0 0-8 0M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === 'globe') {
|
|
||||||
return (
|
|
||||||
<svg {...common}>
|
|
||||||
<circle cx="12" cy="12" r="9" />
|
|
||||||
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === 'database') {
|
if (name === 'database') {
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
hasAuthenticatedSession,
|
hasAuthenticatedSession,
|
||||||
saveAuthSession,
|
saveAuthSession,
|
||||||
} from '../config/api.js'
|
} from '../config/api.js'
|
||||||
import { translateErrorMessage } from './repositoryUtils.js'
|
import { getResponseError } from './repositoryUtils.js'
|
||||||
|
|
||||||
export const authRepository = {
|
export const authRepository = {
|
||||||
async login({ email, password }) {
|
async login({ email, password }) {
|
||||||
@@ -132,8 +132,3 @@ export const authRepository = {
|
|||||||
function shouldFallback(response) {
|
function shouldFallback(response) {
|
||||||
return [404, 405].includes(response.status)
|
return [404, 405].includes(response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getResponseError(response, fallbackMessage) {
|
|
||||||
const error = await response.json().catch(() => ({}))
|
|
||||||
return translateErrorMessage(error.error_description || error.msg || error.message || error.error || fallbackMessage)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export const professionalRepository = {
|
|||||||
headers: getAuthenticatedHeaders()
|
headers: getAuthenticatedHeaders()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro ao buscar médicos.')
|
if (!response.ok) {
|
||||||
|
throw new Error(await getResponseError(response, 'Erro ao buscar médicos.'))
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return (Array.isArray(data) ? data : []).map(mapProfessional)
|
return (Array.isArray(data) ? data : []).map(mapProfessional)
|
||||||
@@ -30,7 +32,7 @@ export const professionalRepository = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await getResponseError(response, 'Erro ao criar m?dico.'))
|
throw new Error(await getResponseError(response, 'Erro ao criar médico.'))
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapProfessional(normalizeItem(await response.json(), ['doctor']))
|
return mapProfessional(normalizeItem(await response.json(), ['doctor']))
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function fetchJsonWithFallback(requests, fallbackMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastError && !lastResponse) {
|
if (lastError && !lastResponse) {
|
||||||
throw new Error(translateErrorMessage(lastError.message || fallbackMessage))
|
throw new Error(translateErrorMessage(lastError.message || fallbackMessage, fallbackMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(await getResponseError(lastResponse, fallbackMessage))
|
throw new Error(await getResponseError(lastResponse, fallbackMessage))
|
||||||
@@ -49,48 +49,54 @@ export function normalizeItem(data, keys = []) {
|
|||||||
return data || null
|
return data || null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getResponseError(response, fallbackMessage) {
|
export async function getResponseError(response, fallbackMessage = 'Erro inesperado.') {
|
||||||
if (!response) return fallbackMessage
|
if (!response) return translateErrorMessage(fallbackMessage)
|
||||||
|
|
||||||
const text = await response.text().catch(() => '')
|
const text = await response.text().catch(() => '')
|
||||||
const error = parseErrorBody(text)
|
const error = parseErrorBody(text)
|
||||||
const message = translateErrorMessage(
|
const message = translateErrorMessage(
|
||||||
error.error_description ||
|
getErrorMessage(error, text) || fallbackMessage,
|
||||||
error.msg ||
|
|
||||||
error.message ||
|
|
||||||
error.error ||
|
|
||||||
error.details ||
|
|
||||||
error.hint ||
|
|
||||||
text ||
|
|
||||||
fallbackMessage,
|
fallbackMessage,
|
||||||
)
|
)
|
||||||
|
|
||||||
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
|
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
|
||||||
}
|
}
|
||||||
|
|
||||||
export function translateErrorMessage(message) {
|
export function translateErrorMessage(message, fallbackMessage = 'Erro inesperado.') {
|
||||||
const rawMessage = String(message || '').trim()
|
const rawMessage = String(message || '').trim()
|
||||||
const normalized = rawMessage.toLowerCase()
|
const normalized = rawMessage.toLowerCase()
|
||||||
|
|
||||||
if (!rawMessage) return 'Erro inesperado.'
|
if (!rawMessage) return fallbackMessage
|
||||||
if (isPortugueseMessage(rawMessage)) return rawMessage
|
if (isPortugueseMessage(rawMessage)) return rawMessage
|
||||||
|
|
||||||
const translations = [
|
const translations = [
|
||||||
[/failed to fetch|networkerror|load failed|network request failed/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
|
[/failed to fetch|networkerror|load failed|network request failed/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
|
||||||
|
[/fetch failed|failed sending request|connection refused|timeout|timed out/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
|
||||||
[/invalid login credentials|invalid credentials/, 'E-mail ou senha inválidos.'],
|
[/invalid login credentials|invalid credentials/, 'E-mail ou senha inválidos.'],
|
||||||
|
[/signup requires a valid password|password should be at least|weak password|invalid password/, 'Informe uma senha válida para continuar.'],
|
||||||
|
[/email rate limit exceeded|rate limit exceeded|too many requests|for security purposes.*request this after/, 'Muitas tentativas em pouco tempo. Aguarde alguns minutos e tente novamente.'],
|
||||||
|
[/invalid email|email address.*invalid|unable to validate email address/, 'Informe um e-mail válido.'],
|
||||||
[/email not confirmed/, 'E-mail ainda não confirmado. Verifique sua caixa de entrada.'],
|
[/email not confirmed/, 'E-mail ainda não confirmado. Verifique sua caixa de entrada.'],
|
||||||
[/user already registered|already registered/, 'Este e-mail já está cadastrado.'],
|
[/user already registered|already registered/, 'Este e-mail já está cadastrado.'],
|
||||||
[/user not found/, 'Usuário não encontrado.'],
|
[/user not found/, 'Usuário não encontrado.'],
|
||||||
|
[/signup.*disabled|signups not allowed|user signups are disabled/, 'O cadastro de novos usuários está desabilitado no momento.'],
|
||||||
|
[/database error saving new user|database error.*user/, 'Não foi possível salvar o usuário. Tente novamente ou contate o suporte.'],
|
||||||
|
[/database error|unexpected failure|internal server error|server error/, 'A API encontrou um erro interno. Tente novamente ou contate o suporte.'],
|
||||||
[/jwt expired|invalid jwt|jwt malformed|invalid token|token is expired/, 'Sessão expirada. Faça login novamente.'],
|
[/jwt expired|invalid jwt|jwt malformed|invalid token|token is expired/, 'Sessão expirada. Faça login novamente.'],
|
||||||
[/missing required parameters?/, 'Parâmetros obrigatórios não foram enviados.'],
|
[/missing required parameters?/, 'Parâmetros obrigatórios não foram enviados.'],
|
||||||
|
[/required field|field .* is required|required parameter|missing .* field/, 'Campo obrigatório não preenchido.'],
|
||||||
[/duplicate key value violates unique constraint/, 'Já existe um registro com essas informações.'],
|
[/duplicate key value violates unique constraint/, 'Já existe um registro com essas informações.'],
|
||||||
[/new row violates row-level security policy|row-level security policy|permission denied/, 'Você não tem permissão para realizar esta ação.'],
|
[/new row violates row-level security policy|row-level security policy|permission denied|insufficient privileges|not authorized|unauthorized|forbidden/, 'Você não tem permissão para realizar esta ação.'],
|
||||||
[/violates foreign key constraint/, 'Não foi possível salvar porque há um vínculo obrigatório ausente ou inválido.'],
|
[/violates foreign key constraint/, 'Não foi possível salvar porque há um vínculo obrigatório ausente ou inválido.'],
|
||||||
|
[/violates check constraint/, 'Os dados enviados não atendem às regras de validação.'],
|
||||||
[/null value in column "([^"]+)".*violates not-null constraint/, 'Campo obrigatório não preenchido.'],
|
[/null value in column "([^"]+)".*violates not-null constraint/, 'Campo obrigatório não preenchido.'],
|
||||||
[/invalid input value for enum ([^:]+): "([^"]+)"/, 'Valor inválido para uma opção do sistema.'],
|
[/invalid input value for enum ([^:]+): "([^"]+)"/, 'Valor inválido para uma opção do sistema.'],
|
||||||
[/invalid input syntax for type uuid/, 'Identificador inválido enviado para a API.'],
|
[/invalid input syntax for type uuid/, 'Identificador inválido enviado para a API.'],
|
||||||
|
[/invalid input syntax for type (integer|bigint|numeric|date|timestamp|boolean)/, 'Valor inválido enviado para a API.'],
|
||||||
|
[/value too long for type|too long/, 'Um dos campos excede o tamanho permitido.'],
|
||||||
[/relation .* does not exist/, 'Recurso da API não encontrado.'],
|
[/relation .* does not exist/, 'Recurso da API não encontrado.'],
|
||||||
[/function .* does not exist/, 'Endpoint da API não encontrado.'],
|
[/function .* does not exist/, 'Endpoint da API não encontrado.'],
|
||||||
|
[/endpoint.*not found|not found/, 'Recurso da API não encontrado.'],
|
||||||
[/cors|preflight/, 'A API bloqueou a requisição por configuração de CORS.'],
|
[/cors|preflight/, 'A API bloqueou a requisição por configuração de CORS.'],
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -98,7 +104,31 @@ export function translateErrorMessage(message) {
|
|||||||
if (pattern.test(normalized)) return translation
|
if (pattern.test(normalized)) return translation
|
||||||
}
|
}
|
||||||
|
|
||||||
return rawMessage
|
return isLikelyEnglishMessage(rawMessage) ? fallbackMessage : rawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error, text) {
|
||||||
|
return error.error_description ||
|
||||||
|
error.msg ||
|
||||||
|
error.message ||
|
||||||
|
error.error ||
|
||||||
|
error.detail ||
|
||||||
|
error.details ||
|
||||||
|
error.hint ||
|
||||||
|
formatFieldErrors(error.errors) ||
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFieldErrors(errors) {
|
||||||
|
if (!errors || typeof errors !== 'object') return ''
|
||||||
|
|
||||||
|
const messages = Object.entries(errors)
|
||||||
|
.flatMap(([field, fieldErrors]) => {
|
||||||
|
const values = Array.isArray(fieldErrors) ? fieldErrors : [fieldErrors]
|
||||||
|
return values.filter(Boolean).map((message) => `${field}: ${message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages.join('; ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPortugueseMessage(message) {
|
function isPortugueseMessage(message) {
|
||||||
@@ -106,6 +136,10 @@ function isPortugueseMessage(message) {
|
|||||||
/\b(erro|falha|não|nao|usuário|usuario|senha|campo|obrigatório|obrigatorio|sessão|sessao)\b/i.test(message)
|
/\b(erro|falha|não|nao|usuário|usuario|senha|campo|obrigatório|obrigatorio|sessão|sessao)\b/i.test(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLikelyEnglishMessage(message) {
|
||||||
|
return /[a-z]/i.test(message) && !/[ãõáéíóúâêôç]/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
function shouldFallback(response) {
|
function shouldFallback(response) {
|
||||||
return [404, 405].includes(response.status)
|
return [404, 405].includes(response.status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
export const settingsRepository = {
|
export const settingsRepository = {
|
||||||
getIntegrations() {
|
|
||||||
return [
|
|
||||||
['WhatsApp Business', 'Envio automático de lembretes e confirmações', true, 'bg-[#3b82f6]'],
|
|
||||||
['Google Calendar', 'Sincronização bidirecional de agenda', false, 'bg-blue-500'],
|
|
||||||
['Stripe / PagSeguro', 'Pagamentos online e links de cobrança', true, 'bg-violet-500'],
|
|
||||||
['CFM - Conselho Federal de Medicina', 'Validação automática de CRM', false, 'bg-amber-500'],
|
|
||||||
['ANS - Planos de Saúde', 'Integração com tabela TUSS e convênios', false, 'bg-rose-500'],
|
|
||||||
['API de IA Preditiva', 'Score de absenteísmo e predição de faltas', true, 'bg-[#3b82f6]'],
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
getSections() {
|
getSections() {
|
||||||
return [
|
return [
|
||||||
{ id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },
|
{ id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export const THEME_STORAGE_KEY = 'mediconnect.theme'
|
export const THEME_STORAGE_KEY = 'mediconnect.theme'
|
||||||
|
|
||||||
export function getStoredTheme() {
|
export function getStoredTheme() {
|
||||||
if (typeof window === 'undefined') return 'dark'
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
|
||||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
return storedTheme === 'light' ? 'light' : 'dark'
|
return storedTheme === 'dark' ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyTheme(theme) {
|
export function applyTheme(theme) {
|
||||||
|
|||||||
Reference in New Issue
Block a user