diff --git a/.env.example b/.env.example index e62b46e..1b61f48 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ VITE_SUPABASE_URL=https://yuanqfswhberkoevtmfr.supabase.co +VITE_API_BASE_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1 VITE_SUPABASE_REST_URL=https://yuanqfswhberkoevtmfr.supabase.co/rest/v1 VITE_SUPABASE_FUNCTIONS_URL=https://yuanqfswhberkoevtmfr.supabase.co/functions/v1 VITE_SUPABASE_STORAGE_URL=https://yuanqfswhberkoevtmfr.supabase.co/storage/v1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f0c24f0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/repository-api-audit.md b/docs/repository-api-audit.md index 4025e5b..4943b59 100644 --- a/docs/repository-api-audit.md +++ b/docs/repository-api-audit.md @@ -1,82 +1,49 @@ -# Auditoria dos repositories contra a API +# Auditoria de Implementacao e Mapeamento da API -Fonte da documentacao: https://do5wegrct3.apidog.io/llms.txt +Este documento resume o estado atual da integracao entre o front-end e os endpoints da API. -## Endpoints documentados +## Integrado no front -| Grupo | Metodo | Endpoint | Status no frontend | -| --- | --- | --- | --- | -| Autenticacao | POST | `/auth/v1/token?grant_type=password` | Sem repository dedicado | -| Autenticacao | POST | `/auth/v1/otp` | Sem repository dedicado | -| Autenticacao | POST | `/auth/v1/logout` | Sem repository dedicado | -| Autenticacao | GET | `/auth/v1/user` | Sem repository dedicado; relacionado a `profileRepository` | -| Usuarios | POST | `/delete-user` | Sem repository dedicado | -| Usuarios | POST | `/functions/v1/create-user` | Sem repository dedicado | -| Usuarios | POST | `/functions/v1/create-user-with-password` | Sem repository dedicado | -| Usuarios | POST | `/request-password-reset` | Sem repository dedicado | -| Usuarios | POST | `/functions/v1/user-info` | Sem repository dedicado; relacionado a `profileRepository` | -| Usuarios | POST | `/functions/v1/user-info-by-id` | Sem repository dedicado | -| SMS | POST | `/functions/v1/send-sms` | `communicationRepository` nao implementa chamada real | -| Pacientes | GET | `/rest/v1/patients` | Implementado em `patientRepository.getAll` | -| Pacientes | POST | `/rest/v1/patients` | Implementado em `patientRepository.create` | -| Pacientes | PATCH | `/rest/v1/patients?id=eq.{id}` | Implementado em `patientRepository.update` | -| Pacientes | DELETE | `/rest/v1/patients?id=eq.{id}` | Implementado em `patientRepository.remove` | -| Pacientes | POST | `/functions/v1/create-patient` | Implementado em `patientRepository.createWithValidation` | -| Pacientes | POST | `/functions/v1/register-patient` | Nao implementado | -| Medicos | GET | `/rest/v1/doctors` | `professionalRepository.getAll` usa mock | -| Medicos | POST | `/functions/v1/create-doctor` | Nao implementado | -| Agendamentos | GET | `/rest/v1/appointments` | `appointmentRepository.getAll` usa mock | -| Agendamentos | POST | `/rest/v1/appointments` | Nao implementado | -| Agendamentos | POST | `/functions/v1/get-available-slots` | Nao implementado | -| Disponibilidade | GET | `/rest/v1/doctor_availability` | Nao implementado | -| Disponibilidade | POST | `/rest/v1/doctor_availability` | Nao implementado | -| Disponibilidade | PATCH | `/rest/v1/doctor_availability?id=eq.{id}` | Nao implementado | -| Disponibilidade | DELETE | `/rest/v1/doctor_availability?id=eq.{id}` | Nao implementado | -| Disponibilidade | GET | `/rest/v1/doctor_exceptions` | Nao implementado | -| Disponibilidade | POST | `/rest/v1/doctor_exceptions` | Nao implementado | -| Storage | POST | `/storage/v1/object/avatars/{path}` | Nao implementado | -| Storage | GET | `/storage/v1/object/avatars/{path}` | Nao implementado | -| Reports | GET | `/rest/v1/reports` | `reportRepository.getInitialReports` usa mock | -| Reports | POST | `/rest/v1/reports` | Nao implementado | -| Reports | PATCH | `/rest/v1/reports?id=eq.{id}` | Nao implementado | +- **Autenticacao** + - Login com email e senha via Supabase Auth (`/auth/v1/token`). + - Solicitar reset de senha: tenta `/solicitar-reset-de-senha` e usa `/auth/v1/recover` como fallback. + - Dados do usuario autenticado: tenta `/informacoes-do-usuario-autenticado` e usa `/auth/v1/user` como fallback. + - Logout: tenta `/logout`, usa `/auth/v1/logout` como fallback e sempre limpa a sessao local. -## Repositories ainda nao implementados +- **Pacientes** + - Listar, criar, atualizar e deletar pacientes via Supabase REST. + - Criar paciente com validacao via Edge Function quando disponivel. -| Repository | Metodos atuais | Endpoint equivalente | Observacao | -| --- | --- | --- | --- | -| `analyticsRepository` | `getDashboardData` | Nao documentado | Retorna dados estaticos de KPIs, graficos e pacientes frequentes. | -| `appointmentRepository` | `getAll`, `getTodayTimeline`, `getPredictiveQueueSummary`, `getWeekDays` | Parcial: `GET /rest/v1/appointments`, `POST /rest/v1/appointments`, `POST /functions/v1/get-available-slots` | `getAll` deveria chamar a API; demais metodos sao derivados/visuais e nao aparecem na doc. | -| `communicationRepository` | `getCampaigns`, `getInitialMessages`, `getInitialTemplates` | Parcial: `POST /functions/v1/send-sms` | A API so documenta envio de SMS; nao ha endpoints para campanhas, mensagens ou templates. | -| `homeRepository` | `getDashboardOverview` | Nao documentado | Tela inicial usa agregados estaticos. | -| `medicalRecordRepository` | `getRecordTypes`, `getInitialRecords` | Nao documentado | Nao ha endpoint de prontuarios/medical records na doc atual. | -| `professionalRepository` | `getAll`, `getCoverageMap` | Parcial: `GET /rest/v1/doctors`, `POST /functions/v1/create-doctor` | `getAll` deveria usar doctors; `getCoverageMap` parece derivado de disponibilidade, mas nao bate direto com um endpoint. | -| `profileRepository` | `getCurrentUserProfile` | Parcial: `GET /auth/v1/user`, `POST /functions/v1/user-info` | Retorna perfil fixo; deveria consumir dados do usuario autenticado. | -| `reportRepository` | `getAdminUsers`, `getCurrentUser`, `getDoctors`, `getInitialReports`, `getReportTypes`, `getTemplates` | Parcial: `GET/POST/PATCH /rest/v1/reports` | Lista e metadados sao mockados; nao existem metodos de criar/atualizar usando API. | -| `settingsRepository` | `getIntegrations`, `getSections` | Nao documentado | Configuracoes exibidas sao estaticas. | -| `visitRepository` | `getCareQueue`, `getStages` | Nao documentado | Nao ha endpoint de atendimentos/fila/visits na doc atual. | +- **Agendamentos** + - Listar agendamentos: tenta `GET /agendamentos` e usa Supabase REST `appointments` como fallback. + - Criar agendamento: tenta `POST /agendamentos` e usa Supabase REST `appointments` como fallback. -## Inconsistencias encontradas +- **Laudos Medicos** + - Listar relatorios: tenta `GET /reports` e usa Supabase REST `reports` como fallback. + - Criar relatorio: tenta `POST /reports` e usa Supabase REST `reports` como fallback. + - Atualizar relatorio: tenta `PATCH /reports/{id}`, depois `PATCH /reports`, e usa Supabase REST `reports` como fallback. -- `patientRepository.getById(patientId)` nao bate com um endpoint documentado especifico. Ele chama `getAll()` e filtra em memoria; se a API aceitar filtro Supabase por id, o ideal seria usar `/rest/v1/patients?id=eq.{id}&select=*`, mas isso nao aparece como endpoint proprio na documentacao. -- `patientRepository.getDirectoryRows()` transforma pacientes em campos de UI e preenche `insurance`, `city`, `state`, `vip`, `lastVisit` e `nextVisit` com valores fixos. Esses campos nao estao descritos na resposta de `GET /rest/v1/patients`. -- `patientRepository.create(data)` e `createWithValidation(data)` enviam `created_by` com UUID zerado quando nao informado. A documentacao nao confirma esse fallback; isso pode gerar registro invalido se a API exigir usuario real. -- `patientRepository.createWithValidation(data)` usa a Edge Function documentada (`/functions/v1/create-patient`), mas a API tambem possui o endpoint publico `/functions/v1/register-patient`, ainda sem metodo correspondente. -- `appointmentRepository.getAll()` nao chama `GET /rest/v1/appointments`; usa `mockData`. Alem disso, nao existem metodos para `POST /rest/v1/appointments` nem para `POST /functions/v1/get-available-slots`. -- `professionalRepository.getAll()` nao chama `GET /rest/v1/doctors`; usa `mockData`. Tambem falta metodo para `POST /functions/v1/create-doctor`. -- `reportRepository.getInitialReports()` nao chama `GET /rest/v1/reports`; usa dados estaticos e nomes/status em portugues (`rascunho`, `finalizado`, `enviado`) diferentes dos status documentados (`draft`, `completed`). -- `reportRepository` expoe templates, tipos, usuarios admin e medico atual, mas esses recursos nao aparecem na API documentada de Reports. -- `communicationRepository` tem campanhas, mensagens e templates, mas a documentacao so possui `POST /functions/v1/send-sms`; nao ha equivalencia para listar ou gerenciar esses dados. -- `profileRepository.getCurrentUserProfile()` retorna perfil fixo; deveria ser alinhado com `GET /auth/v1/user` ou `POST /functions/v1/user-info`. -- `homeRepository`, `analyticsRepository`, `settingsRepository`, `visitRepository` e `medicalRecordRepository` nao possuem endpoints equivalentes na documentacao atual. +- **Medicos / Profissionais** + - Listar medicos: tenta `GET /listar-medicos` e usa Supabase REST `doctors` como fallback. -## Configuracao extraida +- **Mensageria** + - Enviar SMS: tenta `POST /enviar-sms-via-twilio` e usa Edge Function `send-sms` como fallback. + - O formulario agora coleta telefone quando o canal selecionado e SMS. -As variaveis reutilizaveis de API foram centralizadas em `src/config/api.js`: +- **Storage** + - Upload de avatar: tenta `/upload-avatar` e usa Supabase Storage no bucket `avatars` como fallback. + - A tela de perfil atualiza a imagem exibida apos upload bem-sucedido. -- `VITE_SUPABASE_URL` -- `VITE_SUPABASE_REST_URL` -- `VITE_SUPABASE_FUNCTIONS_URL` -- `VITE_SUPABASE_STORAGE_URL` -- `VITE_SUPABASE_ANON_KEY` +## Ainda sem endpoint consolidado documentado -O arquivo mantem os valores atuais como fallback para nao quebrar o ambiente local, mas o ideal e configurar esses valores via `.env`. +- Dashboard / Inicio (`HomePage` / `homeRepository.js`). +- Estatisticas e BI (`AnalyticsPage` / `analyticsRepository.js`). +- Prontuarios especificos separados de laudos (`MedicalRecordsPage` / `medicalRecordRepository.js`). +- Consultas isoladas fora de agendamento (`VisitsPage` / `visitRepository.js`). +- Configuracoes gerais do tenant (`SettingsPage` / `settingsRepository.js`). + +## Observacoes + +- `VITE_API_BASE_URL` define a base dos endpoints nomeados da API. Quando nao informado, o front usa `VITE_SUPABASE_FUNCTIONS_URL`. +- Os reposititorios aceitam formatos de resposta comuns como arrays diretos ou objetos com chaves `data`, `reports`, `agendamentos`, `medicos` etc. +- Os fallbacks existem para manter o front funcional em ambientes onde parte das Edge Functions ainda nao foi publicada. diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..2e5402d --- /dev/null +++ b/openapi.json @@ -0,0 +1,4 @@ +{ + "message": "Invalid API key", + "hint": "Only the `service_role` API key can be used for this endpoint." +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 164fbd4..0b60fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8e61c32..2d18423 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "date-fns": "^4.1.0", "react": "^19.2.4", "react-dom": "^19.2.4" }, diff --git a/src/App.jsx b/src/App.jsx index 790bdc6..348b3d1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { authRepository } from './repositories/authRepository.js' + import './App.css' import { AppShell } from './components/AppShell.jsx' import { AgendaPage } from './pages/AgendaPage.jsx' @@ -48,11 +50,16 @@ function App() { }, []) const route = useMemo(() => resolveRoute(location.pathname, navigate), [location.pathname, navigate]) + const isAuthenticated = authRepository.isAuthenticated() if (!route.withShell) { return route.element } + if (!isAuthenticated) { + return + } + return ( {route.element} @@ -119,15 +126,10 @@ function resolveRoute(pathname, navigate) { if (pathname.startsWith('/pacientes/')) { const patientId = pathname.split('/')[2] - const patient = patientRepository.getById(patientId) return { - element: patient ? ( - - ) : ( - - ), - title: patient?.name || 'Paciente nao encontrado', + element: , + title: 'Paciente', withShell: true, } } @@ -143,7 +145,7 @@ function resolveRoute(pathname, navigate) { if (pathname === '/laudos') { return { element: , - title: 'Laudos', + title: 'Relatorios medicos', withShell: true, } } @@ -195,6 +197,33 @@ function resolveRoute(pathname, navigate) { } } +function PatientDetailRoute({ navigate, patientId }) { + const [patient, setPatient] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let active = true + + patientRepository.getById(patientId) + .then((data) => { + if (active) setPatient(data) + }) + .finally(() => { + if (active) setLoading(false) + }) + + return () => { + active = false + } + }, [patientId]) + + if (loading) { + return
Carregando paciente...
+ } + + return patient ? : +} + function readLocation() { return { pathname: normalizePath(window.location.pathname), diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index 516ab53..f44c0c7 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -1,21 +1,22 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { profileRepository } from '../repositories/profileRepository.js' import { BrandLogo } from './Brand.jsx' 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: '/laudos', label: 'Laudos', icon: 'clipboard' }, + { href: '/prontuario', label: 'Prontuario', icon: 'file' }, + { href: '/laudos', label: 'Relatorios medicos', 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 = { @@ -24,22 +25,23 @@ const titles = { '/dashboard': 'Painel', '/agenda': 'Agenda', '/consultas': 'Consultas', - '/laudos': 'Laudos', + '/laudos': 'Relatorios medicos', '/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) { @@ -49,6 +51,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) @@ -95,8 +116,8 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) { onClick={() => goTo('/perfil')} type="button" > -

Dr. Henrique Cardoso

-

Médico Clínico Geral

+

{viewerProfile.name}

+

{viewerProfile.role}

@@ -128,7 +149,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} /> @@ -154,14 +175,14 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) { type="button" > - HC + {getInitials(viewerProfile.name)} - Dr. Henrique Cardoso + {viewerProfile.name} - Médico(a) + {viewerProfile.role} @@ -331,3 +352,13 @@ function SearchIcon({ className = 'size-4' }) { ) } + +function getInitials(name) { + return String(name || 'US') + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase() +} diff --git a/src/components/FeatureState.jsx b/src/components/FeatureState.jsx new file mode 100644 index 0000000..e6acf9e --- /dev/null +++ b/src/components/FeatureState.jsx @@ -0,0 +1,39 @@ +import { featureStateStyles } from './featureStateStyles.js' + +export function FeatureBadge({ className = '', status = 'partial', text }) { + const current = featureStateStyles[status] || featureStateStyles.partial + + return ( + + {text || current.label} + + ) +} + +export function FeatureCallout({ className = '', description, status = 'partial', title }) { + const current = featureStateStyles[status] || featureStateStyles.partial + + return ( +
+
+ + {title ?

{title}

: null} +
+ {description ?

{description}

: null} +
+ ) +} + +export function FeatureLegend() { + return ( +
+ Legenda + + + + +
+ ) +} diff --git a/src/components/calendar/AgendaDailyView.jsx b/src/components/calendar/AgendaDailyView.jsx new file mode 100644 index 0000000..70713e9 --- /dev/null +++ b/src/components/calendar/AgendaDailyView.jsx @@ -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 ( +
+
+
+ + Vista ampliada do dia + +

+ {format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })} +

+
+ +
+ + {dailyAppointments.length} {dailyAppointments.length === 1 ? 'agendamento' : 'agendamentos'} + + {isToday(baseDate) && ( + + Hoje + + )} +
+
+ + {dailyAppointments.length === 0 ? ( +
+

Nenhum horário encontrado

+

+ Ajuste o filtro ou altere o período no calendário. +

+
+ ) : ( +
+ {dailyAppointments.map((appointment) => ( +
+
+

{appointment.time || '--:--'}

+

+ {appointment.mode} +

+
+ +
+ +

+ {appointment.type} com {appointment.professional} +

+
+ {appointment.room} + {appointment.type} +
+
+ +
+ + {appointment.status} + +
+
+ ))} +
+ )} +
+ ) +} + +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]' + } +} diff --git a/src/components/calendar/AgendaMonthlyView.jsx b/src/components/calendar/AgendaMonthlyView.jsx new file mode 100644 index 0000000..e8b2358 --- /dev/null +++ b/src/components/calendar/AgendaMonthlyView.jsx @@ -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 ( +
+
+ {weekDays.map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {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 ( + + ) + })} +
+
+ ) +} + +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]' + } +} diff --git a/src/components/calendar/AgendaWeeklyView.jsx b/src/components/calendar/AgendaWeeklyView.jsx new file mode 100644 index 0000000..304a30c --- /dev/null +++ b/src/components/calendar/AgendaWeeklyView.jsx @@ -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 ( +
+
+ {days.map((day) => { + const isWeekend = day.getDay() === 0 + + return ( +
+ + {format(day, 'EEE', { locale: ptBR })} + + + {format(day, 'dd')} + +
+ ) + })} +
+ +
+ {days.map((day) => { + const dayAppointments = weeklyAppointments.filter((appointment) => { + if (!appointment.date) return false + + const appointmentDate = parseLocalDate(appointment.date) + return appointmentDate && isSameDay(appointmentDate, day) + }) + + return ( +
+ {dayAppointments.length === 0 ? ( +
+ Livre +
+ ) : ( + dayAppointments.map((appointment) => ( + + )) + )} +
+ ) + })} +
+
+ ) +} + +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]' + } +} diff --git a/src/components/featureStateStyles.js b/src/components/featureStateStyles.js new file mode 100644 index 0000000..d53333c --- /dev/null +++ b/src/components/featureStateStyles.js @@ -0,0 +1,31 @@ +export const featureStateStyles = { + live: { + badge: 'border-emerald-500/40 bg-emerald-500/15 text-emerald-300', + panel: 'border-emerald-500/35 bg-emerald-500/8', + title: 'text-emerald-300', + label: 'Integrado', + }, + partial: { + badge: 'border-sky-500/40 bg-sky-500/15 text-sky-300', + panel: 'border-sky-500/35 bg-sky-500/8', + title: 'text-sky-300', + label: 'Parcial', + }, + mock: { + badge: 'border-amber-500/40 bg-amber-500/15 text-amber-300', + panel: 'border-amber-500/35 bg-amber-500/8', + title: 'text-amber-300', + label: 'Mockado', + }, + wip: { + badge: 'border-rose-500/40 bg-rose-500/15 text-rose-300', + panel: 'border-rose-500/35 bg-rose-500/8', + title: 'text-rose-300', + label: 'WIP', + }, +} + +export function featurePanelClass(status = 'partial') { + const current = featureStateStyles[status] || featureStateStyles.partial + return current.panel +} diff --git a/src/config/api.js b/src/config/api.js index 33de4e8..f83434c 100644 --- a/src/config/api.js +++ b/src/config/api.js @@ -1,7 +1,10 @@ const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://yuanqfswhberkoevtmfr.supabase.co' const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ' +const AUTH_SESSION_KEY = 'mediconnect.auth.session' + export const apiConfig = { + apiUrl: import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`, supabaseUrl: SUPABASE_URL, restUrl: import.meta.env.VITE_SUPABASE_REST_URL || `${SUPABASE_URL}/rest/v1`, functionsUrl: import.meta.env.VITE_SUPABASE_FUNCTIONS_URL || `${SUPABASE_URL}/functions/v1`, @@ -9,8 +12,76 @@ export const apiConfig = { anonKey: SUPABASE_ANON_KEY, } -export const apiHeaders = { - apikey: apiConfig.anonKey, - Authorization: `Bearer ${apiConfig.anonKey}`, - 'Content-Type': 'application/json', +export function apiEndpoint(path, baseUrl = apiConfig.apiUrl) { + const normalizedBase = baseUrl.replace(/\/+$/, '') + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${normalizedBase}${normalizedPath}` +} + +export function getAuthSession() { + if (typeof window === 'undefined') return null + const rawSession = window.sessionStorage.getItem(AUTH_SESSION_KEY) + if (!rawSession) return null + + try { + return JSON.parse(rawSession) + } catch { + clearAuthSession() + return null + } +} + +export function saveAuthSession(session) { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session)) + } +} + +export function clearAuthSession() { + if (typeof window !== 'undefined') { + window.sessionStorage.removeItem(AUTH_SESSION_KEY) + } +} + +export function hasAuthenticatedSession() { + const session = getAuthSession() + if (!session?.access_token) return false + + // Validate expiration locally if available + if (session.expires_at && session.expires_at * 1000 <= Date.now()) { + clearAuthSession() + return false + } + + return true +} + +export function getAnonHeaders(extraHeaders = {}) { + return cleanHeaders({ + apikey: apiConfig.anonKey, + 'Content-Type': 'application/json', + ...extraHeaders, + }) +} + +export function getAuthenticatedHeaders(extraHeaders = {}) { + const session = getAuthSession() + const accessToken = session?.access_token + + if (!accessToken) { + throw new Error('Sessão expirada. Faça login novamente.') + } + + return cleanHeaders({ + apikey: apiConfig.anonKey, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...extraHeaders, + }) +} + +function cleanHeaders(headers) { + return Object.fromEntries( + Object.entries(headers).filter(([, value]) => value !== undefined && value !== null), + ) } diff --git a/src/hooks/useAgenda.js b/src/hooks/useAgenda.js new file mode 100644 index 0000000..1d7a09b --- /dev/null +++ b/src/hooks/useAgenda.js @@ -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() +} diff --git a/src/mappers/appointmentMapper.js b/src/mappers/appointmentMapper.js new file mode 100644 index 0000000..4c50e32 --- /dev/null +++ b/src/mappers/appointmentMapper.js @@ -0,0 +1,99 @@ +export const appointmentMapper = { + toUi(apiData) { + if (!apiData) return null + + 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: dateStr, + time: timeStr, + type: apiData.type || apiData.tipo || apiData.tipo_consulta || 'Consulta', + 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, + 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, + 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, + } + }, +} diff --git a/src/mappers/reportMapper.js b/src/mappers/reportMapper.js new file mode 100644 index 0000000..c23fb99 --- /dev/null +++ b/src/mappers/reportMapper.js @@ -0,0 +1,61 @@ +export const reportMapper = { + toUi(apiData) { + if (!apiData) return null + + return { + id: String(apiData.id || ''), + orderNumber: apiData.order_number || '', + patientId: apiData.patient_id || '', + status: normalizeStatus(apiData.status), + exam: apiData.exam || '', + requestedBy: apiData.requested_by || '', + cidCode: apiData.cid_code || '', + diagnosis: apiData.diagnosis || '', + conclusion: apiData.conclusion || '', + contentHtml: apiData.content_html || '', + contentJson: apiData.content_json ?? null, + hideDate: Boolean(apiData.hide_date), + hideSignature: Boolean(apiData.hide_signature), + dueAt: apiData.due_at || '', + createdBy: apiData.created_by || '', + updatedBy: apiData.updated_by || '', + createdAt: apiData.created_at || '', + updatedAt: apiData.updated_at || '', + } + }, + + toApi(uiData) { + return cleanPayload({ + patient_id: uiData.patientId, + status: normalizeApiStatus(uiData.status), + exam: emptyToUndefined(uiData.exam), + requested_by: emptyToUndefined(uiData.requestedBy), + cid_code: emptyToUndefined(uiData.cidCode), + diagnosis: emptyToUndefined(uiData.diagnosis), + conclusion: emptyToUndefined(uiData.conclusion), + content_html: emptyToUndefined(uiData.contentHtml), + content_json: uiData.contentJson === undefined ? undefined : uiData.contentJson, + hide_date: Boolean(uiData.hideDate), + hide_signature: Boolean(uiData.hideSignature), + due_at: emptyToUndefined(uiData.dueAt), + }) + }, +} + +function normalizeStatus(status) { + return status === 'draft' ? 'draft' : 'draft' +} + +function normalizeApiStatus(status) { + return status === 'draft' ? 'draft' : 'draft' +} + +function emptyToUndefined(value) { + return value === '' || value === null ? undefined : value +} + +function cleanPayload(payload) { + return Object.fromEntries( + Object.entries(payload).filter(([, value]) => value !== undefined), + ) +} diff --git a/src/pages/AgendaPage.jsx b/src/pages/AgendaPage.jsx index 1cace68..635740e 100644 --- a/src/pages/AgendaPage.jsx +++ b/src/pages/AgendaPage.jsx @@ -1,8 +1,20 @@ -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 { 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' }, @@ -11,70 +23,47 @@ 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 = professionalRepository.getAll() - 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(() => appointmentRepository.getAll()) - const [form, setForm] = useState({ - patientId: '', - professional: professionals[0]?.name || '', - 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(() => { - patientRepository.getAll().then((data) => { - setPatients(data) - setForm((current) => ({ - ...current, - patientId: data[0]?.id || '', - })) - }) -}, []) - - 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 ( +
+

Carregando agenda...

+
+ ) } - function handleCreate(event) { - event.preventDefault() - const patient = patients.find((item) => item.id === form.patientId) || patients[0] - - setLocalAppointments((current) => [ - ...current, - { - id: `apt-local-${current.length + 1}`, - date: '2026-04-07', - patient: patient.name, - patientId: patient.id, - professional: form.professional, - room: form.mode === 'Teleconsulta' ? 'Sala virtual 3' : 'Sala 02', - status: 'Confirmada', - time: form.time, - type: form.type, - mode: form.mode, - }, - ]) - setModalOpen(false) - } + const weekStart = startOfWeek(baseDate, { weekStartsOn: 0 }) + const weekEnd = endOfWeek(baseDate, { weekStartsOn: 0 }) + const isDoctorScope = agendaScope === 'doctor' return (
@@ -84,20 +73,53 @@ useEffect(() => { Agenda

- 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.'}

-
+
+
+ + + {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 })} + + +
-
- {weekDays.map((day) => ( - - ))} -
- -
-
-
-
-

Terça, 07 abril

-

- Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros no filtro -

-
- -
- {viewFilters.map((view) => ( - - ))} -
+ {error ? ( +
+
+

Nao foi possivel liberar a agenda

+

{error}

+

+ Enquanto esse vinculo nao existir na API, a tela fica bloqueada para evitar exibir consultas de outro medico. +

- -
- {statusFilters.map((filter) => ( - - ))} -
- -
- {visibleAppointments.length ? ( - visibleAppointments.map((appointment) => ( - - )) - ) : ( -
-

Nenhum horário encontrado

-

- Ajuste o filtro ou crie uma consulta mockada para este período. +

+ ) : ( +
+
+
+
+
+

+ {format(baseDate, "EEEE, dd 'de' MMMM", { locale: ptBR })} +

+
+

+ Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros visíveis

- )} -
-
-
-
-

Linha do tempo

-
- {timeline.map((item) => ( +
+ {viewFilters.map((view) => ( + + ))} +
+
+ +
+ {statusFilters.map((filter) => ( ))}
-
-
-

Resumo preditivo

-
- {queue.map((item) => ( -
- {item.label} - {item.value} -
- ))} + {!isDoctorScope && ( +
+ Perfil atual: {viewerProfile?.role || 'Administrador'} | agendamentos exibidos para todos os profissionais. +
+ )} + +
+ {activeView === 'Semana' && ( + navigate(`/pacientes/${appointment.patientId}`)} + /> + )} + + {activeView === 'Mes' && ( + { + setBaseDate(day) + setActiveView('Dia') + }} + /> + )} + + {activeView === 'Dia' && ( + navigate(`/pacientes/${appointment.patientId}`)} + /> + )}
-
-
-
+
+ )} setModalOpen(false)} open={modalOpen} title="Nova consulta">
@@ -244,7 +236,7 @@ useEffect(() => { > {patients.map((patient) => ( ))} @@ -272,15 +264,26 @@ useEffect(() => {
- + {isDoctorScope ? ( + + ) : ( + + )} @@ -300,7 +303,8 @@ useEffect(() => { Cancelar -

- {appointment.type} com {appointment.professional} -

-

{appointment.room}

- - -
- -
- - ) -} - function DarkField({ children, label }) { return (