forked from RiseUP/riseup_squad_03
Replace hardcoded user with live profile data
This commit is contained in:
44
docs/ARCHITECTURE.md
Normal file
44
docs/ARCHITECTURE.md
Normal 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
11
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
100
src/components/calendar/AgendaDailyView.jsx
Normal file
100
src/components/calendar/AgendaDailyView.jsx
Normal 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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/components/calendar/AgendaMonthlyView.jsx
Normal file
107
src/components/calendar/AgendaMonthlyView.jsx
Normal 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]'
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/components/calendar/AgendaWeeklyView.jsx
Normal file
122
src/components/calendar/AgendaWeeklyView.jsx
Normal 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
210
src/hooks/useAgenda.js
Normal 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()
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}, [localAppointments, status])
|
|
||||||
|
|
||||||
function updateForm(field, value) {
|
|
||||||
setForm((current) => ({ ...current, [field]: value }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(event) {
|
const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
|
||||||
event.preventDefault()
|
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
|
||||||
|
const isDoctorScope = agendaScope === 'doctor'
|
||||||
// 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,142 +130,104 @@ 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}`}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="block text-xs font-semibold uppercase tracking-[0.16em] text-[#a3a3a3]">
|
|
||||||
{day.label}
|
|
||||||
</span>
|
|
||||||
<span className="mt-2 block text-[32px] font-bold leading-8 text-[#e5e5e5]">{day.day}</span>
|
|
||||||
<span className="mt-3 block text-sm text-[#3b82f6]">{day.count} consultas</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.45fr_0.85fr]">
|
|
||||||
<div className={`rounded-2xl border 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>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">Terça, 07 abril</h2>
|
|
||||||
<FeatureBadge status="live" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
|
|
||||||
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros no filtro
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{viewFilters.map((view) => (
|
|
||||||
<button
|
|
||||||
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
|
||||||
activeView === view
|
|
||||||
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
|
|
||||||
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
|
||||||
}`}
|
|
||||||
key={view}
|
|
||||||
onClick={() => setActiveView(view)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{view}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
) : (
|
||||||
{statusFilters.map((filter) => (
|
<section className="grid gap-6 xl:grid-cols-1">
|
||||||
<button
|
<div className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${featurePanelClass('live')}`}>
|
||||||
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
status === filter.value
|
<div>
|
||||||
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
<h2 className="text-base font-bold leading-6 text-[#e5e5e5]">
|
||||||
}`}
|
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
|
||||||
key={filter.value}
|
</h2>
|
||||||
onClick={() => setStatus(filter.value)}
|
<FeatureBadge status="live" />
|
||||||
type="button"
|
</div>
|
||||||
>
|
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
|
||||||
{filter.label}
|
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros visíveis
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3">
|
|
||||||
{visibleAppointments.length ? (
|
|
||||||
visibleAppointments.map((appointment) => (
|
|
||||||
<AgendaListItem
|
|
||||||
appointment={appointment}
|
|
||||||
key={appointment.id}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-dashed border-[#404040] bg-[#1f1f1f] p-8 text-center">
|
|
||||||
<h3 className="text-base font-bold text-[#e5e5e5]">Nenhum horário encontrado</h3>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[#a3a3a3]">
|
|
||||||
Ajuste o filtro ou crie uma consulta mockada para este período.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
|
{viewFilters.map((view) => (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<button
|
||||||
<h2 className="text-base font-bold text-[#e5e5e5]">Linha do tempo</h2>
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
<FeatureBadge status="mock" />
|
activeView === view.value
|
||||||
|
? 'border-[#3b82f6] bg-[#3b82f6] text-white'
|
||||||
|
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={view.value}
|
||||||
|
onClick={() => setActiveView(view.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{view.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid gap-1">
|
|
||||||
{timeline.map((item) => (
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
{statusFilters.map((filter) => (
|
||||||
<button
|
<button
|
||||||
className="grid grid-cols-[58px_1fr] gap-4 rounded-md px-2 py-3 text-left transition hover:bg-[#303030]"
|
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||||
disabled={!item.patientId}
|
status === filter.value
|
||||||
key={`${item.hour}-${item.patient}`}
|
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||||
onClick={() => item.patientId && navigate(`/pacientes/${item.patientId}`)}
|
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||||
|
}`}
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => setStatus(filter.value)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold text-[#3b82f6]">{item.hour}</span>
|
{filter.label}
|
||||||
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`rounded-2xl border bg-[#262626] p-5 ${featurePanelClass('mock')}`}>
|
{!isDoctorScope && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
||||||
<h2 className="text-base font-bold text-[#e5e5e5]">Resumo preditivo</h2>
|
Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais.
|
||||||
<FeatureBadge status="mock" />
|
</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 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>
|
||||||
</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">
|
||||||
<select
|
{isDoctorScope ? (
|
||||||
className="h-11 rounded-md border border-[#404040] bg-[#303030] px-3 text-sm text-[#e5e5e5] outline-none focus:border-[#3b82f6]"
|
<input
|
||||||
onChange={(event) => updateForm('professionalId', event.target.value)}
|
className="h-11 rounded-md border border-[#404040] bg-[#262626] px-3 text-sm text-[#a3a3a3] outline-none"
|
||||||
value={form.professionalId}
|
disabled
|
||||||
>
|
readOnly
|
||||||
{professionals.map((professional) => (
|
value={currentProfessional?.name || 'Médico não vinculado'}
|
||||||
<option key={professional.id} value={professional.id}>{professional.name}</option>
|
/>
|
||||||
))}
|
) : (
|
||||||
</select>
|
<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('professionalId', event.target.value)}
|
||||||
|
value={form.professionalId}
|
||||||
|
>
|
||||||
|
{professionals.map((professional) => (
|
||||||
|
<option key={professional.id} value={professional.id}>
|
||||||
|
{professional.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</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]'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,17 +60,24 @@ export const authRepository = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async getUser() {
|
async getUser() {
|
||||||
const apiResponse = await fetch(apiEndpoint('/informacoes-do-usuario-autenticado'), {
|
const apiEndpoints = [
|
||||||
method: 'GET',
|
apiEndpoint('/user-info'),
|
||||||
headers: getAuthenticatedHeaders(),
|
apiEndpoint('/informacoes-do-usuario-autenticado'),
|
||||||
}).catch(() => null)
|
]
|
||||||
|
|
||||||
if (apiResponse?.ok) {
|
for (const url of apiEndpoints) {
|
||||||
return apiResponse.json()
|
const apiResponse = await fetch(url, {
|
||||||
}
|
method: 'GET',
|
||||||
|
headers: getAuthenticatedHeaders(),
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
if (apiResponse && !shouldFallback(apiResponse)) {
|
if (apiResponse?.ok) {
|
||||||
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.'))
|
return apiResponse.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiResponse && !shouldFallback(apiResponse)) {
|
||||||
|
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`, {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
40
src/utils/agendaDate.js
Normal 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')
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user