Replace hardcoded user with live profile data

This commit is contained in:
EdilbertoC
2026-04-28 12:46:39 -03:00
parent d496494b3e
commit 000abb39ac
15 changed files with 993 additions and 358 deletions

44
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,44 @@
# Arquitetura e Boas Práticas do Front-end (React)
Este documento estabelece as regras canônicas de arquitetura para este projeto. **Qualquer Inteligência Artificial ou desenvolvedor que atuar neste código DEVE ler e seguir estas diretrizes rigorosamente.**
O objetivo desta arquitetura é manter o ecossistema React amigável para desenvolvedores com mentalidade de Backend, priorizando a Separação de Conceitos (Separation of Concerns) e a previsibilidade dos dados.
## 1. A API é a Única Fonte da Verdade (Fim dos Mocks)
- **Regra:** Não crie, não mantenha e não faça fallback para dados mockados (falsos) em produção ou na integração.
- **Motivo:** O banco de dados (Supabase) dita as regras. Se a API falhar, o front-end deve exibir um estado de erro elegante, e não mascarar a falha com dados locais inventados.
- **Ação:** Repositórios (`*Repository.js`) devem apenas fazer o `fetch` seguro para a API e repassar a resposta.
## 2. O Padrão MVC Adaptado (Model-View-Hook)
Para evitar que as Páginas (`*Page.jsx`) se tornem "Componentes Deuses" (fazendo requisições, filtrando dados e renderizando HTML ao mesmo tempo), adotamos o seguinte fluxo:
### A. Repositório (Acesso a Dados)
- Fica na pasta `src/repositories/`.
- Sua ÚNICA função é bater na API, tratar erros HTTP e devolver o JSON puro (Array ou Objeto).
- Não deve conter regras de negócio, filtragem de tela ou formatação de datas.
### B. Mappers (Tradução Estrita)
- Fica na pasta `src/mappers/`.
- Traduz os dados do banco para o formato que a UI espera.
- **Regra de Ouro:** O Mapper deve ser **rígido**. Se o banco retorna `full_name`, o mapper converte para `name` e todo o resto da aplicação usa apenas `name`. Não propague a bagunça do banco para a tela.
### C. Custom Hooks (O Controlador)
- Fica na pasta `src/hooks/` (ex: `useAgenda.js`).
- Puxa os dados do repositório, passa pelo mapper, controla os estados de `loading`, `error`, e lida com a lógica de negócio (como submissão de formulários e filtragem de abas).
- Ele encapsula todos os `useEffect` e `useState` complexos.
### D. Páginas e Componentes (A View Burra)
- Fica em `src/pages/` e `src/components/`.
- As páginas são estritamente cascas visuais (HTML/Tailwind).
- Elas importam o Custom Hook, pegam as variáveis prontas e apenas decidem como desenhar isso na tela.
## 3. Lidar com Datas (Fuso Horário)
- Sempre que a API enviar uma data no formato string `YYYY-MM-DD`, lembre-se que o construtor nativo do JavaScript (`new Date('YYYY-MM-DD')`) converte para o horário UTC e, dependendo do fuso do usuário, pode jogar a data para o dia anterior.
- **Solução:** Use o helper local `parseLocalDate` ou processe os componentes da data (ano, mês, dia) manualmente antes de criar o objeto `Date`.
## Exemplo de Fluxo Ideal
1. A página `PacientesPage.jsx` chama o hook `const { pacientes, loading } = usePacientes()`.
2. O hook `usePacientes` chama o `patientRepository.getAll()`.
3. O repositório faz `fetch` na API.
4. O hook pega o resultado, passa no `patientMapper.toUi()` e atualiza o estado interno.
5. A página renderiza os dados que chegaram do hook.

