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:
2026-05-11 15:26:55 -03:00
parent 04a13c24d3
commit 8f0e616d2b
13 changed files with 166 additions and 803 deletions

View File

@@ -1,30 +1,36 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
import './App.css'
import { AppShell } from './components/AppShell.jsx'
import { canAccess } from './config/permissions.js'
import { useAuth } from './hooks/useAuth.js'
import { AgendaPage } from './pages/AgendaPage.jsx'
import { AnalyticsPage } from './pages/AnalyticsPage.jsx'
import { ForgotPasswordPage, LoginPage, RegisterPage } from './pages/AuthPages.jsx'
import { HomePage } from './pages/HomePage.jsx'
import { MedicalRecordsPage } from './pages/MedicalRecordsPage.jsx'
import { MessagesPage } from './pages/MessagesPage.jsx'
import { NotFoundPage } from './pages/NotFoundPage.jsx'
import { PatientDetailPage, PatientsPage } from './pages/PatientsPage.jsx'
import { ProfilePage } from './pages/ProfilePage.jsx'
import { ReportsPage } from './pages/ReportsPage.jsx'
import { SettingsPage } from './pages/SettingsPage.jsx'
import { UsersPage } from './pages/UsersPage.jsx'
import { VisitsPage } from './pages/VisitsPage.jsx'
import { patientRepository } from './repositories/patientRepository.js'
const AgendaPage = lazyPage(() => import('./pages/AgendaPage.jsx'), 'AgendaPage')
const AnalyticsPage = lazyPage(() => import('./pages/AnalyticsPage.jsx'), 'AnalyticsPage')
const HomePage = lazyPage(() => import('./pages/HomePage.jsx'), 'HomePage')
const MedicalRecordsPage = lazyPage(() => import('./pages/MedicalRecordsPage.jsx'), 'MedicalRecordsPage')
const MessagesPage = lazyPage(() => import('./pages/MessagesPage.jsx'), 'MessagesPage')
const PatientDetailPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientDetailPage')
const PatientsPage = lazyPage(() => import('./pages/PatientsPage.jsx'), 'PatientsPage')
const ProfilePage = lazyPage(() => import('./pages/ProfilePage.jsx'), 'ProfilePage')
const ReportsPage = lazyPage(() => import('./pages/ReportsPage.jsx'), 'ReportsPage')
const SettingsPage = lazyPage(() => import('./pages/SettingsPage.jsx'), 'SettingsPage')
const UsersPage = lazyPage(() => import('./pages/UsersPage.jsx'), 'UsersPage')
const VisitsPage = lazyPage(() => import('./pages/VisitsPage.jsx'), 'VisitsPage')
const PANEL_PATHS = ['/inicio', '/home', '/dashboard']
const ROLE_HOME_PATHS = {
medico: '/agenda',
secretaria: '/agenda',
}
function lazyPage(loader, exportName) {
return lazy(() => loader().then((module) => ({ default: module[exportName] })))
}
function App() {
const [location, setLocation] = useState(() => readLocation())
const { isAuthenticated, role, loading: authLoading } = useAuth()
@@ -72,7 +78,7 @@ function App() {
// Rotas públicas (sem shell)
if (!route.withShell) {
return route.element
return <RouteSuspense>{route.element}</RouteSuspense>
}
// Usuário não autenticado
@@ -97,11 +103,27 @@ function App() {
return (
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
{route.element}
<RouteSuspense>{route.element}</RouteSuspense>
</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) {
if (pathname === '/' || pathname === '/login') {
return {

View File

@@ -43,6 +43,7 @@ const titles = {
export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
const [menuOpen, setMenuOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [profileMenuOpen, setProfileMenuOpen] = useState(false)
const [notificationsOpen, setNotificationsOpen] = useState(false)
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
@@ -139,6 +140,14 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
navigate('/login', { replace: true })
}
function toggleSidebarCollapsed() {
if (typeof window !== 'undefined' && !window.matchMedia('(min-width: 1024px)').matches) {
return
}
setSidebarCollapsed((current) => !current)
}
return (
<div className="min-h-screen bg-[#171717] text-[#e5e5e5]">
<a
@@ -149,15 +158,19 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
</a>
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-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' : ''
}`}
>
<div className="flex h-16 items-center border-b border-[#404040] px-3">
<div className={`flex h-16 items-center border-b border-[#404040] px-3 ${sidebarCollapsed ? 'lg:justify-center' : ''}`}>
<BrandLogo
iconClassName="size-8 rounded-sm"
iconButtonLabel={sidebarCollapsed ? 'Expandir sidebar' : 'Recolher sidebar'}
markClassName="size-5"
textClassName="text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5]"
onIconClick={toggleSidebarCollapsed}
textClassName={`text-xl font-bold leading-7 tracking-[-0.025em] text-[#e5e5e5] ${sidebarCollapsed ? 'lg:hidden' : ''}`}
/>
</div>
@@ -169,6 +182,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
item={item}
key={`${item.label}-${item.href}`}
onNavigate={goTo}
sidebarCollapsed={sidebarCollapsed}
/>
))}
</div>
@@ -176,12 +190,21 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
<div className="p-3">
<button
className="w-full rounded-md border border-[#404040] bg-[#303030] px-3 py-2.5 text-left transition hover:border-[#525252] hover:bg-[#333333]"
className={`w-full rounded-md border border-[#404040] bg-[#303030] text-left transition hover:border-[#525252] hover:bg-[#333333] ${
sidebarCollapsed ? 'grid h-10 place-items-center px-0 py-0 lg:rounded-full' : 'px-3 py-2.5'
}`}
onClick={() => goTo('/perfil')}
title={sidebarCollapsed ? `${viewerProfile.name} - ${viewerProfile.role}` : undefined}
type="button"
>
{sidebarCollapsed ? (
<span className="text-xs font-bold text-[#3b82f6]">{getInitials(viewerProfile.name)}</span>
) : (
<>
<p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
</>
)}
</button>
</div>
</aside>
@@ -195,7 +218,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
/>
) : null}
<div className="lg:pl-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">
<div className="flex h-full flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3">
@@ -236,7 +259,7 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
>
<div className="flex items-center justify-between px-2 py-2">
<p className="text-sm font-semibold text-[#e5e5e5]">Notificações</p>
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
<span className="feature-badge-mock rounded border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-amber-300">
Mock
</span>
</div>
@@ -346,10 +369,11 @@ export function AppShell({ children, currentPath, navigate, role, routeTitle })
)
}
function NavItem({ active, item, onNavigate }) {
function NavItem({ active, item, onNavigate, sidebarCollapsed = false }) {
return (
<a
aria-current={active ? 'page' : undefined}
aria-label={sidebarCollapsed ? item.label : undefined}
className={`flex h-9 items-center gap-3 rounded-sm px-2 text-sm font-medium transition ${
active ? 'bg-[#3b82f6]/10 text-[#3b82f6]' : 'text-[#a3a3a3] hover:bg-[#303030] hover:text-[#e5e5e5]'
}`}
@@ -358,9 +382,10 @@ function NavItem({ active, item, onNavigate }) {
event.preventDefault()
onNavigate(item.href)
}}
title={sidebarCollapsed ? item.label : undefined}
>
<AppIcon className="size-5 shrink-0" name={item.icon} />
<span>{item.label}</span>
<AppIcon className={`size-5 shrink-0 ${sidebarCollapsed ? 'lg:mx-auto' : ''}`} name={item.icon} />
<span className={sidebarCollapsed ? 'lg:hidden' : ''}>{item.label}</span>
</a>
)
}

View File

@@ -1,14 +1,32 @@
export function BrandLogo({
className = '',
iconClassName = 'size-10 rounded-[6px]',
iconButtonLabel = 'MediConnect',
markClassName = 'size-6',
onIconClick,
textClassName = 'text-2xl font-bold leading-8 tracking-[-0.025em] text-white',
}) {
return (
<div className={`flex items-center gap-3 ${className}`}>
const icon = (
<div className={`grid place-items-center bg-[#3b82f6] text-white ${iconClassName}`}>
<StethoscopeIcon className={markClassName} />
</div>
)
return (
<div className={`flex items-center gap-3 ${className}`}>
{onIconClick ? (
<button
aria-label={iconButtonLabel}
className="shrink-0 rounded-sm transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-[#3b82f6]/50"
onClick={onIconClick}
title={iconButtonLabel}
type="button"
>
{icon}
</button>
) : (
icon
)}
<p className={textClassName}>MediConnect</p>
</div>
)

View File

@@ -336,11 +336,6 @@ button:disabled {
background: #ffffff;
}
[data-theme='light'] .report-editor-sidebar {
border-color: #c8d4e2;
background: #edf4fb;
}
[data-theme='light'] .report-editor-body {
background: #f8fbff;
}

View File

@@ -575,25 +575,18 @@ function PatientPickList({ items, onSelect, selectedId }) {
}
function PatientReportHistory({ fallbackReports = [], patientId, patientName }) {
const [reports, setReports] = useState(fallbackReports)
const [loading, setLoading] = useState(Boolean(patientId))
const [reportState, setReportState] = useState({ patientId: '', reports: [] })
const reports = patientId && reportState.patientId === patientId ? reportState.reports : fallbackReports
const loading = Boolean(patientId && reportState.patientId !== patientId)
useEffect(() => {
if (!patientId) return undefined
let active = true
if (!patientId) {
setReports(fallbackReports)
setLoading(false)
return () => {
active = false
}
}
setLoading(true)
loadReportsForPatient(patientId, patientName).then((data) => {
if (!active) return
setReports(data.length ? data : fallbackReports)
setLoading(false)
setReportState({ patientId, reports: data.length ? data : fallbackReports })
})
return () => {

View File

@@ -796,7 +796,7 @@ export function PatientDetailPage({ navigate, patient, role }) {
async function deletePatient() {
if (!canHardDeletePatients) return
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente?')) {
if (!window.confirm('Tem certeza que deseja excluir este paciente definitivamente? Esta ação não poderá ser desfeita.')) {
return
}
@@ -900,21 +900,15 @@ export function PatientDetailPage({ navigate, patient, role }) {
</section>
{canHardDeletePatients ? (
<section className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-sm font-bold text-red-300">Zona de exclusão</h2>
<p className="mt-1 text-sm text-red-100/80">Remove definitivamente o paciente e seus dados locais carregados.</p>
</div>
<div className="flex justify-end">
<button
className="h-10 rounded-sm border border-red-500/40 bg-red-500/10 px-4 text-sm font-semibold text-red-300 transition hover:bg-red-500/20"
className="h-10 rounded-sm border border-red-700 bg-red-600 px-4 text-sm font-semibold text-white shadow-sm transition hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/40"
onClick={deletePatient}
type="button"
>
Excluir paciente
</button>
</div>
</section>
) : null}
{messageShortcutOpen ? (

View File

@@ -35,8 +35,6 @@ const orderOptions = [
const inputClass =
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
const textareaClass =
'min-h-24 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 py-2 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
@@ -123,8 +121,6 @@ const reportTemplates = [
},
]
const templateCategories = ['Todos', ...Array.from(new Set(reportTemplates.map((template) => template.category)))]
const emptyEditor = {
id: null,
orderNumber: '',
@@ -618,16 +614,13 @@ function ReportRow({ onEdit, onView, report }) {
}
function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
const [templateCategory, setTemplateCategory] = useState('Todos')
const [templateSearch, setTemplateSearch] = useState('')
const [templatesOpen, setTemplatesOpen] = useState(false)
const [categoriesOpen, setCategoriesOpen] = useState(true)
const isValid = isReportEditorValid(editor)
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
return matchesSearch
})
function updateField(field, value) {
@@ -672,40 +665,7 @@ function ReportEditorModalV3({ editor, onChange, onClose, onSave, saving }) {
</button>
</div>
<div className={`grid min-h-0 flex-1 ${categoriesOpen ? 'lg:grid-cols-[230px_minmax(0,1fr)]' : 'lg:grid-cols-[56px_minmax(0,1fr)]'}`}>
<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>
<div className="grid min-h-0 flex-1">
<main className="report-editor-body min-h-0 overflow-y-auto p-5">
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<DarkField label="Status *">
@@ -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 }) {
const lastSyncedHtmlRef = useRef(value || '')
const applyingExternalContentRef = useRef(false)
@@ -1322,16 +802,6 @@ function RichTextEditor({ onChange, value }) {
lastSyncedHtmlRef.current = nextValue
}, [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 })
? 'h2'
: 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: '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()} />
<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>
<EditorContent editor={tiptapEditor} />
</div>

View File

@@ -1,6 +1,5 @@
import { useState } from 'react'
import { FeatureCallout } from '../components/FeatureState.jsx'
import { settingsRepository } from '../repositories/settingsRepository.js'
import { getStoredTheme, setStoredTheme } from '../utils/theme.js'
@@ -16,13 +15,6 @@ export function SettingsPage() {
return (
<div className="mx-auto max-w-5xl">
<FeatureCallout
className="mb-6"
description="Preferências, integrações e backup ainda são protótipos locais, sem persistência real."
status="mock"
title="Configurações ainda estão em modo protótipo"
/>
<header className="mb-8">
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Configurações</h1>
<p className="mt-1 text-sm text-[#b8b8b8]">Gerencie preferências, segurança e integrações do MediConnect</p>
@@ -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() {
const [twoFactor, setTwoFactor] = useState(false)
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() {
return (
<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 }) {
return (
<button
@@ -426,15 +290,6 @@ function SettingsIcon({ className = 'size-4', name }) {
viewBox: '0 0 24 24',
}
if (name === 'bell') {
return (
<svg {...common}>
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
<path d="M10 21h4" />
</svg>
)
}
if (name === 'shield') {
return (
<svg {...common}>
@@ -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') {
return (
<svg {...common}>

View File

@@ -8,7 +8,7 @@ import {
hasAuthenticatedSession,
saveAuthSession,
} from '../config/api.js'
import { translateErrorMessage } from './repositoryUtils.js'
import { getResponseError } from './repositoryUtils.js'
export const authRepository = {
async login({ email, password }) {
@@ -132,8 +132,3 @@ export const authRepository = {
function shouldFallback(response) {
return [404, 405].includes(response.status)
}
async function getResponseError(response, fallbackMessage) {
const error = await response.json().catch(() => ({}))
return translateErrorMessage(error.error_description || error.msg || error.message || error.error || fallbackMessage)
}

View File

@@ -7,7 +7,9 @@ export const professionalRepository = {
headers: getAuthenticatedHeaders()
})
if (!response.ok) throw new Error('Erro ao buscar médicos.')
if (!response.ok) {
throw new Error(await getResponseError(response, 'Erro ao buscar médicos.'))
}
const data = await response.json()
return (Array.isArray(data) ? data : []).map(mapProfessional)
@@ -30,7 +32,7 @@ export const professionalRepository = {
})
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']))

View File

@@ -23,7 +23,7 @@ export async function fetchJsonWithFallback(requests, fallbackMessage) {
}
if (lastError && !lastResponse) {
throw new Error(translateErrorMessage(lastError.message || fallbackMessage))
throw new Error(translateErrorMessage(lastError.message || fallbackMessage, fallbackMessage))
}
throw new Error(await getResponseError(lastResponse, fallbackMessage))
@@ -49,48 +49,54 @@ export function normalizeItem(data, keys = []) {
return data || null
}
export async function getResponseError(response, fallbackMessage) {
if (!response) return fallbackMessage
export async function getResponseError(response, fallbackMessage = 'Erro inesperado.') {
if (!response) return translateErrorMessage(fallbackMessage)
const text = await response.text().catch(() => '')
const error = parseErrorBody(text)
const message = translateErrorMessage(
error.error_description ||
error.msg ||
error.message ||
error.error ||
error.details ||
error.hint ||
text ||
getErrorMessage(error, text) || fallbackMessage,
fallbackMessage,
)
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
}
export function translateErrorMessage(message) {
export function translateErrorMessage(message, fallbackMessage = 'Erro inesperado.') {
const rawMessage = String(message || '').trim()
const normalized = rawMessage.toLowerCase()
if (!rawMessage) return 'Erro inesperado.'
if (!rawMessage) return fallbackMessage
if (isPortugueseMessage(rawMessage)) return rawMessage
const translations = [
[/failed to fetch|networkerror|load failed|network request failed/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
[/fetch failed|failed sending request|connection refused|timeout|timed out/, 'Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.'],
[/invalid login credentials|invalid credentials/, 'E-mail ou senha inválidos.'],
[/signup requires a valid password|password should be at least|weak password|invalid password/, 'Informe uma senha válida para continuar.'],
[/email rate limit exceeded|rate limit exceeded|too many requests|for security purposes.*request this after/, 'Muitas tentativas em pouco tempo. Aguarde alguns minutos e tente novamente.'],
[/invalid email|email address.*invalid|unable to validate email address/, 'Informe um e-mail válido.'],
[/email not confirmed/, 'E-mail ainda não confirmado. Verifique sua caixa de entrada.'],
[/user already registered|already registered/, 'Este e-mail já está cadastrado.'],
[/user not found/, 'Usuário não encontrado.'],
[/signup.*disabled|signups not allowed|user signups are disabled/, 'O cadastro de novos usuários está desabilitado no momento.'],
[/database error saving new user|database error.*user/, 'Não foi possível salvar o usuário. Tente novamente ou contate o suporte.'],
[/database error|unexpected failure|internal server error|server error/, 'A API encontrou um erro interno. Tente novamente ou contate o suporte.'],
[/jwt expired|invalid jwt|jwt malformed|invalid token|token is expired/, 'Sessão expirada. Faça login novamente.'],
[/missing required parameters?/, 'Parâmetros obrigatórios não foram enviados.'],
[/required field|field .* is required|required parameter|missing .* field/, 'Campo obrigatório não preenchido.'],
[/duplicate key value violates unique constraint/, 'Já existe um registro com essas informações.'],
[/new row violates row-level security policy|row-level security policy|permission denied/, 'Você não tem permissão para realizar esta ação.'],
[/new row violates row-level security policy|row-level security policy|permission denied|insufficient privileges|not authorized|unauthorized|forbidden/, 'Você não tem permissão para realizar esta ação.'],
[/violates foreign key constraint/, 'Não foi possível salvar porque há um vínculo obrigatório ausente ou inválido.'],
[/violates check constraint/, 'Os dados enviados não atendem às regras de validação.'],
[/null value in column "([^"]+)".*violates not-null constraint/, 'Campo obrigatório não preenchido.'],
[/invalid input value for enum ([^:]+): "([^"]+)"/, 'Valor inválido para uma opção do sistema.'],
[/invalid input syntax for type uuid/, 'Identificador inválido enviado para a API.'],
[/invalid input syntax for type (integer|bigint|numeric|date|timestamp|boolean)/, 'Valor inválido enviado para a API.'],
[/value too long for type|too long/, 'Um dos campos excede o tamanho permitido.'],
[/relation .* does not exist/, 'Recurso da API não encontrado.'],
[/function .* does not exist/, 'Endpoint da API não encontrado.'],
[/endpoint.*not found|not found/, 'Recurso da API não encontrado.'],
[/cors|preflight/, 'A API bloqueou a requisição por configuração de CORS.'],
]
@@ -98,7 +104,31 @@ export function translateErrorMessage(message) {
if (pattern.test(normalized)) return translation
}
return rawMessage
return isLikelyEnglishMessage(rawMessage) ? fallbackMessage : rawMessage
}
function getErrorMessage(error, text) {
return error.error_description ||
error.msg ||
error.message ||
error.error ||
error.detail ||
error.details ||
error.hint ||
formatFieldErrors(error.errors) ||
text
}
function formatFieldErrors(errors) {
if (!errors || typeof errors !== 'object') return ''
const messages = Object.entries(errors)
.flatMap(([field, fieldErrors]) => {
const values = Array.isArray(fieldErrors) ? fieldErrors : [fieldErrors]
return values.filter(Boolean).map((message) => `${field}: ${message}`)
})
return messages.join('; ')
}
function isPortugueseMessage(message) {
@@ -106,6 +136,10 @@ function isPortugueseMessage(message) {
/\b(erro|falha|não|nao|usuário|usuario|senha|campo|obrigatório|obrigatorio|sessão|sessao)\b/i.test(message)
}
function isLikelyEnglishMessage(message) {
return /[a-z]/i.test(message) && !/[ãõáéíóúâêôç]/i.test(message)
}
function shouldFallback(response) {
return [404, 405].includes(response.status)
}

View File

@@ -1,15 +1,4 @@
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() {
return [
{ id: 'aparencia', label: 'Aparência e Acessibilidade', description: 'Tema, cores e exibição', icon: 'palette' },

View File

@@ -1,10 +1,10 @@
export const THEME_STORAGE_KEY = 'mediconnect.theme'
export function getStoredTheme() {
if (typeof window === 'undefined') return 'dark'
if (typeof window === 'undefined') return 'light'
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY)
return storedTheme === 'light' ? 'light' : 'dark'
return storedTheme === 'dark' ? 'dark' : 'light'
}
export function applyTheme(theme) {