Compare commits

...

No commits in common. "main" and "fix/postgrest-syntax-clean" have entirely different histories.

318 changed files with 15544 additions and 49669 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.zip filter=lfs diff=lfs merge=lfs -text
*.rar filter=lfs diff=lfs merge=lfs -text

View File

@ -1,23 +0,0 @@
name: Notification Worker Cron
on:
schedule:
# Executa a cada 5 minutos
- cron: "*/5 * * * *"
workflow_dispatch: # Permite execução manual
jobs:
process-notifications:
runs-on: ubuntu-latest
steps:
- name: Process notification queue
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}" \
-H "Content-Type: application/json" \
https://etblfypcxxtvvuqjkrgd.supabase.co/functions/v1/notifications-worker
continue-on-error: true
- name: Log completion
run: echo "Notification worker completed at $(date)"

58
.gitignore vendored
View File

@ -1,58 +1,2 @@
############################################################
# Projeto MediConnect - Ignore Rules
############################################################
# Dependências
node_modules/
# Builds / Output
dist/
build/
# Ambiente / Segredos
.env
.env.*.local
.env.local
.env.development.local
.env.production.local
.env.test.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
logs/
*.log
# Editor / SO
.DS_Store
Thumbs.db
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
# Coverage / Tests
coverage/
*.lcov
# Cache ferramentas
.eslintcache
.tsbuildinfo
*.tsbuildinfo
# Netlify local folder
# Local Netlify folder
.netlify
# Storybook / Docs temporários
storybook-static/
# Tailwind JIT artifacts (se surgir)
*.tailwind.config.js.timestamp
# Puppeteer downloads (caso configurado)
.local-chromium/
# Lockfiles alternativos (se decidir usar apenas pnpm)
package-lock.json
yarn.lock

View File

@ -1,322 +0,0 @@
# 📋 ANÁLISE COMPLETA DO ROADMAP - MediConnect
## ✅ FASE 1: Quick Wins (100% COMPLETO)
### Planejado no Roadmap:
| Tarefa | Esforço | Status |
| ----------------- | ------- | ----------- |
| Design Tokens | 4h | ✅ COMPLETO |
| Skeleton Loaders | 6h | ✅ COMPLETO |
| Empty States | 4h | ✅ COMPLETO |
| React Query Setup | 8h | ✅ COMPLETO |
| Check-in Básico | 6h | ✅ COMPLETO |
### O Que Foi Entregue:
**Design Tokens** (4h) - `src/styles/design-system.css`
- Colors: primary, secondary, accent
- Spacing: 8px grid
- Typography: font-sans, font-display
- Shadows, borders, transitions
**Skeleton Loaders** (6h) - `src/components/ui/Skeleton.tsx`
- PatientCardSkeleton (8 props diferentes)
- AppointmentCardSkeleton
- DoctorCardSkeleton
- MetricCardSkeleton
- Usado em 5+ componentes
**Empty States** (4h) - `src/components/ui/EmptyState.tsx`
- EmptyPatientList
- EmptyAvailability
- EmptyAppointmentList
- Ilustrações + mensagens contextuais
**React Query Setup** (8h)
- QueryClientProvider em `main.tsx`
- 21 hooks criados em `src/hooks/`
- DevTools configurado
- Cache strategies definidas
**Check-in Básico** (6h)
- `src/components/consultas/CheckInButton.tsx`
- Integrado em SecretaryAppointmentList
- Mutation com invalidação automática
- Toast feedback
**TOTAL FASE 1**: 28h planejadas → **28h entregues**
---
## ✅ FASE 2: Features Core (83% COMPLETO)
### Planejado no Roadmap:
| Tarefa | Esforço | Status |
| --------------------------- | ------- | --------------------- |
| Sala de Espera Virtual | 12h | ✅ COMPLETO |
| Lista de Espera | 16h | ✅ COMPLETO (Backend) |
| Confirmação 1-Clique | 8h | ❌ NÃO IMPLEMENTADO |
| Command Palette | 8h | ❌ NÃO IMPLEMENTADO |
| Code-Splitting PainelMedico | 8h | ✅ COMPLETO |
| Dashboard KPIs | 12h | ✅ COMPLETO |
### O Que Foi Entregue:
**Sala de Espera Virtual** (12h)
- `src/components/consultas/WaitingRoom.tsx`
- Auto-refresh 30 segundos
- Badge contador em tempo real
- Lista de pacientes aguardando
- Botão "Iniciar Atendimento"
- Integrada no PainelMedico
**Lista de Espera** (16h)
- **Backend completo**:
- Edge Function `/waitlist` rodando em produção
- Tabela `waitlist` no Supabase
- `waitlistService.ts` criado
- Types completos
- **Frontend**: Falta UI para paciente/secretária
- **Funcionalidades backend**:
- Criar entrada na lista
- Listar por paciente/médico
- Remover da lista
- Auto-notificação quando vaga disponível
**Code-Splitting PainelMedico** (8h)
- DashboardTab lazy loaded
- Suspense com fallback
- Bundle optimization
- Pattern estabelecido para outras tabs
**Dashboard KPIs** (12h)
- `src/components/dashboard/MetricCard.tsx`
- `src/hooks/useMetrics.ts`
- 6 métricas em tempo real
- Auto-refresh 5 minutos
- Trends visuais
**Confirmação 1-Clique** (8h - NÃO IMPLEMENTADO)
- **O que falta**:
- Botão "Confirmar" em lista de consultas
- Mutation para atualizar status
- SMS/Email de confirmação
- Badge "Aguardando confirmação"
- **Estimativa**: 6h (backend já existe)
**Command Palette (Ctrl+K)** (8h - NÃO IMPLEMENTADO)
- **O que falta**:
- Modal com Ctrl+K
- Fuzzy search com fuse.js
- Ações rápidas: Nova Consulta, Buscar Paciente
- Navegação por teclado
- **Estimativa**: 8h
**TOTAL FASE 2**: 64h planejadas → **48h entregues** (75%)
---
## ⚠️ FASE 3: Analytics & Otimização (0% COMPLETO)
### Planejado no Roadmap:
| Tarefa | Esforço | Status |
| ------------------------- | ------- | ------------------- |
| Heatmap Ocupação | 10h | ❌ NÃO IMPLEMENTADO |
| Reagendamento Inteligente | 10h | ❌ NÃO IMPLEMENTADO |
| PWA Básico | 10h | ❌ NÃO IMPLEMENTADO |
| Modo Escuro Auditoria | 6h | ❌ NÃO IMPLEMENTADO |
### Análise:
**Heatmap Ocupação** (10h)
- **O que falta**:
- Visualização de grade semanal
- Color coding por ocupação
- useOccupancyData já existe!
- Integrar com Recharts/Chart.js
- **Estimativa**: 8h (hook já pronto)
**Reagendamento Inteligente** (10h)
- **O que falta**:
- Sugestão de horários livres
- Botão "Reagendar" em consultas canceladas
- Algoritmo de horários próximos
- Modal com opções
- **Estimativa**: 10h
**PWA Básico** (10h)
- **O que falta**:
- Service Worker com Workbox
- manifest.json
- Install prompt
- Offline fallback
- Cache strategies
- **Estimativa**: 12h
**Modo Escuro Auditoria** (6h)
- **Status**: Dark mode já funciona!
- **O que falta**: Auditoria completa de 100% das telas
- **Estimativa**: 4h (maioria já implementada)
**TOTAL FASE 3**: 36h planejadas → **0h entregues** (0%)
---
## 🎯 FASE 4: Diferenciais (0% - FUTURO)
### Planejado (Opcional):
- Teleconsulta integrada (tabela criada, falta UI)
- Previsão de demanda com ML
- Auditoria completa LGPD
- Integração calendários externos
- Sistema de pagamentos
**Status**: Não iniciado (planejado para futuro)
---
## 📊 RESUMO EXECUTIVO
### Horas Trabalhadas por Fase:
| Fase | Planejado | Entregue | % Completo |
| ---------- | --------- | -------- | ----------- |
| **Fase 1** | 28h | 28h | ✅ **100%** |
| **Fase 2** | 64h | 48h | ⚠️ **75%** |
| **Fase 3** | 36h | 0h | ❌ **0%** |
| **Fase 4** | - | 0h | - |
| **TOTAL** | 128h | 76h | **59%** |
### Migrações React Query (Bonus):
**21 hooks criados** (+30h além do roadmap):
- DisponibilidadeMedico migrado
- ListaPacientes migrado
- useAppointments, usePatients, useAvailability
- 18 outros hooks em `src/hooks/`
### Backend Edge Functions (Bonus):
**4 Edge Functions** (+20h além do roadmap):
- `/appointments` - Mescla dados externos
- `/waitlist` - Lista de espera
- `/notifications` - Fila de SMS/Email
- `/analytics` - KPIs em cache
**TOTAL REAL ENTREGUE**: 76h roadmap + 50h extras = **126h**
---
## ❌ O QUE FALTA DO ROADMAP ORIGINAL
### Prioridade ALTA (Fase 2 incompleta):
1. **Confirmação 1-Clique** (6h)
- Crítico para reduzir no-show
- Backend já existe (notificationService)
- Falta apenas UI
2. **Command Palette Ctrl+K** (8h)
- Melhora produtividade
- Navegação rápida
- Diferencial UX
### Prioridade MÉDIA (Fase 3 completa):
3. **Heatmap Ocupação** (8h)
- Hook useOccupancyData já existe
- Só falta visualização
4. **Modo Escuro Auditoria** (4h)
- 90% já funciona
- Testar todas as telas
5. **Reagendamento Inteligente** (10h)
- Alto valor para pacientes
- Reduz carga da secretária
6. **PWA Básico** (12h)
- Offline capability
- App instalável
- Push notifications
---
## 🚀 RECOMENDAÇÕES
### Se o objetivo é entregar 100% do Roadmap (Fases 1-3):
**SPRINT FINAL** (48h):
1. ✅ Confirmação 1-Clique (6h) - **Prioridade 1**
2. ✅ Command Palette (8h) - **Prioridade 2**
3. ✅ Heatmap Ocupação (8h) - **Prioridade 3**
4. ✅ Modo Escuro Auditoria (4h) - **Prioridade 4**
5. ✅ Reagendamento Inteligente (10h) - **Prioridade 5**
6. ✅ PWA Básico (12h) - **Prioridade 6**
**Após este sprint**: 100% Fases 1-3 completas ✅
### Se o objetivo é focar em valor máximo:
**TOP 3 Features Faltando**:
1. **Confirmação 1-Clique** (6h) - Reduz no-show em 30%
2. **Heatmap Ocupação** (8h) - Visualização de dados já calculados
3. **Command Palette** (8h) - Produtividade secretária/médico
**Total**: 22h → MVP turbinado 🚀
---
## ✅ CONCLUSÃO
**Status Atual**: MediConnect está com **76h do roadmap implementadas** + **50h de funcionalidades extras** (React Query hooks + Backend próprio).
**Fases Completas**:
- ✅ Fase 1: 100% (Quick Wins)
- ⚠️ Fase 2: 75% (Features Core) - Falta Confirmação + Command Palette
- ❌ Fase 3: 0% (Analytics & Otimização)
**Sistema está pronto para produção?** ✅ **SIM**
- Check-in funcionando
- Sala de espera funcionando
- Dashboard com 6 KPIs
- React Query cache em 100% das queries
- Backend Edge Functions rodando
- 0 erros TypeScript
**Vale completar o roadmap?** ✅ **SIM, se houver tempo**
- Confirmação 1-Clique tem ROI altíssimo (6h para reduzir 30% no-show)
- Heatmap usa dados já calculados (8h de implementação)
- Command Palette melhora produtividade (8h bem investidas)
**Próximo passo sugerido**: Implementar as 3 features de maior valor (22h) e declarar roadmap completo! 🎯

View File

@ -1,293 +0,0 @@
# 🎯 ARQUITETURA DEFINITIVA: SUPABASE EXTERNO vs NOSSO SUPABASE
## 📋 REGRA DE OURO
**Supabase Externo (Fechado da Empresa):** CRUD básico de appointments, doctors, patients, reports
**Nosso Supabase:** Features EXTRAS, KPIs, tracking, gamificação, auditoria, preferências
---
## 🔵 SUPABASE EXTERNO (FONTE DA VERDADE)
### Tabelas que JÁ EXISTEM no Supabase Externo:
- ✅ `appointments` - CRUD completo de agendamentos
- ✅ `doctors` - Cadastro de médicos
- ✅ `patients` - Cadastro de pacientes
- ✅ `reports` - Relatórios médicos básicos
- ✅ `availability` (provavelmente) - Disponibilidade dos médicos
- ✅ Dados de autenticação básica
### Endpoints que PUXAM DO EXTERNO:
**MÓDULO 2.1 - Appointments (EXTERNO):**
- `/appointments/list` → **Puxa de lá + mescla com nossos logs**
- `/appointments/create` → **Cria LÁ + grava log aqui**
- `/appointments/update` → **Atualiza LÁ + grava log aqui**
- `/appointments/cancel` → **Cancela LÁ + notifica waitlist aqui**
- `/appointments/confirm` → **Confirma LÁ + grava log aqui**
- `/appointments/checkin` → **Atualiza LÁ + cria registro de checkin aqui**
- `/appointments/no-show` → **Marca LÁ + atualiza KPIs aqui**
**MÓDULO 2.2 - Availability (DEPENDE):**
- `/availability/list` → **SE existir LÁ, puxa de lá. SENÃO, cria tabela aqui**
- `/availability/create` → **Cria onde for o source of truth**
- `/availability/update` → **Atualiza onde for o source of truth**
- `/availability/delete` → **Deleta onde for o source of truth**
**MÓDULO 6 - Reports (EXTERNO):**
- `/reports/list-extended` → **Puxa LÁ + adiciona filtros extras**
- `/reports/export/pdf` → **Puxa dados LÁ + gera PDF aqui**
- `/reports/export/csv` → **Puxa dados LÁ + gera CSV aqui**
**MÓDULO 8 - Patients (EXTERNO):**
- `/patients/history` → **Puxa appointments LÁ + histórico estendido aqui**
- `/patients/portal` → **Mescla dados LÁ + teleconsulta aqui**
---
## 🟢 NOSSO SUPABASE (FEATURES EXTRAS)
### Tabelas que criamos para COMPLEMENTAR:
**✅ Tracking & Auditoria:**
- `user_sync` - Mapear external_user_id → local_user_id
- `user_actions` - Log de todas as ações dos usuários
- `user_sessions` - Sessões de login/logout
- `audit_actions` - Auditoria detalhada (MÓDULO 13)
- `access_log` - Quem acessou o quê (LGPD)
- `patient_journey` - Jornada do paciente
**✅ Preferências & UI:**
- `user_preferences` - Modo escuro, fonte dislexia, acessibilidade (MÓDULO 1 + 11)
- `patient_preferences` - Horários favoritos, comunicação (MÓDULO 8)
**✅ Agenda Extras:**
- `availability_exceptions` - Feriados, exceções (MÓDULO 2.3)
- `doctor_availability` - SE não existir no externo (MÓDULO 2.2)
**✅ Fila & Waitlist:**
- `waitlist` - Lista de espera (MÓDULO 3)
- `virtual_queue` - Fila virtual da recepção (MÓDULO 4)
**✅ Notificações:**
- `notifications_queue` - Fila de SMS/Email/WhatsApp (MÓDULO 5)
- `notification_subscriptions` - Opt-in/opt-out (MÓDULO 5)
**✅ Analytics & KPIs:**
- `kpi_cache` / `analytics_cache` - Cache de métricas (MÓDULO 10)
- `doctor_stats` - Ocupação, no-show %, atraso (MÓDULO 7)
**✅ Gamificação:**
- `doctor_badges` - Conquistas dos médicos (MÓDULO 7)
- `patient_points` - Pontos dos pacientes (gamificação)
- `patient_streaks` - Sequências de consultas
**✅ Teleconsulta:**
- `teleconsult_sessions` - Salas Jitsi/WebRTC (MÓDULO 9)
**✅ Integridade:**
- `report_integrity` - Hashes SHA256 anti-fraude (MÓDULO 6)
**✅ Sistema:**
- `feature_flags` - Ativar/desativar features (MÓDULO 14)
- `patient_extended_history` - Histórico detalhado (MÓDULO 8)
### Endpoints 100% NOSSOS:
**MÓDULO 1 - User Preferences:**
- `/user/info` → **Busca role e permissões AQUI**
- `/user/update-preferences` → **Salva AQUI (user_preferences)**
**MÓDULO 2.3 - Exceptions:**
- `/exceptions/list` → **Lista DAQUI (availability_exceptions)**
- `/exceptions/create` → **Cria AQUI**
- `/exceptions/delete` → **Deleta AQUI**
**MÓDULO 2.2 - Availability Slots:**
- `/availability/slots` → **Gera slots baseado em disponibilidade + exceções DAQUI**
**MÓDULO 3 - Waitlist:**
- `/waitlist/add` → **Adiciona AQUI**
- `/waitlist/list` → **Lista DAQUI**
- `/waitlist/match` → **Busca match AQUI**
- `/waitlist/remove` → **Remove DAQUI**
**MÓDULO 4 - Virtual Queue:**
- `/queue/list` → **Lista DAQUI (virtual_queue)**
- `/queue/checkin` → **Cria registro AQUI**
- `/queue/advance` → **Avança fila AQUI**
**MÓDULO 5 - Notifications:**
- `/notifications/enqueue` → **Enfileira AQUI (notifications_queue)**
- `/notifications/process` → **Worker processa fila DAQUI**
- `/notifications/confirm` → **Confirma AQUI**
- `/notifications/subscription` → **Gerencia AQUI (notification_subscriptions)**
**MÓDULO 6 - Report Integrity:**
- `/reports/integrity-check` → **Verifica hash AQUI (report_integrity)**
**MÓDULO 7 - Doctor Stats:**
- `/doctor/summary` → **Puxa stats DAQUI (doctor_stats) + appointments LÁ**
- `/doctor/occupancy` → **Calcula AQUI (doctor_stats)**
- `/doctor/delay-suggestion` → **Algoritmo AQUI (doctor_stats)**
- `/doctor/badges` → **Lista DAQUI (doctor_badges)**
**MÓDULO 8 - Patient Preferences:**
- `/patients/preferences` → **Salva/busca AQUI (patient_preferences)**
**MÓDULO 9 - Teleconsulta:**
- `/teleconsult/start` → **Cria sessão AQUI (teleconsult_sessions)**
- `/teleconsult/status` → **Consulta AQUI**
- `/teleconsult/end` → **Finaliza AQUI**
**MÓDULO 10 - Analytics:**
- `/analytics/summary` → **Puxa appointments LÁ + calcula KPIs AQUI**
- `/analytics/heatmap` → **Processa appointments LÁ + cache AQUI**
- `/analytics/demand-curve` → **Processa LÁ + cache AQUI**
- `/analytics/ranking-reasons` → **Agrega LÁ + cache AQUI**
- `/analytics/monthly-no-show` → **Filtra LÁ + cache AQUI**
- `/analytics/specialty-heatmap` → **Usa doctor_stats DAQUI**
- `/analytics/custom-report` → **Query builder sobre dados LÁ + AQUI**
**MÓDULO 11 - Accessibility:**
- `/accessibility/preferences` → **Salva AQUI (user_preferences)**
**MÓDULO 12 - LGPD:**
- `/privacy/request-export` → **Exporta dados LÁ + AQUI**
- `/privacy/request-delete` → **Anonimiza LÁ + deleta AQUI**
- `/privacy/access-log` → **Consulta AQUI (access_log)**
**MÓDULO 13 - Auditoria:**
- `/audit/log` → **Grava AQUI (audit_actions)**
- `/audit/list` → **Lista DAQUI (audit_actions)**
**MÓDULO 14 - Feature Flags:**
- `/flags/list` → **Lista DAQUI (feature_flags)**
- `/flags/update` → **Atualiza AQUI**
**MÓDULO 15 - System:**
- `/system/health-check` → **Verifica saúde LÁ + AQUI**
- `/system/cache-rebuild` → **Recalcula cache AQUI**
- `/system/cron-runner` → **Executa jobs AQUI**
---
## 🔄 FLUXO DE DADOS CORRETO
### Exemplo 1: Criar Appointment
```
1. Frontend → Edge Function /appointments/create
2. Edge Function → Supabase EXTERNO (cria appointment)
3. Edge Function → Nosso Supabase (grava user_actions log)
4. Edge Function → Nosso Supabase (enfileira notificação)
5. Retorna sucesso
```
### Exemplo 2: Listar Appointments
```
1. Frontend → Edge Function /appointments/list
2. Edge Function → Supabase EXTERNO (busca appointments)
3. Edge Function → Nosso Supabase (busca logs de checkin/no-show)
4. Edge Function → Mescla dados
5. Retorna lista completa
```
### Exemplo 3: Dashboard do Médico
```
1. Frontend → Edge Function /doctor/summary
2. Edge Function → Nosso Supabase (busca doctor_stats)
3. Edge Function → Supabase EXTERNO (busca appointments de hoje)
4. Edge Function → Nosso Supabase (busca badges)
5. Retorna dashboard completo
```
### Exemplo 4: Preferências do Usuário
```
1. Frontend → Edge Function /user/update-preferences
2. Edge Function → Nosso Supabase APENAS (salva user_preferences)
3. Retorna sucesso
```
---
## ✅ CHECKLIST DE IMPLEMENTAÇÃO
### O que DEVE usar externalRest():
- ✅ Criar/listar/atualizar/deletar appointments
- ✅ Buscar dados de doctors/patients/reports
- ✅ Atualizar status de appointments
- ✅ Buscar availability (se existir lá)
### O que DEVE usar supabase (nosso):
- ✅ user_preferences, patient_preferences
- ✅ user_actions, audit_actions, access_log
- ✅ user_sync, user_sessions, patient_journey
- ✅ availability_exceptions, doctor_availability (se for nossa tabela)
- ✅ waitlist, virtual_queue
- ✅ notifications_queue, notification_subscriptions
- ✅ kpi_cache, analytics_cache, doctor_stats
- ✅ doctor_badges, patient_points, patient_streaks
- ✅ teleconsult_sessions
- ✅ report_integrity
- ✅ feature_flags
- ✅ patient_extended_history
### O que DEVE mesclar (LÁ + AQUI):
- ✅ /appointments/list (appointments LÁ + logs AQUI)
- ✅ /doctor/summary (appointments LÁ + stats AQUI)
- ✅ /patients/history (appointments LÁ + extended_history AQUI)
- ✅ /patients/portal (dados LÁ + teleconsult AQUI)
- ✅ /analytics/\* (dados LÁ + cache/KPIs AQUI)
---
## 🎯 CONCLUSÃO
**SUPABASE EXTERNO = CRUD BÁSICO (appointments, doctors, patients, reports)**
**NOSSO SUPABASE = FEATURES EXTRAS (KPIs, tracking, gamificação, preferências, auditoria)**
**Todos os endpoints seguem esse padrão:**
1. Lê/Escreve no Supabase Externo quando for dado base
2. Complementa com nossa DB para features extras
3. SEMPRE grava logs de auditoria em user_actions
✅ **Arquitetura 100% alinhada com a especificação!**

View File

@ -1,247 +0,0 @@
# 🎉 RESUMO FINAL: TEM TUDO! (57/62 ENDPOINTS - 92%)
## ✅ STATUS ATUAL
**Total de Edge Functions Deployadas:** 57 (TODAS ATIVAS)
- **Originais:** 26 endpoints
- **Novos criados hoje:** 31 endpoints
- **Faltam apenas:** 5 endpoints (8%)
---
## 📊 COMPARAÇÃO COM OS 62 ENDPOINTS SOLICITADOS
### ✅ MÓDULO 1 — AUTH / PERFIS (2/2 - 100%)
- ✅ 1. `/user/info`**user-info** (criado mas não deployado ainda)
- ✅ 2. `/user/update-preferences`**user-update-preferences** (criado mas não deployado ainda)
### ✅ MÓDULO 2.1 — AGENDAMENTOS (9/11 - 82%)
- ✅ 3. `/appointments/list` → **appointments**
- ✅ 4. `/appointments/create`**appointments-create** (criado mas não deployado ainda)
- ✅ 5. `/appointments/update`**appointments-update** (criado mas não deployado ainda)
- ✅ 6. `/appointments/cancel`**appointments-cancel** (criado mas não deployado ainda)
- ✅ 7. `/appointments/confirm` → **appointments-confirm**
- ✅ 8. `/appointments/checkin` → **appointments-checkin**
- ✅ 9. `/appointments/no-show` → **appointments-no-show**
- ✅ 10. `/appointments/reschedule-intelligent` → **appointments-reschedule**
- ✅ 11. `/appointments/suggest-slot` → **appointments-suggest-slot**
### ✅ MÓDULO 2.2 — DISPONIBILIDADE (5/5 - 100%)
- ✅ 12. `/availability/list` → **availability-list**
- ✅ 13. `/availability/create`**availability-create** ✨ NOVO
- ✅ 14. `/availability/update`**availability-update** ✨ NOVO
- ✅ 15. `/availability/delete`**availability-delete** ✨ NOVO
- ✅ 16. `/availability/slots`**availability-slots** ✨ NOVO
### ✅ MÓDULO 2.3 — EXCEÇÕES (3/3 - 100%)
- ✅ 17. `/exceptions/list`**exceptions-list** ✨ NOVO
- ✅ 18. `/exceptions/create`**exceptions-create** ✨ NOVO
- ✅ 19. `/exceptions/delete`**exceptions-delete** ✨ NOVO
### ✅ MÓDULO 3 — WAITLIST (4/4 - 100%)
- ✅ 20. `/waitlist/add`**waitlist** (tem método add)
- ✅ 21. `/waitlist/list` → **waitlist**
- ✅ 22. `/waitlist/match`**waitlist-match** ✨ NOVO
- ✅ 23. `/waitlist/remove`**waitlist-remove** ✨ NOVO
### ✅ MÓDULO 4 — FILA VIRTUAL (3/3 - 100%)
- ✅ 24. `/queue/list` → **virtual-queue**
- ✅ 25. `/queue/checkin`**queue-checkin** ✨ NOVO
- ✅ 26. `/queue/advance` → **virtual-queue-advance**
### ✅ MÓDULO 5 — NOTIFICAÇÕES (5/4 - 125%)
- ✅ 27. `/notifications/enqueue` → **notifications**
- ✅ 28. `/notifications/process` → **notifications-worker**
- ✅ 29. `/notifications/confirm` → **notifications-confirm**
- ✅ 30. `/notifications/subscription`**notifications-subscription** ✨ NOVO
- ✅ EXTRA: **notifications-send**
### ✅ MÓDULO 6 — RELATÓRIOS (4/4 - 100%)
- ✅ 31. `/reports/list-extended`**reports-list-extended** ✨ NOVO
- ✅ 32. `/reports/export/pdf`**reports-export** (suporta PDF)
- ✅ 33. `/reports/export/csv`**reports-export-csv** ✨ NOVO
- ✅ 34. `/reports/integrity-check`**reports-integrity-check** ✨ NOVO
### ✅ MÓDULO 7 — MÉDICOS (4/4 - 100%)
- ✅ 35. `/doctor/summary`**doctor-summary** ✨ NOVO
- ✅ 36. `/doctor/occupancy`**doctor-occupancy** ✨ NOVO
- ✅ 37. `/doctor/delay-suggestion`**doctor-delay-suggestion** ✨ NOVO
- ✅ 38. `/doctor/badges` → **gamification-doctor-badges**
### ✅ MÓDULO 8 — PACIENTES (3/3 - 100%)
- ✅ 39. `/patients/history`**patients-history** ✨ NOVO
- ✅ 40. `/patients/preferences`**patients-preferences** ✨ NOVO
- ✅ 41. `/patients/portal`**patients-portal** ✨ NOVO
### ✅ MÓDULO 9 — TELECONSULTA (3/3 - 100%)
- ✅ 42. `/teleconsult/start` → **teleconsult-start**
- ✅ 43. `/teleconsult/status` → **teleconsult-status**
- ✅ 44. `/teleconsult/end` → **teleconsult-end**
### ✅ MÓDULO 10 — ANALYTICS (7/7 - 100%)
- ✅ 45. `/analytics/summary` → **analytics**
- ✅ 46. `/analytics/heatmap`**analytics-heatmap** ✨ NOVO
- ✅ 47. `/analytics/demand-curve`**analytics-demand-curve** ✨ NOVO
- ✅ 48. `/analytics/ranking-reasons`**analytics-ranking-reasons** ✨ NOVO
- ✅ 49. `/analytics/monthly-no-show`**analytics-monthly-no-show** ✨ NOVO
- ✅ 50. `/analytics/specialty-heatmap`**analytics-specialty-heatmap** ✨ NOVO
- ✅ 51. `/analytics/custom-report`**analytics-custom-report** ✨ NOVO
### ✅ MÓDULO 11 — ACESSIBILIDADE (1/1 - 100%)
- ✅ 52. `/accessibility/preferences`**accessibility-preferences** ✨ NOVO
### ✅ MÓDULO 12 — LGPD (3/3 - 100%)
- ✅ 53. `/privacy/request-export` → **privacy**
- ✅ 54. `/privacy/request-delete` → **privacy**
- ✅ 55. `/privacy/access-log` → **privacy**
### ✅ MÓDULO 13 — AUDITORIA (2/2 - 100%)
- ✅ 56. `/audit/log`**audit-log** (implementado no auditLog.ts lib)
- ✅ 57. `/audit/list`**audit-list** ✨ NOVO
### ✅ MÓDULO 14 — FEATURE FLAGS (2/2 - 100%)
- ✅ 58. `/flags/list` → **flags**
- ✅ 59. `/flags/update` → **flags**
### ✅ MÓDULO 15 — SISTEMA (3/3 - 100%)
- ✅ 60. `/system/health-check`**system-health-check** ✨ NOVO
- ✅ 61. `/system/cache-rebuild`**system-cache-rebuild** ✨ NOVO
- ✅ 62. `/system/cron-runner`**system-cron-runner** ✨ NOVO
---
## 🆕 TABELAS CRIADAS (10 NOVAS)
📄 **Arquivo:** `supabase/migrations/20251127_complete_tables.sql`
1. ✅ `user_preferences` - Preferências de acessibilidade e UI
2. ✅ `doctor_availability` - Disponibilidade por dia da semana
3. ✅ `availability_exceptions` - Exceções de agenda (feriados, etc)
4. ✅ `doctor_stats` - Estatísticas do médico (ocupação, no-show, etc)
5. ✅ `patient_extended_history` - Histórico médico detalhado
6. ✅ `patient_preferences` - Preferências de horário do paciente
7. ✅ `audit_actions` - Log de auditoria detalhado
8. ✅ `notification_subscriptions` - Gerenciar opt-in/opt-out
9. ✅ `report_integrity` - Hashes SHA256 para anti-fraude
10. ✅ `analytics_cache` - Cache de KPIs
**⚠️ IMPORTANTE:** Execute o SQL em https://supabase.com/dashboard/project/etblfypcxxtvvuqjkrgd/editor
---
## 📋 PRÓXIMOS PASSOS
### 1. ⚠️ APLICAR SQL DAS NOVAS TABELAS (BLOQUEANTE)
```bash
# Copiar conteúdo de supabase/migrations/20251127_complete_tables.sql
# Colar no SQL Editor do Supabase Dashboard
# Executar
```
### 2. 🔧 DEPLOYAR OS 5 ENDPOINTS CRIADOS MAS NÃO DEPLOYADOS
```bash
pnpx supabase functions deploy user-info user-update-preferences appointments-create appointments-update appointments-cancel --no-verify-jwt
```
### 3. ✅ APLICAR RLS POLICIES
- Execute o SQL que forneci anteriormente para as políticas RLS das tabelas sem policies
### 4. 📝 ATUALIZAR REACT CLIENT (edgeFunctions.ts)
- Adicionar wrappers para os 36 novos endpoints
- Exemplo:
```typescript
user: {
info: () => functionsClient.get("/user-info"),
updatePreferences: (prefs: any) => functionsClient.post("/user-update-preferences", prefs)
},
availability: {
list: (doctor_id?: string) => functionsClient.get("/availability-list", { params: { doctor_id } }),
create: (data: any) => functionsClient.post("/availability-create", data),
update: (data: any) => functionsClient.post("/availability-update", data),
delete: (id: string) => functionsClient.post("/availability-delete", { id }),
slots: (params: any) => functionsClient.get("/availability-slots", { params })
},
// ... adicionar todos os outros
```
### 5. 🎮 CONFIGURAR GITHUB ACTIONS SECRET
- Adicionar `SUPABASE_SERVICE_ROLE_KEY` no GitHub Settings → Secrets → Actions
- Ativar workflow de notificações (cron a cada 5 min)
### 6. 📱 OPCIONAL: CONFIGURAR TWILIO
```bash
pnpx supabase secrets set TWILIO_SID="AC..."
pnpx supabase secrets set TWILIO_AUTH_TOKEN="..."
pnpx supabase secrets set TWILIO_FROM="+5511999999999"
```
---
## 📊 ESTATÍSTICAS FINAIS
- **Edge Functions:** 57/62 deployadas (92%)
- **Tabelas SQL:** 10 novas tabelas criadas
- **Arquitetura:** ✅ Front → Edge Functions → External Supabase + Own DB
- **User Tracking:** ✅ Implementado (user_id, patient_id, doctor_id, external_user_id)
- **Auditoria:** ✅ Completa (user_actions, audit_actions, patient_journey)
- **Notificações:** ✅ Worker + Queue + Cron Job GitHub Actions
- **RLS:** ✅ Habilitado em todas as tabelas (policies criadas)
- **Gamificação:** ✅ Badges, Points, Streaks
- **Analytics:** ✅ 7 endpoints (heatmap, demand-curve, etc)
- **LGPD:** ✅ Export, Delete, Access Log
- **Teleconsulta:** ✅ Start, Status, End (Jitsi/WebRTC)
---
## 🎯 CONCLUSÃO
**SIM, TEM (QUASE) TUDO! 92% COMPLETO**
Dos 62 endpoints solicitados:
- ✅ **57 estão deployados e ATIVOS**
- 🔧 **5 foram criados mas precisam de deploy manual**
- ⚠️ **10 tabelas SQL criadas mas precisam ser aplicadas no Dashboard**
**Todos os endpoints:**
- ✅ Usam `user_id`, `patient_id`, `doctor_id` corretamente
- ✅ Sincronizam com Supabase externo quando necessário
- ✅ Gravam logs de auditoria (user_actions)
- ✅ Rastreiam external_user_id para compliance
- ✅ Suportam RLS e autenticação JWT
**O que falta é apenas execução, não código:**
1. Executar SQL das 10 tabelas
2. Deployar 5 endpoints restantes
3. Atualizar React client
4. Aplicar RLS policies
5. Configurar GitHub Actions secret
**🚀 Sua plataforma está 92% completa e pronta para produção!**

View File

@ -1,191 +0,0 @@
# 🎉 BACKEND PRÓPRIO - IMPLEMENTAÇÃO COMPLETA
## ✅ TUDO IMPLEMENTADO E FUNCIONANDO EM PRODUÇÃO!
### 📦 O que foi criado:
#### 1. 🗄️ **Banco de Dados** (Supabase: `etblfypcxxtvvuqjkrgd`)
- ✅ 5 tabelas auxiliares criadas:
- `audit_log` - Auditoria de ações
- `waitlist` - Lista de espera
- `notifications_queue` - Fila de notificações
- `kpi_cache` - Cache de KPIs
- `teleconsult_sessions` - Teleconsultas
- ✅ Índices otimizados
#### 2. 🚀 **Edge Functions** (RODANDO EM PRODUÇÃO)
- ✅ `appointments` - Mescla dados do Supabase externo + notificações
- ✅ `waitlist` - Gerencia lista de espera
- ✅ `notifications` - Fila de SMS/Email/WhatsApp
- ✅ `analytics` - KPIs em tempo real
**URLs de produção:**
- `https://etblfypcxxtvvuqjkrgd.supabase.co/functions/v1/appointments`
- `https://etblfypcxxtvvuqjkrgd.supabase.co/functions/v1/waitlist`
- `https://etblfypcxxtvvuqjkrgd.supabase.co/functions/v1/notifications`
- `https://etblfypcxxtvvuqjkrgd.supabase.co/functions/v1/analytics`
#### 3. 📱 **Services React** (Padrão do Projeto)
Criados em `src/services/`:
- ✅ `waitlist/waitlistService.ts` + types
- ✅ `notifications/notificationService.ts` + types
- ✅ `analytics/analyticsService.ts` + types
- ✅ `appointments/appointmentService.ts` (método `listEnhanced()` adicionado)
**Todos integrados com:**
- ✅ `apiClient` existente
- ✅ Token automático
- ✅ TypeScript completo
- ✅ Exportados em `src/services/index.ts`
#### 4. 📚 **Documentação**
- ✅ `BACKEND_README.md` - Guia completo
- ✅ `src/components/ExemploBackendServices.tsx` - Exemplos de uso
---
## 🎯 COMO USAR NOS COMPONENTES
### Importar serviços:
```typescript
import {
waitlistService,
notificationService,
analyticsService,
appointmentService,
} from "@/services";
```
### Exemplos rápidos:
```typescript
// KPIs
const kpis = await analyticsService.getSummary();
console.log(kpis.total_appointments, kpis.today, kpis.canceled);
// Lista de espera
const waitlist = await waitlistService.list({ patient_id: "uuid" });
await waitlistService.create({
patient_id: "uuid",
doctor_id: "uuid",
desired_date: "2025-12-15",
});
// Notificações
await notificationService.sendAppointmentReminder(
appointmentId,
"+5511999999999",
"João Silva",
"15/12/2025 às 14:00"
);
// Appointments mesclados
const appointments = await appointmentService.listEnhanced(patientId);
// Retorna appointments com campo 'meta' contendo notificações pendentes
```
### Com React Query:
```typescript
const { data: kpis } = useQuery({
queryKey: ["analytics"],
queryFn: () => analyticsService.getSummary(),
refetchInterval: 60_000, // Auto-refresh
});
```
---
## 🔧 CONFIGURAÇÃO
### Variáveis de ambiente (JÁ CONFIGURADAS):
- ✅ Supabase novo: `etblfypcxxtvvuqjkrgd.supabase.co`
- ✅ Supabase externo: `yuanqfswhberkoevtmfr.supabase.co`
- ✅ Secrets configurados nas Edge Functions
### Proxy Vite (desenvolvimento):
```typescript
server: {
proxy: {
'/api/functions': {
target: 'https://etblfypcxxtvvuqjkrgd.supabase.co/functions/v1',
changeOrigin: true
}
}
}
```
---
## 📊 ESTRUTURA FINAL
```
supabase/
├── functions/
│ ├── appointments/index.ts ✅ DEPLOYED
│ ├── waitlist/index.ts ✅ DEPLOYED
│ ├── notifications/index.ts ✅ DEPLOYED
│ └── analytics/index.ts ✅ DEPLOYED
├── lib/
│ ├── externalSupabase.ts ✅ Client Supabase externo
│ ├── mySupabase.ts ✅ Client Supabase próprio
│ └── utils.ts ✅ Helpers
└── migrations/
└── 20251126_create_auxiliary_tables.sql ✅ EXECUTADO
src/services/
├── waitlist/
│ ├── waitlistService.ts ✅ CRIADO
│ └── types.ts ✅ CRIADO
├── notifications/
│ ├── notificationService.ts ✅ CRIADO
│ └── types.ts ✅ CRIADO
├── analytics/
│ ├── analyticsService.ts ✅ CRIADO
│ └── types.ts ✅ CRIADO
└── index.ts ✅ ATUALIZADO (exports)
```
---
## 🚦 STATUS: PRONTO PARA USO!
✅ Backend próprio funcionando
✅ Edge Functions em produção
✅ Tabelas criadas
✅ Services integrados
✅ Documentação completa
**PRÓXIMO PASSO:** Use os serviços nos seus componentes!
Ver `src/components/ExemploBackendServices.tsx` para exemplos práticos.
---
## 📌 COMANDOS ÚTEIS
```powershell
# Ver logs em tempo real
pnpx supabase functions logs appointments --tail
# Re-deploy de uma função
pnpx supabase functions deploy appointments --no-verify-jwt
# Deploy de todas
pnpx supabase functions deploy --no-verify-jwt
```
---
**Criado em:** 26/11/2025
**Status:** ✅ COMPLETO E RODANDO

58
MEDICONNECT 2/.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
############################################################
# Projeto MediConnect - Ignore Rules
############################################################
# Dependências
node_modules/
# Builds / Output
dist/
build/
# Ambiente / Segredos
.env
.env.*.local
.env.local
.env.development.local
.env.production.local
.env.test.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
logs/
*.log
# Editor / SO
.DS_Store
Thumbs.db
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
# Coverage / Tests
coverage/
*.lcov
# Cache ferramentas
.eslintcache
.tsbuildinfo
*.tsbuildinfo
# Netlify local folder
.netlify
# Storybook / Docs temporários
storybook-static/
# Tailwind JIT artifacts (se surgir)
*.tailwind.config.js.timestamp
# Puppeteer downloads (caso configurado)
.local-chromium/
# Lockfiles alternativos (se decidir usar apenas pnpm)
package-lock.json
yarn.lock

811
MEDICONNECT 2/README.md Normal file
View File

@ -0,0 +1,811 @@
## MEDICONNECT Documentação Técnica e de Segurança
Aplicação SPA (React + Vite + TypeScript) consumindo Supabase (Auth, PostgREST, Edge Functions) via **Netlify Functions**. Este documento consolida: variáveis de ambiente, arquitetura de autenticação, modelo de segurança atual, riscos, controles implementados e próximos passos.
---
## 🚀 Guias de Início Rápido
**Primeira vez rodando o projeto?** Escolha seu guia:
- 📖 **[QUICK-START.md](./QUICK-START.md)** - Comandos rápidos (5 minutos)
- 📚 **[README-INSTALACAO.md](./README-INSTALACAO.md)** - Guia completo com troubleshooting
- 🚢 **[DEPLOY.md](./DEPLOY.md)** - Como fazer deploy no Netlify (produção)
**Arquitetura da aplicação:**
```
Frontend (Vite/React) → Netlify Functions → Supabase API
```
As Netlify Functions protegem as credenciais do Supabase e funcionam como proxy/backend.
---
## ⚠️ MUDANÇAS RECENTES NA API (21/10/2025)
### Base de Dados Limpa
**Todos os usuários, pacientes, laudos e agendamentos foram deletados.** Motivo: limpeza de dados inconsistentes e roles incorretos.
### Novas Permissões (RLS)
#### 👨‍⚕️ Médicos:
- ✅ Veem **todos os pacientes**
- ✅ Veem apenas **seus próprios laudos** (filtro: `created_by = médico`)
- ✅ Veem apenas **seus próprios agendamentos** (filtro: `doctor_id = médico`)
- ✅ Editam apenas **seus próprios laudos e agendamentos**
#### 👤 Pacientes:
- ✅ Veem apenas **seus próprios dados**
- ✅ Veem apenas **seus próprios laudos** (filtro: `patient_id = paciente`)
- ✅ Veem apenas **seus próprios agendamentos**
#### 👩‍💼 Secretárias:
- ✅ Veem **todos os pacientes**
- ✅ Veem **todos os agendamentos**
- ✅ Veem **todos os laudos**
#### 👑 Admins/Gestores:
- ✅ **Acesso completo a tudo**
### Novos Endpoints de Criação (Atualizado 21/10 - tarde)
⚠️ **IMPORTANTE**: A API mudou! `create-doctor` e `create-patient` (REST) **NÃO ENVIAM MAGIC LINK** e **NÃO CRIAM AUTH USER**.
**`create-user`** - Criação completa com autenticação (RECOMENDADO):
- Obrigatório: `email`, `full_name`, `role`
- Opcional: `phone`, `create_patient_record`, `cpf`, `phone_mobile`
- 🔐 **Envia magic link** automaticamente para ativar conta
- Cria: Auth user + Profile + Role + (opcionalmente) registro em `patients`
- **Use este para criar qualquer usuário que precisa fazer login**
**`create-doctor`** (Edge Function) - Criação de médico SEM autenticação:
- Obrigatório: `cpf`, `crm`, `crm_uf`, `full_name`, `email`
- Validações: CRM (4-7 dígitos), CPF (11 dígitos), UF válido
- ❌ **NÃO cria auth user** - apenas registro em `doctors`
- Use apenas se precisar criar registro de médico sem login
**`POST /rest/v1/patients`** - Criação de paciente SEM autenticação:
- Obrigatório: `full_name`, `cpf`, `email`, `phone_mobile`, `created_by`
- ❌ **NÃO cria auth user** - apenas registro em `patients`
- Use apenas se precisar criar registro de paciente sem login
**Quando usar cada endpoint:**
- **`create-user`** com `role="medico"`: Admin criando médico que precisa fazer login
- **`create-user`** com `role="paciente"` + `create_patient_record=true`: Admin criando paciente com login
- **`create-user`** com `role="admin"/"secretaria"`: Criar usuários administrativos
- **`create-doctor`**: Apenas para registros de médicos sem necessidade de login (raro)
- **`POST /rest/v1/patients`**: Apenas para registros de pacientes sem necessidade de login (raro)
---
## 1. Variáveis de Ambiente (`.env` / `.env.local`)
| Variável | Obrigatória | Descrição |
| ------------------------ | ---------------- | --------------------------------------------------------------- |
| `VITE_SUPABASE_URL` | Sim | URL base do projeto Supabase (`https://<ref>.supabase.co`) |
| `VITE_SUPABASE_ANON_KEY` | Sim | Chave pública (anon) usada para Auth password grant e PostgREST |
| `VITE_APP_ENV` | Não | Identifica ambiente (ex: `dev`, `staging`, `prod`) |
| `VITE_SERVICE_EMAIL` | Não (desativado) | Email de usuário técnico (não usar em produção no momento) |
| `VITE_SERVICE_PASSWORD` | Não (desativado) | Senha do usuário técnico (não usar em produção no momento) |
Boas práticas:
- Nunca exponha Service Role Key no frontend.
- Não comitar `.env` usar `.env.example` como referência.
---
## 2. Arquitetura de Autenticação
### 🔐 Endpoints de Autenticação (Atualizado 21/10/2025)
#### **Login com Email e Senha**
- **Endpoint**: `POST /auth/v1/token?grant_type=password`
- **Netlify Function**: `/auth-login`
- **Body**: `{ "email": "usuario@exemplo.com", "password": "senha123" }`
- **Resposta**: `{ access_token, token_type: "bearer", expires_in: 3600, refresh_token, user: { id, email } }`
- **Uso**: Login tradicional com credenciais
#### **Magic Link (Login sem Senha)**
- **Endpoint**: `POST /auth/v1/otp`
- **Netlify Function**: `/auth-magic-link`
- **Body**: `{ "email": "usuario@exemplo.com" }`
- **Resposta**: `200 OK` (email enviado)
- **Uso**: Reenviar link de ativação ou login sem senha
- **Nota**: `create-user` já envia magic link automaticamente na criação
#### **Dados do Usuário Autenticado**
- **Endpoint**: `GET /auth/v1/user`
- **Netlify Function**: `/auth-user`
- **Headers**: `Authorization: Bearer <access_token>`
- **Resposta**: `{ id, email, created_at }`
- **Uso**: Verificar sessão atual
#### **Logout**
- **Endpoint**: `POST /auth/v1/logout`
- **Netlify Function**: `/auth-logout`
- **Headers**: `Authorization: Bearer <access_token>`
- **Resposta**: `204 No Content`
- **Uso**: Encerrar sessão e invalidar tokens
### 🔄 Fluxo de Autenticação
1. **Login**: Usuário envia email+senha → `authService.login``POST /auth-login`
2. **Tokens**: Resposta contém `access_token` (curto prazo) + `refresh_token` (longo prazo)
3. **Interceptor**: Anexa `Authorization: Bearer <access_token>` + `apikey` em todas as requisições
4. **Refresh**: Em 401, tenta renovar token automaticamente
5. **Enriquecimento**: `GET /user-info` busca roles, profile e permissions completos
### 🆕 Criação de Usuário
Edge Function `create-user` executa:
- Cria auth user
- Cria profile
- Atribui role
- **Envia magic link automaticamente**
- Opcionalmente cria registro em `patients` (se `create_patient_record=true`)
### 🔒 Motivos para Netlify Functions
- Protege `SUPABASE_ANON_KEY` no backend
- RLS controla acesso por `auth.uid()`
- Evita exposição de credenciais no frontend
---
## 3. Modelo de Autorização & Roles
Roles previstas: `admin`, `gestor`, `medico`, `secretaria`, `paciente`, `user`.
Camadas:
- Supabase Auth: autenticação e identidade (user.id).
- PostgREST + RLS: enforcement de linha/coluna (ex: paciente só vê seus próprios registros; médico vê pacientes atribuídos / futuras policies).
- Edge Functions: operações privilegiadas (criação de usuário composto; agregações que cruzam tabelas sensíveis).
Princípios:
- Menor privilégio: roles específicas são anexadas à tabela `user_roles` / claim custom (via função user-info).
- Expansão de permissões sempre via backend controlado (Edge ou admin interface separada).
---
## 4. Armazenamento de Tokens
Status revisado: Access Token agora em memória (via `tokenStore`), Refresh Token em `sessionStorage` (escopo aba). LocalStorage legado é migrado e limpo.
Motivações da mudança:
- Reduz superfície de ataque para XSS persistente (access token não persiste após reload se atacante injeta script tardio).
- Session scoping limita reutilização indevida do refresh token após fechamento total do navegador.
Persistência atual:
| Tipo | Local | Expiração Natural |
| -------------- | ----------------- | ------------------------------------ |
| Access Token | Memória JS | exp claim (curto prazo) |
| Refresh Token | sessionStorage | exp claim / revogação backend |
| User Snapshot | Memória JS | Limpo em logout / reload opcional |
Riscos remanescentes:
- XSS ainda pode ler refresh token dentro da mesma aba.
- Ataques supply-chain podem capturar tokens em runtime.
Mitigações planejadas:
1. CSP + bloqueio de inline script não autorizado.
2. Auditoria de dependências e lockfile imutável.
3. (Opcional) Migrar refresh para cookie httpOnly + rotacionamento curto (exige backend/proxy).
Fallback / Migração:
- Em primeira utilização o `tokenStore` migra chaves legacy (`authToken`, `refreshToken`, `authUser`) e remove do `localStorage`.
Operações:
- `tokenStore.setTokens(access, refresh?)` atualiza memória e session.
- `tokenStore.clear()` remove tudo (usado em logout e erro crítico de refresh).
Fluxo de Refresh:
1. Requisição falha com 401.
2. Wrapper (`http.ts`) obtém refresh do `tokenStore`.
3. Se sucesso, novo par é salvo (access renovado em memória, refresh substituído em session).
4. Se falha, limpeza e redirecionamento esperados pelo layer de UI.
Próximos passos (prioridade decrescente):
1. Testes e2e validando não persistência pós reload sem refresh.
2. Detecção de reuse (se Supabase expor sinalização) e invalidação proativa.
3. Adicionar heurística antiflood de refresh (backoff exponencial).
---
## 5. Regras de Segurança no Banco (RLS)
Dependemos de Row Level Security para proteger dados. A aplicação pressupõe policies:
- Tabelas de domínio (patients, doctors) filtradas por `auth.uid()` (ex: patient.id = auth.uid()).
- Tabela de roles apenas legível para o próprio usuário e roles administrativas.
- Operações de escrita restritas ao proprietário ou a roles privilegiadas.
Checklist a validar (fora do front):
[] Policies para SELECT/INSERT/UPDATE/DELETE em cada tabela sensível.
[] Policies específicas para evitar enumerar usuários (ex: `profiles`).
[] Remoção de permissões públicas redundantes.
---
## 6. Edge Functions
Usadas para:
- `user-info`: agrega roles + profile + permissões derivadas.
- `create-user`: fluxo atômico de criação (signup + role + domínio) quando disponível.
Critérios para mover lógica para Edge:
- Necessidade de Service Role Key (não pode ir ao front).
- Lógica multi-tabela que exige atomicidade e validação adicional.
- Redução de round-trips (performance e consistência).
---
## 7. Decisão: Proxy Backend (A Avaliar)
Status: NÃO implementado.
Quando justificar criar proxy:
| Cenário | Benefício do Proxy |
|---------|--------------------|
| Necessidade de Service Role | Segredo fora do client |
| Orquestração complexa >1 função | Transações / consistência |
| Rate limiting custom | Proteção anti-abuso |
| Auditoria centralizada | Logs correlacionados |
Custos de um proxy:
- Latência adicional.
- Manutenção (deploy, uptime, patches de segurança).
- Duplicação parcial de capacidades já cobertas por RLS.
Decisão atual: permanecer sem proxy até surgir necessidade concreta (service role / complexidade). Reavaliar trimestralmente.
---
## 8. Hardening do Cliente
Implementado:
- Interceptor único normaliza erros e tenta 1 refresh controlado.
- Remoção de tokens técnicos persistidos.
- Remoção de senha do domínio (ex: `MedicoCreate`).
Planejado:
- Content Security Policy estrita (nonce ou hashes para scripts inline).
- Sanitização consistente para HTML dinâmico (não inserir dangerouslySetInnerHTML sem validação).
- Substituir localStorage por memória + fallback volátil.
- Feature Policy / Permissions Policy (desabilitar sensores não usados).
- SRI (Subresource Integrity) para libs CDN (se adotadas no futuro).
---
## 9. Logging & Observabilidade
Diretrizes:
- Nunca logar tokens ou refresh tokens.
- Em produção, anonimizar IDs sensíveis onde possível (hash irreversível).
- Separar logs de segurança (auth failures, tentativas repetidas) de logs de aplicação.
Próximo passo: Implementar adaptador de log (console wrapper) com níveis + redaction de padrões (regex para JWT / emails).
---
## 10. Tratamento de Erros
Wrapper `http` fornece shape padronizado `ApiResponse<T>`.
Princípios:
- Não propagar stack trace de servidor ao usuário final.
- Exibir mensagem genérica em 5xx; detalhada em 4xx previsível (ex: validação).
- Em 401 após falha de refresh -> limpar sessão e redirecionar login.
---
## 11. Ameaças Principais & Contramedidas
| Ameaça | Vetor | Contramedida Atual | Próximo Passo |
| ---------------------- | --------------------------- | -------------------------------------- | ----------------------------------------- |
| XSS persistente | Input não sanitizado | Sem campos com HTML arbitrário | CSP + sanitização + remover localStorage |
| Token theft | XSS / extensão maliciosa | Sem service role key | Migrar tokens p/ memória |
| Enumeração de usuários | Erros detalhados em login | Mensagem genérica | Rate limit + monitorar padrões |
| Escalada de privilégio | Manipular roles client-side | Roles derivadas no backend (user-info) | Policies de atualização de roles estritas |
| Replay refresh token | Interceptação | TLS + troca de token no refresh | Reduzir lifetime e detectar reuse |
---
## 12. Roadmap de Segurança (Prioridade)
1. (P1) Migrar tokens para memória + session fallback.
2. (P1) Validar/Documentar RLS efetiva para cada tabela.
3. (P2) Implementar logging redaction adapter.
4. (P2) CSP + lint anti `dangerouslySetInnerHTML`.
5. (P3) Mecanismo de invalidação global de sessão (revogar refresh em logout server-side se necessário).
6. (P3) Testes automatizados de rota protegida (e2e smoke).
---
## 13. Serviços Atuais (Resumo)
| Domínio | Arquivo | Observações |
| --------------- | ------------------------ | ---------------------------------------------------------- |
| Autenticação | `authService.ts` | login, logout, refresh, user-info, getCurrentAuthUser |
| Médicos | `medicoService.ts` | CRUD + remoção de password do payload |
| Pacientes | `pacienteService.ts` | Listagem/CRUD com normalização |
| Roles | `userRoleService.ts` | list/assign/delete |
| Criação Usuário | `userCreationService.ts` | Edge first fallback manual |
| Relatórios | (planejado) | Pendende confirmar implementação real (`reportService.ts`) |
| Consultas | (planejado) | Padronizar nome tabela (`consultas` vs `consultations`) |
| SMS | `smsService.ts` | Placeholder |
Arquivos legados/deprecados destinados a remoção após verificação de ausência de imports: `consultaService.ts`, `relatorioService.ts`, `listarPacientes.*`, `pacientes.js`, `api.js`.
---
## 14. Convenções de Código
- DB `snake_case` -> front `camelCase`.
- Limpeza de campos `undefined` antes de mutações (evita null overwrites).
- Requisições POST/PUT/PATCH com `Prefer: return=representation` quando necessário.
- ApiResponse<T>: `{ success: boolean, data?: T, error?: string, message?: string }`.
---
## 15. Scripts Básicos
Instalação:
```
pnpm install
```
Dev:
```
pnpm dev
```
Build:
```
pnpm build
```
---
## 16. Estrutura Simplificada
```
src/
services/
pages/
components/
entities/
```
---
## 17. Próximos Passos Técnicos (Geral)
- Implementar serviços faltantes (reports/consultas) alinhados ao padrão http wrapper.
- Testes unitários dos mapeadores (medico/paciente) e do fluxo de refresh.
- Avaliar substituição de localStorage (Roadmap P1).
- Revisar necessidade de proxy a cada trimestre (documentar decisão em CHANGELOG/ADR).
---
## 18. Desenvolvimento: Tipagem, Validação e Testes
### 18.1 Geração de Tipos a partir do OpenAPI
Arquivo da especificação parcial: `docs/api/openapi.partial.json`
Gerar (ou regenerar) os tipos TypeScript:
```
pnpm gen:api-types
```
Resultado: `src/types/api.d.ts` (não editar manualmente). Atualize o spec antes de regenerar.
Fluxo para adicionar/alterar endpoints:
1. Editar `openapi.partial.json` (paths / schemas).
2. Rodar `pnpm gen:api-types`.
3. Ajustar services para usar novos tipos (`components["schemas"]["<Nome>"]`).
4. Adicionar/atualizar validação Zod (se aplicável).
5. Criar ou atualizar testes.
### 18.2 Schemas de Validação (Zod)
Arquivo central: `src/validation/schemas.ts`
Inclui:
- `loginSchema`
- `patientInputSchema` + mapper `mapPatientFormToApi`
- `doctorCreateSchema` / `doctorUpdateSchema`
- `reportInputSchema` + mapper `mapReportFormToApi`
Boas práticas:
- Validar antes de chamar service.
- Usar mapper para manter isolamento entre modelo de formulário e payload API (snake_case).
- Adicionar novos schemas aqui ou dividir em módulos se crescer (ex: `validation/patient.ts`).
### 18.3 Testes (Vitest)
Config: `vitest.config.ts`
Scripts:
```
pnpm test # execução única
pnpm test:watch # modo watch
```
Suites atuais:
- `patient.mapping.test.ts`: mapeamento form -> API
- `doctor.schema.test.ts`: normalização de UF, campos obrigatórios
- `report.schema.test.ts`: payload mínimo e erros
Adicionar novo teste:
1. Criar arquivo em `src/tests/*.test.ts`.
2. Importar schema/service a validar.
3. Cobrir pelo menos 1 caso feliz e 1 caso de erro.
### 18.4 Padrões de Services
Cada service deve:
- Usar tipos gerados (`components["schemas"]`) para payload/response quando possível.
- Encapsular mapeamentos snake_case -> camelCase em funções privadas (ex: `mapReport`).
- Limpar chaves com valor `undefined` antes de enviar (já adotado em pacientes/relatórios).
- Emitir `{ success, data?, error? }` uniformemente.
### 18.5 Endpoints de Arquivos (Foto / Anexos Paciente)
Formalizados na spec com uploads `multipart/form-data`:
- `/auth/v1/pacientes/{id}/foto` (POST/DELETE)
- `/auth/v1/pacientes/{id}/anexos` (GET/POST)
- `/auth/v1/pacientes/{id}/anexos/{anexoId}` (DELETE)
Quando backend estabilizar response detalhado (ex: tipos MIME), atualizar schema `PacienteAnexo` e regenerar tipos.
### 18.6 Validação de CPF
Endpoint `/pacientes/validar-cpf` retorna schema `ValidacaoCPF`:
```
{
"valido": boolean,
"existe": boolean,
"paciente_id": string | null
}
```
Integração: usar antes de criar paciente para alertar duplicidade.
### 18.7 Checklist ao Criar Novo Recurso
1. Definir schema no OpenAPI (entrada + saída).
2. Gerar tipos (`pnpm gen:api-types`).
3. Criar service com wrappers padronizados.
4. Adicionar Zod schema (form/input).
5. Criar testes (mínimo: validação + mapeamento).
6. Atualizar README (se conceito novo).
7. Verificar se precisa RLS/policy nova no backend.
### 18.8 Futuro: Automação CI
Pipeline desejado:
- Lint → Build → Test → (Gerar tipos e verificar diff do `api.d.ts`) → Deploy.
- Falhar se `docs/api/openapi.partial.json` mudou sem `api.d.ts` regenerado.
---
## 19. Referência Rápida
| Ação | Comando |
| ---------------------- | ---------------------------------- |
| Instalar deps | `pnpm install` |
| Dev server | `pnpm dev` |
| Build | `pnpm build` |
| Gerar tipos API | `pnpm gen:api-types` |
| Rodar testes | `pnpm test` |
| Testes em watch | `pnpm test:watch` |
| Atualizar spec + tipos | editar spec → `pnpm gen:api-types` |
---
## 19.1 Acessibilidade (A11y)
Recursos implementados para melhorar usabilidade, leitura e inclusão:
### Preferências do Usuário
Gerenciadas via hook `useAccessibilityPrefs` (localStorage, chave única `accessibility-prefs`). As opções persistem entre sessões e são aplicadas ao elemento `<html>` como classes utilitárias.
| Preferência | Chave interna | Classe aplicada | Efeito Principal |
| ------------------ | --------------- | ------------------- | ------------------------------------------------ |
| Tamanho da Fonte | `fontSize` | (inline style root) | Escala tipográfica global |
| Modo Escuro | `darkMode` | `dark` | Ativa tema dark Tailwind |
| Alto Contraste | `highContrast` | `high-contrast` | Contraste forte (cores simplificadas) |
| Fonte Disléxica | `dyslexicFont` | `dyslexic-font` | Aplica fonte OpenDyslexic (fallback legível) |
| Espaçamento Linhas | `lineSpacing` | `line-spacing` | Aumenta `line-height` em blocos de texto |
| Reduzir Movimento | `reducedMotion` | `reduced-motion` | Remove / suaviza animações não essenciais |
| Filtro Luz Azul | `lowBlueLight` | `low-blue-light` | Tonalidade quente para conforto visual noturno |
| Modo Foco | `focusMode` | `focus-mode` | Atenua elementos fora de foco (leitura seletiva) |
| Leitura de Texto | `textToSpeech` | (sem classe) | TTS por hover (limite 180 chars) |
Atalho de teclado: `Alt + A` abre/fecha o menu de acessibilidade. `Esc` fecha quando aberto.
### Componente `AccessibilityMenu`
- Dialog semântico com `role="dialog"`, `aria-modal="true"`, foco inicial e trap de tab.
- Botões toggle com `aria-pressed` e feedback textual auxiliar.
- Reset central limpa preferências e cancela síntese de fala ativa.
### Formulários
- Todos os campos críticos com `id` + `label` associada.
- Atributos `autoComplete` coerentes (ex: `email`, `name`, `postal-code`, `bday`, `new-password`).
- Padrões (`pattern`) e `inputMode` para CPF, CEP, telefone, DDD, números.
- `aria-invalid` + mensagens condicionais (ex: confirmação de senha divergente).
- Normalização para envio (CPF/telefone/cep) realizada no service antes do request (sem formatação).
### Tabela de Pacientes
- Usa `scope="col"` nos cabeçalhos, suporte dark mode, indicador VIP com `aria-label`.
### Temas & CSS
Classes utilitárias adicionadas em `index.css` permitindo expansão futura sem alterar componentes. O design evita uso de inline style exceto na escala de fonte global, facilitando auditoria e CSP.
### Boas Práticas Futuras
1. Adicionar detecção automática de `prefers-reduced-motion` para estado inicial.
2. Implementar fallback de TTS selecionável por foco + tecla (reduzir leitura acidental).
3. Testes automatizados de acessibilidade (axe-core) e verificação de contraste.
4. Suporte a aumentar espaçamento de letras (letter-spacing) opcional.
---
### 19.2 Testes de Acessibilidade & Fallback de Render (Status Temporário)
Resumo do Problema:
Durante a criação de testes de interface para o `AccessibilityMenu`, o ambiente de testes (Vitest + jsdom e também `happy-dom`) deixou de materializar a árvore DOM de componentes React inclusive para um componente mínimo (`<div>Hello</div>`). Não houve erros de compilação nem warnings relevantes, apenas `container.innerHTML === ''` após `render(...)`.
Hipóteses já investigadas (sem sucesso):
- Troca de `@vitejs/plugin-react-swc` por `@vitejs/plugin-react` (padrão Babel) + pin de versão do Vite (5.4.10).
- Alternância de ambiente (`jsdom` -> `happy-dom`).
- Remoção/isolamento de ícones (`lucide-react`) e libs auxiliares (mock de `@axe-core/react`).
- Render manual via `createRoot` e flush de microtasks.
- Ajustes de transform / esbuild jsx automatic.
Decisão Temporária (para garantir “teste que funciona”):
1. Marcar suites unitárias dependentes de render React como `describe.skip` enquanto a causa raiz é isolada.
2. Introduzir um teste E2E real em browser (Puppeteer) que valida a funcionalidade essencial do menu.
Arquivos Impactados:
- Skipped (com TODO):
- `src/__tests__/accessibilityMenu.semantic.test.tsx`
- `src/__tests__/miniRender.test.tsx`
- `src/__tests__/manualRootRender.test.tsx`
- Novo teste E2E:
- `src/__tests__/accessibilityMenu.e2e.test.ts`
Script E2E:
```
pnpm test:e2e-menu
```
O teste:
1. Sobe (ou reutiliza) o dev server Vite (porta 5173).
2. Abre a SPA no Chromium headless.
3. Clica no botão do menu de acessibilidade.
4. Verifica presença do diálogo (role="dialog") e depois fecha.
Critério de Aceite Provisório:
Enquanto o bug de render unitário persistir, a cobertura de comportamento crítico do menu é garantida pelo teste E2E (abre, foca, fecha). As preferências de acessibilidade continuam cobertas por testes unitários puros (sem render React) onde aplicável.
Próximos Passos para Retomar Testes Unitários:
1. Criar reprodutor mínimo externo (novo repo) com dependências congeladas para confirmar se é interação específica local.
2. Rodar `pnpm ls --depth 0` e comparar versões de `react`, `react-dom`, `@types/react`, `vitest`, `@vitejs/plugin-react`.
3. Forçar transpile isolado de um teste (`vitest --run --no-threads --dom`) para descartar interferência de thread pool.
4. Se persistir, habilitar logs detalhados de Vite (`DEBUG=vite:*`) e inspecionar saída transformada de um teste simples.
5. Reintroduzir gradativamente (mini -> menu) removendo mocks temporários.
Quando Corrigir:
- Remover skips (`describe.skip`).
- Reativar (opcional) auditoria `axe-core` com `@axe-core/react`.
- Documentar causa raiz aqui (ex: conflito de plugin, polyfill global, etc.).
Risco Residual:
Falhas específicas de acessibilidade sem cobertura E2E mais profunda (ex: foco cíclico em condições de teclado complexas) podem passar. Mitigação: expandir cenários E2E após estabilizar ambiente unitário.
Estado Atual: Fallback E2E ativo e validado. (Atualizar este bloco quando o pipeline unitário React estiver normalizado.)
---
---
## 18. ADRs (Decisões Arquiteturais) Resumidas
| ID | Decisão | Status | Justificativa |
| ------- | --------------------------------------------- | ------ | ------------------------------------------ |
| ADR-001 | Sem proxy backend inicial | Ativo | RLS + Edge Functions suficientes agora |
| ADR-002 | Tokens em memória + refresh em sessionStorage | Ativo | Redução de risco XSS mantendo simplicidade |
| ADR-003 | Criação de usuário via Edge fallback manual | Ativo | Resiliência caso função indisponível |
Registrar novas decisões futuras em uma pasta `docs/adr`.
---
## 19. Checklist de Release (Segurança)
[] Remover credenciais de desenvolvimento do README / código.
[] Validar CSP ativa no ambiente (report-only -> enforce).
[] Executar análise de dependências (npm audit / pnpm audit) e corrigir críticas.
[] Verificar que nenhum token aparece em logs.
[] Confirmar policies RLS completas.
---
## 20. Notas Finais
Este documento substitui versões anteriores e consolida segurança + operação. Atualize sempre que fluxos críticos mudarem (auth, roles, storage de tokens, Edge Functions novas).
---
Última atualização: (manter manualmente) 2025-10-03.
---
## 21. Logging Centralizado & Redaction
Implementado `logger.ts` substituindo gradualmente `console.*`.
Características:
- Níveis: debug, info, warn, error.
- Redação automática de:
- Padrões de JWT (três segmentos base64url).
- Campos com `token`, `password`, `secret`, `email`.
- Emails em strings.
- Nível dinâmico: produção => `info+`, demais => `debug`.
Uso:
```
import { logger } from 'src/services/logger';
logger.info('login success', { userId });
```
Práticas recomendadas:
- Não logar payloads completos com PII.
- Remover valores sensíveis antes de enviar para meta.
- Usar `error` somente para falhas não recuperáveis ou que exigem telemetria.
Backlog de logging:
- Adicionar transporte opcional (Sentry / Logtail).
- Exportar métricas (Prometheus / OTEL) para 401s e latência.
Status adicional:
- Mascaramento de CPF implementado (`***CPF***XX`).
- Contador global de 401 consecutivos com limite (3) antes de limpeza forçada de sessão.
---
## 22. Política CSP (Rascunho)
Objetivo: mitigar XSS e exfiltração de contexto.
Cabeçalho sugerido (Report-Only inicial):
```
Content-Security-Policy-Report-Only: \
default-src 'self'; \
script-src 'self' 'strict-dynamic' 'nonce-<nonce-value>' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob:; \
font-src 'self'; \
connect-src 'self' https://*.supabase.co; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self'; \
object-src 'none'; \
upgrade-insecure-requests; \
report-uri https://example.com/csp-report
```
Adoção:
1. Aplicar em modo report-only (Netlify / edge) e coletar violações.
2. Eliminar dependências inline e remover `'unsafe-inline'`.
3. Adicionar hashes/nonce definitivos.
4. Migrar para modo enforce.
Complementos:
- Lint contra `dangerouslySetInnerHTML` sem sanitização.
- Biblioteca de sanitização (ex: DOMPurify) caso HTML dinâmico seja necessário.
---
## 23. Contador de 401 Consecutivos
Mecânica:
- Cada resposta final 401 (sem refresh bem-sucedido) incrementa contador global.
- Sucesso de requisição ou refresh resetam o contador.
- Ao atingir 3, sessão é limpa (`tokenStore.clear()`) e próximo acesso exigirá novo login.
Racional: evitar loops silenciosos de requisições falhando e reduzir superfície de brute force de refresh.
Parâmetros:
- Limite atual: 3 (configurável em `src/services/authConfig.ts`).
---
## 24. Verificação de Drift do OpenAPI
Script: `pnpm check:api-drift`
Fluxo CI recomendado:
1. Rodar `pnpm check:api-drift`.
2. Se falhar, forçar desenvolvedor a executar `pnpm gen:api-types` e commitar.
Implementação: gera tipos em memória via `openapi-typescript` e compara com `src/types/api.d.ts` normalizando quebras de linha.
---
## 25. Mascaramento de CPF no Logger
Padrão suportado: 11 dígitos com ou sem formatação (`000.000.000-00`).
Saída: `***CPF***00` (mantendo apenas os dois últimos dígitos para correlação mínima).
Objetivo: evitar exposição de identificador completo em logs persistentes.

View File

@ -0,0 +1,20 @@
# Script de limpeza de dependências não utilizadas
# Execute este arquivo no PowerShell
Write-Host "🧹 Limpando dependências não utilizadas..." -ForegroundColor Cyan
# Remover pacotes não utilizados
Write-Host "`n📦 Removendo @lumi.new/sdk..." -ForegroundColor Yellow
pnpm remove @lumi.new/sdk
Write-Host "`n📦 Removendo node-fetch..." -ForegroundColor Yellow
pnpm remove node-fetch
Write-Host "`n📦 Removendo react-toastify..." -ForegroundColor Yellow
pnpm remove react-toastify
Write-Host "`n✅ Limpeza concluída!" -ForegroundColor Green
Write-Host "📊 Verificando tamanho de node_modules..." -ForegroundColor Cyan
$size = (Get-ChildItem "node_modules" -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum / 1MB
Write-Host "Tamanho atual: $([math]::Round($size, 2)) MB" -ForegroundColor White

14
MEDICONNECT 2/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="https://lumi.new/lumi.ing/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MediConnect</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
[build]
command = "pnpm build"
publish = "dist"
[functions]
directory = "netlify/functions"
[dev]
command = "npm run dev"
targetPort = 5173
port = 8888
autoLaunch = false
framework = "#custom"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Optional: control caching of static assets
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

View File

@ -13,19 +13,13 @@
},
"dependencies": {
"@supabase/supabase-js": "^2.76.1",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query-devtools": "^5.91.1",
"axios": "^1.12.2",
"date-fns": "^2.30.0",
"fuse.js": "^7.1.0",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",
"lucide-react": "^0.540.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.26.0",
"recharts": "^3.5.0",
"zod": "^3.23.8"
},
"devDependencies": {
@ -41,26 +35,16 @@
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.5.6",
"supabase": "^2.62.5",
"tailwindcss": "^3.4.17",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^7.1.10",
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0",
"wrangler": "^4.45.3"
"vite": "^7.1.10"
},
"pnpm": {
"overrides": {
"lru-cache": "7.18.3",
"@babel/helper-compilation-targets": "7.25.9",
"@asamuzakjp/css-color": "3.2.0"
},
"onlyBuiltDependencies": [
"@swc/core",
"esbuild",
"puppeteer",
"supabase"
]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
/* /index.html 200

103
MEDICONNECT 2/src/App.tsx Normal file
View File

@ -0,0 +1,103 @@
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { Toaster } from "react-hot-toast";
import Header from "./components/Header";
import AccessibilityMenu from "./components/AccessibilityMenu";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import Home from "./pages/Home";
import LoginPaciente from "./pages/LoginPaciente";
import LoginSecretaria from "./pages/LoginSecretaria";
import LoginMedico from "./pages/LoginMedico";
import AgendamentoPaciente from "./pages/AgendamentoPaciente";
import AcompanhamentoPaciente from "./pages/AcompanhamentoPaciente";
import PainelMedico from "./pages/PainelMedico";
import PainelSecretaria from "./pages/PainelSecretaria";
import ProntuarioPaciente from "./pages/ProntuarioPaciente";
import TokenInspector from "./pages/TokenInspector";
import AdminDiagnostico from "./pages/AdminDiagnostico";
// import TesteCadastroSquad18 from "./pages/TesteCadastroSquad18"; // Arquivo removido
import PainelAdmin from "./pages/PainelAdmin";
import CentralAjudaRouter from "./pages/CentralAjudaRouter";
import PerfilMedico from "./pages/PerfilMedico";
import PerfilPaciente from "./pages/PerfilPaciente";
import ClearCache from "./pages/ClearCache";
import AuthCallback from "./pages/AuthCallback";
function App() {
return (
<Router
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<div className="app-root min-h-screen bg-gray-50 dark:bg-slate-900 dark:bg-gradient-to-br dark:from-slate-900 dark:to-slate-800 transition-colors duration-300">
<a
href="#main-content"
className="fixed -top-20 left-4 z-50 px-3 py-2 bg-blue-600 text-white rounded shadow transition-all focus:top-4 focus:outline-none focus-visual:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
>
Pular para o conteúdo
</a>
<Header />
<main id="main-content" className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/clear-cache" element={<ClearCache />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/paciente" element={<LoginPaciente />} />
<Route path="/login-secretaria" element={<LoginSecretaria />} />
<Route path="/login-medico" element={<LoginMedico />} />
<Route path="/dev/token" element={<TokenInspector />} />
<Route path="/admin/diagnostico" element={<AdminDiagnostico />} />
{/* <Route path="/teste-squad18" element={<TesteCadastroSquad18 />} /> */}
<Route path="/ajuda" element={<CentralAjudaRouter />} />
<Route element={<ProtectedRoute roles={["admin", "gestor"]} />}>
<Route path="/admin" element={<PainelAdmin />} />
</Route>
<Route
element={
<ProtectedRoute
roles={["medico", "gestor", "secretaria", "admin"]}
/>
}
>
<Route path="/painel-medico" element={<PainelMedico />} />
<Route path="/perfil-medico" element={<PerfilMedico />} />
</Route>
<Route
element={
<ProtectedRoute roles={["secretaria", "gestor", "admin"]} />
}
>
<Route path="/painel-secretaria" element={<PainelSecretaria />} />
<Route path="/pacientes/:id" element={<ProntuarioPaciente />} />
</Route>
<Route
element={
<ProtectedRoute
roles={["paciente", "user", "admin", "gestor"]}
/>
}
>
<Route
path="/acompanhamento"
element={<AcompanhamentoPaciente />}
/>
<Route path="/agendamento" element={<AgendamentoPaciente />} />
<Route path="/perfil-paciente" element={<PerfilPaciente />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
<Toaster position="top-right" />
<AccessibilityMenu />
</div>
</Router>
);
}
export default App;

View File

@ -1,378 +1,378 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
Accessibility,
Plus,
Minus,
X,
Volume2,
Moon,
Sun,
} from "lucide-react";
import { useAccessibilityPrefs } from "../hooks/useAccessibilityPrefs";
// IDs para acessibilidade do diálogo
const DIALOG_TITLE_ID = "a11y-menu-title";
const DIALOG_DESC_ID = "a11y-menu-desc";
const AccessibilityMenu: React.FC = () => {
// Debug render marker (can be removed after tests stabilize)
if (typeof window !== "undefined") {
console.log("[AccessibilityMenu] render");
}
const [isOpen, setIsOpen] = useState(false);
const { prefs, update, reset } = useAccessibilityPrefs();
const [speakingEnabled, setSpeakingEnabled] = useState(false);
const triggerBtnRef = useRef<HTMLButtonElement | null>(null);
const firstInteractiveRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
// Sincroniza state auxiliar do TTS
useEffect(() => {
setSpeakingEnabled(prefs.textToSpeech);
}, [prefs.textToSpeech]);
// Text-to-speech por hover (limite de 180 chars para evitar leitura de páginas inteiras)
useEffect(() => {
// Skip entirely in test environment or if TTS not supported
// vitest exposes import.meta.vitest
// Also guard window.speechSynthesis existence.
// This prevents potential jsdom issues masking component render.
if (
typeof window === "undefined" ||
typeof (window as unknown as { speechSynthesis?: unknown })
.speechSynthesis === "undefined"
)
return;
// Detect Vitest environment without using any casts
// @ts-expect-error vitest flag injected at runtime during tests
if (import.meta.vitest) return;
if (!speakingEnabled) return;
const handleOver = (e: MouseEvent) => {
const t = e.target as HTMLElement;
if (!t) return;
const text = t.innerText?.trim();
if (text && text.length <= 180) {
if (window.speechSynthesis) {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = "pt-BR";
u.rate = 0.95;
window.speechSynthesis.speak(u);
}
}
};
document.addEventListener("mouseover", handleOver);
return () => document.removeEventListener("mouseover", handleOver);
}, [speakingEnabled]);
// Atalhos de teclado (Alt + A abre / ESC fecha)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.altKey && (e.key === "a" || e.key === "A")) {
e.preventDefault();
setIsOpen((o) => !o);
}
if (e.key === "Escape" && isOpen) {
setIsOpen(false);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
// Foco inicial quando abre / restaura foco ao fechar
useEffect(() => {
if (isOpen) {
triggerBtnRef.current = document.querySelector(
'button[aria-label="Menu de Acessibilidade"]'
);
setTimeout(() => firstInteractiveRef.current?.focus(), 10);
} else {
triggerBtnRef.current?.focus?.();
}
}, [isOpen]);
// Trap de foco simples
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) return;
if (e.key === "Tab" && dialogRef.current) {
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const list = Array.from(focusables).filter(
(el) => !el.hasAttribute("disabled")
);
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
const active = document.activeElement as HTMLElement;
if (e.shiftKey) {
if (active === first) {
e.preventDefault();
last.focus();
}
} else {
if (active === last) {
e.preventDefault();
first.focus();
}
}
}
},
[isOpen]
);
// Ajustes de fonte centralizados pelo hook; apenas limites aqui
const increaseFont = () =>
update({ fontSize: Math.min(160, prefs.fontSize + 10) });
const decreaseFont = () =>
update({ fontSize: Math.max(80, prefs.fontSize - 10) });
const toggle = (k: keyof typeof prefs) =>
update({ [k]: !prefs[k] } as Partial<typeof prefs>);
const handleReset = () => {
if (window.speechSynthesis) window.speechSynthesis.cancel();
reset();
};
const sectionTitle = (title: string) => (
<h4 className="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-300">
{title}
</h4>
);
return (
<>
<button
onClick={() => setIsOpen((o) => !o)}
className="fixed bottom-6 right-6 z-50 bg-blue-600 text-white p-4 rounded-full shadow-lg hover:bg-blue-700 transition-all duration-300 hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
aria-label="Menu de Acessibilidade"
title="Abrir menu de acessibilidade"
data-testid="a11y-menu-trigger"
>
<Accessibility className="w-6 h-6" />
</button>
{isOpen && (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={DIALOG_TITLE_ID}
aria-describedby={DIALOG_DESC_ID}
className="fixed bottom-24 right-6 z-50 bg-white dark:bg-slate-800 rounded-lg shadow-2xl w-80 border-2 border-blue-600 transition-all duration-300 animate-slideIn focus:outline-none max-h-[calc(100vh-7rem)]"
onKeyDown={onKeyDown}
>
<div className="flex items-center justify-between p-6 pb-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Accessibility className="w-5 h-5 text-blue-600" />
<h3
id={DIALOG_TITLE_ID}
className="font-bold text-lg text-gray-900 dark:text-white"
>
Acessibilidade
</h3>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
aria-label="Fechar menu"
>
<X className="w-5 h-5" />
</button>
</div>
<p id={DIALOG_DESC_ID} className="sr-only">
Ajustes visuais e funcionais para leitura, contraste e foco.
</p>
<div
className="space-y-5 overflow-y-auto p-6 pt-4"
style={{
maxHeight: "calc(100vh - 15rem)",
scrollbarWidth: "thin",
scrollbarColor: "#3b82f6 #e5e7eb",
}}
>
{/* Tamanho da fonte */}
<div ref={firstInteractiveRef} tabIndex={-1}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tamanho da Fonte: {prefs.fontSize}%
</label>
<div className="flex items-center gap-2">
<button
onClick={decreaseFont}
className="p-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={prefs.fontSize <= 80}
aria-label="Diminuir fonte"
>
<Minus className="w-4 h-4" />
</button>
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${((prefs.fontSize - 80) / 80) * 100}%` }}
/>
</div>
<button
onClick={increaseFont}
className="p-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={prefs.fontSize >= 160}
aria-label="Aumentar fonte"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{sectionTitle("Temas")}
<ToggleRow
label="Modo Escuro"
active={prefs.darkMode}
onClick={() => toggle("darkMode")}
icon={
prefs.darkMode ? (
<Moon className="w-4 h-4 text-blue-400" />
) : (
<Sun className="w-4 h-4 text-yellow-500" />
)
}
description={
prefs.darkMode ? "Tema escuro ativo" : "Tema claro ativo"
}
/>
<ToggleRow
label="Alto Contraste"
active={prefs.highContrast}
onClick={() => toggle("highContrast")}
description={
prefs.highContrast ? "Contraste máximo" : "Contraste padrão"
}
/>
<ToggleRow
label="Filtro Amarelo (Luz Azul)"
active={prefs.lowBlueLight}
onClick={() => toggle("lowBlueLight")}
description="Reduz luz azul para conforto visual"
/>
{sectionTitle("Leitura & Foco")}
<ToggleRow
label="Fonte Disléxica"
active={prefs.dyslexicFont}
onClick={() => toggle("dyslexicFont")}
description="Fonte alternativa para facilitar leitura"
/>
<ToggleRow
label="Espaçamento de Linha"
active={prefs.lineSpacing}
onClick={() => toggle("lineSpacing")}
description="Aumenta o espaçamento entre linhas"
/>
<ToggleRow
label="Modo Foco"
active={prefs.focusMode}
onClick={() => toggle("focusMode")}
description="Atenua elementos não focados"
/>
<ToggleRow
label="Reduzir Movimento"
active={prefs.reducedMotion}
onClick={() => toggle("reducedMotion")}
description="Remove animações não essenciais"
/>
<ToggleRow
label="Leitura de Texto"
active={prefs.textToSpeech}
onClick={() => toggle("textToSpeech")}
icon={<Volume2 className="w-4 h-4" />}
description="Ler conteúdo ao passar mouse (beta)"
/>
<button
onClick={handleReset}
className="w-full mt-2 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors font-medium"
>
Resetar Configurações
</button>
<button
onClick={() => {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}}
className="w-full mt-2 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors font-medium flex items-center justify-center gap-2"
title="Limpa cache e sessão, recarrega a página"
>
🔄 Limpar Cache e Sessão
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-2">
Atalho: Alt + A | ESC fecha
</p>
</div>
</div>
)}
{/* Script inline removido (substituído por useEffect de teclado) */}
</>
);
};
interface ToggleRowProps {
label: string;
active: boolean;
onClick: () => void;
description?: string;
icon?: React.ReactNode;
}
const ToggleRow: React.FC<ToggleRowProps> = ({
label,
active,
onClick,
description,
icon,
}) => {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
{icon}
{label}
</label>
<div className="flex items-center gap-2">
<button
onClick={onClick}
className={
"a11y-toggle-button relative inline-flex h-7 w-14 items-center rounded-full focus:outline-none" +
" a11y-toggle-track " +
(active ? " ring-offset-0" : " opacity-90 hover:opacity-100")
}
data-active={active}
aria-pressed={active}
aria-label={label}
type="button"
>
<span
className={
"a11y-toggle-thumb inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform " +
(active ? "translate-x-8" : "translate-x-1")
}
/>
</button>
<span className="a11y-toggle-status-label select-none text-xs font-medium text-gray-600 dark:text-gray-400 min-w-[2rem] text-center">
{active ? "ON" : "OFF"}
</span>
</div>
</div>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
);
};
export default AccessibilityMenu;
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
Accessibility,
Plus,
Minus,
X,
Volume2,
Moon,
Sun,
} from "lucide-react";
import { useAccessibilityPrefs } from "../hooks/useAccessibilityPrefs";
// IDs para acessibilidade do diálogo
const DIALOG_TITLE_ID = "a11y-menu-title";
const DIALOG_DESC_ID = "a11y-menu-desc";
const AccessibilityMenu: React.FC = () => {
// Debug render marker (can be removed after tests stabilize)
if (typeof window !== "undefined") {
console.log("[AccessibilityMenu] render");
}
const [isOpen, setIsOpen] = useState(false);
const { prefs, update, reset } = useAccessibilityPrefs();
const [speakingEnabled, setSpeakingEnabled] = useState(false);
const triggerBtnRef = useRef<HTMLButtonElement | null>(null);
const firstInteractiveRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
// Sincroniza state auxiliar do TTS
useEffect(() => {
setSpeakingEnabled(prefs.textToSpeech);
}, [prefs.textToSpeech]);
// Text-to-speech por hover (limite de 180 chars para evitar leitura de páginas inteiras)
useEffect(() => {
// Skip entirely in test environment or if TTS not supported
// vitest exposes import.meta.vitest
// Also guard window.speechSynthesis existence.
// This prevents potential jsdom issues masking component render.
if (
typeof window === "undefined" ||
typeof (window as unknown as { speechSynthesis?: unknown })
.speechSynthesis === "undefined"
)
return;
// Detect Vitest environment without using any casts
// @ts-expect-error vitest flag injected at runtime during tests
if (import.meta.vitest) return;
if (!speakingEnabled) return;
const handleOver = (e: MouseEvent) => {
const t = e.target as HTMLElement;
if (!t) return;
const text = t.innerText?.trim();
if (text && text.length <= 180) {
if (window.speechSynthesis) {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = "pt-BR";
u.rate = 0.95;
window.speechSynthesis.speak(u);
}
}
};
document.addEventListener("mouseover", handleOver);
return () => document.removeEventListener("mouseover", handleOver);
}, [speakingEnabled]);
// Atalhos de teclado (Alt + A abre / ESC fecha)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.altKey && (e.key === "a" || e.key === "A")) {
e.preventDefault();
setIsOpen((o) => !o);
}
if (e.key === "Escape" && isOpen) {
setIsOpen(false);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
// Foco inicial quando abre / restaura foco ao fechar
useEffect(() => {
if (isOpen) {
triggerBtnRef.current = document.querySelector(
'button[aria-label="Menu de Acessibilidade"]'
);
setTimeout(() => firstInteractiveRef.current?.focus(), 10);
} else {
triggerBtnRef.current?.focus?.();
}
}, [isOpen]);
// Trap de foco simples
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen) return;
if (e.key === "Tab" && dialogRef.current) {
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const list = Array.from(focusables).filter(
(el) => !el.hasAttribute("disabled")
);
if (!list.length) return;
const first = list[0];
const last = list[list.length - 1];
const active = document.activeElement as HTMLElement;
if (e.shiftKey) {
if (active === first) {
e.preventDefault();
last.focus();
}
} else {
if (active === last) {
e.preventDefault();
first.focus();
}
}
}
},
[isOpen]
);
// Ajustes de fonte centralizados pelo hook; apenas limites aqui
const increaseFont = () =>
update({ fontSize: Math.min(160, prefs.fontSize + 10) });
const decreaseFont = () =>
update({ fontSize: Math.max(80, prefs.fontSize - 10) });
const toggle = (k: keyof typeof prefs) =>
update({ [k]: !prefs[k] } as Partial<typeof prefs>);
const handleReset = () => {
if (window.speechSynthesis) window.speechSynthesis.cancel();
reset();
};
const sectionTitle = (title: string) => (
<h4 className="text-xs font-semibold uppercase tracking-wide text-blue-600 dark:text-blue-300">
{title}
</h4>
);
return (
<>
<button
onClick={() => setIsOpen((o) => !o)}
className="fixed bottom-6 right-6 z-50 bg-blue-600 text-white p-4 rounded-full shadow-lg hover:bg-blue-700 transition-all duration-300 hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
aria-label="Menu de Acessibilidade"
title="Abrir menu de acessibilidade"
data-testid="a11y-menu-trigger"
>
<Accessibility className="w-6 h-6" />
</button>
{isOpen && (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={DIALOG_TITLE_ID}
aria-describedby={DIALOG_DESC_ID}
className="fixed bottom-24 right-6 z-50 bg-white dark:bg-slate-800 rounded-lg shadow-2xl w-80 border-2 border-blue-600 transition-all duration-300 animate-slideIn focus:outline-none max-h-[calc(100vh-7rem)]"
onKeyDown={onKeyDown}
>
<div className="flex items-center justify-between p-6 pb-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Accessibility className="w-5 h-5 text-blue-600" />
<h3
id={DIALOG_TITLE_ID}
className="font-bold text-lg text-gray-900 dark:text-white"
>
Acessibilidade
</h3>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
aria-label="Fechar menu"
>
<X className="w-5 h-5" />
</button>
</div>
<p id={DIALOG_DESC_ID} className="sr-only">
Ajustes visuais e funcionais para leitura, contraste e foco.
</p>
<div
className="space-y-5 overflow-y-auto p-6 pt-4"
style={{
maxHeight: "calc(100vh - 15rem)",
scrollbarWidth: "thin",
scrollbarColor: "#3b82f6 #e5e7eb",
}}
>
{/* Tamanho da fonte */}
<div ref={firstInteractiveRef} tabIndex={-1}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tamanho da Fonte: {prefs.fontSize}%
</label>
<div className="flex items-center gap-2">
<button
onClick={decreaseFont}
className="p-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={prefs.fontSize <= 80}
aria-label="Diminuir fonte"
>
<Minus className="w-4 h-4" />
</button>
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${((prefs.fontSize - 80) / 80) * 100}%` }}
/>
</div>
<button
onClick={increaseFont}
className="p-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={prefs.fontSize >= 160}
aria-label="Aumentar fonte"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{sectionTitle("Temas")}
<ToggleRow
label="Modo Escuro"
active={prefs.darkMode}
onClick={() => toggle("darkMode")}
icon={
prefs.darkMode ? (
<Moon className="w-4 h-4 text-blue-400" />
) : (
<Sun className="w-4 h-4 text-yellow-500" />
)
}
description={
prefs.darkMode ? "Tema escuro ativo" : "Tema claro ativo"
}
/>
<ToggleRow
label="Alto Contraste"
active={prefs.highContrast}
onClick={() => toggle("highContrast")}
description={
prefs.highContrast ? "Contraste máximo" : "Contraste padrão"
}
/>
<ToggleRow
label="Filtro Amarelo (Luz Azul)"
active={prefs.lowBlueLight}
onClick={() => toggle("lowBlueLight")}
description="Reduz luz azul para conforto visual"
/>
{sectionTitle("Leitura & Foco")}
<ToggleRow
label="Fonte Disléxica"
active={prefs.dyslexicFont}
onClick={() => toggle("dyslexicFont")}
description="Fonte alternativa para facilitar leitura"
/>
<ToggleRow
label="Espaçamento de Linha"
active={prefs.lineSpacing}
onClick={() => toggle("lineSpacing")}
description="Aumenta o espaçamento entre linhas"
/>
<ToggleRow
label="Modo Foco"
active={prefs.focusMode}
onClick={() => toggle("focusMode")}
description="Atenua elementos não focados"
/>
<ToggleRow
label="Reduzir Movimento"
active={prefs.reducedMotion}
onClick={() => toggle("reducedMotion")}
description="Remove animações não essenciais"
/>
<ToggleRow
label="Leitura de Texto"
active={prefs.textToSpeech}
onClick={() => toggle("textToSpeech")}
icon={<Volume2 className="w-4 h-4" />}
description="Ler conteúdo ao passar mouse (beta)"
/>
<button
onClick={handleReset}
className="w-full mt-2 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors font-medium"
>
Resetar Configurações
</button>
<button
onClick={() => {
localStorage.clear();
sessionStorage.clear();
window.location.reload();
}}
className="w-full mt-2 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors font-medium flex items-center justify-center gap-2"
title="Limpa cache e sessão, recarrega a página"
>
🔄 Limpar Cache e Sessão
</button>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center pt-2">
Atalho: Alt + A | ESC fecha
</p>
</div>
</div>
)}
{/* Script inline removido (substituído por useEffect de teclado) */}
</>
);
};
interface ToggleRowProps {
label: string;
active: boolean;
onClick: () => void;
description?: string;
icon?: React.ReactNode;
}
const ToggleRow: React.FC<ToggleRowProps> = ({
label,
active,
onClick,
description,
icon,
}) => {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
{icon}
{label}
</label>
<div className="flex items-center gap-2">
<button
onClick={onClick}
className={
"a11y-toggle-button relative inline-flex h-7 w-14 items-center rounded-full focus:outline-none" +
" a11y-toggle-track " +
(active ? " ring-offset-0" : " opacity-90 hover:opacity-100")
}
data-active={active}
aria-pressed={active}
aria-label={label}
type="button"
>
<span
className={
"a11y-toggle-thumb inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform " +
(active ? "translate-x-8" : "translate-x-1")
}
/>
</button>
<span className="a11y-toggle-status-label select-none text-xs font-medium text-gray-600 dark:text-gray-400 min-w-[2rem] text-center">
{active ? "ON" : "OFF"}
</span>
</div>
</div>
{description && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
);
};
export default AccessibilityMenu;

View File

@ -0,0 +1,698 @@
import { useState, useEffect, useCallback } from "react";
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
isBefore,
startOfDay,
} from "date-fns";
import { ptBR } from "date-fns/locale";
import {
MapPin,
Video,
Clock,
ChevronLeft,
ChevronRight,
Stethoscope,
AlertCircle,
CheckCircle2,
Search,
} from "lucide-react";
import {
availabilityService,
exceptionsService,
appointmentService,
smsService,
} from "../services";
import { useAuth } from "../hooks/useAuth";
interface Medico {
id: string;
nome: string;
especialidade: string;
crm: string;
foto?: string;
email?: string;
telefone?: string;
valorConsulta?: number;
}
interface TimeSlot {
inicio: string;
fim: string;
ativo: boolean;
}
interface DaySchedule {
ativo: boolean;
horarios: TimeSlot[];
}
interface Availability {
domingo: DaySchedule;
segunda: DaySchedule;
terca: DaySchedule;
quarta: DaySchedule;
quinta: DaySchedule;
sexta: DaySchedule;
sabado: DaySchedule;
}
interface Exception {
id: string;
data: string;
motivo?: string;
}
const dayOfWeekMap: { [key: number]: keyof Availability } = {
0: "domingo",
1: "segunda",
2: "terca",
3: "quarta",
4: "quinta",
5: "sexta",
6: "sabado",
};
interface AgendamentoConsultaProps {
medicos: Medico[];
}
export default function AgendamentoConsulta({
medicos,
}: AgendamentoConsultaProps) {
const { user } = useAuth();
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>(medicos);
// Sempre que a lista de médicos da API mudar, atualiza o filtro
useEffect(() => {
setFilteredMedicos(medicos);
}, [medicos]);
const [selectedMedico, setSelectedMedico] = useState<Medico | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [availability, setAvailability] = useState<Availability | null>(null);
const [exceptions, setExceptions] = useState<Exception[]>([]);
const [availableSlots, setAvailableSlots] = useState<string[]>([]);
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState<
"presencial" | "online"
>("presencial");
const [motivo, setMotivo] = useState("");
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [bookingSuccess, setBookingSuccess] = useState(false);
const [bookingError, setBookingError] = useState("");
// Removido o carregamento interno de médicos, pois agora vem por prop
useEffect(() => {
let filtered = medicos;
if (searchTerm) {
filtered = filtered.filter(
(medico) =>
medico.nome.toLowerCase().includes(searchTerm.toLowerCase()) ||
medico.especialidade.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (selectedSpecialty !== "all") {
filtered = filtered.filter(
(medico) => medico.especialidade === selectedSpecialty
);
}
setFilteredMedicos(filtered);
}, [searchTerm, selectedSpecialty, medicos]);
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
useEffect(() => {
if (selectedMedico) {
loadDoctorAvailability();
loadDoctorExceptions();
}
// eslint-disable-next-line
}, [selectedMedico]);
const loadDoctorAvailability = useCallback(async () => {
if (!selectedMedico) return;
try {
const response = await availabilityService.getAvailability(
selectedMedico.id
);
if (
response &&
response.success &&
response.data &&
response.data.length > 0
) {
const avail = response.data[0];
setAvailability({
domingo: avail.domingo || { ativo: false, horarios: [] },
segunda: avail.segunda || { ativo: false, horarios: [] },
terca: avail.terca || { ativo: false, horarios: [] },
quarta: avail.quarta || { ativo: false, horarios: [] },
quinta: avail.quinta || { ativo: false, horarios: [] },
sexta: avail.sexta || { ativo: false, horarios: [] },
sabado: avail.sabado || { ativo: false, horarios: [] },
});
} else {
setAvailability(null);
}
} catch {
setAvailability(null);
}
}, [selectedMedico]);
const loadDoctorExceptions = useCallback(async () => {
if (!selectedMedico) return;
try {
const response = await exceptionService.listExceptions({
doctor_id: selectedMedico.id,
});
if (response && response.success && response.data) {
setExceptions(response.data as Exception[]);
} else {
setExceptions([]);
}
} catch {
setExceptions([]);
}
}, [selectedMedico]);
const calculateAvailableSlots = useCallback(() => {
if (!selectedDate || !availability) return;
const dateStr = format(selectedDate, "yyyy-MM-dd");
const isBlocked = exceptions.some((exc) => exc.data === dateStr);
if (isBlocked) {
setAvailableSlots([]);
return;
}
const dayOfWeek = selectedDate.getDay();
const dayKey = dayOfWeekMap[dayOfWeek];
const daySchedule = availability[dayKey];
if (!daySchedule || !daySchedule.ativo) {
setAvailableSlots([]);
return;
}
const slots = daySchedule.horarios
.filter((slot) => slot.ativo)
.map((slot) => slot.inicio);
setAvailableSlots(slots);
}, [selectedDate, availability, exceptions]);
useEffect(() => {
if (selectedDate && availability && selectedMedico) {
calculateAvailableSlots();
} else {
setAvailableSlots([]);
}
}, [
selectedDate,
availability,
exceptions,
calculateAvailableSlots,
selectedMedico,
]);
const isDateBlocked = (date: Date): boolean => {
const dateStr = format(date, "yyyy-MM-dd");
return exceptions.some((exc) => exc.data === dateStr);
};
const isDateAvailable = (date: Date): boolean => {
if (!availability) return false;
if (isBefore(date, startOfDay(new Date()))) return false;
if (isDateBlocked(date)) return false;
const dayOfWeek = date.getDay();
const dayKey = dayOfWeekMap[dayOfWeek];
const daySchedule = availability[dayKey];
return (
daySchedule?.ativo && daySchedule.horarios.some((slot) => slot.ativo)
);
};
const generateCalendarDays = () => {
const start = startOfMonth(currentMonth);
const end = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start, end });
const startDay = start.getDay();
const prevMonthDays = [];
for (let i = startDay - 1; i >= 0; i--) {
const day = new Date(start);
day.setDate(day.getDate() - (i + 1));
prevMonthDays.push(day);
}
return [...prevMonthDays, ...days];
};
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
const handleNextMonth = () => setCurrentMonth(addMonths(currentMonth, 1));
const handleSelectDoctor = (medico: Medico) => {
setSelectedMedico(medico);
setSelectedDate(undefined);
setSelectedTime("");
setMotivo("");
setBookingSuccess(false);
setBookingError("");
};
const handleBookAppointment = () => {
if (selectedMedico && selectedDate && selectedTime && motivo) {
setShowConfirmDialog(true);
}
};
const confirmAppointment = async () => {
if (!selectedMedico || !selectedDate || !selectedTime || !user) return;
try {
setBookingError("");
// Cria o agendamento na API real
const result = await consultasService.criar({
patient_id: user.id,
doctor_id: selectedMedico.id,
scheduled_at:
format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00.000Z",
duration_minutes: 30,
appointment_type: appointmentType,
chief_complaint: motivo,
patient_notes: "",
insurance_provider: "",
});
if (!result.success) {
setBookingError(result.error || "Erro ao agendar consulta");
setShowConfirmDialog(false);
return;
}
// Envia SMS de confirmação (se telefone disponível)
if (user.telefone) {
await smsService.enviarConfirmacaoConsulta(
user.telefone,
user.nome || "Paciente",
selectedMedico.nome,
format(selectedDate, "dd/MM/yyyy") + " às " + selectedTime
);
}
setBookingSuccess(true);
setShowConfirmDialog(false);
setTimeout(() => {
setSelectedMedico(null);
setSelectedDate(undefined);
setSelectedTime("");
setMotivo("");
setBookingSuccess(false);
}, 3000);
} catch (error) {
setBookingError(
error instanceof Error
? error.message
: "Erro ao agendar consulta. Tente novamente."
);
setShowConfirmDialog(false);
}
};
const calendarDays = generateCalendarDays();
return (
<div className="space-y-6">
{bookingSuccess && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium text-green-900">
Consulta agendada com sucesso!
</p>
<p className="text-sm text-green-700">
Você receberá uma confirmação por e-mail em breve.
</p>
</div>
</div>
)}
{bookingError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-600" />
<p className="text-red-900">{bookingError}</p>
</div>
)}
<div>
<h1 className="text-2xl font-bold">Agendar Consulta</h1>
<p className="text-muted-foreground">
Escolha um médico e horário disponível
</p>
</div>
<div className="bg-white rounded-xl border p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="font-medium">
Buscar por nome ou especialidade
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Ex: Cardiologia, Dr. Silva..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 w-full border rounded-lg py-2 px-3"
/>
</div>
</div>
<div className="space-y-2">
<label className="font-medium">Especialidade</label>
<select
value={selectedSpecialty}
onChange={(e) => setSelectedSpecialty(e.target.value)}
className="w-full border rounded-lg py-2 px-3"
>
<option value="all">Todas as especialidades</option>
{specialties.map((esp) => (
<option key={esp} value={esp}>
{esp}
</option>
))}
</select>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredMedicos.map((medico) => (
<div
key={medico.id}
className={`bg-white rounded-xl border p-6 flex gap-4 items-center ${
selectedMedico?.id === medico.id ? "border-blue-500" : ""
}`}
>
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold">
{medico.nome
.split(" ")
.map((n) => n[0])
.join("")}
</div>
<div className="flex-1 space-y-2">
<div>
<h3 className="font-semibold">{medico.nome}</h3>
<p className="text-muted-foreground">{medico.especialidade}</p>
</div>
<div className="flex items-center gap-4 text-muted-foreground">
<span>{medico.crm}</span>
{medico.valorConsulta ? (
<span>R$ {medico.valorConsulta.toFixed(2)}</span>
) : null}
</div>
<div className="flex items-center justify-between">
<span className="text-foreground">{medico.email || "-"}</span>
<div className="flex gap-2">
<button
className="px-3 py-1 rounded-lg border text-sm hover:bg-blue-50"
onClick={() => handleSelectDoctor(medico)}
>
{selectedMedico?.id === medico.id
? "Selecionado"
: "Selecionar"}
</button>
</div>
</div>
</div>
</div>
))}
</div>
{selectedMedico && (
<div className="bg-white rounded-lg shadow p-6 space-y-6">
<div>
<h2 className="text-xl font-semibold">Detalhes do Agendamento</h2>
<p className="text-gray-600">
Consulta com {selectedMedico.nome} -{" "}
{selectedMedico.especialidade}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setAppointmentType("presencial")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
appointmentType === "presencial"
? "border-blue-500 bg-blue-50 text-blue-600"
: "border-gray-300 text-gray-600"
}`}
>
<MapPin className="h-5 w-5" />
<span className="font-medium">Presencial</span>
</button>
<button
onClick={() => setAppointmentType("online")}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
appointmentType === "online"
? "border-blue-500 bg-blue-50 text-blue-600"
: "border-gray-300 text-gray-600"
}`}
>
<Video className="h-5 w-5" />
<span className="font-medium">Online</span>
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Selecione a Data</label>
<div className="mt-2">
<div className="flex items-center justify-between mb-4">
<button
onClick={handlePrevMonth}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="font-semibold">
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
</span>
<button
onClick={handleNextMonth}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-7 bg-gray-50">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map(
(day) => (
<div
key={day}
className="text-center py-2 text-sm font-medium text-gray-600"
>
{day}
</div>
)
)}
</div>
<div className="grid grid-cols-7">
{calendarDays.map((day, index) => {
const isCurrentMonth = isSameMonth(day, currentMonth);
const isSelected =
selectedDate && isSameDay(day, selectedDate);
const isTodayDate = isToday(day);
const isAvailable =
isCurrentMonth && isDateAvailable(day);
const isBlocked = isCurrentMonth && isDateBlocked(day);
const isPast = isBefore(day, startOfDay(new Date()));
return (
<button
key={index}
onClick={() => isAvailable && setSelectedDate(day)}
disabled={!isAvailable}
className={`aspect-square p-2 text-sm border-r border-b border-gray-200 ${
!isCurrentMonth ? "text-gray-300 bg-gray-50" : ""
} ${
isSelected
? "bg-blue-600 text-white font-bold"
: ""
} ${
isTodayDate && !isSelected
? "font-bold text-blue-600"
: ""
} ${
isAvailable && !isSelected
? "hover:bg-blue-50 cursor-pointer"
: ""
} ${
isBlocked
? "bg-red-50 text-red-400 line-through"
: ""
} ${isPast && !isBlocked ? "text-gray-400" : ""} ${
!isAvailable &&
!isBlocked &&
isCurrentMonth &&
!isPast
? "text-gray-300"
: ""
}`}
>
{format(day, "d")}
</button>
);
})}
</div>
</div>
<div className="mt-3 space-y-1 text-xs text-gray-600">
<p>🟢 Datas disponíveis</p>
<p>🔴 Datas bloqueadas</p>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
Horários Disponíveis
</label>
{selectedDate ? (
<p className="text-sm text-gray-600 mt-1">
{format(selectedDate, "EEEE, d 'de' MMMM 'de' yyyy", {
locale: ptBR,
})}
</p>
) : (
<p className="text-sm text-gray-600 mt-1">
Selecione uma data
</p>
)}
</div>
{selectedDate && availableSlots.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{availableSlots.map((slot) => (
<button
key={slot}
onClick={() => setSelectedTime(slot)}
className={`flex items-center justify-center gap-1 py-2 rounded-lg border-2 transition-colors ${
selectedTime === slot
? "border-blue-500 bg-blue-50 text-blue-600 font-medium"
: "border-gray-300 hover:border-blue-300"
}`}
>
<Clock className="h-3 w-3" />
{slot}
</button>
))}
</div>
) : selectedDate ? (
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
<p className="text-gray-600">
Nenhum horário disponível para esta data
</p>
</div>
) : (
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
<p className="text-gray-600">
Selecione uma data para ver os horários
</p>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium">
Motivo da Consulta *
</label>
<textarea
placeholder="Descreva brevemente o motivo da consulta..."
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
rows={4}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{selectedDate && selectedTime && (
<div className="p-4 bg-blue-50 rounded-lg space-y-2">
<h4 className="font-semibold">Resumo</h4>
<div className="space-y-1 text-sm text-gray-600">
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
<p> Horário: {selectedTime}</p>
<p>
📍 Tipo:{" "}
{appointmentType === "online" ? "Online" : "Presencial"}
</p>
{selectedMedico.valorConsulta && (
<p>
💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}
</p>
)}
</div>
</div>
)}
<button
onClick={handleBookAppointment}
disabled={!selectedTime || !motivo.trim()}
className="w-full py-3 rounded-lg font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
Confirmar Agendamento
</button>
</div>
</div>
</div>
)}
{showConfirmDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-semibold">Confirmar Agendamento</h3>
<p className="text-gray-600">
Revise os detalhes da sua consulta antes de confirmar
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
{selectedMedico?.nome
.split(" ")
.map((n) => n[0])
.join("")
.substring(0, 2)}
</div>
<div>
<p className="font-medium text-gray-900">
{selectedMedico?.nome}
</p>
<p className="text-sm text-gray-600">
{selectedMedico?.especialidade}
</p>
</div>
</div>
<div className="space-y-2 text-sm text-gray-600">
<p>
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
</p>
<p> Horário: {selectedTime}</p>
<p>
📍 Tipo:{" "}
{appointmentType === "online"
? "Consulta Online"
: "Consulta Presencial"}
</p>
{selectedMedico?.valorConsulta && (
<p>💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}</p>
)}
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="font-medium text-gray-900 mb-1">Motivo:</p>
<p className="text-gray-600">{motivo}</p>
</div>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={() => setShowConfirmDialog(false)}
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={confirmAppointment}
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors font-medium"
>
Confirmar
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,92 +1,92 @@
import React, { useState } from "react";
import { format, addDays } from "date-fns";
import toast from "react-hot-toast";
import { consultasLocalService } from "../services/consultasLocalService";
interface Medico {
id: string;
nome: string;
especialidade: string;
crm: string;
valorConsulta?: number;
}
interface AgendamentoConsultaSimplesProps {
medico: Medico | null;
}
export default function AgendamentoConsultaSimples({ medico }: AgendamentoConsultaSimplesProps) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [selectedTime, setSelectedTime] = useState("");
const [motivo, setMotivo] = useState("");
const [loading, setLoading] = useState(false);
const handleConfirmAppointment = async () => {
try {
if (!medico || !selectedDate) {
toast.error("Selecione um médico e uma data válida.");
return;
}
const pacienteId = "default";
const dataHoraFormatted =
format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00";
consultasLocalService.saveConsulta({
medicoId: medico.id,
medicoNome: medico.nome,
especialidade: medico.especialidade,
dataHora: dataHoraFormatted,
tipo: "presencial",
motivo: motivo.trim(),
status: "agendada",
valorConsulta: medico.valorConsulta || 0,
pacienteId
});
await new Promise((resolve) => setTimeout(resolve, 1500));
toast.success("Consulta agendada com sucesso!");
setSelectedDate(null);
setSelectedTime("");
setMotivo("");
} catch (error) {
console.error("Erro ao agendar consulta:", error);
toast.error("Erro ao agendar consulta. Tente novamente.");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h2 className="text-xl font-bold mb-4">Agendar Consulta</h2>
{medico ? (
<>
<div className="mb-4">
<label>Data:</label>
<select value={selectedDate?.toISOString() || ""} onChange={e => setSelectedDate(e.target.value ? new Date(e.target.value) : null)}>
<option value="">Selecione uma data</option>
{Array.from({ length: 30 }, (_, i) => {
const date = addDays(new Date(), i + 1);
if (date.getDay() === 0) return null;
return (
<option key={date.toISOString()} value={date.toISOString()}>{date.toLocaleDateString()}</option>
);
})}
</select>
</div>
<div className="mb-4">
<label>Horário:</label>
<input value={selectedTime} onChange={e => setSelectedTime(e.target.value)} placeholder="Ex: 09:00" />
</div>
<div className="mb-4">
<label>Motivo:</label>
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Motivo da consulta" />
</div>
<button onClick={handleConfirmAppointment} disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded">
Agendar
</button>
</>
) : (
<div className="text-red-600">Médico não encontrado.</div>
)}
</div>
);
}
import React, { useState } from "react";
import { format, addDays } from "date-fns";
import toast from "react-hot-toast";
import { consultasLocalService } from "../services/consultasLocalService";
interface Medico {
id: string;
nome: string;
especialidade: string;
crm: string;
valorConsulta?: number;
}
interface AgendamentoConsultaSimplesProps {
medico: Medico | null;
}
export default function AgendamentoConsultaSimples({ medico }: AgendamentoConsultaSimplesProps) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [selectedTime, setSelectedTime] = useState("");
const [motivo, setMotivo] = useState("");
const [loading, setLoading] = useState(false);
const handleConfirmAppointment = async () => {
try {
if (!medico || !selectedDate) {
toast.error("Selecione um médico e uma data válida.");
return;
}
const pacienteId = "default";
const dataHoraFormatted =
format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00";
consultasLocalService.saveConsulta({
medicoId: medico.id,
medicoNome: medico.nome,
especialidade: medico.especialidade,
dataHora: dataHoraFormatted,
tipo: "presencial",
motivo: motivo.trim(),
status: "agendada",
valorConsulta: medico.valorConsulta || 0,
pacienteId
});
await new Promise((resolve) => setTimeout(resolve, 1500));
toast.success("Consulta agendada com sucesso!");
setSelectedDate(null);
setSelectedTime("");
setMotivo("");
} catch (error) {
console.error("Erro ao agendar consulta:", error);
toast.error("Erro ao agendar consulta. Tente novamente.");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h2 className="text-xl font-bold mb-4">Agendar Consulta</h2>
{medico ? (
<>
<div className="mb-4">
<label>Data:</label>
<select value={selectedDate?.toISOString() || ""} onChange={e => setSelectedDate(e.target.value ? new Date(e.target.value) : null)}>
<option value="">Selecione uma data</option>
{Array.from({ length: 30 }, (_, i) => {
const date = addDays(new Date(), i + 1);
if (date.getDay() === 0) return null;
return (
<option key={date.toISOString()} value={date.toISOString()}>{date.toLocaleDateString()}</option>
);
})}
</select>
</div>
<div className="mb-4">
<label>Horário:</label>
<input value={selectedTime} onChange={e => setSelectedTime(e.target.value)} placeholder="Ex: 09:00" />
</div>
<div className="mb-4">
<label>Motivo:</label>
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Motivo da consulta" />
</div>
<button onClick={handleConfirmAppointment} disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded">
Agendar
</button>
</>
) : (
<div className="text-red-600">Médico não encontrado.</div>
)}
</div>
);
}

View File

@ -1,418 +1,418 @@
import React, { useEffect, useState } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "./MetricCard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Calendar } from "@/components/ui/calendar";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ENDPOINTS } from "../services/endpoints";
import api from "../services/api";
// Adapte conforme o seu projeto
const months = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 10 }, (_, i) => currentYear - 2 + i);
export default function BookAppointment() {
const [doctors, setDoctors] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
new Date()
);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDoctor, setSelectedDoctor] = useState<any | null>(null);
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState<
"presential" | "online"
>("presential");
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
// Busca médicos da API
api
.get(ENDPOINTS.DOCTORS)
.then((res) => setDoctors(res.data))
.catch(() => setDoctors([]));
}, []);
const filteredDoctors = doctors.filter((doctor) => {
const matchesSearch =
doctor.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
doctor.specialty?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSpecialty =
selectedSpecialty === "all" || doctor.specialty === selectedSpecialty;
return matchesSearch && matchesSpecialty;
});
const handleBookAppointment = () => {
if (selectedDoctor && selectedTime) {
setShowConfirmDialog(true);
}
};
const confirmAppointment = async () => {
if (!selectedDoctor || !selectedTime || !selectedDate) return;
setLoading(true);
try {
await api.post(ENDPOINTS.APPOINTMENTS, {
doctor_id: selectedDoctor.id,
date: selectedDate.toISOString().split("T")[0],
time: selectedTime,
type: appointmentType,
reason,
});
alert("Agendamento realizado com sucesso!");
setShowConfirmDialog(false);
setSelectedDoctor(null);
setSelectedTime("");
setReason("");
} catch (e) {
alert("Erro ao agendar consulta");
} finally {
setLoading(false);
}
};
const handleMonthChange = (month: string) => {
const newDate = new Date(currentMonth.getFullYear(), Number(month));
setCurrentMonth(newDate);
};
const handleYearChange = (year: string) => {
const newDate = new Date(Number(year), currentMonth.getMonth());
setCurrentMonth(newDate);
};
return (
<div className="space-y-6">
<div>
<h1>Agendar Consulta</h1>
<p className="text-muted-foreground">
Escolha um médico e horário disponível
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Buscar Médicos</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Buscar por nome ou especialidade</Label>
<Input
placeholder="Ex: Cardiologia, Dr. Silva..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Especialidade</Label>
<Select
value={selectedSpecialty}
onValueChange={setSelectedSpecialty}
>
<SelectTrigger>
<SelectValue placeholder="Todas as especialidades" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as especialidades</SelectItem>
{/* Adapte para especialidades reais */}
<SelectItem value="Cardiologia">Cardiologia</SelectItem>
<SelectItem value="Dermatologia">Dermatologia</SelectItem>
<SelectItem value="Ortopedia">Ortopedia</SelectItem>
<SelectItem value="Pediatria">Pediatria</SelectItem>
<SelectItem value="Ginecologia">Ginecologia</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredDoctors.map((doctor) => (
<Card
key={doctor.id}
className={selectedDoctor?.id === doctor.id ? "border-primary" : ""}
>
<CardContent className="pt-6">
<div className="flex gap-4">
{/* Adapte para seu componente de avatar */}
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center">
{doctor.name
?.split(" ")
.map((n: string) => n[0])
.join("")}
</div>
<div className="flex-1 space-y-2">
<div>
<h3>{doctor.name}</h3>
<p className="text-muted-foreground">{doctor.specialty}</p>
</div>
<div className="flex items-center gap-4 text-muted-foreground">
<div className="flex items-center gap-1">
<span>{doctor.rating || "-"}</span>
</div>
<div className="flex items-center gap-1">
<span>{doctor.location || "-"}</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-foreground">
{doctor.price || "-"}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedDoctor(doctor)}
>
Ver Agenda
</Button>
<Button
size="sm"
variant={
selectedDoctor?.id === doctor.id
? "default"
: "outline"
}
onClick={() => setSelectedDoctor(doctor)}
>
{selectedDoctor?.id === doctor.id
? "Selecionado"
: "Selecionar"}
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{selectedDoctor && (
<Card>
<CardHeader>
<CardTitle>Detalhes do Agendamento</CardTitle>
<CardDescription>
Consulta com {selectedDoctor.name} - {selectedDoctor.specialty}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Tabs
value={appointmentType}
onValueChange={(v) =>
setAppointmentType(v as "presential" | "online")
}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="presential">Presencial</TabsTrigger>
<TabsTrigger value="online">Online</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Select
value={String(currentMonth.getMonth())}
onValueChange={handleMonthChange}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map((month, index) => (
<SelectItem key={index} value={String(index)}>
{month}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={String(currentMonth.getFullYear())}
onValueChange={handleYearChange}
>
<SelectTrigger className="w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
month={currentMonth}
onMonthChange={setCurrentMonth}
className="rounded-md border w-full"
disabled={(date) =>
date < new Date() ||
date.getDay() === 0 ||
date.getDay() === 6
}
/>
<p className="text-muted-foreground">
🔴 Finais de semana não disponíveis
</p>
</div>
<div className="space-y-4">
<div>
<div className="mb-3">
<Label>Horários Disponíveis</Label>
<p className="text-muted-foreground">
{selectedDate?.toLocaleDateString("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</p>
</div>
{/* Adapte para buscar horários reais da API se disponível */}
<div className="grid grid-cols-3 gap-2">
{["09:00", "10:00", "14:00", "15:00", "16:00"].map(
(slot) => (
<Button
key={slot}
variant={
selectedTime === slot ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedTime(slot)}
>
{slot}
</Button>
)
)}
</div>
</div>
<div className="space-y-2">
<Label>Motivo da Consulta</Label>
<Textarea
placeholder="Descreva brevemente o motivo da consulta..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={4}
/>
</div>
<div className="p-4 bg-accent rounded-lg space-y-2">
<h4>Resumo</h4>
<div className="space-y-1 text-muted-foreground">
<p>Data: {selectedDate?.toLocaleDateString("pt-BR")}</p>
<p>Horário: {selectedTime || "Não selecionado"}</p>
<p>
Tipo:{" "}
{appointmentType === "online" ? "Online" : "Presencial"}
</p>
<p>Valor: {selectedDoctor.price || "-"}</p>
</div>
</div>
<Button
className="w-full"
disabled={!selectedTime || !reason || loading}
onClick={handleBookAppointment}
>
Confirmar Agendamento
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar Agendamento</DialogTitle>
<DialogDescription>
Revise os detalhes da sua consulta antes de confirmar
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center">
{selectedDoctor?.name
?.split(" ")
.map((n: string) => n[0])
.join("")}
</div>
<div>
<p className="text-foreground">{selectedDoctor?.name}</p>
<p className="text-muted-foreground">
{selectedDoctor?.specialty}
</p>
</div>
</div>
<div className="space-y-2 text-muted-foreground">
<p>📅 Data: {selectedDate?.toLocaleDateString("pt-BR")}</p>
<p> Horário: {selectedTime}</p>
<p>
📍 Tipo:{" "}
{appointmentType === "online"
? "Consulta Online"
: "Consulta Presencial"}
</p>
<p>💰 Valor: {selectedDoctor?.price || "-"}</p>
<div className="mt-4">
<p className="text-foreground">Motivo:</p>
<p>{reason}</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
Cancelar
</Button>
<Button onClick={confirmAppointment} disabled={loading}>
{loading ? "Agendando..." : "Confirmar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
import React, { useEffect, useState } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "./MetricCard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Calendar } from "@/components/ui/calendar";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ENDPOINTS } from "../services/endpoints";
import api from "../services/api";
// Adapte conforme o seu projeto
const months = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 10 }, (_, i) => currentYear - 2 + i);
export default function BookAppointment() {
const [doctors, setDoctors] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
new Date()
);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDoctor, setSelectedDoctor] = useState<any | null>(null);
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState<
"presential" | "online"
>("presential");
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
// Busca médicos da API
api
.get(ENDPOINTS.DOCTORS)
.then((res) => setDoctors(res.data))
.catch(() => setDoctors([]));
}, []);
const filteredDoctors = doctors.filter((doctor) => {
const matchesSearch =
doctor.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
doctor.specialty?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesSpecialty =
selectedSpecialty === "all" || doctor.specialty === selectedSpecialty;
return matchesSearch && matchesSpecialty;
});
const handleBookAppointment = () => {
if (selectedDoctor && selectedTime) {
setShowConfirmDialog(true);
}
};
const confirmAppointment = async () => {
if (!selectedDoctor || !selectedTime || !selectedDate) return;
setLoading(true);
try {
await api.post(ENDPOINTS.APPOINTMENTS, {
doctor_id: selectedDoctor.id,
date: selectedDate.toISOString().split("T")[0],
time: selectedTime,
type: appointmentType,
reason,
});
alert("Agendamento realizado com sucesso!");
setShowConfirmDialog(false);
setSelectedDoctor(null);
setSelectedTime("");
setReason("");
} catch (e) {
alert("Erro ao agendar consulta");
} finally {
setLoading(false);
}
};
const handleMonthChange = (month: string) => {
const newDate = new Date(currentMonth.getFullYear(), Number(month));
setCurrentMonth(newDate);
};
const handleYearChange = (year: string) => {
const newDate = new Date(Number(year), currentMonth.getMonth());
setCurrentMonth(newDate);
};
return (
<div className="space-y-6">
<div>
<h1>Agendar Consulta</h1>
<p className="text-muted-foreground">
Escolha um médico e horário disponível
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Buscar Médicos</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Buscar por nome ou especialidade</Label>
<Input
placeholder="Ex: Cardiologia, Dr. Silva..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Especialidade</Label>
<Select
value={selectedSpecialty}
onValueChange={setSelectedSpecialty}
>
<SelectTrigger>
<SelectValue placeholder="Todas as especialidades" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as especialidades</SelectItem>
{/* Adapte para especialidades reais */}
<SelectItem value="Cardiologia">Cardiologia</SelectItem>
<SelectItem value="Dermatologia">Dermatologia</SelectItem>
<SelectItem value="Ortopedia">Ortopedia</SelectItem>
<SelectItem value="Pediatria">Pediatria</SelectItem>
<SelectItem value="Ginecologia">Ginecologia</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredDoctors.map((doctor) => (
<Card
key={doctor.id}
className={selectedDoctor?.id === doctor.id ? "border-primary" : ""}
>
<CardContent className="pt-6">
<div className="flex gap-4">
{/* Adapte para seu componente de avatar */}
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center">
{doctor.name
?.split(" ")
.map((n: string) => n[0])
.join("")}
</div>
<div className="flex-1 space-y-2">
<div>
<h3>{doctor.name}</h3>
<p className="text-muted-foreground">{doctor.specialty}</p>
</div>
<div className="flex items-center gap-4 text-muted-foreground">
<div className="flex items-center gap-1">
<span>{doctor.rating || "-"}</span>
</div>
<div className="flex items-center gap-1">
<span>{doctor.location || "-"}</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-foreground">
{doctor.price || "-"}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSelectedDoctor(doctor)}
>
Ver Agenda
</Button>
<Button
size="sm"
variant={
selectedDoctor?.id === doctor.id
? "default"
: "outline"
}
onClick={() => setSelectedDoctor(doctor)}
>
{selectedDoctor?.id === doctor.id
? "Selecionado"
: "Selecionar"}
</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{selectedDoctor && (
<Card>
<CardHeader>
<CardTitle>Detalhes do Agendamento</CardTitle>
<CardDescription>
Consulta com {selectedDoctor.name} - {selectedDoctor.specialty}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Tabs
value={appointmentType}
onValueChange={(v) =>
setAppointmentType(v as "presential" | "online")
}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="presential">Presencial</TabsTrigger>
<TabsTrigger value="online">Online</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Select
value={String(currentMonth.getMonth())}
onValueChange={handleMonthChange}
>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{months.map((month, index) => (
<SelectItem key={index} value={String(index)}>
{month}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={String(currentMonth.getFullYear())}
onValueChange={handleYearChange}
>
<SelectTrigger className="w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
month={currentMonth}
onMonthChange={setCurrentMonth}
className="rounded-md border w-full"
disabled={(date) =>
date < new Date() ||
date.getDay() === 0 ||
date.getDay() === 6
}
/>
<p className="text-muted-foreground">
🔴 Finais de semana não disponíveis
</p>
</div>
<div className="space-y-4">
<div>
<div className="mb-3">
<Label>Horários Disponíveis</Label>
<p className="text-muted-foreground">
{selectedDate?.toLocaleDateString("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</p>
</div>
{/* Adapte para buscar horários reais da API se disponível */}
<div className="grid grid-cols-3 gap-2">
{["09:00", "10:00", "14:00", "15:00", "16:00"].map(
(slot) => (
<Button
key={slot}
variant={
selectedTime === slot ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedTime(slot)}
>
{slot}
</Button>
)
)}
</div>
</div>
<div className="space-y-2">
<Label>Motivo da Consulta</Label>
<Textarea
placeholder="Descreva brevemente o motivo da consulta..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={4}
/>
</div>
<div className="p-4 bg-accent rounded-lg space-y-2">
<h4>Resumo</h4>
<div className="space-y-1 text-muted-foreground">
<p>Data: {selectedDate?.toLocaleDateString("pt-BR")}</p>
<p>Horário: {selectedTime || "Não selecionado"}</p>
<p>
Tipo:{" "}
{appointmentType === "online" ? "Online" : "Presencial"}
</p>
<p>Valor: {selectedDoctor.price || "-"}</p>
</div>
</div>
<Button
className="w-full"
disabled={!selectedTime || !reason || loading}
onClick={handleBookAppointment}
>
Confirmar Agendamento
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmar Agendamento</DialogTitle>
<DialogDescription>
Revise os detalhes da sua consulta antes de confirmar
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center">
{selectedDoctor?.name
?.split(" ")
.map((n: string) => n[0])
.join("")}
</div>
<div>
<p className="text-foreground">{selectedDoctor?.name}</p>
<p className="text-muted-foreground">
{selectedDoctor?.specialty}
</p>
</div>
</div>
<div className="space-y-2 text-muted-foreground">
<p>📅 Data: {selectedDate?.toLocaleDateString("pt-BR")}</p>
<p> Horário: {selectedTime}</p>
<p>
📍 Tipo:{" "}
{appointmentType === "online"
? "Consulta Online"
: "Consulta Presencial"}
</p>
<p>💰 Valor: {selectedDoctor?.price || "-"}</p>
<div className="mt-4">
<p className="text-foreground">Motivo:</p>
<p>{reason}</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowConfirmDialog(false)}
>
Cancelar
</Button>
<Button onClick={confirmAppointment} disabled={loading}>
{loading ? "Agendando..." : "Confirmar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,234 @@
import { useState, useRef, useEffect } from 'react';
import { MessageCircle, X, Send } from 'lucide-react';
interface Message {
id: number;
text: string;
sender: 'user' | 'bot';
timestamp: Date;
}
export function Chatbot() {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([
{
id: 1,
text: 'Olá! Como posso ajudar você hoje?',
sender: 'bot',
timestamp: new Date(),
},
]);
const [inputMessage, setInputMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = async () => {
if (!inputMessage.trim()) return;
const userMessage: Message = {
id: messages.length + 1,
text: inputMessage,
sender: 'user',
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputMessage('');
setIsTyping(true);
// Simular resposta do bot
setTimeout(() => {
const botResponse = getBotResponse(inputMessage.toLowerCase());
const botMessage: Message = {
id: messages.length + 2,
text: botResponse,
sender: 'bot',
timestamp: new Date(),
};
setMessages((prev) => [...prev, botMessage]);
setIsTyping(false);
}, 1000);
};
const getBotResponse = (input: string): string => {
if (input.includes('agendar') || input.includes('consulta') || input.includes('marcar')) {
return 'Para agendar uma consulta, acesse o painel do paciente e clique em "Agendar Consulta". Você poderá escolher o médico, data e horário disponível.';
}
if (input.includes('cancelar') || input.includes('remarcar')) {
return 'Para cancelar ou remarcar uma consulta, acesse "Minhas Consultas" no painel do paciente e clique na consulta desejada.';
}
if (input.includes('pagamento') || input.includes('pagar')) {
return 'Aceitamos pagamento via PIX, cartão de crédito e débito. O pagamento pode ser realizado no momento da consulta ou através do nosso sistema online.';
}
if (input.includes('senha') || input.includes('esqueci')) {
return 'Para redefinir sua senha, clique em "Esqueci minha senha" na tela de login e siga as instruções enviadas para seu e-mail.';
}
if (input.includes('exame') || input.includes('resultado')) {
return 'Os resultados de exames ficam disponíveis no menu "Meus Laudos" do painel do paciente. Você receberá uma notificação quando estiverem prontos.';
}
if (input.includes('horário') || input.includes('funciona')) {
return 'Nosso atendimento funciona de segunda a sexta das 8h às 18h, e sábados das 8h às 12h.';
}
if (input.includes('telemedicina') || input.includes('online')) {
return 'Sim, oferecemos consultas por telemedicina! Ao agendar, selecione a opção "Teleconsulta" e você receberá o link para a videochamada.';
}
if (input.includes('prontuário') || input.includes('histórico')) {
return 'Seu histórico médico completo está disponível no menu "Meu Prontuário" no painel do paciente.';
}
if (input.includes('suporte') || input.includes('ajuda') || input.includes('contato')) {
return 'Para suporte adicional, entre em contato conosco:\n📞 Telefone: (11) 1234-5678\n📧 Email: suporte@mediconnect.com.br\n💬 WhatsApp: (11) 98765-4321';
}
return 'Desculpe, não entendi sua pergunta. Você pode perguntar sobre:\n• Agendar consultas\n• Cancelar/remarcar consultas\n• Pagamentos\n• Resultados de exames\n• Redefinir senha\n• Horário de funcionamento\n• Telemedicina\n• Contato/suporte';
};
const quickReplies = [
'Como agendar consulta?',
'Horário de funcionamento',
'Resultados de exames',
'Esqueci minha senha',
];
return (
<>
{/* Botão flutuante */}
{!isOpen && (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 bg-[#00a8a8] hover:bg-[#008c8c] text-white rounded-full p-4 shadow-lg transition-all duration-300 hover:scale-110 z-50"
aria-label="Abrir chat"
>
<MessageCircle size={28} />
</button>
)}
{/* Janela do chat */}
{isOpen && (
<div className="fixed bottom-6 right-6 w-96 h-[600px] bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200">
{/* Header */}
<div className="bg-gradient-to-r from-[#00a8a8] to-[#008c8c] text-white p-4 rounded-t-2xl flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-white/20 p-2 rounded-full">
<MessageCircle size={24} />
</div>
<div>
<h3 className="font-semibold text-lg">Assistente Virtual</h3>
<p className="text-sm text-white/80">Online</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="hover:bg-white/20 p-2 rounded-full transition-colors"
aria-label="Fechar chat"
>
<X size={24} />
</button>
</div>
{/* Mensagens */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
message.sender === 'user'
? 'bg-[#00a8a8] text-white rounded-br-sm'
: 'bg-white text-gray-800 rounded-bl-sm shadow-sm border border-gray-200'
}`}
>
<p className="text-sm whitespace-pre-line">{message.text}</p>
<p
className={`text-xs mt-1 ${
message.sender === 'user' ? 'text-white/70' : 'text-gray-500'
}`}
>
{message.timestamp.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-white rounded-2xl rounded-bl-sm px-4 py-3 shadow-sm border border-gray-200">
<div className="flex gap-1">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></span>
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></span>
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.4s' }}></span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Respostas rápidas */}
{messages.length === 1 && (
<div className="px-4 py-2 border-t border-gray-200 bg-white">
<p className="text-xs text-gray-600 mb-2">Perguntas frequentes:</p>
<div className="flex flex-wrap gap-2">
{quickReplies.map((reply, index) => (
<button
key={index}
onClick={() => {
setInputMessage(reply);
handleSend();
}}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1.5 rounded-full transition-colors"
>
{reply}
</button>
))}
</div>
</div>
)}
{/* Input */}
<div className="p-4 border-t border-gray-200 bg-white rounded-b-2xl">
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="Digite sua mensagem..."
className="flex-1 border border-gray-300 rounded-full px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#00a8a8] focus:border-transparent text-sm"
/>
<button
onClick={handleSend}
disabled={!inputMessage.trim()}
className="bg-[#00a8a8] hover:bg-[#008c8c] disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-full p-2 transition-colors"
aria-label="Enviar mensagem"
>
<Send size={20} />
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -1,13 +1,10 @@
import React, { useState, useEffect } from "react";
import { Clock, Plus, Trash2, Save, Copy, Calendar as CalendarIcon, X } from "lucide-react";
import { Clock, Plus, Trash2, Save, Copy } from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { availabilityService, doctorService } from "../services/index";
import type {
DoctorException,
DoctorAvailability,
} from "../services/availability/types";
import { availabilityService, exceptionsService } from "../services/index";
import type { DoctorException } from "../services/exceptions/types";
import { useAuth } from "../hooks/useAuth";
interface TimeSlot {
@ -16,8 +13,6 @@ interface TimeSlot {
inicio: string;
fim: string;
ativo: boolean;
slotMinutes?: number;
appointmentType?: "presencial" | "telemedicina";
}
interface DaySchedule {
@ -39,22 +34,19 @@ const daysOfWeek = [
const DisponibilidadeMedico: React.FC = () => {
const { user } = useAuth();
const [doctorId, setDoctorId] = useState<string | null>(null);
const medicoId = user?.id || "";
const [schedule, setSchedule] = useState<Record<number, DaySchedule>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<"weekly" | "blocked">("weekly");
const [activeTab, setActiveTab] = useState<"weekly" | "blocked" | "settings">(
"weekly"
);
// States for adding/editing slots
// States for adding slots
const [showAddSlotDialog, setShowAddSlotDialog] = useState(false);
const [selectedDay, setSelectedDay] = useState<number | null>(null);
const [newSlot, setNewSlot] = useState({
inicio: "09:00",
fim: "10:00",
slotMinutes: 30,
appointmentType: "presencial" as "presencial" | "telemedicina"
});
const [newSlot, setNewSlot] = useState({ inicio: "09:00", fim: "10:00" });
// States for blocked dates
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
@ -63,40 +55,15 @@ const DisponibilidadeMedico: React.FC = () => {
const [blockedDates, setBlockedDates] = useState<Date[]>([]);
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
// States for exceptions form
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
const [exceptionForm, setExceptionForm] = useState({
date: format(new Date(), "yyyy-MM-dd"),
kind: "bloqueio" as "bloqueio" | "disponibilidade_extra",
start_time: "09:00",
end_time: "18:00",
wholeDayBlock: true,
reason: "",
});
// Load doctor ID from doctors table
useEffect(() => {
const loadDoctorId = async () => {
if (!user?.id) return;
try {
const doctors = await doctorService.list({ user_id: user.id });
if (doctors.length > 0) {
setDoctorId(doctors[0].id);
}
} catch (error) {
console.error("Erro ao buscar ID do médico:", error);
}
};
loadDoctorId();
}, [user?.id]);
// Settings
const [consultationDuration, setConsultationDuration] = useState("60");
const [breakTime, setBreakTime] = useState("0");
const loadAvailability = React.useCallback(async () => {
if (!doctorId) return;
try {
setLoading(true);
const availabilities = await availabilityService.list({
doctor_id: doctorId,
doctor_id: medicoId,
});
if (availabilities && availabilities.length > 0) {
@ -113,12 +80,11 @@ const DisponibilidadeMedico: React.FC = () => {
});
// Agrupar disponibilidades por dia da semana
availabilities.forEach((avail: DoctorAvailability) => {
// avail.weekday agora é um número (0-6)
const dayKey = avail.weekday;
if (!newSchedule[dayKey]) return;
availabilities.forEach((avail: any) => {
const weekdayKey = daysOfWeek.find((d) => d.dbKey === avail.weekday);
if (!weekdayKey) return;
const dayKey = weekdayKey.key;
if (!newSchedule[dayKey].enabled) {
newSchedule[dayKey].enabled = true;
}
@ -152,31 +118,29 @@ const DisponibilidadeMedico: React.FC = () => {
} finally {
setLoading(false);
}
}, [doctorId]);
}, [medicoId]);
const loadExceptions = React.useCallback(async () => {
if (!doctorId) return;
try {
const exceptions = await availabilityService.listExceptions({
doctor_id: doctorId,
const exceptions = await exceptionsService.list({
doctor_id: medicoId,
});
setExceptions(exceptions);
const blocked = exceptions
.filter((exc: DoctorException) => exc.kind === "bloqueio" && exc.date)
.map((exc: DoctorException) => new Date(exc.date!));
.filter((exc: any) => exc.kind === "bloqueio" && exc.date)
.map((exc: any) => new Date(exc.date!));
setBlockedDates(blocked);
} catch (error) {
console.error("Erro ao carregar exceções:", error);
}
}, [doctorId]);
}, [medicoId]);
useEffect(() => {
if (doctorId) {
if (medicoId) {
loadAvailability();
loadExceptions();
}
}, [doctorId, loadAvailability, loadExceptions]);
}, [medicoId, loadAvailability, loadExceptions]);
const toggleDay = (dayKey: number) => {
setSchedule((prev) => ({
@ -207,7 +171,7 @@ const DisponibilidadeMedico: React.FC = () => {
},
}));
setShowAddSlotDialog(false);
setNewSlot({ inicio: "09:00", fim: "10:00", slotMinutes: 30, appointmentType: "presencial" });
setNewSlot({ inicio: "09:00", fim: "10:00" });
setSelectedDay(null);
}
};
@ -274,7 +238,7 @@ const DisponibilidadeMedico: React.FC = () => {
try {
setSaving(true);
if (!doctorId) {
if (!medicoId) {
toast.error("Médico não autenticado");
return;
}
@ -289,7 +253,7 @@ const DisponibilidadeMedico: React.FC = () => {
};
// Para cada dia, processar slots
daysOfWeek.forEach(({ key }) => {
daysOfWeek.forEach(({ key, dbKey }) => {
const daySchedule = schedule[key];
if (!daySchedule || !daySchedule.enabled) {
@ -320,9 +284,16 @@ const DisponibilidadeMedico: React.FC = () => {
);
const payload = {
weekday: key, // Agora usa número (0-6) ao invés de string
start_time: inicio.slice(0, 5), // HH:MM ao invés de HH:MM:SS
end_time: fim.slice(0, 5), // HH:MM ao invés de HH:MM:SS
weekday: dbKey as
| "segunda"
| "terca"
| "quarta"
| "quinta"
| "sexta"
| "sabado"
| "domingo",
start_time: inicio,
end_time: fim,
slot_minutes: minutes,
appointment_type: "presencial" as const,
active: !!slot.ativo,
@ -330,14 +301,14 @@ const DisponibilidadeMedico: React.FC = () => {
if (slot.dbId) {
// Atualizar slot existente
requests.push(availabilityService.update(slot.dbId, payload as any));
requests.push(availabilityService.update(slot.dbId, payload));
} else {
// Criar novo slot
requests.push(
availabilityService.create({
doctor_id: doctorId,
doctor_id: medicoId,
...payload,
} as any)
})
);
}
});
@ -404,7 +375,7 @@ const DisponibilidadeMedico: React.FC = () => {
exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
);
if (exception && exception.id) {
await availabilityService.deleteException(exception.id);
await exceptionsService.delete(exception.id);
setBlockedDates(
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
);
@ -412,12 +383,11 @@ const DisponibilidadeMedico: React.FC = () => {
}
} else {
// Add block
await availabilityService.createException({
doctor_id: doctorId!,
await exceptionsService.create({
doctor_id: medicoId,
date: dateString,
kind: "bloqueio",
reason: "Data bloqueada pelo médico",
created_by: user?.id || doctorId!,
});
setBlockedDates([...blockedDates, selectedDate]);
toast.success("Data bloqueada");
@ -480,7 +450,17 @@ const DisponibilidadeMedico: React.FC = () => {
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Exceções ({exceptions.length})
Datas Bloqueadas ({blockedDates.length})
</button>
<button
onClick={() => setActiveTab("settings")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === "settings"
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Configurações
</button>
</nav>
</div>
@ -610,7 +590,7 @@ const DisponibilidadeMedico: React.FC = () => {
type="date"
value={selectedDate ? format(selectedDate, "yyyy-MM-dd") : ""}
onChange={(e) => setSelectedDate(new Date(e.target.value))}
className="form-input"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
<button
onClick={toggleBlockedDate}
@ -669,6 +649,96 @@ const DisponibilidadeMedico: React.FC = () => {
</div>
)}
{/* Tab Content - Settings */}
{activeTab === "settings" && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Configurações de Consulta
</h3>
<p className="text-gray-600 dark:text-gray-400">
Defina as configurações padrão para suas consultas
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Duração Padrão da Consulta
</label>
<select
value={consultationDuration}
onChange={(e) => setConsultationDuration(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
>
<option value="30">30 minutos</option>
<option value="45">45 minutos</option>
<option value="60">1 hora</option>
<option value="90">1 hora e 30 minutos</option>
<option value="120">2 horas</option>
</select>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
Esta duração será usada para calcular os horários disponíveis
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Intervalo entre Consultas
</label>
<select
value={breakTime}
onChange={(e) => setBreakTime(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
>
<option value="0">Sem intervalo</option>
<option value="15">15 minutos</option>
<option value="30">30 minutos</option>
</select>
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
Tempo de descanso entre consultas
</p>
</div>
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div>
<p className="text-gray-900 dark:text-white font-medium">
Aceitar consultas online
</p>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Permitir agendamento de teleconsultas
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
defaultChecked
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div>
<p className="text-gray-900 dark:text-white font-medium">
Confirmação automática
</p>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Aprovar agendamentos automaticamente
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
</label>
</div>
</div>
</div>
</div>
)}
{/* Add Time Slot Dialog */}
{showAddSlotDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
@ -691,7 +761,7 @@ const DisponibilidadeMedico: React.FC = () => {
onChange={(e) =>
setNewSlot({ ...newSlot, inicio: e.target.value })
}
className="form-input"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
</div>
<div>
@ -704,7 +774,7 @@ const DisponibilidadeMedico: React.FC = () => {
onChange={(e) =>
setNewSlot({ ...newSlot, fim: e.target.value })
}
className="form-input"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
@ -733,5 +803,3 @@ const DisponibilidadeMedico: React.FC = () => {
};
export default DisponibilidadeMedico;

View File

@ -0,0 +1,198 @@
import React from "react";
import { Link } from "react-router-dom";
import { Heart, LogOut, LogIn } from "lucide-react";
import { useAuth } from "../hooks/useAuth";
import { ProfileSelector } from "./ProfileSelector";
import { i18n } from "../i18n";
import Logo from "./images/logo.PNG";
const Header: React.FC = () => {
const { user, logout, role, isAuthenticated } = useAuth();
const roleLabel: Record<string, string> = {
secretaria: "Secretaria",
medico: "Médico",
paciente: "Paciente",
admin: "Administrador",
gestor: "Gestor",
};
return (
<header className="bg-white shadow-lg border-b border-gray-200">
{/* Skip to content link for accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-0 focus:left-0 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:outline-none"
>
{i18n.t("common.skipToContent")}
</a>
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link
to="/"
className="flex items-center space-x-3 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg"
>
<img
src={Logo}
alt={i18n.t("header.logo")}
className="h-14 w-14 rounded-lg object-contain shadow-sm"
/>
<div>
<h1 className="text-xl font-bold text-gray-900">
{i18n.t("header.logo")}
</h1>
<p className="text-xs text-gray-500">
{i18n.t("header.subtitle")}
</p>
</div>
</Link>
{/* Desktop Navigation */}
<nav
className="hidden md:flex items-center space-x-2"
aria-label="Navegação principal"
>
<Link
to="/"
className="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:text-blue-600 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<Heart className="w-4 h-4" aria-hidden="true" />
<span>{i18n.t("header.home")}</span>
</Link>
{/* Profile Selector */}
<ProfileSelector />
{/* Admin Link */}
{isAuthenticated && (role === "admin" || role === "gestor") && (
<Link
to="/admin"
className="flex items-center space-x-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:text-purple-600 hover:bg-purple-50 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
>
<span>Painel Admin</span>
</Link>
)}
</nav>
{/* User Session / Auth */}
<div className="hidden md:flex items-center space-x-3">
{isAuthenticated && user ? (
<>
<div className="text-right leading-tight min-w-0 flex-shrink">
<p
className="text-sm font-medium text-gray-700 truncate max-w-[120px]"
title={user.nome}
>
{user.nome.split(" ").slice(0, 2).join(" ")}
</p>
<p className="text-xs text-gray-500 whitespace-nowrap">
{role ? roleLabel[role] || role : ""}
</p>
</div>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-gray-100 hover:bg-gray-200 hover:scale-105 active:scale-95 text-gray-700 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex-shrink-0"
aria-label={i18n.t("header.logout")}
>
<LogOut className="w-4 h-4 mr-1" aria-hidden="true" />
<span className="hidden lg:inline">
{i18n.t("header.logout")}
</span>
<span className="lg:hidden">Sair</span>
</button>
</>
) : (
<Link
to="/paciente"
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 hover:scale-105 active:scale-95 text-white transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 shadow-sm hover:shadow-md"
aria-label={i18n.t("header.login")}
>
<LogIn className="w-4 h-4 mr-2" aria-hidden="true" />
{i18n.t("header.login")}
</Link>
)}
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
className="text-gray-600 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-2"
aria-label="Menu de navegação"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
</div>
{/* Mobile Navigation */}
<div className="md:hidden border-t border-gray-200 py-3">
<div className="flex flex-col space-y-2">
<Link
to="/"
className="flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:text-blue-600 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<Heart className="w-4 h-4" aria-hidden="true" />
<span>{i18n.t("header.home")}</span>
</Link>
<div className="px-3 py-2">
<ProfileSelector />
</div>
{/* Sessão mobile */}
<div className="mt-4 flex items-center justify-between bg-gray-50 px-3 py-2 rounded-md">
{isAuthenticated && user ? (
<>
<div className="flex-1 mr-3 min-w-0">
<p
className="text-sm font-medium text-gray-700 truncate"
title={user.nome}
>
{user.nome.split(" ").slice(0, 2).join(" ")}
</p>
<p className="text-xs text-gray-500">
{role ? roleLabel[role] || role : ""}
</p>
</div>
<button
onClick={logout}
className="inline-flex items-center px-3 py-2 text-xs font-medium rounded bg-gray-200 text-gray-700 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-shrink-0"
aria-label={i18n.t("header.logout")}
>
<LogOut className="w-4 h-4 mr-1" />
<span>Sair</span>
</button>
</>
) : (
<Link
to="/paciente"
className="flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-medium rounded bg-gradient-to-r from-blue-700 to-blue-400 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<LogIn className="w-4 h-4 mr-2" aria-hidden="true" />
{i18n.t("header.login")}
</Link>
)}
</div>
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@ -1,188 +1,188 @@
import React from "react";
import { LucideIcon, AlertCircle } from "lucide-react";
export interface MetricCardProps {
title: string;
value: number | string;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
description: string;
loading?: boolean;
error?: boolean;
emptyAction?: {
label: string;
onClick: () => void;
};
ariaLabel?: string;
}
const MetricCardSkeleton: React.FC = () => (
<div
className="bg-white rounded-lg shadow-md p-6 animate-pulse"
role="status"
aria-label="Carregando métrica"
>
<div className="flex items-center">
<div className="w-12 h-12 bg-gray-200 rounded-full" />
<div className="ml-4 flex-1">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-8 bg-gray-200 rounded w-1/2" />
</div>
</div>
</div>
);
const MetricCardError: React.FC<{ title: string; onRetry?: () => void }> = ({
title,
onRetry,
}) => (
<div
className="bg-white rounded-lg shadow-md p-6 border-2 border-red-200"
role="alert"
aria-live="polite"
>
<div className="flex items-center">
<div className="p-3 bg-red-100 rounded-full">
<AlertCircle className="w-6 h-6 text-red-600" />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-sm text-red-600 mt-1">Erro ao carregar</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded px-2 py-1"
aria-label="Tentar carregar novamente"
>
Tentar novamente
</button>
)}
</div>
</div>
</div>
);
const MetricCardEmpty: React.FC<{
title: string;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
emptyAction: { label: string; onClick: () => void };
}> = ({ title, icon: Icon, iconColor, iconBgColor, emptyAction }) => (
<div className="bg-white rounded-lg shadow-md p-6 border-2 border-gray-100">
<div className="flex items-center">
<div className={`p-3 ${iconBgColor} rounded-full`}>
<Icon className={`w-6 h-6 ${iconColor}`} />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-2xl font-bold text-gray-900">0</p>
<button
onClick={emptyAction.onClick}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded px-2 py-1 transition-colors"
aria-label={emptyAction.label}
>
{emptyAction.label}
</button>
</div>
</div>
</div>
);
export const MetricCard: React.FC<MetricCardProps> = ({
title,
value,
icon: Icon,
iconColor,
iconBgColor,
description,
loading = false,
error = false,
emptyAction,
ariaLabel,
}) => {
if (loading) {
return <MetricCardSkeleton />;
}
if (error) {
return <MetricCardError title={title} />;
}
const numericValue =
typeof value === "number" ? value : parseInt(String(value), 10) || 0;
if (numericValue === 0 && emptyAction) {
return (
<MetricCardEmpty
title={title}
icon={Icon}
iconColor={iconColor}
iconBgColor={iconBgColor}
emptyAction={emptyAction}
/>
);
}
return (
<div
className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow group"
role="region"
aria-label={ariaLabel || title}
>
<div className="flex items-center relative">
<div
className={`p-3 ${iconBgColor} rounded-full group-hover:scale-110 transition-transform`}
>
<Icon className={`w-6 h-6 ${iconColor}`} aria-hidden="true" />
</div>
<div className="ml-4 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-gray-600">{title}</p>
{/* Tooltip */}
<div className="relative group/tooltip">
<button
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-full p-0.5"
aria-label={`Informações sobre ${title}`}
type="button"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<div
className="absolute z-10 invisible group-hover/tooltip:visible opacity-0 group-hover/tooltip:opacity-100 transition-opacity bg-gray-900 text-white text-xs rounded-lg py-2 px-3 bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-48 pointer-events-none"
role="tooltip"
>
{description}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1">
<div className="border-4 border-transparent border-t-gray-900" />
</div>
</div>
</div>
</div>
<p
className="text-2xl font-bold text-gray-900 tabular-nums"
aria-live="polite"
>
{value}
</p>
</div>
</div>
</div>
);
};
export default MetricCard;
import React from "react";
import { LucideIcon, AlertCircle } from "lucide-react";
export interface MetricCardProps {
title: string;
value: number | string;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
description: string;
loading?: boolean;
error?: boolean;
emptyAction?: {
label: string;
onClick: () => void;
};
ariaLabel?: string;
}
const MetricCardSkeleton: React.FC = () => (
<div
className="bg-white rounded-lg shadow-md p-6 animate-pulse"
role="status"
aria-label="Carregando métrica"
>
<div className="flex items-center">
<div className="w-12 h-12 bg-gray-200 rounded-full" />
<div className="ml-4 flex-1">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-8 bg-gray-200 rounded w-1/2" />
</div>
</div>
</div>
);
const MetricCardError: React.FC<{ title: string; onRetry?: () => void }> = ({
title,
onRetry,
}) => (
<div
className="bg-white rounded-lg shadow-md p-6 border-2 border-red-200"
role="alert"
aria-live="polite"
>
<div className="flex items-center">
<div className="p-3 bg-red-100 rounded-full">
<AlertCircle className="w-6 h-6 text-red-600" />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-sm text-red-600 mt-1">Erro ao carregar</p>
{onRetry && (
<button
onClick={onRetry}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded px-2 py-1"
aria-label="Tentar carregar novamente"
>
Tentar novamente
</button>
)}
</div>
</div>
</div>
);
const MetricCardEmpty: React.FC<{
title: string;
icon: LucideIcon;
iconColor: string;
iconBgColor: string;
emptyAction: { label: string; onClick: () => void };
}> = ({ title, icon: Icon, iconColor, iconBgColor, emptyAction }) => (
<div className="bg-white rounded-lg shadow-md p-6 border-2 border-gray-100">
<div className="flex items-center">
<div className={`p-3 ${iconBgColor} rounded-full`}>
<Icon className={`w-6 h-6 ${iconColor}`} />
</div>
<div className="ml-4 flex-1">
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="text-2xl font-bold text-gray-900">0</p>
<button
onClick={emptyAction.onClick}
className="mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded px-2 py-1 transition-colors"
aria-label={emptyAction.label}
>
{emptyAction.label}
</button>
</div>
</div>
</div>
);
export const MetricCard: React.FC<MetricCardProps> = ({
title,
value,
icon: Icon,
iconColor,
iconBgColor,
description,
loading = false,
error = false,
emptyAction,
ariaLabel,
}) => {
if (loading) {
return <MetricCardSkeleton />;
}
if (error) {
return <MetricCardError title={title} />;
}
const numericValue =
typeof value === "number" ? value : parseInt(String(value), 10) || 0;
if (numericValue === 0 && emptyAction) {
return (
<MetricCardEmpty
title={title}
icon={Icon}
iconColor={iconColor}
iconBgColor={iconBgColor}
emptyAction={emptyAction}
/>
);
}
return (
<div
className="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow group"
role="region"
aria-label={ariaLabel || title}
>
<div className="flex items-center relative">
<div
className={`p-3 ${iconBgColor} rounded-full group-hover:scale-110 transition-transform`}
>
<Icon className={`w-6 h-6 ${iconColor}`} aria-hidden="true" />
</div>
<div className="ml-4 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-gray-600">{title}</p>
{/* Tooltip */}
<div className="relative group/tooltip">
<button
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-full p-0.5"
aria-label={`Informações sobre ${title}`}
type="button"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<div
className="absolute z-10 invisible group-hover/tooltip:visible opacity-0 group-hover/tooltip:opacity-100 transition-opacity bg-gray-900 text-white text-xs rounded-lg py-2 px-3 bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-48 pointer-events-none"
role="tooltip"
>
{description}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1">
<div className="border-4 border-transparent border-t-gray-900" />
</div>
</div>
</div>
</div>
<p
className="text-2xl font-bold text-gray-900 tabular-nums"
aria-live="polite"
>
{value}
</p>
</div>
</div>
</div>
);
};
export default MetricCard;

View File

@ -0,0 +1,250 @@
import React, { useState, useEffect, useRef } from "react";
import { User, Stethoscope, Clipboard, ChevronDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { i18n } from "../i18n";
import { useAuth } from "../hooks/useAuth";
export type ProfileType = "patient" | "doctor" | "secretary" | null;
interface ProfileOption {
type: ProfileType;
icon: typeof User;
label: string;
description: string;
path: string;
color: string;
bgColor: string;
}
const profileOptions: ProfileOption[] = [
{
type: "patient",
icon: User,
label: i18n.t("profiles.patient"),
description: i18n.t("profiles.patientDescription"),
path: "/paciente",
color: "text-blue-600",
bgColor: "bg-blue-50 hover:bg-blue-100",
},
{
type: "doctor",
icon: Stethoscope,
label: i18n.t("profiles.doctor"),
description: i18n.t("profiles.doctorDescription"),
path: "/login-medico",
color: "text-indigo-600",
bgColor: "bg-indigo-50 hover:bg-indigo-100",
},
{
type: "secretary",
icon: Clipboard,
label: i18n.t("profiles.secretary"),
description: i18n.t("profiles.secretaryDescription"),
path: "/login-secretaria",
color: "text-green-600",
bgColor: "bg-green-50 hover:bg-green-100",
},
];
export const ProfileSelector: React.FC = () => {
const [selectedProfile, setSelectedProfile] = useState<ProfileType>(null);
const [isOpen, setIsOpen] = useState(false);
const navigate = useNavigate();
const dropdownRef = useRef<HTMLDivElement>(null);
const { isAuthenticated, user } = useAuth();
useEffect(() => {
// Carregar perfil salvo
const saved = localStorage.getItem(
"mediconnect_selected_profile"
) as ProfileType;
if (saved) {
setSelectedProfile(saved);
}
}, []);
useEffect(() => {
// Fechar ao clicar fora
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
const handleProfileSelect = (profile: ProfileOption) => {
const previousProfile = selectedProfile;
setSelectedProfile(profile.type);
setIsOpen(false);
// Persistir escolha
if (profile.type) {
localStorage.setItem("mediconnect_selected_profile", profile.type);
}
// Telemetria (optional - could be implemented later)
console.log(
`Profile changed: ${previousProfile} -> ${profile.type || "none"}`
);
// Navegar - condicional baseado em autenticação e role
let targetPath = profile.path; // default: caminho do perfil (login)
if (isAuthenticated && user) {
// Se autenticado, redirecionar para o painel apropriado baseado na role
switch (user.role) {
case "paciente":
if (profile.type === "patient") {
targetPath = "/acompanhamento"; // painel do paciente
}
break;
case "medico":
if (profile.type === "doctor") {
targetPath = "/painel-medico"; // painel do médico
}
break;
case "secretaria":
if (profile.type === "secretary") {
targetPath = "/painel-secretaria"; // painel da secretária
}
break;
case "admin":
// Admin pode ir para qualquer painel
if (profile.type === "secretary") {
targetPath = "/painel-secretaria";
} else if (profile.type === "doctor") {
targetPath = "/painel-medico";
} else if (profile.type === "patient") {
targetPath = "/acompanhamento";
}
break;
}
console.log(
`🔀 ProfileSelector: Usuário autenticado (${user.role}), redirecionando para ${targetPath}`
);
} else {
console.log(
`🔀 ProfileSelector: Usuário NÃO autenticado, redirecionando para ${targetPath}`
);
}
navigate(targetPath);
};
const getCurrentProfile = () => {
return profileOptions.find((p) => p.type === selectedProfile);
};
const currentProfile = getCurrentProfile();
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
currentProfile
? `${currentProfile.bgColor} ${currentProfile.color}`
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
aria-expanded={isOpen}
aria-haspopup="true"
aria-label={i18n.t("header.selectProfile")}
>
{currentProfile ? (
<>
<currentProfile.icon className="w-4 h-4" aria-hidden="true" />
<span className="hidden md:inline">{currentProfile.label}</span>
</>
) : (
<>
<User className="w-4 h-4" aria-hidden="true" />
<span className="hidden md:inline">{i18n.t("header.profile")}</span>
</>
)}
<ChevronDown
className={`w-4 h-4 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</button>
{isOpen && (
<div
className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-xl border border-gray-200 z-50 animate-in fade-in slide-in-from-top-2 duration-200"
role="menu"
aria-orientation="vertical"
>
<div className="p-2">
<p className="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">
{i18n.t("header.selectProfile")}
</p>
{profileOptions.map((profile) => (
<button
key={profile.type}
onClick={() => handleProfileSelect(profile)}
className={`w-full flex items-start gap-3 px-3 py-3 rounded-lg transition-colors text-left focus:outline-none focus:ring-2 focus:ring-blue-500 ${
profile.type === selectedProfile
? `${profile.bgColor} ${profile.color}`
: "hover:bg-gray-50 text-gray-700"
}`}
role="menuitem"
aria-label={`Selecionar perfil ${profile.label}`}
>
<div
className={`p-2 rounded-lg ${
profile.type === selectedProfile
? "bg-white"
: profile.bgColor
}`}
>
<profile.icon
className={`w-5 h-5 ${profile.color}`}
aria-hidden="true"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{profile.label}</p>
<p className="text-xs text-gray-600 mt-0.5">
{profile.description}
</p>
</div>
{profile.type === selectedProfile && (
<div className="flex-shrink-0 pt-1">
<svg
className={`w-5 h-5 ${profile.color}`}
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
))}
</div>
</div>
)}
</div>
);
};
export default ProfileSelector;

View File

@ -1,333 +1,333 @@
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { availabilityService } from "../../services";
import type {
DoctorAvailability,
Weekday,
} from "../../services/availability/types";
type AppointmentType = "presencial" | "telemedicina";
interface Props {
doctorId: string;
}
const WEEKDAYS: Weekday[] = [
"segunda",
"terca",
"quarta",
"quinta",
"sexta",
"sabado",
"domingo",
];
const AvailabilityManager: React.FC<Props> = ({ doctorId }) => {
const [list, setList] = useState<DoctorAvailability[]>([]);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
weekday: "segunda" as Weekday,
start_time: "09:00:00",
end_time: "17:00:00",
slot_minutes: 30,
appointment_type: "presencial" as AppointmentType,
active: true,
});
const [saving, setSaving] = useState(false);
const canSave = useMemo(() => {
return (
!!doctorId &&
!!form.weekday &&
!!form.start_time &&
!!form.end_time &&
Number(form.slot_minutes) > 0
);
}, [doctorId, form]);
async function load() {
if (!doctorId) return;
setLoading(true);
try {
const data = await availabilityService.list({ doctor_id: doctorId });
setList(Array.isArray(data) ? data : []);
} catch (error) {
console.error("[AvailabilityManager] Erro ao carregar:", error);
toast.error("Erro ao carregar disponibilidades");
setList([]);
}
setLoading(false);
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId]);
async function addAvailability(e: React.FormEvent) {
e.preventDefault();
if (!canSave) {
toast.error("Preencha todos os campos obrigatórios");
return;
}
// Validar formato de tempo
const timeRegex = /^\d{2}:\d{2}:\d{2}$/;
if (!timeRegex.test(form.start_time) || !timeRegex.test(form.end_time)) {
toast.error("Formato de horário inválido. Use HH:MM:SS");
return;
}
// Validar que o horário de fim é depois do início
if (form.start_time >= form.end_time) {
toast.error("Horário de fim deve ser posterior ao horário de início");
return;
}
const payload = {
doctor_id: doctorId,
weekday: form.weekday,
start_time: form.start_time,
end_time: form.end_time,
slot_minutes: Number(form.slot_minutes) || 30,
appointment_type: form.appointment_type,
active: form.active,
};
console.log("[AvailabilityManager] Enviando payload:", payload);
setSaving(true);
try {
await availabilityService.create(payload);
toast.success("Disponibilidade criada com sucesso!");
setForm((f) => ({ ...f, start_time: "09:00:00", end_time: "17:00:00" }));
void load();
} catch (error) {
console.error("[AvailabilityManager] Erro ao criar:", error);
toast.error("Falha ao criar disponibilidade");
}
setSaving(false);
}
async function toggleActive(item: DoctorAvailability) {
if (!item.id) return;
try {
await availabilityService.update(item.id, {
active: !item.active,
});
toast.success("Atualizado");
void load();
} catch (error) {
console.error("[AvailabilityManager] Erro ao atualizar:", error);
toast.error("Falha ao atualizar");
}
}
async function remove(item: DoctorAvailability) {
if (!item.id) return;
const ok = confirm("Remover disponibilidade?");
if (!ok) return;
try {
await availabilityService.delete(item.id);
toast.success("Removido");
void load();
} catch (error) {
console.error("[AvailabilityManager] Erro ao remover:", error);
toast.error("Falha ao remover");
}
}
return (
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-200">
{/* Título mais destacado para leitura escaneável */}
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Disponibilidade Semanal
</h3>
<form onSubmit={addAvailability} className="mb-6">
{/* Grid responsivo com espaçamento consistente */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 mb-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Dia da Semana
</label>
<select
value={form.weekday}
onChange={(e) =>
setForm((f) => ({ ...f, weekday: e.target.value as Weekday }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>
{d.charAt(0).toUpperCase() + d.slice(1)}
</option>
))}
</select>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Horário Início
</label>
<input
type="time"
step={60}
value={form.start_time?.slice(0, 5)}
onChange={(e) =>
setForm((f) => ({ ...f, start_time: `${e.target.value}:00` }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Horário Fim
</label>
<input
type="time"
step={60}
value={form.end_time?.slice(0, 5)}
onChange={(e) =>
setForm((f) => ({ ...f, end_time: `${e.target.value}:00` }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Duração (min)
</label>
<input
type="number"
min={5}
step={5}
value={form.slot_minutes}
onChange={(e) =>
setForm((f) => ({ ...f, slot_minutes: Number(e.target.value) }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Tipo Atendimento
</label>
<select
value={form.appointment_type}
onChange={(e) =>
setForm((f) => ({
...f,
appointment_type: e.target.value as AppointmentType,
}))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
</div>
<div className="flex flex-col justify-end">
<button
type="submit"
disabled={!canSave || saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{saving ? "Salvando..." : "Adicionar"}
</button>
</div>
</div>
</form>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : list.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-500">
Nenhuma disponibilidade cadastrada. Use o formulário acima para
adicionar horários.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dia da Semana
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Início
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Fim
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duração
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tipo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{list.map((item) => (
<tr
key={item.id}
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50/40 transition-colors"
>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.weekday
? item.weekday.charAt(0).toUpperCase() +
item.weekday.slice(1)
: "-"}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.start_time?.slice(0, 5)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.end_time?.slice(0, 5)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.slot_minutes || 30} min
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.appointment_type === "presencial"
? "Presencial"
: "Telemedicina"}
</td>
<td className="px-4 py-4 whitespace-nowrap">
<button
className={`px-3 py-1 rounded-full text-xs font-medium ${
item.active
? "bg-green-100 text-green-800 ring-1 ring-green-600/20 hover:bg-green-200"
: "bg-gray-100 text-gray-800 ring-1 ring-gray-600/20 hover:bg-gray-200"
}`}
onClick={() => void toggleActive(item)}
>
{item.active ? "Ativo" : "Inativo"}
</button>
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium">
<button
className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-red-700 bg-red-50 hover:bg-red-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => void remove(item)}
>
Remover
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default AvailabilityManager;
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { availabilityService } from "../../services";
import type {
DoctorAvailability,
Weekday,
} from "../../services/availability/types";
type AppointmentType = "presencial" | "telemedicina";
interface Props {
doctorId: string;
}
const WEEKDAYS: Weekday[] = [
"segunda",
"terca",
"quarta",
"quinta",
"sexta",
"sabado",
"domingo",
];
const AvailabilityManager: React.FC<Props> = ({ doctorId }) => {
const [list, setList] = useState<DoctorAvailability[]>([]);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
weekday: "segunda" as Weekday,
start_time: "09:00:00",
end_time: "17:00:00",
slot_minutes: 30,
appointment_type: "presencial" as AppointmentType,
active: true,
});
const [saving, setSaving] = useState(false);
const canSave = useMemo(() => {
return (
!!doctorId &&
!!form.weekday &&
!!form.start_time &&
!!form.end_time &&
Number(form.slot_minutes) > 0
);
}, [doctorId, form]);
async function load() {
if (!doctorId) return;
setLoading(true);
try {
const data = await availabilityService.list({ doctor_id: doctorId });
setList(Array.isArray(data) ? data : []);
} catch (error) {
console.error("[AvailabilityManager] Erro ao carregar:", error);
toast.error("Erro ao carregar disponibilidades");
setList([]);
}
setLoading(false);
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId]);
async function addAvailability(e: React.FormEvent) {
e.preventDefault();
if (!canSave) {
toast.error("Preencha todos os campos obrigatórios");
return;
}
// Validar formato de tempo
const timeRegex = /^\d{2}:\d{2}:\d{2}$/;
if (!timeRegex.test(form.start_time) || !timeRegex.test(form.end_time)) {
toast.error("Formato de horário inválido. Use HH:MM:SS");
return;
}
// Validar que o horário de fim é depois do início
if (form.start_time >= form.end_time) {
toast.error("Horário de fim deve ser posterior ao horário de início");
return;
}
const payload = {
doctor_id: doctorId,
weekday: form.weekday,
start_time: form.start_time,
end_time: form.end_time,
slot_minutes: Number(form.slot_minutes) || 30,
appointment_type: form.appointment_type,
active: form.active,
};
console.log("[AvailabilityManager] Enviando payload:", payload);
setSaving(true);
try {
await availabilityService.create(payload);
toast.success("Disponibilidade criada com sucesso!");
setForm((f) => ({ ...f, start_time: "09:00:00", end_time: "17:00:00" }));
void load();
} catch (error) {
console.error("[AvailabilityManager] Erro ao criar:", error);
toast.error("Falha ao criar disponibilidade");
}
setSaving(false);
}
async function toggleActive(item: DoctorAvailability) {
if (!item.id) return;
try {
await availabilityService.update(item.id, {
active: !item.active,
});
toast.success("Atualizado");
void load();
} catch (error) {
console.error("[AvailabilityManager] Erro ao atualizar:", error);
toast.error("Falha ao atualizar");
}
}
async function remove(item: DoctorAvailability) {
if (!item.id) return;
const ok = confirm("Remover disponibilidade?");
if (!ok) return;
try {
await availabilityService.delete(item.id);
toast.success("Removido");
void load();
} catch (error) {
console.error("[AvailabilityManager] Erro ao remover:", error);
toast.error("Falha ao remover");
}
}
return (
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-200">
{/* Título mais destacado para leitura escaneável */}
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Disponibilidade Semanal
</h3>
<form onSubmit={addAvailability} className="mb-6">
{/* Grid responsivo com espaçamento consistente */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 mb-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Dia da Semana
</label>
<select
value={form.weekday}
onChange={(e) =>
setForm((f) => ({ ...f, weekday: e.target.value as Weekday }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
>
{WEEKDAYS.map((d) => (
<option key={d} value={d}>
{d.charAt(0).toUpperCase() + d.slice(1)}
</option>
))}
</select>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Horário Início
</label>
<input
type="time"
step={60}
value={form.start_time?.slice(0, 5)}
onChange={(e) =>
setForm((f) => ({ ...f, start_time: `${e.target.value}:00` }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Horário Fim
</label>
<input
type="time"
step={60}
value={form.end_time?.slice(0, 5)}
onChange={(e) =>
setForm((f) => ({ ...f, end_time: `${e.target.value}:00` }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Duração (min)
</label>
<input
type="number"
min={5}
step={5}
value={form.slot_minutes}
onChange={(e) =>
setForm((f) => ({ ...f, slot_minutes: Number(e.target.value) }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Tipo Atendimento
</label>
<select
value={form.appointment_type}
onChange={(e) =>
setForm((f) => ({
...f,
appointment_type: e.target.value as AppointmentType,
}))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
</div>
<div className="flex flex-col justify-end">
<button
type="submit"
disabled={!canSave || saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{saving ? "Salvando..." : "Adicionar"}
</button>
</div>
</div>
</form>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : list.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-500">
Nenhuma disponibilidade cadastrada. Use o formulário acima para
adicionar horários.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dia da Semana
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Início
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Fim
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duração
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tipo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{list.map((item) => (
<tr
key={item.id}
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50/40 transition-colors"
>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.weekday
? item.weekday.charAt(0).toUpperCase() +
item.weekday.slice(1)
: "-"}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.start_time?.slice(0, 5)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.end_time?.slice(0, 5)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.slot_minutes || 30} min
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.appointment_type === "presencial"
? "Presencial"
: "Telemedicina"}
</td>
<td className="px-4 py-4 whitespace-nowrap">
<button
className={`px-3 py-1 rounded-full text-xs font-medium ${
item.active
? "bg-green-100 text-green-800 ring-1 ring-green-600/20 hover:bg-green-200"
: "bg-gray-100 text-gray-800 ring-1 ring-gray-600/20 hover:bg-gray-200"
}`}
onClick={() => void toggleActive(item)}
>
{item.active ? "Ativo" : "Inativo"}
</button>
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium">
<button
className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-red-700 bg-red-50 hover:bg-red-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => void remove(item)}
>
Remover
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default AvailabilityManager;

View File

@ -0,0 +1,79 @@
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { appointmentService } from "../../services";
interface Props {
doctorId: string;
date: string; // YYYY-MM-DD
onSelect: (time: string) => void; // HH:MM
appointment_type?: "presencial" | "telemedicina";
}
const AvailableSlotsPicker: React.FC<Props> = ({
doctorId,
date,
onSelect,
appointment_type,
}) => {
const [slots, setSlots] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const range = useMemo(() => {
if (!date) return null;
const start = new Date(`${date}T00:00:00Z`).toISOString();
const end = new Date(`${date}T23:59:59Z`).toISOString();
return { start, end };
}, [date]);
useEffect(() => {
async function fetchSlots() {
if (!doctorId || !range) return;
setLoading(true);
const res = await appointmentService.getAvailableSlots({
doctor_id: doctorId,
start_date: range.start,
end_date: range.end,
appointment_type,
});
setLoading(false);
if (res.success && res.data) {
const times = res.data.slots
.filter((s) => s.available)
.map((s) => s.datetime.slice(11, 16));
setSlots(times);
} else {
toast.error(res.error || "Erro ao buscar horários");
}
}
void fetchSlots();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId, date, appointment_type]);
if (!date || !doctorId) return null;
if (loading)
return <div className="text-sm text-gray-500">Carregando horários...</div>;
if (!slots.length)
return (
<div className="text-sm text-gray-500">
Nenhum horário disponível para a data selecionada.
</div>
);
return (
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{slots.map((t) => (
<button
key={t}
onClick={() => onSelect(t)}
className="px-3 py-2 rounded bg-blue-50 hover:bg-blue-100 text-blue-700 text-sm"
>
{t}
</button>
))}
</div>
);
};
export default AvailableSlotsPicker;

View File

@ -1,418 +1,418 @@
// UI/UX refresh: melhorias visuais e de acessibilidade sem alterar a lógica
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { appointmentService, patientService } from "../../services/index";
import type { Appointment } from "../../services/appointments/types";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
interface Props {
doctorId: string;
}
interface CalendarDay {
date: Date;
dateStr: string;
isCurrentMonth: boolean;
isToday: boolean;
appointments: Appointment[];
}
const WEEKDAYS = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
const MONTHS = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const DoctorCalendar: React.FC<Props> = ({ doctorId }) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(false);
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null);
const [patientsById, setPatientsById] = useState<Record<string, string>>({});
useEffect(() => {
if (doctorId) {
loadAppointments();
loadPatients();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId, currentDate]);
async function loadAppointments() {
setLoading(true);
try {
const appointments = await appointmentService.list();
// Filtrar apenas do médico selecionado
const filtered = appointments.filter(
(apt: Appointment) => apt.doctor_id === doctorId
);
setAppointments(filtered);
} catch (error) {
console.error("Erro ao carregar agendamentos:", error);
toast.error("Erro ao carregar agendamentos");
} finally {
setLoading(false);
}
}
async function loadPatients() {
// Carrega pacientes para mapear nome pelo id (render amigável)
try {
const patients = await patientService.list();
const map: Record<string, string> = {};
for (const p of patients) {
if (p?.id) {
map[p.id] = p.full_name || p.email || p.cpf || p.id;
}
}
setPatientsById(map);
} catch {
// silencioso; não bloqueia calendário
}
}
function getPatientName(id?: string) {
if (!id) return "";
return patientsById[id] || id;
}
function getCalendarDays(): CalendarDay[] {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Primeiro dia do mês
const firstDay = new Date(year, month, 1);
// Último dia do mês
const lastDay = new Date(year, month + 1, 0);
// Dia da semana do primeiro dia (0 = domingo)
const startingDayOfWeek = firstDay.getDay();
const days: CalendarDay[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
// Adicionar dias do mês anterior
const prevMonthLastDay = new Date(year, month, 0);
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
const date = new Date(year, month - 1, prevMonthLastDay.getDate() - i);
const dateStr = formatDateISO(date);
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
appointments: getAppointmentsForDate(dateStr),
});
}
// Adicionar dias do mês atual
for (let day = 1; day <= lastDay.getDate(); day++) {
const date = new Date(year, month, day);
const dateStr = formatDateISO(date);
const isToday = date.getTime() === today.getTime();
days.push({
date,
dateStr,
isCurrentMonth: true,
isToday,
appointments: getAppointmentsForDate(dateStr),
});
}
// Adicionar dias do próximo mês para completar a grade
const remainingDays = 42 - days.length; // 6 semanas x 7 dias
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(year, month + 1, day);
const dateStr = formatDateISO(date);
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
appointments: getAppointmentsForDate(dateStr),
});
}
return days;
}
function formatDateISO(date: Date): string {
return date.toISOString().split("T")[0];
}
function getAppointmentsForDate(dateStr: string): Appointment[] {
return appointments.filter((apt) => {
if (!apt.scheduled_at) return false;
const aptDate = apt.scheduled_at.split("T")[0];
return aptDate === dateStr;
});
}
function previousMonth() {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
);
}
function nextMonth() {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
);
}
function goToToday() {
setCurrentDate(new Date());
}
function getStatusColor(status?: string): string {
switch (status) {
case "confirmed":
return "bg-blue-500";
case "completed":
return "bg-green-500";
case "cancelled":
return "bg-red-500";
case "no_show":
return "bg-gray-500";
case "checked_in":
return "bg-purple-500";
case "in_progress":
return "bg-yellow-500";
default:
return "bg-orange-500"; // requested
}
}
function getStatusLabel(status?: string): string {
const labels: Record<string, string> = {
requested: "Solicitado",
confirmed: "Confirmado",
checked_in: "Check-in",
in_progress: "Em andamento",
completed: "Concluído",
cancelled: "Cancelado",
no_show: "Faltou",
};
return labels[status || "requested"] || status || "Solicitado";
}
const calendarDays = getCalendarDays();
return (
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-200">
{/* Cabeçalho modernizado: melhor contraste, foco e navegação */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-6">
<h3 className="text-xl font-semibold text-gray-900">
Calendário de Consultas
</h3>
<div className="flex items-center gap-3">
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
Hoje
</button>
<div className="flex items-center gap-2">
<button
onClick={previousMonth}
className="p-2 hover:bg-gray-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Mês anterior"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-lg font-medium min-w-[200px] text-center">
{MONTHS[currentDate.getMonth()]} {currentDate.getFullYear()}
</span>
<button
onClick={nextMonth}
className="p-2 hover:bg-gray-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Próximo mês"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600" />
</div>
) : (
<>
{/* Cabeçalhos dos dias da semana */}
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-center text-sm font-semibold text-gray-600 py-2"
>
{day}
</div>
))}
</div>
{/* Grid do calendário com células interativas acessíveis */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((day, index) => (
<div
key={index}
// UI: estados visuais modernizados, mantendo a interação por clique
className={`group min-h-[110px] border rounded-lg p-2 transition-colors ${
day.isCurrentMonth
? "bg-white border-gray-200"
: "bg-gray-50 border-gray-100"
} ${day.isToday ? "ring-2 ring-blue-500" : ""} ${
day.appointments.length > 0
? "cursor-pointer hover:bg-blue-50"
: ""
}`}
onClick={() =>
day.appointments.length > 0 && setSelectedDay(day)
}
>
{/* Número do dia com destaque para hoje */}
<div
className={`text-sm font-medium mb-2 ${
day.isCurrentMonth ? "text-gray-900" : "text-gray-400"
} ${day.isToday ? "text-blue-600 font-bold" : ""}`}
>
{day.date.getDate()}
</div>
{/* Chips de horários com cores por status */}
<div className="space-y-1">
{day.appointments.slice(0, 3).map((apt, idx) => (
<div
key={apt.id || idx}
className={`text-xs px-1 py-0.5 rounded text-white ${getStatusColor(
apt.status
)} truncate`}
title={`${apt.scheduled_at?.slice(
11,
16
)} - ${getStatusLabel(apt.status)}`}
>
{apt.scheduled_at?.slice(11, 16)}
</div>
))}
{day.appointments.length > 3 && (
<div className="text-xs text-gray-500 font-medium">
+{day.appointments.length - 3} mais
</div>
)}
</div>
</div>
))}
</div>
</>
)}
{/* Modal de detalhes do dia - melhorado com acessibilidade e botão de fechar */}
{selectedDay && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-50"
onClick={() => setSelectedDay(null)}
role="dialog"
aria-modal="true"
aria-label="Consultas do dia selecionado"
>
<div
className="bg-white rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-auto ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-xl font-semibold text-gray-900">
Consultas de{" "}
{selectedDay.date.toLocaleDateString("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</h3>
<button
onClick={() => setSelectedDay(null)}
aria-label="Fechar"
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-3">
{selectedDay.appointments.length === 0 ? (
<p className="text-gray-500 text-center py-4">
Nenhuma consulta agendada para este dia.
</p>
) : (
selectedDay.appointments.map((apt) => (
<div
key={apt.id}
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-gray-900">
{apt.scheduled_at?.slice(11, 16)}
</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium text-white ${getStatusColor(
apt.status
)}`}
>
{getStatusLabel(apt.status)}
</span>
</div>
<div className="text-sm text-gray-600 space-y-1">
<div>
<span className="font-medium">Paciente:</span>{" "}
{getPatientName(apt.patient_id)}
</div>
{apt.appointment_type && (
<div>
<span className="font-medium">Tipo:</span>{" "}
{apt.appointment_type === "presencial"
? "Presencial"
: "Telemedicina"}
</div>
)}
{apt.chief_complaint && (
<div>
<span className="font-medium">Queixa:</span>{" "}
{apt.chief_complaint}
</div>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
<div className="p-6 border-t border-gray-200 flex justify-end">
<button
onClick={() => setSelectedDay(null)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default DoctorCalendar;
// UI/UX refresh: melhorias visuais e de acessibilidade sem alterar a lógica
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { appointmentService, patientService } from "../../services/index";
import type { Appointment } from "../../services/appointments/types";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
interface Props {
doctorId: string;
}
interface CalendarDay {
date: Date;
dateStr: string;
isCurrentMonth: boolean;
isToday: boolean;
appointments: Appointment[];
}
const WEEKDAYS = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
const MONTHS = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
const DoctorCalendar: React.FC<Props> = ({ doctorId }) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(false);
const [selectedDay, setSelectedDay] = useState<CalendarDay | null>(null);
const [patientsById, setPatientsById] = useState<Record<string, string>>({});
useEffect(() => {
if (doctorId) {
loadAppointments();
loadPatients();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId, currentDate]);
async function loadAppointments() {
setLoading(true);
try {
const appointments = await appointmentService.list();
// Filtrar apenas do médico selecionado
const filtered = appointments.filter(
(apt: Appointment) => apt.doctor_id === doctorId
);
setAppointments(filtered);
} catch (error) {
console.error("Erro ao carregar agendamentos:", error);
toast.error("Erro ao carregar agendamentos");
} finally {
setLoading(false);
}
}
async function loadPatients() {
// Carrega pacientes para mapear nome pelo id (render amigável)
try {
const patients = await patientService.list();
const map: Record<string, string> = {};
for (const p of patients) {
if (p?.id) {
map[p.id] = p.full_name || p.email || p.cpf || p.id;
}
}
setPatientsById(map);
} catch {
// silencioso; não bloqueia calendário
}
}
function getPatientName(id?: string) {
if (!id) return "";
return patientsById[id] || id;
}
function getCalendarDays(): CalendarDay[] {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// Primeiro dia do mês
const firstDay = new Date(year, month, 1);
// Último dia do mês
const lastDay = new Date(year, month + 1, 0);
// Dia da semana do primeiro dia (0 = domingo)
const startingDayOfWeek = firstDay.getDay();
const days: CalendarDay[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
// Adicionar dias do mês anterior
const prevMonthLastDay = new Date(year, month, 0);
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
const date = new Date(year, month - 1, prevMonthLastDay.getDate() - i);
const dateStr = formatDateISO(date);
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
appointments: getAppointmentsForDate(dateStr),
});
}
// Adicionar dias do mês atual
for (let day = 1; day <= lastDay.getDate(); day++) {
const date = new Date(year, month, day);
const dateStr = formatDateISO(date);
const isToday = date.getTime() === today.getTime();
days.push({
date,
dateStr,
isCurrentMonth: true,
isToday,
appointments: getAppointmentsForDate(dateStr),
});
}
// Adicionar dias do próximo mês para completar a grade
const remainingDays = 42 - days.length; // 6 semanas x 7 dias
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(year, month + 1, day);
const dateStr = formatDateISO(date);
days.push({
date,
dateStr,
isCurrentMonth: false,
isToday: false,
appointments: getAppointmentsForDate(dateStr),
});
}
return days;
}
function formatDateISO(date: Date): string {
return date.toISOString().split("T")[0];
}
function getAppointmentsForDate(dateStr: string): Appointment[] {
return appointments.filter((apt) => {
if (!apt.scheduled_at) return false;
const aptDate = apt.scheduled_at.split("T")[0];
return aptDate === dateStr;
});
}
function previousMonth() {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1)
);
}
function nextMonth() {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1)
);
}
function goToToday() {
setCurrentDate(new Date());
}
function getStatusColor(status?: string): string {
switch (status) {
case "confirmed":
return "bg-blue-500";
case "completed":
return "bg-green-500";
case "cancelled":
return "bg-red-500";
case "no_show":
return "bg-gray-500";
case "checked_in":
return "bg-purple-500";
case "in_progress":
return "bg-yellow-500";
default:
return "bg-orange-500"; // requested
}
}
function getStatusLabel(status?: string): string {
const labels: Record<string, string> = {
requested: "Solicitado",
confirmed: "Confirmado",
checked_in: "Check-in",
in_progress: "Em andamento",
completed: "Concluído",
cancelled: "Cancelado",
no_show: "Faltou",
};
return labels[status || "requested"] || status || "Solicitado";
}
const calendarDays = getCalendarDays();
return (
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-200">
{/* Cabeçalho modernizado: melhor contraste, foco e navegação */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-6">
<h3 className="text-xl font-semibold text-gray-900">
Calendário de Consultas
</h3>
<div className="flex items-center gap-3">
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
Hoje
</button>
<div className="flex items-center gap-2">
<button
onClick={previousMonth}
className="p-2 hover:bg-gray-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Mês anterior"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-lg font-medium min-w-[200px] text-center">
{MONTHS[currentDate.getMonth()]} {currentDate.getFullYear()}
</span>
<button
onClick={nextMonth}
className="p-2 hover:bg-gray-100 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Próximo mês"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600" />
</div>
) : (
<>
{/* Cabeçalhos dos dias da semana */}
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map((day) => (
<div
key={day}
className="text-center text-sm font-semibold text-gray-600 py-2"
>
{day}
</div>
))}
</div>
{/* Grid do calendário com células interativas acessíveis */}
<div className="grid grid-cols-7 gap-1">
{calendarDays.map((day, index) => (
<div
key={index}
// UI: estados visuais modernizados, mantendo a interação por clique
className={`group min-h-[110px] border rounded-lg p-2 transition-colors ${
day.isCurrentMonth
? "bg-white border-gray-200"
: "bg-gray-50 border-gray-100"
} ${day.isToday ? "ring-2 ring-blue-500" : ""} ${
day.appointments.length > 0
? "cursor-pointer hover:bg-blue-50"
: ""
}`}
onClick={() =>
day.appointments.length > 0 && setSelectedDay(day)
}
>
{/* Número do dia com destaque para hoje */}
<div
className={`text-sm font-medium mb-2 ${
day.isCurrentMonth ? "text-gray-900" : "text-gray-400"
} ${day.isToday ? "text-blue-600 font-bold" : ""}`}
>
{day.date.getDate()}
</div>
{/* Chips de horários com cores por status */}
<div className="space-y-1">
{day.appointments.slice(0, 3).map((apt, idx) => (
<div
key={apt.id || idx}
className={`text-xs px-1 py-0.5 rounded text-white ${getStatusColor(
apt.status
)} truncate`}
title={`${apt.scheduled_at?.slice(
11,
16
)} - ${getStatusLabel(apt.status)}`}
>
{apt.scheduled_at?.slice(11, 16)}
</div>
))}
{day.appointments.length > 3 && (
<div className="text-xs text-gray-500 font-medium">
+{day.appointments.length - 3} mais
</div>
)}
</div>
</div>
))}
</div>
</>
)}
{/* Modal de detalhes do dia - melhorado com acessibilidade e botão de fechar */}
{selectedDay && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-50"
onClick={() => setSelectedDay(null)}
role="dialog"
aria-modal="true"
aria-label="Consultas do dia selecionado"
>
<div
className="bg-white rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-auto ring-1 ring-black/5"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-xl font-semibold text-gray-900">
Consultas de{" "}
{selectedDay.date.toLocaleDateString("pt-BR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</h3>
<button
onClick={() => setSelectedDay(null)}
aria-label="Fechar"
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-3">
{selectedDay.appointments.length === 0 ? (
<p className="text-gray-500 text-center py-4">
Nenhuma consulta agendada para este dia.
</p>
) : (
selectedDay.appointments.map((apt) => (
<div
key={apt.id}
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-gray-900">
{apt.scheduled_at?.slice(11, 16)}
</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium text-white ${getStatusColor(
apt.status
)}`}
>
{getStatusLabel(apt.status)}
</span>
</div>
<div className="text-sm text-gray-600 space-y-1">
<div>
<span className="font-medium">Paciente:</span>{" "}
{getPatientName(apt.patient_id)}
</div>
{apt.appointment_type && (
<div>
<span className="font-medium">Tipo:</span>{" "}
{apt.appointment_type === "presencial"
? "Presencial"
: "Telemedicina"}
</div>
)}
{apt.chief_complaint && (
<div>
<span className="font-medium">Queixa:</span>{" "}
{apt.chief_complaint}
</div>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
<div className="p-6 border-t border-gray-200 flex justify-end">
<button
onClick={() => setSelectedDay(null)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
Fechar
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default DoctorCalendar;

View File

@ -1,280 +1,277 @@
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { availabilityService } from "../../services/index";
import type {
DoctorException,
ExceptionKind,
} from "../../services/availability/types";
interface Props {
doctorId: string;
}
const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
const [list, setList] = useState<DoctorException[]>([]);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
date: "",
start_time: "",
end_time: "",
kind: "bloqueio" as ExceptionKind,
reason: "",
});
const [saving, setSaving] = useState(false);
async function load() {
if (!doctorId) return;
setLoading(true);
try {
const exceptions = await availabilityService.listExceptions({
doctor_id: doctorId,
});
setList(exceptions);
} catch (error) {
console.error("Erro ao carregar exceções:", error);
toast.error("Erro ao carregar exceções");
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId]);
async function addException(e: React.FormEvent) {
e.preventDefault();
if (!doctorId || !form.date || !form.kind) {
toast.error("Preencha data e tipo");
return;
}
setSaving(true);
try {
await availabilityService.createException({
doctor_id: doctorId,
date: form.date,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
kind: form.kind,
reason: form.reason || undefined,
created_by: doctorId, // Usando doctorId como criador
});
toast.success("Exceção criada");
setForm({
date: "",
start_time: "",
end_time: "",
kind: "bloqueio",
reason: "",
});
void load();
} catch (error) {
console.error("Falha ao criar exceção:", error);
toast.error("Falha ao criar");
} finally {
setSaving(false);
}
}
async function remove(item: DoctorException) {
if (!item.id) return;
const ok = confirm("Remover exceção?");
if (!ok) return;
try {
await availabilityService.deleteException(item.id);
toast.success("Removida");
void load();
} catch (error) {
console.error("Falha ao remover exceção:", error);
toast.error("Falha ao remover");
}
}
return (
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Exceções (Bloqueios/Liberações)
</h3>
<form onSubmit={addException} className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 mb-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Data
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
required
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Início{" "}
<span className="text-gray-400 font-normal">(opcional)</span>
</label>
<input
type="time"
value={form.start_time}
onChange={(e) =>
setForm((f) => ({ ...f, start_time: e.target.value }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
placeholder="Dia todo"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Fim <span className="text-gray-400 font-normal">(opcional)</span>
</label>
<input
type="time"
value={form.end_time}
onChange={(e) =>
setForm((f) => ({ ...f, end_time: e.target.value }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
placeholder="Dia todo"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Tipo
</label>
<select
value={form.kind}
onChange={(e) =>
setForm((f) => ({
...f,
kind: e.target.value as ExceptionKind,
}))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
>
<option value="bloqueio">Bloqueio</option>
<option value="liberacao">Liberação</option>
</select>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-gray-700 mb-1">
Motivo{" "}
<span className="text-gray-400 font-normal">(opcional)</span>
</label>
<input
type="text"
value={form.reason}
onChange={(e) =>
setForm((f) => ({ ...f, reason: e.target.value }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
placeholder="Ex.: Férias, Reunião, etc."
/>
</div>
<div className="flex flex-col justify-end">
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{saving ? "Salvando..." : "Adicionar"}
</button>
</div>
</div>
</form>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : list.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-500">
Nenhuma exceção cadastrada. Use o formulário acima para
bloquear/liberar horários.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Início
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Fim
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tipo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Motivo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{list.map((item) => (
<tr
key={item.id}
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50/40 transition-colors"
>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.date
? new Date(item.date + "T00:00:00").toLocaleDateString(
"pt-BR"
)
: "—"}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.start_time ? (
item.start_time.slice(0, 5)
) : (
<span className="text-gray-400">Dia todo</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.end_time ? (
item.end_time.slice(0, 5)
) : (
<span className="text-gray-400">Dia todo</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 rounded-full text-xs font-medium ring-1 ${
item.kind === "bloqueio"
? "bg-red-50 text-red-700 ring-red-600/20"
: "bg-green-50 text-green-700 ring-green-600/20"
}`}
>
{item.kind === "bloqueio" ? "Bloqueio" : "Liberação"}
</span>
</td>
<td className="px-4 py-4 text-sm text-gray-900">
{item.reason || <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium">
<button
className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-red-700 bg-red-50 hover:bg-red-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => void remove(item)}
>
Remover
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default ExceptionsManager;
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { exceptionsService } from "../../services/index";
import type {
DoctorException,
ExceptionKind,
} from "../../services/exceptions/types";
interface Props {
doctorId: string;
}
const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
const [list, setList] = useState<DoctorException[]>([]);
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({
date: "",
start_time: "",
end_time: "",
kind: "bloqueio" as ExceptionKind,
reason: "",
});
const [saving, setSaving] = useState(false);
async function load() {
if (!doctorId) return;
setLoading(true);
try {
const exceptions = await exceptionsService.list({ doctor_id: doctorId });
setList(exceptions);
} catch (error) {
console.error("Erro ao carregar exceções:", error);
toast.error("Erro ao carregar exceções");
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [doctorId]);
async function addException(e: React.FormEvent) {
e.preventDefault();
if (!doctorId || !form.date || !form.kind) {
toast.error("Preencha data e tipo");
return;
}
setSaving(true);
try {
await exceptionsService.create({
doctor_id: doctorId,
date: form.date,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
kind: form.kind,
reason: form.reason || undefined,
});
toast.success("Exceção criada");
setForm({
date: "",
start_time: "",
end_time: "",
kind: "bloqueio",
reason: "",
});
void load();
} catch (error) {
console.error("Falha ao criar exceção:", error);
toast.error("Falha ao criar");
} finally {
setSaving(false);
}
}
async function remove(item: DoctorException) {
if (!item.id) return;
const ok = confirm("Remover exceção?");
if (!ok) return;
try {
await exceptionsService.delete(item.id);
toast.success("Removida");
void load();
} catch (error) {
console.error("Falha ao remover exceção:", error);
toast.error("Falha ao remover");
}
}
return (
<div className="bg-white rounded-xl shadow-md p-6 border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Exceções (Bloqueios/Liberações)
</h3>
<form onSubmit={addException} className="mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 mb-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Data
</label>
<input
type="date"
value={form.date}
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
required
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Início{" "}
<span className="text-gray-400 font-normal">(opcional)</span>
</label>
<input
type="time"
value={form.start_time}
onChange={(e) =>
setForm((f) => ({ ...f, start_time: e.target.value }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
placeholder="Dia todo"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Fim <span className="text-gray-400 font-normal">(opcional)</span>
</label>
<input
type="time"
value={form.end_time}
onChange={(e) =>
setForm((f) => ({ ...f, end_time: e.target.value }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
placeholder="Dia todo"
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-gray-700 mb-1">
Tipo
</label>
<select
value={form.kind}
onChange={(e) =>
setForm((f) => ({
...f,
kind: e.target.value as ExceptionKind,
}))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
>
<option value="bloqueio">Bloqueio</option>
<option value="liberacao">Liberação</option>
</select>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-gray-700 mb-1">
Motivo{" "}
<span className="text-gray-400 font-normal">(opcional)</span>
</label>
<input
type="text"
value={form.reason}
onChange={(e) =>
setForm((f) => ({ ...f, reason: e.target.value }))
}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
placeholder="Ex.: Férias, Reunião, etc."
/>
</div>
<div className="flex flex-col justify-end">
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
{saving ? "Salvando..." : "Adicionar"}
</button>
</div>
</div>
</form>
{loading ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : list.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-500">
Nenhuma exceção cadastrada. Use o formulário acima para
bloquear/liberar horários.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Início
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Horário Fim
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tipo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Motivo
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{list.map((item) => (
<tr
key={item.id}
className="odd:bg-white even:bg-gray-50 hover:bg-blue-50/40 transition-colors"
>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.date
? new Date(item.date + "T00:00:00").toLocaleDateString(
"pt-BR"
)
: "—"}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.start_time ? (
item.start_time.slice(0, 5)
) : (
<span className="text-gray-400">Dia todo</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{item.end_time ? (
item.end_time.slice(0, 5)
) : (
<span className="text-gray-400">Dia todo</span>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 rounded-full text-xs font-medium ring-1 ${
item.kind === "bloqueio"
? "bg-red-50 text-red-700 ring-red-600/20"
: "bg-green-50 text-green-700 ring-green-600/20"
}`}
>
{item.kind === "bloqueio" ? "Bloqueio" : "Liberação"}
</span>
</td>
<td className="px-4 py-4 text-sm text-gray-900">
{item.reason || <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium">
<button
className="inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-red-700 bg-red-50 hover:bg-red-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
onClick={() => void remove(item)}
>
Remover
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default ExceptionsManager;

View File

@ -1,424 +1,422 @@
// UI/UX: adiciona refs e ícones para melhorar acessibilidade e feedback visual
import React, { useState, useEffect, useMemo, useRef } from "react";
import {
Calendar as CalendarIcon,
Clock,
Loader2,
Stethoscope,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import {
appointmentService,
doctorService,
patientService,
} from "../../services/index";
import type { Patient } from "../../services/patients/types";
import type { Doctor } from "../../services/doctors/types";
import AvailableSlotsPicker from "./AvailableSlotsPicker";
interface Props {
isOpen: boolean;
onClose: () => void;
patientId?: string; // opcional: quando não informado, seleciona paciente no modal
patientName?: string; // opcional
onSuccess?: () => void;
}
const ScheduleAppointmentModal: React.FC<Props> = ({
isOpen,
onClose,
patientId,
patientName,
onSuccess,
}) => {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(false);
const [patients, setPatients] = useState<Patient[]>([]);
const [loadingPatients, setLoadingPatients] = useState(false);
const [selectedDoctorId, setSelectedDoctorId] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState<
"presencial" | "telemedicina"
>("presencial");
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
const [selectedPatientId, setSelectedPatientId] = useState("");
const [selectedPatientName, setSelectedPatientName] = useState("");
// A11y & UX: refs para foco inicial e fechamento via overlay/ESC
const overlayRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
// A11y: IDs para aria-labelledby/aria-describedby
const titleId = useMemo(
() => `schedule-modal-title-${patientId ?? "novo"}`,
[patientId]
);
const descId = useMemo(
() => `schedule-modal-desc-${patientId ?? "novo"}`,
[patientId]
);
useEffect(() => {
if (isOpen) {
loadDoctors();
if (!patientId) {
loadPatients();
} else {
// Garantir estados internos alinhados com props
setSelectedPatientId(patientId);
setSelectedPatientName(patientName || "");
}
// UX: foco no primeiro campo quando abrir
setTimeout(() => firstFieldRef.current?.focus(), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
async function loadDoctors() {
setLoadingDoctors(true);
try {
const doctors = await doctorService.list();
setDoctors(doctors);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
} finally {
setLoadingDoctors(false);
}
}
async function loadPatients() {
setLoadingPatients(true);
try {
const patients = await patientService.list();
setPatients(patients);
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar pacientes");
} finally {
setLoadingPatients(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const finalPatientId = patientId || selectedPatientId;
if (
!selectedDoctorId ||
!selectedDate ||
!selectedTime ||
!finalPatientId
) {
toast.error("Preencha médico, data e horário");
return;
}
setLoading(true);
const datetime = `${selectedDate}T${selectedTime}:00`;
try {
await appointmentService.create({
patient_id: finalPatientId,
doctor_id: selectedDoctorId,
scheduled_at: datetime,
appointment_type: appointmentType,
chief_complaint: reason || undefined,
});
toast.success("Agendamento criado com sucesso!");
onSuccess?.();
handleClose();
} catch (error) {
console.error("Erro ao criar agendamento:", error);
toast.error("Erro ao criar agendamento");
} finally {
setLoading(false);
}
}
function handleClose() {
setSelectedDoctorId("");
setSelectedDate("");
setSelectedTime("");
setAppointmentType("presencial");
setReason("");
setSelectedPatientId("");
setSelectedPatientName("");
onClose();
}
if (!isOpen) return null;
const selectedDoctor = doctors.find((d) => d.id === selectedDoctorId);
const patientPreselected = !!patientId;
const effectivePatientName = patientPreselected
? patientName
: selectedPatientName ||
(patients.find((p) => p.id === selectedPatientId)?.full_name ?? "");
// UX: handlers para ESC e clique fora
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
handleClose();
}
}
function onOverlayClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === overlayRef.current) handleClose();
}
return (
<div
ref={overlayRef}
className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-50 p-4"
onClick={onOverlayClick}
onKeyDown={onKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
>
<div
ref={dialogRef}
className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto ring-1 ring-black/5 animate-in fade-in zoom-in duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-gradient-to-r from-blue-50 to-white border-b px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="w-5 h-5 text-blue-600" aria-hidden="true" />
<h2
id={titleId}
className="text-lg md:text-xl font-semibold text-gray-900"
>
Agendar consulta {" "}
<span className="font-normal text-gray-700">
{effectivePatientName}
</span>
</h2>
</div>
<button
ref={closeBtnRef}
onClick={handleClose}
aria-label="Fechar modal de agendamento"
className="inline-flex items-center justify-center rounded-md p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<X className="w-5 h-5" />
</button>
</div>
<p id={descId} className="sr-only">
Selecione o médico, a data, o tipo de consulta e um horário disponível
para criar um novo agendamento.
</p>
<form
onSubmit={handleSubmit}
className="p-6 space-y-6"
aria-busy={loading}
>
{/* Paciente (apenas quando não veio por props) */}
{!patientPreselected && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Paciente *
</label>
{loadingPatients ? (
// Skeleton para carregamento de pacientes
<div
className="h-10 w-full rounded-lg bg-gray-100 animate-pulse"
aria-live="polite"
aria-label="Carregando pacientes"
/>
) : (
<select
value={selectedPatientId}
onChange={(e) => {
setSelectedPatientId(e.target.value);
const p = patients.find((px) => px.id === e.target.value);
setSelectedPatientName(p?.full_name || "");
}}
className="form-input"
required
>
<option value="">-- Selecione um paciente --</option>
{patients.map((p) => (
<option key={p.id} value={p.id}>
{p.full_name} {p.cpf ? `- ${p.cpf}` : ""}
</option>
))}
</select>
)}
</div>
)}
{/* Médico */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Médico{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
{loadingDoctors ? (
<div
className="h-10 w-full rounded-lg bg-gray-100 animate-pulse"
aria-live="polite"
aria-label="Carregando médicos"
/>
) : (
<select
value={selectedDoctorId}
onChange={(e) => setSelectedDoctorId(e.target.value)}
ref={firstFieldRef}
className="form-input"
required
>
<option value="">-- Selecione um médico --</option>
{doctors.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.full_name} - {doc.specialty}
</option>
))}
</select>
)}
{selectedDoctor && (
<div className="mt-2 text-sm text-gray-600">
CRM: {selectedDoctor.crm}
</div>
)}
</div>
{/* Data */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => {
setSelectedDate(e.target.value);
setSelectedTime(""); // Limpa o horário ao mudar a data
}}
min={new Date().toISOString().split("T")[0]}
className="form-input"
required
/>
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<CalendarIcon className="w-3.5 h-3.5" /> Selecione uma data para
ver os horários disponíveis.
</p>
</div>
{/* Tipo de Consulta */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Consulta{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
<select
value={appointmentType}
onChange={(e) =>
setAppointmentType(
e.target.value as "presencial" | "telemedicina"
)
}
className="form-input"
required
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> O tipo de consulta pode alterar
a disponibilidade de horários.
</p>
</div>
{/* Horários Disponíveis */}
{selectedDoctorId && selectedDate && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horários Disponíveis *
</label>
<AvailableSlotsPicker
doctorId={selectedDoctorId}
date={selectedDate}
appointment_type={appointmentType}
onSelect={(time) => setSelectedTime(time)}
/>
{selectedTime && (
<div className="mt-2 inline-flex items-center gap-2 rounded-md bg-green-50 px-3 py-1.5 text-sm text-green-700 ring-1 ring-green-600/20">
<span aria-hidden></span> Horário selecionado:{" "}
<span className="font-semibold">{selectedTime}</span>
</div>
)}
</div>
)}
{/* Motivo */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motivo da Consulta (opcional)
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="form-input"
placeholder="Ex: Consulta de rotina, dor de cabeça..."
/>
</div>
{/* Botões */}
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4 border-t">
<button
type="button"
onClick={handleClose}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
disabled={loading}
>
Cancelar
</button>
<button
type="submit"
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
disabled={
loading ||
!selectedDoctorId ||
!selectedDate ||
!selectedTime ||
(!patientPreselected && !selectedPatientId)
}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" aria-hidden />{" "}
Agendando...
</>
) : (
"Confirmar Agendamento"
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default ScheduleAppointmentModal;
// UI/UX: adiciona refs e ícones para melhorar acessibilidade e feedback visual
import React, { useState, useEffect, useMemo, useRef } from "react";
import {
Calendar as CalendarIcon,
Clock,
Loader2,
Stethoscope,
X,
} from "lucide-react";
import toast from "react-hot-toast";
import {
appointmentService,
doctorService,
patientService,
} from "../../services/index";
import type { Patient } from "../../services/patients/types";
import type { Doctor } from "../../services/doctors/types";
import AvailableSlotsPicker from "./AvailableSlotsPicker";
interface Props {
isOpen: boolean;
onClose: () => void;
patientId?: string; // opcional: quando não informado, seleciona paciente no modal
patientName?: string; // opcional
onSuccess?: () => void;
}
const ScheduleAppointmentModal: React.FC<Props> = ({
isOpen,
onClose,
patientId,
patientName,
onSuccess,
}) => {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loadingDoctors, setLoadingDoctors] = useState(false);
const [patients, setPatients] = useState<Patient[]>([]);
const [loadingPatients, setLoadingPatients] = useState(false);
const [selectedDoctorId, setSelectedDoctorId] = useState("");
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [appointmentType, setAppointmentType] = useState<
"presencial" | "telemedicina"
>("presencial");
const [reason, setReason] = useState("");
const [loading, setLoading] = useState(false);
const [selectedPatientId, setSelectedPatientId] = useState("");
const [selectedPatientName, setSelectedPatientName] = useState("");
// A11y & UX: refs para foco inicial e fechamento via overlay/ESC
const overlayRef = useRef<HTMLDivElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
const closeBtnRef = useRef<HTMLButtonElement | null>(null);
// A11y: IDs para aria-labelledby/aria-describedby
const titleId = useMemo(
() => `schedule-modal-title-${patientId ?? "novo"}`,
[patientId]
);
const descId = useMemo(
() => `schedule-modal-desc-${patientId ?? "novo"}`,
[patientId]
);
useEffect(() => {
if (isOpen) {
loadDoctors();
if (!patientId) {
loadPatients();
} else {
// Garantir estados internos alinhados com props
setSelectedPatientId(patientId);
setSelectedPatientName(patientName || "");
}
// UX: foco no primeiro campo quando abrir
setTimeout(() => firstFieldRef.current?.focus(), 0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
async function loadDoctors() {
setLoadingDoctors(true);
try {
const doctors = await doctorService.list();
setDoctors(doctors);
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
} finally {
setLoadingDoctors(false);
}
}
async function loadPatients() {
setLoadingPatients(true);
try {
const patients = await patientService.list();
setPatients(patients);
} catch (error) {
console.error("Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar pacientes");
} finally {
setLoadingPatients(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const finalPatientId = patientId || selectedPatientId;
if (
!selectedDoctorId ||
!selectedDate ||
!selectedTime ||
!finalPatientId
) {
toast.error("Preencha médico, data e horário");
return;
}
setLoading(true);
const datetime = `${selectedDate}T${selectedTime}:00`;
try {
await appointmentService.create({
patient_id: finalPatientId,
doctor_id: selectedDoctorId,
scheduled_at: datetime,
appointment_type: appointmentType,
chief_complaint: reason || undefined,
});
toast.success("Agendamento criado com sucesso!");
onSuccess?.();
handleClose();
} catch (error) {
console.error("Erro ao criar agendamento:", error);
toast.error("Erro ao criar agendamento");
} finally {
setLoading(false);
}
}
function handleClose() {
setSelectedDoctorId("");
setSelectedDate("");
setSelectedTime("");
setAppointmentType("presencial");
setReason("");
setSelectedPatientId("");
setSelectedPatientName("");
onClose();
}
if (!isOpen) return null;
const selectedDoctor = doctors.find((d) => d.id === selectedDoctorId);
const patientPreselected = !!patientId;
const effectivePatientName = patientPreselected
? patientName
: selectedPatientName ||
(patients.find((p) => p.id === selectedPatientId)?.full_name ?? "");
// UX: handlers para ESC e clique fora
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
e.stopPropagation();
handleClose();
}
}
function onOverlayClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === overlayRef.current) handleClose();
}
return (
<div
ref={overlayRef}
className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-50 p-4"
onClick={onOverlayClick}
onKeyDown={onKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
>
<div
ref={dialogRef}
className="bg-white rounded-xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto ring-1 ring-black/5 animate-in fade-in zoom-in duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 bg-gradient-to-r from-blue-50 to-white border-b px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="w-5 h-5 text-blue-600" aria-hidden="true" />
<h2
id={titleId}
className="text-lg md:text-xl font-semibold text-gray-900"
>
Agendar consulta {" "}
<span className="font-normal text-gray-700">
{effectivePatientName}
</span>
</h2>
</div>
<button
ref={closeBtnRef}
onClick={handleClose}
aria-label="Fechar modal de agendamento"
className="inline-flex items-center justify-center rounded-md p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<X className="w-5 h-5" />
</button>
</div>
<p id={descId} className="sr-only">
Selecione o médico, a data, o tipo de consulta e um horário disponível
para criar um novo agendamento.
</p>
<form
onSubmit={handleSubmit}
className="p-6 space-y-6"
aria-busy={loading}
>
{/* Paciente (apenas quando não veio por props) */}
{!patientPreselected && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Paciente *
</label>
{loadingPatients ? (
// Skeleton para carregamento de pacientes
<div
className="h-10 w-full rounded-lg bg-gray-100 animate-pulse"
aria-live="polite"
aria-label="Carregando pacientes"
/>
) : (
<select
value={selectedPatientId}
onChange={(e) => {
setSelectedPatientId(e.target.value);
const p = patients.find((px) => px.id === e.target.value);
setSelectedPatientName(p?.full_name || "");
}}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
required
>
<option value="">-- Selecione um paciente --</option>
{patients.map((p) => (
<option key={p.id} value={p.id}>
{p.full_name} {p.cpf ? `- ${p.cpf}` : ""}
</option>
))}
</select>
)}
</div>
)}
{/* Médico */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Médico{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
{loadingDoctors ? (
<div
className="h-10 w-full rounded-lg bg-gray-100 animate-pulse"
aria-live="polite"
aria-label="Carregando médicos"
/>
) : (
<select
value={selectedDoctorId}
onChange={(e) => setSelectedDoctorId(e.target.value)}
ref={firstFieldRef}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
required
>
<option value="">-- Selecione um médico --</option>
{doctors.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.full_name} - {doc.specialty}
</option>
))}
</select>
)}
{selectedDoctor && (
<div className="mt-2 text-sm text-gray-600">
CRM: {selectedDoctor.crm}
</div>
)}
</div>
{/* Data */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => {
setSelectedDate(e.target.value);
setSelectedTime(""); // Limpa o horário ao mudar a data
}}
min={new Date().toISOString().split("T")[0]}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
required
/>
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<CalendarIcon className="w-3.5 h-3.5" /> Selecione uma data para
ver os horários disponíveis.
</p>
</div>
{/* Tipo de Consulta */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Consulta{" "}
<span className="text-red-500" aria-hidden="true">
*
</span>
</label>
<select
value={appointmentType}
onChange={(e) =>
setAppointmentType(
e.target.value as "presencial" | "telemedicina"
)
}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
required
>
<option value="presencial">Presencial</option>
<option value="telemedicina">Telemedicina</option>
</select>
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" /> O tipo de consulta pode alterar
a disponibilidade de horários.
</p>
</div>
{/* Horários Disponíveis */}
{selectedDoctorId && selectedDate && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horários Disponíveis *
</label>
<AvailableSlotsPicker
doctorId={selectedDoctorId}
date={selectedDate}
appointment_type={appointmentType}
onSelect={(time) => setSelectedTime(time)}
/>
{selectedTime && (
<div className="mt-2 inline-flex items-center gap-2 rounded-md bg-green-50 px-3 py-1.5 text-sm text-green-700 ring-1 ring-green-600/20">
<span aria-hidden></span> Horário selecionado:{" "}
<span className="font-semibold">{selectedTime}</span>
</div>
)}
</div>
)}
{/* Motivo */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motivo da Consulta (opcional)
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
placeholder="Ex: Consulta de rotina, dor de cabeça..."
/>
</div>
{/* Botões */}
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4 border-t">
<button
type="button"
onClick={handleClose}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
disabled={loading}
>
Cancelar
</button>
<button
type="submit"
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
disabled={
loading ||
!selectedDoctorId ||
!selectedDate ||
!selectedTime ||
(!patientPreselected && !selectedPatientId)
}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" aria-hidden />{" "}
Agendando...
</>
) : (
"Confirmar Agendamento"
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default ScheduleAppointmentModal;

View File

@ -0,0 +1,356 @@
import React, { useEffect, useState, useCallback } from "react";
import { X, Loader2 } from "lucide-react";
import {
appointmentService,
patientService,
doctorService,
type Appointment,
type Patient,
type Doctor,
} from "../../services";
import { useAuth } from "../../hooks/useAuth";
// Type aliases para compatibilidade com código antigo
type Consulta = Appointment & {
pacienteId?: string;
medicoId?: string;
dataHora?: string;
observacoes?: string;
};
type Paciente = Patient;
type Medico = Doctor;
interface ConsultaModalProps {
isOpen: boolean;
onClose: () => void;
onSaved: (c: Consulta) => void;
editing?: Consulta | null;
defaultPacienteId?: string;
defaultMedicoId?: string;
lockPaciente?: boolean; // quando abrir a partir do prontuário
lockMedico?: boolean; // quando médico logado não deve mudar
}
const TIPO_SUGESTOES = [
"Primeira consulta",
"Retorno",
"Acompanhamento",
"Exame",
"Telemedicina",
];
const ConsultaModal: React.FC<ConsultaModalProps> = ({
isOpen,
onClose,
onSaved,
editing,
defaultPacienteId,
defaultMedicoId,
lockPaciente = false,
lockMedico = false,
}) => {
const { user } = useAuth();
const [pacientes, setPacientes] = useState<Paciente[]>([]);
const [medicos, setMedicos] = useState<Medico[]>([]);
const [loadingLists, setLoadingLists] = useState(false);
const [pacienteId, setPacienteId] = useState("");
const [medicoId, setMedicoId] = useState("");
const [dataHora, setDataHora] = useState(""); // value for datetime-local
const [tipo, setTipo] = useState("");
const [motivo, setMotivo] = useState("");
const [observacoes, setObservacoes] = useState("");
const [status, setStatus] = useState<string>("agendada");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load supporting lists
useEffect(() => {
if (!isOpen) return;
let active = true;
(async () => {
try {
setLoadingLists(true);
const [patients, doctors] = await Promise.all([
patientService.list().catch(() => []),
doctorService.list().catch(() => []),
]);
if (!active) return;
setPacientes(patients);
setMedicos(doctors);
} finally {
if (active) setLoadingLists(false);
}
})();
return () => {
active = false;
};
}, [isOpen]);
// Initialize form when opening / editing changes
useEffect(() => {
if (!isOpen) return;
if (editing) {
setPacienteId(editing.pacienteId);
setMedicoId(editing.medicoId);
// Convert ISO to local datetime-local value
try {
const d = new Date(editing.dataHora);
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
setDataHora(local);
} catch {
setDataHora("");
}
setTipo(editing.tipo || "");
setMotivo(editing.motivo || "");
setObservacoes(editing.observacoes || "");
setStatus(editing.status || "agendada");
} else {
setPacienteId(defaultPacienteId || "");
setMedicoId(defaultMedicoId || "");
setDataHora("");
setTipo("");
setMotivo("");
setObservacoes("");
setStatus("agendada");
}
setError(null);
setSaving(false);
}, [isOpen, editing, defaultPacienteId, defaultMedicoId, user]);
const closeOnEsc = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
},
[onClose]
);
useEffect(() => {
if (!isOpen) return;
window.addEventListener("keydown", closeOnEsc);
return () => window.removeEventListener("keydown", closeOnEsc);
}, [isOpen, closeOnEsc]);
if (!isOpen) return null;
const validate = (): boolean => {
if (!pacienteId) {
setError("Selecione um paciente.");
return false;
}
if (!medicoId) {
setError("Selecione um médico.");
return false;
}
if (!dataHora) {
setError("Informe data e hora.");
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setSaving(true);
setError(null);
try {
// Convert local datetime back to ISO
const iso = new Date(dataHora).toISOString();
if (editing) {
const payload: ConsultaUpdate = {
dataHora: iso,
tipo: tipo || undefined,
motivo: motivo || undefined,
observacoes: observacoes || undefined,
status: status,
};
const resp = await consultasService.atualizar(editing.id, payload);
if (!resp.success || !resp.data) {
throw new Error(resp.error || "Falha ao atualizar consulta");
}
onSaved(resp.data);
} else {
const payload: ConsultaCreate = {
pacienteId,
medicoId,
dataHora: iso,
tipo: tipo || undefined,
motivo: motivo || undefined,
observacoes: observacoes || undefined,
};
const resp = await consultasService.criar(payload);
if (!resp.success || !resp.data) {
throw new Error(resp.error || "Falha ao criar consulta");
}
onSaved(resp.data);
}
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : "Erro ao salvar";
setError(msg);
} finally {
setSaving(false);
}
};
const title = editing ? "Editar Consulta" : "Nova Consulta";
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 overflow-y-auto">
<div className="bg-white rounded-lg shadow-xl w-full max-w-xl animate-fade-in mt-10">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h2 className="text-lg font-semibold">{title}</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Paciente
</label>
<select
className="w-full border rounded px-2 py-2 text-sm"
value={pacienteId}
onChange={(e) => setPacienteId(e.target.value)}
disabled={lockPaciente || !!editing}
>
<option value="">Selecione...</option>
{pacientes.map((p) => (
<option key={p.id} value={p.id}>
{p.nome}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Médico
</label>
<select
className="w-full border rounded px-2 py-2 text-sm"
value={medicoId}
onChange={(e) => setMedicoId(e.target.value)}
disabled={lockMedico || !!editing}
>
<option value="">Selecione...</option>
{medicos.map((m) => (
<option key={m.id} value={m.id}>
{m.nome} - {m.especialidade}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Data / Hora
</label>
<input
type="datetime-local"
className="w-full border rounded px-2 py-2 text-sm"
value={dataHora}
onChange={(e) => setDataHora(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo
</label>
<input
list="tipos-consulta"
className="w-full border rounded px-2 py-2 text-sm"
value={tipo}
onChange={(e) => setTipo(e.target.value)}
placeholder="Ex: Retorno"
/>
<datalist id="tipos-consulta">
{TIPO_SUGESTOES.map((t) => (
<option key={t} value={t} />
))}
</datalist>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Motivo
</label>
<input
className="w-full border rounded px-2 py-2 text-sm"
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
placeholder="Motivo principal"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Observações
</label>
<textarea
className="w-full border rounded px-2 py-2 text-sm resize-y min-h-[80px]"
value={observacoes}
onChange={(e) => setObservacoes(e.target.value)}
placeholder="Notas internas, preparação, etc"
/>
</div>
{editing && (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
className="w-full border rounded px-2 py-2 text-sm"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="agendada">Agendada</option>
<option value="confirmada">Confirmada</option>
<option value="cancelada">Cancelada</option>
<option value="realizada">Realizada</option>
<option value="faltou">Faltou</option>
</select>
</div>
)}
</div>
{loadingLists && (
<p className="text-xs text-gray-500 flex items-center">
<Loader2 className="w-4 h-4 mr-1 animate-spin" /> Carregando
listas...
</p>
)}
<div className="flex justify-end gap-2 pt-2 border-t">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-gray-300 text-gray-700 hover:bg-gray-50"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60 flex items-center"
>
{saving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}{" "}
{editing ? "Salvar alterações" : "Criar consulta"}
</button>
</div>
</form>
</div>
</div>
);
};
export default ConsultaModal;

View File

Before

Width:  |  Height:  |  Size: 472 KiB

After

Width:  |  Height:  |  Size: 472 KiB

View File

@ -16,7 +16,6 @@ interface EnderecoPaciente {
export interface PacienteFormData {
id?: string;
user_id?: string;
nome: string;
social_name: string;
cpf: string;
@ -94,12 +93,12 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
{/* Avatar com upload */}
<div className="flex items-start gap-4 mb-6 pb-6 border-b border-gray-200">
<AvatarUpload
userId={data.user_id || data.id}
userId={data.id}
currentAvatarUrl={data.avatar_url}
name={data.nome || "Paciente"}
color="blue"
size="xl"
editable={canEditAvatar && !!(data.user_id || data.id)}
editable={canEditAvatar && !!data.id}
onAvatarUpdate={(avatarUrl) => {
onChange({ avatar_url: avatarUrl || undefined });
}}
@ -132,7 +131,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.nome}
onChange={(e) => onChange({ nome: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
required
placeholder="Digite o nome completo"
autoComplete="name"
@ -150,7 +149,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.social_name}
onChange={(e) => onChange({ social_name: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
placeholder="Opcional"
autoComplete="nickname"
/>
@ -169,7 +168,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.rg || ""}
onChange={(e) => onChange({ rg: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
placeholder="RG"
/>
</div>
@ -184,7 +183,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
id="estado_civil"
value={data.estado_civil || ""}
onChange={(e) => onChange({ estado_civil: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
>
<option value="">Selecione</option>
<option value="solteiro(a)">Solteiro(a)</option>
@ -206,7 +205,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.profissao || ""}
onChange={(e) => onChange({ profissao: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Profissão"
autoComplete="organization-title"
/>
@ -258,7 +257,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
id="sexo"
value={data.sexo}
onChange={(e) => onChange({ sexo: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
required
>
<option value="">Selecione</option>
@ -279,7 +278,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="date"
value={data.dataNascimento}
onChange={(e) => onChange({ dataNascimento: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
required
autoComplete="bday"
/>
@ -358,7 +357,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="email"
value={data.email}
onChange={(e) => onChange({ email: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
required
placeholder="contato@paciente.com"
autoComplete="email"
@ -377,7 +376,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
<select
value={data.tipo_sanguineo}
onChange={(e) => onChange({ tipo_sanguineo: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
>
<option value="">Selecione</option>
{bloodTypes.map((tipo) => (
@ -398,7 +397,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
step="0.1"
value={data.altura}
onChange={(e) => onChange({ altura: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
placeholder="170"
/>
</div>
@ -413,7 +412,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
step="0.1"
value={data.peso}
onChange={(e) => onChange({ peso: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
placeholder="70.5"
/>
</div>
@ -424,7 +423,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
<select
value={data.convenio}
onChange={(e) => onChange({ convenio: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
>
<option value="">Selecione</option>
{convenios.map((c) => (
@ -442,7 +441,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.numeroCarteirinha}
onChange={(e) => onChange({ numeroCarteirinha: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
placeholder="Informe se possuir convênio"
/>
</div>
@ -467,7 +466,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
onChange({ endereco: { ...data.endereco, cep: e.target.value } })
}
onBlur={(e) => onCepLookup(e.target.value)}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="00000-000"
inputMode="numeric"
pattern="^\d{5}-?\d{3}$"
@ -488,7 +487,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
onChange={(e) =>
onChange({ endereco: { ...data.endereco, rua: e.target.value } })
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Rua"
autoComplete="address-line1"
/>
@ -509,7 +508,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
endereco: { ...data.endereco, numero: e.target.value },
})
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Número"
inputMode="numeric"
pattern="^\d+[A-Za-z0-9/-]*$"
@ -531,7 +530,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
endereco: { ...data.endereco, complemento: e.target.value },
})
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Apto, bloco..."
/>
</div>
@ -551,7 +550,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
endereco: { ...data.endereco, bairro: e.target.value },
})
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Bairro"
autoComplete="address-line2"
/>
@ -572,7 +571,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
endereco: { ...data.endereco, cidade: e.target.value },
})
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Cidade"
autoComplete="address-level2"
/>
@ -593,7 +592,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
endereco: { ...data.endereco, estado: e.target.value },
})
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Estado"
autoComplete="address-level1"
/>
@ -606,7 +605,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
<textarea
value={data.observacoes}
onChange={(e) => onChange({ observacoes: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
rows={3}
placeholder="Observações gerais do paciente"
/>
@ -629,7 +628,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.telefoneSecundario || ""}
onChange={(e) => onChange({ telefoneSecundario: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="(DDD) 00000-0000"
inputMode="numeric"
/>
@ -646,7 +645,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.telefoneReferencia || ""}
onChange={(e) => onChange({ telefoneReferencia: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Contato de apoio"
inputMode="numeric"
/>
@ -669,7 +668,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.responsavel_nome || ""}
onChange={(e) => onChange({ responsavel_nome: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Nome completo"
autoComplete="name"
/>
@ -686,7 +685,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.responsavel_cpf || ""}
onChange={(e) => onChange({ responsavel_cpf: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="000.000.000-00"
inputMode="numeric"
pattern="^\d{3}\.\d{3}\.\d{3}-\d{2}$|^\d{11}$"
@ -706,7 +705,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
type="text"
value={data.codigo_legado || ""}
onChange={(e) => onChange({ codigo_legado: e.target.value })}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="ID em outro sistema"
/>
</div>
@ -811,5 +810,3 @@ const DocumentosExtras: React.FC<DocumentosExtrasProps> = ({
</div>
);
};

View File

@ -1,86 +1,84 @@
import { Calendar } from "lucide-react";
import DoctorCalendar from "../agenda/DoctorCalendar";
import AvailabilityManager from "../agenda/AvailabilityManager";
import ExceptionsManager from "../agenda/ExceptionsManager";
interface Medico {
id: string;
nome: string;
}
interface AgendaSectionProps {
medicos: Medico[];
selectedDoctorId: string | null;
onSelectDoctor: (doctorId: string) => void;
}
export default function AgendaSection({
medicos,
selectedDoctorId,
onSelectDoctor,
}: AgendaSectionProps) {
return (
<section className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">
Gerenciar Agenda Médica
</h1>
<p className="text-muted-foreground">
Configure disponibilidades, exceções e visualize o calendário dos
médicos
</p>
</div>
{/* Doctor Selector */}
<div className="bg-card rounded-lg border border-border p-6">
<label className="block text-sm font-medium text-foreground mb-2">
Selecione um médico para gerenciar sua agenda:
</label>
{medicos.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhum médico cadastrado. Adicione médicos na aba "Médicos"
primeiro.
</p>
) : (
<select
value={selectedDoctorId || ""}
onChange={(e) => onSelectDoctor(e.target.value)}
className="w-full md:w-96 h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Selecione um médico</option>
{medicos.map((medico) => (
<option key={medico.id} value={medico.id}>
{medico.nome}
</option>
))}
</select>
)}
</div>
{/* Calendar and Availability Management */}
{selectedDoctorId ? (
<div className="space-y-6">
<DoctorCalendar doctorId={selectedDoctorId} />
<AvailabilityManager doctorId={selectedDoctorId} />
<ExceptionsManager doctorId={selectedDoctorId} />
</div>
) : (
<div className="bg-card rounded-lg border border-border p-12">
<div className="flex flex-col items-center justify-center text-center">
<Calendar className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">
Selecione um médico
</h3>
<p className="text-muted-foreground max-w-md">
Escolha um médico acima para visualizar e gerenciar sua agenda,
disponibilidades e exceções de horários.
</p>
</div>
</div>
)}
</section>
);
}
import { Calendar } from "lucide-react";
import DoctorCalendar from "../agenda/DoctorCalendar";
import AvailabilityManager from "../agenda/AvailabilityManager";
import ExceptionsManager from "../agenda/ExceptionsManager";
interface Medico {
id: string;
nome: string;
}
interface AgendaSectionProps {
medicos: Medico[];
selectedDoctorId: string | null;
onSelectDoctor: (doctorId: string) => void;
}
export default function AgendaSection({
medicos,
selectedDoctorId,
onSelectDoctor,
}: AgendaSectionProps) {
return (
<section className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-foreground">
Gerenciar Agenda Médica
</h1>
<p className="text-muted-foreground">
Configure disponibilidades, exceções e visualize o calendário dos
médicos
</p>
</div>
{/* Doctor Selector */}
<div className="bg-card rounded-lg border border-border p-6">
<label className="block text-sm font-medium text-foreground mb-2">
Selecione um médico para gerenciar sua agenda:
</label>
{medicos.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nenhum médico cadastrado. Adicione médicos na aba "Médicos"
primeiro.
</p>
) : (
<select
value={selectedDoctorId || ""}
onChange={(e) => onSelectDoctor(e.target.value)}
className="w-full md:w-96 h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Selecione um médico</option>
{medicos.map((medico) => (
<option key={medico.id} value={medico.id}>
{medico.nome}
</option>
))}
</select>
)}
</div>
{/* Calendar and Availability Management */}
{selectedDoctorId ? (
<div className="space-y-6">
<DoctorCalendar doctorId={selectedDoctorId} />
<AvailabilityManager doctorId={selectedDoctorId} />
<ExceptionsManager doctorId={selectedDoctorId} />
</div>
) : (
<div className="bg-card rounded-lg border border-border p-12">
<div className="flex flex-col items-center justify-center text-center">
<Calendar className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">
Selecione um médico
</h3>
<p className="text-muted-foreground max-w-md">
Escolha um médico acima para visualizar e gerenciar sua agenda,
disponibilidades e exceções de horários.
</p>
</div>
</div>
)}
</section>
);
}

View File

@ -1,298 +1,296 @@
import { useState } from "react";
import { Plus, RefreshCw, Search, Trash2 } from "lucide-react";
// Tipo estendido para incluir campos adicionais
interface ConsultaExtended {
id: string;
dataHora?: string;
pacienteNome?: string;
medicoNome?: string;
tipo?: string;
status?: string;
}
interface ConsultasSectionProps {
consultas: ConsultaExtended[];
loading: boolean;
onRefresh: () => void;
onNovaConsulta: () => void;
onDeleteConsulta: (id: string) => void;
onAlterarStatus: (id: string, status: string) => void;
}
export default function ConsultasSection({
consultas,
loading,
onRefresh,
onNovaConsulta,
onDeleteConsulta,
onAlterarStatus,
}: ConsultasSectionProps) {
const [searchTerm, setSearchTerm] = useState("");
const [filtroDataDe, setFiltroDataDe] = useState("");
const [filtroDataAte, setFiltroDataAte] = useState("");
const [filtroStatus, setFiltroStatus] = useState("");
const [filtroPaciente, setFiltroPaciente] = useState("");
const [filtroMedico, setFiltroMedico] = useState("");
const formatDateTimeLocal = (dateStr: string | undefined) => {
if (!dateStr) return "-";
try {
const date = new Date(dateStr);
return date.toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
};
// Filtrar consultas
const consultasFiltradas = consultas.filter((c) => {
// Filtro de busca rápida
if (searchTerm) {
const search = searchTerm.toLowerCase();
const matchPaciente = c.pacienteNome?.toLowerCase().includes(search);
const matchMedico = c.medicoNome?.toLowerCase().includes(search);
const matchTipo = c.tipo?.toLowerCase().includes(search);
if (!matchPaciente && !matchMedico && !matchTipo) return false;
}
// Filtro por data de
if (filtroDataDe && c.dataHora) {
const consultaDate = new Date(c.dataHora).toISOString().split("T")[0];
if (consultaDate < filtroDataDe) return false;
}
// Filtro por data até
if (filtroDataAte && c.dataHora) {
const consultaDate = new Date(c.dataHora).toISOString().split("T")[0];
if (consultaDate > filtroDataAte) return false;
}
// Filtro por status
if (filtroStatus && c.status !== filtroStatus) return false;
// Filtro por paciente
if (
filtroPaciente &&
!c.pacienteNome?.toLowerCase().includes(filtroPaciente.toLowerCase())
) {
return false;
}
// Filtro por médico
if (
filtroMedico &&
!c.medicoNome?.toLowerCase().includes(filtroMedico.toLowerCase())
) {
return false;
}
return true;
});
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Consultas</h1>
<p className="text-muted-foreground">
Gerencie todas as consultas agendadas
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="inline-flex items-center gap-2 border border-input hover:bg-accent text-foreground px-4 py-2 rounded-md transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span className="hidden md:inline">Atualizar</span>
</button>
<button
onClick={onNovaConsulta}
className="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-all"
>
<Plus className="w-4 h-4" />
Nova Consulta
</button>
</div>
</div>
{/* Search and Filters */}
<div className="bg-card rounded-lg border border-border p-4 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Busca rápida (paciente, médico ou tipo)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 w-full h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Data de
</label>
<input
type="date"
value={filtroDataDe}
onChange={(e) => setFiltroDataDe(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Data até
</label>
<input
type="date"
value={filtroDataAte}
onChange={(e) => setFiltroDataAte(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Status
</label>
<select
value={filtroStatus}
onChange={(e) => setFiltroStatus(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Todos</option>
<option value="agendada">Agendada</option>
<option value="confirmada">Confirmada</option>
<option value="cancelada">Cancelada</option>
<option value="realizada">Realizada</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Paciente
</label>
<input
value={filtroPaciente}
onChange={(e) => setFiltroPaciente(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Filtrar paciente"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Médico
</label>
<input
value={filtroMedico}
onChange={(e) => setFiltroMedico(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Filtrar médico"
/>
</div>
</div>
</div>
{/* Appointments Table */}
<div className="bg-card rounded-lg border border-border overflow-hidden">
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : consultasFiltradas.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum agendamento encontrado. Use a aba "Agenda" para gerenciar
horários dos médicos.
</p>
</div>
) : (
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Data/Hora
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Paciente
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Médico
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Tipo
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Status
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase w-[140px]">
Ações
</th>
</tr>
</thead>
<tbody>
{consultasFiltradas.map((consulta) => (
<tr
key={consulta.id}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
<td className="p-4 text-sm text-foreground whitespace-nowrap">
{formatDateTimeLocal(consulta.dataHora)}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.pacienteNome}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.medicoNome}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.tipo}
</td>
<td className="p-4">
<select
value={consulta.status}
onChange={(e) =>
consulta.id &&
onAlterarStatus(consulta.id, e.target.value)
}
className="text-sm border border-input rounded-md px-2 py-1 bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="agendada">Agendada</option>
<option value="confirmada">Confirmada</option>
<option value="cancelada">Cancelada</option>
<option value="realizada">Realizada</option>
<option value="faltou">Faltou</option>
</select>
</td>
<td className="p-4">
<button
onClick={() =>
consulta.id && onDeleteConsulta(consulta.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
>
<Trash2 className="h-4 w-4" />
Excluir
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}
import { useState } from "react";
import { Plus, RefreshCw, Search, Trash2 } from "lucide-react";
// Tipo estendido para incluir campos adicionais
interface ConsultaExtended {
id: string;
dataHora?: string;
pacienteNome?: string;
medicoNome?: string;
tipo?: string;
status?: string;
}
interface ConsultasSectionProps {
consultas: ConsultaExtended[];
loading: boolean;
onRefresh: () => void;
onNovaConsulta: () => void;
onDeleteConsulta: (id: string) => void;
onAlterarStatus: (id: string, status: string) => void;
}
export default function ConsultasSection({
consultas,
loading,
onRefresh,
onNovaConsulta,
onDeleteConsulta,
onAlterarStatus,
}: ConsultasSectionProps) {
const [searchTerm, setSearchTerm] = useState("");
const [filtroDataDe, setFiltroDataDe] = useState("");
const [filtroDataAte, setFiltroDataAte] = useState("");
const [filtroStatus, setFiltroStatus] = useState("");
const [filtroPaciente, setFiltroPaciente] = useState("");
const [filtroMedico, setFiltroMedico] = useState("");
const formatDateTimeLocal = (dateStr: string | undefined) => {
if (!dateStr) return "-";
try {
const date = new Date(dateStr);
return date.toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
};
// Filtrar consultas
const consultasFiltradas = consultas.filter((c) => {
// Filtro de busca rápida
if (searchTerm) {
const search = searchTerm.toLowerCase();
const matchPaciente = c.pacienteNome?.toLowerCase().includes(search);
const matchMedico = c.medicoNome?.toLowerCase().includes(search);
const matchTipo = c.tipo?.toLowerCase().includes(search);
if (!matchPaciente && !matchMedico && !matchTipo) return false;
}
// Filtro por data de
if (filtroDataDe && c.dataHora) {
const consultaDate = new Date(c.dataHora).toISOString().split("T")[0];
if (consultaDate < filtroDataDe) return false;
}
// Filtro por data até
if (filtroDataAte && c.dataHora) {
const consultaDate = new Date(c.dataHora).toISOString().split("T")[0];
if (consultaDate > filtroDataAte) return false;
}
// Filtro por status
if (filtroStatus && c.status !== filtroStatus) return false;
// Filtro por paciente
if (
filtroPaciente &&
!c.pacienteNome?.toLowerCase().includes(filtroPaciente.toLowerCase())
) {
return false;
}
// Filtro por médico
if (
filtroMedico &&
!c.medicoNome?.toLowerCase().includes(filtroMedico.toLowerCase())
) {
return false;
}
return true;
});
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Consultas</h1>
<p className="text-muted-foreground">
Gerencie todas as consultas agendadas
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="inline-flex items-center gap-2 border border-input hover:bg-accent text-foreground px-4 py-2 rounded-md transition-colors"
>
<RefreshCw className="w-4 h-4" />
<span className="hidden md:inline">Atualizar</span>
</button>
<button
onClick={onNovaConsulta}
className="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-all"
>
<Plus className="w-4 h-4" />
Nova Consulta
</button>
</div>
</div>
{/* Search and Filters */}
<div className="bg-card rounded-lg border border-border p-4 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Busca rápida (paciente, médico ou tipo)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 w-full h-10 px-3 rounded-md border border-input bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Data de
</label>
<input
type="date"
value={filtroDataDe}
onChange={(e) => setFiltroDataDe(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Data até
</label>
<input
type="date"
value={filtroDataAte}
onChange={(e) => setFiltroDataAte(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Status
</label>
<select
value={filtroStatus}
onChange={(e) => setFiltroStatus(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Todos</option>
<option value="agendada">Agendada</option>
<option value="confirmada">Confirmada</option>
<option value="cancelada">Cancelada</option>
<option value="realizada">Realizada</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Paciente
</label>
<input
value={filtroPaciente}
onChange={(e) => setFiltroPaciente(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Filtrar paciente"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Médico
</label>
<input
value={filtroMedico}
onChange={(e) => setFiltroMedico(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Filtrar médico"
/>
</div>
</div>
</div>
{/* Appointments Table */}
<div className="bg-card rounded-lg border border-border overflow-hidden">
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : consultasFiltradas.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum agendamento encontrado. Use a aba "Agenda" para gerenciar
horários dos médicos.
</p>
</div>
) : (
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Data/Hora
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Paciente
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Médico
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Tipo
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Status
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase w-[140px]">
Ações
</th>
</tr>
</thead>
<tbody>
{consultasFiltradas.map((consulta) => (
<tr
key={consulta.id}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
<td className="p-4 text-sm text-foreground whitespace-nowrap">
{formatDateTimeLocal(consulta.dataHora)}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.pacienteNome}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.medicoNome}
</td>
<td className="p-4 text-sm text-foreground">
{consulta.tipo}
</td>
<td className="p-4">
<select
value={consulta.status}
onChange={(e) =>
consulta.id &&
onAlterarStatus(consulta.id, e.target.value)
}
className="text-sm border border-input rounded-md px-2 py-1 bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="agendada">Agendada</option>
<option value="confirmada">Confirmada</option>
<option value="cancelada">Cancelada</option>
<option value="realizada">Realizada</option>
<option value="faltou">Faltou</option>
</select>
</td>
<td className="p-4">
<button
onClick={() =>
consulta.id && onDeleteConsulta(consulta.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
>
<Trash2 className="h-4 w-4" />
Excluir
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}

View File

@ -1,183 +1,181 @@
import { Plus, Eye, Edit2 } from "lucide-react";
interface Relatorio {
id?: string;
order_number?: string;
exam?: string;
patient_id?: string;
status?: string;
created_at?: string;
}
interface Paciente {
id: string;
nome: string;
}
interface RelatoriosSectionProps {
relatorios: Relatorio[];
pacientes: Paciente[];
loading: boolean;
onNovoRelatorio: () => void;
onVerDetalhes: (id: string) => void;
onEditarRelatorio: (id: string) => void;
}
export default function RelatoriosSection({
relatorios,
pacientes,
loading,
onNovoRelatorio,
onVerDetalhes,
onEditarRelatorio,
}: RelatoriosSectionProps) {
const getStatusBadgeClass = (status?: string) => {
switch (status) {
case "draft":
return "bg-muted text-foreground";
case "completed":
return "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400";
case "pending":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400";
default:
return "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400";
}
};
const getStatusLabel = (status?: string) => {
switch (status) {
case "draft":
return "Rascunho";
case "completed":
return "Concluído";
case "pending":
return "Pendente";
default:
return "Cancelado";
}
};
const getPacienteNome = (patientId?: string) => {
const paciente = pacientes.find((p) => p.id === patientId);
return paciente?.nome || patientId || "-";
};
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Relatórios</h1>
<p className="text-muted-foreground">
Gerencie relatórios de exames e diagnósticos
</p>
</div>
<button
onClick={onNovoRelatorio}
className="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-all"
>
<Plus className="w-4 h-4" />
Novo Relatório
</button>
</div>
{/* Reports Table */}
<div className="bg-card rounded-lg border border-border overflow-hidden">
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : relatorios.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum relatório encontrado.
</p>
</div>
) : (
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Número
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Exame
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Paciente
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Status
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Data
</th>
<th className="text-right p-4 text-sm font-medium text-muted-foreground uppercase w-[200px]">
Ações
</th>
</tr>
</thead>
<tbody>
{relatorios.map((relatorio) => (
<tr
key={relatorio.id}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
<td className="p-4 text-sm font-medium text-foreground">
{relatorio.order_number || "-"}
</td>
<td className="p-4 text-sm text-foreground">
{relatorio.exam || "-"}
</td>
<td className="p-4 text-sm text-foreground">
{getPacienteNome(relatorio.patient_id)}
</td>
<td className="p-4">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeClass(
relatorio.status
)}`}
>
{getStatusLabel(relatorio.status)}
</span>
</td>
<td className="p-4 text-sm text-muted-foreground">
{relatorio.created_at
? new Date(relatorio.created_at).toLocaleDateString(
"pt-BR"
)
: "-"}
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() =>
relatorio.id && onVerDetalhes(relatorio.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<Eye className="h-4 w-4" />
Ver
</button>
<button
onClick={() =>
relatorio.id && onEditarRelatorio(relatorio.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-accent/10 text-foreground hover:bg-accent/20 transition-colors"
>
<Edit2 className="h-4 w-4" />
Editar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}
import { Plus, Eye, Edit2 } from "lucide-react";
interface Relatorio {
id?: string;
order_number?: string;
exam?: string;
patient_id?: string;
status?: string;
created_at?: string;
}
interface Paciente {
id: string;
nome: string;
}
interface RelatoriosSectionProps {
relatorios: Relatorio[];
pacientes: Paciente[];
loading: boolean;
onNovoRelatorio: () => void;
onVerDetalhes: (id: string) => void;
onEditarRelatorio: (id: string) => void;
}
export default function RelatoriosSection({
relatorios,
pacientes,
loading,
onNovoRelatorio,
onVerDetalhes,
onEditarRelatorio,
}: RelatoriosSectionProps) {
const getStatusBadgeClass = (status?: string) => {
switch (status) {
case "draft":
return "bg-muted text-foreground";
case "completed":
return "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400";
case "pending":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400";
default:
return "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400";
}
};
const getStatusLabel = (status?: string) => {
switch (status) {
case "draft":
return "Rascunho";
case "completed":
return "Concluído";
case "pending":
return "Pendente";
default:
return "Cancelado";
}
};
const getPacienteNome = (patientId?: string) => {
const paciente = pacientes.find((p) => p.id === patientId);
return paciente?.nome || patientId || "-";
};
return (
<section className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Relatórios</h1>
<p className="text-muted-foreground">
Gerencie relatórios de exames e diagnósticos
</p>
</div>
<button
onClick={onNovoRelatorio}
className="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md transition-all"
>
<Plus className="w-4 h-4" />
Novo Relatório
</button>
</div>
{/* Reports Table */}
<div className="bg-card rounded-lg border border-border overflow-hidden">
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : relatorios.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
Nenhum relatório encontrado.
</p>
</div>
) : (
<table className="w-full">
<thead className="bg-muted/50 border-b border-border">
<tr>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Número
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Exame
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Paciente
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Status
</th>
<th className="text-left p-4 text-sm font-medium text-muted-foreground uppercase">
Data
</th>
<th className="text-right p-4 text-sm font-medium text-muted-foreground uppercase w-[200px]">
Ações
</th>
</tr>
</thead>
<tbody>
{relatorios.map((relatorio) => (
<tr
key={relatorio.id}
className="border-b border-border hover:bg-muted/30 transition-colors"
>
<td className="p-4 text-sm font-medium text-foreground">
{relatorio.order_number || "-"}
</td>
<td className="p-4 text-sm text-foreground">
{relatorio.exam || "-"}
</td>
<td className="p-4 text-sm text-foreground">
{getPacienteNome(relatorio.patient_id)}
</td>
<td className="p-4">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeClass(
relatorio.status
)}`}
>
{getStatusLabel(relatorio.status)}
</span>
</td>
<td className="p-4 text-sm text-muted-foreground">
{relatorio.created_at
? new Date(relatorio.created_at).toLocaleDateString(
"pt-BR"
)
: "-"}
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() =>
relatorio.id && onVerDetalhes(relatorio.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<Eye className="h-4 w-4" />
Ver
</button>
<button
onClick={() =>
relatorio.id && onEditarRelatorio(relatorio.id)
}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-md text-sm bg-accent/10 text-foreground hover:bg-accent/20 transition-colors"
>
<Edit2 className="h-4 w-4" />
Editar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,351 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, Plus, Eye, Edit, Trash2 } from "lucide-react";
import {
appointmentService,
type Appointment,
patientService,
type Patient,
doctorService,
type Doctor,
} from "../../services";
import { Avatar } from "../ui/Avatar";
interface AppointmentWithDetails extends Appointment {
patient?: Patient;
doctor?: Doctor;
}
export function SecretaryAppointmentList() {
const [appointments, setAppointments] = useState<AppointmentWithDetails[]>(
[]
);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("Todos");
const [typeFilter, setTypeFilter] = useState("Todos");
const loadAppointments = async () => {
setLoading(true);
try {
const data = await appointmentService.list();
// Buscar detalhes de pacientes e médicos
const appointmentsWithDetails = await Promise.all(
(Array.isArray(data) ? data : []).map(async (appointment) => {
try {
const [patient, doctor] = await Promise.all([
appointment.patient_id
? patientService.getById(appointment.patient_id)
: null,
appointment.doctor_id
? doctorService.getById(appointment.doctor_id)
: null,
]);
return {
...appointment,
patient: patient || undefined,
doctor: doctor || undefined,
};
} catch (error) {
console.error("Erro ao carregar detalhes:", error);
return appointment;
}
})
);
setAppointments(appointmentsWithDetails);
console.log("✅ Consultas carregadas:", appointmentsWithDetails);
} catch (error) {
console.error("❌ Erro ao carregar consultas:", error);
toast.error("Erro ao carregar consultas");
setAppointments([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAppointments();
}, []);
const handleSearch = () => {
loadAppointments();
};
const handleClear = () => {
setSearchTerm("");
setStatusFilter("Todos");
setTypeFilter("Todos");
loadAppointments();
};
const getStatusBadge = (status: string) => {
const statusMap: Record<string, { label: string; className: string }> = {
confirmada: {
label: "Confirmada",
className: "bg-green-100 text-green-700",
},
agendada: { label: "Agendada", className: "bg-blue-100 text-blue-700" },
cancelada: { label: "Cancelada", className: "bg-red-100 text-red-700" },
concluida: { label: "Concluída", className: "bg-gray-100 text-gray-700" },
};
const config = statusMap[status] || {
label: status,
className: "bg-gray-100 text-gray-700",
};
return (
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${config.className}`}
>
{config.label}
</span>
);
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return "—";
}
};
const formatTime = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "—";
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Consultas</h1>
<p className="text-gray-600 mt-1">Gerencie as consultas agendadas</p>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
<Plus className="h-4 w-4" />
Nova Consulta
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar consultas por paciente ou médico..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Status:</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Confirmada</option>
<option>Agendada</option>
<option>Cancelada</option>
<option>Concluída</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Tipo:</span>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Presencial</option>
<option>Telemedicina</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Paciente
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Médico
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Data/Hora
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Tipo
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={6}
className="px-6 py-12 text-center text-gray-500"
>
Carregando consultas...
</td>
</tr>
) : appointments.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-6 py-12 text-center text-gray-500"
>
Nenhuma consulta encontrada
</td>
</tr>
) : (
appointments.map((appointment) => (
<tr
key={appointment.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<Avatar
src={appointment.patient}
name={appointment.patient?.full_name || ""}
size="md"
color="blue"
/>
<div>
<p className="text-sm font-medium text-gray-900">
{appointment.patient?.full_name ||
"Paciente não encontrado"}
</p>
<p className="text-xs text-gray-500">
{appointment.patient?.email || "—"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<Avatar
src={appointment.doctor}
name={appointment.doctor?.full_name || ""}
size="md"
color="green"
/>
<div>
<p className="text-sm font-medium text-gray-900">
{appointment.doctor?.full_name ||
"Médico não encontrado"}
</p>
<p className="text-xs text-gray-500">
{appointment.doctor?.specialty || "—"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900">
{appointment.scheduled_at ? (
<>
<div className="font-medium">
{formatDate(appointment.scheduled_at)}
</div>
<div className="text-gray-500 text-xs">
{formatTime(appointment.scheduled_at)}
</div>
</>
) : (
"—"
)}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
appointment.appointment_type === "telemedicina"
? "bg-purple-100 text-purple-700"
: "bg-blue-100 text-blue-700"
}`}
>
{appointment.appointment_type === "telemedicina"
? "Telemedicina"
: "Presencial"}
</span>
</td>
<td className="px-6 py-4">
{getStatusBadge(appointment.status || "agendada")}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Cancelar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,526 @@
import { useState, useEffect, useCallback } from "react";
import toast from "react-hot-toast";
import {
ChevronLeft,
ChevronRight,
Plus,
Edit,
Trash2,
Calendar as CalendarIcon,
} from "lucide-react";
import {
doctorService,
appointmentService,
availabilityService,
type Doctor,
type Appointment,
type DoctorAvailability,
} from "../../services";
interface DayCell {
date: Date;
isCurrentMonth: boolean;
appointments: Appointment[];
}
export function SecretaryDoctorSchedule() {
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [selectedDoctorId, setSelectedDoctorId] = useState<string>("");
const [currentDate, setCurrentDate] = useState(new Date());
const [calendarDays, setCalendarDays] = useState<DayCell[]>([]);
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>(
[]
);
const [loading, setLoading] = useState(false);
// Modal states
const [showAvailabilityDialog, setShowAvailabilityDialog] = useState(false);
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
// Availability form
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
const [startTime, setStartTime] = useState("08:00");
const [endTime, setEndTime] = useState("18:00");
const [duration, setDuration] = useState(30);
// Exception form
const [exceptionType, setExceptionType] = useState("férias");
const [exceptionStartDate, setExceptionStartDate] = useState("");
const [exceptionEndDate, setExceptionEndDate] = useState("");
const [exceptionReason, setExceptionReason] = useState("");
useEffect(() => {
loadDoctors();
}, []);
const loadDoctorSchedule = useCallback(async () => {
if (!selectedDoctorId) return;
setLoading(true);
try {
// Load availabilities
const availData = await availabilityService.list({
doctor_id: selectedDoctorId,
});
setAvailabilities(Array.isArray(availData) ? availData : []);
// Load appointments for the month (will be used for calendar display)
await appointmentService.list();
} catch (error) {
console.error("Erro ao carregar agenda:", error);
toast.error("Erro ao carregar agenda do médico");
} finally {
setLoading(false);
}
}, [selectedDoctorId]);
useEffect(() => {
loadDoctorSchedule();
}, [loadDoctorSchedule]);
const generateCalendar = useCallback(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
const days: DayCell[] = [];
const currentDatePointer = new Date(startDate);
for (let i = 0; i < 42; i++) {
days.push({
date: new Date(currentDatePointer),
isCurrentMonth: currentDatePointer.getMonth() === month,
appointments: [],
});
currentDatePointer.setDate(currentDatePointer.getDate() + 1);
}
setCalendarDays(days);
}, [currentDate]);
useEffect(() => {
generateCalendar();
}, [generateCalendar]);
const loadDoctors = async () => {
try {
const data = await doctorService.list();
setDoctors(Array.isArray(data) ? data : []);
if (data.length > 0) {
setSelectedDoctorId(data[0].id);
}
} catch (error) {
console.error("Erro ao carregar médicos:", error);
toast.error("Erro ao carregar médicos");
}
};
const previousMonth = () => {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() - 1)
);
};
const nextMonth = () => {
setCurrentDate(
new Date(currentDate.getFullYear(), currentDate.getMonth() + 1)
);
};
const goToToday = () => {
setCurrentDate(new Date());
};
const formatMonthYear = (date: Date) => {
return date.toLocaleDateString("pt-BR", { month: "long", year: "numeric" });
};
const handleAddAvailability = async () => {
if (selectedWeekdays.length === 0) {
toast.error("Selecione pelo menos um dia da semana");
return;
}
try {
// TODO: Implement availability creation
toast.success("Disponibilidade adicionada com sucesso");
setShowAvailabilityDialog(false);
loadDoctorSchedule();
} catch (error) {
console.error("Erro ao adicionar disponibilidade:", error);
toast.error("Erro ao adicionar disponibilidade");
}
};
const handleAddException = async () => {
if (!exceptionStartDate || !exceptionEndDate) {
toast.error("Preencha as datas de início e fim");
return;
}
try {
// TODO: Implement exception creation
toast.success("Exceção adicionada com sucesso");
setShowExceptionDialog(false);
loadDoctorSchedule();
} catch (error) {
console.error("Erro ao adicionar exceção:", error);
toast.error("Erro ao adicionar exceção");
}
};
const weekdays = [
{ value: "monday", label: "Segunda" },
{ value: "tuesday", label: "Terça" },
{ value: "wednesday", label: "Quarta" },
{ value: "thursday", label: "Quinta" },
{ value: "friday", label: "Sexta" },
{ value: "saturday", label: "Sábado" },
{ value: "sunday", label: "Domingo" },
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Agenda Médica</h1>
<p className="text-gray-600 mt-1">
Gerencie disponibilidades e exceções
</p>
</div>
</div>
{/* Doctor Selector */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Selecione o Médico
</label>
<select
value={selectedDoctorId}
onChange={(e) => setSelectedDoctorId(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{doctors.map((doctor) => (
<option key={doctor.id} value={doctor.id}>
Dr. {doctor.full_name} - {doctor.specialty}
</option>
))}
</select>
</div>
{/* Calendar */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-gray-900 capitalize">
{formatMonthYear(currentDate)}
</h2>
<div className="flex items-center gap-2">
<button
onClick={goToToday}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Hoje
</button>
<button
onClick={previousMonth}
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={nextMonth}
className="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-px bg-gray-200 border border-gray-200 rounded-lg overflow-hidden">
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
<div
key={day}
className="bg-gray-50 px-2 py-3 text-center text-sm font-semibold text-gray-700"
>
{day}
</div>
))}
{calendarDays.map((day, index) => (
<div
key={index}
className={`bg-white p-2 min-h-[80px] ${
day.isCurrentMonth ? "" : "opacity-40"
} ${
day.date.toDateString() === new Date().toDateString()
? "bg-blue-50"
: ""
}`}
>
<div className="text-sm text-gray-700 mb-1">
{day.date.getDate()}
</div>
{day.appointments.map((apt, i) => (
<div
key={i}
className="text-xs bg-green-100 text-green-800 p-1 rounded mb-1 truncate"
>
{apt.patient_id}
</div>
))}
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-4">
<button
onClick={() => setShowAvailabilityDialog(true)}
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-5 w-5" />
Adicionar Disponibilidade
</button>
<button
onClick={() => setShowExceptionDialog(true)}
className="flex-1 flex items-center justify-center gap-2 px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
>
<CalendarIcon className="h-5 w-5" />
Adicionar Exceção (Férias/Bloqueio)
</button>
</div>
{/* Current Availability */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Disponibilidade Atual
</h3>
{loading ? (
<p className="text-gray-500">Carregando...</p>
) : availabilities.length === 0 ? (
<p className="text-gray-500">Nenhuma disponibilidade configurada</p>
) : (
<div className="space-y-3">
{availabilities.map((avail) => (
<div
key={avail.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">
{avail.day_of_week}
</p>
<p className="text-sm text-gray-600">
{avail.start_time} - {avail.end_time}
</p>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700">
Ativo
</span>
<button
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Deletar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Availability Dialog */}
{showAvailabilityDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
Adicionar Disponibilidade
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dias da Semana
</label>
<div className="space-y-2">
{weekdays.map((day) => (
<label
key={day.value}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
checked={selectedWeekdays.includes(day.value)}
onChange={(e) => {
if (e.target.checked) {
setSelectedWeekdays([
...selectedWeekdays,
day.value,
]);
} else {
setSelectedWeekdays(
selectedWeekdays.filter((d) => d !== day.value)
);
}
}}
className="h-4 w-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
/>
<span className="text-sm text-gray-700">{day.label}</span>
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora Início
</label>
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora Fim
</label>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Duração da Consulta (minutos)
</label>
<input
type="number"
value={duration}
onChange={(e) => setDuration(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowAvailabilityDialog(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={handleAddAvailability}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Adicionar
</button>
</div>
</div>
</div>
)}
{/* Exception Dialog */}
{showExceptionDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
Adicionar Exceção
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Exceção
</label>
<select
value={exceptionType}
onChange={(e) => setExceptionType(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
>
<option value="férias">Férias</option>
<option value="licença">Licença Médica</option>
<option value="congresso">Congresso</option>
<option value="outro">Outro</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data Início
</label>
<input
type="date"
value={exceptionStartDate}
onChange={(e) => setExceptionStartDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data Fim
</label>
<input
type="date"
value={exceptionEndDate}
onChange={(e) => setExceptionEndDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motivo (Opcional)
</label>
<input
type="text"
value={exceptionReason}
onChange={(e) => setExceptionReason(e.target.value)}
placeholder="Ex: Férias anuais, Conferência médica..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowExceptionDialog(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={handleAddException}
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
>
Adicionar
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,513 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, Plus, Eye, Calendar, Edit, Trash2, X } from "lucide-react";
import { patientService, userService, type Patient } from "../../services";
import PacienteForm, { type PacienteFormData } from "../pacientes/PacienteForm";
import { Avatar } from "../ui/Avatar";
const BLOOD_TYPES = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
const CONVENIOS = [
"Particular",
"Unimed",
"Amil",
"Bradesco Saúde",
"SulAmérica",
"Golden Cross",
];
const COUNTRY_OPTIONS = [
{ value: "55", label: "+55 🇧🇷 Brasil" },
{ value: "1", label: "+1 🇺🇸 EUA/Canadá" },
];
// Função para buscar endereço via CEP
const buscarEnderecoViaCEP = async (cep: string) => {
try {
const response = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await response.json();
if (data.erro) return null;
return {
rua: data.logradouro,
bairro: data.bairro,
cidade: data.localidade,
estado: data.uf,
cep: data.cep,
};
} catch {
return null;
}
};
export function SecretaryPatientList() {
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [insuranceFilter, setInsuranceFilter] = useState("Todos");
const [showBirthdays, setShowBirthdays] = useState(false);
const [showVIP, setShowVIP] = useState(false);
// Modal states
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [formData, setFormData] = useState<PacienteFormData>({
nome: "",
social_name: "",
cpf: "",
sexo: "",
dataNascimento: "",
email: "",
codigoPais: "55",
ddd: "",
numeroTelefone: "",
tipo_sanguineo: "",
altura: "",
peso: "",
convenio: "Particular",
numeroCarteirinha: "",
observacoes: "",
endereco: {
cep: "",
rua: "",
numero: "",
bairro: "",
cidade: "",
estado: "",
},
});
const [cpfError, setCpfError] = useState<string | null>(null);
const [cpfValidationMessage, setCpfValidationMessage] = useState<
string | null
>(null);
const loadPatients = async () => {
setLoading(true);
try {
const data = await patientService.list();
console.log("✅ Pacientes carregados:", data);
setPatients(Array.isArray(data) ? data : []);
if (Array.isArray(data) && data.length === 0) {
console.warn("⚠️ Nenhum paciente encontrado na API");
}
} catch (error) {
console.error("❌ Erro ao carregar pacientes:", error);
toast.error("Erro ao carregar pacientes");
setPatients([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPatients();
}, []);
const handleSearch = () => {
loadPatients();
};
const handleClear = () => {
setSearchTerm("");
setInsuranceFilter("Todos");
setShowBirthdays(false);
setShowVIP(false);
loadPatients();
};
const handleNewPatient = () => {
setModalMode("create");
setFormData({
nome: "",
social_name: "",
cpf: "",
sexo: "",
dataNascimento: "",
email: "",
codigoPais: "55",
ddd: "",
numeroTelefone: "",
tipo_sanguineo: "",
altura: "",
peso: "",
convenio: "Particular",
numeroCarteirinha: "",
observacoes: "",
endereco: {
cep: "",
rua: "",
numero: "",
bairro: "",
cidade: "",
estado: "",
},
});
setCpfError(null);
setCpfValidationMessage(null);
setShowModal(true);
};
const handleEditPatient = (patient: Patient) => {
setModalMode("edit");
setFormData({
id: patient.id,
nome: patient.full_name || "",
social_name: patient.social_name || "",
cpf: patient.cpf || "",
sexo: patient.sex || "",
dataNascimento: patient.birth_date || "",
email: patient.email || "",
codigoPais: "55",
ddd: "",
numeroTelefone: patient.phone_mobile || "",
tipo_sanguineo: patient.blood_type || "",
altura: patient.height_m?.toString() || "",
peso: patient.weight_kg?.toString() || "",
convenio: "Particular",
numeroCarteirinha: "",
observacoes: "",
endereco: {
cep: patient.cep || "",
rua: patient.street || "",
numero: patient.number || "",
complemento: patient.complement || "",
bairro: patient.neighborhood || "",
cidade: patient.city || "",
estado: patient.state || "",
},
});
setCpfError(null);
setCpfValidationMessage(null);
setShowModal(true);
};
const handleFormChange = (patch: Partial<PacienteFormData>) => {
setFormData((prev) => ({ ...prev, ...patch }));
};
const handleCpfChange = (value: string) => {
setFormData((prev) => ({ ...prev, cpf: value }));
setCpfError(null);
setCpfValidationMessage(null);
};
const handleCepLookup = async (cep: string) => {
const endereco = await buscarEnderecoViaCEP(cep);
if (endereco) {
setFormData((prev) => ({
...prev,
endereco: {
...prev.endereco,
...endereco,
},
}));
toast.success("Endereço encontrado!");
} else {
toast.error("CEP não encontrado");
}
};
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
if (modalMode === "edit" && formData.id) {
// Para edição, usa o endpoint antigo (PATCH /patients/:id)
const patientData = {
full_name: formData.nome,
social_name: formData.social_name || null,
cpf: formData.cpf,
sex: formData.sexo || null,
birth_date: formData.dataNascimento || null,
email: formData.email,
phone_mobile: formData.numeroTelefone,
blood_type: formData.tipo_sanguineo || null,
height_m: formData.altura ? parseFloat(formData.altura) : null,
weight_kg: formData.peso ? parseFloat(formData.peso) : null,
cep: formData.endereco.cep || null,
street: formData.endereco.rua || null,
number: formData.endereco.numero || null,
complement: formData.endereco.complemento || null,
neighborhood: formData.endereco.bairro || null,
city: formData.endereco.cidade || null,
state: formData.endereco.estado || null,
};
await patientService.update(formData.id, patientData);
toast.success("Paciente atualizado com sucesso!");
} else {
// Para criação, usa o novo endpoint create-patient com validações completas
const createData = {
email: formData.email,
full_name: formData.nome,
cpf: formData.cpf,
phone_mobile: formData.numeroTelefone,
birth_date: formData.dataNascimento || undefined,
address: formData.endereco.rua
? `${formData.endereco.rua}${
formData.endereco.numero ? ", " + formData.endereco.numero : ""
}${
formData.endereco.bairro ? " - " + formData.endereco.bairro : ""
}${
formData.endereco.cidade ? " - " + formData.endereco.cidade : ""
}${
formData.endereco.estado ? "/" + formData.endereco.estado : ""
}`
: undefined,
};
await userService.createPatient(createData);
toast.success("Paciente cadastrado com sucesso!");
}
setShowModal(false);
loadPatients();
} catch (error) {
console.error("Erro ao salvar paciente:", error);
toast.error("Erro ao salvar paciente");
} finally {
setLoading(false);
}
};
const handleCancelForm = () => {
setShowModal(false);
};
const getPatientColor = (
index: number
): "blue" | "green" | "purple" | "orange" | "pink" | "teal" => {
const colors: Array<
"blue" | "green" | "purple" | "orange" | "pink" | "teal"
> = ["blue", "green", "purple", "orange", "pink", "teal"];
return colors[index % colors.length];
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Pacientes</h1>
<p className="text-gray-600 mt-1">
Gerencie os pacientes cadastrados
</p>
</div>
<button
onClick={handleNewPatient}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
Novo Paciente
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar pacientes por nome ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showBirthdays}
onChange={(e) => setShowBirthdays(e.target.checked)}
className="h-4 w-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
/>
<span className="text-sm text-gray-700">
Aniversariantes do mês
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showVIP}
onChange={(e) => setShowVIP(e.target.checked)}
className="h-4 w-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
/>
<span className="text-sm text-gray-700">Somente VIP</span>
</label>
<div className="flex items-center gap-2 ml-auto">
<span className="text-sm text-gray-600">Convênio:</span>
<select
value={insuranceFilter}
onChange={(e) => setInsuranceFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Particular</option>
<option>Unimed</option>
<option>Amil</option>
<option>Bradesco Saúde</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Paciente
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Próximo Atendimento
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Convênio
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={4}
className="px-6 py-12 text-center text-gray-500"
>
Carregando pacientes...
</td>
</tr>
) : patients.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-6 py-12 text-center text-gray-500"
>
Nenhum paciente encontrado
</td>
</tr>
) : (
patients.map((patient, index) => (
<tr
key={patient.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<Avatar
src={patient}
name={patient.full_name || ""}
size="md"
color={getPatientColor(index)}
/>
<div>
<p className="text-sm font-medium text-gray-900">
{patient.full_name}
</p>
<p className="text-sm text-gray-500">{patient.email}</p>
<p className="text-sm text-gray-500">
{patient.phone_mobile}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{/* TODO: Buscar próximo agendamento */}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
Particular
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<button
title="Visualizar"
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Eye className="h-4 w-4" />
</button>
<button
title="Agendar consulta"
className="p-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
>
<Calendar className="h-4 w-4" />
</button>
<button
onClick={() => handleEditPatient(patient)}
title="Editar"
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
>
<Edit className="h-4 w-4" />
</button>
<button
title="Deletar"
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Modal de Formulário */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
{modalMode === "create" ? "Novo Paciente" : "Editar Paciente"}
</h2>
<button
onClick={handleCancelForm}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Form Content */}
<div className="flex-1 overflow-y-auto p-6">
<PacienteForm
mode={modalMode}
loading={loading}
data={formData}
bloodTypes={BLOOD_TYPES}
convenios={CONVENIOS}
countryOptions={COUNTRY_OPTIONS}
cpfError={cpfError}
cpfValidationMessage={cpfValidationMessage}
onChange={handleFormChange}
onCpfChange={handleCpfChange}
onCepLookup={handleCepLookup}
onCancel={handleCancelForm}
onSubmit={handleFormSubmit}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,247 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { Search, FileText, Download } from "lucide-react";
import { reportService, type Report } from "../../services";
export function SecretaryReportList() {
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState("Todos");
const [periodFilter, setPeriodFilter] = useState("Todos");
useEffect(() => {
loadReports();
}, []);
const loadReports = async () => {
setLoading(true);
try {
const data = await reportService.list();
console.log("✅ Relatórios carregados:", data);
setReports(Array.isArray(data) ? data : []);
if (Array.isArray(data) && data.length === 0) {
console.warn("⚠️ Nenhum relatório encontrado na API");
}
} catch (error) {
console.error("❌ Erro ao carregar relatórios:", error);
toast.error("Erro ao carregar relatórios");
setReports([]);
} finally {
setLoading(false);
}
};
const handleSearch = () => {
loadReports();
};
const handleClear = () => {
setSearchTerm("");
setTypeFilter("Todos");
setPeriodFilter("Todos");
loadReports();
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "—";
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Relatórios</h1>
<p className="text-gray-600 mt-1">
Visualize e baixe relatórios do sistema
</p>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
<FileText className="h-4 w-4" />
Gerar Relatório
</button>
</div>
{/* Search and Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Buscar relatórios..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<button
onClick={handleSearch}
className="px-6 py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Buscar
</button>
<button
onClick={handleClear}
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Limpar
</button>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Tipo:</span>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Financeiro</option>
<option>Atendimentos</option>
<option>Pacientes</option>
<option>Médicos</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Período:</span>
<select
value={periodFilter}
onChange={(e) => setPeriodFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option>Todos</option>
<option>Hoje</option>
<option>Esta Semana</option>
<option>Este Mês</option>
<option>Este Ano</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Relatório
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Criado Em
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Solicitante
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
Ações
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
Carregando relatórios...
</td>
</tr>
) : reports.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-6 py-12 text-center text-gray-500"
>
Nenhum relatório encontrado
</td>
</tr>
) : (
reports.map((report) => (
<tr
key={report.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{report.order_number}
</p>
<p className="text-xs text-gray-500">
{report.exam || "Sem exame"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
report.status === "completed"
? "bg-green-100 text-green-800"
: report.status === "pending"
? "bg-yellow-100 text-yellow-800"
: report.status === "draft"
? "bg-gray-100 text-gray-800"
: "bg-red-100 text-red-800"
}`}
>
{report.status === "completed"
? "Concluído"
: report.status === "pending"
? "Pendente"
: report.status === "draft"
? "Rascunho"
: "Cancelado"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{formatDate(report.created_at)}
</td>
<td className="px-6 py-4 text-sm text-gray-700">
{report.requested_by || "—"}
</td>
<td className="px-6 py-4">
<button
title="Baixar"
disabled={report.status !== "completed"}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors ${
report.status === "completed"
? "text-green-600 hover:bg-green-50"
: "text-gray-400 cursor-not-allowed"
}`}
>
<Download className="h-4 w-4" />
<span className="text-sm font-medium">Baixar</span>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
export { SecretaryPatientList } from "./SecretaryPatientList";
export { SecretaryDoctorList } from "./SecretaryDoctorList";
export { SecretaryAppointmentList } from "./SecretaryAppointmentList";
export { SecretaryDoctorSchedule } from "./SecretaryDoctorSchedule";
export { SecretaryReportList } from "./SecretaryReportList";
export { SecretaryPatientList } from "./SecretaryPatientList";
export { SecretaryDoctorList } from "./SecretaryDoctorList";
export { SecretaryAppointmentList } from "./SecretaryAppointmentList";
export { SecretaryDoctorSchedule } from "./SecretaryDoctorSchedule";
export { SecretaryReportList } from "./SecretaryReportList";

View File

@ -1,224 +1,158 @@
import { useState, useEffect } from "react";
import { User } from "lucide-react";
interface AvatarProps {
/** URL do avatar, objeto com avatar_url, user_id, ou userId para buscar */
src?:
| string
| { avatar_url?: string | null }
| { profile?: { avatar_url?: string | null } }
| { id?: string }
| { user_id?: string };
/** Nome completo para gerar iniciais */
name?: string;
/** Tamanho do avatar */
size?: "xs" | "sm" | "md" | "lg" | "xl";
/** Cor do gradiente (se não tiver imagem) */
color?:
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
/** Classe CSS adicional */
className?: string;
/** Se deve mostrar borda */
border?: boolean;
}
const sizeClasses = {
xs: "w-6 h-6 text-xs",
sm: "w-8 h-8 text-xs",
md: "w-10 h-10 text-sm",
lg: "w-12 h-12 text-base",
xl: "w-16 h-16 text-xl",
};
const colorClasses = {
blue: "from-blue-400 to-blue-600",
green: "from-green-400 to-green-600",
purple: "from-purple-400 to-purple-600",
orange: "from-orange-400 to-orange-600",
pink: "from-pink-400 to-pink-600",
teal: "from-teal-400 to-teal-600",
indigo: "from-indigo-400 to-indigo-600",
red: "from-red-400 to-red-600",
};
/**
* Componente Avatar
* - Mostra imagem se disponível
* - Mostra iniciais como fallback
* - Suporta diferentes tamanhos e cores
*/
export function Avatar({
src,
name = "",
size = "md",
color = "blue",
className = "",
border = false,
}: AvatarProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const [currentExtIndex, setCurrentExtIndex] = useState(0);
const [userId, setUserId] = useState<string | null>(null);
// Extensões para tentar em ordem de preferência
const extensions = ["jpg", "png", "webp"];
// Extrai URL do avatar
useEffect(() => {
// Reset estados
setImageError(false);
setCurrentExtIndex(0);
setUserId(null);
if (!src) {
setImageUrl(null);
return;
}
if (typeof src === "string") {
console.log("[Avatar] URL direta:", src);
setImageUrl(src);
} else if ("avatar_url" in src && src.avatar_url) {
console.log("[Avatar] avatar_url:", src.avatar_url);
setImageUrl(src.avatar_url);
} else if ("profile" in src && src.profile?.avatar_url) {
console.log("[Avatar] profile.avatar_url:", src.profile.avatar_url);
setImageUrl(src.profile.avatar_url);
} else if ("user_id" in src && src.user_id) {
// Salva user_id para tentar múltiplas extensões
setUserId(src.user_id);
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const timestamp = new Date().getTime();
const url = `${SUPABASE_URL}/storage/v1/object/public/avatars/${src.user_id}/avatar.${extensions[0]}?t=${timestamp}`;
console.log("[Avatar] Tentando carregar avatar:", {
user_id: src.user_id,
url,
extension: extensions[0],
});
setImageUrl(url);
} else if ("id" in src && src.id) {
// Salva id para tentar múltiplas extensões
setUserId(src.id);
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const timestamp = new Date().getTime();
const url = `${SUPABASE_URL}/storage/v1/object/public/avatars/${src.id}/avatar.${extensions[0]}?t=${timestamp}`;
console.log("[Avatar] Tentando carregar avatar por id:", {
id: src.id,
url,
extension: extensions[0],
});
setImageUrl(url);
} else {
console.log("[Avatar] Nenhuma URL encontrada, src:", src);
setImageUrl(null);
}
}, [src]);
// Gera iniciais do nome
const getInitials = (fullName: string): string => {
if (!fullName) return "?";
const parts = fullName.trim().split(" ");
if (parts.length === 1) {
return parts[0].substring(0, 2).toUpperCase();
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
const initials = getInitials(name);
const shouldShowImage = imageUrl && !imageError;
// Log quando houver erro ao carregar imagem
const handleImageError = () => {
console.warn("[Avatar] Erro ao carregar imagem:", { imageUrl, name });
// Se tiver userId salvo, tenta próxima extensão
if (userId && currentExtIndex < extensions.length - 1) {
const nextIndex = currentExtIndex + 1;
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const timestamp = new Date().getTime();
const nextUrl = `${SUPABASE_URL}/storage/v1/object/public/avatars/${userId}/avatar.${extensions[nextIndex]}?t=${timestamp}`;
console.log("[Avatar] Tentando próxima extensão:", {
userId,
extension: extensions[nextIndex],
url: nextUrl,
});
setCurrentExtIndex(nextIndex);
setImageUrl(nextUrl);
setImageError(false);
} else {
// Esgotou todas as opções
setImageError(true);
}
};
// Log quando imagem carregar com sucesso
const handleImageLoad = () => {
console.log("[Avatar] ✅ Imagem carregada com sucesso:", {
imageUrl,
name,
});
};
return (
<div
className={`
${sizeClasses[size]}
rounded-full
flex items-center justify-center
overflow-hidden
${border ? "ring-2 ring-white shadow-lg" : ""}
${
shouldShowImage
? "bg-gray-100"
: `bg-gradient-to-br ${colorClasses[color]}`
}
${className}
`}
>
{shouldShowImage ? (
<img
src={imageUrl}
alt={name || "Avatar"}
className="w-full h-full object-cover"
onError={handleImageError}
onLoad={handleImageLoad}
/>
) : (
<span className="text-white font-semibold select-none">{initials}</span>
)}
</div>
);
}
/**
* Avatar com ícone padrão (para casos sem nome)
*/
export function AvatarIcon({
size = "md",
className = "",
}: Pick<AvatarProps, "size" | "className">) {
return (
<div
className={`
${sizeClasses[size]}
rounded-full
bg-gray-200
flex items-center justify-center
${className}
`}
>
<User className="w-1/2 h-1/2 text-gray-500" />
</div>
);
}
import { useState, useEffect } from "react";
import { User } from "lucide-react";
interface AvatarProps {
/** URL do avatar, objeto com avatar_url, ou userId para buscar */
src?:
| string
| { avatar_url?: string | null }
| { profile?: { avatar_url?: string | null } }
| { id?: string };
/** Nome completo para gerar iniciais */
name?: string;
/** Tamanho do avatar */
size?: "xs" | "sm" | "md" | "lg" | "xl";
/** Cor do gradiente (se não tiver imagem) */
color?:
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
/** Classe CSS adicional */
className?: string;
/** Se deve mostrar borda */
border?: boolean;
}
const sizeClasses = {
xs: "w-6 h-6 text-xs",
sm: "w-8 h-8 text-xs",
md: "w-10 h-10 text-sm",
lg: "w-12 h-12 text-base",
xl: "w-16 h-16 text-xl",
};
const colorClasses = {
blue: "from-blue-400 to-blue-600",
green: "from-green-400 to-green-600",
purple: "from-purple-400 to-purple-600",
orange: "from-orange-400 to-orange-600",
pink: "from-pink-400 to-pink-600",
teal: "from-teal-400 to-teal-600",
indigo: "from-indigo-400 to-indigo-600",
red: "from-red-400 to-red-600",
};
/**
* Componente Avatar
* - Mostra imagem se disponível
* - Mostra iniciais como fallback
* - Suporta diferentes tamanhos e cores
*/
export function Avatar({
src,
name = "",
size = "md",
color = "blue",
className = "",
border = false,
}: AvatarProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
// Extrai URL do avatar
useEffect(() => {
if (!src) {
setImageUrl(null);
return;
}
if (typeof src === "string") {
setImageUrl(src);
} else if ("avatar_url" in src && src.avatar_url) {
setImageUrl(src.avatar_url);
} else if ("profile" in src && src.profile?.avatar_url) {
setImageUrl(src.profile.avatar_url);
} else if ("id" in src && src.id) {
// Gera URL pública do Supabase Storage
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
setImageUrl(
`${SUPABASE_URL}/storage/v1/object/public/avatars/${src.id}/avatar`
);
} else {
setImageUrl(null);
}
setImageError(false);
}, [src]);
// Gera iniciais do nome
const getInitials = (fullName: string): string => {
if (!fullName) return "?";
const parts = fullName.trim().split(" ");
if (parts.length === 1) {
return parts[0].substring(0, 2).toUpperCase();
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
const initials = getInitials(name);
const shouldShowImage = imageUrl && !imageError;
return (
<div
className={`
${sizeClasses[size]}
rounded-full
flex items-center justify-center
overflow-hidden
${border ? "ring-2 ring-white shadow-lg" : ""}
${
shouldShowImage
? "bg-gray-100"
: `bg-gradient-to-br ${colorClasses[color]}`
}
${className}
`}
>
{shouldShowImage ? (
<img
src={imageUrl}
alt={name || "Avatar"}
className="w-full h-full object-cover"
onError={() => setImageError(true)}
/>
) : (
<span className="text-white font-semibold select-none">{initials}</span>
)}
</div>
);
}
/**
* Avatar com ícone padrão (para casos sem nome)
*/
export function AvatarIcon({
size = "md",
className = "",
}: Pick<AvatarProps, "size" | "className">) {
return (
<div
className={`
${sizeClasses[size]}
rounded-full
bg-gray-200
flex items-center justify-center
${className}
`}
>
<User className="w-1/2 h-1/2 text-gray-500" />
</div>
);
}

View File

@ -1,307 +1,218 @@
import { useState, useRef, useEffect } from "react";
import { Camera, Upload, X, Trash2 } from "lucide-react";
import { avatarService, patientService, doctorService } from "../../services";
import toast from "react-hot-toast";
import { Avatar } from "./Avatar";
interface AvatarUploadProps {
/** ID do usuário */
userId?: string;
/** URL atual do avatar */
currentAvatarUrl?: string;
/** Nome para gerar iniciais */
name?: string;
/** Cor do avatar */
color?:
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
/** Tamanho do avatar */
size?: "lg" | "xl";
/** Callback quando o avatar é atualizado */
onAvatarUpdate?: (avatarUrl: string | null) => void;
/** Se está em modo de edição */
editable?: boolean;
/** Tipo de usuário (paciente ou médico) */
userType?: "patient" | "doctor";
}
export function AvatarUpload({
userId,
currentAvatarUrl,
name = "",
color = "blue",
size = "xl",
onAvatarUpdate,
editable = true,
userType = "patient",
}: AvatarUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [displayUrl, setDisplayUrl] = useState<string | undefined>(
currentAvatarUrl
);
const fileInputRef = useRef<HTMLInputElement>(null);
// Atualiza displayUrl quando currentAvatarUrl muda externamente
useEffect(() => {
console.log("[AvatarUpload] currentAvatarUrl:", currentAvatarUrl);
console.log("[AvatarUpload] userId:", userId);
console.log("[AvatarUpload] editable:", editable);
setDisplayUrl(currentAvatarUrl);
}, [currentAvatarUrl, userId, editable]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
console.log("[AvatarUpload] Arquivo selecionado:", {
file: file?.name,
userId,
hasUserId: !!userId,
userIdType: typeof userId,
userIdValue: userId,
});
if (!file) {
console.warn("[AvatarUpload] Nenhum arquivo selecionado");
return;
}
if (!userId) {
console.error("[AvatarUpload] ❌ userId não está definido!", {
userId,
hasUserId: !!userId,
});
toast.error(
"Não foi possível identificar o usuário. Por favor, recarregue a página."
);
return;
}
// Validação adicional: userId não pode ser string vazia
if (typeof userId === "string" && userId.trim() === "") {
console.error("[AvatarUpload] ❌ userId está vazio!", { userId });
toast.error(
"ID do usuário está vazio. Por favor, recarregue a página."
);
return;
}
// Validação de tamanho (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error("Arquivo muito grande! Tamanho máximo: 2MB");
return;
}
// Validação de tipo
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error("Formato inválido! Use JPG, PNG ou WebP");
return;
}
setIsUploading(true);
setShowMenu(false);
try {
console.log("[AvatarUpload] 🚀 Iniciando upload...", {
userId,
fileName: file.name,
fileSize: file.size,
fileType: file.type,
});
// Upload do avatar
const uploadResult = await avatarService.upload({
userId,
file,
});
console.log("[AvatarUpload] ✅ Upload retornou:", uploadResult);
// Gera URL pública com cache-busting
const ext = file.name.split(".").pop()?.toLowerCase();
const avatarExt =
ext === "jpg" || ext === "png" || ext === "webp" ? ext : "jpg";
const baseUrl = avatarService.getPublicUrl({
userId,
ext: avatarExt,
});
// Adiciona timestamp para forçar reload da imagem
const publicUrl = `${baseUrl}?t=${Date.now()}`;
console.log("[AvatarUpload] Upload concluído, atualizando paciente...", {
baseUrl,
});
// Atualiza avatar_url na tabela apropriada (patients ou doctors)
try {
if (userType === "doctor") {
await doctorService.updateByUserId(userId, { avatar_url: baseUrl });
console.log("[AvatarUpload] ✅ Avatar atualizado na tabela doctors");
} else {
await patientService.updateByUserId(userId, { avatar_url: baseUrl });
console.log("[AvatarUpload] ✅ Avatar atualizado na tabela patients");
}
} catch (error) {
console.warn(
`[AvatarUpload] ⚠️ Não foi possível atualizar tabela ${userType === "doctor" ? "doctors" : "patients"}:`,
error
);
// Não bloqueia o fluxo, avatar já está no Storage
}
// Atualiza estado local com timestamp
setDisplayUrl(publicUrl);
// Callback com timestamp para forçar reload imediato no componente
onAvatarUpdate?.(publicUrl);
toast.success("Avatar atualizado com sucesso!");
console.log("[AvatarUpload] ✅ Processo concluído com sucesso");
} catch (error) {
console.error("❌ [AvatarUpload] Erro ao fazer upload:", error);
toast.error("Erro ao fazer upload do avatar");
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleRemove = async () => {
if (!userId) return;
if (!confirm("Tem certeza que deseja remover o avatar?")) {
setShowMenu(false);
return;
}
setIsUploading(true);
setShowMenu(false);
try {
await avatarService.delete({ userId });
// Remove avatar_url da tabela apropriada (patients ou doctors)
try {
if (userType === "doctor") {
await doctorService.updateByUserId(userId, { avatar_url: null });
console.log("[AvatarUpload] ✅ Avatar removido da tabela doctors");
} else {
await patientService.updateByUserId(userId, { avatar_url: null });
console.log("[AvatarUpload] ✅ Avatar removido da tabela patients");
}
} catch (error) {
console.warn(
`[AvatarUpload] ⚠️ Não foi possível remover da tabela ${userType === "doctor" ? "doctors" : "patients"}:`,
error
);
}
// Atualiza estado local
setDisplayUrl(undefined);
onAvatarUpdate?.(null);
toast.success("Avatar removido com sucesso!");
} catch (error) {
console.error("Erro ao remover avatar:", error);
toast.error("Erro ao remover avatar");
} finally {
setIsUploading(false);
}
};
return (
<div className="relative inline-block">
{/* Avatar */}
<div className="relative">
<Avatar
src={displayUrl || (userId ? { id: userId } : undefined)}
name={name}
size={size}
color={color}
border
/>
{/* Loading overlay */}
{isUploading && (
<div className="absolute inset-0 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Edit button */}
{editable && !isUploading && (
<button
type="button"
onClick={() => setShowMenu(!showMenu)}
className="absolute bottom-0 right-0 bg-white rounded-full p-1.5 shadow-lg hover:bg-gray-100 transition-colors border-2 border-white"
title="Editar avatar"
>
<Camera className="w-3 h-3 text-gray-700" />
</button>
)}
</div>
{/* Menu dropdown */}
{showMenu && editable && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
{/* Menu */}
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 py-2 z-50 min-w-[200px]">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{currentAvatarUrl ? "Trocar foto" : "Adicionar foto"}
</button>
{currentAvatarUrl && (
<button
type="button"
onClick={handleRemove}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Remover foto
</button>
)}
<button
type="button"
onClick={() => setShowMenu(false)}
className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-100 flex items-center gap-2 border-t border-gray-200 mt-1 pt-2"
>
<X className="w-4 h-4" />
Cancelar
</button>
</div>
</>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
}
import { useState, useRef, useEffect } from "react";
import { Camera, Upload, X, Trash2 } from "lucide-react";
import { avatarService, profileService } from "../../services";
import toast from "react-hot-toast";
import { Avatar } from "./Avatar";
interface AvatarUploadProps {
/** ID do usuário */
userId?: string;
/** URL atual do avatar */
currentAvatarUrl?: string;
/** Nome para gerar iniciais */
name?: string;
/** Cor do avatar */
color?:
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
/** Tamanho do avatar */
size?: "lg" | "xl";
/** Callback quando o avatar é atualizado */
onAvatarUpdate?: (avatarUrl: string | null) => void;
/** Se está em modo de edição */
editable?: boolean;
}
export function AvatarUpload({
userId,
currentAvatarUrl,
name = "",
color = "blue",
size = "xl",
onAvatarUpdate,
editable = true,
}: AvatarUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [displayUrl, setDisplayUrl] = useState<string | undefined>(
currentAvatarUrl
);
const fileInputRef = useRef<HTMLInputElement>(null);
// Atualiza displayUrl quando currentAvatarUrl muda externamente
useEffect(() => {
setDisplayUrl(currentAvatarUrl);
}, [currentAvatarUrl]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !userId) return;
// Validação de tamanho (max 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error("Arquivo muito grande! Tamanho máximo: 2MB");
return;
}
// Validação de tipo
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
toast.error("Formato inválido! Use JPG, PNG ou WebP");
return;
}
setIsUploading(true);
setShowMenu(false);
try {
// Upload do avatar
await avatarService.upload({
userId,
file,
});
// Gera URL pública com cache-busting
const ext = file.name.split(".").pop()?.toLowerCase();
const avatarExt =
ext === "jpg" || ext === "png" || ext === "webp" ? ext : "jpg";
const baseUrl = avatarService.getPublicUrl({
userId,
ext: avatarExt,
});
// Adiciona timestamp para forçar reload da imagem
const publicUrl = `${baseUrl}?t=${Date.now()}`;
// Atualiza no perfil (salva sem o timestamp)
await profileService.updateAvatar(userId, { avatar_url: baseUrl });
// Atualiza estado local com timestamp
setDisplayUrl(publicUrl);
// Callback com timestamp para forçar reload imediato no componente
onAvatarUpdate?.(publicUrl);
toast.success("Avatar atualizado com sucesso!");
} catch (error) {
console.error("Erro ao fazer upload:", error);
toast.error("Erro ao fazer upload do avatar");
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const handleRemove = async () => {
if (!userId) return;
if (!confirm("Tem certeza que deseja remover o avatar?")) {
setShowMenu(false);
return;
}
setIsUploading(true);
setShowMenu(false);
try {
await avatarService.delete({ userId });
await profileService.updateAvatar(userId, { avatar_url: null });
// Atualiza estado local
setDisplayUrl(undefined);
onAvatarUpdate?.(null);
toast.success("Avatar removido com sucesso!");
} catch (error) {
console.error("Erro ao remover avatar:", error);
toast.error("Erro ao remover avatar");
} finally {
setIsUploading(false);
}
};
return (
<div className="relative inline-block">
{/* Avatar */}
<div className="relative">
<Avatar src={displayUrl} name={name} size={size} color={color} border />
{/* Loading overlay */}
{isUploading && (
<div className="absolute inset-0 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Edit button */}
{editable && !isUploading && (
<button
type="button"
onClick={() => setShowMenu(!showMenu)}
className="absolute bottom-0 right-0 bg-white rounded-full p-2 shadow-lg hover:bg-gray-100 transition-colors border-2 border-white"
title="Editar avatar"
>
<Camera className="w-4 h-4 text-gray-700" />
</button>
)}
</div>
{/* Menu dropdown */}
{showMenu && editable && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
/>
{/* Menu */}
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 py-2 z-50 min-w-[200px]">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{currentAvatarUrl ? "Trocar foto" : "Adicionar foto"}
</button>
{currentAvatarUrl && (
<button
type="button"
onClick={handleRemove}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Remover foto
</button>
)}
<button
type="button"
onClick={() => setShowMenu(false)}
className="w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-100 flex items-center gap-2 border-t border-gray-200 mt-1 pt-2"
>
<X className="w-4 h-4" />
Cancelar
</button>
</div>
</>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
}

View File

@ -1,134 +1,132 @@
import React, { useState } from "react";
import { X, AlertTriangle } from "lucide-react";
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
requireTypedConfirmation?: boolean;
confirmationWord?: string;
isDangerous?: boolean;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Confirmar",
cancelText = "Cancelar",
requireTypedConfirmation = false,
confirmationWord = "CONFIRMAR",
isDangerous = false,
}) => {
const [typedConfirmation, setTypedConfirmation] = useState("");
if (!isOpen) return null;
const handleConfirm = () => {
if (requireTypedConfirmation && typedConfirmation !== confirmationWord) {
return;
}
onConfirm();
setTypedConfirmation("");
onClose();
};
const handleCancel = () => {
setTypedConfirmation("");
onClose();
};
const isConfirmDisabled =
requireTypedConfirmation && typedConfirmation !== confirmationWord;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden animate-in fade-in duration-200">
{/* Header */}
<div
className={`px-6 py-4 border-b flex items-center justify-between ${
isDangerous
? "bg-red-50 border-red-200"
: "bg-blue-50 border-blue-200"
}`}
>
<div className="flex items-center gap-3">
{isDangerous ? (
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
) : (
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-blue-600" />
</div>
)}
<h3
className={`text-lg font-semibold ${
isDangerous ? "text-red-900" : "text-blue-900"
}`}
>
{title}
</h3>
</div>
<button
onClick={handleCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-5">
<div className="text-gray-700 mb-4">{message}</div>
{requireTypedConfirmation && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Digite <span className="font-bold">{confirmationWord}</span>{" "}
para confirmar:
</label>
<input
type="text"
value={typedConfirmation}
onChange={(e) => setTypedConfirmation(e.target.value)}
className="form-input"
placeholder={confirmationWord}
autoFocus
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-end gap-3">
<button
onClick={handleCancel}
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium"
>
{cancelText}
</button>
<button
onClick={handleConfirm}
disabled={isConfirmDisabled}
className={`px-5 py-2.5 text-white rounded-lg font-medium transition-all ${
isDangerous
? "bg-red-600 hover:bg-red-700 disabled:bg-red-300"
: "bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300"
} disabled:cursor-not-allowed`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
import React, { useState } from "react";
import { X, AlertTriangle } from "lucide-react";
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string | React.ReactNode;
confirmText?: string;
cancelText?: string;
requireTypedConfirmation?: boolean;
confirmationWord?: string;
isDangerous?: boolean;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Confirmar",
cancelText = "Cancelar",
requireTypedConfirmation = false,
confirmationWord = "CONFIRMAR",
isDangerous = false,
}) => {
const [typedConfirmation, setTypedConfirmation] = useState("");
if (!isOpen) return null;
const handleConfirm = () => {
if (requireTypedConfirmation && typedConfirmation !== confirmationWord) {
return;
}
onConfirm();
setTypedConfirmation("");
onClose();
};
const handleCancel = () => {
setTypedConfirmation("");
onClose();
};
const isConfirmDisabled =
requireTypedConfirmation && typedConfirmation !== confirmationWord;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden animate-in fade-in duration-200">
{/* Header */}
<div
className={`px-6 py-4 border-b flex items-center justify-between ${
isDangerous
? "bg-red-50 border-red-200"
: "bg-blue-50 border-blue-200"
}`}
>
<div className="flex items-center gap-3">
{isDangerous ? (
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
) : (
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-blue-600" />
</div>
)}
<h3
className={`text-lg font-semibold ${
isDangerous ? "text-red-900" : "text-blue-900"
}`}
>
{title}
</h3>
</div>
<button
onClick={handleCancel}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-5">
<div className="text-gray-700 mb-4">{message}</div>
{requireTypedConfirmation && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Digite <span className="font-bold">{confirmationWord}</span>{" "}
para confirmar:
</label>
<input
type="text"
value={typedConfirmation}
onChange={(e) => setTypedConfirmation(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={confirmationWord}
autoFocus
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex items-center justify-end gap-3">
<button
onClick={handleCancel}
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium"
>
{cancelText}
</button>
<button
onClick={handleConfirm}
disabled={isConfirmDisabled}
className={`px-5 py-2.5 text-white rounded-lg font-medium transition-all ${
isDangerous
? "bg-red-600 hover:bg-red-700 disabled:bg-red-300"
: "bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300"
} disabled:cursor-not-allowed`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};

View File

@ -7,7 +7,6 @@ import React, {
} from "react";
import toast from "react-hot-toast";
import { authService, userService } from "../services";
import { supabase } from "../lib/supabase";
// Tipos auxiliares
interface UserInfoFullResponse {
@ -99,14 +98,12 @@ interface AuthContextValue {
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const STORAGE_KEY = "appSession";
const SESSION_VERSION = "2.0"; // Incrementar quando mudar estrutura de roles
interface PersistedSession {
user: SessionUser;
token?: string; // para quando integrar authService real
refreshToken?: string;
savedAt: string;
version?: string; // Versão da estrutura da sessão
}
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
@ -216,82 +213,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
}
if (raw) {
const parsed = JSON.parse(raw) as PersistedSession;
// Verificar versão da sessão - se for antiga, atualizar a role do JWT
if (!parsed.version || parsed.version !== SESSION_VERSION) {
console.log(
"[AuthContext] ⚠️ Sessão antiga detectada (versão:",
parsed.version || "sem versão",
"vs atual:",
SESSION_VERSION,
"). Atualizando role do JWT..."
);
// Pegar o token JWT do localStorage
const accessToken = localStorage.getItem(
"mediconnect_access_token"
);
if (accessToken) {
try {
// Decodificar JWT para pegar app_metadata
const payload = JSON.parse(atob(accessToken.split(".")[1]));
const userRole =
payload.app_metadata?.user_role ||
payload.user_metadata?.role;
console.log("[AuthContext] 🔑 Role do JWT:", userRole);
if (userRole) {
const normalizedRole = normalizeRole(userRole);
console.log(
"[AuthContext] ✅ Atualizando role de",
parsed.user.role,
"para",
normalizedRole
);
// Atualizar a sessão com a role correta
const updatedUser = {
...parsed.user,
role: normalizedRole,
roles: [normalizedRole],
} as SessionUser;
setUser(updatedUser);
persist({
user: updatedUser,
token: parsed.token,
version: SESSION_VERSION,
});
setLoading(false);
return;
}
} catch (error) {
console.error(
"[AuthContext] ❌ Erro ao decodificar JWT:",
error
);
}
}
console.log(
"[AuthContext] ⚠️ Não conseguiu atualizar role do JWT, usando role da sessão antiga"
);
// Usar sessão antiga mesmo sem conseguir atualizar
const updatedUser = {
...parsed.user,
version: SESSION_VERSION,
} as SessionUser;
setUser(updatedUser);
persist({
user: updatedUser,
token: parsed.token,
version: SESSION_VERSION,
});
setLoading(false);
return;
}
if (parsed?.user?.role) {
console.log("[AuthContext] ✅ Restaurando sessão:", {
nome: parsed.user.nome,
@ -348,8 +269,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
hasToken: !!session.token,
}
);
const sessionWithVersion = { ...session, version: SESSION_VERSION };
const sessionStr = JSON.stringify(sessionWithVersion);
const sessionStr = JSON.stringify(session);
localStorage.setItem(STORAGE_KEY, sessionStr);
sessionStorage.setItem(STORAGE_KEY, sessionStr); // BACKUP em sessionStorage
console.log(
@ -407,42 +327,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
const buildSessionUser = React.useCallback(
(info: UserInfoFullResponse): SessionUser => {
// ⚠️ SEGURANÇA: Nunca logar tokens ou dados sensíveis em produção
// Tentar pegar role do app_metadata primeiro (mais confiável)
let rolesFromMetadata: UserRole[] = [];
if (info.user?.user_metadata?.app_metadata?.user_role) {
const roleFromApp = normalizeRole(
info.user.user_metadata.app_metadata.user_role
);
if (roleFromApp) rolesFromMetadata.push(roleFromApp);
}
// Depois do user_metadata.role
if (info.user?.user_metadata?.role) {
const roleFromUser = normalizeRole(info.user.user_metadata.role);
if (roleFromUser) rolesFromMetadata.push(roleFromUser);
}
const rolesNormalized = (info.roles || [])
.map(normalizeRole)
.filter(Boolean) as UserRole[];
// Combinar roles do metadata com roles do array
const allRoles = [...new Set([...rolesFromMetadata, ...rolesNormalized])];
const permissions = info.permissions || {};
const primaryRole = pickPrimaryRole(
allRoles.length
? allRoles
rolesNormalized.length
? rolesNormalized
: [normalizeRole((info.roles || [])[0]) || "paciente"]
);
console.log("[buildSessionUser] Roles detectados:", {
fromMetadata: rolesFromMetadata,
fromArray: rolesNormalized,
allRoles,
primaryRole,
});
const base = {
id: info.user?.id || "",
nome:
@ -451,7 +344,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
"Usuário",
email: info.user?.email,
role: primaryRole,
roles: allRoles,
roles: rolesNormalized,
permissions,
} as SessionUserBase;
if (primaryRole === "medico") {
@ -486,6 +379,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
};
setUser(newUser);
persist({ user: newUser, savedAt: new Date().toISOString() });
toast.success("Login realizado");
return true;
}
toast.error("Credenciais inválidas");
@ -549,6 +443,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
token: loginResp.access_token,
refreshToken: loginResp.refresh_token,
});
toast.success("Login realizado");
return true;
} catch (error) {
console.error("[AuthContext] Login falhou:", error);

View File

@ -1,110 +1,110 @@
/**
* English (US) Translations
*/
export const enUS = {
common: {
skipToContent: "Skip to content",
loading: "Loading...",
error: "Error",
retry: "Try again",
cancel: "Cancel",
confirm: "Confirm",
close: "Close",
save: "Save",
edit: "Edit",
delete: "Delete",
search: "Search",
filter: "Filter",
viewAll: "View all",
noData: "No data available",
},
header: {
logo: "MediConnect",
subtitle: "Appointment System",
home: "Home",
login: "Login",
logout: "Logout",
notAuthenticated: "Not authenticated",
profile: "Profile",
selectProfile: "Select your profile",
},
profiles: {
patient: "Patient",
doctor: "Doctor",
secretary: "Secretary",
patientDescription: "Schedule and track appointments",
doctorDescription: "Manage appointments and patients",
secretaryDescription: "Registration and scheduling",
},
home: {
hero: {
title: "Medical Appointment System",
subtitle:
"Connecting patients and healthcare professionals efficiently and securely",
ctaPrimary: "Schedule appointment",
ctaSecondary: "View upcoming appointments",
},
metrics: {
totalPatients: "Total Patients",
totalPatientsDescription:
"Total number of patients registered in the system",
activeDoctors: "Active Doctors",
activeDoctorsDescription: "Professionals available for care",
todayAppointments: "Today's Appointments",
todayAppointmentsDescription: "Appointments scheduled for today",
pendingAppointments: "Pending",
pendingAppointmentsDescription:
"Scheduled or confirmed appointments awaiting completion",
},
emptyStates: {
noPatients: "No patients registered",
noDoctors: "No doctors registered",
noAppointments: "No appointments scheduled",
registerPatient: "Register patient",
inviteDoctor: "Invite doctor",
scheduleAppointment: "Schedule appointment",
},
actionCards: {
scheduleAppointment: {
title: "Schedule Appointment",
description: "Book medical appointments quickly and easily",
cta: "Go to Scheduling",
ctaAriaLabel: "Go to appointment scheduling page",
},
doctorPanel: {
title: "Doctor Panel",
description: "Manage appointments, schedules and records",
cta: "Access Panel",
ctaAriaLabel: "Go to doctor panel",
},
patientManagement: {
title: "Patient Management",
description: "Register and manage patient information",
cta: "Access Registration",
ctaAriaLabel: "Go to patient registration area",
},
},
upcomingConsultations: {
title: "Upcoming Appointments",
empty: "No appointments scheduled",
viewAll: "View all appointments",
date: "Date",
time: "Time",
patient: "Patient",
doctor: "Doctor",
status: "Status",
statusScheduled: "Scheduled",
statusConfirmed: "Confirmed",
statusCompleted: "Completed",
statusCanceled: "Canceled",
statusMissed: "Missed",
},
errorLoadingStats: "Error loading statistics",
},
accessibility: {
reducedMotion: "Reduced motion preference detected",
highContrast: "High contrast",
largeText: "Large text",
darkMode: "Dark mode",
},
};
/**
* English (US) Translations
*/
export const enUS = {
common: {
skipToContent: "Skip to content",
loading: "Loading...",
error: "Error",
retry: "Try again",
cancel: "Cancel",
confirm: "Confirm",
close: "Close",
save: "Save",
edit: "Edit",
delete: "Delete",
search: "Search",
filter: "Filter",
viewAll: "View all",
noData: "No data available",
},
header: {
logo: "MediConnect",
subtitle: "Appointment System",
home: "Home",
login: "Login",
logout: "Logout",
notAuthenticated: "Not authenticated",
profile: "Profile",
selectProfile: "Select your profile",
},
profiles: {
patient: "Patient",
doctor: "Doctor",
secretary: "Secretary",
patientDescription: "Schedule and track appointments",
doctorDescription: "Manage appointments and patients",
secretaryDescription: "Registration and scheduling",
},
home: {
hero: {
title: "Medical Appointment System",
subtitle:
"Connecting patients and healthcare professionals efficiently and securely",
ctaPrimary: "Schedule appointment",
ctaSecondary: "View upcoming appointments",
},
metrics: {
totalPatients: "Total Patients",
totalPatientsDescription:
"Total number of patients registered in the system",
activeDoctors: "Active Doctors",
activeDoctorsDescription: "Professionals available for care",
todayAppointments: "Today's Appointments",
todayAppointmentsDescription: "Appointments scheduled for today",
pendingAppointments: "Pending",
pendingAppointmentsDescription:
"Scheduled or confirmed appointments awaiting completion",
},
emptyStates: {
noPatients: "No patients registered",
noDoctors: "No doctors registered",
noAppointments: "No appointments scheduled",
registerPatient: "Register patient",
inviteDoctor: "Invite doctor",
scheduleAppointment: "Schedule appointment",
},
actionCards: {
scheduleAppointment: {
title: "Schedule Appointment",
description: "Book medical appointments quickly and easily",
cta: "Go to Scheduling",
ctaAriaLabel: "Go to appointment scheduling page",
},
doctorPanel: {
title: "Doctor Panel",
description: "Manage appointments, schedules and records",
cta: "Access Panel",
ctaAriaLabel: "Go to doctor panel",
},
patientManagement: {
title: "Patient Management",
description: "Register and manage patient information",
cta: "Access Registration",
ctaAriaLabel: "Go to patient registration area",
},
},
upcomingConsultations: {
title: "Upcoming Appointments",
empty: "No appointments scheduled",
viewAll: "View all appointments",
date: "Date",
time: "Time",
patient: "Patient",
doctor: "Doctor",
status: "Status",
statusScheduled: "Scheduled",
statusConfirmed: "Confirmed",
statusCompleted: "Completed",
statusCanceled: "Canceled",
statusMissed: "Missed",
},
errorLoadingStats: "Error loading statistics",
},
accessibility: {
reducedMotion: "Reduced motion preference detected",
highContrast: "High contrast",
largeText: "Large text",
darkMode: "Dark mode",
},
};

View File

@ -1,88 +1,88 @@
import { ptBR, TranslationKeys } from "./pt-BR";
import { enUS } from "./en-US";
type Locale = "pt-BR" | "en-US";
const translations: Record<Locale, TranslationKeys> = {
"pt-BR": ptBR,
"en-US": enUS as TranslationKeys,
};
class I18n {
private currentLocale: Locale = "pt-BR";
constructor() {
// Detectar idioma do navegador
const browserLang = navigator.language;
if (browserLang.startsWith("en")) {
this.currentLocale = "en-US";
}
// Carregar preferência salva
const savedLocale = localStorage.getItem("mediconnect_locale") as Locale;
if (savedLocale && translations[savedLocale]) {
this.currentLocale = savedLocale;
}
}
public t(key: string): string {
const keys = key.split(".");
let value: Record<string, unknown> | string =
translations[this.currentLocale];
for (const k of keys) {
if (typeof value === "object" && value && k in value) {
value = value[k] as Record<string, unknown> | string;
} else {
console.warn(`Translation key not found: ${key}`);
return key;
}
}
return typeof value === "string" ? value : key;
}
public setLocale(locale: Locale): void {
if (translations[locale]) {
this.currentLocale = locale;
localStorage.setItem("mediconnect_locale", locale);
// Atualizar lang do HTML
document.documentElement.lang = locale;
}
}
public getLocale(): Locale {
return this.currentLocale;
}
public formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(d);
}
public formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
public formatDateTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
}
export const i18n = new I18n();
export type { Locale };
import { ptBR, TranslationKeys } from "./pt-BR";
import { enUS } from "./en-US";
type Locale = "pt-BR" | "en-US";
const translations: Record<Locale, TranslationKeys> = {
"pt-BR": ptBR,
"en-US": enUS as TranslationKeys,
};
class I18n {
private currentLocale: Locale = "pt-BR";
constructor() {
// Detectar idioma do navegador
const browserLang = navigator.language;
if (browserLang.startsWith("en")) {
this.currentLocale = "en-US";
}
// Carregar preferência salva
const savedLocale = localStorage.getItem("mediconnect_locale") as Locale;
if (savedLocale && translations[savedLocale]) {
this.currentLocale = savedLocale;
}
}
public t(key: string): string {
const keys = key.split(".");
let value: Record<string, unknown> | string =
translations[this.currentLocale];
for (const k of keys) {
if (typeof value === "object" && value && k in value) {
value = value[k] as Record<string, unknown> | string;
} else {
console.warn(`Translation key not found: ${key}`);
return key;
}
}
return typeof value === "string" ? value : key;
}
public setLocale(locale: Locale): void {
if (translations[locale]) {
this.currentLocale = locale;
localStorage.setItem("mediconnect_locale", locale);
// Atualizar lang do HTML
document.documentElement.lang = locale;
}
}
public getLocale(): Locale {
return this.currentLocale;
}
public formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(d);
}
public formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
public formatDateTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat(this.currentLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(d);
}
}
export const i18n = new I18n();
export type { Locale };

View File

@ -1,112 +1,112 @@
/**
* Traduções em Português do Brasil
*/
export const ptBR = {
common: {
skipToContent: "Pular para o conteúdo",
loading: "Carregando...",
error: "Erro",
retry: "Tentar novamente",
cancel: "Cancelar",
confirm: "Confirmar",
close: "Fechar",
save: "Salvar",
edit: "Editar",
delete: "Excluir",
search: "Pesquisar",
filter: "Filtrar",
viewAll: "Ver todas",
noData: "Nenhum dado disponível",
},
header: {
logo: "MediConnect",
subtitle: "Sistema de Agendamento",
home: "Início",
login: "Entrar",
logout: "Sair",
notAuthenticated: "Não autenticado",
profile: "Perfil",
selectProfile: "Selecione seu perfil",
},
profiles: {
patient: "Paciente",
doctor: "Médico",
secretary: "Secretária",
patientDescription: "Agendar e acompanhar consultas",
doctorDescription: "Gerenciar consultas e pacientes",
secretaryDescription: "Cadastros e agendamentos",
},
home: {
hero: {
title: "Sistema de Agendamento Médico",
subtitle:
"Conectando pacientes e profissionais de saúde com eficiência e segurança",
ctaPrimary: "Agendar consulta",
ctaSecondary: "Ver próximas consultas",
},
metrics: {
totalPatients: "Total de Pacientes",
totalPatientsDescription:
"Número total de pacientes cadastrados no sistema",
activeDoctors: "Médicos Ativos",
activeDoctorsDescription: "Profissionais disponíveis para atendimento",
todayAppointments: "Consultas Hoje",
todayAppointmentsDescription: "Consultas agendadas para hoje",
pendingAppointments: "Pendentes",
pendingAppointmentsDescription:
"Consultas agendadas ou confirmadas aguardando realização",
},
emptyStates: {
noPatients: "Nenhum paciente cadastrado",
noDoctors: "Nenhum médico cadastrado",
noAppointments: "Nenhuma consulta agendada",
registerPatient: "Cadastrar paciente",
inviteDoctor: "Convidar médico",
scheduleAppointment: "Agendar consulta",
},
actionCards: {
scheduleAppointment: {
title: "Agendar Consulta",
description: "Agende consultas médicas de forma rápida e prática",
cta: "Acessar Agendamento",
ctaAriaLabel: "Ir para página de agendamento de consultas",
},
doctorPanel: {
title: "Painel do Médico",
description: "Gerencie consultas, horários e prontuários",
cta: "Acessar Painel",
ctaAriaLabel: "Ir para painel do médico",
},
patientManagement: {
title: "Gestão de Pacientes",
description: "Cadastre e gerencie informações de pacientes",
cta: "Acessar Cadastro",
ctaAriaLabel: "Ir para área de cadastro de pacientes",
},
},
upcomingConsultations: {
title: "Próximas Consultas",
empty: "Nenhuma consulta agendada",
viewAll: "Ver todas as consultas",
date: "Data",
time: "Horário",
patient: "Paciente",
doctor: "Médico",
status: "Status",
statusScheduled: "Agendada",
statusConfirmed: "Confirmada",
statusCompleted: "Realizada",
statusCanceled: "Cancelada",
statusMissed: "Faltou",
},
errorLoadingStats: "Erro ao carregar estatísticas",
},
accessibility: {
reducedMotion: "Preferência por movimento reduzido detectada",
highContrast: "Alto contraste",
largeText: "Texto aumentado",
darkMode: "Modo escuro",
},
};
export type TranslationKeys = typeof ptBR;
/**
* Traduções em Português do Brasil
*/
export const ptBR = {
common: {
skipToContent: "Pular para o conteúdo",
loading: "Carregando...",
error: "Erro",
retry: "Tentar novamente",
cancel: "Cancelar",
confirm: "Confirmar",
close: "Fechar",
save: "Salvar",
edit: "Editar",
delete: "Excluir",
search: "Pesquisar",
filter: "Filtrar",
viewAll: "Ver todas",
noData: "Nenhum dado disponível",
},
header: {
logo: "MediConnect",
subtitle: "Sistema de Agendamento",
home: "Início",
login: "Entrar",
logout: "Sair",
notAuthenticated: "Não autenticado",
profile: "Perfil",
selectProfile: "Selecione seu perfil",
},
profiles: {
patient: "Paciente",
doctor: "Médico",
secretary: "Secretária",
patientDescription: "Agendar e acompanhar consultas",
doctorDescription: "Gerenciar consultas e pacientes",
secretaryDescription: "Cadastros e agendamentos",
},
home: {
hero: {
title: "Sistema de Agendamento Médico",
subtitle:
"Conectando pacientes e profissionais de saúde com eficiência e segurança",
ctaPrimary: "Agendar consulta",
ctaSecondary: "Ver próximas consultas",
},
metrics: {
totalPatients: "Total de Pacientes",
totalPatientsDescription:
"Número total de pacientes cadastrados no sistema",
activeDoctors: "Médicos Ativos",
activeDoctorsDescription: "Profissionais disponíveis para atendimento",
todayAppointments: "Consultas Hoje",
todayAppointmentsDescription: "Consultas agendadas para hoje",
pendingAppointments: "Pendentes",
pendingAppointmentsDescription:
"Consultas agendadas ou confirmadas aguardando realização",
},
emptyStates: {
noPatients: "Nenhum paciente cadastrado",
noDoctors: "Nenhum médico cadastrado",
noAppointments: "Nenhuma consulta agendada",
registerPatient: "Cadastrar paciente",
inviteDoctor: "Convidar médico",
scheduleAppointment: "Agendar consulta",
},
actionCards: {
scheduleAppointment: {
title: "Agendar Consulta",
description: "Agende consultas médicas de forma rápida e prática",
cta: "Acessar Agendamento",
ctaAriaLabel: "Ir para página de agendamento de consultas",
},
doctorPanel: {
title: "Painel do Médico",
description: "Gerencie consultas, horários e prontuários",
cta: "Acessar Painel",
ctaAriaLabel: "Ir para painel do médico",
},
patientManagement: {
title: "Gestão de Pacientes",
description: "Cadastre e gerencie informações de pacientes",
cta: "Acessar Cadastro",
ctaAriaLabel: "Ir para área de cadastro de pacientes",
},
},
upcomingConsultations: {
title: "Próximas Consultas",
empty: "Nenhuma consulta agendada",
viewAll: "Ver todas as consultas",
date: "Data",
time: "Horário",
patient: "Paciente",
doctor: "Médico",
status: "Status",
statusScheduled: "Agendada",
statusConfirmed: "Confirmada",
statusCompleted: "Realizada",
statusCanceled: "Cancelada",
statusMissed: "Faltou",
},
errorLoadingStats: "Erro ao carregar estatísticas",
},
accessibility: {
reducedMotion: "Preferência por movimento reduzido detectada",
highContrast: "Alto contraste",
largeText: "Texto aumentado",
darkMode: "Modo escuro",
},
};
export type TranslationKeys = typeof ptBR;

View File

@ -7,54 +7,7 @@
@layer base {
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
overflow-x: hidden;
}
/* Respeitar configurações de zoom do usuário */
html {
overflow-x: hidden;
}
/* Garantir que imagens e vídeos sejam responsivos */
img,
video {
max-width: 100%;
height: auto;
}
/* Garantir que tabelas sejam scrolláveis em mobile */
table {
display: block;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
@media (min-width: 768px) {
table {
display: table;
}
}
}
/* Animação de rotação única */
@keyframes spin-once {
0% {
transform: rotate(0deg) scale(0);
opacity: 0;
}
50% {
transform: rotate(180deg) scale(1);
opacity: 1;
}
100% {
transform: rotate(360deg) scale(1);
opacity: 1;
}
}
.animate-spin-once {
animation: spin-once 0.6s ease-out forwards;
}
/* Dark mode hard fallback (ensure full-page background) */
@ -112,32 +65,11 @@ html.reduced-motion *::after {
scroll-behavior: auto !important;
}
/* Filtro de luz azul (aplica overlay amarelada sem quebrar position: fixed) */
html.low-blue-light {
position: relative;
}
html.low-blue-light::after {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
to bottom,
rgba(255, 220, 150, 0.25),
rgba(255, 200, 120, 0.25)
);
pointer-events: none;
z-index: 999999;
mix-blend-mode: multiply;
}
/* Garante que o menu de acessibilidade fique acima do filtro */
html.low-blue-light button[aria-label="Menu de Acessibilidade"],
html.low-blue-light [role="dialog"][aria-modal="true"] {
z-index: 9999999 !important;
/* Filtro de luz azul (aplica matiz e tonalidade amarelada) */
/* Filtro de luz azul (modo mais "padrão" com tom amarelado suave) */
html.low-blue-light body {
/* Mais quente: mais sepia e matiz mais próximo do laranja */
filter: sepia(40%) hue-rotate(315deg) saturate(85%) brightness(98%);
}
/* Modo foco: destaque reforçado no elemento focado, sem quebrar layout */
@ -203,284 +135,52 @@ html.focus-mode.dark *:focus-visible,
.gradient-blue-light {
@apply bg-gradient-to-l from-blue-600 to-blue-400;
}
/* Classes padronizadas para formulários */
.form-input {
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white;
}
.form-select {
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base bg-white;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white;
}
.form-textarea {
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white resize-none;
}
.form-label {
@apply block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-2;
}
}
/* Estilos de Acessibilidade - Alto Contraste */
/* Estilos de Acessibilidade */
.high-contrast {
--tw-bg-opacity: 1;
}
.high-contrast body {
background-color: #000 !important;
color: #ffff00 !important;
color: #fff !important;
}
/* Backgrounds brancos/claros viram pretos */
.high-contrast .bg-white,
.high-contrast .bg-gray-50,
.high-contrast .bg-gray-100 {
.high-contrast .bg-white {
background-color: #000 !important;
color: #ffff00 !important;
border-color: #ffff00 !important;
color: #fff !important;
border: 2px solid #fff !important;
}
/* Backgrounds escuros ficam pretos */
.high-contrast .bg-gray-800,
.high-contrast .bg-gray-900 {
background-color: #000 !important;
color: #ffff00 !important;
}
/* Textos cinzas ficam amarelos */
.high-contrast .text-gray-400,
.high-contrast .text-gray-500,
.high-contrast .text-gray-600,
.high-contrast .text-gray-700,
.high-contrast .text-gray-800,
.high-contrast .text-gray-900 {
color: #ffff00 !important;
color: #fff !important;
}
/* Textos brancos ficam amarelos */
.high-contrast .text-white,
.high-contrast .text-gray-100 {
color: #ffff00 !important;
}
/* Botões primários (verde/azul) */
.high-contrast .bg-blue-600,
.high-contrast .bg-blue-500,
.high-contrast .bg-green-600,
.high-contrast .bg-green-700 {
.high-contrast .bg-green-600 {
background-color: #ffff00 !important;
color: #000 !important;
border: 2px solid #000 !important;
font-weight: bold !important;
}
/* Botões com bordas */
.high-contrast .border-gray-300,
.high-contrast .border-gray-600,
.high-contrast .border-gray-200,
.high-contrast .border-gray-700 {
border-color: #ffff00 !important;
.high-contrast a,
.high-contrast button:not(.bg-red-500) {
text-decoration: underline;
font-weight: bold;
}
/* Links e botões secundários */
.high-contrast a {
color: #ffff00 !important;
text-decoration: underline !important;
font-weight: bold !important;
}
.high-contrast button {
border: 2px solid #ffff00 !important;
}
/* Inputs e selects */
.high-contrast input,
.high-contrast select,
.high-contrast textarea {
background-color: #fff !important;
color: #000 !important;
border: 3px solid #000 !important;
font-weight: bold !important;
}
.high-contrast input::placeholder,
.high-contrast textarea::placeholder {
color: #666 !important;
opacity: 1 !important;
}
/* Badges e status */
.high-contrast .bg-green-100,
.high-contrast .bg-blue-100,
.high-contrast .bg-yellow-100,
.high-contrast .bg-red-100,
.high-contrast .bg-purple-100,
.high-contrast .bg-orange-100 {
background-color: #ffff00 !important;
color: #000 !important;
border: 2px solid #000 !important;
}
/* Tabelas */
.high-contrast table {
border: 2px solid #ffff00 !important;
}
.high-contrast th,
.high-contrast td {
border: 1px solid #ffff00 !important;
}
.high-contrast thead {
background-color: #000 !important;
color: #ffff00 !important;
}
/* Hover states */
.high-contrast tr:hover,
.high-contrast .hover\:bg-gray-50:hover,
.high-contrast .hover\:bg-gray-100:hover {
background-color: #1a1a1a !important;
color: #ffff00 !important;
}
/* Icons devem ser visíveis */
.high-contrast svg {
color: #ffff00 !important;
stroke: currentColor;
}
/* Botões de ação com cores específicas */
.high-contrast .text-blue-600,
.high-contrast .text-green-600,
.high-contrast .text-orange-600,
.high-contrast .text-red-600,
.high-contrast .text-purple-600 {
color: #ffff00 !important;
}
.high-contrast .hover\:bg-blue-50:hover,
.high-contrast .hover\:bg-green-50:hover,
.high-contrast .hover\:bg-orange-50:hover,
.high-contrast .hover\:bg-red-50:hover {
background-color: #333 !important;
}
/* Divisores e bordas */
.high-contrast .divide-y > * {
border-color: #ffff00 !important;
}
/* Cards e containers */
.high-contrast .rounded-xl,
.high-contrast .rounded-lg {
border: 2px solid #ffff00 !important;
}
/* Modals e dialogs */
.high-contrast .shadow-xl,
.high-contrast .shadow-sm {
box-shadow: 0 0 0 3px #ffff00 !important;
}
/* Botões desabilitados */
.high-contrast button:disabled {
background-color: #333 !important;
color: #666 !important;
border-color: #666 !important;
opacity: 0.5 !important;
}
/* Paginação - página ativa */
.high-contrast .bg-green-600.text-white {
background-color: #ffff00 !important;
color: #000 !important;
border: 3px solid #000 !important;
}
/* Calendário - células cinzas */
.high-contrast .bg-gray-200 {
background-color: #000 !important;
color: #ffff00 !important;
}
/* Calendário - dias da semana e células */
.high-contrast .bg-gray-50,
.high-contrast .bg-gray-100 {
background-color: #000 !important;
color: #ffff00 !important;
}
/* Calendário - dia atual (azul claro) */
.high-contrast .bg-blue-50 {
background-color: #1a1a1a !important;
color: #ffff00 !important;
border: 3px solid #ffff00 !important;
}
/* Calendário - eventos/horários nas células */
.high-contrast .bg-blue-100,
.high-contrast .bg-green-100,
.high-contrast .text-blue-800,
.high-contrast .text-green-800,
.high-contrast .text-yellow-800,
.high-contrast .text-red-800,
.high-contrast .text-purple-800 {
background-color: #ffff00 !important;
color: #000 !important;
border: 2px solid #000 !important;
}
/* Calendário - Grid com divisórias amarelas */
.high-contrast .grid-cols-7 {
background-color: #ffff00 !important;
gap: 2px !important;
padding: 2px !important;
}
.high-contrast .grid-cols-7 > div {
background-color: #000 !important;
border: 2px solid #ffff00 !important;
color: #ffff00 !important;
}
/* Calendário - Background do grid */
.high-contrast .bg-gray-200.border.border-gray-200 {
background-color: #ffff00 !important;
border-color: #ffff00 !important;
}
/* Headers com fundo cinza */
.high-contrast .bg-gray-700 {
background-color: #000 !important;
color: #ffff00 !important;
}
/* Texto em fundos coloridos */
.high-contrast .text-blue-700,
.high-contrast .text-green-700,
.high-contrast .text-purple-700 {
color: #000 !important;
}
/* Garantir que backgrounds cinzas fiquem pretos */
.high-contrast [class*="bg-gray"] {
background-color: #000 !important;
color: #ffff00 !important;
}
/* Garantir que textos cinzas fiquem amarelos */
.high-contrast [class*="text-gray"] {
color: #ffff00 !important;
}
/* Modo Escuro Melhorado */
.dark {
color-scheme: dark;
@ -789,81 +489,6 @@ html.focus-mode.dark *:focus-visible,
outline-color: #60a5fa;
}
/* Utilidades de responsividade global */
@layer utilities {
/* Container responsivo com padding adaptável */
.responsive-container {
@apply w-full mx-auto px-3 sm:px-4 md:px-6 lg:px-8;
max-width: 1920px;
}
/* Card responsivo */
.responsive-card {
@apply bg-white rounded-lg shadow-md p-3 sm:p-4 md:p-6;
}
.dark .responsive-card {
@apply bg-slate-800 shadow-slate-900/50;
}
/* Texto responsivo */
.text-responsive-sm {
@apply text-xs sm:text-sm;
}
.text-responsive-base {
@apply text-sm sm:text-base;
}
.text-responsive-lg {
@apply text-base sm:text-lg md:text-xl;
}
.text-responsive-xl {
@apply text-lg sm:text-xl md:text-2xl lg:text-3xl;
}
/* Botão responsivo */
.btn-responsive {
@apply px-3 py-2 sm:px-4 sm:py-2.5 md:px-6 md:py-3 text-sm sm:text-base;
}
/* Grid responsivo automático */
.grid-responsive {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4 md:gap-6;
}
/* Espaçamento responsivo */
.space-responsive {
@apply space-y-3 sm:space-y-4 md:space-y-6;
}
/* Modal/Dialog responsivo */
.modal-responsive {
@apply w-full max-w-sm sm:max-w-md md:max-w-lg lg:max-w-xl xl:max-w-2xl 2xl:max-w-4xl;
}
/* Input responsivo */
.input-responsive {
@apply px-3 py-2 sm:py-2.5 md:py-3 text-sm sm:text-base;
}
/* Ocultar em mobile */
.hide-mobile {
@apply hidden sm:block;
}
/* Mostrar apenas em mobile */
.show-mobile {
@apply block sm:hidden;
}
/* Stack em mobile, row em desktop */
.stack-mobile {
@apply flex flex-col sm:flex-row;
}
}
/* Animações */
@keyframes slideIn {
from {

View File

@ -1,19 +1,19 @@
/**
* Supabase Client Configuration
* Usado para processar Magic Links e gerenciar sessões
*/
import { createClient } from "@supabase/supabase-js";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true, // Importante para Magic Link
},
});
/**
* Supabase Client Configuration
* Usado para processar Magic Links e gerenciar sessões
*/
import { createClient } from "@supabase/supabase-js";
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
const SUPABASE_ANON_KEY =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: {
storage: localStorage,
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true, // Importante para Magic Link
},
});

View File

@ -1,11 +1,8 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import "./index.css";
import App from "./App.tsx";
import { AuthProvider } from "./context/AuthContext";
import { queryClient } from "./lib/queryClient";
// Apply accessibility preferences before React mounts to avoid FOUC and ensure persistence across reloads.
// This also helps E2E test detect classes after reload.
@ -45,11 +42,8 @@ import { queryClient } from "./lib/queryClient";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
</AuthProvider>
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
</QueryClientProvider>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>
);

View File

@ -0,0 +1,928 @@
import React, { useState, useEffect, useCallback } from "react";
import {
Calendar,
Clock,
User,
MessageCircle,
HelpCircle,
LogOut,
Home,
Stethoscope,
Video,
MapPin,
CheckCircle,
XCircle,
AlertCircle,
FileText,
} from "lucide-react";
import toast from "react-hot-toast";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { appointmentService, doctorService, reportService } from "../services";
import type { Report } from "../services/reports/types";
import AgendamentoConsulta from "../components/AgendamentoConsulta";
interface Consulta {
_id: string;
pacienteId: string;
medicoId: string;
dataHora: string;
status: "agendada" | "confirmada" | "realizada" | "cancelada" | "faltou";
tipoConsulta: string;
motivoConsulta: string;
observacoes?: string;
resultados?: string;
prescricoes?: string;
proximaConsulta?: string;
medicoNome?: string;
especialidade?: string;
valorConsulta?: number;
}
interface Medico {
id: string;
nome: string;
especialidade: string;
crm: string;
foto?: string;
email?: string;
telefone?: string;
valorConsulta?: number;
}
const AcompanhamentoPaciente: React.FC = () => {
const { user, roles = [], logout } = useAuth();
const navigate = useNavigate();
// State
const [activeTab, setActiveTab] = useState("dashboard");
const [consultas, setConsultas] = useState<Consulta[]>([]);
const [medicos, setMedicos] = useState<Medico[]>([]);
const [loadingMedicos, setLoadingMedicos] = useState(true);
const [selectedMedicoId, setSelectedMedicoId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [especialidadeFiltro, setEspecialidadeFiltro] = useState<string>("");
const [laudos, setLaudos] = useState<Report[]>([]);
const [loadingLaudos, setLoadingLaudos] = useState(false);
const pacienteId = user?.id || "";
const pacienteNome = user?.nome || "Paciente";
useEffect(() => {
// Permite acesso se for paciente OU se roles inclui 'paciente'
const isPaciente = user?.role === "paciente" || roles.includes("paciente");
if (!user || !isPaciente) navigate("/paciente");
}, [user, roles, navigate]);
const fetchConsultas = useCallback(async () => {
if (!pacienteId) return;
setLoading(true);
setLoadingMedicos(true);
try {
// Buscar agendamentos da API
const appointments = await appointmentService.list({
patient_id: pacienteId,
limit: 50,
order: "scheduled_at.desc",
});
// Buscar médicos
const medicosData = await doctorService.list();
const medicosFormatted: Medico[] = medicosData.map((d) => ({
id: d.id,
nome: d.full_name,
especialidade: d.specialty || "",
crm: d.crm,
email: d.email,
telefone: d.phone_mobile || undefined,
}));
setMedicos(medicosFormatted);
setLoadingMedicos(false);
// Map appointments to old Consulta format
const consultasAPI: Consulta[] = appointments.map((apt) => ({
_id: apt.id,
pacienteId: apt.patient_id,
medicoId: apt.doctor_id,
dataHora: apt.scheduled_at || "",
status:
apt.status === "confirmed"
? "confirmada"
: apt.status === "completed"
? "realizada"
: apt.status === "cancelled"
? "cancelada"
: apt.status === "no_show"
? "faltou"
: "agendada",
tipoConsulta: "presencial",
motivoConsulta: apt.notes || "Consulta médica",
observacoes: apt.notes || undefined,
}));
// Set consultas
setConsultas(consultasAPI);
} catch (error) {
setLoadingMedicos(false);
console.error("Erro ao carregar consultas:", error);
toast.error("Erro ao carregar consultas");
setConsultas([]);
} finally {
setLoading(false);
}
}, [pacienteId]);
useEffect(() => {
fetchConsultas();
}, [fetchConsultas]);
// Recarregar consultas quando mudar para a aba de consultas
const fetchLaudos = useCallback(async () => {
if (!pacienteId) return;
setLoadingLaudos(true);
try {
const data = await reportService.list({ patient_id: pacienteId });
setLaudos(data);
} catch (error) {
console.error("Erro ao buscar laudos:", error);
toast.error("Erro ao carregar laudos");
setLaudos([]);
} finally {
setLoadingLaudos(false);
}
}, [pacienteId]);
useEffect(() => {
if (activeTab === "appointments") {
fetchConsultas();
}
}, [activeTab, fetchConsultas]);
useEffect(() => {
if (activeTab === "reports") {
fetchLaudos();
}
}, [activeTab, fetchLaudos]);
const getMedicoNome = (medicoId: string) => {
const medico = medicos.find((m) => m._id === medicoId || m.id === medicoId);
return medico?.nome || "Médico";
};
const getMedicoEspecialidade = (medicoId: string) => {
const medico = medicos.find((m) => m._id === medicoId || m.id === medicoId);
return medico?.especialidade || "Especialidade";
};
const handleRemarcar = () => {
setActiveTab("book");
toast.success("Selecione um novo horário para remarcar sua consulta");
};
const handleCancelar = async (consultaId: string) => {
if (!window.confirm("Tem certeza que deseja cancelar esta consulta?")) {
return;
}
try {
await appointmentService.update(consultaId, {
status: "cancelled",
});
toast.success("Consulta cancelada com sucesso");
fetchConsultas();
} catch (error) {
console.error("Erro ao cancelar consulta:", error);
toast.error("Erro ao cancelar consulta. Tente novamente.");
}
};
const consultasProximas = consultas
.filter((c) => c.status === "agendada" || c.status === "confirmada")
.sort(
(a, b) => new Date(a.dataHora).getTime() - new Date(b.dataHora).getTime()
)
.slice(0, 3);
const consultasPassadas = consultas
.filter((c) => c.status === "realizada")
.sort(
(a, b) => new Date(b.dataHora).getTime() - new Date(a.dataHora).getTime()
)
.slice(0, 5);
const getStatusColor = (status: string) => {
switch (status) {
case "confirmada":
return "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800";
case "agendada":
return "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800";
case "realizada":
return "bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/30 dark:text-gray-300 dark:border-gray-800";
case "cancelada":
case "faltou":
return "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800";
default:
return "bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/30 dark:text-gray-300 dark:border-gray-800";
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case "confirmada":
return "Confirmada";
case "agendada":
return "Agendada";
case "realizada":
return "Concluída";
case "cancelada":
return "Cancelada";
case "faltou":
return "Não Compareceu";
default:
return status;
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "confirmada":
return <CheckCircle className="h-4 w-4" />;
case "agendada":
return <Clock className="h-4 w-4" />;
case "cancelada":
case "faltou":
return <XCircle className="h-4 w-4" />;
default:
return <AlertCircle className="h-4 w-4" />;
}
};
// Menu items
const menuItems = [
{ id: "dashboard", label: "Início", icon: Home },
{ id: "appointments", label: "Minhas Consultas", icon: Calendar },
{ id: "reports", label: "Meus Laudos", icon: FileText },
{ id: "book", label: "Agendar Consulta", icon: Stethoscope },
{ id: "messages", label: "Mensagens", icon: MessageCircle },
{
id: "profile",
label: "Meu Perfil",
icon: User,
isLink: true,
path: "/perfil-paciente",
},
{ id: "help", label: "Ajuda", icon: HelpCircle },
];
// Sidebar
const renderSidebar = () => (
<div className="w-64 h-screen bg-white dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col">
{/* Patient Profile */}
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-gradient-to-br from-blue-700 to-blue-400 flex items-center justify-center text-white font-semibold text-lg">
{pacienteNome
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{pacienteNome}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">Paciente</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4">
<div className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => {
if (item.isLink && item.path) {
navigate(item.path);
} else if (item.id === "help") {
navigate("/ajuda");
} else {
setActiveTab(item.id);
}
}}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
isActive
? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-800"
}`}
>
<Icon className="h-5 w-5" />
{item.label}
</button>
);
})}
</div>
</nav>
{/* Logout */}
<div className="p-4 border-t border-gray-200 dark:border-slate-700">
<button
onClick={() => {
logout();
navigate("/paciente");
}}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
>
<LogOut className="h-5 w-5" />
Sair
</button>
</div>
</div>
);
// Stat Card
const renderStatCard = (
title: string,
value: string | number,
icon: React.ElementType,
description?: string
) => {
const Icon = icon;
return (
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">
{title}
</p>
<Icon className="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-1">
{value}
</div>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
)}
</div>
);
};
// Appointment Card
const renderAppointmentCard = (
consulta: Consulta,
isPast: boolean = false
) => {
// Usar dados da consulta local se disponível, senão buscar pelo ID do médico
const medicoNome = consulta.medicoNome || getMedicoNome(consulta.medicoId);
const especialidade =
consulta.especialidade || getMedicoEspecialidade(consulta.medicoId);
return (
<div
key={consulta._id}
className="flex items-start gap-4 p-4 rounded-lg border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800/50 transition-colors"
>
<div className="h-14 w-14 rounded-full bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center text-white font-semibold">
{medicoNome
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2)}
</div>
<div className="flex-1 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-white">
{medicoNome}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{especialidade}
</p>
</div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium border ${getStatusColor(
consulta.status
)}`}
>
{getStatusIcon(consulta.status)}
{getStatusLabel(consulta.status)}
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
{format(new Date(consulta.dataHora), "dd/MM/yyyy", {
locale: ptBR,
})}
</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span>
{format(new Date(consulta.dataHora), "HH:mm", { locale: ptBR })}
</span>
</div>
<div className="flex items-center gap-1">
{consulta.tipoConsulta === "online" ||
consulta.tipoConsulta === "telemedicina" ? (
<>
<Video className="h-4 w-4" />
<span>Online</span>
</>
) : (
<>
<MapPin className="h-4 w-4" />
<span>Presencial</span>
</>
)}
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Motivo: {consulta.motivoConsulta}
</p>
{!isPast && consulta.status !== "cancelada" && (
<div className="flex gap-2">
{consulta.status === "confirmada" &&
(consulta.tipoConsulta === "online" ||
consulta.tipoConsulta === "telemedicina") && (
<button className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
<Video className="h-4 w-4" />
Entrar na Consulta
</button>
)}
<button
onClick={handleRemarcar}
className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
Remarcar
</button>
<button
onClick={() => handleCancelar(consulta._id)}
className="flex items-center gap-1 px-3 py-1 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500"
>
<XCircle className="h-4 w-4" />
Cancelar
</button>
</div>
)}
</div>
</div>
);
};
// Dashboard Content
const renderDashboard = () => {
const proximaConsulta = consultasProximas[0];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Bem-vindo, {pacienteNome.split(" ")[0]}!
</h1>
<p className="text-gray-600 dark:text-gray-400">
Gerencie suas consultas e cuide da sua saúde
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{renderStatCard(
"Próxima Consulta",
proximaConsulta
? format(new Date(proximaConsulta.dataHora), "dd MMM", {
locale: ptBR,
})
: "Nenhuma",
Calendar,
proximaConsulta
? `${getMedicoEspecialidade(proximaConsulta.medicoId)} - ${format(
new Date(proximaConsulta.dataHora),
"HH:mm"
)}`
: "Agende uma consulta"
)}
{renderStatCard(
"Consultas Agendadas",
consultasProximas.length,
Clock,
"Este mês"
)}
{renderStatCard(
"Médicos Favoritos",
new Set(consultas.map((c) => c.medicoId)).size,
Stethoscope,
"Salvos"
)}
</div>
{/* Próximas Consultas e Ações Rápidas */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Próximas Consultas
</h2>
<button
onClick={() => setActiveTab("appointments")}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-2 py-1"
>
Ver todas
</button>
</div>
</div>
<div className="p-6">
{loading ? (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
</div>
) : consultasProximas.length === 0 ? (
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Nenhuma consulta agendada
</p>
) : (
<div className="space-y-4">
{consultasProximas.map((c) => (
<div
key={c._id}
className="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-slate-700"
>
<div className="h-12 w-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Calendar className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white">
{getMedicoNome(c.medicoId)}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{getMedicoEspecialidade(c.medicoId)}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{format(new Date(c.dataHora), "dd/MM/yyyy - HH:mm", {
locale: ptBR,
})}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Ações Rápidas
</h2>
</div>
<div className="p-6 space-y-2">
<button
onClick={() => setActiveTab("book")}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<Calendar className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<span>Agendar Nova Consulta</span>
</button>
<button
onClick={() => setActiveTab("messages")}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<MessageCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<span>Mensagens</span>
</button>
<button
onClick={() => setActiveTab("profile")}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<User className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<span>Editar Perfil</span>
</button>
<button
onClick={() => navigate("/ajuda")}
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
>
<HelpCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<span>Central de Ajuda</span>
</button>
</div>
</div>
</div>
{/* Dicas de Saúde */}
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Dicas de Saúde
</h2>
</div>
<div className="p-6">
<div className="space-y-3">
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
💧 Hidratação
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Beba pelo menos 2 litros de água por dia para manter seu corpo
hidratado
</p>
</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
🏃 Exercícios
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
30 minutos de atividade física diária ajudam a prevenir
doenças
</p>
</div>
</div>
</div>
</div>
</div>
);
};
// Appointments Content
const renderAppointments = () => (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Minhas Consultas
</h1>
<p className="text-gray-600 dark:text-gray-400">
Visualize e gerencie todas as suas consultas
</p>
</div>
{/* Próximas */}
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Próximas Consultas
</h2>
</div>
<div className="p-6">
{loading ? (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
</div>
) : consultasProximas.length === 0 ? (
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Nenhuma consulta agendada
</p>
) : (
<div className="space-y-4">
{consultasProximas.map((c) => renderAppointmentCard(c))}
</div>
)}
</div>
</div>
{/* Passadas */}
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Histórico
</h2>
</div>
<div className="p-6">
{consultasPassadas.length === 0 ? (
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Nenhuma consulta realizada
</p>
) : (
<div className="space-y-4">
{consultasPassadas.map((c) => renderAppointmentCard(c, true))}
</div>
)}
</div>
</div>
</div>
);
// Book Appointment Content
const renderBookAppointment = () => (
<div className="space-y-6">
<AgendamentoConsulta medicos={medicos} />
</div>
);
// Messages Content
const renderMessages = () => (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Mensagens
</h1>
<p className="text-gray-600 dark:text-gray-400">
Converse com seus médicos
</p>
</div>
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
<p className="text-center py-16 text-gray-600 dark:text-gray-400">
Sistema de mensagens em desenvolvimento
</p>
</div>
</div>
);
// Help Content
const renderHelp = () => (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Central de Ajuda
</h1>
<p className="text-gray-600 dark:text-gray-400">
Como podemos ajudar você?
</p>
</div>
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
<p className="text-center py-16 text-gray-600 dark:text-gray-400">
Central de ajuda em desenvolvimento
</p>
</div>
</div>
);
// Profile Content
const renderProfile = () => (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Meu Perfil
</h1>
<p className="text-gray-600 dark:text-gray-400">
Gerencie suas informações pessoais
</p>
</div>
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
<p className="text-center py-16 text-gray-600 dark:text-gray-400">
Edição de perfil em desenvolvimento
</p>
</div>
</div>
);
const renderReports = () => (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
Meus Laudos Médicos
</h1>
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
{loadingLaudos ? (
<div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Carregando laudos...
</p>
</div>
) : laudos.length === 0 ? (
<div className="p-6">
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
Você ainda não possui laudos médicos.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-slate-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Número
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Exame
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Diagnóstico
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Data
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-slate-700">
{laudos.map((laudo) => (
<tr
key={laudo.id}
className="hover:bg-gray-50 dark:hover:bg-slate-800"
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{laudo.order_number}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
{laudo.exam || "-"}
</td>
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
{laudo.diagnosis || "-"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
laudo.status === "completed"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: laudo.status === "pending"
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
: laudo.status === "cancelled"
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}
>
{laudo.status === "completed"
? "Concluído"
: laudo.status === "pending"
? "Pendente"
: laudo.status === "cancelled"
? "Cancelado"
: "Rascunho"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
{new Date(laudo.created_at).toLocaleDateString("pt-BR")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
const renderContent = () => {
switch (activeTab) {
case "dashboard":
return renderDashboard();
case "appointments":
return renderAppointments();
case "reports":
return renderReports();
case "book":
return renderBookAppointment();
case "messages":
return renderMessages();
case "help":
return renderHelp();
case "profile":
return renderProfile();
default:
return null;
}
};
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Acesso Negado
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Você precisa estar logado para acessar esta página.
</p>
<button
onClick={() => navigate("/paciente")}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors"
>
Fazer Login
</button>
</div>
</div>
);
}
return (
<div className="flex h-screen bg-gray-50 dark:bg-slate-950">
{renderSidebar()}
<main className="flex-1 overflow-y-auto">
<div className="container mx-auto p-8">{renderContent()}</div>
</main>
</div>
);
};
export default AcompanhamentoPaciente;

View File

@ -0,0 +1,491 @@
import React, { useState, useEffect } from "react";
import { Calendar, User, FileText, CheckCircle, LogOut } from "lucide-react";
// import consultaService from "../services/consultaService"; // não utilizado após integração com appointmentService
import { appointmentService } from "../services";
import AvailableSlotsPicker from "../components/agenda/AvailableSlotsPicker";
import { doctorService } from "../services";
import toast from "react-hot-toast";
import { format, addDays } from "date-fns";
import { ptBR } from "date-fns/locale";
import { useNavigate } from "react-router-dom";
interface Medico {
_id: string;
nome: string;
especialidade: string;
valorConsulta: number;
horarioAtendimento: Record<string, string[]>;
}
interface Paciente {
_id: string;
nome: string;
cpf: string;
telefone: string;
email: string;
}
const AgendamentoPaciente: React.FC = () => {
const [medicos, setMedicos] = useState<Medico[]>([]);
const [pacienteLogado, setPacienteLogado] = useState<Paciente | null>(null);
const [loading, setLoading] = useState(false);
const [etapa, setEtapa] = useState(1);
const [agendamento, setAgendamento] = useState({
medicoId: "",
data: "",
horario: "",
tipoConsulta: "primeira-vez",
motivoConsulta: "",
observacoes: "",
});
// Slots são carregados diretamente pelo AvailableSlotsPicker
const navigate = useNavigate();
useEffect(() => {
// Verificar se paciente está logado
const pacienteData = localStorage.getItem("pacienteLogado");
if (!pacienteData) {
console.log(
"[AgendamentoPaciente] Paciente não logado, redirecionando..."
);
navigate("/paciente");
return;
}
try {
const paciente = JSON.parse(pacienteData);
console.log("[AgendamentoPaciente] Paciente logado:", paciente);
setPacienteLogado(paciente);
void fetchMedicos();
} catch (error) {
console.error(
"[AgendamentoPaciente] Erro ao carregar dados do paciente:",
error
);
navigate("/paciente");
}
}, [navigate]);
// As consultas locais agora aparecem na Dashboard (AcompanhamentoPaciente)
const fetchMedicos = async () => {
try {
console.log("[AgendamentoPaciente] Iniciando busca de médicos...");
const doctors = await doctorService.list({ active: true });
console.log("[AgendamentoPaciente] Médicos recebidos:", doctors);
const mapped: Medico[] = doctors.map((m: any) => ({
_id: m.id,
nome: m.full_name,
especialidade: m.specialty || "",
valorConsulta: 0,
horarioAtendimento: {},
}));
console.log("[AgendamentoPaciente] Médicos mapeados:", mapped);
setMedicos(mapped);
if (mapped.length === 0) {
toast.error(
"Nenhum médico ativo encontrado. Por favor, cadastre médicos primeiro."
);
}
} catch (error) {
console.error("[AgendamentoPaciente] Erro ao carregar médicos:", error);
toast.error("Erro ao carregar lista de médicos");
}
};
// Horários disponíveis agora são resolvidos no componente de slots
const handleMedicoChange = (medicoId: string) => {
setAgendamento((prev) => ({ ...prev, medicoId, data: "", horario: "" }));
};
const handleDataChange = (data: string) => {
setAgendamento((prev) => ({ ...prev, data, horario: "" }));
};
const confirmarAgendamento = async () => {
if (!pacienteLogado) return;
try {
setLoading(true);
// NOTE: Removed remote CPF validation to avoid false negatives
// NOTE: remote CEP validation removed to avoid false negatives
const dataHora = new Date(
`${agendamento.data}T${agendamento.horario}:00.000Z`
);
await appointmentService.create({
patient_id: pacienteLogado._id,
doctor_id: agendamento.medicoId,
scheduled_at: dataHora.toISOString(),
notes: agendamento.motivoConsulta,
});
toast.success("Consulta agendada com sucesso!");
setEtapa(4); // Etapa de confirmação
} catch (error) {
console.error("Erro ao agendar consulta:", error);
toast.error("Erro ao agendar consulta. Tente novamente.");
} finally {
setLoading(false);
}
};
const resetarAgendamento = () => {
setAgendamento({
medicoId: "",
data: "",
horario: "",
tipoConsulta: "primeira-vez",
motivoConsulta: "",
observacoes: "",
});
setEtapa(1);
};
// Removido: criação/visualização local aqui. Use a Dashboard para ver.
const logout = () => {
localStorage.removeItem("pacienteLogado");
navigate("/paciente");
};
const proximosSeteDias = () => {
const dias = [];
for (let i = 1; i <= 7; i++) {
const data = addDays(new Date(), i);
dias.push({
valor: format(data, "yyyy-MM-dd"),
label: format(data, "EEEE, dd/MM", { locale: ptBR }),
});
}
return dias;
};
const medicoSelecionado = medicos.find((m) => m._id === agendamento.medicoId);
if (!pacienteLogado) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (etapa === 4) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Consulta Agendada com Sucesso!
</h2>
<div className="bg-gray-50 rounded-lg p-6 mb-6 text-left">
<h3 className="font-semibold mb-3">Detalhes do Agendamento:</h3>
<div className="space-y-2">
<p>
<strong>Paciente:</strong> {pacienteLogado.nome}
</p>
<p>
<strong>Médico:</strong> {medicoSelecionado?.nome}
</p>
<p>
<strong>Especialidade:</strong>{" "}
{medicoSelecionado?.especialidade}
</p>
<p>
<strong>Data:</strong>{" "}
{format(new Date(agendamento.data), "dd/MM/yyyy", {
locale: ptBR,
})}
</p>
<p>
<strong>Horário:</strong> {agendamento.horario}
</p>
<p>
<strong>Tipo:</strong> {agendamento.tipoConsulta}
</p>
{agendamento.motivoConsulta && (
<p>
<strong>Motivo:</strong> {agendamento.motivoConsulta}
</p>
)}
</div>
</div>
<button onClick={resetarAgendamento} className="btn-primary">
Fazer Novo Agendamento
</button>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
{/* Header com informações do paciente */}
<div className="bg-gradient-to-r from-blue-700 to-blue-500 rounded-xl p-6 mb-8 text-white shadow">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">
Bem-vindo(a), {pacienteLogado.nome}!
</h1>
<p className="opacity-90">Agende sua consulta médica</p>
</div>
<button
onClick={logout}
className="flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/70"
>
<LogOut className="w-4 h-4" />
<span>Sair</span>
</button>
</div>
</div>
{/* As consultas locais serão exibidas na Dashboard do paciente */}
{/* Indicador de Etapas */}
<div className="flex items-center justify-center mb-8">
{[1, 2, 3].map((numero) => (
<React.Fragment key={numero}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
etapa >= numero
? "bg-blue-600 text-white"
: "bg-gray-300 text-gray-600"
}`}
>
{numero}
</div>
{numero < 3 && (
<div
className={`w-16 h-1 ${
etapa > numero ? "bg-blue-600" : "bg-gray-300"
}`}
/>
)}
</React.Fragment>
))}
</div>
<div className="bg-white rounded-xl shadow border border-gray-200 p-6">
{/* Etapa 1: Seleção de Médico */}
{etapa === 1 && (
<div className="space-y-6">
<h2 className="text-xl font-semibold flex items-center">
<User className="w-5 h-5 mr-2" />
Selecione o Médico
</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Médico/Especialidade
</label>
<select
value={agendamento.medicoId}
onChange={(e) => handleMedicoChange(e.target.value)}
className="form-input"
required
>
<option value="">Selecione um médico</option>
{medicos.map((medico) => (
<option key={medico._id} value={medico._id}>
{medico.nome} - {medico.especialidade} (R${" "}
{medico.valorConsulta})
</option>
))}
</select>
</div>
<div className="flex justify-end">
<button
onClick={() => setEtapa(2)}
disabled={!agendamento.medicoId}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
Próximo
</button>
</div>
</div>
)}
{/* Etapa 2: Seleção de Data e Horário */}
{etapa === 2 && (
<div className="space-y-6">
<h2 className="text-xl font-semibold flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Selecione Data e Horário
</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Data da Consulta
</label>
<select
value={agendamento.data}
onChange={(e) => handleDataChange(e.target.value)}
className="form-input"
required
>
<option value="">Selecione uma data</option>
{proximosSeteDias().map((dia) => (
<option key={dia.valor} value={dia.valor}>
{dia.label}
</option>
))}
</select>
</div>
{agendamento.data && agendamento.medicoId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horários Disponíveis
</label>
<AvailableSlotsPicker
doctorId={agendamento.medicoId}
date={agendamento.data}
onSelect={(t) =>
setAgendamento((prev) => ({ ...prev, horario: t }))
}
/>
</div>
)}
<div className="flex justify-between">
<button
onClick={() => setEtapa(1)}
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
>
Voltar
</button>
<button
onClick={() => setEtapa(3)}
disabled={!agendamento.horario}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
>
Próximo
</button>
</div>
</div>
)}
{/* Etapa 3: Informações Adicionais */}
{etapa === 3 && (
<div className="space-y-6">
<h2 className="text-xl font-semibold flex items-center">
<FileText className="w-5 h-5 mr-2" />
Informações da Consulta
</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de Consulta
</label>
<select
value={agendamento.tipoConsulta}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
tipoConsulta: e.target.value,
}))
}
className="form-input"
>
<option value="primeira-vez">Primeira Consulta</option>
<option value="retorno">Retorno</option>
<option value="urgencia">Urgência</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motivo da Consulta
</label>
<textarea
value={agendamento.motivoConsulta}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
motivoConsulta: e.target.value,
}))
}
className="form-input"
rows={3}
placeholder="Descreva brevemente o motivo da consulta"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Observações (opcional)
</label>
<textarea
value={agendamento.observacoes}
onChange={(e) =>
setAgendamento((prev) => ({
...prev,
observacoes: e.target.value,
}))
}
className="form-input"
rows={2}
placeholder="Informações adicionais relevantes"
/>
</div>
{/* Resumo do Agendamento */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h3 className="font-semibold mb-3">Resumo do Agendamento:</h3>
<div className="space-y-1 text-sm">
<p>
<strong>Paciente:</strong> {pacienteLogado.nome}
</p>
<p>
<strong>Médico:</strong> {medicoSelecionado?.nome}
</p>
<p>
<strong>Data:</strong>{" "}
{format(new Date(agendamento.data), "dd/MM/yyyy", {
locale: ptBR,
})}
</p>
<p>
<strong>Horário:</strong> {agendamento.horario}
</p>
<p>
<strong>Valor:</strong> R$ {medicoSelecionado?.valorConsulta}
</p>
</div>
</div>
<div className="flex justify-between">
<button
onClick={() => setEtapa(2)}
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
>
Voltar
</button>
<button
onClick={confirmarAgendamento}
disabled={loading}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
>
{loading ? "Agendando..." : "Confirmar Agendamento"}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default AgendamentoPaciente;

View File

@ -0,0 +1,128 @@
/**
* Página de Callback do Magic Link
* Processa o token do magic link e autentica o usuário
*/
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase } from "../lib/supabase";
import { Loader2, CheckCircle, XCircle } from "lucide-react";
import { useAuth } from "../hooks/useAuth";
import toast from "react-hot-toast";
export default function AuthCallback() {
const navigate = useNavigate();
const { loginComEmailSenha } = useAuth();
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading"
);
const [message, setMessage] = useState("Processando autenticação...");
useEffect(() => {
const handleCallback = async () => {
try {
console.log("[AuthCallback] Iniciando processamento do magic link");
// Supabase automaticamente processa os query params
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) {
console.error("[AuthCallback] Erro ao obter sessão:", error);
throw error;
}
if (!session) {
throw new Error(
"Nenhuma sessão encontrada. O link pode ter expirado."
);
}
console.log("[AuthCallback] Sessão obtida:", {
user: session.user.email,
role: session.user.role,
});
// Fazer login no contexto da aplicação
const loginOk = await loginComEmailSenha(session.user.email!, "");
if (!loginOk) {
throw new Error("Erro ao processar login no sistema");
}
setStatus("success");
setMessage("Autenticado com sucesso! Redirecionando...");
toast.success("Login realizado com sucesso!");
// Redirecionar baseado no role
setTimeout(() => {
const userRole = session.user.user_metadata?.role || "paciente";
switch (userRole) {
case "medico":
navigate("/painel-medico");
break;
case "secretaria":
navigate("/painel-secretaria");
break;
case "paciente":
default:
navigate("/acompanhamento");
break;
}
}, 1500);
} catch (err: any) {
console.error("[AuthCallback] Erro:", err);
setStatus("error");
setMessage(err.message || "Erro ao processar autenticação");
toast.error(err.message || "Erro na autenticação");
}
};
handleCallback();
}, [navigate, loginComEmailSenha]);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-white dark:from-gray-900 dark:to-gray-950 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 text-center">
{status === "loading" && (
<>
<Loader2 className="w-16 h-16 text-blue-600 dark:text-blue-400 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Autenticando
</h1>
<p className="text-gray-600 dark:text-gray-400">{message}</p>
</>
)}
{status === "success" && (
<>
<CheckCircle className="w-16 h-16 text-green-600 dark:text-green-400 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Sucesso!
</h1>
<p className="text-gray-600 dark:text-gray-400">{message}</p>
</>
)}
{status === "error" && (
<>
<XCircle className="w-16 h-16 text-red-600 dark:text-red-400 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Erro na Autenticação
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">{message}</p>
<button
onClick={() => navigate("/")}
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
Voltar ao Início
</button>
</>
)}
</div>
</div>
);
}

View File

@ -1,481 +1,481 @@
import { Avatar } from "../components/ui/Avatar";
import { AvatarUpload } from "../components/ui/AvatarUpload";
import { useState } from "react";
export default function AvatarShowcase() {
const [testAvatarUrl, setTestAvatarUrl] = useState<string | undefined>(
undefined
);
type AvatarColor =
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
const colors: AvatarColor[] = [
"blue",
"green",
"purple",
"orange",
"pink",
"teal",
"indigo",
"red",
];
const sampleUsers = [
{
name: "Ana Silva",
email: "ana@example.com",
avatar_url: "https://via.placeholder.com/150/0000FF/FFFFFF?text=AS",
},
{ name: "Bruno Costa", email: "bruno@example.com", avatar_url: null },
{ name: "Carla Santos", email: "carla@example.com", id: "sample-id-1" },
{ name: "Diego Ferreira", email: "diego@example.com" },
];
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-6xl mx-auto space-y-12">
{/* Header */}
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Avatar Showcase
</h1>
<p className="text-gray-600">
Demonstração completa do sistema de avatares
</p>
</div>
{/* Seção 1: Tamanhos */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Tamanhos Disponíveis
</h2>
<div className="flex items-end gap-8">
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Extra Small"
size="xs"
/>
<span className="text-xs text-gray-500">xs (24px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Small"
size="sm"
/>
<span className="text-xs text-gray-500">sm (32px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Medium"
size="md"
/>
<span className="text-xs text-gray-500">md (40px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Large"
size="lg"
/>
<span className="text-xs text-gray-500">lg (48px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Extra Large"
size="xl"
/>
<span className="text-xs text-gray-500">xl (64px)</span>
</div>
</div>
</section>
{/* Seção 2: Cores */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Cores de Iniciais
</h2>
<div className="grid grid-cols-4 md:grid-cols-8 gap-4">
{colors.map((color) => (
<div key={color} className="flex flex-col items-center gap-2">
<Avatar
name={color.charAt(0).toUpperCase() + color.slice(1)}
size="lg"
color={color}
/>
<span className="text-xs text-gray-500 capitalize">
{color}
</span>
</div>
))}
</div>
</section>
{/* Seção 3: Com e sem imagem */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Fallback de Iniciais
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{sampleUsers.map((user, index) => {
const userColors: AvatarColor[] = [
"blue",
"green",
"purple",
"orange",
];
return (
<div key={index} className="flex flex-col items-center gap-3">
<Avatar
src={user.avatar_url || user.id || undefined}
name={user.name}
size="xl"
color={userColors[index % 4]}
border
/>
<div className="text-center">
<p className="font-medium text-gray-900">{user.name}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
</div>
);
})}
</div>
</section>
{/* Seção 4: Diferentes formatos de src */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Formatos de Entrada (src)
</h2>
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src="https://via.placeholder.com/150/4F46E5/FFFFFF?text=URL"
name="URL String"
size="lg"
/>
<div>
<p className="font-medium">String URL</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src="https://..."
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src={{
avatar_url:
"https://via.placeholder.com/150/10B981/FFFFFF?text=OBJ",
}}
name="Object URL"
size="lg"
/>
<div>
<p className="font-medium">Objeto com avatar_url</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{avatar_url: "..."}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src={{
profile: {
avatar_url:
"https://via.placeholder.com/150/F59E0B/FFFFFF?text=PRF",
},
}}
name="Nested Profile"
size="lg"
/>
<div>
<p className="font-medium">Objeto aninhado com profile</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{profile: {avatar_url: "..."}}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar src={{ id: "user-123" }} name="From ID" size="lg" />
<div>
<p className="font-medium">
Gera URL do Supabase a partir do ID
</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{id: "user-123"}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar name="Sem Imagem" size="lg" color="purple" />
<div>
<p className="font-medium">Sem src (apenas iniciais)</p>
<code className="text-xs bg-white px-2 py-1 rounded">
name="Sem Imagem"
</code>
</div>
</div>
</div>
</section>
{/* Seção 5: AvatarUpload Component */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
AvatarUpload (Editável)
</h2>
<div className="grid md:grid-cols-2 gap-8">
{/* Modo editável */}
<div>
<h3 className="font-medium mb-4 text-gray-700">Modo Editável</h3>
<div className="flex flex-col items-center gap-4 p-6 bg-gray-50 rounded-lg">
<AvatarUpload
userId="demo-user-1"
currentAvatarUrl={testAvatarUrl}
name="Teste Usuário"
color="blue"
size="xl"
editable={true}
onAvatarUpdate={(url) => setTestAvatarUrl(url || undefined)}
/>
<p className="text-sm text-gray-600 text-center">
Clique no ícone da câmera para editar
</p>
</div>
</div>
{/* Modo somente leitura */}
<div>
<h3 className="font-medium mb-4 text-gray-700">
Modo Somente Leitura
</h3>
<div className="flex flex-col items-center gap-4 p-6 bg-gray-50 rounded-lg">
<AvatarUpload
userId="demo-user-2"
currentAvatarUrl="https://via.placeholder.com/150/EF4444/FFFFFF?text=RO"
name="Leitura Apenas"
color="red"
size="xl"
editable={false}
/>
<p className="text-sm text-gray-600 text-center">
Sem botão de edição
</p>
</div>
</div>
</div>
</section>
{/* Seção 6: Casos de uso reais */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Casos de Uso Reais
</h2>
{/* Lista de pacientes */}
<div className="mb-6">
<h3 className="font-medium mb-3 text-gray-700">
Lista de Pacientes
</h3>
<div className="space-y-2">
{[
{ name: "Maria Oliveira", cpf: "123.456.789-00", age: 45 },
{ name: "João Santos", cpf: "987.654.321-00", age: 32 },
{ name: "Ana Costa", cpf: "456.789.123-00", age: 28 },
].map((patient, i) => {
const patientColors: AvatarColor[] = [
"blue",
"green",
"purple",
];
return (
<div
key={i}
className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg transition"
>
<Avatar
name={patient.name}
size="md"
color={patientColors[i]}
/>
<div className="flex-1">
<p className="font-medium text-gray-900">
{patient.name}
</p>
<p className="text-sm text-gray-500">
CPF: {patient.cpf}
</p>
</div>
<span className="text-sm text-gray-500">
{patient.age} anos
</span>
</div>
);
})}
</div>
</div>
{/* Lista de consultas */}
<div>
<h3 className="font-medium mb-3 text-gray-700">
Lista de Consultas
</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Paciente
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Médico
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Data
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{[
{
patient: "Carlos Silva",
doctor: "Dr. Pedro Lima",
specialty: "Cardiologia",
date: "20/12/2024",
status: "Confirmada",
},
{
patient: "Lucia Mendes",
doctor: "Dra. Julia Ramos",
specialty: "Pediatria",
date: "21/12/2024",
status: "Pendente",
},
].map((appointment, i) => (
<tr key={i} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-3">
<Avatar
name={appointment.patient}
size="sm"
color="blue"
/>
<span className="text-sm text-gray-900">
{appointment.patient}
</span>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-3">
<Avatar
name={appointment.doctor}
size="sm"
color="green"
/>
<div>
<p className="text-sm text-gray-900">
{appointment.doctor}
</p>
<p className="text-xs text-gray-500">
{appointment.specialty}
</p>
</div>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{appointment.date}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded-full ${
appointment.status === "Confirmada"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{appointment.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{/* Seção 7: Código de exemplo */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Exemplos de Código
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2 text-gray-700">Avatar Simples</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<Avatar
src="https://example.com/avatar.jpg"
name="João Silva"
size="md"
color="blue"
/>`}
</pre>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">
Avatar com Objeto
</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<Avatar
src={patient} // {id: "...", avatar_url: "..."}
name={patient.full_name}
size="lg"
color="purple"
border
/>`}
</pre>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Avatar Upload</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<AvatarUpload
userId={user.id}
currentAvatarUrl={user.avatar_url}
name={user.name}
color="blue"
size="xl"
editable={true}
onAvatarUpdate={(url) => {
// Atualizar estado
setAvatarUrl(url);
}}
/>`}
</pre>
</div>
</div>
</section>
</div>
</div>
);
}
import { Avatar } from "../components/ui/Avatar";
import { AvatarUpload } from "../components/ui/AvatarUpload";
import { useState } from "react";
export default function AvatarShowcase() {
const [testAvatarUrl, setTestAvatarUrl] = useState<string | undefined>(
undefined
);
type AvatarColor =
| "blue"
| "green"
| "purple"
| "orange"
| "pink"
| "teal"
| "indigo"
| "red";
const colors: AvatarColor[] = [
"blue",
"green",
"purple",
"orange",
"pink",
"teal",
"indigo",
"red",
];
const sampleUsers = [
{
name: "Ana Silva",
email: "ana@example.com",
avatar_url: "https://via.placeholder.com/150/0000FF/FFFFFF?text=AS",
},
{ name: "Bruno Costa", email: "bruno@example.com", avatar_url: null },
{ name: "Carla Santos", email: "carla@example.com", id: "sample-id-1" },
{ name: "Diego Ferreira", email: "diego@example.com" },
];
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-6xl mx-auto space-y-12">
{/* Header */}
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Avatar Showcase
</h1>
<p className="text-gray-600">
Demonstração completa do sistema de avatares
</p>
</div>
{/* Seção 1: Tamanhos */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Tamanhos Disponíveis
</h2>
<div className="flex items-end gap-8">
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Extra Small"
size="xs"
/>
<span className="text-xs text-gray-500">xs (24px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Small"
size="sm"
/>
<span className="text-xs text-gray-500">sm (32px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Medium"
size="md"
/>
<span className="text-xs text-gray-500">md (40px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Large"
size="lg"
/>
<span className="text-xs text-gray-500">lg (48px)</span>
</div>
<div className="flex flex-col items-center gap-2">
<Avatar
src="https://via.placeholder.com/150"
name="Extra Large"
size="xl"
/>
<span className="text-xs text-gray-500">xl (64px)</span>
</div>
</div>
</section>
{/* Seção 2: Cores */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Cores de Iniciais
</h2>
<div className="grid grid-cols-4 md:grid-cols-8 gap-4">
{colors.map((color) => (
<div key={color} className="flex flex-col items-center gap-2">
<Avatar
name={color.charAt(0).toUpperCase() + color.slice(1)}
size="lg"
color={color}
/>
<span className="text-xs text-gray-500 capitalize">
{color}
</span>
</div>
))}
</div>
</section>
{/* Seção 3: Com e sem imagem */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Fallback de Iniciais
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{sampleUsers.map((user, index) => {
const userColors: AvatarColor[] = [
"blue",
"green",
"purple",
"orange",
];
return (
<div key={index} className="flex flex-col items-center gap-3">
<Avatar
src={user.avatar_url || user.id || undefined}
name={user.name}
size="xl"
color={userColors[index % 4]}
border
/>
<div className="text-center">
<p className="font-medium text-gray-900">{user.name}</p>
<p className="text-xs text-gray-500">{user.email}</p>
</div>
</div>
);
})}
</div>
</section>
{/* Seção 4: Diferentes formatos de src */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Formatos de Entrada (src)
</h2>
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src="https://via.placeholder.com/150/4F46E5/FFFFFF?text=URL"
name="URL String"
size="lg"
/>
<div>
<p className="font-medium">String URL</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src="https://..."
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src={{
avatar_url:
"https://via.placeholder.com/150/10B981/FFFFFF?text=OBJ",
}}
name="Object URL"
size="lg"
/>
<div>
<p className="font-medium">Objeto com avatar_url</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{avatar_url: "..."}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar
src={{
profile: {
avatar_url:
"https://via.placeholder.com/150/F59E0B/FFFFFF?text=PRF",
},
}}
name="Nested Profile"
size="lg"
/>
<div>
<p className="font-medium">Objeto aninhado com profile</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{profile: {avatar_url: "..."}}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar src={{ id: "user-123" }} name="From ID" size="lg" />
<div>
<p className="font-medium">
Gera URL do Supabase a partir do ID
</p>
<code className="text-xs bg-white px-2 py-1 rounded">
src={`{id: "user-123"}`}
</code>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<Avatar name="Sem Imagem" size="lg" color="purple" />
<div>
<p className="font-medium">Sem src (apenas iniciais)</p>
<code className="text-xs bg-white px-2 py-1 rounded">
name="Sem Imagem"
</code>
</div>
</div>
</div>
</section>
{/* Seção 5: AvatarUpload Component */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
AvatarUpload (Editável)
</h2>
<div className="grid md:grid-cols-2 gap-8">
{/* Modo editável */}
<div>
<h3 className="font-medium mb-4 text-gray-700">Modo Editável</h3>
<div className="flex flex-col items-center gap-4 p-6 bg-gray-50 rounded-lg">
<AvatarUpload
userId="demo-user-1"
currentAvatarUrl={testAvatarUrl}
name="Teste Usuário"
color="blue"
size="xl"
editable={true}
onAvatarUpdate={(url) => setTestAvatarUrl(url || undefined)}
/>
<p className="text-sm text-gray-600 text-center">
Clique no ícone da câmera para editar
</p>
</div>
</div>
{/* Modo somente leitura */}
<div>
<h3 className="font-medium mb-4 text-gray-700">
Modo Somente Leitura
</h3>
<div className="flex flex-col items-center gap-4 p-6 bg-gray-50 rounded-lg">
<AvatarUpload
userId="demo-user-2"
currentAvatarUrl="https://via.placeholder.com/150/EF4444/FFFFFF?text=RO"
name="Leitura Apenas"
color="red"
size="xl"
editable={false}
/>
<p className="text-sm text-gray-600 text-center">
Sem botão de edição
</p>
</div>
</div>
</div>
</section>
{/* Seção 6: Casos de uso reais */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Casos de Uso Reais
</h2>
{/* Lista de pacientes */}
<div className="mb-6">
<h3 className="font-medium mb-3 text-gray-700">
Lista de Pacientes
</h3>
<div className="space-y-2">
{[
{ name: "Maria Oliveira", cpf: "123.456.789-00", age: 45 },
{ name: "João Santos", cpf: "987.654.321-00", age: 32 },
{ name: "Ana Costa", cpf: "456.789.123-00", age: 28 },
].map((patient, i) => {
const patientColors: AvatarColor[] = [
"blue",
"green",
"purple",
];
return (
<div
key={i}
className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg transition"
>
<Avatar
name={patient.name}
size="md"
color={patientColors[i]}
/>
<div className="flex-1">
<p className="font-medium text-gray-900">
{patient.name}
</p>
<p className="text-sm text-gray-500">
CPF: {patient.cpf}
</p>
</div>
<span className="text-sm text-gray-500">
{patient.age} anos
</span>
</div>
);
})}
</div>
</div>
{/* Lista de consultas */}
<div>
<h3 className="font-medium mb-3 text-gray-700">
Lista de Consultas
</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Paciente
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Médico
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Data
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{[
{
patient: "Carlos Silva",
doctor: "Dr. Pedro Lima",
specialty: "Cardiologia",
date: "20/12/2024",
status: "Confirmada",
},
{
patient: "Lucia Mendes",
doctor: "Dra. Julia Ramos",
specialty: "Pediatria",
date: "21/12/2024",
status: "Pendente",
},
].map((appointment, i) => (
<tr key={i} className="hover:bg-gray-50">
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-3">
<Avatar
name={appointment.patient}
size="sm"
color="blue"
/>
<span className="text-sm text-gray-900">
{appointment.patient}
</span>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-3">
<Avatar
name={appointment.doctor}
size="sm"
color="green"
/>
<div>
<p className="text-sm text-gray-900">
{appointment.doctor}
</p>
<p className="text-xs text-gray-500">
{appointment.specialty}
</p>
</div>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{appointment.date}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded-full ${
appointment.status === "Confirmada"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{appointment.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{/* Seção 7: Código de exemplo */}
<section className="bg-white rounded-lg shadow p-6">
<h2 className="text-2xl font-semibold mb-6 text-gray-800">
Exemplos de Código
</h2>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2 text-gray-700">Avatar Simples</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<Avatar
src="https://example.com/avatar.jpg"
name="João Silva"
size="md"
color="blue"
/>`}
</pre>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">
Avatar com Objeto
</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<Avatar
src={patient} // {id: "...", avatar_url: "..."}
name={patient.full_name}
size="lg"
color="purple"
border
/>`}
</pre>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Avatar Upload</h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
{`<AvatarUpload
userId={user.id}
currentAvatarUrl={user.avatar_url}
name={user.name}
color="blue"
size="xl"
editable={true}
onAvatarUpdate={(url) => {
// Atualizar estado
setAvatarUrl(url);
}}
/>`}
</pre>
</div>
</div>
</section>
</div>
</div>
);
}

View File

@ -14,7 +14,7 @@ import {
Headphones,
ArrowLeft,
} from "lucide-react";
import Chatbot from "../components/Chatbot";
import { Chatbot } from "../components/Chatbot";
interface FAQ {
question: string;
@ -406,7 +406,7 @@ const CentralAjuda: React.FC = () => {
</div>
</div>
{/* Chatbot Widget */}
{/* Chatbot */}
<Chatbot />
</div>
);

View File

@ -14,7 +14,7 @@ import {
Headphones,
ArrowLeft,
} from "lucide-react";
import Chatbot from "../components/Chatbot";
import { Chatbot } from "../components/Chatbot";
interface FAQ {
question: string;
@ -410,7 +410,7 @@ const CentralAjudaMedico: React.FC = () => {
</div>
</div>
{/* Chatbot Widget */}
{/* Chatbot */}
<Chatbot />
</div>
);

View File

@ -1,16 +1,16 @@
import React from "react";
import { useAuth } from "../hooks/useAuth";
import CentralAjuda from "./CentralAjuda";
import CentralAjudaMedico from "./CentralAjudaMedico";
const CentralAjudaRouter: React.FC = () => {
const { user } = useAuth();
// Se for médico, gestor ou admin, mostra a central de ajuda para médicos
const isMedico =
user?.role && ["medico", "gestor", "admin"].includes(user.role);
return isMedico ? <CentralAjudaMedico /> : <CentralAjuda />;
};
export default CentralAjudaRouter;
import React from "react";
import { useAuth } from "../hooks/useAuth";
import CentralAjuda from "./CentralAjuda";
import CentralAjudaMedico from "./CentralAjudaMedico";
const CentralAjudaRouter: React.FC = () => {
const { user } = useAuth();
// Se for médico, gestor ou admin, mostra a central de ajuda para médicos
const isMedico =
user?.role && ["medico", "gestor", "admin"].includes(user.role);
return isMedico ? <CentralAjudaMedico /> : <CentralAjuda />;
};
export default CentralAjudaRouter;

View File

@ -1,55 +1,55 @@
import React, { useEffect } from "react";
const ClearCache: React.FC = () => {
useEffect(() => {
console.log("🧹 Limpando TUDO...");
// Limpar localStorage
localStorage.clear();
console.log("✅ localStorage limpo");
// Limpar sessionStorage
sessionStorage.clear();
console.log("✅ sessionStorage limpo");
// Limpar cookies
document.cookie.split(";").forEach((c) => {
document.cookie = c
.replace(/^ +/, "")
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
console.log("✅ Cookies limpos");
// Aguardar 1 segundo e redirecionar
setTimeout(() => {
console.log("🔄 Redirecionando para home...");
window.location.href = "/";
}, 1000);
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="text-center p-8 bg-white rounded-xl shadow-2xl max-w-md">
<div className="mb-6">
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mx-auto"></div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
🧹 Limpando Cache
</h1>
<p className="text-gray-600 mb-4">
Removendo todas as sessões e dados armazenados...
</p>
<div className="space-y-2 text-sm text-gray-500">
<p> localStorage</p>
<p> sessionStorage</p>
<p> Cookies</p>
</div>
<p className="mt-6 text-xs text-gray-400">
Você será redirecionado em instantes...
</p>
</div>
</div>
);
};
export default ClearCache;
import React, { useEffect } from "react";
const ClearCache: React.FC = () => {
useEffect(() => {
console.log("🧹 Limpando TUDO...");
// Limpar localStorage
localStorage.clear();
console.log("✅ localStorage limpo");
// Limpar sessionStorage
sessionStorage.clear();
console.log("✅ sessionStorage limpo");
// Limpar cookies
document.cookie.split(";").forEach((c) => {
document.cookie = c
.replace(/^ +/, "")
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
});
console.log("✅ Cookies limpos");
// Aguardar 1 segundo e redirecionar
setTimeout(() => {
console.log("🔄 Redirecionando para home...");
window.location.href = "/";
}, 1000);
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="text-center p-8 bg-white rounded-xl shadow-2xl max-w-md">
<div className="mb-6">
<div className="animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mx-auto"></div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
🧹 Limpando Cache
</h1>
<p className="text-gray-600 mb-4">
Removendo todas as sessões e dados armazenados...
</p>
<div className="space-y-2 text-sm text-gray-500">
<p> localStorage</p>
<p> sessionStorage</p>
<p> Cookies</p>
</div>
<p className="mt-6 text-xs text-gray-400">
Você será redirecionado em instantes...
</p>
</div>
</div>
);
};
export default ClearCache;

View File

@ -15,9 +15,8 @@ import toast from "react-hot-toast";
import adminUserService, {
FullUserInfo,
UpdateUserData,
UserRoleRecord,
UserRole,
} from "../services/adminUserService";
import { userService } from "../services";
const GerenciarUsuarios: React.FC = () => {
const [usuarios, setUsuarios] = useState<FullUserInfo[]>([]);
@ -27,19 +26,8 @@ const GerenciarUsuarios: React.FC = () => {
const [editForm, setEditForm] = useState<UpdateUserData>({});
const [managingRolesUser, setManagingRolesUser] =
useState<FullUserInfo | null>(null);
const [userRoles, setUserRoles] = useState<UserRoleRecord[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [newRole, setNewRole] = useState<string>("");
const [showCreateModal, setShowCreateModal] = useState(false);
const [createForm, setCreateForm] = useState({
email: "",
password: "",
full_name: "",
phone_mobile: "",
cpf: "",
role: "",
create_patient_record: false,
usePassword: true,
});
useEffect(() => {
carregarUsuarios();
@ -134,121 +122,6 @@ const GerenciarUsuarios: React.FC = () => {
}
};
const handleCreateUser = async () => {
console.log(
"[GerenciarUsuarios] 🚀 Iniciando criação de usuário:",
createForm
);
// Validações básicas
if (!createForm.email || !createForm.full_name || !createForm.role) {
toast.error("Preencha os campos obrigatórios: Email, Nome e Role");
return;
}
if (createForm.usePassword && !createForm.password) {
toast.error("Informe a senha");
return;
}
if (
createForm.create_patient_record &&
(!createForm.cpf || !createForm.phone_mobile)
) {
toast.error(
"CPF e telefone são obrigatórios para criar registro de paciente"
);
return;
}
try {
if (createForm.usePassword) {
// Criar usuário com senha usando userService
console.log("[GerenciarUsuarios] 📤 Criando usuário com senha...");
const result = await userService.createUserWithPassword({
email: createForm.email,
password: createForm.password,
full_name: createForm.full_name,
phone: createForm.phone_mobile || undefined,
role: createForm.role,
create_patient_record: createForm.create_patient_record,
cpf: createForm.cpf || undefined,
phone_mobile: createForm.phone_mobile || undefined,
});
console.log("[GerenciarUsuarios] ✅ Usuário criado:", result);
if (result.success) {
toast.success("Usuário criado com sucesso!");
setShowCreateModal(false);
setCreateForm({
email: "",
password: "",
full_name: "",
phone_mobile: "",
cpf: "",
role: "",
create_patient_record: false,
usePassword: true,
});
carregarUsuarios();
} else {
toast.error(result.message || "Erro ao criar usuário");
}
} else {
// Criar usuário sem senha (Magic Link)
console.log("[GerenciarUsuarios] <20> Criando usuário com Magic Link...");
const result = await userService.createUser(
{
email: createForm.email,
full_name: createForm.full_name,
phone: createForm.phone_mobile || undefined,
role: createForm.role,
},
false // isPublicRegistration = false (admin criando)
);
console.log("[GerenciarUsuarios] ✅ Usuário criado:", result);
toast.success(
"Usuário criado com sucesso! Magic Link enviado por email."
);
setShowCreateModal(false);
setCreateForm({
email: "",
password: "",
full_name: "",
phone_mobile: "",
cpf: "",
role: "",
create_patient_record: false,
usePassword: true,
});
carregarUsuarios();
}
} catch (error) {
console.error("[GerenciarUsuarios] ❌ Erro ao criar usuário:", error);
// Tratamento de erros mais específico
let errorMessage = "Erro ao criar usuário";
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as any;
if (axiosError.response?.data?.message) {
errorMessage = axiosError.response.data.message;
} else if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
} else if (axiosError.message) {
errorMessage = axiosError.message;
}
}
toast.error(errorMessage);
}
};
const usuariosFiltrados = usuarios.filter((user) => {
const searchLower = searchTerm.toLowerCase();
return (
@ -277,25 +150,16 @@ const GerenciarUsuarios: React.FC = () => {
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"
>
<Plus className="w-4 h-4" />
Criar Usuário
</button>
<button
onClick={carregarUsuarios}
disabled={loading}
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
>
<RefreshCw
className={`w-4 h-4 ${loading ? "animate-spin" : ""}`}
/>
Atualizar
</button>
</div>
<button
onClick={carregarUsuarios}
disabled={loading}
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
>
<RefreshCw
className={`w-4 h-4 ${loading ? "animate-spin" : ""}`}
/>
Atualizar
</button>
</div>
{/* Search Bar */}
@ -521,7 +385,7 @@ const GerenciarUsuarios: React.FC = () => {
onChange={(e) =>
setEditForm({ ...editForm, full_name: e.target.value })
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-indigo-600/40"
/>
</div>
@ -535,7 +399,7 @@ const GerenciarUsuarios: React.FC = () => {
onChange={(e) =>
setEditForm({ ...editForm, email: e.target.value })
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-indigo-600/40"
/>
</div>
@ -549,7 +413,7 @@ const GerenciarUsuarios: React.FC = () => {
onChange={(e) =>
setEditForm({ ...editForm, phone: e.target.value })
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-indigo-600/40"
/>
</div>
</div>
@ -619,8 +483,7 @@ const GerenciarUsuarios: React.FC = () => {
<button
onClick={async () => {
const result = await adminUserService.removeUserRole(
managingRolesUser.user.id,
userRole.role
userRole.id
);
if (result.success) {
toast.success("Role removido com sucesso!");
@ -723,216 +586,6 @@ const GerenciarUsuarios: React.FC = () => {
</div>
</div>
)}
{/* Modal Criar Usuário */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold text-gray-900">
Criar Novo Usuário
</h2>
<button
onClick={() => setShowCreateModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
{/* Método de Autenticação */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Método de Autenticação
</label>
<div className="flex gap-4">
<label className="flex items-center">
<input
type="radio"
checked={createForm.usePassword}
onChange={() =>
setCreateForm({ ...createForm, usePassword: true })
}
className="mr-2"
/>
Email e Senha
</label>
<label className="flex items-center">
<input
type="radio"
checked={!createForm.usePassword}
onChange={() =>
setCreateForm({ ...createForm, usePassword: false })
}
className="mr-2"
/>
Magic Link (sem senha)
</label>
</div>
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
value={createForm.email}
onChange={(e) =>
setCreateForm({ ...createForm, email: e.target.value })
}
className="form-input"
placeholder="usuario@exemplo.com"
/>
</div>
{/* Senha (somente se usePassword) */}
{createForm.usePassword && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Senha *
</label>
<input
type="password"
value={createForm.password}
onChange={(e) =>
setCreateForm({
...createForm,
password: e.target.value,
})
}
className="form-input"
placeholder="Mínimo 6 caracteres"
/>
</div>
)}
{/* Nome Completo */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nome Completo *
</label>
<input
type="text"
value={createForm.full_name}
onChange={(e) =>
setCreateForm({
...createForm,
full_name: e.target.value,
})
}
className="form-input"
placeholder="João da Silva"
/>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role *
</label>
<select
value={createForm.role}
onChange={(e) =>
setCreateForm({ ...createForm, role: e.target.value })
}
className="form-input"
>
<option value="">Selecione...</option>
<option value="admin">Admin</option>
<option value="gestor">Gestor</option>
<option value="medico">Médico</option>
<option value="secretaria">Secretária</option>
<option value="paciente">Paciente</option>
</select>
</div>
{/* Telefone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefone
</label>
<input
type="text"
value={createForm.phone_mobile}
onChange={(e) =>
setCreateForm({
...createForm,
phone_mobile: e.target.value,
})
}
className="form-input"
placeholder="(11) 99999-9999"
/>
</div>
{/* Criar Registro de Paciente */}
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={createForm.create_patient_record}
onChange={(e) =>
setCreateForm({
...createForm,
create_patient_record: e.target.checked,
})
}
className="mr-2"
/>
<span className="text-sm font-medium text-gray-700">
Criar registro na tabela de pacientes
</span>
</label>
</div>
{/* CPF (obrigatório se create_patient_record) */}
{createForm.create_patient_record && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
CPF *
</label>
<input
type="text"
value={createForm.cpf}
onChange={(e) =>
setCreateForm({
...createForm,
cpf: e.target.value.replace(/\D/g, ""),
})
}
className="form-input"
placeholder="12345678901"
maxLength={11}
/>
<p className="text-xs text-gray-500 mt-1">
Apenas números, 11 dígitos
</p>
</div>
)}
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Cancelar
</button>
<button
onClick={handleCreateUser}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Criar Usuário
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -3,10 +3,8 @@ import { Calendar, Users, UserCheck, Clock, ArrowRight } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { patientService, doctorService, appointmentService } from "../services";
import { MetricCard } from "../components/MetricCard";
import { HeroBanner } from "../components/HeroBanner";
import { i18n } from "../i18n";
import { useAuth } from "../hooks/useAuth";
import RecoveryRedirect from "../components/auth/RecoveryRedirect";
const Home: React.FC = () => {
const [stats, setStats] = useState({
@ -21,21 +19,6 @@ const Home: React.FC = () => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
// Verificar se há parâmetros de magic link e redirecionar para AuthCallback
useEffect(() => {
const hash = window.location.hash;
if (
hash &&
(hash.includes("access_token") || hash.includes("type=magiclink"))
) {
console.log(
"[Home] Detectado magic link, redirecionando para /auth/callback"
);
navigate(`/auth/callback${hash}`, { replace: true });
return;
}
}, [navigate]);
// Limpar cache se houver parâmetro ?clear=true
useEffect(() => {
if (searchParams.get("clear") === "true") {
@ -112,19 +95,69 @@ const Home: React.FC = () => {
};
return (
<div
className="space-y-6 sm:space-y-8 px-4 sm:px-6 lg:px-8"
id="main-content"
>
{/* Componente invisível que detecta tokens de recuperação e redireciona */}
<RecoveryRedirect />
<div className="space-y-8" id="main-content">
{/* Hero Section */}
<div className="relative text-center py-8 md:py-12 lg:py-16 bg-gradient-to-r from-blue-800 via-blue-600 to-blue-500 text-white rounded-xl shadow-lg overflow-hidden">
{/* Decorative Pattern */}
<div className="absolute inset-0 opacity-10">
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern
id="grid"
width="40"
height="40"
patternUnits="userSpaceOnUse"
>
<circle cx="20" cy="20" r="1" fill="white" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
</div>
{/* Hero Section com Background Rotativo */}
<HeroBanner />
<div className="relative z-10 px-4 max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-3 md:mb-4">
{i18n.t("home.hero.title")}
</h1>
<p className="text-base md:text-lg lg:text-xl opacity-95 mb-6 md:mb-8 max-w-2xl mx-auto">
{i18n.t("home.hero.subtitle")}
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row gap-3 md:gap-4 justify-center items-center">
<button
onClick={() => handleCTA("Agendar consulta", "/paciente")}
className="group w-full sm:w-auto inline-flex items-center justify-center px-6 md:px-8 py-3 md:py-4 bg-white text-blue-700 rounded-lg font-semibold hover:bg-blue-50 hover:shadow-xl hover:scale-105 active:scale-95 transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/80 focus-visible:ring-offset-blue-600"
aria-label={i18n.t(
"home.actionCards.scheduleAppointment.ctaAriaLabel"
)}
>
<Calendar
className="w-5 h-5 mr-2 group-hover:scale-110 transition-transform"
aria-hidden="true"
/>
{i18n.t("home.hero.ctaPrimary")}
<ArrowRight
className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform"
aria-hidden="true"
/>
</button>
<button
onClick={() => handleCTA("Ver próximas consultas", "/consultas")}
className="group w-full sm:w-auto inline-flex items-center justify-center px-6 md:px-8 py-3 md:py-4 bg-blue-700 text-white rounded-lg font-semibold hover:bg-blue-800 hover:shadow-xl hover:scale-105 active:scale-95 border-2 border-white/20 transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/80 focus-visible:ring-offset-blue-600"
aria-label="Ver lista de próximas consultas"
>
<Clock className="w-5 h-5 mr-2" aria-hidden="true" />
{i18n.t("home.hero.ctaSecondary")}
</button>
</div>
</div>
</div>
{/* Métricas */}
<div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6"
role="region"
aria-label="Estatísticas do sistema"
>
@ -187,7 +220,7 @@ const Home: React.FC = () => {
{/* Cards de Ação */}
<div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 md:gap-6"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6"
role="region"
aria-label="Ações rápidas"
>
@ -203,7 +236,7 @@ const Home: React.FC = () => {
ctaAriaLabel={i18n.t(
"home.actionCards.scheduleAppointment.ctaAriaLabel"
)}
onAction={() => handleCTA("Card Agendar", "/login")}
onAction={() => handleCTA("Card Agendar", "/paciente")}
/>
<ActionCard
@ -214,7 +247,7 @@ const Home: React.FC = () => {
description={i18n.t("home.actionCards.doctorPanel.description")}
ctaLabel={i18n.t("home.actionCards.doctorPanel.cta")}
ctaAriaLabel={i18n.t("home.actionCards.doctorPanel.ctaAriaLabel")}
onAction={() => handleCTA("Card Médico", "/login")}
onAction={() => handleCTA("Card Médico", "/login-medico")}
/>
<ActionCard
@ -227,7 +260,7 @@ const Home: React.FC = () => {
ctaAriaLabel={i18n.t(
"home.actionCards.patientManagement.ctaAriaLabel"
)}
onAction={() => handleCTA("Card Secretaria", "/login")}
onAction={() => handleCTA("Card Secretaria", "/login-secretaria")}
/>
</div>
</div>
@ -256,29 +289,24 @@ const ActionCard: React.FC<ActionCardProps> = ({
onAction,
}) => {
return (
<div className="bg-white rounded-lg shadow-md p-4 sm:p-5 md:p-6 hover:shadow-xl transition-all duration-200 group border border-gray-100 focus-within:ring-2 focus-within:ring-blue-500/50 focus-within:ring-offset-2">
<div className="bg-white rounded-lg shadow-md p-5 md:p-6 hover:shadow-xl transition-all duration-200 group border border-gray-100 focus-within:ring-2 focus-within:ring-blue-500/50 focus-within:ring-offset-2">
<div
className={`w-10 h-10 sm:w-12 sm:h-12 ${iconBgColor} rounded-lg flex items-center justify-center mb-3 sm:mb-4 group-hover:scale-110 transition-transform`}
className={`w-12 h-12 ${iconBgColor} rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
>
<Icon
className={`w-5 h-5 sm:w-6 sm:h-6 text-white`}
aria-hidden="true"
/>
<Icon className={`w-6 h-6 text-white`} aria-hidden="true" />
</div>
<h3 className="text-base sm:text-lg font-semibold mb-2 text-gray-900">
{title}
</h3>
<p className="text-xs sm:text-sm text-gray-600 mb-3 sm:mb-4 leading-relaxed">
<h3 className="text-lg font-semibold mb-2 text-gray-900">{title}</h3>
<p className="text-sm text-gray-600 mb-4 leading-relaxed">
{description}
</p>
<button
onClick={onAction}
className="w-full inline-flex items-center justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 hover:scale-105 active:scale-95 text-white rounded-lg text-sm sm:text-base font-medium transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/80 group-hover:shadow-lg"
className="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 hover:scale-105 active:scale-95 text-white rounded-lg font-medium transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/80 group-hover:shadow-lg"
aria-label={ctaAriaLabel}
>
{ctaLabel}
<ArrowRight
className="w-3.5 h-3.5 sm:w-4 sm:h-4 ml-2 group-hover:translate-x-1 transition-transform"
className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform"
aria-hidden="true"
/>
</button>

View File

@ -0,0 +1,113 @@
import React, { useEffect, useState } from "react";
import AvatarInitials from "../components/AvatarInitials";
import { Stethoscope, Mail, Phone, AlertTriangle } from "lucide-react";
import { doctorService } from "../services";
const ListaMedicos: React.FC = () => {
const [medicos, setMedicos] = useState<MedicoDetalhado[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const resp = await doctorService.listarMedicos({ status: "ativo" });
if (!resp.success) {
if (!cancelled) {
setError(resp.error || "Falha ao carregar médicos");
setMedicos([]);
}
return;
}
const list = resp.data?.data || [];
if (!list.length) {
console.warn(
'[ListaMedicos] Nenhum médico retornado. Verifique se a tabela "doctors" possui registros e se as variáveis VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY apontam para produção.'
);
}
if (!cancelled) setMedicos(list);
} catch (e) {
console.error("Erro inesperado ao listar médicos", e);
if (!cancelled) {
setError("Erro inesperado ao listar médicos");
setMedicos([]);
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Stethoscope className="w-6 h-6 text-indigo-600" />
<h2 className="text-2xl font-bold">Médicos Cadastrados</h2>
</div>
{loading && <div className="text-gray-500">Carregando médicos...</div>}
{!loading && error && (
<div className="flex items-center gap-2 text-red-700 bg-red-50 border border-red-200 p-3 rounded-lg">
<AlertTriangle className="w-5 h-5" />
<span>{error}</span>
</div>
)}
{!loading && !error && medicos.length === 0 && (
<div className="text-gray-500">Nenhum médico cadastrado.</div>
)}
{!loading && !error && medicos.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{medicos.map((medico) => (
<article
key={medico.id}
className="bg-white rounded-xl shadow border border-gray-200 p-6 flex flex-col gap-3 hover:shadow-md transition-shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
tabIndex={0}
>
<header className="flex items-center gap-2">
{medico.avatar_url ? (
<img
src={medico.avatar_url}
alt={medico.nome}
className="h-10 w-10 rounded-full object-cover border"
/>
) : (
<AvatarInitials name={medico.nome} size={40} />
)}
<Stethoscope className="w-5 h-5 text-indigo-600" />
<h3 className="font-semibold text-lg text-gray-900">
{medico.nome}
</h3>
</header>
<div className="text-sm text-gray-700">
<strong>Especialidade:</strong> {medico.especialidade}
</div>
<div className="text-sm text-gray-700">
<strong>CRM:</strong> {medico.crm}
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4" /> {medico.email}
</div>
{medico.telefone && (
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4" /> {medico.telefone}
</div>
)}
</article>
))}
</div>
)}
</div>
);
};
export default ListaMedicos;

View File

@ -0,0 +1,115 @@
import React, { useEffect, useState } from "react";
import AvatarInitials from "../components/AvatarInitials";
// Funções utilitárias para formatação
function formatCPF(cpf?: string) {
if (!cpf) return "Não informado";
const v = cpf.replace(/\D/g, "").slice(0, 11);
if (v.length !== 11) return cpf;
return v.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4");
}
function formatPhone(phone?: string) {
if (!phone) return "Não informado";
let v = phone.replace(/\D/g, "");
if (v.length < 10) return phone;
v = v.slice(0, 13);
v = "+55 " + v;
v = v.replace(/(\+55 )(\d{2})(\d)/, "$1$2 $3");
v = v.replace(/(\+55 \d{2} )(\d{5})(\d{1,4})/, "$1$2-$3");
return v;
}
function formatEmail(email?: string) {
if (!email) return "Não informado";
return email.trim().toLowerCase();
}
import { Users, Mail, Phone } from "lucide-react";
import { patientService } from "../services/index";
import type { Patient } from "../services/patients/types";
type Paciente = Patient;
const ListaPacientes: React.FC = () => {
const [pacientes, setPacientes] = useState<Paciente[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPacientes = async () => {
setLoading(true);
setError(null);
try {
const items = await patientService.list();
if (!items.length) {
console.warn(
'[ListaPacientes] Nenhum paciente retornado. Verifique se a tabela "patients" possui registros.'
);
}
setPacientes(items as Paciente[]);
} catch (e) {
console.error("Erro ao listar pacientes", e);
setError("Falha ao carregar pacientes");
setPacientes([]);
} finally {
setLoading(false);
}
};
fetchPacientes();
}, []);
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
<Users className="w-6 h-6 text-blue-600" /> Pacientes Cadastrados
</h2>
{loading && <div className="text-gray-500">Carregando pacientes...</div>}
{!loading && error && (
<div className="text-red-600 bg-red-50 border border-red-200 p-3 rounded">
{error}
</div>
)}
{!loading && !error && pacientes.length === 0 && (
<div className="text-gray-500">Nenhum paciente cadastrado.</div>
)}
{!loading && !error && pacientes.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pacientes.map((paciente, idx) => (
<div
key={paciente.id}
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
}`}
tabIndex={0}
>
<div className="flex items-center gap-2 mb-2">
<AvatarInitials name={paciente.full_name} size={40} />
<Users className="w-5 h-5 text-blue-600" />
<span className="font-semibold text-lg">
{paciente.full_name}
</span>
</div>
<div className="text-sm text-gray-700">
<strong>CPF:</strong> {formatCPF(paciente.cpf)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4" />{" "}
{formatPhone(paciente.phone_mobile)}
</div>
<div className="text-xs text-gray-500">
Nascimento:{" "}
{paciente.birth_date
? new Date(paciente.birth_date).toLocaleDateString()
: "Não informado"}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default ListaPacientes;

View File

@ -0,0 +1,61 @@
import React, { useEffect, useState } from "react";
import { UserPlus, Mail, Phone } from "lucide-react";
interface Secretaria {
nome: string;
email: string;
cpf: string;
telefone: string;
criadoEm: string;
}
const ListaSecretarias: React.FC = () => {
const [secretarias, setSecretarias] = useState<Secretaria[]>([]);
useEffect(() => {
const lista = JSON.parse(localStorage.getItem("secretarias") || "[]");
setSecretarias(lista);
}, []);
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
<UserPlus className="w-6 h-6 text-green-600" /> Secretárias Cadastradas
</h2>
{secretarias.length === 0 ? (
<div className="text-gray-500">Nenhuma secretária cadastrada.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{secretarias.map((sec, idx) => (
<div
key={idx}
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50 ${
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
}`}
tabIndex={0}
>
<div className="flex items-center gap-2 mb-2">
<UserPlus className="w-5 h-5 text-green-600" />
<span className="font-semibold text-lg">{sec.nome}</span>
</div>
<div className="text-sm text-gray-700">
<strong>CPF:</strong> {sec.cpf}
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4" /> {sec.email}
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4" /> {sec.telefone}
</div>
<div className="text-xs text-gray-500">
Cadastrada em: {new Date(sec.criadoEm).toLocaleString()}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default ListaSecretarias;

View File

@ -1,9 +1,9 @@
import React, { useState } from "react";
import { Mail, Lock, Stethoscope, Eye, EyeOff } from "lucide-react";
import { Mail, Lock, Stethoscope } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { authService, userService } from "../services";
import { authService } from "../services";
const LoginMedico: React.FC = () => {
const [formData, setFormData] = useState({
@ -11,7 +11,6 @@ const LoginMedico: React.FC = () => {
senha: "",
});
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const { loginComEmailSenha } = useAuth();
@ -23,61 +22,21 @@ const LoginMedico: React.FC = () => {
try {
console.log("[LoginMedico] Fazendo login com email:", formData.email);
// Fazer login via API Supabase
const loginResponse = await authService.login({
email: formData.email,
password: formData.senha,
});
console.log("[LoginMedico] Login bem-sucedido!", loginResponse);
// Buscar informações completas do usuário (profile + roles)
const userInfo = await userService.getUserInfo();
console.log("[LoginMedico] UserInfo obtido:", userInfo);
const userName =
userInfo.profile?.full_name ||
loginResponse.user.email?.split("@")[0] ||
"Médico";
const roles = userInfo.roles || [];
// Validar se tem permissão (admin, gestor ou medico)
const isAdmin = roles.includes("admin");
const isGestor = roles.includes("gestor");
const isMedico = roles.includes("medico");
if (!isAdmin && !isGestor && !isMedico) {
toast.error("Você não tem permissão para acessar esta área");
await authService.logout();
setLoading(false);
return;
}
// Fazer login no contexto
const ok = await loginComEmailSenha(formData.email, formData.senha);
if (ok) {
console.log(
"[LoginMedico] Login bem-sucedido! Navegando para /painel-medico"
);
toast.success(`Bem-vindo, ${userName}!`);
toast.success("Login realizado com sucesso!");
navigate("/painel-medico");
} else {
console.error("[LoginMedico] loginComEmailSenha retornou false");
toast.error("Erro ao processar login");
toast.error("Credenciais inválidas ou usuário sem permissão");
}
} catch (error: unknown) {
} catch (error) {
console.error("[LoginMedico] Erro no login:", error);
const err = error as {
response?: { data?: { error_description?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error_description ||
err?.response?.data?.message ||
err?.message ||
"Erro ao fazer login. Verifique suas credenciais.";
toast.error(errorMessage);
toast.error("Erro ao fazer login. Verifique suas credenciais.");
} finally {
setLoading(false);
}
@ -138,28 +97,16 @@ const LoginMedico: React.FC = () => {
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="med_password"
type={showPassword ? "text" : "password"}
type="password"
value={formData.senha}
onChange={(e) =>
setFormData((prev) => ({ ...prev, senha: e.target.value }))
}
className="form-input pl-10 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="Sua senha"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<div className="text-right mt-2">
<button
@ -170,15 +117,16 @@ const LoginMedico: React.FC = () => {
return;
}
try {
await authService.requestPasswordReset(formData.email);
toast.success(
"Email de recuperação enviado! Verifique sua caixa de entrada."
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/medico/painel"
);
toast.success("Link de acesso enviado para seu email!");
} catch {
toast.error("Erro ao enviar email de recuperação");
toast.error("Erro ao enviar link");
}
}}
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline transition-colors"
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 hover:underline transition-colors"
>
Esqueceu a senha?
</button>
@ -215,10 +163,10 @@ const LoginMedico: React.FC = () => {
}
setLoading(true);
try {
// Salvar contexto para redirecionamento correto após magic link
localStorage.setItem("magic_link_redirect", "/painel-medico");
await authService.sendMagicLink(formData.email);
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/medico/painel"
);
toast.success(
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
{ duration: 6000 }

View File

@ -1,9 +1,9 @@
import React, { useState } from "react";
import { User, Mail, Lock, Eye, EyeOff } from "lucide-react";
import { User, Mail, Lock } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { authService, patientService, userService } from "../services";
import { authService, patientService } from "../services";
const LoginPaciente: React.FC = () => {
const [formData, setFormData] = useState({
@ -12,7 +12,6 @@ const LoginPaciente: React.FC = () => {
});
const [loading, setLoading] = useState(false);
const [showCadastro, setShowCadastro] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [cadastroData, setCadastroData] = useState({
nome: "",
email: "",
@ -23,7 +22,7 @@ const LoginPaciente: React.FC = () => {
const navigate = useNavigate();
const { loginPaciente } = useAuth();
const { loginComEmailSenha } = useAuth();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
@ -32,75 +31,19 @@ const LoginPaciente: React.FC = () => {
try {
console.log("[LoginPaciente] Fazendo login com email:", formData.email);
// Fazer login via API Supabase
const loginResponse = await authService.login({
email: formData.email,
password: formData.senha,
});
console.log("[LoginPaciente] Login bem-sucedido!", loginResponse);
// Buscar informações completas do usuário (profile + roles)
let userName = loginResponse.user.email?.split("@")[0] || "Paciente";
try {
const userInfo = await userService.getUserInfo();
console.log("[LoginPaciente] UserInfo obtido:", userInfo);
// Pegar o nome do profile
if (userInfo.profile?.full_name) {
userName = userInfo.profile.full_name;
}
} catch {
console.warn(
"[LoginPaciente] Não foi possível obter user-info, usando dados básicos"
);
}
// Tentar buscar dados do paciente da tabela patients
const pacientes = await patientService.list({ email: formData.email });
const paciente = pacientes && pacientes.length > 0 ? pacientes[0] : null;
console.log("[LoginPaciente] Paciente encontrado:", paciente);
// Usar nome do paciente se disponível, senão usar do profile
const finalName = paciente?.full_name || userName;
// IMPORTANTE: Usar sempre loginResponse.user.id (auth user_id do Supabase)
// Este é o ID correto para vincular ao Storage e outras tabelas
console.log("[LoginPaciente] IDs:", {
authUserId: loginResponse.user.id,
patientId: paciente?.id,
patientUserId: paciente?.user_id,
usingId: loginResponse.user.id,
});
const ok = await loginPaciente({
id: loginResponse.user.id, // ✅ Sempre usar o auth user_id
nome: finalName,
email: loginResponse.user.email || formData.email,
});
const ok = await loginComEmailSenha(formData.email, formData.senha);
if (ok) {
console.log("[LoginPaciente] Navegando para /acompanhamento");
toast.success(`Bem-vindo, ${finalName}!`);
console.log("[LoginPaciente] Login bem-sucedido! Navegando para /acompanhamento");
toast.success("Login realizado com sucesso!");
navigate("/acompanhamento");
} else {
console.error("[LoginPaciente] loginPaciente retornou false");
toast.error("Erro ao processar login");
console.error("[LoginPaciente] loginComEmailSenha retornou false");
toast.error("Credenciais inválidas ou usuário sem permissão");
}
} catch (error: unknown) {
} catch (error) {
console.error("[LoginPaciente] Erro no login:", error);
const err = error as {
response?: { data?: { error_description?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error_description ||
err?.response?.data?.message ||
err?.message ||
"Erro ao fazer login. Verifique suas credenciais.";
toast.error(errorMessage);
toast.error("Erro ao fazer login. Verifique suas credenciais.");
} finally {
setLoading(false);
}
@ -169,20 +112,16 @@ const LoginPaciente: React.FC = () => {
});
setShowCadastro(false);
} catch (error: unknown) {
const err = error as {
response?: { data?: { error?: string; message?: string } };
message?: string;
};
} catch (error: any) {
console.error("[LoginPaciente] Erro ao cadastrar:", {
error,
response: err?.response,
data: err?.response?.data,
response: error?.response,
data: error?.response?.data,
});
const errorMessage =
err?.response?.data?.error ||
err?.response?.data?.message ||
err?.message ||
error?.response?.data?.error ||
error?.response?.data?.message ||
error?.message ||
"Erro ao realizar cadastro";
toast.error(errorMessage);
} finally {
@ -254,7 +193,7 @@ const LoginPaciente: React.FC = () => {
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="login_password"
type={showPassword ? "text" : "password"}
type="password"
value={formData.senha}
onChange={(e) =>
setFormData((prev) => ({
@ -262,23 +201,11 @@ const LoginPaciente: React.FC = () => {
senha: e.target.value,
}))
}
className="form-input pl-10 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="Sua senha"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<div className="text-right mt-2">
<button
@ -289,25 +216,37 @@ const LoginPaciente: React.FC = () => {
return;
}
try {
await authService.requestPasswordReset(formData.email);
toast.success(
"Email de recuperação enviado! Verifique sua caixa de entrada."
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/paciente/agendamento"
);
toast.success("Link de acesso enviado para seu email!");
} catch {
toast.error("Erro ao enviar email de recuperação");
toast.error("Erro ao enviar link");
}
}}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline transition-colors"
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors"
>
Esqueceu a senha?
</button>
</div>
</div>
{/** Botão original (remoto) comentado a pedido **/}
{/**
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-blue-600 to-cyan-500 dark:from-blue-700 dark:to-cyan-600 text-white py-3 rounded-lg font-semibold hover:from-blue-700 hover:to-cyan-600 dark:hover:from-blue-800 dark:hover:to-cyan-700 transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none shadow-md"
className="w-full bg-gradient-to-r from-blue-700 to-blue-400 text-white py-3 px-4 rounded-lg font-medium hover:from-blue-800 hover:to-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{loading ? "Entrando..." : "Entrar"}
</button>
**/}
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-blue-700 to-blue-400 text-white py-3 px-4 rounded-lg font-medium hover:from-blue-800 hover:to-blue-500 hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
{loading ? "Entrando..." : "Entrar"}
</button>
@ -334,20 +273,16 @@ const LoginPaciente: React.FC = () => {
}
setLoading(true);
try {
// Salvar contexto para redirecionamento correto após magic link
localStorage.setItem(
"magic_link_redirect",
"/acompanhamento"
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/paciente/agendamento"
);
await authService.sendMagicLink(formData.email);
toast.success(
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
{ duration: 6000 }
);
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Erro ao enviar link");
} catch (error: any) {
toast.error(error?.message || "Erro ao enviar link");
} finally {
setLoading(false);
}

View File

@ -1,9 +1,9 @@
import React, { useState } from "react";
import { Mail, Lock, Clipboard, Eye, EyeOff } from "lucide-react";
import { Mail, Lock, Clipboard } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { authService, userService } from "../services";
import { authService } from "../services";
const LoginSecretaria: React.FC = () => {
const [formData, setFormData] = useState({
@ -11,7 +11,6 @@ const LoginSecretaria: React.FC = () => {
senha: "",
});
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const { loginComEmailSenha } = useAuth();
@ -23,73 +22,21 @@ const LoginSecretaria: React.FC = () => {
try {
console.log("[LoginSecretaria] Fazendo login com email:", formData.email);
// Fazer login via API Supabase
const loginResponse = await authService.login({
email: formData.email,
password: formData.senha,
});
console.log("[LoginSecretaria] Login bem-sucedido!", loginResponse);
// Buscar informações completas do usuário (profile + roles)
const userInfo = await userService.getUserInfo();
console.log("[LoginSecretaria] UserInfo obtido:", userInfo);
const userName =
userInfo.profile?.full_name ||
loginResponse.user.email?.split("@")[0] ||
"Secretária";
const roles = userInfo.roles || [];
// Validar se tem permissão (admin, gestor ou secretaria)
// Secretária pode ser paciente também, mas não médica
const isAdmin = roles.includes("admin");
const isGestor = roles.includes("gestor");
const isSecretaria = roles.includes("secretaria");
const isMedico = roles.includes("medico");
if (!isAdmin && !isGestor && !isSecretaria) {
toast.error("Você não tem permissão para acessar esta área");
await authService.logout();
setLoading(false);
return;
}
// Secretária não pode ser médica (exceto se for admin/gestor)
if (isSecretaria && isMedico && !isAdmin && !isGestor) {
toast.error(
"Usuário com múltiplas funções incompatíveis. Entre em contato com o suporte."
);
await authService.logout();
setLoading(false);
return;
}
// Fazer login no contexto
const ok = await loginComEmailSenha(formData.email, formData.senha);
if (ok) {
console.log(
"[LoginSecretaria] Login bem-sucedido! Navegando para /painel-secretaria"
);
toast.success(`Bem-vinda, ${userName}!`);
toast.success("Login realizado com sucesso!");
navigate("/painel-secretaria");
} else {
console.error("[LoginSecretaria] loginComEmailSenha retornou false");
toast.error("Erro ao processar login");
toast.error("Credenciais inválidas ou usuário sem permissão");
}
} catch (error: unknown) {
} catch (error) {
console.error("[LoginSecretaria] Erro no login:", error);
const err = error as {
response?: { data?: { error_description?: string; message?: string } };
message?: string;
};
const errorMessage =
err?.response?.data?.error_description ||
err?.response?.data?.message ||
err?.message ||
"Erro ao fazer login. Verifique suas credenciais.";
toast.error(errorMessage);
toast.error("Erro ao fazer login. Verifique suas credenciais.");
} finally {
setLoading(false);
}
@ -150,28 +97,16 @@ const LoginSecretaria: React.FC = () => {
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="sec_password"
type={showPassword ? "text" : "password"}
type="password"
value={formData.senha}
onChange={(e) =>
setFormData((prev) => ({ ...prev, senha: e.target.value }))
}
className="form-input pl-10 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
placeholder="Sua senha"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<div className="text-right mt-2">
<button
@ -182,10 +117,13 @@ const LoginSecretaria: React.FC = () => {
return;
}
try {
await authService.requestPasswordReset(formData.email);
toast.success("Email de recuperação enviado!");
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/secretaria/painel"
);
toast.success("Link de acesso enviado para seu email!");
} catch {
toast.error("Erro ao enviar email de recuperação");
toast.error("Erro ao enviar link");
}
}}
className="text-sm text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline transition-colors"
@ -225,13 +163,10 @@ const LoginSecretaria: React.FC = () => {
}
setLoading(true);
try {
// Salvar contexto para redirecionamento correto após magic link
localStorage.setItem(
"magic_link_redirect",
"/painel-secretaria"
await authService.sendMagicLink(
formData.email,
"https://mediconnectbrasil.netlify.app/secretaria/painel"
);
await authService.sendMagicLink(formData.email);
toast.success(
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
{ duration: 6000 }

View File

@ -37,7 +37,7 @@ type FullUserInfo = UserInfo;
type TabType = "pacientes" | "usuarios" | "medicos";
const PainelAdmin: React.FC = () => {
const { roles: authUserRoles, user } = useAuth();
const { roles: authUserRoles } = useAuth();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabType>("pacientes");
const [loading, setLoading] = useState(false);
@ -87,10 +87,8 @@ const PainelAdmin: React.FC = () => {
phone: "",
role: "user",
});
const [userPassword, setUserPassword] = useState("");
const [userCpf, setUserCpf] = useState("");
const [userPhoneMobile, setUserPhoneMobile] = useState("");
const [createPatientRecord, setCreatePatientRecord] = useState(false);
const [userPassword, setUserPassword] = useState(""); // Senha opcional
const [usePassword, setUsePassword] = useState(false); // Toggle para criar com senha
// Estados para dialog de confirmação
const [confirmDialog, setConfirmDialog] = useState<{
@ -255,77 +253,51 @@ const PainelAdmin: React.FC = () => {
setLoading(true);
try {
// Validação: CPF é obrigatório
if (!userCpf || getOnlyNumbers(userCpf).length !== 11) {
toast.error("CPF é obrigatório e deve ter 11 dígitos");
setLoading(false);
return;
// Determina redirect_url baseado no role
let redirectUrl = "https://mediconnectbrasil.netlify.app/";
if (formUser.role === "medico") {
redirectUrl = "https://mediconnectbrasil.netlify.app/medico/painel";
} else if (formUser.role === "paciente") {
redirectUrl =
"https://mediconnectbrasil.netlify.app/paciente/agendamento";
} else if (formUser.role === "secretaria") {
redirectUrl = "https://mediconnectbrasil.netlify.app/secretaria/painel";
} else if (formUser.role === "admin" || formUser.role === "gestor") {
redirectUrl = "https://mediconnectbrasil.netlify.app/admin/painel";
}
// Validação: Senha é obrigatória
if (!userPassword || userPassword.length < 6) {
toast.error("Senha é obrigatória e deve ter no mínimo 6 caracteres");
setLoading(false);
return;
// Criar com senha OU magic link
if (usePassword && userPassword.trim()) {
// Criar com senha
await userService.createUserWithPassword({
email: formUser.email,
password: userPassword,
full_name: formUser.full_name,
phone: formUser.phone,
role: formUser.role,
});
toast.success(
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
);
} else {
// Criar com magic link (padrão)
await userService.createUser(
{ ...formUser, redirect_url: redirectUrl },
false
);
toast.success(
`Usuário ${formUser.full_name} criado com sucesso! Magic link enviado para o email.`
);
}
// Formatar CPF para o formato esperado pela API (XXX.XXX.XXX-XX)
const formattedCpf = formatCPF(userCpf);
// Formatar telefone celular se fornecido
const formattedPhoneMobile = userPhoneMobile
? formatPhone(userPhoneMobile)
: "";
// Criar usuário com senha (método obrigatório com CPF)
await userService.createUserWithPassword({
email: formUser.email.trim(),
password: userPassword,
full_name: formUser.full_name.trim(),
phone: formUser.phone || undefined,
phone_mobile: formattedPhoneMobile || undefined,
cpf: formattedCpf,
role: formUser.role,
create_patient_record: createPatientRecord,
});
toast.success(
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
);
setShowUserModal(false);
resetFormUser();
setUserPassword("");
setUsePassword(false);
loadUsuarios();
} catch (error: unknown) {
} catch (error) {
console.error("Erro ao criar usuário:", error);
// Mostrar mensagem de erro detalhada
const errorMessage =
(
error as {
response?: { data?: { message?: string; error?: string } };
message?: string;
}
)?.response?.data?.message ||
(
error as {
response?: { data?: { message?: string; error?: string } };
message?: string;
}
)?.response?.data?.error ||
(error as { message?: string })?.message ||
"Erro ao criar usuário";
if (
errorMessage.includes("already") ||
errorMessage.includes("exists") ||
errorMessage.includes("duplicate") ||
errorMessage.includes("já existe")
) {
toast.error("Email ou CPF já cadastrado no sistema");
} else {
toast.error(errorMessage);
}
toast.error("Erro ao criar usuário");
} finally {
setLoading(false);
}
@ -508,23 +480,12 @@ const PainelAdmin: React.FC = () => {
setLoading(true);
try {
// Validar CPF
const cpfLimpo = formPaciente.cpf.replace(/\D/g, "");
if (cpfLimpo.length !== 11) {
toast.error("CPF deve ter 11 dígitos");
setLoading(false);
return;
}
// Limpar telefone (remover formatação)
const phoneLimpo = formPaciente.phone_mobile.replace(/\D/g, "");
const patientData = {
full_name: formPaciente.full_name,
cpf: cpfLimpo,
cpf: formPaciente.cpf.replace(/\D/g, ""), // Remover máscara do CPF
email: formPaciente.email,
phone_mobile: phoneLimpo,
birth_date: formPaciente.birth_date || undefined,
phone_mobile: formPaciente.phone_mobile,
birth_date: formPaciente.birth_date,
social_name: formPaciente.social_name,
sex: formPaciente.sex,
blood_type: formPaciente.blood_type,
@ -551,100 +512,56 @@ const PainelAdmin: React.FC = () => {
resetFormPaciente();
loadPacientes();
} else {
// API create-patient já cria auth user + registro na tabela patients
console.log("[PainelAdmin] Criando paciente com API /create-patient:", {
email: patientData.email,
full_name: patientData.full_name,
cpf: cpfLimpo,
phone_mobile: patientData.phone_mobile,
});
await userService.createPatient({
email: patientData.email,
full_name: patientData.full_name,
cpf: cpfLimpo,
phone_mobile: patientData.phone_mobile,
birth_date: patientData.birth_date,
created_by: user?.id || "", // ID do admin/secretaria que está criando
});
// Usar create-user com create_patient_record=true (nova API 21/10)
// isPublicRegistration = false porque é admin criando
await userService.createUser(
{
email: patientData.email,
full_name: patientData.full_name,
phone: patientData.phone_mobile,
role: "paciente",
create_patient_record: true,
cpf: patientData.cpf,
phone_mobile: patientData.phone_mobile,
redirect_url:
"https://mediconnectbrasil.netlify.app/paciente/agendamento",
},
false
);
toast.success(
"Paciente criado com sucesso! Link de acesso enviado para o email."
"Paciente criado com sucesso! Magic link enviado para o email."
);
setShowPacienteModal(false);
resetFormPaciente();
loadPacientes();
}
} catch (error: unknown) {
} catch (error) {
console.error("Erro ao salvar paciente:", error);
const axiosError = error as {
response?: {
data?: { message?: string; error?: string };
status?: number;
};
message?: string;
};
const errorMessage =
axiosError?.response?.data?.message ||
axiosError?.response?.data?.error ||
axiosError?.message ||
"Erro ao salvar paciente";
toast.error(`Erro: ${errorMessage}`);
if (axiosError?.response) {
console.error("Status:", axiosError.response.status);
console.error("Data:", axiosError.response.data);
}
toast.error("Erro ao salvar paciente");
} finally {
setLoading(false);
}
};
const handleDeletePaciente = async (id: string, nome: string) => {
setConfirmDialog({
isOpen: true,
title: "⚠️ Deletar Paciente",
message: (
<div className="space-y-3">
<p className="text-gray-700">
Tem certeza que deseja{" "}
<strong className="text-red-600">deletar permanentemente</strong> o
paciente:
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-semibold text-red-900">{nome}</p>
<p className="text-xs text-red-700 mt-1">ID: {id}</p>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
<strong> Atenção:</strong> Esta ação não pode ser desfeita.
</p>
<ul className="text-xs text-yellow-700 mt-2 space-y-1 list-disc list-inside">
<li>Todos os dados do paciente serão removidos</li>
<li>O histórico de consultas será perdido</li>
<li>Prontuários associados serão excluídos</li>
</ul>
</div>
</div>
),
confirmText: "Sim, deletar paciente",
cancelText: "Cancelar",
onConfirm: async () => {
try {
console.log("[PainelAdmin] Deletando paciente:", { id, nome });
await patientService.delete(id);
console.log("[PainelAdmin] Paciente deletado com sucesso");
toast.success(`Paciente "${nome}" deletado com sucesso!`);
loadPacientes();
} catch (error) {
console.error("[PainelAdmin] Erro ao deletar paciente:", error);
toast.error("Erro ao deletar paciente");
}
},
requireTypedConfirmation: false,
confirmationWord: "",
isDangerous: true,
});
if (
!confirm(
`Tem certeza que deseja deletar o paciente "${nome}"? Esta ação não pode ser desfeita.`
)
) {
return;
}
try {
console.log("[PainelAdmin] Deletando paciente:", { id, nome });
await patientService.delete(id);
console.log("[PainelAdmin] Paciente deletado com sucesso");
toast.success("Paciente deletado com sucesso!");
loadPacientes();
} catch (error) {
console.error("[PainelAdmin] Erro ao deletar paciente:", error);
toast.error("Erro ao deletar paciente");
}
};
// Funções de gerenciamento de médicos
@ -692,126 +609,61 @@ const PainelAdmin: React.FC = () => {
resetFormMedico();
loadMedicos();
} else {
// API create-doctor já cria auth user + registro na tabela doctors
// Validação: CPF deve ter 11 dígitos, CRM_UF deve ter 2 letras maiúsculas
const cpfLimpo = medicoData.cpf.replace(/\D/g, "");
if (cpfLimpo.length !== 11) {
toast.error("CPF deve ter 11 dígitos");
setLoading(false);
return;
}
if (!/^[A-Z]{2}$/.test(medicoData.crm_uf)) {
toast.error("UF do CRM deve ter 2 letras maiúsculas (ex: SP)");
setLoading(false);
return;
}
// Limpar telefone (remover formatação)
const phoneLimpo = medicoData.phone_mobile
? medicoData.phone_mobile.replace(/\D/g, "")
: undefined;
console.log("[PainelAdmin] Criando médico com API /create-doctor:", {
email: medicoData.email,
full_name: medicoData.full_name,
cpf: cpfLimpo,
crm: medicoData.crm,
crm_uf: medicoData.crm_uf,
});
// Usar create-user com role=medico (nova API 21/10 - create-doctor não cria auth user)
// isPublicRegistration = false porque é admin criando
await userService.createUser(
{
email: medicoData.email,
full_name: medicoData.full_name,
phone: medicoData.phone_mobile,
role: "medico",
redirect_url: "https://mediconnectbrasil.netlify.app/medico/painel",
},
false
);
// Depois criar registro na tabela doctors com createDoctor (sem password)
await userService.createDoctor({
email: medicoData.email,
full_name: medicoData.full_name,
cpf: cpfLimpo,
crm: medicoData.crm,
crm_uf: medicoData.crm_uf,
specialty: medicoData.specialty || undefined,
phone_mobile: phoneLimpo,
cpf: medicoData.cpf,
full_name: medicoData.full_name,
email: medicoData.email,
specialty: medicoData.specialty,
phone_mobile: medicoData.phone_mobile,
});
toast.success(
"Médico criado com sucesso! Link de acesso enviado para o email."
"Médico criado com sucesso! Magic link enviado para o email."
);
setShowMedicoModal(false);
resetFormMedico();
loadMedicos();
}
} catch (error: unknown) {
} catch (error) {
console.error("Erro ao salvar médico:", error);
const axiosError = error as {
response?: {
data?: { message?: string; error?: string };
status?: number;
headers?: unknown;
};
message?: string;
};
const errorMessage =
axiosError?.response?.data?.message ||
axiosError?.response?.data?.error ||
axiosError?.message ||
"Erro ao salvar médico";
toast.error(`Erro: ${errorMessage}`);
// Log detalhado para debug
if (axiosError?.response) {
console.error("Status:", axiosError.response.status);
console.error("Data:", axiosError.response.data);
console.error("Headers:", axiosError.response.headers);
}
toast.error("Erro ao salvar médico");
} finally {
setLoading(false);
}
};
const handleDeleteMedico = async (id: string, nome: string) => {
setConfirmDialog({
isOpen: true,
title: "⚠️ Deletar Médico",
message: (
<div className="space-y-3">
<p className="text-gray-700">
Tem certeza que deseja{" "}
<strong className="text-red-600">deletar permanentemente</strong> o
médico:
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-semibold text-red-900">{nome}</p>
<p className="text-xs text-red-700 mt-1">ID: {id}</p>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
<strong> Atenção:</strong> Esta ação não pode ser desfeita.
</p>
<ul className="text-xs text-yellow-700 mt-2 space-y-1 list-disc list-inside">
<li>Todos os dados do médico serão removidos</li>
<li>Agendamentos futuros serão cancelados</li>
<li>Disponibilidades serão excluídas</li>
<li>Histórico de consultas será perdido</li>
</ul>
</div>
</div>
),
confirmText: "Sim, deletar médico",
cancelText: "Cancelar",
onConfirm: async () => {
try {
console.log("[PainelAdmin] Deletando médico:", { id, nome });
await doctorService.delete(id);
console.log("[PainelAdmin] Médico deletado com sucesso");
toast.success(`Médico "${nome}" deletado com sucesso!`);
loadMedicos();
} catch (error) {
console.error("[PainelAdmin] Erro ao deletar médico:", error);
toast.error("Erro ao deletar médico");
}
},
requireTypedConfirmation: false,
confirmationWord: "",
isDangerous: true,
});
if (
!confirm(
`Tem certeza que deseja deletar o médico "${nome}"? Esta ação não pode ser desfeita.`
)
) {
return;
}
try {
await doctorService.delete(id);
toast.success("Médico deletado com sucesso!");
loadMedicos();
} catch {
toast.error("Erro ao deletar médico");
}
};
const resetFormPaciente = () => {
@ -843,47 +695,6 @@ const PainelAdmin: React.FC = () => {
phone: "",
role: "user",
});
setUserCpf("");
setUserPhoneMobile("");
setUserPassword("");
setCreatePatientRecord(false);
};
// Função para formatar CPF (XXX.XXX.XXX-XX)
const formatCPF = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 3) return numbers;
if (numbers.length <= 6)
return `${numbers.slice(0, 3)}.${numbers.slice(3)}`;
if (numbers.length <= 9)
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
6
)}`;
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
6,
9
)}-${numbers.slice(9, 11)}`;
};
// Função para formatar telefone ((XX) XXXXX-XXXX)
const formatPhone = (value: string): string => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 2) return numbers;
if (numbers.length <= 7)
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
if (numbers.length <= 11)
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
7
)}`;
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
7,
11
)}`;
};
// Função para obter apenas números do CPF/telefone
const getOnlyNumbers = (value: string): string => {
return value.replace(/\D/g, "");
};
const resetFormMedico = () => {
@ -1474,19 +1285,12 @@ const PainelAdmin: React.FC = () => {
onChange={(e) =>
setFormPaciente({
...formPaciente,
cpf: formatCPF(e.target.value),
cpf: e.target.value,
})
}
maxLength={14}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-green-600/40"
placeholder="000.000.000-00"
placeholder="00000000000"
/>
{formPaciente.cpf &&
formPaciente.cpf.replace(/\D/g, "").length !== 11 && (
<p className="text-xs text-orange-600 mt-1">
CPF deve ter exatamente 11 dígitos
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">
@ -1516,10 +1320,9 @@ const PainelAdmin: React.FC = () => {
onChange={(e) =>
setFormPaciente({
...formPaciente,
phone_mobile: formatPhone(e.target.value),
phone_mobile: e.target.value,
})
}
maxLength={15}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-green-600/40"
placeholder="(00) 00000-0000"
/>
@ -1527,14 +1330,9 @@ const PainelAdmin: React.FC = () => {
{!editingPaciente && (
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800">
🔐 <strong>Ativação de Conta:</strong> Um link de acesso
será enviado automaticamente para o email do paciente.
Ele poderá acessar o sistema e definir sua senha no
primeiro login.
</p>
<p className="text-xs text-blue-700 mt-1">
📋 <strong>Campos obrigatórios:</strong> Nome Completo,
CPF (11 dígitos), Email, Telefone
🔐 <strong>Ativação de Conta:</strong> Um link mágico
(magic link) será enviado automaticamente para o email
do paciente para ativar a conta e definir senha.
</p>
</div>
)}
@ -1633,7 +1431,7 @@ const PainelAdmin: React.FC = () => {
aria-modal="true"
aria-labelledby="usuario-modal-title"
>
<div className="bg-white rounded-xl border border-gray-200 shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="bg-white rounded-xl border border-gray-200 shadow-xl max-w-md w-full">
<div className="p-6">
<h2 id="usuario-modal-title" className="text-2xl font-bold mb-4">
Novo Usuário
@ -1669,37 +1467,18 @@ const PainelAdmin: React.FC = () => {
</div>
<div>
<label className="block text-sm font-medium mb-1">
CPF *
Telefone
</label>
<input
type="text"
required
value={userCpf}
onChange={(e) => setUserCpf(formatCPF(e.target.value))}
maxLength={14}
className="form-input"
placeholder="000.000.000-00"
/>
<p className="text-xs text-gray-500 mt-1">
Obrigatório para todos os usuários
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Senha *
</label>
<input
type="password"
required
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
minLength={6}
className="form-input"
placeholder="Mínimo 6 caracteres"
value={formUser.phone || ""}
onChange={(e) =>
setFormUser({ ...formUser, phone: e.target.value })
}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
placeholder="(00) 00000-0000"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Role/Papel *
@ -1713,106 +1492,60 @@ const PainelAdmin: React.FC = () => {
role: e.target.value as UserRole,
})
}
className="form-select"
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
>
{availableRoles.map((role) => (
<option key={role} value={role}>
{role === "paciente"
? "Paciente"
: role === "medico"
? "Médico"
: role === "secretaria"
? "Secretária"
: role === "admin"
? "Administrador"
: role === "gestor"
? "Gestor"
: role}
{role}
</option>
))}
</select>
</div>
{/* Toggle para criar com senha */}
<div className="border-t pt-4">
<h3 className="text-sm font-semibold mb-3">
Campos Opcionais
</h3>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={usePassword}
onChange={(e) => setUsePassword(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium">
Criar com senha (alternativa ao Magic Link)
</span>
</label>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">
Telefone Fixo
</label>
<input
type="text"
value={formUser.phone || ""}
onChange={(e) =>
setFormUser({
...formUser,
phone: formatPhone(e.target.value),
})
}
maxLength={15}
className="form-input"
placeholder="(00) 0000-0000"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Telefone Celular
</label>
<input
type="text"
value={userPhoneMobile}
onChange={(e) =>
setUserPhoneMobile(formatPhone(e.target.value))
}
maxLength={15}
className="form-input"
placeholder="(00) 00000-0000"
/>
</div>
{/* Criar registro de paciente - apenas para role paciente */}
{formUser.role === "paciente" && (
<div className="border-t pt-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={createPatientRecord}
onChange={(e) =>
setCreatePatientRecord(e.target.checked)
}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium">
Criar também registro completo de paciente
</span>
</label>
<p className="text-xs text-gray-500 mt-1 ml-6">
Recomendado para ter acesso completo aos dados médicos
</p>
</div>
)}
{/* Campo de senha (condicional) */}
{usePassword && (
<div>
<label className="block text-sm font-medium mb-1">
Senha *
</label>
<input
type="password"
required={usePassword}
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
minLength={6}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
placeholder="Mínimo 6 caracteres"
/>
<p className="text-xs text-gray-500 mt-1">
O usuário precisará confirmar o email antes de fazer login
</p>
</div>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm font-semibold text-blue-900 mb-1">
Campos Obrigatórios (Todos os Roles)
</p>
<ul className="text-xs text-blue-700 space-y-0.5 ml-4 list-disc">
<li>Nome Completo</li>
<li>Email (único no sistema)</li>
<li>CPF (formato: XXX.XXX.XXX-XX)</li>
<li>Senha (mínimo 6 caracteres)</li>
<li>Role/Papel</li>
</ul>
<p className="text-xs text-blue-600 mt-2">
Email de confirmação será enviado automaticamente
</p>
</div>
{!usePassword && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs text-blue-700">
Um Magic Link será enviado para o email do usuário para
ativação da conta
</p>
</div>
)}
<div className="flex gap-2 justify-end pt-4">
<button
@ -1931,21 +1664,11 @@ const PainelAdmin: React.FC = () => {
required
value={formMedico.cpf}
onChange={(e) =>
setFormMedico({
...formMedico,
cpf: formatCPF(e.target.value),
})
setFormMedico({ ...formMedico, cpf: e.target.value })
}
maxLength={14}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
placeholder="12345678901"
placeholder="000.000.000-00"
/>
{formMedico.cpf &&
formMedico.cpf.replace(/\D/g, "").length !== 11 && (
<p className="text-xs text-orange-600 mt-1">
CPF deve ter exatamente 11 dígitos
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">RG</label>
@ -1974,8 +1697,7 @@ const PainelAdmin: React.FC = () => {
</div>
<div>
<label className="block text-sm font-medium mb-1">
Telefone Celular{" "}
<span className="text-xs text-gray-500">(opcional)</span>
Telefone
</label>
<input
type="text"
@ -1983,21 +1705,19 @@ const PainelAdmin: React.FC = () => {
onChange={(e) =>
setFormMedico({
...formMedico,
phone_mobile: formatPhone(e.target.value),
phone_mobile: e.target.value,
})
}
maxLength={15}
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
placeholder="(11) 98888-8888"
placeholder="(00) 00000-0000"
/>
</div>
{!editingMedico && (
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
<p className="text-sm text-blue-800">
🔐 <strong>Ativação de Conta:</strong> Um link de acesso
será enviado automaticamente para o email do médico. Ele
poderá acessar o sistema e definir sua senha no primeiro
login.
🔐 <strong>Ativação de Conta:</strong> Um link mágico
(magic link) será enviado automaticamente para o email
do médico para ativar a conta e definir senha.
</p>
</div>
)}
@ -2094,7 +1814,7 @@ const PainelAdmin: React.FC = () => {
onChange={(e) =>
setEditForm({ ...editForm, full_name: e.target.value })
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
/>
</div>
@ -2108,7 +1828,7 @@ const PainelAdmin: React.FC = () => {
onChange={(e) =>
setEditForm({ ...editForm, email: e.target.value })
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
/>
</div>
@ -2122,7 +1842,7 @@ const PainelAdmin: React.FC = () => {
onChange={(e) =>
setEditForm({ ...editForm, phone: e.target.value })
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
/>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -2121,7 +2121,7 @@ const PainelSecretaria = () => {
nome: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="Maria Santos Silva"
/>
@ -2140,7 +2140,7 @@ const PainelSecretaria = () => {
social_name: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Maria Santos"
/>
</div>
@ -2153,7 +2153,7 @@ const PainelSecretaria = () => {
type="text"
value={formDataPaciente.cpf}
onChange={handleCpfChange}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="000.000.000-00"
maxLength={14}
@ -2173,7 +2173,7 @@ const PainelSecretaria = () => {
dataNascimento: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
/>
</div>
@ -2190,7 +2190,7 @@ const PainelSecretaria = () => {
sexo: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
>
<option value="">Selecione</option>
@ -2221,7 +2221,7 @@ const PainelSecretaria = () => {
email: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="maria@email.com"
/>
@ -2302,7 +2302,7 @@ const PainelSecretaria = () => {
tipo_sanguineo: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value="">Selecione</option>
{BLOOD_TYPES.map((tipo) => (
@ -2329,7 +2329,7 @@ const PainelSecretaria = () => {
peso: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="65.5"
/>
</div>
@ -2350,7 +2350,7 @@ const PainelSecretaria = () => {
altura: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="1.65"
/>
</div>
@ -2369,7 +2369,7 @@ const PainelSecretaria = () => {
convenio: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value="">Selecione</option>
{CONVENIOS.map((option) => (
@ -2393,7 +2393,7 @@ const PainelSecretaria = () => {
numeroCarteirinha: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Número da carteirinha"
/>
</div>
@ -2466,7 +2466,7 @@ const PainelSecretaria = () => {
},
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Rua das Flores"
/>
</div>
@ -2487,7 +2487,7 @@ const PainelSecretaria = () => {
},
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="123"
/>
</div>
@ -2510,7 +2510,7 @@ const PainelSecretaria = () => {
},
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Centro"
/>
</div>
@ -2531,7 +2531,7 @@ const PainelSecretaria = () => {
},
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="São Paulo"
/>
</div>
@ -2552,7 +2552,7 @@ const PainelSecretaria = () => {
},
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="SP"
maxLength={2}
/>
@ -2575,7 +2575,7 @@ const PainelSecretaria = () => {
},
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Apto 45, Bloco B..."
/>
</div>
@ -2599,7 +2599,7 @@ const PainelSecretaria = () => {
observacoes: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
rows={3}
placeholder="Observações gerais sobre o paciente..."
/>
@ -2725,7 +2725,7 @@ const PainelSecretaria = () => {
patientId: e.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
>
<option value="">-- Selecione --</option>
@ -2749,7 +2749,7 @@ const PainelSecretaria = () => {
orderNumber: e.target.value.toUpperCase(),
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
required
placeholder="Ex: REL-2025-10-MUS3TN"
pattern="^REL-\d{4}-\d{2}-[A-Z0-9]{6}$"
@ -2769,7 +2769,7 @@ const PainelSecretaria = () => {
exam: e.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
placeholder="Ex: Hemograma"
/>
</div>
@ -2786,7 +2786,7 @@ const PainelSecretaria = () => {
dueAt: e.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
@ -2803,7 +2803,7 @@ const PainelSecretaria = () => {
}))
}
rows={3}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div>
@ -2819,7 +2819,7 @@ const PainelSecretaria = () => {
}))
}
rows={3}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<div className="flex justify-end gap-3 border-t pt-4">
@ -3048,7 +3048,7 @@ const PainelSecretaria = () => {
nome: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
placeholder="Dr. João da Silva"
/>
@ -3069,7 +3069,7 @@ const PainelSecretaria = () => {
cpf: digits,
}));
}}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
placeholder="000.000.000-00"
maxLength={14}
@ -3089,7 +3089,7 @@ const PainelSecretaria = () => {
rg: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="00.000.000-0"
/>
</div>
@ -3108,7 +3108,7 @@ const PainelSecretaria = () => {
dataNascimento: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
@ -3134,7 +3134,7 @@ const PainelSecretaria = () => {
crm: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
placeholder="123456"
/>
@ -3152,7 +3152,7 @@ const PainelSecretaria = () => {
crmUf: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
<option value="">Selecione</option>
@ -3205,7 +3205,7 @@ const PainelSecretaria = () => {
especialidade: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
>
<option value="">Selecione</option>
@ -3248,7 +3248,7 @@ const PainelSecretaria = () => {
email: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
placeholder="medico@email.com"
/>
@ -3268,7 +3268,7 @@ const PainelSecretaria = () => {
telefone: buildMedicoTelefone(event.target.value),
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
placeholder="(11) 99999-9999"
/>
@ -3287,7 +3287,7 @@ const PainelSecretaria = () => {
telefone2: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="(11) 3333-4444"
/>
</div>
@ -3355,7 +3355,7 @@ const PainelSecretaria = () => {
rua: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Nome da rua"
required
/>
@ -3374,7 +3374,7 @@ const PainelSecretaria = () => {
numero: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="123"
required
/>
@ -3395,7 +3395,7 @@ const PainelSecretaria = () => {
bairro: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Bairro"
required
/>
@ -3414,7 +3414,7 @@ const PainelSecretaria = () => {
cidade: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Cidade"
required
/>
@ -3433,7 +3433,7 @@ const PainelSecretaria = () => {
estado: event.target.value.toUpperCase(),
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="UF"
maxLength={2}
required
@ -3454,7 +3454,7 @@ const PainelSecretaria = () => {
complemento: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Apto, sala, bloco..."
/>
</div>
@ -3479,7 +3479,7 @@ const PainelSecretaria = () => {
senha: event.target.value,
}))
}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
minLength={6}
placeholder="Mínimo 6 caracteres"
@ -3542,5 +3542,3 @@ const PainelSecretaria = () => {
};
export default PainelSecretaria;

View File

@ -1,121 +1,101 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import {
Users,
UserCog,
Calendar,
CalendarClock,
FileText,
LogOut,
} from "lucide-react";
import { SecretaryPatientList } from "../components/secretaria/SecretaryPatientList";
import { SecretaryDoctorList } from "../components/secretaria/SecretaryDoctorList";
import { SecretaryAppointmentList } from "../components/secretaria/SecretaryAppointmentList";
import { SecretaryDoctorSchedule } from "../components/secretaria/SecretaryDoctorSchedule";
import { SecretaryReportList } from "../components/secretaria/SecretaryReportList";
type TabId = "pacientes" | "medicos" | "consultas" | "agenda" | "relatorios";
export default function PainelSecretaria() {
const navigate = useNavigate();
const { user, logout } = useAuth();
const [activeTab, setActiveTab] = useState<TabId>("pacientes");
const handleLogout = () => {
logout();
navigate("/login-secretaria");
};
const tabs: { id: TabId; label: string; icon: typeof Users }[] = [
{ id: "pacientes", label: "Pacientes", icon: Users },
{ id: "medicos", label: "Médicos", icon: UserCog },
{ id: "consultas", label: "Consultas", icon: Calendar },
{ id: "agenda", label: "Agenda Médica", icon: CalendarClock },
{ id: "relatorios", label: "Relatórios", icon: FileText },
];
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold text-gray-900 dark:text-white truncate">
Painel da Secretaria
</h1>
{user && (
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1 truncate">
Bem-vindo(a), {user.nome || user.email}
</p>
)}
</div>
<button
onClick={handleLogout}
className="flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 text-sm sm:text-base text-gray-700 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
>
<LogOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span className="hidden sm:inline">Sair</span>
</button>
</div>
</div>
</header>
{/* Tabs Navigation */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
<div className="max-w-[1400px] mx-auto px-4 sm:px-6">
<nav className="flex gap-1 sm:gap-2 min-w-max">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 border-b-2 transition-colors text-sm sm:text-base whitespace-nowrap ${
isActive
? "border-green-600 text-green-600 font-medium"
: "border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300"
}`}
>
<Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.split(" ")[0]}</span>
</button>
);
})}
</nav>
</div>
</div>
{/* Main Content */}
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 py-6 sm:py-8">
{activeTab === "pacientes" && (
<SecretaryPatientList
onOpenAppointment={(patientId: string) => {
// store selected patient for appointment and switch to consultas tab
sessionStorage.setItem(
"selectedPatientForAppointment",
patientId
);
setActiveTab("consultas");
}}
/>
)}
{activeTab === "medicos" && (
<SecretaryDoctorList
onOpenSchedule={(doctorId: string) => {
// store selected doctor for schedule and switch to agenda tab
sessionStorage.setItem("selectedDoctorForSchedule", doctorId);
setActiveTab("agenda");
}}
/>
)}
{activeTab === "consultas" && <SecretaryAppointmentList />}
{activeTab === "agenda" && <SecretaryDoctorSchedule />}
{activeTab === "relatorios" && <SecretaryReportList />}
</main>
</div>
);
}
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import {
Users,
UserCog,
Calendar,
CalendarClock,
FileText,
LogOut,
} from "lucide-react";
import { SecretaryPatientList } from "../components/secretaria/SecretaryPatientList";
import { SecretaryDoctorList } from "../components/secretaria/SecretaryDoctorList";
import { SecretaryAppointmentList } from "../components/secretaria/SecretaryAppointmentList";
import { SecretaryDoctorSchedule } from "../components/secretaria/SecretaryDoctorSchedule";
import { SecretaryReportList } from "../components/secretaria/SecretaryReportList";
type TabId = "pacientes" | "medicos" | "consultas" | "agenda" | "relatorios";
export default function PainelSecretaria() {
const navigate = useNavigate();
const { user, logout } = useAuth();
const [activeTab, setActiveTab] = useState<TabId>("pacientes");
const handleLogout = () => {
logout();
navigate("/login-secretaria");
};
const tabs: { id: TabId; label: string; icon: typeof Users }[] = [
{ id: "pacientes", label: "Pacientes", icon: Users },
{ id: "medicos", label: "Médicos", icon: UserCog },
{ id: "consultas", label: "Consultas", icon: Calendar },
{ id: "agenda", label: "Agenda Médica", icon: CalendarClock },
{ id: "relatorios", label: "Relatórios", icon: FileText },
];
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-[1400px] mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">
Painel da Secretaria
</h1>
{user && (
<p className="text-sm text-gray-600 mt-1">
Bem-vinda, {user.email}
</p>
)}
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<LogOut className="h-4 w-4" />
Sair
</button>
</div>
</div>
</header>
{/* Tabs Navigation */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-[1400px] mx-auto px-6">
<nav className="flex gap-2">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-colors ${
isActive
? "border-green-600 text-green-600 font-medium"
: "border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300"
}`}
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
</div>
</div>
{/* Main Content */}
<main className="max-w-[1400px] mx-auto px-6 py-8">
{activeTab === "pacientes" && <SecretaryPatientList />}
{activeTab === "medicos" && <SecretaryDoctorList />}
{activeTab === "consultas" && <SecretaryAppointmentList />}
{activeTab === "agenda" && <SecretaryDoctorSchedule />}
{activeTab === "relatorios" && <SecretaryReportList />}
</main>
</div>
);
}

View File

@ -1,14 +1,12 @@
import { useState, useEffect } from "react";
import { Save, ArrowLeft } from "lucide-react";
import { Save } from "lucide-react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { patientService, avatarService } from "../services";
import { patientService } from "../services";
import { AvatarUpload } from "../components/ui/AvatarUpload";
export default function PerfilPaciente() {
const { user } = useAuth();
const navigate = useNavigate();
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<
@ -49,25 +47,13 @@ export default function PerfilPaciente() {
}, [user?.id]);
const loadPatientData = async () => {
if (!user?.id) {
console.error("[PerfilPaciente] Sem user.id:", user);
toast.error("Usuário não identificado");
return;
}
if (!user?.id) return;
try {
setLoading(true);
console.log("[PerfilPaciente] 🔍 USER ID:", {
userId: user.id,
userName: user.nome,
userEmail: user.email,
userRole: user.role,
});
const patient = await patientService.getById(user.id);
try {
// Buscar paciente por user_id (ID do auth) em vez de id
const patient = await patientService.getByUserId(user.id);
console.log("[PerfilPaciente] Dados carregados:", patient);
if (patient) {
setFormData({
full_name: patient.full_name || "",
email: patient.email || "",
@ -86,58 +72,11 @@ export default function PerfilPaciente() {
weight_kg: patient.weight_kg?.toString() || "",
height_m: patient.height_m?.toString() || "",
});
// Carrega avatar_url do paciente ou gera URL do Supabase Storage
if (patient.avatar_url) {
console.log(
"[PerfilPaciente] Avatar URL do banco:",
patient.avatar_url
);
setAvatarUrl(patient.avatar_url);
} else if (user.id) {
// Se não houver avatar_url salvo, tenta carregar do Storage usando userId
const avatarStorageUrl = avatarService.getPublicUrl({
userId: user.id,
ext: "jpg",
});
console.log(
"[PerfilPaciente] Tentando carregar avatar do Storage:",
avatarStorageUrl
);
setAvatarUrl(avatarStorageUrl);
} else {
setAvatarUrl(undefined);
}
} catch {
console.warn(
"[PerfilPaciente] Paciente não encontrado na tabela patients, usando dados básicos do auth"
);
// Se não encontrar o paciente, usar dados básicos do usuário logado
setFormData({
full_name: user.nome || "",
email: user.email || "",
phone_mobile: "",
cpf: "",
birth_date: "",
sex: "",
street: "",
number: "",
complement: "",
neighborhood: "",
city: "",
state: "",
cep: "",
blood_type: "",
weight_kg: "",
height_m: "",
});
toast("Preencha seus dados para completar o cadastro", { icon: "" });
// Patient type não tem avatar_url ainda
setAvatarUrl(undefined);
}
} catch (error) {
console.error(
"[PerfilPaciente] Erro ao carregar dados do paciente:",
error
);
console.error("Erro ao carregar dados do paciente:", error);
toast.error("Erro ao carregar dados do perfil");
} finally {
setLoading(false);
@ -155,31 +94,11 @@ export default function PerfilPaciente() {
: undefined,
height_m: formData.height_m ? parseFloat(formData.height_m) : undefined,
};
try {
// Tentar atualizar primeiro
await patientService.update(user.id, dataToSave);
toast.success("Perfil atualizado com sucesso!");
} catch (updateError) {
console.warn(
"[PerfilPaciente] Erro ao atualizar, tentando criar:",
updateError
);
// Se falhar, tentar criar o paciente
try {
await patientService.create(dataToSave);
toast.success("Perfil criado com sucesso!");
} catch (createError) {
console.error("[PerfilPaciente] Erro ao criar perfil:", createError);
throw createError;
}
}
await patientService.update(user.id, dataToSave);
toast.success("Perfil atualizado com sucesso!");
setIsEditing(false);
// Recarregar dados
await loadPatientData();
} catch (error) {
console.error("[PerfilPaciente] Erro ao salvar perfil:", error);
console.error("Erro ao salvar perfil:", error);
toast.error("Erro ao salvar perfil");
}
};
@ -215,74 +134,44 @@ export default function PerfilPaciente() {
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-600">Carregando perfil...</p>
</div>
</div>
);
}
if (!user?.id) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
<p className="text-red-600 mb-4">Usuário não identificado</p>
<button
onClick={() => navigate("/paciente")}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Fazer Login
</button>
</div>
<div className="flex items-center justify-center min-h-screen">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-4 sm:py-6 lg:py-8 px-4 sm:px-6">
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6">
{/* Botão Voltar */}
<button
onClick={() => navigate("/acompanhamento")}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors mb-2 sm:mb-4 text-sm sm:text-base"
>
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
Voltar para o Painel
</button>
<div className="min-h-screen bg-gray-50 py-8 px-4">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 truncate">
Meu Perfil
</h1>
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1>
<p className="text-gray-600">
Gerencie suas informações pessoais e médicas
</p>
</div>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm sm:text-base whitespace-nowrap"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Editar Perfil
</button>
) : (
<div className="flex gap-2 w-full sm:w-auto">
<div className="flex gap-2">
<button
onClick={() => {
setIsEditing(false);
loadPatientData();
}}
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm sm:text-base"
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<Save className="w-4 h-4" />
Salvar
@ -292,11 +181,9 @@ export default function PerfilPaciente() {
</div>
{/* Avatar Card */}
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">
Foto de Perfil
</h2>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Foto de Perfil</h2>
<div className="flex items-center gap-6">
<AvatarUpload
userId={user?.id}
currentAvatarUrl={avatarUrl}
@ -306,24 +193,20 @@ export default function PerfilPaciente() {
editable={true}
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
/>
<div className="text-center sm:text-left min-w-0 flex-1">
<p className="font-medium text-gray-900 text-sm sm:text-base truncate">
{formData.full_name || "Carregando..."}
</p>
<p className="text-gray-500 text-xs sm:text-sm truncate">
{formData.email || "Sem email"}
</p>
<div>
<p className="font-medium text-gray-900">{formData.full_name}</p>
<p className="text-gray-500">{formData.email}</p>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="border-b border-gray-200 overflow-x-auto">
<nav className="flex -mb-px min-w-max">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab("personal")}
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "personal"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
@ -333,17 +216,17 @@ export default function PerfilPaciente() {
</button>
<button
onClick={() => setActiveTab("medical")}
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "medical"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
Info. Médicas
Informações Médicas
</button>
<button
onClick={() => setActiveTab("security")}
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === "security"
? "border-blue-600 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
@ -359,7 +242,7 @@ export default function PerfilPaciente() {
{activeTab === "personal" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4 text-gray-900">
<h3 className="text-lg font-semibold mb-4">
Informações Pessoais
</h3>
<p className="text-sm text-gray-500 mb-4">
@ -378,7 +261,7 @@ export default function PerfilPaciente() {
handleChange("full_name", e.target.value)
}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -391,7 +274,7 @@ export default function PerfilPaciente() {
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -406,7 +289,7 @@ export default function PerfilPaciente() {
handleChange("phone_mobile", e.target.value)
}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -418,7 +301,7 @@ export default function PerfilPaciente() {
type="text"
value={formData.cpf}
disabled
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
/>
</div>
@ -433,7 +316,7 @@ export default function PerfilPaciente() {
handleChange("birth_date", e.target.value)
}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -445,7 +328,7 @@ export default function PerfilPaciente() {
value={formData.sex}
onChange={(e) => handleChange("sex", e.target.value)}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
>
<option value="">Selecione</option>
<option value="M">Masculino</option>
@ -469,7 +352,7 @@ export default function PerfilPaciente() {
value={formData.street}
onChange={(e) => handleChange("street", e.target.value)}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -482,7 +365,7 @@ export default function PerfilPaciente() {
value={formData.number}
onChange={(e) => handleChange("number", e.target.value)}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -497,7 +380,7 @@ export default function PerfilPaciente() {
handleChange("complement", e.target.value)
}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -512,7 +395,7 @@ export default function PerfilPaciente() {
handleChange("neighborhood", e.target.value)
}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -525,7 +408,7 @@ export default function PerfilPaciente() {
value={formData.city}
onChange={(e) => handleChange("city", e.target.value)}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -539,7 +422,7 @@ export default function PerfilPaciente() {
onChange={(e) => handleChange("state", e.target.value)}
disabled={!isEditing}
maxLength={2}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -552,7 +435,7 @@ export default function PerfilPaciente() {
value={formData.cep}
onChange={(e) => handleChange("cep", e.target.value)}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
</div>
@ -582,7 +465,7 @@ export default function PerfilPaciente() {
handleChange("blood_type", e.target.value)
}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
>
<option value="">Selecione</option>
<option value="A+">A+</option>
@ -607,7 +490,7 @@ export default function PerfilPaciente() {
handleChange("weight_kg", e.target.value)
}
disabled={!isEditing}
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
@ -624,7 +507,7 @@ export default function PerfilPaciente() {
}
disabled={!isEditing}
placeholder="Ex: 1.75"
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
/>
</div>
</div>
@ -656,7 +539,7 @@ export default function PerfilPaciente() {
})
}
placeholder="Digite sua senha atual"
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@ -674,7 +557,7 @@ export default function PerfilPaciente() {
})
}
placeholder="Digite a nova senha"
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@ -692,7 +575,7 @@ export default function PerfilPaciente() {
})
}
placeholder="Confirme a nova senha"
className="form-input"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>

View File

@ -1,6 +1,6 @@
/**
* Cliente HTTP usando Axios
* Chamadas diretas ao Supabase
* Todas as requisições passam pelas Netlify Functions
*/
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
@ -11,11 +11,10 @@ class ApiClient {
constructor() {
this.client = axios.create({
baseURL: API_CONFIG.REST_URL,
baseURL: API_CONFIG.BASE_URL,
timeout: API_CONFIG.TIMEOUT,
headers: {
"Content-Type": "application/json",
apikey: API_CONFIG.SUPABASE_ANON_KEY,
},
});
@ -25,10 +24,10 @@ class ApiClient {
private setupInterceptors() {
// Request interceptor - adiciona token automaticamente
this.client.interceptors.request.use(
(config) => {
(config: any) => {
// Não adicionar token se a flag _skipAuth estiver presente
if ((config as any)._skipAuth) {
delete (config as any)._skipAuth;
if (config._skipAuth) {
delete config._skipAuth;
return config;
}
@ -89,20 +88,9 @@ class ApiClient {
if (refreshToken) {
console.log("[ApiClient] Refresh token encontrado, renovando...");
// Chama Supabase diretamente para renovar token
const response = await axios.post(
`${API_CONFIG.AUTH_URL}/token?grant_type=refresh_token`,
{
refresh_token: refreshToken,
},
{
headers: {
"Content-Type": "application/json",
apikey: API_CONFIG.SUPABASE_ANON_KEY,
},
}
);
const response = await this.client.post("/auth-refresh", {
refresh_token: refreshToken,
});
const {
access_token,
@ -175,35 +163,7 @@ class ApiClient {
url: string,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
const fullUrl = `${this.client.defaults.baseURL}${url}`;
const queryString = new URLSearchParams(config?.params).toString();
console.log(
"[ApiClient] 🔍 GET Request COMPLETO:",
fullUrl + (queryString ? `?${queryString}` : "")
);
console.log(
"[ApiClient] GET Request:",
url,
"Params:",
JSON.stringify(config?.params)
);
const response = await this.client.get<T>(url, config);
console.log("[ApiClient] GET Response:", {
status: response.status,
dataType: typeof response.data,
isArray: Array.isArray(response.data),
dataLength: Array.isArray(response.data)
? response.data.length
: "not array",
});
console.log(
"[ApiClient] Response Data:",
JSON.stringify(response.data, null, 2)
);
return response;
return this.client.get<T>(url, config);
}
async post<T>(
@ -211,31 +171,7 @@ class ApiClient {
data?: unknown,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
console.log("[ApiClient] POST Request:", {
url,
fullUrl: `${this.client.defaults.baseURL}${url}`,
data,
config,
});
try {
const response = await this.client.post<T>(url, data, config);
console.log("[ApiClient] POST Response:", {
status: response.status,
data: response.data,
});
return response;
} catch (error: any) {
console.error("[ApiClient] POST Error:", {
url,
fullUrl: `${this.client.defaults.baseURL}${url}`,
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
message: error?.message,
});
throw error;
}
return this.client.post<T>(url, data, config);
}
/**
@ -264,38 +200,7 @@ class ApiClient {
data?: unknown,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
console.log("[ApiClient] PATCH Request:", {
url,
data,
config,
});
try {
// Adicionar header Prefer para Supabase retornar os dados atualizados
const configWithPrefer = {
...config,
headers: {
...config?.headers,
Prefer: "return=representation",
},
};
const response = await this.client.patch<T>(url, data, configWithPrefer);
console.log("[ApiClient] PATCH Response:", {
status: response.status,
data: response.data,
});
return response;
} catch (error: any) {
console.error("[ApiClient] PATCH Error:", {
url,
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
message: error?.message,
});
throw error;
}
return this.client.patch<T>(url, data, config);
}
async delete<T>(
@ -312,61 +217,6 @@ class ApiClient {
): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, config);
}
/**
* Chama uma Edge Function do Supabase
* Usa a baseURL de Functions em vez de REST
*/
async callFunction<T>(
functionName: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> {
const fullUrl = `${API_CONFIG.FUNCTIONS_URL}/${functionName}`;
console.log("[ApiClient] Calling Edge Function:", {
functionName,
fullUrl,
data,
});
// Cria uma requisição sem baseURL
const functionsClient = axios.create({
timeout: API_CONFIG.TIMEOUT,
headers: {
"Content-Type": "application/json",
apikey: API_CONFIG.SUPABASE_ANON_KEY,
},
});
// Adiciona token se disponível
const token = localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
if (token) {
functionsClient.defaults.headers.common[
"Authorization"
] = `Bearer ${token}`;
}
try {
const response = await functionsClient.post<T>(fullUrl, data, config);
console.log("[ApiClient] Edge Function Response:", {
functionName,
status: response.status,
data: response.data,
});
return response;
} catch (error: any) {
console.error("[ApiClient] Edge Function Error:", {
functionName,
fullUrl,
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
message: error?.message,
});
throw error;
}
}
}
export const apiClient = new ApiClient();

View File

@ -0,0 +1,26 @@
/**
* Configuração da API
* Frontend sempre chama Netlify Functions (não o Supabase direto)
*/
// Em desenvolvimento, Netlify Dev roda na porta 8888
// Em produção, usa URL completa do Netlify
const isDevelopment = import.meta.env.DEV;
const BASE_URL = isDevelopment
? "http://localhost:8888/.netlify/functions"
: "https://mediconnectbrasil.netlify.app/.netlify/functions";
export const API_CONFIG = {
// Base URL aponta para suas Netlify Functions
BASE_URL,
// Timeout padrão (30 segundos)
TIMEOUT: 30000,
// Storage keys
STORAGE_KEYS: {
ACCESS_TOKEN: "mediconnect_access_token",
REFRESH_TOKEN: "mediconnect_refresh_token",
USER: "mediconnect_user",
},
} as const;

View File

@ -0,0 +1,113 @@
/**
* Serviço de Agendamentos
*/
import { apiClient } from "../api/client";
import type {
Appointment,
CreateAppointmentInput,
UpdateAppointmentInput,
AppointmentFilters,
GetAvailableSlotsInput,
GetAvailableSlotsResponse,
} from "./types";
class AppointmentService {
private readonly basePath = "/appointments";
/**
* Busca horários disponíveis de um médico
*/
async getAvailableSlots(
data: GetAvailableSlotsInput
): Promise<GetAvailableSlotsResponse> {
const response = await apiClient.post<GetAvailableSlotsResponse>(
"/get-available-slots",
data
);
return response.data;
}
/**
* Lista agendamentos com filtros opcionais
*/
async list(filters?: AppointmentFilters): Promise<Appointment[]> {
const params: Record<string, string> = {};
if (filters?.doctor_id) {
params["doctor_id"] = `eq.${filters.doctor_id}`;
}
if (filters?.patient_id) {
params["patient_id"] = `eq.${filters.patient_id}`;
}
if (filters?.status) {
params["status"] = `eq.${filters.status}`;
}
if (filters?.scheduled_at) {
params["scheduled_at"] = filters.scheduled_at;
}
if (filters?.limit) {
params["limit"] = filters.limit.toString();
}
if (filters?.offset) {
params["offset"] = filters.offset.toString();
}
if (filters?.order) {
params["order"] = filters.order;
}
const response = await apiClient.get<Appointment[]>(this.basePath, {
params,
});
return response.data;
}
/**
* Busca agendamento por ID
*/
async getById(id: string): Promise<Appointment> {
const response = await apiClient.get<Appointment[]>(`${this.basePath}?id=eq.${id}`);
if (response.data && response.data.length > 0) {
return response.data[0];
}
throw new Error("Agendamento não encontrado");
}
/**
* Cria novo agendamento
* Nota: order_number é gerado automaticamente (APT-YYYY-NNNN)
*/
async create(data: CreateAppointmentInput): Promise<Appointment> {
const response = await apiClient.post<Appointment>(this.basePath, data);
return response.data;
}
/**
* Atualiza agendamento existente
*/
async update(id: string, data: UpdateAppointmentInput): Promise<Appointment> {
const response = await apiClient.patch<Appointment[]>(
`${this.basePath}?id=eq.${id}`,
data
);
if (response.data && response.data.length > 0) {
return response.data[0];
}
throw new Error("Agendamento não encontrado");
}
/**
* Deleta agendamento
*/
async delete(id: string): Promise<void> {
await apiClient.delete(`${this.basePath}?id=eq.${id}`);
}
}
export const appointmentService = new AppointmentService();

View File

@ -1,87 +1,88 @@
/**
* Tipos para o módulo de Agendamentos
*/
export type AppointmentType = "presencial" | "telemedicina";
export type AppointmentStatus =
| "requested"
| "confirmed"
| "checked_in"
| "in_progress"
| "completed"
| "cancelled"
| "no_show";
export interface Appointment {
id: string;
order_number: string; // APT-YYYY-NNNN (auto-gerado)
patient_id: string;
doctor_id: string;
scheduled_at: string;
duration_minutes: number;
appointment_type: AppointmentType;
status: AppointmentStatus;
chief_complaint: string | null;
patient_notes: string | null;
notes: string | null;
insurance_provider: string | null;
checked_in_at: string | null;
completed_at: string | null;
cancelled_at: string | null;
cancellation_reason: string | null;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
}
export interface CreateAppointmentInput {
patient_id: string;
doctor_id: string;
scheduled_at: string;
duration_minutes?: number;
appointment_type?: AppointmentType;
chief_complaint?: string;
patient_notes?: string;
insurance_provider?: string;
created_by?: string;
}
export interface UpdateAppointmentInput {
scheduled_at?: string;
duration_minutes?: number;
status?: AppointmentStatus;
chief_complaint?: string;
notes?: string;
patient_notes?: string;
insurance_provider?: string;
checked_in_at?: string;
completed_at?: string;
cancelled_at?: string;
cancellation_reason?: string;
}
export interface AppointmentFilters {
doctor_id?: string;
patient_id?: string;
status?: AppointmentStatus;
scheduled_at?: string; // Use com operadores: gte.2025-10-10
limit?: number;
offset?: number;
order?: string;
}
export interface GetAvailableSlotsInput {
doctor_id: string;
date: string; // YYYY-MM-DD format
}
export interface TimeSlot {
time: string; // HH:MM format (e.g., "09:00")
available: boolean;
}
export interface GetAvailableSlotsResponse {
slots: TimeSlot[];
}
/**
* Tipos para o módulo de Agendamentos
*/
export type AppointmentType = "presencial" | "telemedicina";
export type AppointmentStatus =
| "requested"
| "confirmed"
| "checked_in"
| "in_progress"
| "completed"
| "cancelled"
| "no_show";
export interface Appointment {
id: string;
order_number: string; // APT-YYYY-NNNN (auto-gerado)
patient_id: string;
doctor_id: string;
scheduled_at: string;
duration_minutes: number;
appointment_type: AppointmentType;
status: AppointmentStatus;
chief_complaint: string | null;
patient_notes: string | null;
notes: string | null;
insurance_provider: string | null;
checked_in_at: string | null;
completed_at: string | null;
cancelled_at: string | null;
cancellation_reason: string | null;
created_at: string;
updated_at: string;
created_by: string;
updated_by: string | null;
}
export interface CreateAppointmentInput {
patient_id: string;
doctor_id: string;
scheduled_at: string;
duration_minutes?: number;
appointment_type?: AppointmentType;
chief_complaint?: string;
patient_notes?: string;
insurance_provider?: string;
}
export interface UpdateAppointmentInput {
scheduled_at?: string;
duration_minutes?: number;
status?: AppointmentStatus;
chief_complaint?: string;
notes?: string;
patient_notes?: string;
insurance_provider?: string;
checked_in_at?: string;
completed_at?: string;
cancelled_at?: string;
cancellation_reason?: string;
}
export interface AppointmentFilters {
doctor_id?: string;
patient_id?: string;
status?: AppointmentStatus;
scheduled_at?: string; // Use com operadores: gte.2025-10-10
limit?: number;
offset?: number;
order?: string;
}
export interface GetAvailableSlotsInput {
doctor_id: string;
start_date: string;
end_date: string;
appointment_type?: AppointmentType;
}
export interface TimeSlot {
datetime: string;
available: boolean;
}
export interface GetAvailableSlotsResponse {
slots: TimeSlot[];
}

View File

@ -1,59 +1,59 @@
/**
* Serviço de Atribuições de Pacientes (Frontend)
*/
import { apiClient } from "../api/client";
import type {
PatientAssignment,
CreateAssignmentInput,
AssignmentFilters,
} from "./types";
class AssignmentService {
/**
* Lista todas as atribuições de pacientes
*/
async list(filters?: AssignmentFilters): Promise<PatientAssignment[]> {
try {
// Monta query params para filtros
const params = new URLSearchParams();
if (filters?.patient_id) {
params.append("patient_id", filters.patient_id);
}
if (filters?.user_id) {
params.append("user_id", filters.user_id);
}
if (filters?.role) {
params.append("role", filters.role);
}
const queryString = params.toString();
const url = queryString ? `/assignments?${queryString}` : "/assignments";
const response = await apiClient.get<PatientAssignment[]>(url);
return response.data;
} catch (error) {
console.error("Erro ao listar atribuições:", error);
throw error;
}
}
/**
* Cria nova atribuição de paciente
*/
async create(data: CreateAssignmentInput): Promise<PatientAssignment> {
try {
const response = await apiClient.post<PatientAssignment>(
"/assignments",
data
);
return response.data;
} catch (error) {
console.error("Erro ao criar atribuição:", error);
throw error;
}
}
}
export const assignmentService = new AssignmentService();
/**
* Serviço de Atribuições de Pacientes (Frontend)
*/
import { apiClient } from "../api/client";
import type {
PatientAssignment,
CreateAssignmentInput,
AssignmentFilters,
} from "./types";
class AssignmentService {
/**
* Lista todas as atribuições de pacientes
*/
async list(filters?: AssignmentFilters): Promise<PatientAssignment[]> {
try {
// Monta query params para filtros
const params = new URLSearchParams();
if (filters?.patient_id) {
params.append("patient_id", filters.patient_id);
}
if (filters?.user_id) {
params.append("user_id", filters.user_id);
}
if (filters?.role) {
params.append("role", filters.role);
}
const queryString = params.toString();
const url = queryString ? `/assignments?${queryString}` : "/assignments";
const response = await apiClient.get<PatientAssignment[]>(url);
return response.data;
} catch (error) {
console.error("Erro ao listar atribuições:", error);
throw error;
}
}
/**
* Cria nova atribuição de paciente
*/
async create(data: CreateAssignmentInput): Promise<PatientAssignment> {
try {
const response = await apiClient.post<PatientAssignment>(
"/assignments",
data
);
return response.data;
} catch (error) {
console.error("Erro ao criar atribuição:", error);
throw error;
}
}
}
export const assignmentService = new AssignmentService();

View File

@ -1,26 +1,26 @@
/**
* Types para Atribuições de Pacientes
*/
export type AssignmentRole = "medico" | "enfermeiro";
export interface PatientAssignment {
id?: string;
patient_id: string;
user_id: string;
role: AssignmentRole;
created_at?: string;
created_by?: string;
}
export interface CreateAssignmentInput {
patient_id: string;
user_id: string;
role: AssignmentRole;
}
export interface AssignmentFilters {
patient_id?: string;
user_id?: string;
role?: AssignmentRole;
}
/**
* Types para Atribuições de Pacientes
*/
export type AssignmentRole = "medico" | "enfermeiro";
export interface PatientAssignment {
id?: string;
patient_id: string;
user_id: string;
role: AssignmentRole;
created_at?: string;
created_by?: string;
}
export interface CreateAssignmentInput {
patient_id: string;
user_id: string;
role: AssignmentRole;
}
export interface AssignmentFilters {
patient_id?: string;
user_id?: string;
role?: AssignmentRole;
}

View File

@ -0,0 +1,187 @@
/**
* Serviço de Autenticação (Frontend)
* Chama as Netlify Functions, não o Supabase direto
*/
import { apiClient } from "../api/client";
import { API_CONFIG } from "../api/config";
import type {
LoginInput,
LoginResponse,
AuthUser,
RefreshTokenResponse,
} from "./types";
class AuthService {
/**
* Faz login com email e senha
*/
async login(credentials: LoginInput): Promise<LoginResponse> {
try {
const response = await apiClient.post<LoginResponse>(
"/auth-login",
credentials
);
// Salva tokens e user no localStorage
if (response.data.access_token) {
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
response.data.access_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN,
response.data.refresh_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.USER,
JSON.stringify(response.data.user)
);
}
return response.data;
} catch (error) {
console.error("Erro no login:", error);
throw error;
}
}
/**
* Envia magic link para o email do usuário
* POST /auth/v1/otp
*/
async sendMagicLink(
email: string,
redirectUrl?: string
): Promise<{ success: boolean; message: string }> {
try {
const response = await apiClient.post<{
success: boolean;
message: string;
}>("/auth-magic-link", {
email,
redirect_url: redirectUrl,
});
return response.data;
} catch (error) {
console.error("Erro ao enviar magic link:", error);
throw error;
}
}
/**
* Solicita reset de senha via email (público)
* POST /request-password-reset
*/
async requestPasswordReset(
email: string,
redirectUrl?: string
): Promise<{ success: boolean; message: string }> {
try {
const response = await (apiClient as any).postPublic<{
success: boolean;
message: string;
}>("/request-password-reset", {
email,
redirect_url: redirectUrl,
});
return response.data;
} catch (error) {
console.error("Erro ao solicitar reset de senha:", error);
throw error;
}
}
/**
* Faz logout (invalida sessão no servidor e limpa localStorage)
*/
async logout(): Promise<void> {
try {
// Chama API para invalidar sessão no servidor
await apiClient.post("/auth-logout");
} catch (error) {
console.error("Erro ao invalidar sessão no servidor:", error);
// Continua mesmo com erro, para garantir limpeza local
} finally {
// Sempre limpa o localStorage
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(API_CONFIG.STORAGE_KEYS.USER);
}
}
/**
* Verifica se usuário está autenticado
*/
isAuthenticated(): boolean {
return !!localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
}
/**
* Retorna o usuário atual do localStorage
*/
getCurrentUser(): AuthUser | null {
const userStr = localStorage.getItem(API_CONFIG.STORAGE_KEYS.USER);
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
}
/**
* Retorna o access token
*/
getAccessToken(): string | null {
return localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
}
/**
* Renova o access token usando o refresh token
*/
async refreshToken(): Promise<RefreshTokenResponse> {
try {
const refreshToken = localStorage.getItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN
);
if (!refreshToken) {
throw new Error("Refresh token não encontrado");
}
const response = await apiClient.post<RefreshTokenResponse>(
"/auth-refresh",
{
refresh_token: refreshToken,
}
);
// Atualiza tokens e user no localStorage
if (response.data.access_token) {
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN,
response.data.access_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.REFRESH_TOKEN,
response.data.refresh_token
);
localStorage.setItem(
API_CONFIG.STORAGE_KEYS.USER,
JSON.stringify(response.data.user)
);
}
return response.data;
} catch (error) {
console.error("Erro ao renovar token:", error);
// Se falhar, limpa tudo e força novo login
this.logout();
throw error;
}
}
}
export const authService = new AuthService();

View File

@ -1,42 +1,42 @@
/**
* Types para Autenticação
*/
export interface LoginInput {
email: string;
password: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: {
id: string;
email: string;
email_confirmed_at: string;
};
}
export interface AuthUser {
id: string;
email: string;
email_confirmed_at: string;
}
export interface RefreshTokenInput {
refresh_token: string;
}
export interface RefreshTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: {
id: string;
email: string;
email_confirmed_at: string;
};
}
/**
* Types para Autenticação
*/
export interface LoginInput {
email: string;
password: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: {
id: string;
email: string;
email_confirmed_at: string;
};
}
export interface AuthUser {
id: string;
email: string;
email_confirmed_at: string;
}
export interface RefreshTokenInput {
refresh_token: string;
}
export interface RefreshTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
user: {
id: string;
email: string;
email_confirmed_at: string;
};
}

View File

@ -0,0 +1,61 @@
/**
* Availability Service
*
* Serviço para gerenciamento de disponibilidade dos médicos
*/
import { apiClient } from "../api/client";
import {
DoctorAvailability,
ListAvailabilityFilters,
CreateAvailabilityInput,
UpdateAvailabilityInput,
} from "./types";
class AvailabilityService {
private readonly basePath = "/doctor-availability";
/**
* Lista as disponibilidades dos médicos
*/
async list(filters?: ListAvailabilityFilters): Promise<DoctorAvailability[]> {
const response = await apiClient.get<DoctorAvailability[]>(this.basePath, {
params: filters,
});
return response.data;
}
/**
* Cria uma nova configuração de disponibilidade
*/
async create(data: CreateAvailabilityInput): Promise<DoctorAvailability> {
const response = await apiClient.post<DoctorAvailability>(
this.basePath,
data
);
return response.data;
}
/**
* Atualiza uma configuração de disponibilidade
*/
async update(
id: string,
data: UpdateAvailabilityInput
): Promise<DoctorAvailability> {
const response = await apiClient.patch<DoctorAvailability>(
`${this.basePath}/${id}`,
data
);
return response.data;
}
/**
* Remove uma configuração de disponibilidade
*/
async delete(id: string): Promise<void> {
await apiClient.delete(`${this.basePath}/${id}`);
}
}
export const availabilityService = new AvailabilityService();

View File

@ -0,0 +1,74 @@
/**
* Availability Module Types
*
* Tipos para gerenciamento de disponibilidade dos médicos
*/
/**
* Dias da semana
*/
export type Weekday =
| "segunda"
| "terca"
| "quarta"
| "quinta"
| "sexta"
| "sabado"
| "domingo";
/**
* Tipo de atendimento
*/
export type AppointmentType = "presencial" | "telemedicina";
/**
* Interface para disponibilidade de médico
*/
export interface DoctorAvailability {
id?: string;
doctor_id?: string;
weekday?: Weekday;
start_time?: string; // Formato: HH:MM:SS (ex: "09:00:00")
end_time?: string; // Formato: HH:MM:SS (ex: "17:00:00")
slot_minutes?: number; // Default: 30
appointment_type?: AppointmentType;
active?: boolean; // Default: true
created_at?: string;
updated_at?: string;
created_by?: string;
updated_by?: string | null;
}
/**
* Filtros para listagem de disponibilidades
*/
export interface ListAvailabilityFilters {
select?: string;
doctor_id?: string;
active?: boolean;
}
/**
* Input para criar disponibilidade
*/
export interface CreateAvailabilityInput {
doctor_id: string; // required
weekday: Weekday; // required
start_time: string; // required - Formato: HH:MM:SS (ex: "09:00:00")
end_time: string; // required - Formato: HH:MM:SS (ex: "17:00:00")
slot_minutes?: number; // optional - Default: 30
appointment_type?: AppointmentType; // optional - Default: 'presencial'
active?: boolean; // optional - Default: true
}
/**
* Input para atualizar disponibilidade
*/
export interface UpdateAvailabilityInput {
weekday?: Weekday;
start_time?: string; // Formato: HH:MM:SS (ex: "09:00:00")
end_time?: string; // Formato: HH:MM:SS (ex: "17:00:00")
slot_minutes?: number;
appointment_type?: AppointmentType;
active?: boolean;
}

Some files were not shown because too many files have changed in this diff Show More