11
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "projeto-residencia", "name": "projeto-residencia",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"date-fns": "^4.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
@@ -1495,6 +1496,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"date-fns": "^4.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { profileRepository } from '../repositories/profileRepository.js'
import { BrandLogo } from './Brand.jsx' import { BrandLogo } from './Brand.jsx'
import { FeatureLegend } from './FeatureState.jsx' import { FeatureLegend } from './FeatureState.jsx'
@@ -7,16 +8,16 @@ const navItems = [
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] }, { href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
{ href: '/agenda', label: 'Agenda', icon: 'calendar' }, { href: '/agenda', label: 'Agenda', icon: 'calendar' },
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true }, { href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
{ href: '/prontuario', label: 'Prontuário', icon: 'file' }, { href: '/prontuario', label: 'Prontuario', icon: 'file' },
{ href: '/laudos', label: 'Laudos', icon: 'clipboard' }, { href: '/laudos', label: 'Laudos', icon: 'clipboard' },
{ {
href: '/camunicacao', href: '/camunicacao',
label: 'Comunicação', label: 'Comunicacao',
icon: 'message', icon: 'message',
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'], activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
}, },
{ href: '/relatorios', label: 'Relatórios', icon: 'chart' }, { href: '/relatorios', label: 'Relatorios', icon: 'chart' },
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] }, { href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
] ]
const titles = { const titles = {
@@ -27,20 +28,21 @@ const titles = {
'/consultas': 'Consultas', '/consultas': 'Consultas',
'/laudos': 'Laudos', '/laudos': 'Laudos',
'/pacientes': 'Pacientes', '/pacientes': 'Pacientes',
'/prontuario': 'Prontuário', '/prontuario': 'Prontuario',
'/camunicacao': 'Comunicação', '/camunicacao': 'Comunicacao',
'/comunicacao': 'Comunicação', '/comunicacao': 'Comunicacao',
'/mensagens': 'Comunicação', '/mensagens': 'Comunicacao',
'/relatorios': 'Relatórios', '/relatorios': 'Relatorios',
'/profissionais': 'Profissionais', '/profissionais': 'Profissionais',
'/perfil': 'Perfil', '/perfil': 'Perfil',
'/configuracoes': 'Configurações', '/configuracoes': 'Configuracoes',
'/config': 'Configurações', '/config': 'Configuracoes',
} }
export function AppShell({ children, currentPath, navigate, routeTitle }) { export function AppShell({ children, currentPath, navigate, routeTitle }) {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [quickSearch, setQuickSearch] = useState('') const [quickSearch, setQuickSearch] = useState('')
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' })
const pageTitle = useMemo(() => { const pageTitle = useMemo(() => {
if (currentPath.startsWith('/pacientes/') && routeTitle) { if (currentPath.startsWith('/pacientes/') && routeTitle) {
@@ -50,6 +52,25 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
return routeTitle || titles[currentPath] || 'MediConnect' return routeTitle || titles[currentPath] || 'MediConnect'
}, [currentPath, routeTitle]) }, [currentPath, routeTitle])
useEffect(() => {
let active = true
profileRepository.getCurrentUserProfile()
.then((profile) => {
if (!active || !profile) return
setViewerProfile({
name: profile.name || 'Usuario',
role: profile.role || 'Usuario do Sistema',
})
})
.catch(() => {})
return () => {
active = false
}
}, [])
function goTo(path) { function goTo(path) {
setMenuOpen(false) setMenuOpen(false)
navigate(path) navigate(path)
@@ -96,8 +117,8 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
onClick={() => goTo('/perfil')} onClick={() => goTo('/perfil')}
type="button" type="button"
> >
<p className="truncate text-xs font-semibold text-[#e5e5e5]">Dr. Henrique Cardoso</p> <p className="truncate text-xs font-semibold text-[#e5e5e5]">{viewerProfile.name}</p>
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">Médico Clínico Geral</p> <p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">{viewerProfile.role}</p>
</button> </button>
</div> </div>
</aside> </aside>
@@ -129,7 +150,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
aria-label="Busca rapida" aria-label="Busca rapida"
className="h-[38px] w-full rounded-sm border border-[#404040] bg-[#303030] py-2 pl-10 pr-4 text-sm text-[#e5e5e5] outline-none transition placeholder:text-[#a3a3a3] focus:border-[#3b82f6] focus:ring-2 focus:ring-[#3b82f6]/20" 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)} onChange={(event) => setQuickSearch(event.target.value)}
placeholder="Buscar paciente, prontuário..." placeholder="Buscar paciente, prontuario..."
value={quickSearch} value={quickSearch}
/> />
</div> </div>
@@ -155,14 +176,14 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
type="button" type="button"
> >
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]"> <span className="grid size-8 shrink-0 place-items-center rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/15 text-xs font-bold text-[#3b82f6]">
HC {getInitials(viewerProfile.name)}
</span> </span>
<span className="hidden min-w-0 sm:block"> <span className="hidden min-w-0 sm:block">
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]"> <span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
Dr. Henrique Cardoso {viewerProfile.name}
</span> </span>
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]"> <span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
Médico(a) {viewerProfile.role}
</span> </span>
</span> </span>
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" /> <ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
@@ -335,3 +356,13 @@ function SearchIcon({ className = 'size-4' }) {
</svg> </svg>
) )
} }
function getInitials(name) {
return String(name || 'US')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}

View File

