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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
@@ -1495,6 +1496,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^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 { FeatureLegend } from './FeatureState.jsx'
|
||||
|
||||
@@ -7,16 +8,16 @@ const navItems = [
|
||||
{ href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] },
|
||||
{ href: '/agenda', label: 'Agenda', icon: 'calendar' },
|
||||
{ href: '/pacientes', label: 'Pacientes', icon: 'users', exact: true },
|
||||
{ href: '/prontuario', label: 'Prontuário', icon: 'file' },
|
||||
{ href: '/prontuario', label: 'Prontuario', icon: 'file' },
|
||||
{ href: '/laudos', label: 'Laudos', icon: 'clipboard' },
|
||||
{
|
||||
href: '/camunicacao',
|
||||
label: 'Comunicação',
|
||||
label: 'Comunicacao',
|
||||
icon: 'message',
|
||||
activePaths: ['/camunicacao', '/comunicacao', '/mensagens'],
|
||||
},
|
||||
{ href: '/relatorios', label: 'Relatórios', icon: 'chart' },
|
||||
{ href: '/configuracoes', label: 'Configurações', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||
{ href: '/relatorios', label: 'Relatorios', icon: 'chart' },
|
||||
{ href: '/configuracoes', label: 'Configuracoes', icon: 'settings', activePaths: ['/configuracoes', '/config'] },
|
||||
]
|
||||
|
||||
const titles = {
|
||||
@@ -27,20 +28,21 @@ const titles = {
|
||||
'/consultas': 'Consultas',
|
||||
'/laudos': 'Laudos',
|
||||
'/pacientes': 'Pacientes',
|
||||
'/prontuario': 'Prontuário',
|
||||
'/camunicacao': 'Comunicação',
|
||||
'/comunicacao': 'Comunicação',
|
||||
'/mensagens': 'Comunicação',
|
||||
'/relatorios': 'Relatórios',
|
||||
'/prontuario': 'Prontuario',
|
||||
'/camunicacao': 'Comunicacao',
|
||||
'/comunicacao': 'Comunicacao',
|
||||
'/mensagens': 'Comunicacao',
|
||||
'/relatorios': 'Relatorios',
|
||||
'/profissionais': 'Profissionais',
|
||||
'/perfil': 'Perfil',
|
||||
'/configuracoes': 'Configurações',
|
||||
'/config': 'Configurações',
|
||||
'/configuracoes': 'Configuracoes',
|
||||
'/config': 'Configuracoes',
|
||||
}
|
||||
|
||||
export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [quickSearch, setQuickSearch] = useState('')
|
||||
const [viewerProfile, setViewerProfile] = useState({ name: 'Usuario', role: 'Usuario do Sistema' })
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
if (currentPath.startsWith('/pacientes/') && routeTitle) {
|
||||
@@ -50,6 +52,25 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
return routeTitle || titles[currentPath] || 'MediConnect'
|
||||
}, [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) {
|
||||
setMenuOpen(false)
|
||||
navigate(path)
|
||||
@@ -96,8 +117,8 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
onClick={() => goTo('/perfil')}
|
||||
type="button"
|
||||
>
|
||||
<p className="truncate text-xs font-semibold text-[#e5e5e5]">Dr. Henrique Cardoso</p>
|
||||
<p className="mt-0.5 truncate text-[11px] leading-4 text-[#a3a3a3]">Médico Clínico Geral</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]">{viewerProfile.role}</p>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -129,7 +150,7 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
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"
|
||||
onChange={(event) => setQuickSearch(event.target.value)}
|
||||
placeholder="Buscar paciente, prontuário..."
|
||||
placeholder="Buscar paciente, prontuario..."
|
||||
value={quickSearch}
|
||||
/>
|
||||
</div>
|
||||
@@ -155,14 +176,14 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
|
||||
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]">
|
||||
HC
|
||||
{getInitials(viewerProfile.name)}
|
||||
</span>
|
||||
<span className="hidden min-w-0 sm:block">
|
||||
<span className="block truncate text-sm font-semibold leading-4 text-[#e5e5e5]">
|
||||
Dr. Henrique Cardoso
|
||||
{viewerProfile.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] font-medium leading-4 text-[#51a2ff]">
|
||||
Médico(a)
|
||||
{viewerProfile.role}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="hidden size-4 text-[#a3a3a3] sm:block" />
|
||||
@@ -335,3 +356,13 @@ function SearchIcon({ className = 'size-4' }) {
|
||||
</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 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 {
|
||||
id: apiData.id || apiData.agendamento_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',
|
||||
professional:
|
||||
apiData.professional ||
|
||||
apiData.professionalName ||
|
||||
apiData.doctor_name ||
|
||||
apiData.medico_nome ||
|
||||
professional.full_name ||
|
||||
professional.name ||
|
||||
professional.nome ||
|
||||
'Medico(a)',
|
||||
date: apiData.date || apiData.data || apiData.appointment_date || apiData.data_agendamento || '',
|
||||
time: apiData.time || apiData.hora || apiData.appointment_time || apiData.horario || '',
|
||||
date: dateStr,
|
||||
time: timeStr,
|
||||
type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta',
|
||||
mode: apiData.mode || apiData.modalidade || apiData.formato || 'Presencial',
|
||||
status: apiData.status || apiData.situacao || 'Aguardando',
|
||||
room: apiData.room || apiData.sala || apiData.local || 'Consultorio 1',
|
||||
mode: mode,
|
||||
status: mappedStatus,
|
||||
room: apiData.room || apiData.sala || apiData.local || 'Consultório 1',
|
||||
}
|
||||
},
|
||||
|
||||
toApi(uiData, dialect = 'api') {
|
||||
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 {
|
||||
patient_id: uiData.patientId,
|
||||
doctor_id: uiData.professionalId || null,
|
||||
appointment_date: uiData.date,
|
||||
appointment_time: uiData.time,
|
||||
type: uiData.type,
|
||||
mode: uiData.mode,
|
||||
status: uiData.status || 'Confirmada',
|
||||
room: uiData.room,
|
||||
scheduled_at: scheduledAt,
|
||||
appointment_type: uiData.mode === 'Teleconsulta' ? 'telemedicina' : 'presencial',
|
||||
status: uiData.status === 'Confirmada' ? 'confirmed' : 'requested',
|
||||
duration_minutes: 30, // Padrao
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
patient_id: uiData.patientId,
|
||||
paciente_id: uiData.patientId,
|
||||
doctor_id: uiData.professionalId || null,
|
||||
medico_id: uiData.professionalId || null,
|
||||
appointment_date: uiData.date,
|
||||
data: uiData.date,
|
||||
appointment_time: uiData.time,
|
||||
hora: uiData.time,
|
||||
type: uiData.type,
|
||||
tipo: uiData.type,
|
||||
mode: uiData.mode,
|
||||
modalidade: uiData.mode,
|
||||
status: uiData.status || 'Confirmada',
|
||||
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, FeatureCallout } from '../components/FeatureState.jsx'
|
||||
import { FeatureBadge } from '../components/FeatureState.jsx'
|
||||
import { featurePanelClass } from '../components/featureStateStyles.js'
|
||||
import { patientRepository } from '../repositories/patientRepository.js'
|
||||
import { professionalRepository } from '../repositories/professionalRepository.js'
|
||||
import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx'
|
||||
import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx'
|
||||
import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx'
|
||||
import { useAgenda } from '../hooks/useAgenda.js'
|
||||
|
||||
const statusFilters = [
|
||||
{ label: 'Todos', value: 'Todos' },
|
||||
@@ -13,109 +25,103 @@ const statusFilters = [
|
||||
{ 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 }) {
|
||||
const [patients, setPatients] = useState([])
|
||||
const [professionals, setProfessionals] = useState([])
|
||||
const queue = appointmentRepository.getPredictiveQueueSummary()
|
||||
const timeline = appointmentRepository.getTodayTimeline()
|
||||
const weekDays = appointmentRepository.getWeekDays()
|
||||
const [activeView, setActiveView] = useState('Dia')
|
||||
const [status, setStatus] = useState('Todos')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [localAppointments, setLocalAppointments] = useState([])
|
||||
const [form, setForm] = useState({
|
||||
patientId: '',
|
||||
professionalId: '',
|
||||
type: 'Retorno',
|
||||
time: '15:30',
|
||||
mode: 'Teleconsulta',
|
||||
})
|
||||
const {
|
||||
patients,
|
||||
professionals,
|
||||
currentProfessional,
|
||||
viewerProfile,
|
||||
agendaScope,
|
||||
loading,
|
||||
error,
|
||||
canCreateAppointment,
|
||||
activeView,
|
||||
setActiveView,
|
||||
baseDate,
|
||||
setBaseDate,
|
||||
status,
|
||||
setStatus,
|
||||
modalOpen,
|
||||
setModalOpen,
|
||||
form,
|
||||
updateForm,
|
||||
handleCreate,
|
||||
visibleAppointments,
|
||||
} = useAgenda()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
patientRepository.getAll(),
|
||||
appointmentRepository.getAll(),
|
||||
professionalRepository.getAll()
|
||||
]).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 }))
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[50vh] items-center justify-center text-[#a3a3a3]">
|
||||
<p>Carregando agenda...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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.')
|
||||
}
|
||||
}
|
||||
const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 })
|
||||
const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 })
|
||||
const isDoctorScope = agendaScope === 'doctor'
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="text-[32px] font-bold leading-8 tracking-[-0.02em] text-[#e5e5e5]">
|
||||
Agenda
|
||||
</h1>
|
||||
<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>
|
||||
</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
|
||||
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"
|
||||
>
|
||||
Hoje
|
||||
</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)}
|
||||
type="button"
|
||||
>
|
||||
@@ -124,142 +130,104 @@ useEffect(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`grid gap-4 lg:grid-cols-5 ${featurePanelClass('mock')}`}>
|
||||
{weekDays.map((day) => (
|
||||
<button
|
||||
className={`rounded-2xl border p-4 text-left transition ${
|
||||
day.active
|
||||
? 'border-[#3b82f6] bg-[#3b82f6]/10'
|
||||
: 'border-[#404040] bg-[#262626] hover:border-[#525252]'
|
||||
}`}
|
||||
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>
|
||||
{error ? (
|
||||
<section className={`rounded-2xl border bg-[#262626] p-5 shadow-[0_1px_3px_rgba(0,0,0,0.2)] ${featurePanelClass('live')}`}>
|
||||
<div className="rounded-xl border border-dashed border-[#7f1d1d] bg-[#2a1111] p-6">
|
||||
<h2 className="text-base font-bold text-[#fecaca]">Nao foi possivel liberar a agenda</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[#fca5a5]">{error}</p>
|
||||
<p className="mt-3 text-sm leading-6 text-[#a3a3a3]">
|
||||
Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{statusFilters.map((filter) => (
|
||||
<button
|
||||
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||
status === filter.value
|
||||
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={filter.value}
|
||||
onClick={() => setStatus(filter.value)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
</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.
|
||||
</section>
|
||||
) : (
|
||||
<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="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]">
|
||||
{format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })}
|
||||
</h2>
|
||||
<FeatureBadge status="live" />
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-5 text-[#a3a3a3]">
|
||||
Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros visíveis
|
||||
</p>
|
||||
</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 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.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 className="mt-5 grid gap-1">
|
||||
{timeline.map((item) => (
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{statusFilters.map((filter) => (
|
||||
<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}`)}
|
||||
className={`h-8 rounded-sm border px-3 text-sm font-semibold transition ${
|
||||
status === filter.value
|
||||
? 'border-[#3b82f6] bg-[#3b82f6]/10 text-[#3b82f6]'
|
||||
: 'border-[#404040] bg-[#303030] text-[#a3a3a3] hover:text-[#e5e5e5]'
|
||||
}`}
|
||||
key={filter.value}
|
||||
onClick={() => setStatus(filter.value)}
|
||||
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>
|
||||
{filter.label}
|
||||
</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" />
|
||||
{!isDoctorScope && (
|
||||
<div className="mt-4 rounded-xl border border-[#404040] bg-[#1f1f1f] px-4 py-3 text-sm text-[#a3a3a3]">
|
||||
Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais.
|
||||
</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 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">
|
||||
<form className="grid gap-4" onSubmit={handleCreate}>
|
||||
@@ -299,15 +267,26 @@ useEffect(() => {
|
||||
</div>
|
||||
|
||||
<DarkField label="Profissional">
|
||||
<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>
|
||||
{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
|
||||
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 label="Tipo de consulta">
|
||||
@@ -327,7 +306,8 @@ useEffect(() => {
|
||||
Cancelar
|
||||
</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"
|
||||
>
|
||||
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 }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm font-semibold text-[#a3a3a3]">
|
||||
@@ -403,30 +352,3 @@ function DarkModal({ children, onClose, open, title }) {
|
||||
</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'
|
||||
|
||||
export const appointmentRepository = {
|
||||
async getAll() {
|
||||
const response = await fetch(`${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(full_name)`, {
|
||||
async getAll({ doctorId } = {}) {
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -25,33 +27,5 @@ export const appointmentRepository = {
|
||||
const data = await response.json()
|
||||
const item = Array.isArray(data) ? data[0] : data
|
||||
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() {
|
||||
const apiResponse = await fetch(apiEndpoint('/informacoes-do-usuario-autenticado'), {
|
||||
method: 'GET',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
}).catch(() => null)
|
||||
const apiEndpoints = [
|
||||
apiEndpoint('/user-info'),
|
||||
apiEndpoint('/informacoes-do-usuario-autenticado'),
|
||||
]
|
||||
|
||||
if (apiResponse?.ok) {
|
||||
return apiResponse.json()
|
||||
}
|
||||
for (const url of apiEndpoints) {
|
||||
const apiResponse = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getAuthenticatedHeaders(),
|
||||
}).catch(() => null)
|
||||
|
||||
if (apiResponse && !shouldFallback(apiResponse)) {
|
||||
throw new Error(await getResponseError(apiResponse, 'Erro ao resgatar perfil de usuario.'))
|
||||
if (apiResponse?.ok) {
|
||||
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`, {
|
||||
|
||||
@@ -23,7 +23,9 @@ export const professionalRepository = {
|
||||
function mapProfessional(doctor) {
|
||||
return {
|
||||
id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome),
|
||||
userId: doctor.user_id || doctor.userId || doctor.usuario_id || doctor.auth_user_id || null,
|
||||
name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)',
|
||||
email: doctor.email || doctor.user_email || doctor.usuario_email || '',
|
||||
role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Medico(a)',
|
||||
schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h',
|
||||
nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente',
|
||||
|
||||
@@ -5,18 +5,34 @@ import { getResponseError } from './repositoryUtils.js'
|
||||
export const profileRepository = {
|
||||
async getCurrentUserProfile() {
|
||||
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 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 {
|
||||
id: user?.id || user?.user_id || user?.uid || '',
|
||||
email: user?.email || meta.email || '',
|
||||
name: user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
|
||||
phone: user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
||||
role: user?.role || user?.cargo || meta.role || meta.cargo || 'Usuario do Sistema',
|
||||
unit: user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista',
|
||||
id: profile?.id || user?.id || user?.user_id || user?.uid || '',
|
||||
email: profile?.email || user?.email || meta.email || '',
|
||||
name: profile?.full_name || user?.name || user?.nome || user?.full_name || meta.full_name || meta.name || 'Usuario',
|
||||
phone: profile?.phone || user?.phone || user?.telefone || meta.phone || meta.telefone || '',
|
||||
role: resolveProfileRole({ permissions, roles, user, meta }),
|
||||
unit: profile?.unit || user?.unit || user?.unidade || meta.unit || meta.unidade || 'Clinica Boa Vista',
|
||||
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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
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