forked from RiseUP/riseup_squad_03
Merge pull request 'user-profiles' (#2) from user-profiles into main
Reviewed-on: RiseUP/riseup_squad_03#2
This commit is contained in:
94
src/App.jsx
94
src/App.jsx
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { authRepository } from './repositories/authRepository.js'
|
||||
|
||||
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'
|
||||
@@ -16,11 +16,13 @@ import { ProfilePage } from './pages/ProfilePage.jsx'
|
||||
import { ReportsPage } from './pages/ReportsPage.jsx'
|
||||
import { SettingsPage } from './pages/SettingsPage.jsx'
|
||||
import { TeamPage } from './pages/TeamPage.jsx'
|
||||
import { UsersPage } from './pages/UsersPage.jsx'
|
||||
import { VisitsPage } from './pages/VisitsPage.jsx'
|
||||
import { patientRepository } from './repositories/patientRepository.js'
|
||||
|
||||
function App() {
|
||||
const [location, setLocation] = useState(() => readLocation())
|
||||
const { isAuthenticated, role, loading: authLoading } = useAuth()
|
||||
|
||||
const navigate = useCallback((to, options = {}) => {
|
||||
if (options.replace) {
|
||||
@@ -49,25 +51,47 @@ function App() {
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [])
|
||||
|
||||
const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate])
|
||||
const isAuthenticated = authRepository.isAuthenticated()
|
||||
const route = useMemo(
|
||||
() => resolveRoute(location.pathname, navigate, role),
|
||||
[location.pathname, navigate, role],
|
||||
)
|
||||
|
||||
// Tela de carregamento enquanto busca o role do usuário
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]">
|
||||
<p className="text-sm text-[#a3a3a3]">Carregando...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Rotas públicas (sem shell)
|
||||
if (!route.withShell) {
|
||||
return route.element
|
||||
}
|
||||
|
||||
// Usuário não autenticado
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage navigate={navigate} />
|
||||
}
|
||||
|
||||
// Usuário autenticado mas sem permissão para a rota
|
||||
if (!role || !canAccess(role, location.pathname)) {
|
||||
return (
|
||||
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle="Sem acesso">
|
||||
<UnauthorizedPage navigate={navigate} />
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell currentPath={location.pathname} navigate={navigate} routeTitle={route.title}>
|
||||
<AppShell currentPath={location.pathname} navigate={navigate} role={role} routeTitle={route.title}>
|
||||
{route.element}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRoute(pathname, navigate) {
|
||||
function resolveRoute(pathname, navigate, role) {
|
||||
if (pathname === '/' || pathname === '/login') {
|
||||
return {
|
||||
element: <LoginPage navigate={navigate} />,
|
||||
@@ -102,7 +126,7 @@ function resolveRoute(pathname, navigate) {
|
||||
|
||||
if (pathname === '/agenda') {
|
||||
return {
|
||||
element: <AgendaPage navigate={navigate} />,
|
||||
element: <AgendaPage navigate={navigate} role={role} />,
|
||||
title: 'Agenda',
|
||||
withShell: true,
|
||||
}
|
||||
@@ -110,7 +134,7 @@ function resolveRoute(pathname, navigate) {
|
||||
|
||||
if (pathname === '/pacientes') {
|
||||
return {
|
||||
element: <PatientsPage navigate={navigate} />,
|
||||
element: <PatientsPage navigate={navigate} role={role} />,
|
||||
title: 'Pacientes',
|
||||
withShell: true,
|
||||
}
|
||||
@@ -126,7 +150,6 @@ function resolveRoute(pathname, navigate) {
|
||||
|
||||
if (pathname.startsWith('/pacientes/')) {
|
||||
const patientId = pathname.split('/')[2]
|
||||
|
||||
return {
|
||||
element: <PatientDetailRoute navigate={navigate} patientId={patientId} />,
|
||||
title: 'Paciente',
|
||||
@@ -145,7 +168,7 @@ function resolveRoute(pathname, navigate) {
|
||||
if (pathname === '/laudos') {
|
||||
return {
|
||||
element: <ReportsPage navigate={navigate} />,
|
||||
title: 'Relatorios medicos',
|
||||
title: 'Relatórios médicos',
|
||||
withShell: true,
|
||||
}
|
||||
}
|
||||
@@ -158,9 +181,18 @@ function resolveRoute(pathname, navigate) {
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/camunicacao' || pathname === '/comunicacao' || pathname === '/mensagens') {
|
||||
if (pathname === '/camunicacao') {
|
||||
navigate('/comunicacao', { replace: true })
|
||||
return {
|
||||
element: <MessagesPage navigate={navigate} />,
|
||||
element: <MessagesPage navigate={navigate} role={role} />,
|
||||
title: 'Comunicação',
|
||||
withShell: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/comunicacao' || pathname === '/mensagens') {
|
||||
return {
|
||||
element: <MessagesPage navigate={navigate} role={role} />,
|
||||
title: 'Comunicação',
|
||||
withShell: true,
|
||||
}
|
||||
@@ -174,6 +206,14 @@ function resolveRoute(pathname, navigate) {
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/usuarios') {
|
||||
return {
|
||||
element: <UsersPage role={role} />,
|
||||
title: 'Usuários',
|
||||
withShell: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/perfil') {
|
||||
return {
|
||||
element: <ProfilePage navigate={navigate} />,
|
||||
@@ -192,7 +232,7 @@ function resolveRoute(pathname, navigate) {
|
||||
|
||||
return {
|
||||
element: <NotFoundPage navigate={navigate} />,
|
||||
title: 'Tela nao encontrada',
|
||||
title: 'Página não encontrada',
|
||||
withShell: true,
|
||||
}
|
||||
}
|
||||
@@ -204,7 +244,8 @@ function PatientDetailRoute({ navigate, patientId }) {
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
patientRepository.getById(patientId)
|
||||
patientRepository
|
||||
.getById(patientId)
|
||||
.then((data) => {
|
||||
if (active) setPatient(data)
|
||||
})
|
||||
@@ -221,7 +262,30 @@ function PatientDetailRoute({ navigate, patientId }) {
|
||||
return <div className="pt-10 text-sm text-[#a3a3a3]">Carregando paciente...</div>
|
||||
}
|
||||
|
||||
return patient ? <PatientDetailPage navigate={navigate} patient={patient} /> : <NotFoundPage navigate={navigate} />
|
||||
return patient ? (
|
||||
<PatientDetailPage navigate={navigate} patient={patient} />
|
||||
) : (
|
||||
<NotFoundPage navigate={navigate} />
|
||||
)
|
||||
}
|
||||
|
||||
function UnauthorizedPage({ navigate }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-5xl">🔒</p>
|
||||
<h1 className="mt-4 text-2xl font-bold text-[#e5e5e5]">Acesso não permitido</h1>
|
||||
<p className="mt-2 text-sm text-[#a3a3a3]">
|
||||
Você não tem permissão para acessar esta página.
|
||||
</p>
|
||||
<button
|
||||
className="mt-6 rounded-lg bg-[#3b82f6] px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[#2563eb]"
|
||||
onClick={() => navigate('/inicio')}
|
||||
type="button"
|
||||
>
|
||||
Voltar ao painel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function readLocation() {
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { ROLE_LABELS, ROLE_NAV_ITEMS } from '../config/permissions.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
import { BrandLogo } from './Brand.jsx'
|
||||
|
||||
const navItems = [
|
||||
// Todos os itens de navegação com seus ícones e metadados
|
||||
const ALL_NAV_ITEMS = [
|
||||
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
|
||||
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
||||
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
||||
{ href: '/prontuario', label: 'Prontuario', icon: 'file' },
|
||||
{ href: '/laudos', label: 'Relatorios medicos', icon: 'clipboard' },
|
||||
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
|
||||
{ href: '/laudos', label: 'Relatórios médicos', icon: 'clipboard' },
|
||||
{
|
||||
href: '/camunicacao',
|
||||
label: 'Comunicacao',
|
||||
href: '/comunicacao',
|
||||
label: 'Comunicação',
|
||||
icon: 'message',
|
||||
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
|
||||
activePaths: ['/comunicacao', '/mensagens'],
|
||||
},
|
||||
{ href: '/relatorios', label: 'Relatorios', icon: 'chart' },
|
||||
{ href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||
{ href: '/relatorios', label: 'Relatórios', icon: 'chart' },
|
||||
{ href: '/profissionais', label: 'Profissionais', icon: 'users' },
|
||||
{ href: '/usuarios', label: 'Usuários', icon: 'shield' },
|
||||
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||
]
|
||||
|
||||
const titles = {
|
||||
@@ -25,23 +29,23 @@ const titles = {
|
||||
'/dashboard': 'Painel',
|
||||
'/agenda': 'Agenda',
|
||||
'/consultas': 'Consultas',
|
||||
'/laudos': 'Relatorios medicos',
|
||||
'/laudos': 'Relatórios médicos',
|
||||
'/pacientes': 'Pacientes',
|
||||
'/prontuario': 'Prontuario',
|
||||
'/camunicacao': 'Comunicacao',
|
||||
'/comunicacao': 'Comunicacao',
|
||||
'/mensagens': 'Comunicacao',
|
||||
'/relatorios': 'Relatorios',
|
||||
'/prontuario': 'Prontuário',
|
||||
'/comunicacao': 'Comunicação',
|
||||
'/mensagens': 'Comunicação',
|
||||
'/relatorios': 'Relatórios',
|
||||
'/profissionais': 'Profissionais',
|
||||
'/perfil': 'Perfil',
|
||||
'/configuracoes': 'Configuracoes',
|
||||
'/config': 'Configuracoes',
|
||||
'/configuracoes': 'Configurações',
|
||||
'/config': 'Configurações',
|
||||
'/usuarios': 'Usuários',
|
||||
}
|
||||
|
||||
export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
export function AppShell({ children, currentPath, navigate, role, routeTitle }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [quickSearch, setQuickSearch] = useState('')
|
||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' })
|
||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuário', role: 'Usuário do Sistema' })
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
if (currentPath.startsWith('/pacientes/') && routeTitle) {
|
||||
@@ -51,24 +55,46 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
return routeTitle || titles[currentPath] || 'MediConnect'
|
||||
}, [currentPath, routeTitle])
|
||||
|
||||
// Filtra os itens de navegação com base no role do usuário
|
||||
const navItems = useMemo(() => {
|
||||
if (!role) return []
|
||||
|
||||
const allowedPaths = ROLE_NAV_ITEMS[role]?.map((item) => item.path) ?? []
|
||||
|
||||
return ALL_NAV_ITEMS.filter((item) =>
|
||||
allowedPaths.some(
|
||||
(allowed) => item.href === allowed || item.activePaths?.includes(allowed),
|
||||
),
|
||||
)
|
||||
}, [role])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
profileRepository.getCurrentUserProfile()
|
||||
profileRepository
|
||||
.getCurrentUserProfile()
|
||||
.then((profile) => {
|
||||
if (!active || !profile) return
|
||||
|
||||
setViewerProfile({
|
||||
name: profile.name || 'Usuario',
|
||||
role: profile.role || 'Usuario do Sistema',
|
||||
name: profile.name || 'Usuário',
|
||||
role: ROLE_LABELS[role] || profile.role || 'Usuário do Sistema',
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {
|
||||
// Fallback: usa o label do role diretamente
|
||||
if (active && role) {
|
||||
setViewerProfile((prev) => ({
|
||||
...prev,
|
||||
role: ROLE_LABELS[role] || 'Usuário do Sistema',
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
}, [role])
|
||||
|
||||
function goTo(path) {
|
||||
setMenuOpen(false)
|
||||
@@ -81,7 +107,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-[#262626] focus:px-4 focus:py-2 focus:text-sm focus:font-semibold focus:text-[#3b82f6]"
|
||||
href="#app-content"
|
||||
>
|
||||
Pular para conteudo
|
||||
Pular para conteúdo
|
||||
</a>
|
||||
|
||||
<aside
|
||||
@@ -146,10 +172,10 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
<div className="relative w-full max-w-sm lg:w-96">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#a3a3a3]" />
|
||||
<input
|
||||
aria-label="Busca rapida"
|
||||
aria-label="Busca rápida"
|
||||
className="h-[38px] w-full rounded-sm border border-[#404040] bg-[#303030] py-2 pl-10 pr-4 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20"
|
||||
onChange={(event) => setQuickSearch(event.target.value)}
|
||||
placeholder="Buscar paciente, prontuario..."
|
||||
placeholder="Buscar paciente, prontuário..."
|
||||
value={quickSearch}
|
||||
/>
|
||||
</div>
|
||||
@@ -157,7 +183,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<button
|
||||
aria-label="Notificacoes"
|
||||
aria-label="Notificações"
|
||||
className="relative grid size-8 place-items-center text-[#a3a3a3] transition hover:text-[#e5e5e5]"
|
||||
type="button"
|
||||
>
|
||||
@@ -303,10 +329,10 @@ function AppIcon({ className = 'size-5', name }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'dollar') {
|
||||
if (name === 'shield') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M12 2v20M17 6.5C15.8 5.4 14.2 5 12.5 5 9.9 5 8 6.2 8 8s1.6 2.7 4.2 3.3C15 12 17 13 17 15.5S14.8 19 12 19c-2 0-3.8-.6-5-1.8" />
|
||||
<path d="M12 3 5 6v5c0 4.5 3 8.5 7 10 4-1.5 7-5.5 7-10V6l-7-3Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberk
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ'
|
||||
|
||||
const AUTH_SESSION_KEY = 'mediconnect.auth.session'
|
||||
export const AUTH_SESSION_CHANGED_EVENT = 'mediconnect:auth-session-changed'
|
||||
|
||||
export const apiConfig = {
|
||||
apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`,
|
||||
@@ -34,12 +35,14 @@ export function getAuthSession() {
|
||||
export function saveAuthSession(session) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
notifyAuthSessionChanged()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||
notifyAuthSessionChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,3 +88,7 @@ function cleanHeaders(headers) {
|
||||
Object.entries(headers).filter(([, value]) => value !== undefined && value !== null),
|
||||
)
|
||||
}
|
||||
|
||||
function notifyAuthSessionChanged() {
|
||||
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
209
src/config/permissions.js
Normal file
209
src/config/permissions.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// Roles disponíveis no sistema
|
||||
export const ROLES = {
|
||||
ADMIN: 'admin',
|
||||
GESTOR: 'gestor',
|
||||
MEDICO: 'medico',
|
||||
SECRETARIA: 'secretaria',
|
||||
PACIENTE: 'paciente',
|
||||
}
|
||||
|
||||
const ROLE_ALIASES = {
|
||||
admin: ROLES.ADMIN,
|
||||
administrador: ROLES.ADMIN,
|
||||
administrator: ROLES.ADMIN,
|
||||
gestor: ROLES.GESTOR,
|
||||
gestao: ROLES.GESTOR,
|
||||
gestao_coordenacao: ROLES.GESTOR,
|
||||
coordenacao: ROLES.GESTOR,
|
||||
coordenador: ROLES.GESTOR,
|
||||
manager: ROLES.GESTOR,
|
||||
medico: ROLES.MEDICO,
|
||||
medica: ROLES.MEDICO,
|
||||
doctor: ROLES.MEDICO,
|
||||
physician: ROLES.MEDICO,
|
||||
secretaria: ROLES.SECRETARIA,
|
||||
secretario: ROLES.SECRETARIA,
|
||||
secretary: ROLES.SECRETARIA,
|
||||
receptionist: ROLES.SECRETARIA,
|
||||
paciente: ROLES.PACIENTE,
|
||||
patient: ROLES.PACIENTE,
|
||||
}
|
||||
|
||||
// Rotas permitidas por role ('*' = todas)
|
||||
const ROLE_ROUTES = {
|
||||
admin: '*',
|
||||
gestor: [
|
||||
'/inicio', '/home', '/dashboard',
|
||||
'/agenda',
|
||||
'/pacientes',
|
||||
'/prontuario',
|
||||
'/laudos',
|
||||
'/relatorios',
|
||||
'/comunicacao', '/mensagens',
|
||||
'/profissionais',
|
||||
'/configuracoes', '/config',
|
||||
'/consultas',
|
||||
'/usuarios',
|
||||
'/perfil',
|
||||
],
|
||||
medico: [
|
||||
'/inicio', '/home', '/dashboard',
|
||||
'/agenda',
|
||||
'/prontuario',
|
||||
'/laudos',
|
||||
'/comunicacao', '/mensagens',
|
||||
'/relatorios',
|
||||
'/perfil',
|
||||
],
|
||||
secretaria: [
|
||||
'/inicio', '/home', '/dashboard',
|
||||
'/agenda',
|
||||
'/pacientes',
|
||||
'/comunicacao', '/mensagens',
|
||||
'/perfil',
|
||||
],
|
||||
paciente: [
|
||||
'/inicio', '/home', '/dashboard',
|
||||
'/perfil',
|
||||
],
|
||||
}
|
||||
|
||||
// Capacidades especiais por role
|
||||
export const ROLE_CAPABILITIES = {
|
||||
admin: {
|
||||
manageUsers: true,
|
||||
hardDeletePatients: true,
|
||||
accessSettings: true,
|
||||
ownAppointmentsOnly: false,
|
||||
canEditPatients: true,
|
||||
canViewReports: true,
|
||||
canViewMedicalRecords: true,
|
||||
},
|
||||
gestor: {
|
||||
manageUsers: true,
|
||||
hardDeletePatients: true,
|
||||
accessSettings: true,
|
||||
ownAppointmentsOnly: false,
|
||||
canEditPatients: true,
|
||||
canViewReports: true,
|
||||
canViewMedicalRecords: true,
|
||||
},
|
||||
medico: {
|
||||
manageUsers: false,
|
||||
hardDeletePatients: false,
|
||||
accessSettings: false,
|
||||
ownAppointmentsOnly: true,
|
||||
canEditPatients: false,
|
||||
canViewReports: true,
|
||||
canViewMedicalRecords: true,
|
||||
},
|
||||
secretaria: {
|
||||
manageUsers: false,
|
||||
hardDeletePatients: false,
|
||||
accessSettings: false,
|
||||
ownAppointmentsOnly: false,
|
||||
canEditPatients: true,
|
||||
canViewReports: false,
|
||||
canViewMedicalRecords: false,
|
||||
},
|
||||
paciente: {
|
||||
manageUsers: false,
|
||||
hardDeletePatients: false,
|
||||
accessSettings: false,
|
||||
ownAppointmentsOnly: false,
|
||||
canEditPatients: false,
|
||||
canViewReports: false,
|
||||
canViewMedicalRecords: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Itens do menu por role (para o AppShell)
|
||||
export const ROLE_NAV_ITEMS = {
|
||||
admin: [
|
||||
{ path: '/inicio', label: 'Painel' },
|
||||
{ path: '/agenda', label: 'Agenda' },
|
||||
{ path: '/pacientes', label: 'Pacientes' },
|
||||
{ path: '/prontuario', label: 'Prontuário' },
|
||||
{ path: '/laudos', label: 'Laudos' },
|
||||
{ path: '/relatorios', label: 'Relatórios' },
|
||||
{ path: '/comunicacao', label: 'Comunicação' },
|
||||
{ path: '/profissionais', label: 'Profissionais' },
|
||||
{ path: '/usuarios', label: 'Usuários' },
|
||||
{ path: '/configuracoes', label: 'Configurações' },
|
||||
],
|
||||
gestor: [
|
||||
{ path: '/inicio', label: 'Painel' },
|
||||
{ path: '/agenda', label: 'Agenda' },
|
||||
{ path: '/pacientes', label: 'Pacientes' },
|
||||
{ path: '/prontuario', label: 'Prontuário' },
|
||||
{ path: '/laudos', label: 'Laudos' },
|
||||
{ path: '/relatorios', label: 'Relatórios' },
|
||||
{ path: '/comunicacao', label: 'Comunicação' },
|
||||
{ path: '/profissionais', label: 'Profissionais' },
|
||||
{ path: '/usuarios', label: 'Usuários' },
|
||||
{ path: '/configuracoes', label: 'Configurações' },
|
||||
],
|
||||
medico: [
|
||||
{ path: '/inicio', label: 'Painel' },
|
||||
{ path: '/agenda', label: 'Agenda' },
|
||||
{ path: '/prontuario', label: 'Prontuário' },
|
||||
{ path: '/laudos', label: 'Laudos' },
|
||||
{ path: '/comunicacao', label: 'Comunicação' },
|
||||
{ path: '/relatorios', label: 'Relatórios' },
|
||||
],
|
||||
secretaria: [
|
||||
{ path: '/inicio', label: 'Painel' },
|
||||
{ path: '/agenda', label: 'Agenda' },
|
||||
{ path: '/pacientes', label: 'Pacientes' },
|
||||
{ path: '/comunicacao', label: 'Comunicação' },
|
||||
],
|
||||
paciente: [
|
||||
{ path: '/inicio', label: 'Painel' },
|
||||
],
|
||||
}
|
||||
|
||||
// Verifica se um role pode acessar uma rota
|
||||
export function canAccess(role, pathname) {
|
||||
const normalizedRole = normalizeRole(role)
|
||||
if (!normalizedRole) return false
|
||||
const allowed = ROLE_ROUTES[normalizedRole]
|
||||
if (allowed === '*') return true
|
||||
if (!Array.isArray(allowed)) return false
|
||||
return allowed.some((route) => pathname === route || pathname.startsWith(route + '/'))
|
||||
}
|
||||
|
||||
// Verifica se um role tem uma capacidade específica
|
||||
export function hasCapability(role, capability) {
|
||||
const normalizedRole = normalizeRole(role)
|
||||
return ROLE_CAPABILITIES[normalizedRole]?.[capability] ?? false
|
||||
}
|
||||
|
||||
export function normalizeRole(role) {
|
||||
const normalized = normalizeRoleKey(role)
|
||||
return ROLE_ALIASES[normalized] ?? null
|
||||
}
|
||||
|
||||
function normalizeRoleKey(role) {
|
||||
return String(role ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
}
|
||||
|
||||
// Rótulos amigáveis para cada role
|
||||
export const ROLE_LABELS = {
|
||||
admin: 'Administrador',
|
||||
gestor: 'Gestão / Coordenação',
|
||||
medico: 'Médico',
|
||||
secretaria: 'Secretária',
|
||||
paciente: 'Paciente',
|
||||
}
|
||||
|
||||
// Roles que um gestor pode criar
|
||||
export const GESTOR_CREATABLE_ROLES = ['medico', 'secretaria', 'paciente']
|
||||
|
||||
// Roles que um admin pode criar
|
||||
export const ADMIN_CREATABLE_ROLES = ['admin', 'gestor', 'medico', 'secretaria', 'paciente']
|
||||
@@ -65,8 +65,8 @@ export const patients = [
|
||||
nextVisit: '07 abr 2026, 14:30',
|
||||
team: ['Dr. Rafael Nunes', 'Nutri. Clara Meireles'],
|
||||
notes: [
|
||||
'Pressao ainda oscilando no periodo da tarde.',
|
||||
'Conferir adesao ao medicamento e orientar diario de pressao.',
|
||||
'Pressão ainda oscilando no período da tarde.',
|
||||
'Conferir adesão ao medicamento e orientar diário de pressão.',
|
||||
],
|
||||
exams: ['MAPA 24h', 'Eletrocardiograma', 'Creatinina'],
|
||||
},
|
||||
@@ -179,10 +179,10 @@ export const careQueue = [
|
||||
id: 'queue-002',
|
||||
patient: 'Bruno Lima',
|
||||
patientId: 'bruno-lima',
|
||||
status: 'Aguardando medico',
|
||||
status: 'Aguardando médico',
|
||||
priority: 'Alta',
|
||||
wait: '25 min',
|
||||
reason: 'Pressao elevada',
|
||||
reason: 'Pressão elevada',
|
||||
},
|
||||
{
|
||||
id: 'queue-003',
|
||||
@@ -226,7 +226,7 @@ export const conversations = [
|
||||
id: 'conv-bruno',
|
||||
patient: 'Bruno Lima',
|
||||
patientId: 'bruno-lima',
|
||||
subject: 'Pressao no fim do dia',
|
||||
subject: 'Pressão no fim do dia',
|
||||
unread: 1,
|
||||
lastMessage: 'Hoje marcou 15 por 9 novamente.',
|
||||
status: 'Prioridade alta',
|
||||
@@ -247,7 +247,7 @@ export const conversations = [
|
||||
id: 'conv-carla',
|
||||
patient: 'Carla Mendes',
|
||||
patientId: 'carla-mendes',
|
||||
subject: 'Confirmacao de horario',
|
||||
subject: 'Confirmação de horario',
|
||||
unread: 0,
|
||||
lastMessage: 'Confirmado para quinta as 08:30.',
|
||||
status: 'Respondida',
|
||||
@@ -265,7 +265,7 @@ export const professionals = [
|
||||
{
|
||||
id: 'marina-lopes',
|
||||
name: 'Dra. Marina Lopes',
|
||||
role: 'Clinica geral',
|
||||
role: 'Clínica geral',
|
||||
schedule: 'Seg a sex, 08:00-16:00',
|
||||
status: 'Disponivel',
|
||||
nextSlot: 'Hoje, 15:30',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'
|
||||
import { isSameDay } from 'date-fns'
|
||||
|
||||
import { appointmentRepository } from '../repositories/appointmentRepository.js'
|
||||
import { availabilityRepository } from '../repositories/availabilityRepository.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||
import { profileRepository } from '../repositories/profileRepository.js'
|
||||
@@ -15,6 +16,9 @@ export function useAgenda() {
|
||||
const [localAppointments, setLocalAppointments] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [availableSlots, setAvailableSlots] = useState([])
|
||||
const [slotsLoading, setSlotsLoading] = useState(false)
|
||||
const [slotsError, setSlotsError] = useState('')
|
||||
|
||||
const [activeView, setActiveView] = useState('Dia')
|
||||
const [baseDate, setBaseDate] = useState(new Date())
|
||||
@@ -28,6 +32,10 @@ export function useAgenda() {
|
||||
time: '15:30',
|
||||
mode: 'Teleconsulta',
|
||||
})
|
||||
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const canCreateAppointment = agendaScope === 'doctor'
|
||||
? Boolean(currentProfessional?.id)
|
||||
: professionals.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
@@ -63,7 +71,7 @@ export function useAgenda() {
|
||||
|
||||
if (agendaScope === 'doctor' && !resolvedProfessional) {
|
||||
setLocalAppointments([])
|
||||
setError('Nao foi possivel vincular o medico logado a um profissional da base.')
|
||||
setError('Não foi possível vincular o médico logado a um profissional da base.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,6 +105,59 @@ export function useAgenda() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return
|
||||
|
||||
const targetProfessionalId = agendaScope === 'doctor'
|
||||
? currentProfessional?.id
|
||||
: form.professionalId
|
||||
|
||||
let active = true
|
||||
|
||||
async function loadAvailableSlots() {
|
||||
if (!targetProfessionalId) {
|
||||
setAvailableSlots([])
|
||||
setSlotsError('')
|
||||
return
|
||||
}
|
||||
|
||||
setSlotsLoading(true)
|
||||
setSlotsError('')
|
||||
|
||||
try {
|
||||
const slots = await availabilityRepository.getAvailableSlots({
|
||||
doctorId: targetProfessionalId,
|
||||
date: formatLocalDateInput(baseDate),
|
||||
})
|
||||
|
||||
if (!active) return
|
||||
|
||||
const activeSlots = slots.filter((slot) => slot.available)
|
||||
setAvailableSlots(activeSlots)
|
||||
|
||||
if (activeSlots.length) {
|
||||
setForm((current) =>
|
||||
activeSlots.some((slot) => slot.time === current.time)
|
||||
? current
|
||||
: { ...current, time: activeSlots[0].time },
|
||||
)
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (!active) return
|
||||
setAvailableSlots([])
|
||||
setSlotsError(loadError.message || 'Não foi possível calcular horários disponíveis.')
|
||||
} finally {
|
||||
if (active) setSlotsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAvailableSlots()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [agendaScope, baseDate, currentProfessional?.id, form.professionalId, modalOpen])
|
||||
|
||||
const visibleAppointments = useMemo(() => {
|
||||
let filtered = localAppointments
|
||||
|
||||
@@ -118,11 +179,6 @@ export function useAgenda() {
|
||||
return sortAppointmentsByTime(filtered)
|
||||
}, [localAppointments, status, activeView, baseDate])
|
||||
|
||||
const agendaScope = viewerProfile?.isDoctor ? 'doctor' : 'global'
|
||||
const canCreateAppointment = agendaScope === 'doctor'
|
||||
? Boolean(currentProfessional?.id)
|
||||
: professionals.length > 0
|
||||
|
||||
function updateForm(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
@@ -135,7 +191,7 @@ export function useAgenda() {
|
||||
: form.professionalId
|
||||
|
||||
if (!targetProfessionalId) {
|
||||
alert('Nao foi possivel identificar o profissional da consulta.')
|
||||
alert('Não foi possível identificar o profissional da consulta.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,6 +236,9 @@ export function useAgenda() {
|
||||
updateForm,
|
||||
handleCreate,
|
||||
visibleAppointments,
|
||||
availableSlots,
|
||||
slotsLoading,
|
||||
slotsError,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
src/hooks/useAuth.js
Normal file
101
src/hooks/useAuth.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { AUTH_SESSION_CHANGED_EVENT, getAuthSession, saveAuthSession } from '../config/api.js'
|
||||
import { normalizeRole } from '../config/permissions.js'
|
||||
import { authRepository } from '../repositories/authRepository.js'
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState(() => getStateFromSession(getAuthSession()))
|
||||
|
||||
useEffect(() => {
|
||||
function syncSession() {
|
||||
setState(getStateFromSession(getAuthSession()))
|
||||
}
|
||||
|
||||
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||
return () => window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isAuthenticated || state.role) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
authRepository
|
||||
.getUser()
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return
|
||||
|
||||
const profile = data.profile ?? data.perfil ?? null
|
||||
const user = data.user ?? data.usuario ?? data ?? null
|
||||
const role = resolveRole(data)
|
||||
const session = getAuthSession()
|
||||
|
||||
saveAuthSession({ ...session, role, profile, user: user || session?.user })
|
||||
setState((current) => ({ ...current, role, profile, user: user || current.user, loading: false }))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState((current) => ({ ...current, loading: false }))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [state.isAuthenticated, state.role])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
function getStateFromSession(session) {
|
||||
const role = normalizeRole(session?.role)
|
||||
|
||||
return {
|
||||
user: session?.user ?? null,
|
||||
role,
|
||||
profile: session?.profile ?? null,
|
||||
isAuthenticated: !!session?.access_token,
|
||||
loading: !!session?.access_token && !role,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRole(data) {
|
||||
const user = data?.user ?? data?.usuario ?? {}
|
||||
const profile = data?.profile ?? data?.perfil ?? {}
|
||||
const metadata = {
|
||||
...user?.user_metadata,
|
||||
...user?.app_metadata,
|
||||
...user?.metadata,
|
||||
...data?.user_metadata,
|
||||
...data?.app_metadata,
|
||||
...data?.metadata,
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
...(Array.isArray(data?.roles) ? data.roles : []),
|
||||
...(Array.isArray(user?.roles) ? user.roles : []),
|
||||
data?.role,
|
||||
data?.cargo,
|
||||
profile?.role,
|
||||
profile?.cargo,
|
||||
user?.role,
|
||||
user?.cargo,
|
||||
metadata.role,
|
||||
metadata.cargo,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const role = normalizeRole(candidate)
|
||||
if (role) return role
|
||||
}
|
||||
|
||||
const permissions = data?.permissions ?? data?.permissoes ?? {}
|
||||
if (permissions.isAdmin) return 'admin'
|
||||
if (permissions.isManager) return 'gestor'
|
||||
if (permissions.isDoctor) return 'medico'
|
||||
if (permissions.isSecretary) return 'secretaria'
|
||||
if (permissions.isPatient) return 'paciente'
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export const appointmentMapper = {
|
||||
professional.full_name ||
|
||||
professional.name ||
|
||||
professional.nome ||
|
||||
'Medico(a)',
|
||||
'Médico(a)',
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
|
||||
|
||||
@@ -51,6 +51,9 @@ export function AgendaPage({ navigate }) {
|
||||
updateForm,
|
||||
handleCreate,
|
||||
visibleAppointments,
|
||||
availableSlots,
|
||||
slotsLoading,
|
||||
slotsError,
|
||||
} = useAgenda()
|
||||
|
||||
if (loading) {
|
||||
@@ -131,10 +134,10 @@ export function AgendaPage({ navigate }) {
|
||||
{error ? (
|
||||
<section className="rounded-2xl border border-[#404040] bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)]">
|
||||
<div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
|
||||
<h2 className="text-base font-bold text-[#fecaca]">Nao foi possivel liberar a agenda</h2>
|
||||
<h2 className="text-base font-bold text-[#fecaca]">Não foi possível liberar a agenda</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
|
||||
Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico.
|
||||
Enquanto esse vínculo não existir na API, a tela fica bloqueada para evitar exibir consultas de outro médico.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -244,12 +247,32 @@ export function AgendaPage({ navigate }) {
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<DarkField label="Horário">
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('time', event.target.value)}
|
||||
type="time"
|
||||
value={form.time}
|
||||
/>
|
||||
{availableSlots.length ? (
|
||||
<select
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('time', event.target.value)}
|
||||
value={form.time}
|
||||
>
|
||||
{availableSlots.map((slot) => (
|
||||
<option key={slot.time} value={slot.time}>
|
||||
{slot.time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
||||
onChange={(event) => updateForm('time', event.target.value)}
|
||||
type="time"
|
||||
value={form.time}
|
||||
/>
|
||||
)}
|
||||
{slotsLoading ? (
|
||||
<span className="text-xs font-normal text-[#a3a3a3]">Calculando horários...</span>
|
||||
) : null}
|
||||
{slotsError ? (
|
||||
<span className="text-xs font-normal text-amber-400">{slotsError}</span>
|
||||
) : null}
|
||||
</DarkField>
|
||||
<DarkField label="Formato">
|
||||
<select
|
||||
|
||||
@@ -176,7 +176,7 @@ export function LoginPage({ navigate }) {
|
||||
}
|
||||
|
||||
export function RegisterPage({ navigate }) {
|
||||
const [role, setRole] = useState('Clinica')
|
||||
const [role, setRole] = useState('Clínica')
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
@@ -204,7 +204,7 @@ export function RegisterPage({ navigate }) {
|
||||
</AuthField>
|
||||
<AuthField label="Tipo de conta">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Clinica', 'Profissional'].map((option) => (
|
||||
{['Clínica', 'Profissional'].map((option) => (
|
||||
<button
|
||||
className={`h-11 rounded-[6px] border px-3 text-sm font-semibold transition ${
|
||||
role === option
|
||||
|
||||
@@ -209,7 +209,7 @@ function ReportAction({ card, navigate }) {
|
||||
|
||||
function LineChart() {
|
||||
return (
|
||||
<svg aria-label="Grafico mockado de absenteismo" className="h-full w-full" role="img" viewBox="0 0 732 260">
|
||||
<svg aria-label="Gráfico mockado de absenteísmo" className="h-full w-full" role="img" viewBox="0 0 732 260">
|
||||
<defs>
|
||||
<linearGradient id="home-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.24" />
|
||||
|
||||
@@ -197,7 +197,7 @@ function RecordEditorModal({ onClose, onSave, recordTypes }) {
|
||||
date: formData.date ? formatDate(formData.date) : '07/04/2026',
|
||||
doctor: 'Dr. Henrique Cardoso',
|
||||
type: formData.type,
|
||||
cid: formData.cid || 'CID nao informado',
|
||||
cid: formData.cid || 'CID não informado',
|
||||
status,
|
||||
summary: formData.conduct || formData.anamnesis || 'Registro criado localmente para simulação.',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { normalizeRole } from '../config/permissions.js'
|
||||
import { FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||
import { communicationRepository } from '../repositories/communicationRepository.js'
|
||||
@@ -40,7 +41,13 @@ const textareaClass =
|
||||
'min-h-28 w-full resize-y rounded-sm border border-[#404040] bg-[#171717] px-3 py-2 text-sm leading-6 text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20'
|
||||
const labelClass = 'text-xs font-semibold uppercase tracking-[0.08em] text-[#a3a3a3]'
|
||||
|
||||
export function MessagesPage() {
|
||||
export function MessagesPage({ role }) {
|
||||
const normalizedRole = normalizeRole(role)
|
||||
const isSecretary = normalizedRole === 'secretaria'
|
||||
const allowedChannelKeys = useMemo(
|
||||
() => (isSecretary ? ['whatsapp', 'sms'] : Object.keys(channels)),
|
||||
[isSecretary],
|
||||
)
|
||||
const campaigns = communicationRepository.getCampaigns()
|
||||
const [messages, setMessages] = useState(() => communicationRepository.getInitialMessages())
|
||||
const [templates, setTemplates] = useState(() => communicationRepository.getInitialTemplates())
|
||||
@@ -51,10 +58,15 @@ export function MessagesPage() {
|
||||
const [templateEditorOpen, setTemplateEditorOpen] = useState(false)
|
||||
const [composer, setComposer] = useState(emptyMessage)
|
||||
const [templateDraft, setTemplateDraft] = useState(emptyTemplate)
|
||||
const availableTemplates = useMemo(
|
||||
() => templates.filter((template) => allowedChannelKeys.includes(template.channel)),
|
||||
[allowedChannelKeys, templates],
|
||||
)
|
||||
|
||||
const filteredMessages = useMemo(
|
||||
() =>
|
||||
messages.filter((message) => {
|
||||
const isAllowedChannel = allowedChannelKeys.includes(message.channel)
|
||||
const matchesChannel = channelFilter === 'todos' || message.channel === channelFilter
|
||||
const query = search.trim().toLowerCase()
|
||||
const matchesSearch =
|
||||
@@ -64,22 +76,24 @@ export function MessagesPage() {
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
|
||||
return matchesChannel && matchesSearch
|
||||
return isAllowedChannel && matchesChannel && matchesSearch
|
||||
}),
|
||||
[channelFilter, messages, search],
|
||||
[allowedChannelKeys, channelFilter, messages, search],
|
||||
)
|
||||
|
||||
const stats = useMemo(
|
||||
() => ({
|
||||
total: messages.length,
|
||||
delivered: messages.filter((message) => message.status === 'entregue' || message.status === 'lida').length,
|
||||
read: messages.filter((message) => message.status === 'lida').length,
|
||||
failed: messages.filter((message) => message.status === 'falha').length,
|
||||
total: messages.filter((message) => allowedChannelKeys.includes(message.channel)).length,
|
||||
delivered: messages.filter((message) => allowedChannelKeys.includes(message.channel) && (message.status === 'entregue' || message.status === 'lida')).length,
|
||||
read: messages.filter((message) => allowedChannelKeys.includes(message.channel) && message.status === 'lida').length,
|
||||
failed: messages.filter((message) => allowedChannelKeys.includes(message.channel) && message.status === 'falha').length,
|
||||
}),
|
||||
[messages],
|
||||
[allowedChannelKeys, messages],
|
||||
)
|
||||
|
||||
function openTemplate(template) {
|
||||
if (!allowedChannelKeys.includes(template.channel)) return
|
||||
|
||||
setComposer({
|
||||
patient: '',
|
||||
phone: '',
|
||||
@@ -97,6 +111,11 @@ export function MessagesPage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!allowedChannelKeys.includes(composer.channel)) {
|
||||
alert('Canal indisponivel para o seu perfil.')
|
||||
return
|
||||
}
|
||||
|
||||
let smsSent = false
|
||||
|
||||
if (composer.channel === 'sms') {
|
||||
@@ -158,26 +177,28 @@ export function MessagesPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<FeatureCallout
|
||||
description="Envio de SMS usa API. Histórico, templates e campanhas ainda são dados locais de demonstração."
|
||||
description={isSecretary ? 'Perfil Secretária limitado a comunicação básica por WhatsApp e SMS.' : 'Envio de SMS usa API. Histórico, templates e campanhas ainda são dados locais de demonstração.'}
|
||||
status="partial"
|
||||
title="Mensageria híbrida"
|
||||
title={isSecretary ? 'Comunicação basica' : 'Mensageria hibrida'}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#f5f5f5]">Comunicação</h1>
|
||||
<p className="mt-1 text-sm text-[#b8b8b8]">WhatsApp, E-mail e SMS - histórico e campanhas</p>
|
||||
<p className="mt-1 text-sm text-[#b8b8b8]">{isSecretary ? 'WhatsApp e SMS para contato operacional com pacientes' : 'WhatsApp, E-mail e SMS - historico e campanhas'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||
onClick={() => setActiveTab('campanha')}
|
||||
type="button"
|
||||
>
|
||||
<CommIcon className="size-4" name="send" />
|
||||
Envio em Massa
|
||||
</button>
|
||||
{!isSecretary ? (
|
||||
<button
|
||||
className="inline-flex h-12 items-center gap-2 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||
onClick={() => setActiveTab('campanha')}
|
||||
type="button"
|
||||
>
|
||||
<CommIcon className="size-4" name="send" />
|
||||
Envio em Massa
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="inline-flex h-12 items-center gap-2 rounded-sm bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#2563eb]"
|
||||
onClick={() => setComposerOpen(true)}
|
||||
@@ -199,8 +220,7 @@ export function MessagesPage() {
|
||||
<div className="flex gap-4 border-b border-[#404040]">
|
||||
{[
|
||||
['historico', 'Histórico'],
|
||||
['templates', 'Templates'],
|
||||
['campanha', 'Campanhas'],
|
||||
...(!isSecretary ? [['templates', 'Templates'], ['campanha', 'Campanhas']] : []),
|
||||
].map(([key, label]) => (
|
||||
<button
|
||||
className={`border-b-2 px-2 pb-3 text-sm font-semibold transition ${
|
||||
@@ -238,9 +258,7 @@ export function MessagesPage() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
['todos', 'Todos'],
|
||||
['whatsapp', 'Whatsapp'],
|
||||
['email', 'E-mail'],
|
||||
['sms', 'Sms'],
|
||||
...allowedChannelKeys.map((key) => [key, channels[key].label]),
|
||||
].map(([key, label]) => (
|
||||
<button
|
||||
className={`h-12 rounded-sm border px-4 text-xs font-semibold transition ${
|
||||
@@ -300,7 +318,7 @@ export function MessagesPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{templates.map((template) => (
|
||||
{availableTemplates.map((template) => (
|
||||
<TemplateCard key={template.id} onUse={openTemplate} template={template} />
|
||||
))}
|
||||
</div>
|
||||
@@ -363,6 +381,7 @@ export function MessagesPage() {
|
||||
|
||||
{composerOpen ? (
|
||||
<MessageComposer
|
||||
allowedChannelKeys={allowedChannelKeys}
|
||||
draft={composer}
|
||||
onChange={setComposer}
|
||||
onClose={() => {
|
||||
@@ -370,12 +389,13 @@ export function MessagesPage() {
|
||||
setComposer(emptyMessage)
|
||||
}}
|
||||
onSubmit={submitMessage}
|
||||
templates={templates}
|
||||
templates={availableTemplates}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{templateEditorOpen ? (
|
||||
<TemplateEditor
|
||||
allowedChannelKeys={allowedChannelKeys}
|
||||
draft={templateDraft}
|
||||
onChange={setTemplateDraft}
|
||||
onClose={() => {
|
||||
@@ -459,7 +479,7 @@ function TemplateCard({ onUse, template }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
||||
function MessageComposer({ allowedChannelKeys, draft, onChange, onClose, onSubmit, templates }) {
|
||||
function update(field, value) {
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
@@ -494,9 +514,9 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
||||
</DarkField>
|
||||
<DarkField label="Canal">
|
||||
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="email">E-mail</option>
|
||||
<option value="sms">SMS</option>
|
||||
{allowedChannelKeys.map((key) => (
|
||||
<option key={key} value={key}>{channels[key].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
@@ -549,7 +569,7 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateEditor({ draft, onChange, onClose, onSubmit }) {
|
||||
function TemplateEditor({ allowedChannelKeys, draft, onChange, onClose, onSubmit }) {
|
||||
function update(field, value) {
|
||||
onChange((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
@@ -563,9 +583,9 @@ function TemplateEditor({ draft, onChange, onClose, onSubmit }) {
|
||||
</DarkField>
|
||||
<DarkField label="Canal">
|
||||
<select className={inputClass} onChange={(event) => update('channel', event.target.value)} value={draft.channel}>
|
||||
<option value="whatsapp">WhatsApp</option>
|
||||
<option value="email">E-mail</option>
|
||||
<option value="sms">SMS</option>
|
||||
{allowedChannelKeys.map((key) => (
|
||||
<option key={key} value={key}>{channels[key].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,9 @@ export function NotFoundPage({ navigate }) {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
description="A rota acessada nao faz parte do shell navegavel deste prototipo."
|
||||
description="A rota acessada não faz parte do shell navegável deste protótipo."
|
||||
eyebrow="404"
|
||||
title="Tela nao encontrada"
|
||||
title="Tela não encontrada"
|
||||
/>
|
||||
<Card className="p-6">
|
||||
<p className="max-w-2xl text-sm leading-6 text-slate-600">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { hasCapability } from '../config/permissions.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
const ITEMS_PER_PAGE = 25
|
||||
|
||||
@@ -14,7 +15,7 @@ const patientTabs = [
|
||||
{ label: 'Documentos', value: 'documentos' },
|
||||
]
|
||||
|
||||
export function PatientsPage({ navigate }) {
|
||||
export function PatientsPage({ navigate, role }) {
|
||||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
@@ -45,6 +46,8 @@ export function PatientsPage({ navigate }) {
|
||||
const insuranceOptions = useMemo(() => [...new Set(rows.map((patient) => patient.insurance).filter(Boolean))], [rows])
|
||||
const stateOptions = useMemo(() => [...new Set(rows.map((patient) => patient.state).filter(Boolean))], [rows])
|
||||
const hasAdvancedFilters = city || state || ageMin || ageMax || lastVisitSince
|
||||
const canEditPatients = hasCapability(role, 'canEditPatients')
|
||||
const canHardDeletePatients = hasCapability(role, 'hardDeletePatients')
|
||||
|
||||
const filteredPatients = useMemo(() => {
|
||||
return rows.filter((patient) => {
|
||||
@@ -65,7 +68,7 @@ export function PatientsPage({ navigate }) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (vip === 'Nao' && patient.vip) {
|
||||
if (vip === 'Não' && patient.vip) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -117,12 +120,18 @@ export function PatientsPage({ navigate }) {
|
||||
}
|
||||
|
||||
function openForm(patientId = null) {
|
||||
if (!canEditPatients) return
|
||||
setEditingId(patientId)
|
||||
setOpenMenuId(null)
|
||||
setView('form')
|
||||
}
|
||||
|
||||
async function savePatient(patient) {
|
||||
if (!canEditPatients) {
|
||||
window.alert('Você não tem permissão para salvar pacientes.')
|
||||
return
|
||||
}
|
||||
|
||||
const isNew = !rows.some((item) => item.id === patient.id)
|
||||
setSaving(true)
|
||||
|
||||
@@ -156,6 +165,11 @@ export function PatientsPage({ navigate }) {
|
||||
}
|
||||
|
||||
async function deletePatient(patientId) {
|
||||
if (!canHardDeletePatients) {
|
||||
window.alert('Você não tem permissão para excluir pacientes.')
|
||||
return
|
||||
}
|
||||
|
||||
if (window.confirm('Tem certeza que deseja excluir este paciente?')) {
|
||||
try {
|
||||
await patientRepository.remove(patientId)
|
||||
@@ -206,16 +220,18 @@ async function deletePatient(patientId) {
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Pacientes</h1>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">Gerencie as informacoes de seus pacientes</p>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">Gerencie as informações de seus pacientes</p>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white shadow-sm transition hover:bg-[#2563eb] md:w-auto"
|
||||
onClick={() => openForm()}
|
||||
type="button"
|
||||
>
|
||||
<PatientIcon name="user-plus" />
|
||||
Adicionar
|
||||
</button>
|
||||
{canEditPatients ? (
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white shadow-sm transition hover:bg-[#2563eb] md:w-auto"
|
||||
onClick={() => openForm()}
|
||||
type="button"
|
||||
>
|
||||
<PatientIcon name="user-plus" />
|
||||
Adicionar
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-[#404040] bg-[#262626] px-6 py-8 shadow-sm xl:py-14">
|
||||
@@ -253,7 +269,7 @@ async function deletePatient(patientId) {
|
||||
setVip(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={['Sim', 'Nao']}
|
||||
options={['Sim', 'Não']}
|
||||
value={vip}
|
||||
/>
|
||||
|
||||
@@ -310,7 +326,7 @@ async function deletePatient(patientId) {
|
||||
<th className="w-[8%] px-6 py-4">Estado</th>
|
||||
<th className="w-[16%] px-6 py-4">Ultimo atendimento</th>
|
||||
<th className="w-[18%] px-6 py-4">Proximo atendimento</th>
|
||||
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-6 py-4 text-right">Acoes</th>
|
||||
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-6 py-4 text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
||||
@@ -335,11 +351,11 @@ async function deletePatient(patientId) {
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.phone}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.city}</td>
|
||||
<td className="px-6 py-4 align-top text-[#a3a3a3]">{patient.state}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.lastVisit || 'Ainda nao houve atendimento'}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.lastVisit || 'Ainda não houve atendimento'}</td>
|
||||
<td className="px-6 py-4 align-top whitespace-normal break-words text-[#a3a3a3]">{patient.nextVisit || 'Nenhum atendimento agendado'}</td>
|
||||
<td className="relative sticky right-0 bg-[#262626] px-6 py-4 text-right shadow-[-10px_0_12px_-12px_rgba(0,0,0,0.75)]">
|
||||
<button
|
||||
aria-label={`Acoes de ${patient.name}`}
|
||||
aria-label={`Ações de ${patient.name}`}
|
||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333] hover:text-[#e5e5e5]"
|
||||
onClick={() => setOpenMenuId(openMenuId === patient.id ? null : patient.id)}
|
||||
type="button"
|
||||
@@ -356,7 +372,7 @@ async function deletePatient(patientId) {
|
||||
/>
|
||||
<div className="absolute right-8 top-10 z-20 w-48 rounded-lg border border-[#404040] bg-[#303030] py-1 text-left shadow-lg">
|
||||
<ActionItem icon="file" label="Ver detalhes" onClick={() => openDetail(patient)} />
|
||||
<ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} />
|
||||
{canEditPatients ? <ActionItem icon="edit" label="Editar" onClick={() => openForm(patient.id)} /> : null}
|
||||
<ActionItem
|
||||
icon="calendar"
|
||||
label="Marcar consulta"
|
||||
@@ -365,7 +381,9 @@ async function deletePatient(patientId) {
|
||||
navigate('/agenda')
|
||||
}}
|
||||
/>
|
||||
<ActionItem danger icon="trash" label="Excluir" onClick={() => deletePatient(patient.id)} />
|
||||
{canHardDeletePatients ? (
|
||||
<ActionItem danger icon="trash" label="Excluir" onClick={() => deletePatient(patient.id)} />
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -488,12 +506,12 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
id: formData.id || uniqueSlug(formData.name, existingIds),
|
||||
age: Number(formData.age) || 0,
|
||||
birthday: formData.birthday || '07/04',
|
||||
city: formData.city || 'Cidade nao informada',
|
||||
document: formData.cpf ? `CPF ${formData.cpf}` : 'CPF nao informado',
|
||||
city: formData.city || 'Cidade não informada',
|
||||
document: formData.cpf ? `CPF ${formData.cpf}` : 'CPF não informado',
|
||||
insurance: formData.insurance || 'Particular',
|
||||
lastVisit: formData.lastVisit || 'Ainda nao houve atendimento',
|
||||
lastVisit: formData.lastVisit || 'Ainda não houve atendimento',
|
||||
nextVisit: formData.nextVisit || null,
|
||||
phone: formData.phone || 'Telefone nao informado',
|
||||
phone: formData.phone || 'Telefone não informado',
|
||||
plan: formData.insurance || formData.plan || 'Particular',
|
||||
state: formData.state || 'UF',
|
||||
})
|
||||
@@ -512,7 +530,7 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Paciente</h1>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">Gerencie as informacoes de seus pacientes</p>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">Gerencie as informações de seus pacientes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -551,8 +569,8 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
<DarkField className="md:col-span-3" label="Etnia">
|
||||
<select className={darkInput} defaultValue="">
|
||||
<option value="">Selecione</option>
|
||||
<option>Indigena</option>
|
||||
<option>Nao Indigena</option>
|
||||
<option>Indígena</option>
|
||||
<option>Não Indígena</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
<DarkField className="md:col-span-3" label="Estado civil">
|
||||
@@ -605,12 +623,12 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
</section>
|
||||
|
||||
<section className={darkCard}>
|
||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Endereco</h2>
|
||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Endereço</h2>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
||||
<DarkField className="md:col-span-3" label="CEP">
|
||||
<input className={darkInput} maxLength={9} onChange={maskCEPInput} placeholder="_____-___" />
|
||||
</DarkField>
|
||||
<DarkField className="md:col-span-5" label="Endereco">
|
||||
<DarkField className="md:col-span-5" label="Endereço">
|
||||
<input className={darkInput} />
|
||||
</DarkField>
|
||||
<DarkField className="md:col-span-4" label="Cidade">
|
||||
@@ -621,7 +639,7 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
<option value="">Selecione</option>
|
||||
<option value="PE">Pernambuco</option>
|
||||
<option value="SE">Sergipe</option>
|
||||
<option value="SP">Sao Paulo</option>
|
||||
<option value="SP">São Paulo</option>
|
||||
<option value="RJ">Rio de Janeiro</option>
|
||||
</select>
|
||||
</DarkField>
|
||||
@@ -629,7 +647,7 @@ function PatientEditor({ existingIds, onCancel, onSave, patient, saving }) {
|
||||
</section>
|
||||
|
||||
<section className={darkCard}>
|
||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Informacoes de convenio</h2>
|
||||
<h2 className="mb-6 text-lg font-semibold text-[#e5e5e5]">Informações de convenio</h2>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 md:grid-cols-12">
|
||||
<DarkField className="md:col-span-6" label="Convenio">
|
||||
<select className={darkInput} name="insurance" onChange={handleChange} value={formData.insurance}>
|
||||
@@ -698,7 +716,7 @@ export function PatientDetailPage({ navigate, patient }) {
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="h-10 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:bg-[#303030]"
|
||||
onClick={() => navigate('/camunicacao')}
|
||||
onClick={() => navigate('/comunicacao')}
|
||||
type="button"
|
||||
>
|
||||
Enviar mensagem
|
||||
|
||||
@@ -15,8 +15,8 @@ const statusConfig = {
|
||||
}
|
||||
|
||||
const orderOptions = [
|
||||
{ label: 'Criacao mais recente', value: 'created_at.desc' },
|
||||
{ label: 'Criacao mais antiga', value: 'created_at.asc' },
|
||||
{ label: 'Criação mais recente', value: 'created_at.desc' },
|
||||
{ label: 'Criação mais antiga', value: 'created_at.asc' },
|
||||
{ label: 'Prazo mais proximo', value: 'due_at.asc' },
|
||||
{ label: 'Prazo mais distante', value: 'due_at.desc' },
|
||||
]
|
||||
@@ -80,7 +80,7 @@ export function ReportsPage() {
|
||||
return {
|
||||
id: String(professional.id || ''),
|
||||
createdByValue,
|
||||
name: professional.name || 'Medico(a)',
|
||||
name: professional.name || 'Médico(a)',
|
||||
}
|
||||
})
|
||||
.filter((professional) => {
|
||||
@@ -107,7 +107,7 @@ export function ReportsPage() {
|
||||
() =>
|
||||
reports.map((report) => ({
|
||||
...report,
|
||||
patientName: patientNameById[String(report.patientId || '')] || 'Paciente nao encontrado',
|
||||
patientName: patientNameById[String(report.patientId || '')] || 'Paciente não encontrado',
|
||||
createdByName: professionalNameByCreatedBy[String(report.createdBy || '')] || report.createdBy || 'Sistema',
|
||||
})),
|
||||
[patientNameById, professionalNameByCreatedBy, reports],
|
||||
@@ -146,7 +146,7 @@ export function ReportsPage() {
|
||||
setPage(1)
|
||||
} catch (loadError) {
|
||||
console.error(loadError)
|
||||
setError(loadError.message || 'Erro ao carregar relatorios medicos.')
|
||||
setError(loadError.message || 'Erro ao carregar relatórios médicos.')
|
||||
setReports([])
|
||||
setPage(1)
|
||||
} finally {
|
||||
@@ -238,7 +238,7 @@ export function ReportsPage() {
|
||||
setEditorOpen(false)
|
||||
await loadReports()
|
||||
} catch (saveError) {
|
||||
alert(saveError.message || 'Erro ao salvar relatorio medico.')
|
||||
alert(saveError.message || 'Erro ao salvar relatório médico.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -248,8 +248,8 @@ export function ReportsPage() {
|
||||
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Relatorios medicos</h1>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">Consulta, criacao e edicao de relatorios medicos.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Relatórios médicos</h1>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">Consulta, criação e edição de relatórios médicos.</p>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white transition hover:bg-[#2563eb]"
|
||||
@@ -257,7 +257,7 @@ export function ReportsPage() {
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon name="plus" />
|
||||
Novo relatorio
|
||||
Novo relatório
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +324,7 @@ export function ReportsPage() {
|
||||
</select>
|
||||
</FilterField>
|
||||
|
||||
<FilterField label="Ordenacao">
|
||||
<FilterField label="Ordenação">
|
||||
<select
|
||||
className={inputClass}
|
||||
onChange={(event) => {
|
||||
@@ -358,14 +358,14 @@ export function ReportsPage() {
|
||||
<th className="w-[18%] px-4 py-3">Solicitante</th>
|
||||
<th className="w-[14%] px-4 py-3">Criado em</th>
|
||||
<th className="w-[10%] px-4 py-3">Status</th>
|
||||
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-4 py-3 text-right">Acoes</th>
|
||||
<th className="sticky right-0 w-[8.5rem] bg-[#171717] px-4 py-3 text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#404040] bg-[#262626]">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
||||
Carregando relatorios medicos...
|
||||
Carregando relatórios médicos...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedReports.length ? (
|
||||
@@ -380,7 +380,7 @@ export function ReportsPage() {
|
||||
) : (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-sm text-[#a3a3a3]" colSpan={7}>
|
||||
Nenhum relatorio encontrado com os filtros atuais.
|
||||
Nenhum relatório encontrado com os filtros atuais.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -391,7 +391,7 @@ export function ReportsPage() {
|
||||
<div className="mt-4 flex flex-col gap-4 border-t border-[#404040] pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-[#a3a3a3]">
|
||||
Mostrando {enrichedReports.length ? startIndex + 1 : 0}-{Math.min(startIndex + ITEMS_PER_PAGE, enrichedReports.length)} de{' '}
|
||||
{enrichedReports.length} relatorios
|
||||
{enrichedReports.length} relatórios
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageButton disabled={currentPage === 1} onClick={() => setPage(currentPage - 1)}>
|
||||
@@ -480,7 +480,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
>
|
||||
<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 relatorio medico' : 'Novo relatorio medico'}
|
||||
{editor.id ? 'Editar relatório médico' : 'Novo relatório médico'}
|
||||
</h2>
|
||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||
@@ -556,29 +556,29 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
</DarkField>
|
||||
</div>
|
||||
|
||||
<DarkField label="Diagnostico">
|
||||
<DarkField label="Diagnóstico">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('diagnosis', event.target.value)}
|
||||
placeholder="Diagnostico do relatorio"
|
||||
placeholder="Diagnóstico do relatório"
|
||||
value={editor.diagnosis}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Conclusao">
|
||||
<DarkField label="Conclusão">
|
||||
<textarea
|
||||
className={textareaClass}
|
||||
onChange={(event) => updateField('conclusion', event.target.value)}
|
||||
placeholder="Conclusao do relatorio"
|
||||
placeholder="Conclusão do relatório"
|
||||
value={editor.conclusion}
|
||||
/>
|
||||
</DarkField>
|
||||
|
||||
<DarkField label="Conteudo HTML">
|
||||
<DarkField label="Conteúdo HTML">
|
||||
<textarea
|
||||
className={`${textareaClass} min-h-72`}
|
||||
onChange={(event) => updateField('contentHtml', event.target.value)}
|
||||
placeholder="<p>Conteudo do relatorio</p>"
|
||||
placeholder="<p>Conteúdo do relatório</p>"
|
||||
value={editor.contentHtml}
|
||||
/>
|
||||
</DarkField>
|
||||
@@ -622,7 +622,7 @@ function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions,
|
||||
type="button"
|
||||
>
|
||||
<ReportIcon className="size-3.5" name="save" />
|
||||
{saving ? 'Salvando...' : 'Salvar relatorio'}
|
||||
{saving ? 'Salvando...' : 'Salvar relatório'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -639,8 +639,8 @@ function ReportViewModal({ onClose, report }) {
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[#404040] px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatorio medico</h2>
|
||||
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem numero'} </p>
|
||||
<h2 className="text-lg font-bold text-[#e5e5e5]">Relatório médico</h2>
|
||||
<p className="mt-1 text-xs text-[#a3a3a3]">{report.orderNumber || 'Sem número'} </p>
|
||||
</div>
|
||||
<button className="rounded-lg p-1.5 transition hover:bg-[#2a2a2a]" onClick={onClose} type="button">
|
||||
<ReportIcon className="size-4 text-[#a3a3a3]" name="x" />
|
||||
@@ -663,8 +663,8 @@ function ReportViewModal({ onClose, report }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<DetailBlock label="Diagnostico" value={report.diagnosis || '-'} />
|
||||
<DetailBlock label="Conclusao" value={report.conclusion || '-'} />
|
||||
<DetailBlock label="Diagnóstico" value={report.diagnosis || '-'} />
|
||||
<DetailBlock label="Conclusão" value={report.conclusion || '-'} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-xs text-[#a3a3a3]">
|
||||
@@ -677,14 +677,14 @@ function ReportViewModal({ onClose, report }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-xl border border-[#404040] bg-[#1a1a1a] p-5">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Conteudo HTML</p>
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[#a3a3a3]">Conteúdo HTML</p>
|
||||
{report.contentHtml ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm text-[#e5e5e5]"
|
||||
dangerouslySetInnerHTML={{ __html: report.contentHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-[#a3a3a3]">Nenhum conteudo HTML informado.</p>
|
||||
<p className="text-sm text-[#a3a3a3]">Nenhum conteúdo HTML informado.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,66 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||
import { availabilityRepository } from '../repositories/availabilityRepository.js'
|
||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||
|
||||
const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm'
|
||||
const weekdays = [
|
||||
{ label: 'Seg', value: 1 },
|
||||
{ label: 'Ter', value: 2 },
|
||||
{ label: 'Qua', value: 3 },
|
||||
{ label: 'Qui', value: 4 },
|
||||
{ label: 'Sex', value: 5 },
|
||||
]
|
||||
|
||||
export function TeamPage({ navigate }) {
|
||||
const [professionals, setProfessionals] = useState([])
|
||||
const { slots, weekdays } = professionalRepository.getCoverageMap()
|
||||
const [availability, setAvailability] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
professionalRepository.getAll().then(setProfessionals).catch(console.error)
|
||||
let active = true
|
||||
|
||||
async function loadTeam() {
|
||||
try {
|
||||
setError('')
|
||||
const [professionalsData, availabilityData] = await Promise.all([
|
||||
professionalRepository.getAll(),
|
||||
availabilityRepository.getAll({ active: true }),
|
||||
])
|
||||
|
||||
if (!active) return
|
||||
|
||||
setProfessionals(professionalsData)
|
||||
setAvailability(availabilityData)
|
||||
} catch (loadError) {
|
||||
if (!active) return
|
||||
setError(loadError.message || 'Erro ao carregar profissionais e disponibilidade.')
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadTeam()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const availabilityByDoctor = useMemo(() => groupAvailabilityByDoctor(availability), [availability])
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<FeatureCallout
|
||||
description="A listagem de profissionais usa API, mas o mapa de cobertura e parte da disponibilidade ainda são simulados."
|
||||
status="partial"
|
||||
title="Tela híbrida: parte real, parte mockada"
|
||||
/>
|
||||
{error ? (
|
||||
<FeatureCallout
|
||||
description={error}
|
||||
status="wip"
|
||||
title="Não foi possível carregar disponibilidade"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<header className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
@@ -35,7 +76,11 @@ export function TeamPage({ navigate }) {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe médica">
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-[#a3a3a3]">Carregando profissionais...</p>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4" aria-label="Equipe medica">
|
||||
{professionals.map((professional) => (
|
||||
<article className={`${cardClass} ${featurePanelClass('live')} p-5`} key={professional.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -51,22 +96,22 @@ export function TeamPage({ navigate }) {
|
||||
|
||||
<dl className="mt-5 grid gap-3 text-sm">
|
||||
<Info label="Agenda" value={professional.schedule} />
|
||||
<Info label="Próximo horário" value={professional.nextSlot} />
|
||||
<Info label="Proximo horario" value={professional.nextSlot} />
|
||||
<Info label="Pacientes ativos" value={professional.patients} />
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className={`${cardClass} ${featurePanelClass('mock')} p-5`}>
|
||||
<section className={`${cardClass} ${featurePanelClass('live')} p-5`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-[#f5f5f5]">Mapa de cobertura</h2>
|
||||
<FeatureBadge status="mock" />
|
||||
<FeatureBadge status="live" />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">
|
||||
Matriz simples para preparar o fluxo de agenda, plantão e disponibilidade.
|
||||
Disponibilidades ativas cadastradas em /rest/v1/doctor_availability.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -80,18 +125,18 @@ export function TeamPage({ navigate }) {
|
||||
|
||||
<div className="mt-5 overflow-x-auto rounded-sm border border-[#404040]">
|
||||
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] bg-[#171717] text-xs font-bold uppercase tracking-[0.16em] text-[#a3a3a3]">
|
||||
{['Profissional', ...weekdays].map((label) => (
|
||||
{['Profissional', ...weekdays.map((weekday) => weekday.label)].map((label) => (
|
||||
<div className="border-b border-[#404040] px-4 py-3" key={label}>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{professionals.map((professional, rowIndex) => (
|
||||
{professionals.map((professional) => (
|
||||
<div className="grid min-w-[720px] grid-cols-[1.2fr_repeat(5,1fr)] text-sm" key={professional.id}>
|
||||
<div className="border-b border-[#404040] px-4 py-3 font-semibold text-[#f5f5f5]">{professional.name}</div>
|
||||
{slots.map((slot, index) => (
|
||||
<div className="border-b border-[#404040] px-4 py-3 text-[#b8b8b8]" key={`${professional.id}-${slot}`}>
|
||||
{shiftSlot(slot, rowIndex + index)}
|
||||
{weekdays.map((weekday) => (
|
||||
<div className="border-b border-[#404040] px-4 py-3 text-[#b8b8b8]" key={`${professional.id}-${weekday.value}`}>
|
||||
{formatCoverage(availabilityByDoctor.get(String(professional.id))?.[weekday.value])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -123,7 +168,7 @@ function StatusPill({ status }) {
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
return name
|
||||
return String(name || '')
|
||||
.replace(/^(Dr\.|Dra\.|Nutri\.|Enf\.)\s+/i, '')
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
@@ -132,10 +177,28 @@ function initials(name) {
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function shiftSlot(slot, index) {
|
||||
if (index % 4 === 0) {
|
||||
return 'Bloqueado'
|
||||
function groupAvailabilityByDoctor(items) {
|
||||
const grouped = new Map()
|
||||
|
||||
for (const item of items) {
|
||||
const doctorId = String(item.doctorId)
|
||||
const current = grouped.get(doctorId) || {}
|
||||
current[item.weekday] = [...(current[item.weekday] || []), item]
|
||||
grouped.set(doctorId, current)
|
||||
}
|
||||
|
||||
return slot
|
||||
return grouped
|
||||
}
|
||||
|
||||
function formatCoverage(items = []) {
|
||||
const activeItems = items.filter((item) => item.active !== false)
|
||||
if (!activeItems.length) return 'Sem regra'
|
||||
|
||||
return activeItems
|
||||
.map((item) => `${formatTime(item.startTime)}-${formatTime(item.endTime)}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
return String(value || '').slice(0, 5)
|
||||
}
|
||||
|
||||
441
src/pages/UsersPage.jsx
Normal file
441
src/pages/UsersPage.jsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ADMIN_CREATABLE_ROLES, GESTOR_CREATABLE_ROLES, hasCapability, normalizeRole, ROLE_LABELS } from '../config/permissions.js'
|
||||
import { userRepository } from '../repositories/userRepository.js'
|
||||
|
||||
const darkInput =
|
||||
'h-10 w-full rounded-lg border border-[#404040] bg-[#1a1a1a] px-3 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#737373] focus:border-[#3b82f6] focus:ring-1 focus:ring-[#3b82f6]'
|
||||
const darkLabel = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]'
|
||||
const authMethodOptions = [
|
||||
{
|
||||
value: 'magic_link',
|
||||
label: 'Magic Link',
|
||||
description: 'Enviar link de acesso por email',
|
||||
},
|
||||
{
|
||||
value: 'password',
|
||||
label: 'Email e senha',
|
||||
description: 'Definir senha inicial agora',
|
||||
},
|
||||
]
|
||||
const initialUserForm = {
|
||||
email: '',
|
||||
full_name: '',
|
||||
phone: '',
|
||||
cpf: '',
|
||||
role: '',
|
||||
auth_method: 'magic_link',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
create_patient_record: false,
|
||||
}
|
||||
|
||||
export function UsersPage({ role: currentRole }) {
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState(null)
|
||||
const [form, setForm] = useState(initialUserForm)
|
||||
|
||||
const normalizedRole = normalizeRole(currentRole)
|
||||
const canManageUsers = hasCapability(normalizedRole, 'manageUsers')
|
||||
const creatableRoles = normalizedRole === 'admin' ? ADMIN_CREATABLE_ROLES : GESTOR_CREATABLE_ROLES
|
||||
const isPasswordCreation = form.auth_method === 'password'
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
async function loadUsers() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await userRepository.getAll()
|
||||
setUsers(data)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormChange(event) {
|
||||
const { checked, name, type, value } = event.target
|
||||
setForm((current) => ({ ...current, [name]: type === 'checkbox' ? checked : value }))
|
||||
}
|
||||
|
||||
async function handleCreate(event) {
|
||||
event.preventDefault()
|
||||
if (!canManageUsers) {
|
||||
window.alert('Você não tem permissão para criar usuários.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.email || !form.full_name || !form.phone || !form.cpf || !form.role) {
|
||||
window.alert('Preencha email, nome completo, celular, CPF e perfil.')
|
||||
return
|
||||
}
|
||||
|
||||
if (isPasswordCreation) {
|
||||
if (!form.password || !form.confirm_password) {
|
||||
window.alert('Preencha a senha e a confirmação de senha.')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password.length < 8) {
|
||||
window.alert('A senha deve ter pelo menos 8 caracteres.')
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirm_password) {
|
||||
window.alert('A confirmação de senha não confere.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isPasswordCreation) {
|
||||
await userRepository.createWithPassword(form)
|
||||
window.alert(`Usuário criado com email e senha para ${form.email}.`)
|
||||
} else {
|
||||
await userRepository.create(form)
|
||||
window.alert(`Usuário criado! Magic Link enviado para ${form.email}.`)
|
||||
}
|
||||
setModalOpen(false)
|
||||
setForm(initialUserForm)
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
window.alert(`Erro ao criar usuário: ${err.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(user) {
|
||||
if (!canManageUsers) {
|
||||
window.alert('Você não tem permissão para deletar usuários.')
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`⚠️ ATENÇÃO: Esta operação é IRREVERSÍVEL!\n\nO usuário "${user.full_name || user.email}" e TODOS os dados relacionados (perfil, agendamentos, registros) serão deletados permanentemente.\n\nDeseja continuar?`
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setDeletingId(user.id)
|
||||
try {
|
||||
await userRepository.remove(user.id)
|
||||
setUsers((current) => current.filter((u) => u.id !== user.id))
|
||||
} catch (err) {
|
||||
window.alert(`Erro ao deletar usuário: ${err.message}`)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canManageUsers) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-[#404040] bg-[#262626] p-8 text-center text-[#e5e5e5]">
|
||||
<h1 className="text-xl font-bold">Acesso não permitido</h1>
|
||||
<p className="mt-2 text-sm text-[#a3a3a3]">Somente Administrador e Gestão/Coordenação podem gerenciar usuários.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 text-[#e5e5e5]">
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#e5e5e5]">Usuários do Sistema</h1>
|
||||
<p className="mt-1 text-sm text-[#a3a3a3]">Gerencie os usuários e seus perfis de acesso</p>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-[#3b82f6] px-4 text-sm font-medium text-white shadow-sm transition hover:bg-[#2563eb] md:w-auto"
|
||||
onClick={() => setModalOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
+ Novo usuário
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-[#a3a3a3]">Carregando usuários...</p>
|
||||
) : error ? (
|
||||
<p className="py-10 text-center text-sm text-red-400">Erro ao carregar usuários: {error}</p>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[#404040] bg-[#262626] shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full whitespace-nowrap text-left text-sm">
|
||||
<thead className="bg-[#171717] text-xs font-semibold uppercase text-[#a3a3a3]">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Nome</th>
|
||||
<th className="px-6 py-4">Email</th>
|
||||
<th className="px-6 py-4">Perfil</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4 text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#404040]">
|
||||
{users.length ? (
|
||||
users.map((user) => {
|
||||
const userRole = Array.isArray(user.roles) ? user.roles[0] : (user.role ?? '—')
|
||||
return (
|
||||
<tr className="transition hover:bg-[#303030]" key={user.id}>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-8 place-items-center rounded-full bg-[#333333] text-xs font-bold text-[#3b82f6]">
|
||||
{(user.full_name || user.email || '?').charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="font-medium text-[#e5e5e5]">{user.full_name || '—'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-[#a3a3a3]">{user.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<RoleBadge role={userRole} />
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
|
||||
user.email_confirmed_at
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-amber-500/20 text-amber-400'
|
||||
}`}>
|
||||
{user.email_confirmed_at ? 'Ativo' : 'Pendente'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
className="rounded-lg border border-[#ef4444]/30 bg-[#ef4444]/10 px-3 py-1.5 text-xs font-semibold text-[#ef4444] transition hover:bg-[#ef4444]/20 disabled:opacity-50"
|
||||
disabled={deletingId === user.id}
|
||||
onClick={() => handleDelete(user)}
|
||||
type="button"
|
||||
>
|
||||
{deletingId === user.id ? 'Deletando...' : 'Deletar'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td className="px-6 py-10 text-center text-[#a3a3a3]" colSpan={5}>
|
||||
Nenhum usuário encontrado.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setModalOpen(false)}>
|
||||
<div
|
||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-[#404040] bg-[#262626] p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[#e5e5e5]">Novo Usuário</h2>
|
||||
<p className="mt-1 text-xs text-[#a3a3a3]">
|
||||
{isPasswordCreation ? 'Crie o acesso inicial com email e senha.' : 'Um Magic Link sera enviado para o email cadastrado.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded p-1 text-[#a3a3a3] transition hover:bg-[#333333]"
|
||||
onClick={() => setModalOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleCreate}>
|
||||
<div>
|
||||
<span className={darkLabel}>Criar usuário usando *</span>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{authMethodOptions.map((option) => {
|
||||
const selected = form.auth_method === option.value
|
||||
return (
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border p-3 transition ${
|
||||
selected
|
||||
? 'border-[#3b82f6] bg-[#3b82f6]/15 text-[#e5e5e5]'
|
||||
: 'border-[#404040] bg-[#1a1a1a] text-[#a3a3a3] hover:border-[#525252] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={option.value}
|
||||
>
|
||||
<span className="flex items-start gap-3">
|
||||
<input
|
||||
checked={selected}
|
||||
className="mt-1 size-4 accent-[#3b82f6]"
|
||||
name="auth_method"
|
||||
onChange={handleFormChange}
|
||||
type="radio"
|
||||
value={option.value}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold">{option.label}</span>
|
||||
<span className="mt-1 block text-xs text-[#a3a3a3]">{option.description}</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={darkLabel}>Nome completo *</label>
|
||||
<input
|
||||
className={darkInput}
|
||||
name="full_name"
|
||||
onChange={handleFormChange}
|
||||
placeholder="Ex: João da Silva"
|
||||
required
|
||||
value={form.full_name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={darkLabel}>Email *</label>
|
||||
<input
|
||||
className={darkInput}
|
||||
name="email"
|
||||
onChange={handleFormChange}
|
||||
placeholder="email@exemplo.com"
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={darkLabel}>Celular *</label>
|
||||
<input
|
||||
className={darkInput}
|
||||
maxLength={15}
|
||||
name="phone"
|
||||
onChange={handleFormChange}
|
||||
placeholder="(00) 00000-0000"
|
||||
required
|
||||
value={form.phone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={darkLabel}>CPF *</label>
|
||||
<input
|
||||
className={darkInput}
|
||||
maxLength={14}
|
||||
name="cpf"
|
||||
onChange={handleFormChange}
|
||||
placeholder="000.000.000-00"
|
||||
required
|
||||
value={form.cpf}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPasswordCreation ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={darkLabel}>Senha *</label>
|
||||
<input
|
||||
autoComplete="new-password"
|
||||
className={darkInput}
|
||||
minLength={8}
|
||||
name="password"
|
||||
onChange={handleFormChange}
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
required={isPasswordCreation}
|
||||
type="password"
|
||||
value={form.password}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={darkLabel}>Confirmar senha *</label>
|
||||
<input
|
||||
autoComplete="new-password"
|
||||
className={darkInput}
|
||||
minLength={8}
|
||||
name="confirm_password"
|
||||
onChange={handleFormChange}
|
||||
placeholder="Repita a senha"
|
||||
required={isPasswordCreation}
|
||||
type="password"
|
||||
value={form.confirm_password}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className={darkLabel}>Perfil de acesso *</label>
|
||||
<select
|
||||
className={darkInput}
|
||||
name="role"
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
value={form.role}
|
||||
>
|
||||
<option value="">Selecione um perfil</option>
|
||||
{creatableRoles.map((r) => (
|
||||
<option key={`role-option-${r}`} value={r}>{ROLE_LABELS[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-[#e5e5e5]">
|
||||
<input
|
||||
checked={form.create_patient_record}
|
||||
className="size-4 accent-[#3b82f6]"
|
||||
name="create_patient_record"
|
||||
onChange={handleFormChange}
|
||||
type="checkbox"
|
||||
/>
|
||||
Criar também um registro de paciente
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="rounded-lg border border-[#404040] bg-[#262626] px-4 py-2 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#333333]"
|
||||
disabled={saving}
|
||||
onClick={() => setModalOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-[#3b82f6] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#2563eb] disabled:opacity-60"
|
||||
disabled={saving}
|
||||
type="submit"
|
||||
>
|
||||
{saving ? 'Criando...' : isPasswordCreation ? 'Criar com senha' : 'Criar e enviar Magic Link'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleBadge({ role }) {
|
||||
const styles = {
|
||||
admin: 'bg-purple-500/20 text-purple-400',
|
||||
gestor: 'bg-blue-500/20 text-blue-400',
|
||||
medico: 'bg-emerald-500/20 text-emerald-400',
|
||||
secretaria: 'bg-amber-500/20 text-amber-400',
|
||||
paciente: 'bg-[#303030] text-[#a3a3a3]',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${styles[role] || styles.paciente}`}>
|
||||
{ROLE_LABELS[role] || role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function VisitsPage({ navigate }) {
|
||||
}
|
||||
|
||||
if (activeTab === 'atendimento') {
|
||||
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando medico')
|
||||
return careQueue.filter((item) => item.status !== 'Finalizada' && item.status !== 'Aguardando médico')
|
||||
}
|
||||
|
||||
return careQueue.filter((item) => item.status !== 'Finalizada')
|
||||
|
||||
@@ -18,12 +18,12 @@ export const authRepository = {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro de autenticacao.'))
|
||||
throw new Error(await getResponseError(response, 'Erro de autenticação.'))
|
||||
}
|
||||
|
||||
const session = await response.json()
|
||||
if (!session?.access_token) {
|
||||
throw new Error('Falha no login. Token nao recebido.')
|
||||
throw new Error('Falha no login. Token não recebido.')
|
||||
}
|
||||
|
||||
saveAuthSession(session)
|
||||
@@ -32,7 +32,7 @@ export const authRepository = {
|
||||
|
||||
async requestPasswordReset(email) {
|
||||
const payload = { email: email?.trim() }
|
||||
const apiResponse = await fetch(apiEndpoint('/solicitar-reset-de-senha'), {
|
||||
const apiResponse = await fetch(apiEndpoint('/request-password-reset'), {
|
||||
method: 'POST',
|
||||
headers: getAnonHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
@@ -60,24 +60,17 @@ export const authRepository = {
|
||||
},
|
||||
|
||||
async getUser() {
|
||||
const apiEndpoints = [
|
||||
apiEndpoint('/user-info'),
|
||||
apiEndpoint('/informacoes-do-usuario-autenticado'),
|
||||
]
|
||||
const apiResponse = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/user-info`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
}).catch(() => null)
|
||||
|
||||
for (const url of apiEndpoints) {
|
||||
const apiResponse = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
}).catch(() => null)
|
||||
if (apiResponse?.ok) {
|
||||
return apiResponse.json()
|
||||
}
|
||||
|
||||
if (apiResponse?.ok) {
|
||||
return apiResponse.json()
|
||||
}
|
||||
|
||||
if (apiResponse && !shouldFallback(apiResponse)) {
|
||||
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.'))
|
||||
}
|
||||
if (apiResponse && !shouldFallback(apiResponse)) {
|
||||
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuário.'))
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, {
|
||||
@@ -86,7 +79,7 @@ export const authRepository = {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuario.'))
|
||||
throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuário.'))
|
||||
}
|
||||
|
||||
return response.json()
|
||||
@@ -114,7 +107,7 @@ export const authRepository = {
|
||||
headers: getAuthenticatedHeaders(),
|
||||
})
|
||||
} catch {
|
||||
// A sessao local precisa ser removida mesmo quando o backend nao responde.
|
||||
// A sessão local precisa ser removida mesmo quando o backend não responde.
|
||||
} finally {
|
||||
clearAuthSession()
|
||||
}
|
||||
|
||||
186
src/repositories/availabilityRepository.js
Normal file
186
src/repositories/availabilityRepository.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { getResponseError, normalizeCollection, normalizeItem } from './repositoryUtils.js'
|
||||
|
||||
const availabilityBaseUrl = `${apiConfig.restUrl}/doctor_availability`
|
||||
const exceptionsBaseUrl = `${apiConfig.restUrl}/doctor_exceptions`
|
||||
|
||||
export const availabilityRepository = {
|
||||
async getAll(filters = {}) {
|
||||
const query = buildRestQuery(filters)
|
||||
const response = await fetch(`${availabilityBaseUrl}?${query.toString()}`, {
|
||||
headers: getAuthenticatedHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao listar disponibilidades.'))
|
||||
}
|
||||
|
||||
return normalizeCollection(await response.json(), []).map(mapAvailability)
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const response = await fetch(availabilityBaseUrl, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify(toAvailabilityPayload(data)),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao criar disponibilidade.'))
|
||||
}
|
||||
|
||||
return mapAvailability(normalizeItem(await response.json()))
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
const response = await fetch(`${availabilityBaseUrl}?id=eq.${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify(toAvailabilityPayload(data)),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao atualizar disponibilidade.'))
|
||||
}
|
||||
|
||||
return mapAvailability(normalizeItem(await response.json()))
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
const response = await fetch(`${availabilityBaseUrl}?id=eq.${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao deletar disponibilidade.'))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
async getExceptions(filters = {}) {
|
||||
const query = buildRestQuery(filters)
|
||||
const response = await fetch(`${exceptionsBaseUrl}?${query.toString()}`, {
|
||||
headers: getAuthenticatedHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao listar excecoes de agenda.'))
|
||||
}
|
||||
|
||||
return normalizeCollection(await response.json(), []).map(mapException)
|
||||
},
|
||||
|
||||
async createException(data) {
|
||||
const response = await fetch(exceptionsBaseUrl, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }),
|
||||
body: JSON.stringify(toExceptionPayload(data)),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao criar excecao de agenda.'))
|
||||
}
|
||||
|
||||
return mapException(normalizeItem(await response.json()))
|
||||
},
|
||||
|
||||
async getAvailableSlots({ date, doctorId }) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl.replace(/\/+$/, '')}/get-available-slots`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify({
|
||||
doctor_id: doctorId,
|
||||
date,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao calcular slots disponíveis.'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return normalizeCollection(data, ['slots']).map(mapSlot)
|
||||
},
|
||||
}
|
||||
|
||||
function buildRestQuery(filters) {
|
||||
const query = new URLSearchParams()
|
||||
query.set('select', '*')
|
||||
|
||||
if (filters.doctorId) query.set('doctor_id', `eq.${filters.doctorId}`)
|
||||
if (filters.weekday !== undefined) query.set('weekday', `eq.${filters.weekday}`)
|
||||
if (filters.active !== undefined) query.set('active', `eq.${filters.active}`)
|
||||
if (filters.appointmentType) query.set('appointment_type', `eq.${filters.appointmentType}`)
|
||||
if (filters.date) query.set('date', `eq.${filters.date}`)
|
||||
if (filters.kind) query.set('kind', `eq.${filters.kind}`)
|
||||
if (filters.order) query.set('order', filters.order)
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
function toAvailabilityPayload(data) {
|
||||
return cleanPayload({
|
||||
doctor_id: data.doctorId,
|
||||
weekday: data.weekday,
|
||||
start_time: data.startTime,
|
||||
end_time: data.endTime,
|
||||
slot_minutes: data.slotMinutes,
|
||||
appointment_type: data.appointmentType,
|
||||
active: data.active,
|
||||
})
|
||||
}
|
||||
|
||||
function toExceptionPayload(data) {
|
||||
return cleanPayload({
|
||||
doctor_id: data.doctorId,
|
||||
date: data.date,
|
||||
kind: data.kind,
|
||||
start_time: data.startTime,
|
||||
end_time: data.endTime,
|
||||
reason: data.reason,
|
||||
created_by: data.createdBy,
|
||||
})
|
||||
}
|
||||
|
||||
function mapAvailability(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
doctorId: item.doctor_id,
|
||||
weekday: item.weekday,
|
||||
startTime: item.start_time,
|
||||
endTime: item.end_time,
|
||||
slotMinutes: item.slot_minutes,
|
||||
appointmentType: item.appointment_type,
|
||||
active: item.active,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function mapException(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
doctorId: item.doctor_id,
|
||||
date: item.date,
|
||||
kind: item.kind,
|
||||
startTime: item.start_time,
|
||||
endTime: item.end_time,
|
||||
reason: item.reason,
|
||||
createdBy: item.created_by,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSlot(slot) {
|
||||
return {
|
||||
time: slot.time,
|
||||
available: Boolean(slot.available),
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPayload(payload) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== undefined),
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,17 @@
|
||||
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { fetchJsonWithFallback } from './repositoryUtils.js'
|
||||
|
||||
export const communicationRepository = {
|
||||
async sendSms({ patientName, phone, content }) {
|
||||
async sendSms({ patientId, patientName, phone, content }) {
|
||||
const message = `[MediConnect] Ola ${patientName}, ${content}`
|
||||
const payload = {
|
||||
telefone: normalizePhone(phone),
|
||||
phone: normalizePhone(phone),
|
||||
mensagem: message,
|
||||
phone_number: normalizePhone(phone),
|
||||
message,
|
||||
paciente: patientName,
|
||||
patient_id: patientId || undefined,
|
||||
}
|
||||
|
||||
await fetchJsonWithFallback(
|
||||
[
|
||||
{
|
||||
url: apiEndpoint('/enviar-sms-via-twilio'),
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`,
|
||||
options: {
|
||||
@@ -39,9 +29,9 @@ export const communicationRepository = {
|
||||
|
||||
getCampaigns() {
|
||||
return [
|
||||
{ title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' },
|
||||
{ title: 'Vacinacao 2026', desc: 'Campanha de vacinacao anual', count: '156 pacientes elegiveis' },
|
||||
{ title: 'Retorno Pendente', desc: 'Pacientes com retorno atrasado', count: '42 pacientes elegiveis' },
|
||||
{ title: 'Lembretes Anti-Falta', desc: 'Envio automático 48h e 4h antes', count: '324 pacientes elegíveis' },
|
||||
{ title: 'Vacinação 2026', desc: 'Campanha de vacinação anual', count: '156 pacientes elegíveis' },
|
||||
{ title: 'Retorno Pendente', desc: 'Pacientes com retorno atrasado', count: '42 pacientes elegíveis' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -50,7 +40,7 @@ export const communicationRepository = {
|
||||
{ id: '1', patient: 'Carlos Eduardo Santos', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:00', status: 'lida', response: 'Confirmado!' },
|
||||
{ id: '2', patient: 'Mariana Costa', channel: 'whatsapp', template: 'Lembrete 48h', sentAt: '25/03/2026 09:05', status: 'entregue' },
|
||||
{ id: '3', patient: 'Joao Pedro Alves', channel: 'whatsapp', template: 'Lembrete 4h', sentAt: '27/03/2026 05:00', status: 'pendente' },
|
||||
{ id: '4', patient: 'Fernanda Lima', channel: 'email', template: 'Confirmacao de Agendamento', sentAt: '24/03/2026 15:30', status: 'lida' },
|
||||
{ id: '4', patient: 'Fernanda Lima', channel: 'email', template: 'Confirmação de Agendamento', sentAt: '24/03/2026 15:30', status: 'lida' },
|
||||
{ id: '5', patient: 'Roberto Campos', channel: 'whatsapp', template: 'Lembrete Extra (Risco Alto)', sentAt: '26/03/2026 10:00', status: 'entregue' },
|
||||
{ id: '6', patient: 'Sandra Oliveira', channel: 'sms', template: 'Lembrete 48h', sentAt: '24/03/2026 08:00', status: 'falha' },
|
||||
{ id: '7', patient: 'Lucia Ferreira', channel: 'email', template: 'Resultado de Exames', sentAt: '26/03/2026 14:00', status: 'lida' },
|
||||
@@ -60,11 +50,11 @@ export const communicationRepository = {
|
||||
|
||||
getInitialTemplates() {
|
||||
return [
|
||||
{ id: 't1', name: 'Lembrete 48h', channel: 'whatsapp', content: 'Ola {nome}! Lembramos que sua consulta esta agendada para {data} as {hora}. Confirme respondendo SIM.', category: 'Lembrete' },
|
||||
{ id: 't2', name: 'Lembrete 4h', channel: 'whatsapp', content: 'Ola {nome}! Sua consulta e hoje as {hora}. Estamos te esperando!', category: 'Lembrete' },
|
||||
{ id: 't1', name: 'Lembrete 48h', channel: 'whatsapp', content: 'Ola {nome}! Lembramos que sua consulta esta agendada para {data} às {hora}. Confirme respondendo SIM.', category: 'Lembrete' },
|
||||
{ id: 't2', name: 'Lembrete 4h', channel: 'whatsapp', content: 'Ola {nome}! Sua consulta e hoje às {hora}. Estamos te esperando!', category: 'Lembrete' },
|
||||
{ id: 't3', name: 'Lembrete Extra (Risco Alto)', channel: 'whatsapp', content: 'Ola {nome}! Notamos que sua presenca e muito importante. Podemos confirmar sua consulta de {data}?', category: 'IA' },
|
||||
{ id: 't4', name: 'Confirmacao de Agendamento', channel: 'email', content: 'Prezado(a) {nome}, confirmamos seu agendamento para {data} as {hora} com {medico}.', category: 'Agendamento' },
|
||||
{ id: 't5', name: 'Resultado de Exames', channel: 'email', content: 'Prezado(a) {nome}, seus resultados de exames estao disponiveis. Acesse o portal do paciente.', category: 'Exames' },
|
||||
{ id: 't4', name: 'Confirmação de Agendamento', channel: 'email', content: 'Prezado(a) {nome}, confirmamos seu agendamento para {data} às {hora} com {medico}.', category: 'Agendamento' },
|
||||
{ id: 't5', name: 'Resultado de Exames', channel: 'email', content: 'Prezado(a) {nome}, seus resultados de exames estão disponíveis. Acesse o portal do paciente.', category: 'Exames' },
|
||||
{ id: 't6', name: 'Reagendamento Sugerido (IA)', channel: 'whatsapp', content: 'Ola {nome}! Que tal reagendar sua consulta para um horario mais conveniente? Temos vagas em {sugestoes}.', category: 'IA' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ export const patientRepository = {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.message || 'Erro ao criar paciente com validacao')
|
||||
throw new Error(error.message || 'Erro ao criar paciente com validação')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
@@ -113,7 +113,7 @@ function mapPatientToDirectory(patient) {
|
||||
state: patient.state || patient.uf || 'PE',
|
||||
vip: Boolean(patient.vip),
|
||||
lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || null,
|
||||
lastVisit: patient.lastVisit || patient.last_visit || 'Ainda nao houve atendimento',
|
||||
lastVisit: patient.lastVisit || patient.last_visit || 'Ainda não houve atendimento',
|
||||
nextVisit: patient.nextVisit || patient.next_visit || 'Nenhum atendimento agendado',
|
||||
}
|
||||
}
|
||||
@@ -124,13 +124,13 @@ function mapPatientToDetail(patient) {
|
||||
return {
|
||||
...directory,
|
||||
age: patient.age || patient.idade || calculateAge(patient.birth_date),
|
||||
document: patient.document || patient.cpf || 'CPF nao informado',
|
||||
document: patient.document || patient.cpf || 'CPF não informado',
|
||||
plan: directory.insurance,
|
||||
condition: patient.condition || patient.condicao || 'Sem condicao principal',
|
||||
status: patient.status || 'Acompanhamento',
|
||||
risk: patient.risk || patient.risco || 'Baixo',
|
||||
email: patient.email || '',
|
||||
address: patient.address || patient.endereco || 'Endereco nao informado',
|
||||
address: patient.address || patient.endereco || 'Endereço não informado',
|
||||
team: patient.team || patient.equipe || [],
|
||||
notes: patient.notes || patient.observacoes || [],
|
||||
exams: patient.exams || patient.exames || [],
|
||||
|
||||
@@ -6,7 +6,7 @@ export const professionalRepository = {
|
||||
headers: getAuthenticatedHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Erro ao buscar medicos.')
|
||||
if (!response.ok) throw new Error('Erro ao buscar médicos.')
|
||||
|
||||
const data = await response.json()
|
||||
return (Array.isArray(data) ? data : []).map(mapProfessional)
|
||||
@@ -24,9 +24,9 @@ function mapProfessional(doctor) {
|
||||
return {
|
||||
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome),
|
||||
userId: doctor.user_id || doctor.userId || doctor.usuario_id || doctor.auth_user_id || null,
|
||||
name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)',
|
||||
name: doctor.name || doctor.nome || doctor.full_name || 'Médico(a)',
|
||||
email: doctor.email || doctor.user_email || doctor.usuario_email || '',
|
||||
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Medico(a)',
|
||||
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Médico(a)',
|
||||
schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h',
|
||||
nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente',
|
||||
patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authRepository } from './authRepository.js'
|
||||
import { normalizeRole, ROLE_LABELS } from '../config/permissions.js'
|
||||
import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { getResponseError } from './repositoryUtils.js'
|
||||
|
||||
@@ -9,7 +10,8 @@ export const profileRepository = {
|
||||
const user = data?.user || data?.usuario || profile || data
|
||||
const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {}
|
||||
const permissions = data?.permissions || {}
|
||||
const roles = Array.isArray(data?.roles) ? data.roles : []
|
||||
const roles = collectRoles({ data, meta, profile, user })
|
||||
const normalizedRole = resolveNormalizedRole({ permissions, roles, user, meta })
|
||||
const avatarUrl =
|
||||
profile?.avatar_url ||
|
||||
profile?.avatarUrl ||
|
||||
@@ -22,17 +24,19 @@ export const profileRepository = {
|
||||
return {
|
||||
id: profile?.id || user?.id || user?.user_id || user?.uid || '',
|
||||
email: profile?.email || user?.email || meta.email || '',
|
||||
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
|
||||
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuário',
|
||||
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
||||
role: resolveProfileRole({ permissions, roles, user, meta }),
|
||||
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista',
|
||||
role: ROLE_LABELS[normalizedRole] || user?.role || user?.cargo || meta.role || meta.cargo || 'Usuário do Sistema',
|
||||
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clínica Boa Vista',
|
||||
avatarUrl,
|
||||
doctorId: data?.doctor_id || data?.doctorId || null,
|
||||
patientId: data?.patient_id || data?.patientId || null,
|
||||
roles,
|
||||
permissions,
|
||||
isDoctor: Boolean(permissions.isDoctor || roles.includes('doctor') || data?.doctor_id),
|
||||
isAdmin: Boolean(permissions.isAdmin || roles.includes('admin')),
|
||||
isDoctor: normalizedRole === 'medico',
|
||||
isAdmin: normalizedRole === 'admin',
|
||||
isManager: normalizedRole === 'gestor',
|
||||
isSecretary: normalizedRole === 'secretaria',
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,7 +61,7 @@ export const profileRepository = {
|
||||
}
|
||||
|
||||
if (!profile.id) {
|
||||
throw new Error('Nao foi possivel identificar o usuario para enviar o avatar.')
|
||||
throw new Error('Não foi possível identificar o usuário para enviar o avatar.')
|
||||
}
|
||||
|
||||
const extension = file.name?.split('.').pop() || 'jpg'
|
||||
@@ -89,12 +93,31 @@ function normalizeAvatarResponse(data) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfileRole({ permissions, roles, user, meta }) {
|
||||
if (permissions.isAdmin || roles.includes('admin')) return 'Administrador'
|
||||
if (permissions.isManager || roles.includes('manager')) return 'Gestor'
|
||||
if (permissions.isDoctor || roles.includes('doctor')) return 'Medico(a)'
|
||||
if (permissions.isSecretary || roles.includes('secretary')) return 'Secretaria'
|
||||
if (permissions.isPatient || roles.includes('patient')) return 'Paciente'
|
||||
|
||||
return user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema'
|
||||
function collectRoles({ data, meta, profile, user }) {
|
||||
return [
|
||||
...(Array.isArray(data?.roles) ? data.roles : []),
|
||||
...(Array.isArray(user?.roles) ? user.roles : []),
|
||||
data?.role,
|
||||
data?.cargo,
|
||||
profile?.role,
|
||||
profile?.cargo,
|
||||
user?.role,
|
||||
user?.cargo,
|
||||
meta.role,
|
||||
meta.cargo,
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
function resolveNormalizedRole({ permissions, roles, user, meta }) {
|
||||
for (const role of roles) {
|
||||
const normalized = normalizeRole(role)
|
||||
if (normalized) return normalized
|
||||
}
|
||||
|
||||
if (permissions.isAdmin) return 'admin'
|
||||
if (permissions.isManager) return 'gestor'
|
||||
if (permissions.isDoctor) return 'medico'
|
||||
if (permissions.isSecretary) return 'secretaria'
|
||||
|
||||
return normalizeRole(user?.role || user?.cargo || meta.role || meta.cargo)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const reportRepository = {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao buscar relatorios medicos.'))
|
||||
throw new Error(await getResponseError(response, 'Falha ao buscar relatórios médicos.'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -40,7 +40,7 @@ export const reportRepository = {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao criar relatorio medico.'))
|
||||
throw new Error(await getResponseError(response, 'Falha ao criar relatório médico.'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -55,7 +55,7 @@ export const reportRepository = {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Falha ao atualizar relatorio medico.'))
|
||||
throw new Error(await getResponseError(response, 'Falha ao atualizar relatório médico.'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@@ -52,8 +52,19 @@ export function normalizeItem(data, keys = []) {
|
||||
export async function getResponseError(response, fallbackMessage) {
|
||||
if (!response) return fallbackMessage
|
||||
|
||||
const error = await response.json().catch(() => ({}))
|
||||
return error.error_description || error.msg || error.message || error.error || fallbackMessage
|
||||
const text = await response.text().catch(() => '')
|
||||
const error = parseErrorBody(text)
|
||||
const message =
|
||||
error.error_description ||
|
||||
error.msg ||
|
||||
error.message ||
|
||||
error.error ||
|
||||
error.details ||
|
||||
error.hint ||
|
||||
text ||
|
||||
fallbackMessage
|
||||
|
||||
return response.status ? `${fallbackMessage} (${response.status}): ${message}` : message
|
||||
}
|
||||
|
||||
function shouldFallback(response) {
|
||||
@@ -72,3 +83,13 @@ async function parseJsonResponse(response) {
|
||||
return { message: text }
|
||||
}
|
||||
}
|
||||
|
||||
function parseErrorBody(text) {
|
||||
if (!text) return {}
|
||||
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { message: text }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
export const settingsRepository = {
|
||||
getIntegrations() {
|
||||
return [
|
||||
['WhatsApp Business', 'Envio automatico de lembretes e confirmacoes', true, 'bg-emerald-500'],
|
||||
['WhatsApp Business', 'Envio automático de lembretes e confirmações', true, 'bg-emerald-500'],
|
||||
['Google Calendar', 'Sincronizacao bidirecional de agenda', false, 'bg-blue-500'],
|
||||
['Stripe / PagSeguro', 'Pagamentos online e links de cobranca', true, 'bg-violet-500'],
|
||||
['CFM - Conselho Federal de Medicina', 'Validacao automatica de CRM', false, 'bg-amber-500'],
|
||||
['ANS - Planos de Saude', 'Integracao com tabela TUSS e convenios', false, 'bg-rose-500'],
|
||||
['API de IA Preditiva', 'Score de absenteismo e predicao de faltas', true, 'bg-[#3b82f6]'],
|
||||
['API de IA Preditiva', 'Score de absenteísmo e predição de faltas', true, 'bg-[#3b82f6]'],
|
||||
]
|
||||
},
|
||||
|
||||
getSections() {
|
||||
return [
|
||||
{ id: 'aparencia', label: 'Aparencia', description: 'Tema, cores e exibicao', icon: 'palette' },
|
||||
{ id: 'notificacoes', label: 'Notificacoes', description: 'Alertas e lembretes', icon: 'bell' },
|
||||
{ id: 'aparencia', label: 'Aparência', description: 'Tema, cores e exibição', icon: 'palette' },
|
||||
{ id: 'notificacoes', label: 'Notificações', description: 'Alertas e lembretes', icon: 'bell' },
|
||||
{ id: 'privacidade', label: 'Privacidade & LGPD', description: 'Dados e conformidade', icon: 'shield' },
|
||||
{ id: 'conta', label: 'Conta & Perfil', description: 'Informacoes pessoais', icon: 'user' },
|
||||
{ id: 'integracoes', label: 'Integracoes', description: 'APIs e sistemas externos', icon: 'globe' },
|
||||
{ id: 'dados', label: 'Dados & Backup', description: 'Exportacao e backup', icon: 'database' },
|
||||
{ id: 'conta', label: 'Conta & Perfil', description: 'Informações pessoais', icon: 'user' },
|
||||
{ id: 'integracoes', label: 'Integrações', description: 'APIs e sistemas externos', icon: 'globe' },
|
||||
{ id: 'dados', label: 'Dados & Backup', description: 'Exportação e backup', icon: 'database' },
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
121
src/repositories/userRepository.js
Normal file
121
src/repositories/userRepository.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
|
||||
import { getResponseError, normalizeCollection } from './repositoryUtils.js'
|
||||
|
||||
const USER_PROFILE_TABLES = ['profiles', 'user_profiles']
|
||||
const USER_LIST_KEYS = ['users', 'usuarios', 'data', 'items', 'results']
|
||||
|
||||
export const userRepository = {
|
||||
async getAll() {
|
||||
let lastResponse = null
|
||||
|
||||
for (const table of USER_PROFILE_TABLES) {
|
||||
const query = new URLSearchParams({
|
||||
select: '*',
|
||||
})
|
||||
const response = await fetch(`${apiConfig.restUrl}/${table}?${query.toString()}`, {
|
||||
headers: getAuthenticatedHeaders(),
|
||||
}).catch(() => null)
|
||||
|
||||
if (!response) continue
|
||||
lastResponse = response
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json().catch(() => null)
|
||||
return normalizeCollection(data, USER_LIST_KEYS).map(normalizeListedUser)
|
||||
}
|
||||
|
||||
if (![404, 406].includes(response.status)) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao listar usuários.'))
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(await getResponseError(lastResponse, 'Tabela de perfis de usuários não encontrada.'))
|
||||
},
|
||||
|
||||
async getById(userId) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/user-info-by-id`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao buscar usuário.'))
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/create-user`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify(buildCreateUserBody(data)),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao criar usuário.'))
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async createWithPassword(data) {
|
||||
const body = {
|
||||
...buildCreateUserBody(data),
|
||||
password: data.password,
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/create-user-with-password`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao criar usuário com senha.'))
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
|
||||
async remove(userId) {
|
||||
const response = await fetch(`${apiConfig.functionsUrl}/delete-user`, {
|
||||
method: 'POST',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getResponseError(response, 'Erro ao deletar usuário.'))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
function buildCreateUserBody(data) {
|
||||
const body = {
|
||||
email: data.email?.trim(),
|
||||
full_name: data.full_name?.trim(),
|
||||
phone: data.phone?.trim(),
|
||||
cpf: data.cpf?.trim(),
|
||||
role: data.role,
|
||||
}
|
||||
|
||||
if (data.create_patient_record) {
|
||||
body.create_patient_record = true
|
||||
body.phone_mobile = data.phone_mobile?.trim() || data.phone?.trim()
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function normalizeListedUser(user) {
|
||||
return {
|
||||
...user,
|
||||
email: user.email || user.user_email || '',
|
||||
full_name: user.full_name || user.name || user.nome || '',
|
||||
role: Array.isArray(user.roles) ? user.roles[0] : (user.role || user.cargo || ''),
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export const visitRepository = {
|
||||
getStages() {
|
||||
return [
|
||||
{ title: 'Triagem', description: 'Sinais vitais, queixa principal e alerta de risco antes da chamada medica.' },
|
||||
{ title: 'Atendimento medico', description: 'Consulta em andamento, conduta, prescricao e solicitacao de exames.' },
|
||||
{ title: 'Atendimento médico', description: 'Consulta em andamento, conduta, prescrição e solicitação de exames.' },
|
||||
{ title: 'Pos-consulta', description: 'Orientacoes finais, documentos emitidos e retorno sugerido pela equipe.' },
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user