@@ -0,0 +1,100 @@
import React from 'react'
import { format, isToday } from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { sortAppointmentsByTime } from '../../utils/agendaDate.js'
export function AgendaDailyView({ baseDate, appointments, onAppointmentClick }) {
const dailyAppointments = sortAppointmentsByTime(appointments)
return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="flex flex-col gap-3 border-b border-[#404040] pb-4 md:flex-row md:items-end md:justify-between">
<div>
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-[#737373]">
Vista ampliada do dia
</span>
<h3 className="mt-2 text-xl font-bold text-[#e5e5e5]">
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
</h3>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-[#404040] bg-[#1f1f1f] px-3 py-1 text-xs font-semibold text-[#a3a3a3]">
{dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'}
</span>
{isToday(baseDate) && (
<span className="rounded-full border border-[#3b82f6]/30 bg-[#3b82f6]/10 px-3 py-1 text-xs font-semibold text-[#93c5fd]">
Hoje
</span>
)}
</div>
</div>
{dailyAppointments.length === 0 ? (
<div className="mt-4 rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
Ajuste o filtro ou altere o período no calendário.
</p>
</div>
) : (
<div className="mt-4 grid gap-3">
{dailyAppointments.map((appointment) => (
<article
key={appointment.id}
className={`grid gap-4 rounded-xl border p-4 md:grid-cols-[96px_1fr_auto] ${getStatusColors(appointment.status)}`}
>
<div>
<p className="text-2xl font-bold leading-none">{appointment.time || '--:--'}</p>
<p className="mt-2 text-[11px] font-semibold uppercase tracking-[0.14em] opacity-80">
{appointment.mode}
</p>
</div>
<div>
<button
className="text-left text-base font-bold transition hover:opacity-85"
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
type="button"
>
{appointment.patient}
</button>
<p className="mt-1 text-sm opacity-90">
{appointment.type} com {appointment.professional}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs font-medium opacity-80">
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.room}</span>
<span className="rounded-full bg-black/15 px-2.5 py-1">{appointment.type}</span>
</div>
</div>
<div className="flex items-start justify-start md:justify-end">
<span className="rounded-full border border-current/20 bg-black/10 px-3 py-1 text-xs font-bold">
{appointment.status}
</span>
</div>
</article>
))}
</div>
)}
</div>
)
}
function getStatusColors(status) {
switch (status) {
case 'Confirmada':
return 'border-[#14532d] bg-[#052e1a] text-[#a7f3d0]'
case 'Em triagem':
return 'border-[#78350f] bg-[#2d1e05] text-[#fde68a]'
case 'Concluida':
case 'Concluída':
return 'border-[#1e3a8a] bg-[#172554] text-[#bfdbfe]'
case 'Cancelada':
return 'border-[#7f1d1d] bg-[#450a0a] text-[#fecaca]'
case 'Aguardando':
default:
return 'border-[#404040] bg-[#1f1f1f] text-[#e5e5e5]'
}
}

View File

@@ -0,0 +1,107 @@
import React from 'react'
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameMonth,
isSameDay,
isToday,
} from 'date-fns'
import { parseLocalDate, sortAppointmentsByTime } from '../../utils/agendaDate.js'
export function AgendaMonthlyView({ baseDate, appointments, onDayClick }) {
const monthStart = startOfMonth(baseDate)
const monthEnd = endOfMonth(monthStart)
const startDate = startOfWeek(monthStart, { weekStartsOn: 0 })
const endDate = endOfWeek(monthEnd, { weekStartsOn: 0 })
const days = eachDayOfInterval({ start: startDate, end: endDate })
const weekDays = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="grid grid-cols-7 gap-px border-b border-[#404040] pb-4">
{weekDays.map((day) => (
<div key={day} className="text-center text-xs font-semibold uppercase tracking-widest text-[#a3a3a3]">
{day}
</div>
))}
</div>
<div className="mt-4 grid grid-cols-7 gap-2">
{days.map((day) => {
const isCurrentMonth = isSameMonth(day, monthStart)
const dayAppointments = sortAppointmentsByTime(
appointments.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
return appointmentDate && isSameDay(appointmentDate, day)
}),
)
return (
<button
key={day.toISOString()}
onClick={() => onDayClick && onDayClick(day)}
className={`flex min-h-[100px] flex-col rounded-xl border p-2 text-left transition hover:border-[#525252] ${
isCurrentMonth
? 'border-[#404040] bg-[#1f1f1f]'
: 'border-transparent bg-transparent opacity-40 hover:opacity-80'
}`}
>
<span
className={`text-sm font-bold ${
isToday(day)
? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#3b82f6] text-white'
: 'text-[#e5e5e5]'
}`}
>
{format(day, 'd')}
</span>
<div className="mt-2 flex w-full flex-col gap-1">
{dayAppointments.slice(0, 3).map((appointment) => (
<div
key={appointment.id}
className="flex items-center gap-1.5 truncate rounded bg-[#303030] px-1.5 py-1 text-[10px] font-semibold text-[#a3a3a3]"
>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${getDotColor(appointment.status)}`} />
<span className="truncate">
{appointment.time} - {appointment.patient}
</span>
</div>
))}
{dayAppointments.length > 3 && (
<span className="text-[10px] font-semibold text-[#3b82f6]">
+ {dayAppointments.length - 3} mais
</span>
)}
</div>
</button>
)
})}
</div>
</div>
)
}
function getDotColor(status) {
switch (status) {
case 'Confirmada':
return 'bg-[#10b981]'
case 'Em triagem':
return 'bg-[#f59e0b]'
case 'Aguardando':
return 'bg-[#a3a3a3]'
case 'Bloqueado':
return 'bg-[#737373]'
default:
return 'bg-[#3b82f6]'
}
}

View File

@@ -0,0 +1,122 @@
import React from 'react'
import {
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameDay,
isToday,
} from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { parseLocalDate, sortAppointmentsByTime } from '../../utils/agendaDate.js'
export function AgendaWeeklyView({ baseDate, appointments, onAppointmentClick }) {
const start = startOfWeek(baseDate, { weekStartsOn: 0 })
const end = endOfWeek(baseDate, { weekStartsOn: 0 })
const days = eachDayOfInterval({ start, end })
const weeklyAppointments = sortAppointmentsByTime(
appointments.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
return appointmentDate && appointmentDate >= start && appointmentDate <= end
}),
)
return (
<div className="rounded-2xl border border-[#404040] bg-[#262626] p-5">
<div className="grid grid-cols-7 gap-4 border-b border-[#404040] pb-4">
{days.map((day) => {
const isWeekend = day.getDay() === 0
return (
<div key={day.toISOString()} className="text-center">
<span
className={`block text-xs font-semibold uppercase tracking-[0.16em] ${
isWeekend ? 'text-[#93c5fd]' : 'text-[#a3a3a3]'
}`}
>
{format(day, 'EEE', { locale: ptBR })}
</span>
<span className={`mt-1 block text-2xl font-bold ${isToday(day) ? 'text-[#3b82f6]' : 'text-[#e5e5e5]'}`}>
{format(day, 'dd')}
</span>
</div>
)
})}
</div>
<div className="mt-4 grid min-h-[400px] grid-cols-7 gap-4">
{days.map((day) => {
const dayAppointments = weeklyAppointments.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
return appointmentDate && isSameDay(appointmentDate, day)
})
return (
<div
key={day.toISOString()}
className="flex h-full flex-col gap-2 rounded-lg border border-[#404040]/50 bg-[#1f1f1f] p-2"
>
{dayAppointments.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<span className="text-center text-xs text-[#737373]">Livre</span>
</div>
) : (
dayAppointments.map((appointment) => (
<button
key={appointment.id}
onClick={() => onAppointmentClick && onAppointmentClick(appointment)}
className={`flex w-full flex-col items-start rounded-md border p-2 text-left shadow-sm transition hover:brightness-110 ${getStatusColors(appointment.status)}`}
>
<div className="mb-1 flex items-center gap-2">
<span className="rounded bg-black/20 px-1.5 py-0.5 text-xs font-bold leading-none">
{appointment.time}
</span>
<span className="truncate text-[10px] font-semibold uppercase tracking-wider opacity-80">
{appointment.mode}
</span>
</div>
<span className="w-full truncate text-xs font-bold leading-tight" title={appointment.patient}>
{appointment.patient}
</span>
<span
className="mt-0.5 w-full truncate text-[10px] font-medium opacity-80"
title={appointment.professional}
>
Dr(a). {appointment.professional?.split(' ')[0]}
</span>
</button>
))
)}
</div>
)
})}
</div>
</div>
)
}
function getStatusColors(status) {
switch (status) {
case 'Confirmada':
return 'border-[#14532d] bg-[#052e1a] text-[#10b981]'
case 'Em triagem':
return 'border-[#78350f] bg-[#2d1e05] text-[#f59e0b]'
case 'Concluida':
case 'Concluída':
return 'border-[#1e3a8a] bg-[#172554] text-[#60a5fa]'
case 'Aguardando':
return 'border-[#404040] bg-[#303030] text-[#e5e5e5]'
case 'Cancelada':
return 'border-[#7f1d1d] bg-[#450a0a] text-[#f87171] opacity-75'
case 'Bloqueado':
return 'border-[#404040] bg-[#1f1f1f] text-[#737373]'
default:
return 'border-[#404040] bg-[#303030] text-[#e5e5e5]'
}
}

210
src/hooks/useAgenda.js Normal file
View File

@@ -0,0 +1,210 @@
import { useState, useEffect, useMemo } from 'react'
import { isSameDay } from 'date-fns'
import { appointmentRepository } from '../repositories/appointmentRepository.js'
import { patientRepository } from '../repositories/patientRepository.js'
import { professionalRepository } from '../repositories/professionalRepository.js'
import { profileRepository } from '../repositories/profileRepository.js'
import { formatLocalDateInput, parseLocalDate, sortAppointmentsByTime } from '../utils/agendaDate.js'
export function useAgenda() {
const [patients, setPatients] = useState([])
const [professionals, setProfessionals] = useState([])
const [currentProfessional, setCurrentProfessional] = useState(null)
const [viewerProfile, setViewerProfile] = useState(null)
const [localAppointments, setLocalAppointments] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [activeView, setActiveView] = useState('Dia')
const [baseDate, setBaseDate] = useState(new Date())
const [status, setStatus] = useState('Todos')
const [modalOpen, setModalOpen] = useState(false)
const [form, setForm] = useState({
patientId: '',
professionalId: '',
type: 'Retorno',
time: '15:30',
mode: 'Teleconsulta',
})
useEffect(() => {
let active = true
async function loadAgendaContext() {
try {
setError('')
const [patientsData, professionalsData, currentProfile] = await Promise.all([
patientRepository.getAll(),
professionalRepository.getAll(),
profileRepository.getCurrentUserProfile(),
])
if (!active) return
const agendaScope = currentProfile?.isDoctor ? 'doctor' : 'global'
const resolvedProfessional = resolveCurrentProfessional(currentProfile, professionalsData)
const initialProfessionalId =
agendaScope === 'doctor'
? resolvedProfessional?.id || ''
: professionalsData?.[0]?.id || ''
setViewerProfile(currentProfile)
setPatients(patientsData || [])
setCurrentProfessional(resolvedProfessional)
setProfessionals(professionalsData || [])
setForm((current) => ({
...current,
patientId: patientsData?.length ? patientsData[0].id : '',
professionalId: initialProfessionalId,
}))
if (agendaScope === 'doctor' && !resolvedProfessional) {
setLocalAppointments([])
setError('Nao foi possivel vincular o medico logado a um profissional da base.')
return
}
const appointmentsData = await appointmentRepository.getAll({
doctorId: agendaScope === 'doctor' ? resolvedProfessional?.id : undefined,
})
if (!active) return
setLocalAppointments(
agendaScope === 'doctor' && resolvedProfessional
? filterAppointmentsByProfessional(appointmentsData || [], resolvedProfessional.id)
: sortAppointmentsByTime(appointmentsData || []),
)
} catch (loadError) {
if (!active) return
console.error(loadError)
setError(loadError.message || 'Erro ao carregar agenda.')
} finally {
if (active) {
setLoading(false)
}
}
}
loadAgendaContext()
return () => {
active = false
}
}, [])
const visibleAppointments = useMemo(() => {
let filtered = localAppointments
if (status !== 'Todos') {
filtered = filtered.filter((appointment) => appointment.status === status)
}
if (activeView === 'Dia') {
filtered = filtered.filter((appointment) => {
if (!appointment.date) return false
const appointmentDate = parseLocalDate(appointment.date)
if (!appointmentDate) return false
return isSameDay(appointmentDate, baseDate)
})
}
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 }))
}
async function handleCreate(event) {
event.preventDefault()
const targetProfessionalId = agendaScope === 'doctor'
? currentProfessional?.id
: form.professionalId
if (!targetProfessionalId) {
alert('Nao foi possivel identificar o profissional da consulta.')
return
}
const dateStr = formatLocalDateInput(baseDate)
try {
const created = await appointmentRepository.create({
patientId: form.patientId,
date: dateStr,
time: form.time,
type: form.type,
mode: form.mode,
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
professionalId: targetProfessionalId,
})
setLocalAppointments((current) => sortAppointmentsByTime([...current, created]))
setModalOpen(false)
} catch (createError) {
alert(createError.message || 'Erro ao criar agendamento.')
}
}
return {
patients,
professionals,
currentProfessional,
viewerProfile,
agendaScope,
loading,
error,
canCreateAppointment,
activeView,
setActiveView,
baseDate,
setBaseDate,
status,
setStatus,
modalOpen,
setModalOpen,
form,
updateForm,
handleCreate,
visibleAppointments,
}
}
function resolveCurrentProfessional(profile, professionals) {
const doctorId = normalizeValue(profile?.doctorId)
const userId = normalizeValue(profile?.id)
const email = normalizeValue(profile?.email)
return (
professionals.find((professional) => normalizeValue(professional.id) === doctorId) ||
professionals.find((professional) => normalizeValue(professional.userId) === userId) ||
professionals.find((professional) => normalizeValue(professional.id) === userId) ||
professionals.find((professional) => normalizeValue(professional.email) === email) ||
null
)
}
function filterAppointmentsByProfessional(appointments, professionalId) {
const normalizedProfessionalId = normalizeValue(professionalId)
return sortAppointmentsByTime(
appointments.filter((appointment) => normalizeValue(appointment.professionalId) === normalizedProfessionalId),
)
}
function normalizeValue(value) {
return String(value || '').trim().toLowerCase()
}

View File

@@ -5,57 +5,95 @@ export const appointmentMapper = {
const patient = apiData.patient || apiData.paciente || apiData.patients || {} const patient = apiData.patient || apiData.paciente || apiData.patients || {}
const professional = apiData.doctor || apiData.medico || apiData.professional || apiData.doctors || {} const professional = apiData.doctor || apiData.medico || apiData.professional || apiData.doctors || {}
// Tratamento de data e hora do campo scheduled_at
let dateStr = apiData.date || apiData.data || apiData.appointment_date || apiData.data_agendamento || ''
let timeStr = apiData.time || apiData.hora || apiData.appointment_time || apiData.horario || ''
if (apiData.scheduled_at) {
const d = new Date(apiData.scheduled_at)
if (!isNaN(d)) {
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
dateStr = `${yyyy}-${mm}-${dd}`
const hh = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
timeStr = `${hh}:${mins}`
}
}
// Tradução de status do banco (inglês) para UI (português)
const statusMap = {
requested: 'Aguardando',
confirmed: 'Confirmada',
checked_in: 'Em triagem',
completed: 'Concluída',
cancelled: 'Cancelada',
}
const rawStatus = (apiData.status || '').toLowerCase()
const mappedStatus = statusMap[rawStatus] || apiData.situacao || 'Aguardando'
// Modalidade
let mode = apiData.mode || apiData.modalidade || apiData.formato || 'Presencial'
if (apiData.appointment_type) {
mode = apiData.appointment_type === 'telemedicina' ? 'Teleconsulta' : 'Presencial'
}
return { return {
id: apiData.id || apiData.agendamento_id, id: apiData.id || apiData.agendamento_id,
patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id, patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id,
professionalId:
apiData.professionalId ||
apiData.doctor_id ||
apiData.medico_id ||
apiData.professional_id ||
professional.id ||
null,
patient: apiData.patientName || apiData.patient_name || patient.full_name || patient.nome || patient.name || 'Paciente', patient: apiData.patientName || apiData.patient_name || patient.full_name || patient.nome || patient.name || 'Paciente',
professional: professional:
apiData.professional || apiData.professional ||
apiData.professionalName || apiData.professionalName ||
apiData.doctor_name || apiData.doctor_name ||
apiData.medico_nome || apiData.medico_nome ||
professional.full_name ||
professional.name || professional.name ||
professional.nome || professional.nome ||
'Medico(a)', 'Medico(a)',
date: apiData.date || apiData.data || apiData.appointment_date || apiData.data_agendamento || '', date: dateStr,
time: apiData.time || apiData.hora || apiData.appointment_time || apiData.horario || '', time: timeStr,
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta', type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
mode: apiData.mode || apiData.modalidade || apiData.formato || 'Presencial', mode: mode,
status: apiData.status || apiData.situacao || 'Aguardando', status: mappedStatus,
room: apiData.room || apiData.sala || apiData.local || 'Consultorio 1', room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
} }
}, },
toApi(uiData, dialect = 'api') { toApi(uiData, dialect = 'api') {
if (dialect === 'supabase') { if (dialect === 'supabase') {
// Monta o scheduled_at no formato ISO assumindo fuso local
const scheduledAt = new Date(`${uiData.date}T${uiData.time}:00`).toISOString()
return { return {
patient_id: uiData.patientId, patient_id: uiData.patientId,
doctor_id: uiData.professionalId || null, doctor_id: uiData.professionalId || null,
appointment_date: uiData.date, scheduled_at: scheduledAt,
appointment_time: uiData.time, appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial',
type: uiData.type, status: uiData.status === 'Confirmada' ? 'confirmed' : 'requested',
mode: uiData.mode, duration_minutes: 30, // Padrao
status: uiData.status || 'Confirmada',
room: uiData.room,
} }
} }
return { return {
patient_id: uiData.patientId, patient_id: uiData.patientId,
paciente_id: uiData.patientId,
doctor_id: uiData.professionalId || null, doctor_id: uiData.professionalId || null,
medico_id: uiData.professionalId || null,
appointment_date: uiData.date, appointment_date: uiData.date,
data: uiData.date,
appointment_time: uiData.time, appointment_time: uiData.time,
hora: uiData.time,
type: uiData.type, type: uiData.type,
tipo: uiData.type,
mode: uiData.mode, mode: uiData.mode,
modalidade: uiData.mode,
status: uiData.status || 'Confirmada', status: uiData.status || 'Confirmada',
room: uiData.room, room: uiData.room,
sala: uiData.room,
} }
}, },
} }

View File

@@ -1,10 +1,22 @@
import { useEffect, useMemo, useState } from 'react' import {
addDays,
subDays,
addWeeks,
subWeeks,
addMonths,
subMonths,
endOfWeek,
format,
startOfWeek,
} from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { appointmentRepository } from '../repositories/appointmentRepository.js' import { FeatureBadge } from '../components/FeatureState.jsx'
import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx'
import { featurePanelClass } from '../components/featureStateStyles.js' import { featurePanelClass } from '../components/featureStateStyles.js'
import { patientRepository } from '../repositories/patientRepository.js' import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
import { professionalRepository } from '../repositories/professionalRepository.js' import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
import { useAgenda } from '../hooks/useAgenda.js'
const statusFilters = [ const statusFilters = [
{ label: 'Todos', value: 'Todos' }, { label: 'Todos', value: 'Todos' },
@@ -13,109 +25,103 @@ const statusFilters = [
{ label: 'Aguardando', value: 'Aguardando' }, { label: 'Aguardando', value: 'Aguardando' },
] ]
const viewFilters = ['Dia', 'Semana', 'Mês'] const viewFilters = [
{ label: 'Dia', value: 'Dia' },
{ label: 'Semana', value: 'Semana' },
{ label: 'Mês', value: 'Mes' },
]
export function AgendaPage({ navigate }) { export function AgendaPage({ navigate }) {
const [patients, setPatients] = useState([]) const {
const [professionals, setProfessionals] = useState([]) patients,
const queue = appointmentRepository.getPredictiveQueueSummary() professionals,
const timeline = appointmentRepository.getTodayTimeline() currentProfessional,
const weekDays = appointmentRepository.getWeekDays() viewerProfile,
const [activeView, setActiveView] = useState('Dia') agendaScope,
const [status, setStatus] = useState('Todos') loading,
const [modalOpen, setModalOpen] = useState(false) error,
const [localAppointments, setLocalAppointments] = useState([]) canCreateAppointment,
const [form, setForm] = useState({ activeView,
patientId: '', setActiveView,
professionalId: '', baseDate,
type: 'Retorno', setBaseDate,
time: '15:30', status,
mode: 'Teleconsulta', setStatus,
}) modalOpen,
setModalOpen,
form,
updateForm,
handleCreate,
visibleAppointments,
} = useAgenda()
useEffect(() => { if (loading) {
Promise.all([ return (
patientRepository.getAll(), <div className="flex h-[50vh] items-center justify-center text-[#a3a3a3]">
appointmentRepository.getAll(), <p>Carregando agenda...</p>
professionalRepository.getAll() </div>
]).then(([patientsData, appointmentsData, professionalsData]) => { )
setPatients(patientsData)
setLocalAppointments(appointmentsData || [])
setProfessionals(professionalsData || [])
setForm((current) => ({
...current,
patientId: patientsData?.length ? patientsData[0].id : '',
professionalId: professionalsData?.length ? professionalsData[0].id : '',
}))
}).catch(e => console.error(e))
}, [])
const visibleAppointments = useMemo(() => {
if (status === 'Todos') {
return localAppointments
} }
return localAppointments.filter((appointment) => appointment.status === status) const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
}, [localAppointments, status]) const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
const isDoctorScope = agendaScope === 'doctor'
function updateForm(field, value) {
setForm((current) => ({ ...current, [field]: value }))
}
async function handleCreate(event) {
event.preventDefault()
// Fallback date and time
const today = new Date().toISOString().split('T')[0]
try {
const created = await appointmentRepository.create({
patientId: form.patientId,
date: today,
time: form.time,
type: form.type,
mode: form.mode,
room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1',
professionalId: form.professionalId,
})
setLocalAppointments((current) => [...current, created])
setModalOpen(false)
} catch(err) {
alert(err.message || 'Erro ao criar agendamento.')
}
}
return ( return (
<div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]"> <div className="mx-auto flex max-w-[1180px] flex-col gap-8 text-[#e5e5e5]">
<FeatureCallout
description="Listagem e criação de agendamentos usam API. Linha do tempo, resumo preditivo e calendário semanal ainda são visuais simulados."
status="partial"
title="Agenda com partes reais e partes mockadas"
/>
<section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <section className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]"> <h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
Agenda Agenda
</h1> </h1>
<p className="mt-2 text-sm leading-5 text-[#a3a3a3]"> <p className="mt-2 text-sm leading-5 text-[#a3a3a3]">
Organize consultas, retornos e teleatendimentos do dia. {isDoctorScope
? `Agenda restrita ao médico logado: ${currentProfessional?.name || viewerProfile?.name || 'Médico atual'}.`
: 'Visualização completa da agenda com todos os médicos.'}
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1 rounded-sm border border-[#404040] bg-[#262626] p-1">
<button
className="grid size-7 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
onClick={() => {
if (activeView === 'Dia') setBaseDate((current) => subDays(current, 1))
if (activeView === 'Semana') setBaseDate((current) => subWeeks(current, 1))
if (activeView === 'Mes') setBaseDate((current) => subMonths(current, 1))
}}
type="button"
>
{'<'}
</button>
<span className="min-w-[160px] text-center text-sm font-semibold text-[#e5e5e5] capitalize">
{activeView === 'Dia' && format(baseDate, "dd 'de' MMM", { locale: ptBR })}
{activeView === 'Semana' &&
`${format(weekStart, 'dd MMM', { locale: ptBR })} - ${format(weekEnd, 'dd MMM', { locale: ptBR })}`}
{activeView === 'Mes' && format(baseDate, 'MMMM yyyy', { locale: ptBR })}
</span>
<button
className="grid size-7 place-items-center rounded-sm text-[#a3a3a3] transition hover:bg-[#303030] hover:text-[#e5e5e5]"
onClick={() => {
if (activeView === 'Dia') setBaseDate((current) => addDays(current, 1))
if (activeView === 'Semana') setBaseDate((current) => addWeeks(current, 1))
if (activeView === 'Mes') setBaseDate((current) => addMonths(current, 1))
}}
type="button"
>
{'>'}
</button>
</div>
<button <button
className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]" className="h-9 rounded-sm border border-[#404040] bg-[#262626] px-4 text-sm font-medium text-[#e5e5e5] transition hover:bg-[#303030]"
onClick={() => setStatus('Todos')} onClick={() => setBaseDate(new Date())}
type="button" type="button"
> >
Hoje Hoje
</button> </button>
<button <button
className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed]" className="h-9 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white shadow-[0_10px_15px_rgba(59,130,246,0.16)] transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373] disabled:shadow-none"
disabled={!canCreateAppointment}
onClick={() => setModalOpen(true)} onClick={() => setModalOpen(true)}
type="button" type="button"
> >
@@ -124,36 +130,29 @@ useEffect(() => {
</div> </div>
</section> </section>
<section className={`grid gap-4 lg:grid-cols-5 ${featurePanelClass('mock')}`}> {error ? (
{weekDays.map((day) => ( <section className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${featurePanelClass('live')}`}>
<button <div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
className={`rounded-2xl border p-4 text-left transition ${ <h2 className="text-base font-bold text-[#fecaca]">Nao foi possivel liberar a agenda</h2>
day.active <p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
? 'border-[#3b82f6] bg-[#3b82f6]/10' <p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
: 'border-[#404040] bg-[#262626] hover:border-[#525252]' Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico.
}`} </p>
key={`${day.label}-${day.day}`} </div>
type="button"
>
<span className="block text-xs font-semibold uppercase tracking-[0.16em] text-[#a3a3a3]">
{day.label}
</span>
<span className="mt-2 block text-[32px] font-bold leading-8 text-[#e5e5e5]">{day.day}</span>
<span className="mt-3 block text-sm text-[#3b82f6]">{day.count} consultas</span>
</button>
))}
</section> </section>
) : (
<section className="grid gap-6 xl:grid-cols-[1.45fr_0.85fr]"> <section className="grid gap-6 xl:grid-cols-1">
<div className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${featurePanelClass('live')}`}> <div className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${featurePanelClass('live')}`}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">Terça, 07 abril</h2> <h2 className="text-base font-bold leading-6 text-[#e5e5e5]">
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
</h2>
<FeatureBadge status="live" /> <FeatureBadge status="live" />
</div> </div>
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]"> <p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros no filtro Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros visíveis
</p> </p>
</div> </div>
@@ -161,15 +160,15 @@ useEffect(() => {
{viewFilters.map((view) => ( {viewFilters.map((view) => (
<button <button
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${ className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
activeView === view activeView === view.value
? 'border-[#3b82f6] bg-[#3b82f6] text-white' ? 'border-[#3b82f6] bg-[#3b82f6] text-white'
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]' : 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
}`} }`}
key={view} key={view.value}
onClick={() => setActiveView(view)} onClick={() => setActiveView(view.value)}
type="button" type="button"
> >
{view} {view.label}
</button> </button>
))} ))}
</div> </div>
@@ -192,74 +191,43 @@ useEffect(() => {
))} ))}
</div> </div>
<div className="mt-6 grid gap-3"> {!isDoctorScope && (
{visibleAppointments.length ? ( <div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
visibleAppointments.map((appointment) => ( Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais.
<AgendaListItem
appointment={appointment}
key={appointment.id}
navigate={navigate}
/>
))
) : (
<div className="rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
Ajuste o filtro ou crie uma consulta mockada para este período.
</p>
</div> </div>
)} )}
<div className="mt-6 grid gap-3">
{activeView === 'Semana' && (
<AgendaWeeklyView
baseDate={baseDate}
appointments={visibleAppointments}
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
/>
)}
{activeView === 'Mes' && (
<AgendaMonthlyView
baseDate={baseDate}
appointments={visibleAppointments}
onDayClick={(day) => {
setBaseDate(day)
setActiveView('Dia')
}}
/>
)}
{activeView === 'Dia' && (
<AgendaDailyView
baseDate={baseDate}
appointments={visibleAppointments}
onAppointmentClick={(appointment) => navigate(`/pacientes/${appointment.patientId}`)}
/>
)}
</div> </div>
</div> </div>
<div className="grid gap-6">
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold text-[#e5e5e5]">Linha do tempo</h2>
<FeatureBadge status="mock" />
</div>
<div className="mt-5 grid gap-1">
{timeline.map((item) => (
<button
className="grid grid-cols-[58px_1fr] gap-4 rounded-md px-2 py-3 text-left transition hover:bg-[#303030]"
disabled={!item.patientId}
key={`${item.hour}-${item.patient}`}
onClick={() => item.patientId && navigate(`/pacientes/${item.patientId}`)}
type="button"
>
<span className="text-sm font-bold text-[#3b82f6]">{item.hour}</span>
<span className="border-l border-[#404040] pl-4">
<span className="block text-sm font-semibold text-[#e5e5e5]">{item.patient}</span>
<span className="mt-1 block text-xs text-[#a3a3a3]">{item.type}</span>
</span>
</button>
))}
</div>
</div>
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-bold text-[#e5e5e5]">Resumo preditivo</h2>
<FeatureBadge status="mock" />
</div>
<div className="mt-5 grid gap-3">
{queue.map((item) => (
<div className="flex items-center justify-between rounded-md bg-[#2a2a2a] px-4 py-3" key={item.label}>
<span className="text-sm font-medium text-[#a3a3a3]">{item.label}</span>
<span className={`text-lg font-bold ${queueTone(item.tone)}`}>{item.value}</span>
</div>
))}
</div>
<button
className="mt-5 h-9 rounded-sm border border-[#404040] bg-[#303030] px-4 text-sm font-semibold text-[#e5e5e5] transition hover:border-[#3b82f6] hover:text-[#3b82f6]"
onClick={() => navigate('/mensagens')}
type="button"
>
Confirmar presenças
</button>
</div>
</div>
</section> </section>
)}
<DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta"> <DarkModal onClose={() => setModalOpen(false)} open={modalOpen} title="Nova consulta">
<form className="grid gap-4" onSubmit={handleCreate}> <form className="grid gap-4" onSubmit={handleCreate}>
@@ -299,15 +267,26 @@ useEffect(() => {
</div> </div>
<DarkField label="Profissional"> <DarkField label="Profissional">
{isDoctorScope ? (
<input
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
disabled
readOnly
value={currentProfessional?.name || 'Médico não vinculado'}
/>
) : (
<select <select
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]" className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
onChange={(event) => updateForm('professionalId', event.target.value)} onChange={(event) => updateForm('professionalId', event.target.value)}
value={form.professionalId} value={form.professionalId}
> >
{professionals.map((professional) => ( {professionals.map((professional) => (
<option key={professional.id} value={professional.id}>{professional.name}</option> <option key={professional.id} value={professional.id}>
{professional.name}
</option>
))} ))}
</select> </select>
)}
</DarkField> </DarkField>
<DarkField label="Tipo de consulta"> <DarkField label="Tipo de consulta">
@@ -327,7 +306,8 @@ useEffect(() => {
Cancelar Cancelar
</button> </button>
<button <button
className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed]" className="h-10 rounded-sm border border-[#3b82f6] bg-[#3b82f6] px-4 text-sm font-semibold text-white transition hover:bg-[#3478ed] disabled:cursor-not-allowed disabled:border-[#404040] disabled:bg-[#303030] disabled:text-[#737373]"
disabled={!canCreateAppointment}
type="submit" type="submit"
> >
Salvar consulta Salvar consulta
@@ -339,37 +319,6 @@ useEffect(() => {
) )
} }
function AgendaListItem({ appointment, navigate }) {
return (
<article className="grid gap-4 rounded-xl border border-[#404040] bg-[#1f1f1f] p-4 md:grid-cols-[72px_1fr_auto]">
<div>
<p className="text-xl font-bold leading-7 text-[#e5e5e5]">{appointment.time}</p>
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-[#737373]">
{appointment.mode}
</p>
</div>
<div>
<button
className="text-left text-base font-bold text-[#e5e5e5] transition hover:text-[#3b82f6]"
onClick={() => navigate(`/pacientes/${appointment.patientId}`)}
type="button"
>
{appointment.patient}
</button>
<p className="mt-1 text-sm text-[#a3a3a3]">
{appointment.type} com {appointment.professional}
</p>
<p className="mt-2 text-xs font-medium text-[#737373]">{appointment.room}</p>
</div>
<div className="flex items-start justify-between gap-3 md:justify-end">
<StatusPill status={appointment.status} />
</div>
</article>
)
}
function DarkField({ children, label }) { function DarkField({ children, label }) {
return ( return (
<label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]"> <label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]">
@@ -403,30 +352,3 @@ function DarkModal({ children, onClose, open, title }) {
</div> </div>
) )
} }
function StatusPill({ status }) {
const classes = {
Confirmada: 'border-[#14532d] bg-[#052e1a] text-[#10b981]',
'Em triagem': 'border-[#78350f] bg-[#2d1e05] text-[#f59e0b]',
Aguardando: 'border-[#404040] bg-[#303030] text-[#a3a3a3]',
Bloqueado: 'border-[#404040] bg-[#303030] text-[#737373]',
}
return (
<span className={`rounded-full border px-3 py-1 text-xs font-bold ${classes[status] || classes.Aguardando}`}>
{status}
</span>
)
}
function queueTone(tone) {
if (tone === 'red') {
return 'text-[#ef4444]'
}
if (tone === 'amber') {
return 'text-[#f59e0b]'
}
return 'text-[#3b82f6]'
}

View File

@@ -2,8 +2,10 @@ import { apiConfig, getAuthenticatedHeaders } from '../config/api.js'
import { appointmentMapper } from '../mappers/appointmentMapper.js' import { appointmentMapper } from '../mappers/appointmentMapper.js'
export const appointmentRepository = { export const appointmentRepository = {
async getAll() { async getAll({ doctorId } = {}) {
const response = await fetch(`${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(full_name)`, { const doctorFilter = doctorId ? `&doctor_id=eq.${encodeURIComponent(doctorId)}` : ''
const response = await fetch(`${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(full_name)${doctorFilter}`, {
headers: getAuthenticatedHeaders() headers: getAuthenticatedHeaders()
}) })
@@ -25,33 +27,5 @@ export const appointmentRepository = {
const data = await response.json() const data = await response.json()
const item = Array.isArray(data) ? data[0] : data const item = Array.isArray(data) ? data[0] : data
return appointmentMapper.toUi(item) return appointmentMapper.toUi(item)
}, }
getTodayTimeline() {
return [
{ hour: '08:00', patient: 'Carla Mendes', type: 'Consulta inicial', status: 'Confirmada', patientId: 'carla-mendes' },
{ hour: '09:30', patient: 'Ana Souza', type: 'Retorno clinico', status: 'Em triagem', patientId: 'ana-souza' },
{ hour: '11:00', patient: 'Diego Alves', type: 'Acompanhamento', status: 'Aguardando', patientId: 'diego-alves' },
{ hour: '14:30', patient: 'Bruno Lima', type: 'Teleconsulta', status: 'Confirmada', patientId: 'bruno-lima' },
{ hour: '16:00', patient: 'Horario protegido', type: 'Revisao de laudos', status: 'Bloqueado', patientId: null },
]
},
getPredictiveQueueSummary() {
return [
{ label: 'Alta prioridade', value: 3, tone: 'red' },
{ label: 'A confirmar', value: 5, tone: 'amber' },
{ label: 'Teleconsultas', value: 6, tone: 'blue' },
]
},
getWeekDays() {
return [
{ label: 'Seg', day: '06', active: false, count: 6 },
{ label: 'Ter', day: '07', active: true, count: 18 },
{ label: 'Qua', day: '08', active: false, count: 12 },
{ label: 'Qui', day: '09', active: false, count: 9 },
{ label: 'Sex', day: '10', active: false, count: 15 },
]
},
} }

View File

@@ -60,7 +60,13 @@ export const authRepository = {
}, },
async getUser() { async getUser() {
const apiResponse = await fetch(apiEndpoint('/informacoes-do-usuario-autenticado'), { const apiEndpoints = [
apiEndpoint('/user-info'),
apiEndpoint('/informacoes-do-usuario-autenticado'),
]
for (const url of apiEndpoints) {
const apiResponse = await fetch(url, {
method: 'GET', method: 'GET',
headers: getAuthenticatedHeaders(), headers: getAuthenticatedHeaders(),
}).catch(() => null) }).catch(() => null)
@@ -72,6 +78,7 @@ export const authRepository = {
if (apiResponse && !shouldFallback(apiResponse)) { if (apiResponse && !shouldFallback(apiResponse)) {
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.')) throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.'))
} }
}
const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, { const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/user`, {
method: 'GET', method: 'GET',

View File

@@ -23,7 +23,9 @@ export const professionalRepository = {
function mapProfessional(doctor) { function mapProfessional(doctor) {
return { return {
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome), 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 || 'Medico(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 || 'Medico(a)',
schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h', schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h',
nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente', nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente',

View File

@@ -5,18 +5,34 @@ import { getResponseError } from './repositoryUtils.js'
export const profileRepository = { export const profileRepository = {
async getCurrentUserProfile() { async getCurrentUserProfile() {
const data = await authRepository.getUser() const data = await authRepository.getUser()
const user = data?.user || data?.usuario || data?.profile || data const profile = data?.profile || data?.perfil || {}
const user = data?.user || data?.usuario || profile || data
const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {} const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {}
const avatarUrl = user?.avatarUrl || user?.avatar_url || meta.avatar_url || meta.picture || '' const permissions = data?.permissions || {}
const roles = Array.isArray(data?.roles) ? data.roles : []
const avatarUrl =
profile?.avatar_url ||
profile?.avatarUrl ||
user?.avatarUrl ||
user?.avatar_url ||
meta.avatar_url ||
meta.picture ||
''
return { return {
id: user?.id || user?.user_id || user?.uid || '', id: profile?.id || user?.id || user?.user_id || user?.uid || '',
email: user?.email || meta.email || '', email: profile?.email || user?.email || meta.email || '',
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 || 'Usuario',
phone: user?.phone || user?.telefone || meta.phone || meta.telefone || '', phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
role: user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema', role: resolveProfileRole({ permissions, roles, user, meta }),
unit: user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista', unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista',
avatarUrl, 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')),
} }
}, },
@@ -72,3 +88,13 @@ function normalizeAvatarResponse(data) {
path: data.path || data.key || '', path: data.path || data.key || '',
} }
} }
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'
}

40
src/utils/agendaDate.js Normal file
View File

@@ -0,0 +1,40 @@
export function parseLocalDate(dateString) {
if (!dateString || typeof dateString !== 'string') return null
const parts = dateString.split('T')[0].split('-')
if (parts.length === 3) {
const [year, month, day] = parts.map(Number)
return new Date(year, month - 1, day)
}
const parsed = new Date(dateString)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
export function formatLocalDateInput(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function getTimeSortValue(timeString) {
const normalized = String(timeString || '').trim()
const match = normalized.match(/^(\d{1,2}):(\d{2})/)
if (!match) return Number.MAX_SAFE_INTEGER
return Number(match[1]) * 60 + Number(match[2])
}
export function sortAppointmentsByTime(appointments) {
return [...appointments].sort((a, b) => {
const difference = getTimeSortValue(a.time) - getTimeSortValue(b.time)
if (difference !== 0) {
return difference
}
return String(a.patient || '').localeCompare(String(b.patient || ''), 'pt-BR')
})
}