From 7199c107f218319005f938c31a784ef9e436c976 Mon Sep 17 00:00:00 2001 From: EdilbertoC Date: Tue, 28 Apr 2026 10:22:54 -0300 Subject: [PATCH 1/5] fix(principal): integra auth, agenda e laudos com a api --- .env.example | 1 + docs/repository-api-audit.md | 109 ++++----- src/App.jsx | 43 +++- src/config/api.js | 79 ++++++- src/mappers/appointmentMapper.js | 61 +++++ src/mappers/reportMapper.js | 73 ++++++ src/pages/AgendaPage.jsx | 64 +++--- src/pages/AuthPages.jsx | 70 ++++-- src/pages/MessagesPage.jsx | 38 +++- src/pages/ProfilePage.jsx | 101 ++++++++- src/pages/ReportsPage.jsx | 92 +++----- src/pages/TeamPage.jsx | 7 +- src/repositories/appointmentRepository.js | 48 +++- src/repositories/authRepository.js | 124 +++++++++++ src/repositories/communicationRepository.js | 44 ++++ src/repositories/patientRepository.js | 81 +++++-- src/repositories/professionalRepository.js | 33 ++- src/repositories/profileRepository.js | 75 ++++++- src/repositories/reportRepository.js | 235 +++++++++++--------- src/repositories/repositoryUtils.js | 74 ++++++ 20 files changed, 1121 insertions(+), 331 deletions(-) create mode 100644 src/mappers/appointmentMapper.js create mode 100644 src/mappers/reportMapper.js create mode 100644 src/repositories/authRepository.js create mode 100644 src/repositories/repositoryUtils.js 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/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/src/App.jsx b/src/App.jsx index 790bdc6..2a9c4ed 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, } } @@ -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/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/mappers/appointmentMapper.js b/src/mappers/appointmentMapper.js new file mode 100644 index 0000000..61e7c38 --- /dev/null +++ b/src/mappers/appointmentMapper.js @@ -0,0 +1,61 @@ +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 || {} + + return { + id: apiData.id || apiData.agendamento_id, + patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id, + 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.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 || '', + 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', + } + }, + + toApi(uiData, dialect = 'api') { + if (dialect === 'supabase') { + 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, + } + } + + 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, + } + }, +} diff --git a/src/mappers/reportMapper.js b/src/mappers/reportMapper.js new file mode 100644 index 0000000..1f9f01f --- /dev/null +++ b/src/mappers/reportMapper.js @@ -0,0 +1,73 @@ +export const reportMapper = { + toUi(apiData) { + if (!apiData) return null + + const patient = apiData.patient || apiData.paciente || apiData.patients || {} + const doctor = apiData.doctor || apiData.medico || apiData.professional || apiData.doctors || {} + const createdAt = apiData.created_at || apiData.createdAt || apiData.data_criacao || apiData.date + const status = normalizeStatus(apiData.status || apiData.situacao) + + return { + id: String(apiData.id || apiData.report_id || apiData.laudo_id), + patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id || '', + patient: apiData.patientName || apiData.patient_name || patient.full_name || patient.nome || patient.name || 'Paciente', + date: createdAt ? new Date(createdAt).toLocaleDateString('pt-BR') : 'Sem data', + doctor: apiData.doctorName || apiData.doctor_name || apiData.medico_nome || doctor.name || doctor.nome || 'Medico(a)', + author: apiData.author || apiData.autor || doctor.name || doctor.nome || 'Medico(a)', + type: apiData.type || apiData.report_type || apiData.tipo || apiData.tipo_laudo || 'Laudo medico', + status, + content: apiData.content || apiData.conteudo || apiData.text || '', + cid: apiData.cid || '', + tags: apiData.tags || [], + verified: apiData.verified ?? apiData.verificado ?? status !== 'rascunho', + showDate: apiData.showDate ?? apiData.exibir_data ?? true, + signDigital: apiData.signDigital ?? apiData.assinatura_digital ?? true, + versions: normalizeVersions(apiData.versions || apiData.versoes), + } + }, + + toApi(uiData, dialect = 'api') { + if (dialect === 'supabase') { + return { + patient_id: uiData.patientId, + report_type: uiData.type, + content: uiData.content, + status: uiData.status, + cid: uiData.cid || null, + } + } + + return { + patient_id: uiData.patientId, + paciente_id: uiData.patientId, + report_type: uiData.type, + tipo: uiData.type, + content: uiData.content, + conteudo: uiData.content, + status: uiData.status, + cid: uiData.cid || null, + } + }, +} + +function normalizeStatus(status) { + if (!status) return 'rascunho' + + const normalized = String(status).toLowerCase() + if (['finalizado', 'liberado', 'assinado'].includes(normalized)) return 'finalizado' + if (['enviado', 'entregue'].includes(normalized)) return 'enviado' + return 'rascunho' +} + +function normalizeVersions(versions) { + if (Array.isArray(versions) && versions.length) return versions + + return [ + { + version: 1, + action: 'Criado', + user: 'Sistema', + summary: 'Registro importado da API', + }, + ] +} diff --git a/src/pages/AgendaPage.jsx b/src/pages/AgendaPage.jsx index 1cace68..b9f3b2c 100644 --- a/src/pages/AgendaPage.jsx +++ b/src/pages/AgendaPage.jsx @@ -16,30 +16,38 @@ const viewFilters = ['Dia', 'Semana', 'Mês'] export function AgendaPage({ navigate }) { const [patients, setPatients] = useState([]) - const professionals = professionalRepository.getAll() + 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(() => appointmentRepository.getAll()) + const [localAppointments, setLocalAppointments] = useState([]) const [form, setForm] = useState({ patientId: '', - professional: professionals[0]?.name || '', + professionalId: '', type: 'Retorno', time: '15:30', mode: 'Teleconsulta', }) useEffect(() => { - patientRepository.getAll().then((data) => { - setPatients(data) + Promise.all([ + patientRepository.getAll(), + appointmentRepository.getAll(), + professionalRepository.getAll() + ]).then(([patientsData, appointmentsData, professionalsData]) => { + setPatients(patientsData) + setLocalAppointments(appointmentsData || []) + setProfessionals(professionalsData || []) + setForm((current) => ({ ...current, - patientId: data[0]?.id || '', + patientId: patientsData?.length ? patientsData[0].id : '', + professionalId: professionalsData?.length ? professionalsData[0].id : '', })) - }) + }).catch(e => console.error(e)) }, []) const visibleAppointments = useMemo(() => { @@ -54,26 +62,28 @@ useEffect(() => { setForm((current) => ({ ...current, [field]: value })) } - function handleCreate(event) { + async 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', + + // 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, - }, - ]) - setModalOpen(false) + room: form.mode === 'Teleconsulta' ? 'Virtual' : 'Consultório 1', + professionalId: form.professionalId, + }) + + setLocalAppointments((current) => [...current, created]) + setModalOpen(false) + } catch(err) { + alert(err.message || 'Erro ao criar agendamento.') + } } return ( @@ -244,7 +254,7 @@ useEffect(() => { > {patients.map((patient) => ( ))} @@ -274,11 +284,11 @@ useEffect(() => { diff --git a/src/pages/AuthPages.jsx b/src/pages/AuthPages.jsx index 02ff71d..ff8d741 100644 --- a/src/pages/AuthPages.jsx +++ b/src/pages/AuthPages.jsx @@ -1,5 +1,7 @@ import { useState } from 'react' +import { authRepository } from '../repositories/authRepository.js' + import { BrandLogo } from '../components/Brand.jsx' import loginClinicImage from '../assets/figma/login-clinic.png' @@ -9,14 +11,26 @@ export function LoginPage({ navigate }) { password: '', }) const [showPassword, setShowPassword] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') function updateField(field, value) { setForm((current) => ({ ...current, [field]: value })) } - function handleSubmit(event) { + async function handleSubmit(event) { event.preventDefault() - navigate('/inicio') + setLoading(true) + setError('') + + try { + await authRepository.login(form) + navigate('/inicio') + } catch (err) { + setError(err.message || 'Erro de autenticação') + } finally { + setLoading(false) + } } return ( @@ -74,6 +88,12 @@ export function LoginPage({ navigate }) {

+ {error && ( +
+ {error} +
+ )} +
@@ -204,29 +225,52 @@ export function RegisterPage({ navigate }) { export function ForgotPasswordPage({ navigate }) { const [sent, setSent] = useState(false) + const [email, setEmail] = useState('recepcao@mediconnect.com') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + async function handleSubmit(event) { + event.preventDefault() + setLoading(true) + setError('') + try { + await authRepository.requestPasswordReset(email) + setSent(true) + } catch (err) { + setError(err.message || 'Erro ao comunicar com o servidor.') + } finally { + setLoading(false) + } + } return ( {sent ? (
- Link de recuperação mockado enviado para o e-mail informado. + Link de recuperação enviado para o e-mail informado. Siga as instruções do link!
) : (
{ - event.preventDefault() - setSent(true) - }} + onSubmit={handleSubmit} > + {error && ( +
+ {error} +
+ )} - + setEmail(e.target.value)} value={email} type="email" /> -
)} diff --git a/src/pages/MessagesPage.jsx b/src/pages/MessagesPage.jsx index 107ccfa..05898fb 100644 --- a/src/pages/MessagesPage.jsx +++ b/src/pages/MessagesPage.jsx @@ -18,6 +18,7 @@ const statusConfig = { const emptyMessage = { patient: '', + phone: '', channel: 'whatsapp', template: 'Lembrete 48h', content: '', @@ -79,6 +80,7 @@ export function MessagesPage() { function openTemplate(template) { setComposer({ patient: '', + phone: '', channel: template.channel, template: template.name, content: template.content, @@ -86,13 +88,33 @@ export function MessagesPage() { setComposerOpen(true) } - function submitMessage(event) { + async function submitMessage(event) { event.preventDefault() if (!composer.patient.trim()) { return } + let smsSent = false + + if (composer.channel === 'sms') { + if (!composer.phone.trim()) { + alert('Informe o telefone para enviar SMS.') + return + } + + try { + await communicationRepository.sendSms({ + patientName: composer.patient.trim(), + phone: composer.phone.trim(), + content: composer.content, + }) + smsSent = true + } catch (e) { + alert('Falha ao disparar SMS: ' + e.message) + } + } + setMessages((current) => [ { id: `local-${Date.now()}`, @@ -100,7 +122,7 @@ export function MessagesPage() { channel: composer.channel, template: composer.template.trim() || 'Mensagem avulsa', sentAt: 'Agora', - status: 'pendente', + status: composer.channel === 'sms' ? (smsSent ? 'entregue' : 'falha') : 'pendente', response: '', }, ...current, @@ -300,6 +322,7 @@ export function MessagesPage() { onClick={() => { setComposer({ patient: campaign.count, + phone: '', channel: 'whatsapp', template: campaign.title, content: campaign.desc, @@ -470,6 +493,17 @@ function MessageComposer({ draft, onChange, onClose, onSubmit, templates }) { + {draft.channel === 'sms' ? ( + + update('phone', event.target.value)} + placeholder="(81) 99999-9999" + value={draft.phone} + /> + + ) : null} + + {avatarError ?

{avatarError}

: null} @@ -79,10 +142,18 @@ export function ProfilePage() { @@ -106,3 +177,13 @@ function Info({ label, value }) { ) } + +function initials(name) { + return String(name || 'US') + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase() +} diff --git a/src/pages/ReportsPage.jsx b/src/pages/ReportsPage.jsx index 93749d6..3c98582 100644 --- a/src/pages/ReportsPage.jsx +++ b/src/pages/ReportsPage.jsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { reportRepository } from '../repositories/reportRepository.js' +import { patientRepository } from '../repositories/patientRepository.js' const statusConfig = { @@ -43,7 +44,14 @@ const labelClass = 'mb-1.5 block text-xs font-medium text-[#e5e5e5]' const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm' export function ReportsPage() { - const [reports, setReports] = useState(() => reportRepository.getInitialReports()) + const [reports, setReports] = useState([]) + const [patients, setPatients] = useState([]) + + useEffect(() => { + reportRepository.getInitialReports().then(setReports).catch(console.error) + patientRepository.getAll().then(setPatients).catch(console.error) + }, []) + const [search, setSearch] = useState('') const [filterStatus, setFilterStatus] = useState('') const [openMenuId, setOpenMenuId] = useState(null) @@ -104,64 +112,34 @@ export function ReportsPage() { setEditorOpen(true) } - function saveReport(status) { - if (!editor.patient.trim() || !editor.content.trim()) { - return - } + async function saveReport(status) { + if (!editor.patient.trim() || !editor.content.trim()) return + + try { + const selectedPatient = patients.find(p => p.name === editor.patient || p.full_name === editor.patient) + const patientId = selectedPatient?.id || null - const date = new Date().toLocaleDateString('pt-BR') - setReports((currentReports) => { if (editor.id) { - return currentReports.map((report) => - report.id === editor.id - ? { - ...report, - type: editor.type, - patient: editor.patient, - doctor: editor.doctor, - content: editor.content, - showDate: editor.showDate, - signDigital: editor.signDigital, - status, - versions: [ - ...report.versions, - { - version: report.versions.length + 1, - action: status === 'finalizado' ? 'Liberado' : 'Rascunho', - user: currentUser, - summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo', - }, - ], - } - : report, - ) - } - - return [ - { - id: `report-${Date.now()}`, + const updated = await reportRepository.update(editor.id, { type: editor.type, - patient: editor.patient, - doctor: editor.doctor, - date, - status, content: editor.content, - showDate: editor.showDate, - signDigital: editor.signDigital, - versions: [ - { version: 1, action: 'Criado', user: currentUser, summary: 'Laudo criado localmente' }, - { - version: 2, - action: status === 'finalizado' ? 'Liberado' : 'Rascunho', - user: currentUser, - summary: status === 'finalizado' ? 'Laudo liberado' : 'Rascunho salvo', - }, - ], - }, - ...currentReports, - ] - }) - setEditorOpen(false) + patientId: patientId, + status, + }) + setReports(curr => curr.map(r => r.id == updated.id ? { ...updated, status } : r)) + } else { + const created = await reportRepository.create({ + type: editor.type, + content: editor.content, + patientId: patientId, + status, + }) + setReports(curr => [{ ...created, status }, ...curr]) + } + setEditorOpen(false) + } catch(e) { + alert(e.message || 'Erro ao persistir na Base de Dados') + } } function releaseReport(reportId) { @@ -391,7 +369,7 @@ function ReportRow({ type="button" > - v{report.versions.length} + v{report.versions ? report.versions.length : 1} diff --git a/src/pages/TeamPage.jsx b/src/pages/TeamPage.jsx index 95dc43b..25265f7 100644 --- a/src/pages/TeamPage.jsx +++ b/src/pages/TeamPage.jsx @@ -1,11 +1,16 @@ +import { useState, useEffect } from 'react' import { professionalRepository } from '../repositories/professionalRepository.js' const cardClass = 'rounded-2xl border border-[#404040] bg-[#262626] shadow-sm' export function TeamPage({ navigate }) { - const professionals = professionalRepository.getAll() + const [professionals, setProfessionals] = useState([]) const { slots, weekdays } = professionalRepository.getCoverageMap() + useEffect(() => { + professionalRepository.getAll().then(setProfessionals).catch(console.error) + }, []) + return (
diff --git a/src/repositories/appointmentRepository.js b/src/repositories/appointmentRepository.js index e733ba9..e5f217b 100644 --- a/src/repositories/appointmentRepository.js +++ b/src/repositories/appointmentRepository.js @@ -1,8 +1,50 @@ -import { appointments as mockAppointments } from '../data/mockData.js' +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { appointmentMapper } from '../mappers/appointmentMapper.js' +import { fetchJsonWithFallback, normalizeCollection, normalizeItem } from './repositoryUtils.js' export const appointmentRepository = { - getAll() { - return mockAppointments + async getAll() { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/agendamentos'), + options: { headers: getAuthenticatedHeaders() }, + }, + { + url: `${apiConfig.restUrl}/appointments?select=*,patients(full_name),doctors(name)`, + options: { headers: getAuthenticatedHeaders() }, + }, + ], + 'Erro ao buscar agendamentos.', + ) + + return normalizeCollection(data, ['agendamentos', 'appointments', 'data']).map(appointmentMapper.toUi) + }, + + async create(uiData) { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/agendamentos'), + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(appointmentMapper.toApi(uiData)), + }, + }, + { + url: `${apiConfig.restUrl}/appointments`, + options: { + method: 'POST', + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), + body: JSON.stringify(appointmentMapper.toApi(uiData, 'supabase')), + }, + }, + ], + 'Falha ao criar o agendamento.', + ) + + return appointmentMapper.toUi(normalizeItem(data, ['agendamento', 'appointment', 'data'])) }, getTodayTimeline() { diff --git a/src/repositories/authRepository.js b/src/repositories/authRepository.js new file mode 100644 index 0000000..80cac01 --- /dev/null +++ b/src/repositories/authRepository.js @@ -0,0 +1,124 @@ +import { + apiConfig, + apiEndpoint, + clearAuthSession, + getAnonHeaders, + getAuthenticatedHeaders, + getAuthSession, + hasAuthenticatedSession, + saveAuthSession, +} from '../config/api.js' + +export const authRepository = { + async login({ email, password }) { + const response = await fetch(`${apiConfig.supabaseUrl}/auth/v1/token?grant_type=password`, { + method: 'POST', + headers: getAnonHeaders(), + body: JSON.stringify({ email: email?.trim(), password }), + }) + + if (!response.ok) { + throw new Error(await getResponseError(response, 'Erro de autenticacao.')) + } + + const session = await response.json() + if (!session?.access_token) { + throw new Error('Falha no login. Token nao recebido.') + } + + saveAuthSession(session) + return session + }, + + async requestPasswordReset(email) { + const payload = { email: email?.trim() } + const apiResponse = await fetch(apiEndpoint('/solicitar-reset-de-senha'), { + method: 'POST', + headers: getAnonHeaders(), + body: JSON.stringify(payload), + }).catch(() => null) + + if (apiResponse?.ok) { + return true + } + + if (apiResponse && !shouldFallback(apiResponse)) { + throw new Error(await getResponseError(apiResponse, 'Erro ao solicitar reset de senha.')) + } + + const supabaseResponse = await fetch(`${apiConfig.supabaseUrl}/auth/v1/recover`, { + method: 'POST', + headers: getAnonHeaders(), + body: JSON.stringify(payload), + }) + + if (!supabaseResponse.ok) { + throw new Error(await getResponseError(supabaseResponse, 'Erro ao enviar link de recuperacao.')) + } + + return true + }, + + async getUser() { + const apiResponse = await fetch(apiEndpoint('/informacoes-do-usuario-autenticado'), { + method: 'GET', + headers: getAuthenticatedHeaders(), + }).catch(() => null) + + 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`, { + method: 'GET', + headers: getAuthenticatedHeaders(), + }) + + if (!response.ok) { + throw new Error(await getResponseError(response, 'Erro ao resgatar perfil de usuario.')) + } + + return response.json() + }, + + getSession() { + return getAuthSession() + }, + + isAuthenticated() { + return hasAuthenticatedSession() + }, + + async logout() { + try { + const apiResponse = await fetch(apiEndpoint('/logout'), { + method: 'POST', + headers: getAuthenticatedHeaders(), + }).catch(() => null) + + if (apiResponse?.ok || (apiResponse && !shouldFallback(apiResponse))) return + + await fetch(`${apiConfig.supabaseUrl}/auth/v1/logout`, { + method: 'POST', + headers: getAuthenticatedHeaders(), + }) + } catch { + // A sessao local precisa ser removida mesmo quando o backend nao responde. + } finally { + clearAuthSession() + } + }, +} + +function shouldFallback(response) { + return [404, 405].includes(response.status) +} + +async function getResponseError(response, fallbackMessage) { + const error = await response.json().catch(() => ({})) + return error.error_description || error.msg || error.message || error.error || fallbackMessage +} diff --git a/src/repositories/communicationRepository.js b/src/repositories/communicationRepository.js index d671251..ba42579 100644 --- a/src/repositories/communicationRepository.js +++ b/src/repositories/communicationRepository.js @@ -1,4 +1,42 @@ +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { fetchJsonWithFallback } from './repositoryUtils.js' + export const communicationRepository = { + async sendSms({ patientName, phone, content }) { + const message = `[MediConnect] Ola ${patientName}, ${content}` + const payload = { + telefone: normalizePhone(phone), + phone: normalizePhone(phone), + mensagem: message, + message, + paciente: patientName, + } + + await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/enviar-sms-via-twilio'), + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(payload), + }, + }, + { + url: `${apiConfig.functionsUrl.replace(/\/+$/, '')}/send-sms`, + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(payload), + }, + }, + ], + 'Falha no envio de SMS via Twilio.', + ) + + return true + }, + getCampaigns() { return [ { title: 'Lembretes Anti-Falta', desc: 'Envio automatico 48h e 4h antes', count: '324 pacientes elegiveis' }, @@ -31,3 +69,9 @@ export const communicationRepository = { ] }, } + +function normalizePhone(phone) { + const digits = String(phone || '').replace(/\D/g, '') + if (!digits) return '' + return digits.startsWith('55') ? `+${digits}` : `+55${digits}` +} diff --git a/src/repositories/patientRepository.js b/src/repositories/patientRepository.js index 6d1ecba..b68358a 100644 --- a/src/repositories/patientRepository.js +++ b/src/repositories/patientRepository.js @@ -1,33 +1,22 @@ -import { apiConfig, apiHeaders } from '../config/api.js' +import { apiConfig, getAuthenticatedHeaders } from '../config/api.js' export const patientRepository = { // 1. Listar pacientes async getAll() { - const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: apiHeaders }) + const response = await fetch(`${apiConfig.restUrl}/patients?select=*`, { headers: getAuthenticatedHeaders() }) if (!response.ok) throw new Error('Erro ao buscar pacientes') return response.json() }, async getById(patientId) { const patients = await this.getAll() - return patients.find((p) => String(p.id) === String(patientId)) || null + const patient = patients.find((p) => String(p.id) === String(patientId)) || null + return patient ? mapPatientToDetail(patient) : null }, async getDirectoryRows() { const patients = await this.getAll() - return patients.map((patient) => ({ - ...patient, - name: patient.full_name, - phone: patient.phone_mobile, - detailId: patient.id, - insurance: 'Particular', - city: 'Recife', - state: 'PE', - vip: false, - lastVisitIso: null, - lastVisit: 'Ainda nao houve atendimento', - nextVisit: 'Nenhum atendimento agendado', - })) + return patients.map(mapPatientToDirectory) }, // 2. Criar paciente (direto) @@ -43,7 +32,7 @@ export const patientRepository = { const response = await fetch(`${apiConfig.restUrl}/patients`, { method: 'POST', - headers: { ...apiHeaders, Prefer: 'return=representation' }, + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), body: JSON.stringify(body), }) @@ -69,7 +58,7 @@ export const patientRepository = { const response = await fetch(`${apiConfig.functionsUrl}/create-patient`, { method: 'POST', - headers: apiHeaders, + headers: getAuthenticatedHeaders(), body: JSON.stringify(body), }) @@ -93,7 +82,7 @@ export const patientRepository = { const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, { method: 'PATCH', - headers: { ...apiHeaders, Prefer: 'return=representation' }, + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), body: JSON.stringify(body), }) @@ -105,10 +94,62 @@ export const patientRepository = { async remove(patientId) { const response = await fetch(`${apiConfig.restUrl}/patients?id=eq.${patientId}`, { method: 'DELETE', - headers: apiHeaders, + headers: getAuthenticatedHeaders(), }) if (!response.ok) throw new Error('Erro ao deletar paciente') return true }, } + +function mapPatientToDirectory(patient) { + return { + ...patient, + name: patient.name || patient.full_name || patient.nome || 'Paciente', + phone: patient.phone || patient.phone_mobile || patient.telefone || '', + detailId: patient.id, + insurance: patient.insurance || patient.convenio || 'Particular', + city: patient.city || patient.cidade || 'Recife', + state: patient.state || patient.uf || 'PE', + vip: Boolean(patient.vip), + lastVisitIso: patient.lastVisitIso || patient.last_visit_iso || null, + lastVisit: patient.lastVisit || patient.last_visit || 'Ainda nao houve atendimento', + nextVisit: patient.nextVisit || patient.next_visit || 'Nenhum atendimento agendado', + } +} + +function mapPatientToDetail(patient) { + const directory = mapPatientToDirectory(patient) + + return { + ...directory, + age: patient.age || patient.idade || calculateAge(patient.birth_date), + document: patient.document || patient.cpf || 'CPF nao informado', + plan: directory.insurance, + condition: patient.condition || patient.condicao || 'Sem condicao principal', + status: patient.status || 'Acompanhamento', + risk: patient.risk || patient.risco || 'Baixo', + email: patient.email || '', + address: patient.address || patient.endereco || 'Endereco nao informado', + team: patient.team || patient.equipe || [], + notes: patient.notes || patient.observacoes || [], + exams: patient.exams || patient.exames || [], + } +} + +function calculateAge(birthDate) { + if (!birthDate) return 0 + + const birth = new Date(birthDate) + if (Number.isNaN(birth.getTime())) return 0 + + const today = new Date() + let age = today.getFullYear() - birth.getFullYear() + const monthDiff = today.getMonth() - birth.getMonth() + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { + age -= 1 + } + + return age +} diff --git a/src/repositories/professionalRepository.js b/src/repositories/professionalRepository.js index d52f17e..11242f3 100644 --- a/src/repositories/professionalRepository.js +++ b/src/repositories/professionalRepository.js @@ -1,8 +1,23 @@ -import { professionals as mockProfessionals } from '../data/mockData.js' +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { fetchJsonWithFallback, normalizeCollection } from './repositoryUtils.js' export const professionalRepository = { - getAll() { - return mockProfessionals + async getAll() { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/listar-medicos'), + options: { headers: getAuthenticatedHeaders() }, + }, + { + url: `${apiConfig.restUrl}/doctors`, + options: { headers: getAuthenticatedHeaders() }, + }, + ], + 'Erro ao buscar medicos.', + ) + + return normalizeCollection(data, ['medicos', 'doctors', 'professionals', 'data']).map(mapProfessional) }, getCoverageMap() { @@ -12,3 +27,15 @@ export const professionalRepository = { } }, } + +function mapProfessional(doctor) { + return { + id: String(doctor.id || doctor.medico_id || doctor.user_id || doctor.name || doctor.nome), + name: doctor.name || doctor.nome || doctor.full_name || 'Medico(a)', + role: doctor.specialty || doctor.speciality || doctor.especialidade || doctor.role || 'Medico(a)', + schedule: doctor.schedule || doctor.agenda || doctor.disponibilidade || 'Seg a Sex, 08h as 18h', + nextSlot: doctor.nextSlot || doctor.proximo_horario || doctor.next_slot || 'Consulta pendente', + patients: doctor.patients || doctor.pacientes_ativos || doctor.active_patients || 0, + status: doctor.status || doctor.situacao || 'Disponivel', + } +} diff --git a/src/repositories/profileRepository.js b/src/repositories/profileRepository.js index 8c69ad7..c450f99 100644 --- a/src/repositories/profileRepository.js +++ b/src/repositories/profileRepository.js @@ -1,11 +1,74 @@ +import { authRepository } from './authRepository.js' +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { getResponseError } from './repositoryUtils.js' + export const profileRepository = { - getCurrentUserProfile() { + async getCurrentUserProfile() { + const data = await authRepository.getUser() + const user = data?.user || data?.usuario || data?.profile || data + const meta = user?.user_metadata || user?.metadata || user?.app_metadata || {} + const avatarUrl = user?.avatarUrl || user?.avatar_url || meta.avatar_url || meta.picture || '' + return { - email: 'henrique.cardoso@mediconnect.com.br', - name: 'Dr. Henrique Cardoso', - phone: '(81) 98888-0101', - role: 'Medico Clinico Geral', - unit: 'Clinica Boa Vista', + 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', + avatarUrl, + } + }, + + async updateAvatar(file) { + const profile = await this.getCurrentUserProfile() + const formData = new FormData() + formData.append('avatar', file) + formData.append('file', file) + + const apiResponse = await fetch(apiEndpoint('/upload-avatar'), { + method: 'POST', + headers: getAuthenticatedHeaders({ 'Content-Type': undefined }), + body: formData, + }).catch(() => null) + + if (apiResponse?.ok) { + return normalizeAvatarResponse(await apiResponse.json().catch(() => ({}))) + } + + if (apiResponse && ![404, 405].includes(apiResponse.status)) { + throw new Error(await getResponseError(apiResponse, 'Falha ao enviar avatar.')) + } + + if (!profile.id) { + throw new Error('Nao foi possivel identificar o usuario para enviar o avatar.') + } + + const extension = file.name?.split('.').pop() || 'jpg' + const objectPath = `${profile.id}/avatar.${extension}` + const response = await fetch(`${apiConfig.storageUrl}/object/avatars/${objectPath}`, { + method: 'POST', + headers: getAuthenticatedHeaders({ + 'Content-Type': file.type || 'application/octet-stream', + 'x-upsert': 'true', + }), + body: file, + }) + + if (!response.ok) { + throw new Error(await getResponseError(response, 'Falha ao enviar avatar.')) + } + + return { + avatarUrl: `${apiConfig.storageUrl}/object/public/avatars/${objectPath}`, + path: objectPath, } }, } + +function normalizeAvatarResponse(data) { + return { + avatarUrl: data.avatarUrl || data.avatar_url || data.publicUrl || data.public_url || data.url || '', + path: data.path || data.key || '', + } +} diff --git a/src/repositories/reportRepository.js b/src/repositories/reportRepository.js index b4be4e4..9ad9242 100644 --- a/src/repositories/reportRepository.js +++ b/src/repositories/reportRepository.js @@ -1,121 +1,142 @@ -const reportTypes = [ - 'Atestado Medico', - 'Laudo de Exame', - 'Laudo de Imagem', - 'Relatorio Cirurgico', - 'Declaracao de Acompanhante', - 'Encaminhamento', -] - -const doctors = ['Dra. Ana Silva', 'Dr. Carlos Mendes', 'Dr. Roberto Nunes'] -const currentUser = 'Dra. Ana Silva' -const adminUsers = ['Dr. Roberto Nunes'] +import { apiConfig, apiEndpoint, getAuthenticatedHeaders } from '../config/api.js' +import { reportMapper } from '../mappers/reportMapper.js' +import { fetchJsonWithFallback, normalizeCollection, normalizeItem } from './repositoryUtils.js' export const reportRepository = { - getAdminUsers() { - return adminUsers + async getInitialReports() { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/reports'), + options: { headers: getAuthenticatedHeaders() }, + }, + { + url: `${apiConfig.restUrl}/reports?select=*,patients(full_name),doctors(name)`, + options: { headers: getAuthenticatedHeaders() }, + }, + ], + 'Falha ao buscar laudos da API.', + ) + + return normalizeCollection(data, ['reports', 'relatorios', 'laudos', 'data']).map(reportMapper.toUi) }, - getCurrentUser() { - return currentUser + async create(uiData) { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint('/reports'), + options: { + method: 'POST', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(reportMapper.toApi(uiData)), + }, + }, + { + url: `${apiConfig.restUrl}/reports`, + options: { + method: 'POST', + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), + body: JSON.stringify(reportMapper.toApi(uiData, 'supabase')), + }, + }, + ], + 'Falha ao salvar laudo.', + ) + + return reportMapper.toUi(normalizeItem(data, ['report', 'relatorio', 'laudo', 'data'])) }, - getDoctors() { - return doctors - }, + async update(id, uiData) { + const data = await fetchJsonWithFallback( + [ + { + url: apiEndpoint(`/reports/${id}`), + options: { + method: 'PATCH', + headers: getAuthenticatedHeaders(), + body: JSON.stringify(reportMapper.toApi({ ...uiData, id })), + }, + }, + { + url: apiEndpoint('/reports'), + options: { + method: 'PATCH', + headers: getAuthenticatedHeaders(), + body: JSON.stringify({ id, ...reportMapper.toApi(uiData) }), + }, + }, + { + url: `${apiConfig.restUrl}/reports?id=eq.${id}`, + options: { + method: 'PATCH', + headers: getAuthenticatedHeaders({ Prefer: 'return=representation' }), + body: JSON.stringify(reportMapper.toApi(uiData, 'supabase')), + }, + }, + ], + 'Falha ao atualizar o laudo.', + ) - getInitialReports() { - return [ - { - id: 'report-1', - type: 'Atestado Medico', - patient: 'Carlos Eduardo Santos', - doctor: 'Dra. Ana Silva', - date: '27/03/2026', - status: 'finalizado', - content: 'Atesto que o paciente esteve em consulta medica nesta data, necessitando de repouso por 2 dias.', - showDate: true, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' }, - { version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Ajuste no periodo de repouso' }, - { version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Laudo liberado e finalizado' }, - ], - }, - { - id: 'report-2', - type: 'Laudo de Exame', - patient: 'Mariana Costa', - doctor: 'Dra. Ana Silva', - date: '26/03/2026', - status: 'enviado', - content: 'Laudo referente ao exame de ecocardiograma. Resultado dentro dos parametros normais.', - showDate: true, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Laudo criado' }, - { version: 2, action: 'Editado', user: 'Dra. Ana Silva', summary: 'Adicao da data do exame' }, - { version: 3, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao incluida' }, - { version: 4, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' }, - ], - }, - { - id: 'report-3', - type: 'Relatorio Cirurgico', - patient: 'Fernanda Lima', - doctor: 'Dr. Carlos Mendes', - date: '25/03/2026', - status: 'rascunho', - content: 'Relatorio do procedimento de colecistectomia laparoscopica realizado sob anestesia geral.', - showDate: false, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dr. Carlos Mendes', summary: 'Relatorio criado' }, - { version: 2, action: 'Rascunho', user: 'Dr. Carlos Mendes', summary: 'Detalhamento do procedimento' }, - ], - }, - { - id: 'report-4', - type: 'Declaracao de Acompanhante', - patient: 'Joao Pedro Alves', - doctor: 'Dr. Roberto Nunes', - date: '24/03/2026', - status: 'finalizado', - content: 'Declaro que o acompanhante esteve presente durante todo o periodo de internacao.', - showDate: true, - signDigital: false, - versions: [ - { version: 1, action: 'Criado', user: 'Dr. Roberto Nunes', summary: 'Declaracao criada e liberada' }, - ], - }, - { - id: 'report-5', - type: 'Laudo de Imagem', - patient: 'Roberto Campos', - doctor: 'Dra. Ana Silva', - date: '22/03/2026', - status: 'enviado', - content: 'Ultrassonografia de abdomen total sem achados patologicos relevantes.', - showDate: true, - signDigital: true, - versions: [ - { version: 1, action: 'Criado', user: 'Dra. Ana Silva', summary: 'Laudo criado' }, - { version: 2, action: 'Liberado', user: 'Dra. Ana Silva', summary: 'Conclusao adicionada' }, - { version: 3, action: 'Enviado', user: 'Dr. Roberto Nunes', summary: 'Laudo enviado ao paciente' }, - ], - }, - ] - }, - - getReportTypes() { - return reportTypes + return reportMapper.toUi(normalizeItem(data, ['report', 'relatorio', 'laudo', 'data'])) }, getTemplates() { return [ - { id: 'template-1', name: 'Atestado de Repouso Simples', type: 'Atestado Medico', description: 'Atestado padrao concedendo dias de repouso ao paciente.', content: 'Atesto, para os devidos fins, que o(a) paciente necessita de repouso pelo periodo indicado.' }, - { id: 'template-2', name: 'Laudo de Hemograma', type: 'Laudo de Exame', description: 'Resultado de hemograma completo com interpretacao clinica.', content: 'Laudo de hemograma completo com parametros avaliados e interpretacao clinica.' }, - { id: 'template-3', name: 'Relatorio Cirurgico', type: 'Relatorio Cirurgico', description: 'Relatorio padronizado para procedimento cirurgico.', content: 'Relatorio do procedimento cirurgico, achados, conduta e evolucao imediata.' }, + { + id: 't1', + name: 'Atestado Medico Padrao', + type: 'Atestado Medico', + description: 'Atestado simples para repouso, consulta e CID.', + content: + 'Atesto para os devidos fins que o(a) paciente [NOME DO PACIENTE] esteve em consulta medica nesta data, necessitando de [DIAS] dias de repouso por motivo de saude (CID: [CODIGO]).', + }, + { + id: 't2', + name: 'Encaminhamento Especializado', + type: 'Encaminhamento', + description: 'Encaminhamento para avaliacao de especialidade.', + content: + 'Encaminho o(a) paciente [NOME DO PACIENTE] para avaliacao da especialidade de [ESPECIALIDADE] devido ao quadro clinico de [SINTOMAS/DIAGNOSTICO PREVIO].\n\nConduta mantida ate o momento: [MEDICACOES]', + }, + { + id: 't3', + name: 'Laudo de Evolucao Diaria', + type: 'Evolucao Clinica', + description: 'Modelo para evolucao clinica diaria.', + content: + 'Paciente evolui [BEM/MAL], [COM/SEM] queixas no momento.\nSinais vitais: PA [VALOR], FC [VALOR] bpm, SatO2 [VALOR]%.\nExame fisico: [DESCRICAO].\nConduta: [MANTER/ALTERAR TRATAMENTO OPCOES].', + }, + { + id: 't4', + name: 'Receituario de Uso Continuo', + type: 'Receituario Fixado', + description: 'Lista de medicamentos de uso continuo.', + content: + 'Uso continuo:\n1. [MEDICAMENTO] - [DOSE] - Tomar [POSOLOGIA]\n2. [MEDICAMENTO] - [DOSE] - Tomar [POSOLOGIA]', + }, + ] + }, + + getAdminUsers() { + return ['Dr. Henrique Cardoso', 'Dra. Marina Lopes', 'Dra. Ana Silva'] + }, + + getCurrentUser() { + return 'Dr. Henrique Cardoso' + }, + + getDoctors() { + return ['Dr. Henrique Cardoso', 'Dra. Marina Lopes', 'Dra. Ana Silva', 'Dr. Roberto Santos'] + }, + + getReportTypes() { + return [ + 'Atestado Medico', + 'Encaminhamento', + 'Evolucao Clinica', + 'Receituario Fixado', + 'Laudo de Procedimento', ] }, } diff --git a/src/repositories/repositoryUtils.js b/src/repositories/repositoryUtils.js new file mode 100644 index 0000000..5ade276 --- /dev/null +++ b/src/repositories/repositoryUtils.js @@ -0,0 +1,74 @@ +export async function fetchJsonWithFallback(requests, fallbackMessage) { + let lastResponse = null + let lastError = null + + for (const request of requests) { + let response + + try { + response = await fetch(request.url, request.options) + lastResponse = response + } catch (error) { + lastError = error + continue + } + + if (response.ok) { + return parseJsonResponse(response) + } + + if (!shouldFallback(response)) { + throw new Error(await getResponseError(response, fallbackMessage)) + } + } + + if (lastError && !lastResponse) { + throw new Error(lastError.message || fallbackMessage) + } + + throw new Error(await getResponseError(lastResponse, fallbackMessage)) +} + +export function normalizeCollection(data, keys = []) { + if (Array.isArray(data)) return data + + for (const key of keys) { + if (Array.isArray(data?.[key])) return data[key] + } + + return [] +} + +export function normalizeItem(data, keys = []) { + if (Array.isArray(data)) return data[0] || null + + for (const key of keys) { + if (data?.[key]) return data[key] + } + + return data || null +} + +export async function getResponseError(response, fallbackMessage) { + if (!response) return fallbackMessage + + const error = await response.json().catch(() => ({})) + return error.error_description || error.msg || error.message || error.error || fallbackMessage +} + +function shouldFallback(response) { + return [404, 405].includes(response.status) +} + +async function parseJsonResponse(response) { + if (response.status === 204) return null + + const text = await response.text() + if (!text) return null + + try { + return JSON.parse(text) + } catch { + return { message: text } + } +} From 767f226952be0139f3081fd48bccd08b97840c6c Mon Sep 17 00:00:00 2001 From: EdilbertoC Date: Tue, 28 Apr 2026 10:34:05 -0300 Subject: [PATCH 2/5] feat: destaca dados mockados e WIP no app --- src/components/AppShell.jsx | 4 +++ src/components/FeatureState.jsx | 39 ++++++++++++++++++++++++++++ src/components/featureStateStyles.js | 31 ++++++++++++++++++++++ src/pages/AgendaPage.jsx | 31 +++++++++++++++++----- src/pages/AnalyticsPage.jsx | 7 +++++ src/pages/AuthPages.jsx | 8 ++++++ src/pages/HomePage.jsx | 34 +++++++++++++++++++----- src/pages/MedicalRecordsPage.jsx | 7 +++++ src/pages/MessagesPage.jsx | 14 +++++++--- src/pages/ProfilePage.jsx | 14 +++++++--- src/pages/ReportsPage.jsx | 13 ++++++++-- src/pages/SettingsPage.jsx | 8 ++++++ src/pages/TeamPage.jsx | 17 +++++++++--- src/pages/VisitsPage.jsx | 7 +++++ 14 files changed, 209 insertions(+), 25 deletions(-) create mode 100644 src/components/FeatureState.jsx create mode 100644 src/components/featureStateStyles.js diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index 516ab53..ba2f89d 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react' import { BrandLogo } from './Brand.jsx' +import { FeatureLegend } from './FeatureState.jsx' const navItems = [ { href: '/inicio', label: 'Painel', icon: 'pulse', activePaths: ['/inicio', '/home', '/dashboard'] }, @@ -176,6 +177,9 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
+
+ +
{pageTitle}
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/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/pages/AgendaPage.jsx b/src/pages/AgendaPage.jsx index b9f3b2c..53ec236 100644 --- a/src/pages/AgendaPage.jsx +++ b/src/pages/AgendaPage.jsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState } from 'react' import { appointmentRepository } from '../repositories/appointmentRepository.js' +import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx' +import { featurePanelClass } from '../components/featureStateStyles.js' import { patientRepository } from '../repositories/patientRepository.js' import { professionalRepository } from '../repositories/professionalRepository.js' @@ -88,6 +90,12 @@ useEffect(() => { return (
+ +

@@ -116,7 +124,7 @@ useEffect(() => {

-
+
{weekDays.map((day) => (
-
+
-

Terça, 07 abril

+
+

Terça, 07 abril

+ +

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

@@ -202,8 +213,11 @@ useEffect(() => {
-
-

Linha do tempo

+
+
+

Linha do tempo

+ +
{timeline.map((item) => (
-
-

Resumo preditivo

+
+
+

Resumo preditivo

+ +
{queue.map((item) => (
diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx index 2e16e4f..49e86a5 100644 --- a/src/pages/AnalyticsPage.jsx +++ b/src/pages/AnalyticsPage.jsx @@ -1,5 +1,6 @@ import { useState } from 'react' +import { FeatureCallout } from '../components/FeatureState.jsx' import { analyticsRepository } from '../repositories/analyticsRepository.js' const periods = [ @@ -25,6 +26,12 @@ export function AnalyticsPage() { return (
+ +

Relatórios & Analytics

diff --git a/src/pages/AuthPages.jsx b/src/pages/AuthPages.jsx index ff8d741..463ea5e 100644 --- a/src/pages/AuthPages.jsx +++ b/src/pages/AuthPages.jsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { authRepository } from '../repositories/authRepository.js' import { BrandLogo } from '../components/Brand.jsx' +import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx' import loginClinicImage from '../assets/figma/login-clinic.png' export function LoginPage({ navigate }) { @@ -163,6 +164,7 @@ export function LoginPage({ navigate }) { type="button" > dev · credenciais + @@ -181,6 +183,12 @@ export function RegisterPage({ navigate }) { description="Crie um acesso mockado para navegar pelo ambiente da clínica." title="Criar acesso" > +
{ diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 19855de..c8369a9 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,4 +1,6 @@ import loginClinicImage from '../assets/figma/login-clinic.png' +import { FeatureBadge, FeatureCallout } from '../components/FeatureState.jsx' +import { featurePanelClass } from '../components/featureStateStyles.js' import { homeRepository } from '../repositories/homeRepository.js' export function HomePage({ navigate }) { @@ -6,6 +8,12 @@ export function HomePage({ navigate }) { return (
+ +

@@ -41,14 +49,17 @@ export function HomePage({ navigate }) {

-
+
-

Insights de IA

+
+

Insights de IA

+ +

Evolução de absenteísmo e risco da semana

@@ -65,8 +76,11 @@ export function HomePage({ navigate }) {
-
-

Pacientes de hoje

+
+
+

Pacientes de hoje

+ +
{appointmentsToday.map((item) => (
-
-

Alerta preditivo

+
+
+

Alerta preditivo

+ +

3 pacientes apresentam risco de falta. Recomenda-se confirmar presença antes das 16h.

@@ -102,7 +119,10 @@ export function HomePage({ navigate }) {
-

Relatórios e Análises

+
+

Relatórios e Análises

+ +
- {saved ? Preferências salvas localmente : null} + {saved ? Preferências salvas localmente : null}
-
@@ -155,14 +176,14 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) { type="button" > - HC + {getInitials(viewerProfile.name)} - Dr. Henrique Cardoso + {viewerProfile.name} - Médico(a) + {viewerProfile.role} @@ -335,3 +356,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/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/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 index 61e7c38..4c50e32 100644 --- a/src/mappers/appointmentMapper.js +++ b/src/mappers/appointmentMapper.js @@ -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, } }, } diff --git a/src/pages/AgendaPage.jsx b/src/pages/AgendaPage.jsx index 53ec236..95e28c8 100644 --- a/src/pages/AgendaPage.jsx +++ b/src/pages/AgendaPage.jsx @@ -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 ( +
+

Carregando agenda...

+
+ ) } - 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 (
- -

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

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

Resumo preditivo

- + {!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}`)} + /> + )}
-
- {queue.map((item) => ( -
- {item.label} - {item.value} -
- ))} -
-
-
-
+
+ )} setModalOpen(false)} open={modalOpen} title="Nova consulta">
@@ -299,15 +267,26 @@ useEffect(() => {
- + {isDoctorScope ? ( + + ) : ( + + )} @@ -327,7 +306,8 @@ useEffect(() => { Cancelar -

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

-

{appointment.room}

-
- -
- -
- - ) -} - function DarkField({ children, label }) { return (
) } - -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 ( - - {status} - - ) -} - -function queueTone(tone) { - if (tone === 'red') { - return 'text-[#ef4444]' - } - - if (tone === 'amber') { - return 'text-[#f59e0b]' - } - - return 'text-[#3b82f6]' -} diff --git a/src/repositories/appointmentRepository.js b/src/repositories/appointmentRepository.js index 63e486f..8b1b230 100644 --- a/src/repositories/appointmentRepository.js +++ b/src/repositories/appointmentRepository.js @@ -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 }, - ] - }, + } } diff --git a/src/repositories/authRepository.js b/src/repositories/authRepository.js index 80cac01..bbb3118 100644 --- a/src/repositories/authRepository.js +++ b/src/repositories/authRepository.js @@ -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`, { diff --git a/src/repositories/professionalRepository.js b/src/repositories/professionalRepository.js index f490a9b..171703b 100644 --- a/src/repositories/professionalRepository.js +++ b/src/repositories/professionalRepository.js @@ -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', diff --git a/src/repositories/profileRepository.js b/src/repositories/profileRepository.js index c450f99..ae1c04a 100644 --- a/src/repositories/profileRepository.js +++ b/src/repositories/profileRepository.js @@ -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' +} diff --git a/src/utils/agendaDate.js b/src/utils/agendaDate.js new file mode 100644 index 0000000..bcd1ca3 --- /dev/null +++ b/src/utils/agendaDate.js @@ -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') + }) +} From 77079e173cbd18d86419dfd814719b8d243132b4 Mon Sep 17 00:00:00 2001 From: EdilbertoC Date: Tue, 28 Apr 2026 14:00:14 -0300 Subject: [PATCH 5/5] refactor(principal): remove legenda global do AppShell --- src/App.jsx | 2 +- src/components/AppShell.jsx | 8 +- src/mappers/reportMapper.js | 102 +- src/pages/AgendaPage.jsx | 7 +- src/pages/PatientsPage.jsx | 44 +- src/pages/ReportsPage.jsx | 1346 +++++++++++++------------- src/repositories/reportRepository.js | 176 +--- 7 files changed, 770 insertions(+), 915 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 2a9c4ed..348b3d1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -145,7 +145,7 @@ function resolveRoute(pathname, navigate) { if (pathname === '/laudos') { return { element: , - title: 'Laudos', + title: 'Relatorios medicos', withShell: true, } } diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index 8726804..f44c0c7 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -2,14 +2,13 @@ import { useEffect, useMemo, useState } from 'react' import { profileRepository } from '../repositories/profileRepository.js' import { BrandLogo } from './Brand.jsx' -import { FeatureLegend } from './FeatureState.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: 'Prontuario', icon: 'file' }, - { href: '/laudos', label: 'Laudos', icon: 'clipboard' }, + { href: '/laudos', label: 'Relatorios medicos', icon: 'clipboard' }, { href: '/camunicacao', label: 'Comunicacao', @@ -26,7 +25,7 @@ const titles = { '/dashboard': 'Painel', '/agenda': 'Agenda', '/consultas': 'Consultas', - '/laudos': 'Laudos', + '/laudos': 'Relatorios medicos', '/pacientes': 'Pacientes', '/prontuario': 'Prontuario', '/camunicacao': 'Comunicacao', @@ -198,9 +197,6 @@ export function AppShell({ children, currentPath, navigate, routeTitle }) {
-
- -
{pageTitle}
diff --git a/src/mappers/reportMapper.js b/src/mappers/reportMapper.js index 1f9f01f..c23fb99 100644 --- a/src/mappers/reportMapper.js +++ b/src/mappers/reportMapper.js @@ -2,72 +2,60 @@ export const reportMapper = { toUi(apiData) { if (!apiData) return null - const patient = apiData.patient || apiData.paciente || apiData.patients || {} - const doctor = apiData.doctor || apiData.medico || apiData.professional || apiData.doctors || {} - const createdAt = apiData.created_at || apiData.createdAt || apiData.data_criacao || apiData.date - const status = normalizeStatus(apiData.status || apiData.situacao) - return { - id: String(apiData.id || apiData.report_id || apiData.laudo_id), - patientId: apiData.patientId || apiData.patient_id || apiData.paciente_id || patient.id || '', - patient: apiData.patientName || apiData.patient_name || patient.full_name || patient.nome || patient.name || 'Paciente', - date: createdAt ? new Date(createdAt).toLocaleDateString('pt-BR') : 'Sem data', - doctor: apiData.doctorName || apiData.doctor_name || apiData.medico_nome || doctor.name || doctor.nome || 'Medico(a)', - author: apiData.author || apiData.autor || doctor.name || doctor.nome || 'Medico(a)', - type: apiData.type || apiData.report_type || apiData.tipo || apiData.tipo_laudo || 'Laudo medico', - status, - content: apiData.content || apiData.conteudo || apiData.text || '', - cid: apiData.cid || '', - tags: apiData.tags || [], - verified: apiData.verified ?? apiData.verificado ?? status !== 'rascunho', - showDate: apiData.showDate ?? apiData.exibir_data ?? true, - signDigital: apiData.signDigital ?? apiData.assinatura_digital ?? true, - versions: normalizeVersions(apiData.versions || apiData.versoes), + 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, dialect = 'api') { - if (dialect === 'supabase') { - return { - patient_id: uiData.patientId, - report_type: uiData.type, - content: uiData.content, - status: uiData.status, - cid: uiData.cid || null, - } - } - - return { + toApi(uiData) { + return cleanPayload({ patient_id: uiData.patientId, - paciente_id: uiData.patientId, - report_type: uiData.type, - tipo: uiData.type, - content: uiData.content, - conteudo: uiData.content, - status: uiData.status, - cid: uiData.cid || null, - } + 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) { - if (!status) return 'rascunho' - - const normalized = String(status).toLowerCase() - if (['finalizado', 'liberado', 'assinado'].includes(normalized)) return 'finalizado' - if (['enviado', 'entregue'].includes(normalized)) return 'enviado' - return 'rascunho' + return status === 'draft' ? 'draft' : 'draft' } -function normalizeVersions(versions) { - if (Array.isArray(versions) && versions.length) return versions - - return [ - { - version: 1, - action: 'Criado', - user: 'Sistema', - summary: 'Registro importado da API', - }, - ] +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 95e28c8..635740e 100644 --- a/src/pages/AgendaPage.jsx +++ b/src/pages/AgendaPage.jsx @@ -11,8 +11,6 @@ import { } from 'date-fns' import { ptBR } from 'date-fns/locale' -import { FeatureBadge } from '../components/FeatureState.jsx' -import { featurePanelClass } from '../components/featureStateStyles.js' import { AgendaDailyView } from '../components/calendar/AgendaDailyView.jsx' import { AgendaWeeklyView } from '../components/calendar/AgendaWeeklyView.jsx' import { AgendaMonthlyView } from '../components/calendar/AgendaMonthlyView.jsx' @@ -131,7 +129,7 @@ export function AgendaPage({ navigate }) {
{error ? ( -
+

Nao foi possivel liberar a agenda

{error}

@@ -142,14 +140,13 @@ export function AgendaPage({ navigate }) {
) : (
-
+

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

-

Visualização: {activeView.toLowerCase()} | {visibleAppointments.length} registros visíveis diff --git a/src/pages/PatientsPage.jsx b/src/pages/PatientsPage.jsx index 33a383e..b28ab17 100644 --- a/src/pages/PatientsPage.jsx +++ b/src/pages/PatientsPage.jsx @@ -301,43 +301,43 @@ async function deletePatient(patientId) { ) : null}

- +
- - - - - - - + + + + + + + {paginatedPatients.length ? ( paginatedPatients.map((patient) => ( - - - - - - - + + + + + - + - - - - + + + - - ) } -function ReportEditorModal({ editor, onClose, onSave, preview, setEditor, setPreview }) { - const isValid = editor.patient.trim() && editor.content.trim() +function ReportEditorModal({ editor, onChange, onClose, onSave, patientOptions, professionalOptions, saving }) { + const isValid = Boolean(editor.patientId) + + function updateField(field, value) { + onChange((current) => ({ ...current, [field]: value })) + } return (
@@ -436,102 +479,132 @@ function ReportEditorModal({ editor, onClose, onSave, preview, setEditor, setPre onClick={(event) => event.stopPropagation()} >
-

{editor.id ? 'Editar Laudo' : 'Novo Laudo'}

-
- - -
+

+ {editor.id ? 'Editar relatorio medico' : 'Novo relatorio medico'} +

+
- {preview ? ( -
-
-

{editor.type}

- {editor.showDate ?

{new Date().toLocaleDateString('pt-BR')}

: null} -
-

Paciente: {editor.patient || '-'}

-

Médico(a): {editor.doctor}

-

- {editor.content || 'Nenhum conteúdo inserido.'} -

- {editor.signDigital ? ( -
-

{editor.doctor}

-

Assinatura Digital - MediConnect

-
- ) : null} -
- ) : ( -
-
- - - - - setEditorValue(setEditor, 'patient', event.target.value)} - placeholder="Digite o nome do paciente..." - value={editor.patient} - /> - -
- - updateField('patientId', event.target.value)} value={editor.patientId}> + + {patientOptions.map((patient) => ( + ))} - -
NomeTelefoneCidadeEstadoUltimo atendimentoProximo atendimentoAcoesNomeTelefoneCidadeEstadoUltimo atendimentoProximo atendimentoAcoes
+ {patient.phone}{patient.city}{patient.state}{patient.lastVisit || 'Ainda nao houve atendimento'}{patient.nextVisit || 'Nenhum atendimento agendado'} + {patient.phone}{patient.city}{patient.state}{patient.lastVisit || 'Ainda nao houve atendimento'}{patient.nextVisit || 'Nenhum atendimento agendado'} - +

Relatorios medicos

+

Consulta, criacao e edicao de relatorios medicos.

+
{stats.map((stat) => ( -
-

{stat.label}

-

{stat.value}

+
+
+

{stat.label}

+

{stat.value}

+
))}
-
-
-
- - setSearch(event.target.value)} - placeholder="Buscar por paciente ou tipo..." - value={search} - /> -
- +
+
+ + + + + + + + + + + + + + +
-
- + {error ? ( +
+ {error} +
+ ) : null} + +
+
- - - - - - - + + + + + + + - {filteredReports.length ? ( - filteredReports.map((report) => ( + {loading ? ( + + + + ) : paginatedReports.length ? ( + paginatedReports.map((report) => ( { - setConfirmDelete({ report }) - setDeleteConfirmText('') - setOpenMenuId(null) - }} - onDelivery={() => { - setDeliveryReport(report) - setOpenMenuId(null) - }} onEdit={() => openEdit(report)} - onHistory={() => { - setHistoryReport(report) - setOpenMenuId(null) - }} - onPrint={() => { - window.print() - setOpenMenuId(null) - }} - onRelease={() => { - setConfirmRelease(report) - setOpenMenuId(null) - }} - onSend={() => sendReport(report.id)} - open={openMenuId === report.id} + onView={() => setViewerReport(report)} report={report} - setOpenMenuId={setOpenMenuId} /> )) ) : ( )}
TipoPacienteMédicoDataStatusVersõesAçõesNumeroExamePacienteSolicitanteCriado emStatusAcoes
+ Carregando relatorios medicos... +
- Nenhum laudo encontrado. + Nenhum relatorio encontrado com os filtros atuais.
+ +
+

+ Mostrando {enrichedReports.length ? startIndex + 1 : 0}-{Math.min(startIndex + ITEMS_PER_PAGE, enrichedReports.length)} de{' '} + {enrichedReports.length} relatorios +

+
+ setPage(currentPage - 1)}> + + + {Array.from({ length: totalPages }, (_, index) => index + 1).map((pageNumber) => ( + + ))} + setPage(currentPage + 1)}> + + +
+
- {templatesOpen ? setTemplatesOpen(false)} onUseTemplate={openNew} /> : null} - {historyReport ? setHistoryReport(null)} report={historyReport} /> : null} - {deliveryReport ? setDeliveryReport(null)} report={deliveryReport} /> : null} - {confirmRelease ? ( - setConfirmRelease(null)} - onConfirm={() => releaseReport(confirmRelease.id)} - report={confirmRelease} - /> - ) : null} - {confirmDelete ? ( - setConfirmDelete(null)} - onConfirm={() => deleteReport(confirmDelete.report.id)} - report={confirmDelete.report} - setConfirmText={setDeleteConfirmText} - /> - ) : null} {editorOpen ? ( setEditorOpen(false)} - onSave={saveReport} - preview={preview} - setEditor={setEditor} - setPreview={setPreview} + onSave={handleSave} + patientOptions={patientOptions} + professionalOptions={professionalOptions} + saving={saving} /> ) : null} + + {viewerReport ? ( + setViewerReport(null)} report={viewerReport} /> + ) : null}
) } -function ReportRow({ - onDelete, - onDelivery, - onEdit, - onHistory, - onPrint, - onRelease, - onSend, - open, - report, - setOpenMenuId, -}) { +function ReportRow({ onEdit, onView, report }) { return (
+ {report.orderNumber || '-'}
- - {report.type} + + {report.exam || 'Sem exame'}
{report.patient}{report.doctor}{report.date} + {report.patientName}{report.requestedBy || '-'}{formatDate(report.createdAt)} {statusConfig[report.status].label} - - - - {open ? ( - <> - +
+ + +