Compare commits
2 Commits
c5461858b0
...
6b9bfbbd29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b9bfbbd29 | ||
|
|
7768ebc46d |
278
MEDICONNECT 2/AGENDAMENTO-SLOTS-API.md
Normal file
278
MEDICONNECT 2/AGENDAMENTO-SLOTS-API.md
Normal file
@ -0,0 +1,278 @@
|
||||
# Sistema de Agendamento com API de Slots
|
||||
|
||||
## Implementação Concluída ✅
|
||||
|
||||
### Fluxo de Agendamento
|
||||
|
||||
1. **Usuário seleciona médico** → Mostra calendário
|
||||
2. **Usuário seleciona data** → Chama API de slots disponíveis
|
||||
3. **API calcula horários** → Considera:
|
||||
- Disponibilidade do médico (agenda configurada)
|
||||
- Exceções (bloqueios e horários extras)
|
||||
- Antecedência mínima para agendamento
|
||||
- Consultas já agendadas
|
||||
4. **Usuário seleciona horário** e preenche motivo
|
||||
5. **Sistema cria agendamento** → Salva no banco
|
||||
|
||||
---
|
||||
|
||||
## APIs Implementadas
|
||||
|
||||
### 1. Calcular Slots Disponíveis
|
||||
|
||||
**Endpoint**: `POST /functions/v1/get-available-slots`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"doctor_id": "uuid-do-medico",
|
||||
"date": "2025-10-30"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"slots": [
|
||||
{
|
||||
"time": "09:00",
|
||||
"available": true
|
||||
},
|
||||
{
|
||||
"time": "09:30",
|
||||
"available": false
|
||||
},
|
||||
{
|
||||
"time": "10:00",
|
||||
"available": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Código Implementado**:
|
||||
```typescript
|
||||
// src/services/appointments/appointmentService.ts
|
||||
async getAvailableSlots(data: GetAvailableSlotsInput): Promise<GetAvailableSlotsResponse> {
|
||||
const response = await apiClient.post<GetAvailableSlotsResponse>(
|
||||
"/functions/v1/get-available-slots",
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Criar Agendamento
|
||||
|
||||
**Endpoint**: `POST /rest/v1/appointments`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"doctor_id": "uuid-do-medico",
|
||||
"patient_id": "uuid-do-paciente",
|
||||
"scheduled_at": "2025-10-30T09:00:00Z",
|
||||
"duration_minutes": 30,
|
||||
"appointment_type": "presencial",
|
||||
"chief_complaint": "Consulta de rotina",
|
||||
"created_by": "uuid-do-usuario"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "uuid-do-agendamento",
|
||||
"order_number": "APT-2025-0001",
|
||||
"status": "requested",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Código Implementado**:
|
||||
```typescript
|
||||
// src/services/appointments/appointmentService.ts
|
||||
async create(data: CreateAppointmentInput): Promise<Appointment> {
|
||||
const payload = {
|
||||
...data,
|
||||
duration_minutes: data.duration_minutes || 30,
|
||||
appointment_type: data.appointment_type || "presencial",
|
||||
status: "requested",
|
||||
};
|
||||
|
||||
const response = await apiClient.post<Appointment[]>(
|
||||
"/rest/v1/appointments",
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data[0];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Componente AgendamentoConsulta
|
||||
|
||||
### Principais Melhorias
|
||||
|
||||
#### Antes ❌
|
||||
- Calculava slots manualmente no frontend
|
||||
- Precisava carregar disponibilidade + exceções separadamente
|
||||
- Lógica complexa de validação no cliente
|
||||
- Não considerava antecedência mínima
|
||||
- Não verificava consultas já agendadas
|
||||
|
||||
#### Depois ✅
|
||||
- Usa Edge Function para calcular slots
|
||||
- API retorna apenas horários realmente disponíveis
|
||||
- Validações centralizadas no backend
|
||||
- Considera todas as regras de negócio
|
||||
- Performance melhorada (menos requisições)
|
||||
|
||||
### Código Simplificado
|
||||
|
||||
```typescript
|
||||
// src/components/AgendamentoConsulta.tsx
|
||||
|
||||
const calculateAvailableSlots = useCallback(async () => {
|
||||
if (!selectedDate || !selectedMedico) {
|
||||
setAvailableSlots([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dateStr = format(selectedDate, "yyyy-MM-dd");
|
||||
|
||||
// Chama a Edge Function
|
||||
const response = await appointmentService.getAvailableSlots({
|
||||
doctor_id: selectedMedico.id,
|
||||
date: dateStr,
|
||||
});
|
||||
|
||||
if (response && response.slots) {
|
||||
// Filtra apenas slots disponíveis
|
||||
const available = response.slots
|
||||
.filter((slot) => slot.available)
|
||||
.map((slot) => slot.time);
|
||||
setAvailableSlots(available);
|
||||
} else {
|
||||
setAvailableSlots([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar slots:", error);
|
||||
setAvailableSlots([]);
|
||||
}
|
||||
}, [selectedDate, selectedMedico]);
|
||||
|
||||
const confirmAppointment = async () => {
|
||||
if (!selectedMedico || !selectedDate || !selectedTime || !user) return;
|
||||
|
||||
try {
|
||||
const scheduledAt = format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00Z";
|
||||
|
||||
// Cria o agendamento
|
||||
const appointment = await appointmentService.create({
|
||||
patient_id: user.id,
|
||||
doctor_id: selectedMedico.id,
|
||||
scheduled_at: scheduledAt,
|
||||
duration_minutes: 30,
|
||||
appointment_type: appointmentType === "online" ? "telemedicina" : "presencial",
|
||||
chief_complaint: motivo,
|
||||
});
|
||||
|
||||
console.log("Consulta criada:", appointment);
|
||||
setBookingSuccess(true);
|
||||
} catch (error) {
|
||||
setBookingError(error.message);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tipos TypeScript
|
||||
|
||||
```typescript
|
||||
// src/services/appointments/types.ts
|
||||
|
||||
export interface GetAvailableSlotsInput {
|
||||
doctor_id: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
time: string; // HH:MM (ex: "09:00")
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface GetAvailableSlotsResponse {
|
||||
slots: TimeSlot[];
|
||||
}
|
||||
|
||||
export interface CreateAppointmentInput {
|
||||
patient_id: string;
|
||||
doctor_id: string;
|
||||
scheduled_at: string; // ISO 8601
|
||||
duration_minutes?: number;
|
||||
appointment_type?: "presencial" | "telemedicina";
|
||||
chief_complaint?: string;
|
||||
patient_notes?: string;
|
||||
insurance_provider?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefícios da Implementação
|
||||
|
||||
✅ **Performance**: Menos requisições ao backend
|
||||
✅ **Confiabilidade**: Validações centralizadas
|
||||
✅ **Manutenibilidade**: Lógica de negócio no servidor
|
||||
✅ **Escalabilidade**: Edge Functions são otimizadas
|
||||
✅ **UX**: Interface mais responsiva e clara
|
||||
✅ **Segurança**: Validações no backend não podem ser burladas
|
||||
|
||||
---
|
||||
|
||||
## Próximos Passos (Opcional)
|
||||
|
||||
- [ ] Adicionar loading states mais detalhados
|
||||
- [ ] Implementar cache de slots (evitar chamadas repetidas)
|
||||
- [ ] Adicionar retry automático em caso de falha
|
||||
- [ ] Mostrar motivo quando slot não está disponível
|
||||
- [ ] Implementar notificações (SMS/Email) após agendamento
|
||||
|
||||
---
|
||||
|
||||
## Testando
|
||||
|
||||
### 1. Selecione um médico
|
||||
### 2. Selecione uma data futura
|
||||
### 3. Verifique os slots disponíveis
|
||||
### 4. Selecione um horário
|
||||
### 5. Preencha o motivo
|
||||
### 6. Confirme o agendamento
|
||||
|
||||
**Logs no Console**:
|
||||
```
|
||||
[AppointmentService] Buscando slots para: {doctor_id, date}
|
||||
[AppointmentService] Slots recebidos: 12 slots
|
||||
[AppointmentService] Criando agendamento...
|
||||
[AppointmentService] Consulta criada: {id, order_number, ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data de Implementação
|
||||
|
||||
**30 de Outubro de 2025**
|
||||
|
||||
Implementado por: GitHub Copilot
|
||||
Revisado por: Equipe RiseUp Squad 18
|
||||
@ -1,348 +0,0 @@
|
||||
# Configuração das APIs - MediConnect
|
||||
|
||||
## ✅ APIs Testadas e Funcionando
|
||||
|
||||
### 1. Autenticação (Auth API)
|
||||
|
||||
**Base URL:** `https://yuanqfswhberkoevtmfr.supabase.co/auth/v1`
|
||||
|
||||
#### Endpoints Funcionais:
|
||||
|
||||
- **Login** ✅
|
||||
|
||||
- `POST /token?grant_type=password`
|
||||
- Body: `{ email, password }`
|
||||
- Retorna: `{ access_token, refresh_token, user }`
|
||||
|
||||
- **Recuperação de Senha** ✅
|
||||
|
||||
- `POST /recover`
|
||||
- Body: `{ email, options: { redirectTo: url } }`
|
||||
- Envia email com link de recuperação
|
||||
|
||||
- **Atualizar Senha** ✅
|
||||
- `PUT /user`
|
||||
- Headers: `Authorization: Bearer <access_token>`
|
||||
- Body: `{ password: "nova_senha" }`
|
||||
- **IMPORTANTE:** Nova senha deve ser diferente da anterior (erro 422 se for igual)
|
||||
|
||||
### 2. REST API
|
||||
|
||||
**Base URL:** `https://yuanqfswhberkoevtmfr.supabase.co/rest/v1`
|
||||
|
||||
#### Tabelas e Campos Corretos:
|
||||
|
||||
##### **appointments** ✅
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string (UUID)
|
||||
order_number: string (auto-gerado: APT-YYYY-NNNN)
|
||||
patient_id: string (UUID)
|
||||
doctor_id: string (UUID)
|
||||
scheduled_at: string (ISO 8601 DateTime)
|
||||
duration_minutes: number
|
||||
appointment_type: "presencial" | "telemedicina"
|
||||
status: "requested" | "confirmed" | "checked_in" | "in_progress" | "completed" | "cancelled" | "no_show"
|
||||
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 (UUID)
|
||||
updated_by: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**Criar Consulta:**
|
||||
|
||||
```bash
|
||||
POST /rest/v1/appointments
|
||||
Headers:
|
||||
- apikey: <SUPABASE_ANON_KEY>
|
||||
- Authorization: Bearer <user_access_token>
|
||||
- Content-Type: application/json
|
||||
- Prefer: return=representation
|
||||
|
||||
Body:
|
||||
{
|
||||
"patient_id": "uuid",
|
||||
"doctor_id": "uuid",
|
||||
"scheduled_at": "2025-11-03T10:00:00.000Z",
|
||||
"duration_minutes": 30,
|
||||
"appointment_type": "presencial",
|
||||
"chief_complaint": "Motivo da consulta"
|
||||
}
|
||||
```
|
||||
|
||||
##### **doctor_availability** ✅
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string (UUID)
|
||||
doctor_id: string (UUID)
|
||||
weekday: "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
|
||||
start_time: string (HH:MM:SS, ex: "07:00:00")
|
||||
end_time: string (HH:MM:SS, ex: "19:00:00")
|
||||
slot_duration_minutes: number (ex: 30)
|
||||
appointment_type: "presencial" | "telemedicina"
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string (UUID)
|
||||
updated_by: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**Criar Disponibilidade:**
|
||||
|
||||
```bash
|
||||
POST /rest/v1/doctor_availability
|
||||
Headers:
|
||||
- apikey: <SUPABASE_ANON_KEY>
|
||||
- Authorization: Bearer <admin_access_token>
|
||||
- Content-Type: application/json
|
||||
- Prefer: return=representation
|
||||
|
||||
Body:
|
||||
{
|
||||
"doctor_id": "uuid",
|
||||
"weekday": "monday", // ⚠️ Texto, não número!
|
||||
"start_time": "07:00:00",
|
||||
"end_time": "19:00:00",
|
||||
"slot_duration_minutes": 30,
|
||||
"appointment_type": "presencial",
|
||||
"is_active": true,
|
||||
"created_by": "admin_user_id"
|
||||
}
|
||||
```
|
||||
|
||||
##### **patients** ✅
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string(UUID);
|
||||
user_id: string(UUID); // ⚠️ Deve estar vinculado ao auth.users
|
||||
full_name: string;
|
||||
email: string;
|
||||
cpf: string;
|
||||
phone_mobile: string;
|
||||
// ... outros campos
|
||||
}
|
||||
```
|
||||
|
||||
**Atualizar Patient:**
|
||||
|
||||
```bash
|
||||
PATCH /rest/v1/patients?id=eq.<patient_id>
|
||||
Headers:
|
||||
- apikey: <SUPABASE_ANON_KEY>
|
||||
- Authorization: Bearer <admin_access_token>
|
||||
- Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"user_id": "auth_user_id"
|
||||
}
|
||||
```
|
||||
|
||||
##### **doctors** ✅
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string(UUID);
|
||||
user_id: string(UUID);
|
||||
full_name: string;
|
||||
email: string;
|
||||
crm: string;
|
||||
crm_uf: string;
|
||||
specialty: string;
|
||||
// ... outros campos
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Edge Functions
|
||||
|
||||
**Base URL:** `https://yuanqfswhberkoevtmfr.supabase.co/functions/v1`
|
||||
|
||||
#### Funcionais:
|
||||
|
||||
- **create-user-with-password** ✅
|
||||
- `POST /functions/v1/create-user-with-password`
|
||||
- Cria usuário com senha e perfil completo
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"email": "email@example.com",
|
||||
"password": "senha123",
|
||||
"full_name": "Nome Completo",
|
||||
"phone_mobile": "(11) 99999-9999",
|
||||
"cpf": "12345678900",
|
||||
"create_patient_record": true,
|
||||
"role": "paciente"
|
||||
}
|
||||
```
|
||||
|
||||
#### Com Problemas:
|
||||
|
||||
- **request-password-reset** ❌
|
||||
- CORS blocking - não usar
|
||||
- Usar diretamente `/auth/v1/recover` em vez disso
|
||||
|
||||
## 🔑 Chaves de API
|
||||
|
||||
```typescript
|
||||
SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
```
|
||||
|
||||
## 👥 Usuários de Teste
|
||||
|
||||
### Admin
|
||||
|
||||
- Email: `riseup@popcode.com.br`
|
||||
- Senha: `riseup`
|
||||
|
||||
### Dr. Fernando Pirichowski
|
||||
|
||||
- Email: `fernando.pirichowski@souunit.com.br`
|
||||
- Senha: `fernando123`
|
||||
- User ID: `38aca60d-7418-4c35-95b6-cb206bb18a0a`
|
||||
- Doctor ID: `6dad001d-229b-40b5-80f3-310243c4599c`
|
||||
- CRM: `24245`
|
||||
- Disponibilidade: Segunda a Domingo, 07:00-19:00
|
||||
|
||||
### Aurora Sabrina Clara Nascimento (Paciente)
|
||||
|
||||
- Email: `aurora-nascimento94@gmx.com`
|
||||
- Senha: `auroranasc94`
|
||||
- User ID: `6dc15cc5-7dae-4b30-924a-a4b4fa142f24`
|
||||
- Patient ID: `b85486f7-9135-4b67-9aa7-b884d9603d12`
|
||||
- CPF: `66864784231`
|
||||
- Telefone: `(21) 99856-3014`
|
||||
|
||||
## ⚠️ Pontos de Atenção
|
||||
|
||||
### 1. Weekday no doctor_availability
|
||||
|
||||
- ❌ **NÃO** usar números (0-6)
|
||||
- ✅ **USAR** strings em inglês: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`
|
||||
|
||||
### 2. scheduled_at em appointments
|
||||
|
||||
- ❌ **NÃO** usar campos separados `appointment_date` e `appointment_time`
|
||||
- ✅ **USAR** campo único `scheduled_at` com ISO 8601 DateTime
|
||||
- Exemplo: `"2025-11-03T10:00:00.000Z"`
|
||||
|
||||
### 3. user_id nas tabelas patients e doctors
|
||||
|
||||
- ⚠️ Sempre vincular ao `auth.users.id`
|
||||
- Sem esse vínculo, queries por `user_id` não funcionam
|
||||
|
||||
### 4. Senha na recuperação
|
||||
|
||||
- ⚠️ Nova senha DEVE ser diferente da anterior
|
||||
- Erro 422 com `error_code: "same_password"` se tentar usar a mesma
|
||||
|
||||
### 5. redirectTo no password recovery
|
||||
|
||||
- ⚠️ Supabase pode ignorar o parâmetro `redirectTo`
|
||||
- ✅ Implementar detecção de token no lado do cliente
|
||||
- Verificar tanto query string `?token=` quanto hash `#access_token=`
|
||||
|
||||
## 📦 Estrutura de Serviços no Frontend
|
||||
|
||||
```typescript
|
||||
// Tudo configurado em:
|
||||
src / services / api / config.ts; // URLs e chaves
|
||||
src / services / api / client.ts; // Cliente axios
|
||||
src /
|
||||
services /
|
||||
appointments / // Serviço de consultas
|
||||
src /
|
||||
services /
|
||||
availability / // Disponibilidade médicos
|
||||
src /
|
||||
services /
|
||||
auth / // Autenticação
|
||||
src /
|
||||
services /
|
||||
doctors / // Médicos
|
||||
src /
|
||||
services /
|
||||
patients / // Pacientes
|
||||
src /
|
||||
services /
|
||||
index.ts; // Exportações centralizadas
|
||||
```
|
||||
|
||||
## ✅ Status Atual
|
||||
|
||||
- [x] Autenticação funcionando
|
||||
- [x] Recuperação de senha funcionando
|
||||
- [x] Criação de usuários funcionando
|
||||
- [x] Criação de pacientes funcionando
|
||||
- [x] Criação de disponibilidade médica funcionando
|
||||
- [x] Criação de consultas funcionando
|
||||
- [x] Vinculação user_id ↔ patient_id corrigida
|
||||
- [x] Todos os serviços usando campos corretos
|
||||
|
||||
## 🚀 Próximos Passos
|
||||
|
||||
1. Testar agendamento completo no frontend
|
||||
2. Verificar listagem de consultas
|
||||
3. Testar cancelamento e atualização de consultas
|
||||
4. Verificar notificações SMS
|
||||
5. Testar fluxo completo de check-in e prontuário
|
||||
|
||||
## 📝 Exemplos de Uso
|
||||
|
||||
### Criar Consulta
|
||||
|
||||
```typescript
|
||||
import { appointmentService } from "@/services";
|
||||
|
||||
const appointment = await appointmentService.create({
|
||||
patient_id: "patient-uuid",
|
||||
doctor_id: "doctor-uuid",
|
||||
scheduled_at: "2025-11-03T10:00:00.000Z",
|
||||
duration_minutes: 30,
|
||||
appointment_type: "presencial",
|
||||
chief_complaint: "Consulta de rotina",
|
||||
});
|
||||
```
|
||||
|
||||
### Criar Disponibilidade
|
||||
|
||||
```typescript
|
||||
import { availabilityService } from "@/services";
|
||||
|
||||
const availability = await availabilityService.create({
|
||||
doctor_id: "doctor-uuid",
|
||||
weekday: "monday",
|
||||
start_time: "07:00:00",
|
||||
end_time: "19:00:00",
|
||||
slot_duration_minutes: 30,
|
||||
appointment_type: "presencial",
|
||||
});
|
||||
```
|
||||
|
||||
### Login
|
||||
|
||||
```typescript
|
||||
import { authService } from "@/services";
|
||||
|
||||
const response = await authService.login({
|
||||
email: "user@example.com",
|
||||
password: "senha123",
|
||||
});
|
||||
|
||||
// response.access_token - JWT token
|
||||
// response.user - dados do usuário
|
||||
```
|
||||
@ -1,292 +0,0 @@
|
||||
# ✅ Checklist de Testes - MediConnect
|
||||
|
||||
## 🎯 Testes Funcionais
|
||||
|
||||
### 1. Autenticação ✅
|
||||
|
||||
#### Login
|
||||
|
||||
- [x] Login de admin funcionando
|
||||
- [x] Login de médico (Dr. Fernando) funcionando
|
||||
- [x] Login de paciente (Aurora) funcionando
|
||||
- [x] Token JWT sendo retornado corretamente
|
||||
- [x] Refresh token funcionando
|
||||
|
||||
#### Recuperação de Senha
|
||||
|
||||
- [x] Email de recuperação sendo enviado
|
||||
- [x] Token de recuperação detectado na URL
|
||||
- [x] Reset de senha funcionando (senha diferente da anterior)
|
||||
- [x] Erro 422 tratado quando senha é igual à anterior
|
||||
- [x] Redirecionamento para página de reset funcionando
|
||||
|
||||
### 2. Gestão de Usuários ✅
|
||||
|
||||
#### Criação de Paciente
|
||||
|
||||
- [x] Edge Function `create-user-with-password` funcionando
|
||||
- [x] Paciente criado com auth user
|
||||
- [x] Registro na tabela `patients` criado
|
||||
- [x] `user_id` vinculado corretamente ao `auth.users.id`
|
||||
- [x] Credenciais de login funcionando após criação
|
||||
|
||||
**Usuário Teste:**
|
||||
|
||||
- Email: aurora-nascimento94@gmx.com
|
||||
- Senha: auroranasc94
|
||||
- Patient ID: b85486f7-9135-4b67-9aa7-b884d9603d12
|
||||
|
||||
#### Médicos
|
||||
|
||||
- [x] Dr. Fernando no sistema
|
||||
- [x] User ID vinculado corretamente
|
||||
- [x] Doctor ID identificado
|
||||
- [x] CRM registrado
|
||||
|
||||
**Médico Teste:**
|
||||
|
||||
- Email: fernando.pirichowski@souunit.com.br
|
||||
- Senha: fernando123
|
||||
- Doctor ID: 6dad001d-229b-40b5-80f3-310243c4599c
|
||||
|
||||
### 3. Disponibilidade Médica ✅
|
||||
|
||||
#### Criação de Disponibilidade
|
||||
|
||||
- [x] Script de criação funcionando
|
||||
- [x] Campo `weekday` usando strings em inglês
|
||||
- [x] Formato de horário correto (HH:MM:SS)
|
||||
- [x] Disponibilidade criada para todos os dias da semana
|
||||
- [x] Horário: 07:00 - 19:00
|
||||
- [x] Duração de slot: 30 minutos
|
||||
- [x] Tipo: presencial
|
||||
|
||||
**Status:**
|
||||
|
||||
- ✅ 7 dias configurados (Domingo a Sábado)
|
||||
- ✅ Dr. Fernando disponível das 07:00 às 19:00
|
||||
|
||||
### 4. Agendamento de Consultas ✅
|
||||
|
||||
#### Criação de Consulta
|
||||
|
||||
- [x] API de appointments funcionando
|
||||
- [x] Campo `scheduled_at` usando ISO 8601 DateTime
|
||||
- [x] Consulta criada com status "requested"
|
||||
- [x] Order number gerado automaticamente (APT-YYYY-NNNN)
|
||||
- [x] Duração de 30 minutos configurada
|
||||
- [x] Tipo presencial configurado
|
||||
|
||||
**Consulta Teste:**
|
||||
|
||||
- Paciente: Aurora
|
||||
- Médico: Dr. Fernando
|
||||
- Data: 03/11/2025 às 10:00
|
||||
- Order Number: APT-2025-00027
|
||||
- ID: cb4f608f-e580-437f-8653-75ec74621065
|
||||
|
||||
### 5. Frontend - Componentes
|
||||
|
||||
#### AgendamentoConsulta.tsx ✅
|
||||
|
||||
- [x] Usando `appointmentService` correto
|
||||
- [x] Campo `scheduled_at` implementado
|
||||
- [x] Formato ISO 8601 DateTime
|
||||
- [x] Integração com availability service
|
||||
- [x] Integração com exceptions service
|
||||
- [x] SMS notification configurado
|
||||
|
||||
#### Outros Componentes
|
||||
|
||||
- [ ] BookAppointment.tsx - não usado (pode ser removido)
|
||||
- [ ] AgendamentoConsultaSimples.tsx - não usado (pode ser removido)
|
||||
|
||||
## 🔧 Configurações Verificadas
|
||||
|
||||
### API Config ✅
|
||||
|
||||
- [x] `src/services/api/config.ts` - URLs corretas
|
||||
- [x] SUPABASE_ANON_KEY atualizada
|
||||
- [x] Endpoints configurados corretamente
|
||||
|
||||
### Services ✅
|
||||
|
||||
- [x] `appointmentService` - usando campos corretos
|
||||
- [x] `availabilityService` - usando weekday strings
|
||||
- [x] `authService` - recuperação de senha funcionando
|
||||
- [x] `patientService` - CRUD funcionando
|
||||
- [x] `doctorService` - CRUD funcionando
|
||||
- [x] Todos exportados em `src/services/index.ts`
|
||||
|
||||
## 🧪 Testes Pendentes
|
||||
|
||||
### Fluxo Completo de Agendamento
|
||||
|
||||
- [ ] Paciente faz login
|
||||
- [ ] Paciente busca médicos disponíveis
|
||||
- [ ] Paciente visualiza horários disponíveis
|
||||
- [ ] Paciente agenda consulta
|
||||
- [ ] Consulta aparece na lista do paciente
|
||||
- [ ] Médico visualiza consulta na agenda
|
||||
- [ ] Notificação SMS enviada
|
||||
|
||||
### Check-in e Atendimento
|
||||
|
||||
- [ ] Check-in de paciente
|
||||
- [ ] Status da consulta muda para "checked_in"
|
||||
- [ ] Médico inicia atendimento
|
||||
- [ ] Status muda para "in_progress"
|
||||
- [ ] Preenchimento de prontuário
|
||||
- [ ] Finalização da consulta
|
||||
- [ ] Status muda para "completed"
|
||||
|
||||
### Cancelamento
|
||||
|
||||
- [ ] Paciente cancela consulta
|
||||
- [ ] Médico cancela consulta
|
||||
- [ ] Status muda para "cancelled"
|
||||
- [ ] Motivo do cancelamento registrado
|
||||
- [ ] Horário fica disponível novamente
|
||||
|
||||
### Exceções de Disponibilidade
|
||||
|
||||
- [ ] Criar exceção (feriado, folga)
|
||||
- [ ] Exceção bloqueia horários
|
||||
- [ ] Listar exceções
|
||||
- [ ] Remover exceção
|
||||
|
||||
## 📊 Métricas e Relatórios
|
||||
|
||||
- [ ] Dashboard de consultas
|
||||
- [ ] Estatísticas de atendimento
|
||||
- [ ] Relatório de faturamento
|
||||
- [ ] Exportação de dados
|
||||
|
||||
## 🔐 Segurança
|
||||
|
||||
### Autenticação
|
||||
|
||||
- [x] JWT tokens funcionando
|
||||
- [x] Refresh tokens implementados
|
||||
- [x] Session storage configurado
|
||||
- [ ] Expiração de tokens tratada
|
||||
- [ ] Logout funcionando corretamente
|
||||
|
||||
### Autorização
|
||||
|
||||
- [ ] RLS (Row Level Security) configurado no Supabase
|
||||
- [ ] Paciente só vê suas próprias consultas
|
||||
- [ ] Médico só vê consultas atribuídas
|
||||
- [ ] Admin tem acesso total
|
||||
- [ ] Secretária tem permissões específicas
|
||||
|
||||
## 🌐 Deploy e Performance
|
||||
|
||||
- [ ] Build de produção funcionando
|
||||
- [ ] Deploy no Cloudflare Pages
|
||||
- [ ] URLs de produção configuradas
|
||||
- [ ] Performance otimizada
|
||||
- [ ] Lazy loading de componentes
|
||||
- [ ] Cache configurado
|
||||
|
||||
## 📱 Responsividade
|
||||
|
||||
- [ ] Desktop (1920x1080)
|
||||
- [ ] Laptop (1366x768)
|
||||
- [ ] Tablet (768x1024)
|
||||
- [ ] Mobile (375x667)
|
||||
|
||||
## ♿ Acessibilidade
|
||||
|
||||
- [ ] Menu de acessibilidade funcionando
|
||||
- [ ] Contraste de cores ajustável
|
||||
- [ ] Tamanho de fonte ajustável
|
||||
- [ ] Leitura de tela compatível
|
||||
- [ ] Navegação por teclado
|
||||
|
||||
## 🐛 Bugs Conhecidos
|
||||
|
||||
Nenhum bug crítico identificado até o momento.
|
||||
|
||||
## 📝 Notas Importantes
|
||||
|
||||
### Campos Corretos nas APIs
|
||||
|
||||
1. **appointments.scheduled_at**
|
||||
|
||||
- ❌ NÃO: `appointment_date` e `appointment_time` separados
|
||||
- ✅ SIM: `scheduled_at` com ISO 8601 DateTime
|
||||
|
||||
2. **doctor_availability.weekday**
|
||||
|
||||
- ❌ NÃO: Números 0-6
|
||||
- ✅ SIM: Strings "sunday", "monday", etc.
|
||||
|
||||
3. **patients.user_id**
|
||||
|
||||
- ⚠️ DEVE estar vinculado ao `auth.users.id`
|
||||
- Sem isso, queries por user_id falham
|
||||
|
||||
4. **Password Recovery**
|
||||
- ⚠️ Nova senha DEVE ser diferente da anterior
|
||||
- Erro 422 com `error_code: "same_password"` se igual
|
||||
|
||||
### Scripts Úteis
|
||||
|
||||
```bash
|
||||
# Login como usuário
|
||||
node get-fernando-user-id.cjs
|
||||
|
||||
# Buscar dados de paciente
|
||||
node get-aurora-info.cjs
|
||||
|
||||
# Criar disponibilidade
|
||||
node create-fernando-availability.cjs
|
||||
|
||||
# Criar consulta
|
||||
node create-aurora-appointment.cjs
|
||||
|
||||
# Corrigir user_id
|
||||
node fix-aurora-user-id.cjs
|
||||
```
|
||||
|
||||
## 🚀 Próximas Funcionalidades
|
||||
|
||||
1. **Telemedicina**
|
||||
|
||||
- [ ] Integração com serviço de videochamada
|
||||
- [ ] Sala de espera virtual
|
||||
- [ ] Gravação de consultas (opcional)
|
||||
|
||||
2. **Prontuário Eletrônico**
|
||||
|
||||
- [ ] CRUD completo de prontuários
|
||||
- [ ] Histórico de consultas
|
||||
- [ ] Anexos e exames
|
||||
- [ ] Prescrições médicas
|
||||
|
||||
3. **Notificações**
|
||||
|
||||
- [x] SMS via Twilio configurado
|
||||
- [ ] Email notifications
|
||||
- [ ] Push notifications (PWA)
|
||||
- [ ] Lembretes de consulta
|
||||
|
||||
4. **Pagamentos**
|
||||
|
||||
- [ ] Integração com gateway de pagamento
|
||||
- [ ] Registro de pagamentos
|
||||
- [ ] Emissão de recibos
|
||||
- [ ] Relatório financeiro
|
||||
|
||||
5. **Telemática**
|
||||
- [ ] Assinatura digital de documentos
|
||||
- [ ] Certificação digital A1/A3
|
||||
- [ ] Integração com e-SUS
|
||||
- [ ] Compliance LGPD
|
||||
|
||||
---
|
||||
|
||||
**Última atualização:** 27/10/2025
|
||||
**Status:** ✅ APIs configuradas e funcionando
|
||||
**Próximo passo:** Testar fluxo completo no frontend
|
||||
@ -1,6 +1,9 @@
|
||||
# MediConnect - Sistema de Agendamento Médico
|
||||
|
||||
Aplicação SPA (React + Vite + TypeScript) consumindo **Supabase** (Auth, PostgREST, Storage) diretamente do frontend, hospedada no **Cloudflare Pages**.
|
||||
Sistema completo de gestão médica com agendamento inteligente, prontuários eletrônicos e gerenciamento de pacientes.
|
||||
|
||||
**Stack:** React + TypeScript + Vite + TailwindCSS + Supabase
|
||||
**Deploy:** Cloudflare Pages
|
||||
|
||||
---
|
||||
|
||||
@ -9,101 +12,392 @@ Aplicação SPA (React + Vite + TypeScript) consumindo **Supabase** (Auth, Postg
|
||||
- **URL Principal:** https://mediconnectbrasil.app/
|
||||
- **URL Cloudflare:** https://mediconnect-5oz.pages.dev/
|
||||
|
||||
---
|
||||
### Credenciais de Teste
|
||||
|
||||
## 🏗️ Arquitetura Atual (Outubro 2025)
|
||||
**Médico:**
|
||||
- Email: medico@teste.com
|
||||
- Senha: senha123
|
||||
|
||||
```
|
||||
Frontend (Vite/React) → Supabase API
|
||||
↓ ├── Auth (JWT)
|
||||
Cloudflare Pages ├── PostgREST (PostgreSQL)
|
||||
└── Storage (Avatares)
|
||||
```
|
||||
**Paciente:**
|
||||
- Email: paciente@teste.com
|
||||
- Senha: senha123
|
||||
|
||||
**Mudança importante:** O sistema **não usa mais Netlify Functions**. Toda comunicação é direta entre frontend e Supabase via services (`authService`, `userService`, `patientService`, `avatarService`, etc.).
|
||||
**Secretária:**
|
||||
- Email: secretaria@teste.com
|
||||
- Senha: senha123
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Guias de Início Rápido
|
||||
## 🏗️ Arquitetura
|
||||
|
||||
**Primeira vez rodando o projeto?**
|
||||
```
|
||||
Frontend (React/Vite)
|
||||
↓
|
||||
Supabase Backend
|
||||
├── Auth (JWT + Magic Link)
|
||||
├── PostgreSQL (PostgREST)
|
||||
├── Edge Functions (Slots, Criação de Usuários)
|
||||
└── Storage (Avatares, Documentos)
|
||||
↓
|
||||
Cloudflare Pages (Deploy)
|
||||
```
|
||||
|
||||
### Instalação Rápida (5 minutos)
|
||||
---
|
||||
|
||||
```powershell
|
||||
# 1. Instalar dependências
|
||||
## <20> Instalação e Execução
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
- Node.js 18+
|
||||
- pnpm (recomendado) ou npm
|
||||
|
||||
### Instalação
|
||||
|
||||
```bash
|
||||
# Instalar dependências
|
||||
pnpm install
|
||||
|
||||
# 2. Iniciar servidor de desenvolvimento
|
||||
# Iniciar desenvolvimento
|
||||
pnpm dev
|
||||
|
||||
# 3. Acessar http://localhost:5173
|
||||
# Acessar em http://localhost:5173
|
||||
```
|
||||
|
||||
### Build e Deploy
|
||||
|
||||
```powershell
|
||||
```bash
|
||||
# Build de produção
|
||||
pnpm build
|
||||
|
||||
# Deploy para Cloudflare
|
||||
npx wrangler pages deploy dist --project-name=mediconnect --branch=production
|
||||
# Deploy para Cloudflare Pages
|
||||
pnpm wrangler pages deploy dist --project-name=mediconnect --branch=production
|
||||
```
|
||||
|
||||
📚 **Documentação completa:** Veja o [README principal](../README.md) com arquitetura, API e serviços.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ SISTEMA DE AUTENTICAÇÃO E PERMISSÕES
|
||||
## ✨ Funcionalidades Principais
|
||||
|
||||
### Autenticação JWT com Supabase
|
||||
### 🏥 Para Médicos
|
||||
- ✅ Agenda personalizada com disponibilidade configurável
|
||||
- ✅ Gerenciamento de exceções (bloqueios e horários extras)
|
||||
- ✅ Prontuário eletrônico completo
|
||||
- ✅ Histórico de consultas do paciente
|
||||
- ✅ Dashboard com métricas e estatísticas
|
||||
- ✅ Teleconsulta e presencial
|
||||
|
||||
O sistema usa **Supabase Auth** com tokens JWT. Todo login retorna:
|
||||
### 👥 Para Pacientes
|
||||
- ✅ Agendamento inteligente com slots disponíveis em tempo real
|
||||
- ✅ Histórico completo de consultas
|
||||
- ✅ Visualização e download de relatórios médicos (PDF)
|
||||
- ✅ Perfil com avatar e dados pessoais
|
||||
- ✅ Filtros por médico, especialidade e data
|
||||
|
||||
- `access_token` (JWT, expira em 1 hora)
|
||||
- `refresh_token` (para renovação automática)
|
||||
### 🏢 Para Secretárias
|
||||
- ✅ Gerenciamento completo de médicos, pacientes e consultas
|
||||
- ✅ Cadastro com validação de CPF e CRM
|
||||
- ✅ Configuração de agenda médica (horários e exceções)
|
||||
- ✅ Busca e filtros avançados
|
||||
- ✅ Confirmação profissional para exclusões
|
||||
|
||||
### Interceptors Automáticos
|
||||
### 🔐 Sistema de Autenticação
|
||||
- ✅ Login com email/senha
|
||||
- ✅ Magic Link (login sem senha)
|
||||
- ✅ Recuperação de senha
|
||||
- ✅ Tokens JWT com refresh automático
|
||||
- ✅ Controle de acesso por role (médico/paciente/secretária)
|
||||
|
||||
```typescript
|
||||
// Adiciona token automaticamente em todas as requisições
|
||||
axios.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
---
|
||||
|
||||
## 🔧 Tecnologias
|
||||
|
||||
### Frontend
|
||||
- **React 18** - Interface moderna e reativa
|
||||
- **TypeScript** - Tipagem estática
|
||||
- **Vite** - Build ultra-rápido
|
||||
- **TailwindCSS** - Estilização utilitária
|
||||
- **React Router** - Navegação SPA
|
||||
- **Axios** - Cliente HTTP
|
||||
- **date-fns** - Manipulação de datas
|
||||
- **jsPDF** - Geração de PDFs
|
||||
- **Lucide Icons** - Ícones modernos
|
||||
|
||||
### Backend (Supabase)
|
||||
- **PostgreSQL** - Banco de dados relacional
|
||||
- **PostgREST** - API REST automática
|
||||
- **Edge Functions** - Funções serverless (Deno)
|
||||
- **Storage** - Armazenamento de arquivos
|
||||
- **Auth** - Autenticação e autorização
|
||||
|
||||
### Deploy
|
||||
- **Cloudflare Pages** - Hospedagem global com CDN
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estrutura do Projeto
|
||||
|
||||
// Refresh automático quando token expira
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
const newTokens = await authService.refreshToken(refreshToken);
|
||||
// Retry request original
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
MEDICONNECT 2/
|
||||
├── src/
|
||||
│ ├── components/ # Componentes React
|
||||
│ │ ├── auth/ # Login, cadastro, recuperação
|
||||
│ │ ├── secretaria/ # Painéis da secretária
|
||||
│ │ ├── agenda/ # Sistema de agendamento
|
||||
│ │ ├── consultas/ # Gerenciamento de consultas
|
||||
│ │ └── ui/ # Componentes reutilizáveis
|
||||
│ ├── pages/ # Páginas da aplicação
|
||||
│ │ ├── Home.tsx
|
||||
│ │ ├── PainelMedico.tsx
|
||||
│ │ ├── PainelSecretaria.tsx
|
||||
│ │ └── AgendamentoPaciente.tsx
|
||||
│ ├── services/ # Camada de API
|
||||
│ │ ├── api/ # Cliente HTTP
|
||||
│ │ ├── auth/ # Autenticação
|
||||
│ │ ├── appointments/ # Agendamentos
|
||||
│ │ ├── doctors/ # Médicos
|
||||
│ │ ├── patients/ # Pacientes
|
||||
│ │ ├── availability/ # Disponibilidade
|
||||
│ │ └── avatars/ # Avatares
|
||||
│ ├── context/ # Context API
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── types/ # TypeScript types
|
||||
│ └── utils/ # Funções utilitárias
|
||||
├── public/ # Arquivos estáticos
|
||||
├── scripts/ # Scripts de utilidade
|
||||
└── dist/ # Build de produção
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 APIs e Serviços
|
||||
|
||||
### Principais Endpoints
|
||||
|
||||
#### Agendamentos
|
||||
```typescript
|
||||
// Buscar slots disponíveis (Edge Function)
|
||||
POST /functions/v1/get-available-slots
|
||||
{
|
||||
"doctor_id": "uuid",
|
||||
"date": "2025-10-30"
|
||||
}
|
||||
|
||||
// Criar agendamento
|
||||
POST /rest/v1/appointments
|
||||
{
|
||||
"doctor_id": "uuid",
|
||||
"patient_id": "uuid",
|
||||
"scheduled_at": "2025-10-30T09:00:00Z",
|
||||
"duration_minutes": 30,
|
||||
"appointment_type": "presencial"
|
||||
}
|
||||
```
|
||||
|
||||
#### Disponibilidade
|
||||
```typescript
|
||||
// Listar disponibilidade do médico
|
||||
GET /rest/v1/doctor_availability?doctor_id=eq.{uuid}
|
||||
|
||||
// Criar horário de atendimento
|
||||
POST /rest/v1/doctor_availability
|
||||
|
||||
// Atualizar disponibilidade
|
||||
PATCH /rest/v1/doctor_availability?id=eq.{uuid}
|
||||
```
|
||||
|
||||
#### Usuários
|
||||
```typescript
|
||||
// Criar médico (Edge Function com validações)
|
||||
POST /functions/v1/create-doctor
|
||||
|
||||
// Criar paciente
|
||||
POST /rest/v1/patients
|
||||
|
||||
// Listar médicos
|
||||
GET /rest/v1/doctors?select=*
|
||||
|
||||
// Atualizar perfil
|
||||
PATCH /rest/v1/doctors?id=eq.{uuid}
|
||||
```
|
||||
|
||||
**Documentação completa:** Ver [AGENDAMENTO-SLOTS-API.md](./AGENDAMENTO-SLOTS-API.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Autenticação e Permissões
|
||||
|
||||
### Sistema de Autenticação
|
||||
|
||||
- **JWT Tokens** com refresh automático
|
||||
- **Magic Link** - Login sem senha via email
|
||||
- **Recuperação de senha** com email
|
||||
- **Interceptors** adicionam token automaticamente
|
||||
- **Renovação automática** quando token expira
|
||||
|
||||
### Roles e Permissões (RLS)
|
||||
|
||||
#### 👑 Admin/Gestor:
|
||||
**Admin/Gestor:**
|
||||
- Acesso completo a todos os recursos
|
||||
- Criar/editar/deletar usuários
|
||||
- Visualizar todos os dados
|
||||
|
||||
- ✅ **Acesso completo a todos os recursos**
|
||||
- ✅ Criar/editar/deletar usuários, médicos, pacientes
|
||||
- ✅ Visualizar todos os agendamentos e prontuários
|
||||
**Médicos:**
|
||||
- Gerenciar agenda e disponibilidade
|
||||
- Visualizar todos os pacientes
|
||||
- Criar e editar prontuários
|
||||
- Ver apenas próprios agendamentos
|
||||
|
||||
#### 👨⚕️ Médicos:
|
||||
**Pacientes:**
|
||||
- Agendar consultas
|
||||
- Visualizar histórico próprio
|
||||
- Editar perfil pessoal
|
||||
- Download de relatórios 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**
|
||||
**Secretárias:**
|
||||
- Cadastrar médicos e pacientes
|
||||
- Gerenciar agendamentos
|
||||
- Configurar agendas médicas
|
||||
- Busca e filtros avançados
|
||||
|
||||
#### 👤 Pacientes:
|
||||
---
|
||||
|
||||
## 🎨 Recursos de Acessibilidade
|
||||
|
||||
- ✅ Modo de alto contraste
|
||||
- ✅ Ajuste de tamanho de fonte
|
||||
- ✅ Navegação por teclado
|
||||
- ✅ Leitores de tela compatíveis
|
||||
- ✅ Menu de acessibilidade flutuante
|
||||
|
||||
---
|
||||
|
||||
## 📊 Dashboards e Relatórios
|
||||
|
||||
### Médico
|
||||
- Total de pacientes atendidos
|
||||
- Consultas do dia/semana/mês
|
||||
- Próximas consultas
|
||||
- Histórico de atendimentos
|
||||
|
||||
### Paciente
|
||||
- Histórico de consultas
|
||||
- Relatórios médicos com download PDF
|
||||
- Próximos agendamentos
|
||||
- Acompanhamento médico
|
||||
|
||||
### Secretária
|
||||
- Visão geral de agendamentos
|
||||
- Filtros por médico, data e status
|
||||
- Busca de pacientes e médicos
|
||||
- Estatísticas gerais
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Melhorias Recentes (Outubro 2025)
|
||||
|
||||
### Sistema de Agendamento
|
||||
- ✅ API de slots disponíveis (Edge Function)
|
||||
- ✅ Cálculo automático de horários
|
||||
- ✅ Validação de antecedência mínima
|
||||
- ✅ Verificação de conflitos
|
||||
- ✅ Interface otimizada
|
||||
|
||||
### Formatação de Dados
|
||||
- ✅ Limpeza automática de telefone/CPF
|
||||
- ✅ Formatação de nomes de médicos ("Dr.")
|
||||
- ✅ Validação de campos obrigatórios
|
||||
- ✅ Máscaras de entrada
|
||||
|
||||
### UX/UI
|
||||
- ✅ Diálogos de confirmação profissionais
|
||||
- ✅ Filtros de busca em todas as listas
|
||||
- ✅ Feedback visual melhorado
|
||||
- ✅ Loading states consistentes
|
||||
- ✅ Mensagens de erro claras
|
||||
|
||||
### Performance
|
||||
- ✅ Build otimizado (~424KB)
|
||||
- ✅ Code splitting
|
||||
- ✅ Lazy loading de rotas
|
||||
- ✅ Cache de assets
|
||||
|
||||
---
|
||||
|
||||
## 📝 Convenções de Código
|
||||
|
||||
### TypeScript
|
||||
- Interfaces para todas as entidades
|
||||
- Tipos explícitos em funções
|
||||
- Evitar `any` (usar `unknown` quando necessário)
|
||||
|
||||
### Componentes React
|
||||
- Functional components com hooks
|
||||
- Props tipadas com interfaces
|
||||
- Estado local com useState/useContext
|
||||
- Effects para side effects
|
||||
|
||||
### Serviços
|
||||
- Um serviço por entidade (doctorService, patientService)
|
||||
- Métodos assíncronos com try/catch
|
||||
- Logs de debug no console
|
||||
- Tratamento de erros consistente
|
||||
|
||||
### Nomenclatura
|
||||
- **Componentes:** PascalCase (ex: `AgendamentoConsulta`)
|
||||
- **Arquivos:** kebab-case ou PascalCase conforme tipo
|
||||
- **Variáveis:** camelCase (ex: `selectedDate`)
|
||||
- **Constantes:** UPPER_SNAKE_CASE (ex: `API_CONFIG`)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Erro 401 (Unauthorized)
|
||||
- Verificar se token está no localStorage
|
||||
- Tentar logout e login novamente
|
||||
- Verificar permissões RLS no Supabase
|
||||
|
||||
### Slots não aparecem
|
||||
- Verificar se médico tem disponibilidade configurada
|
||||
- Verificar se data é futura
|
||||
- Verificar logs da Edge Function
|
||||
|
||||
### Upload de avatar falha
|
||||
- Verificar tamanho do arquivo (max 2MB)
|
||||
- Verificar formato (jpg, png)
|
||||
- Verificar permissões do Storage no Supabase
|
||||
|
||||
### Build falha
|
||||
- Limpar cache: `rm -rf node_modules dist`
|
||||
- Reinstalar: `pnpm install`
|
||||
- Verificar versão do Node (18+)
|
||||
|
||||
---
|
||||
|
||||
## 👥 Equipe
|
||||
|
||||
**RiseUp Squad 18**
|
||||
- Desenvolvimento: GitHub Copilot + Equipe
|
||||
- Data: Outubro 2025
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
Este projeto é privado e desenvolvido para fins educacionais.
|
||||
|
||||
---
|
||||
|
||||
## <20> Links Úteis
|
||||
|
||||
- [Supabase Docs](https://supabase.com/docs)
|
||||
- [React Docs](https://react.dev/)
|
||||
- [Vite Docs](https://vitejs.dev/)
|
||||
- [TailwindCSS Docs](https://tailwindcss.com/)
|
||||
- [Cloudflare Pages](https://pages.cloudflare.com/)
|
||||
|
||||
---
|
||||
|
||||
**Última atualização:** 30 de Outubro de 2025
|
||||
|
||||
- ✅ Veem apenas **seus próprios dados**
|
||||
- ✅ Veem apenas **seus próprios laudos** (filtro: `patient_id = paciente`)
|
||||
@ -150,12 +444,17 @@ src/services/
|
||||
#### 🔐 Autenticação (authService)
|
||||
|
||||
```typescript
|
||||
// Login
|
||||
// Login com Email e Senha
|
||||
await authService.login({ email, password });
|
||||
// Retorna: { access_token, refresh_token, user }
|
||||
|
||||
// Magic Link (Login sem senha)
|
||||
await authService.sendMagicLink("email@example.com");
|
||||
// Envia email com link de autenticação
|
||||
// Usuário clica no link e é automaticamente autenticado
|
||||
|
||||
// Recuperação de senha
|
||||
await authService.requestPasswordReset(email);
|
||||
await authService.requestPasswordReset("email@example.com");
|
||||
// Envia email com link de reset
|
||||
|
||||
// Atualizar senha
|
||||
@ -166,6 +465,18 @@ await authService.updatePassword(accessToken, newPassword);
|
||||
await authService.refreshToken(refreshToken);
|
||||
```
|
||||
|
||||
**Fluxo Magic Link:**
|
||||
|
||||
1. Usuário solicita magic link na tela de login
|
||||
2. `localStorage.setItem("magic_link_redirect", "/painel-medico")` salva contexto
|
||||
3. Supabase envia email com link único
|
||||
4. Usuário clica no link
|
||||
5. `Home.tsx` detecta hash params e redireciona para `/auth/callback`
|
||||
6. `AuthCallback.tsx` processa tokens, salva no localStorage
|
||||
7. `window.location.href` redireciona para painel salvo
|
||||
8. Página recarrega com `AuthContext` atualizado
|
||||
9. Usuário autenticado no painel correto ✅
|
||||
|
||||
#### 👤 Usuários (userService)
|
||||
|
||||
```typescript
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
async function createAppointment() {
|
||||
try {
|
||||
console.log("🔐 Fazendo login como Aurora...");
|
||||
|
||||
// Login como Aurora
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "aurora-nascimento94@gmx.com",
|
||||
password: "auroranasc94",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const auroraToken = loginResponse.data.access_token;
|
||||
console.log("✅ Login realizado como Aurora");
|
||||
|
||||
// Buscar o patient_id da Aurora
|
||||
console.log("\n👤 Buscando dados da paciente...");
|
||||
const patientResponse = await axios.get(
|
||||
`${SUPABASE_URL}/rest/v1/patients?user_id=eq.${loginResponse.data.user.id}`,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${auroraToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!patientResponse.data || patientResponse.data.length === 0) {
|
||||
throw new Error("Paciente não encontrada");
|
||||
}
|
||||
|
||||
const patientId = patientResponse.data[0].id;
|
||||
console.log(`✅ Patient ID: ${patientId}`);
|
||||
|
||||
// Buscar disponibilidade do Dr. Fernando para segunda-feira
|
||||
console.log("\n📅 Buscando disponibilidade do Dr. Fernando...");
|
||||
const availabilityResponse = await axios.get(
|
||||
`${SUPABASE_URL}/rest/v1/doctor_availability?doctor_id=eq.6dad001d-229b-40b5-80f3-310243c4599c&weekday=eq.monday`,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${auroraToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!availabilityResponse.data || availabilityResponse.data.length === 0) {
|
||||
throw new Error("Disponibilidade não encontrada");
|
||||
}
|
||||
|
||||
const availability = availabilityResponse.data[0];
|
||||
console.log(
|
||||
`✅ Disponibilidade encontrada: ${availability.start_time} - ${availability.end_time}`
|
||||
);
|
||||
|
||||
// Criar consulta para próxima segunda-feira às 10:00
|
||||
const today = new Date();
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7; // Próxima segunda
|
||||
const appointmentDate = new Date(today);
|
||||
appointmentDate.setDate(today.getDate() + daysUntilMonday);
|
||||
appointmentDate.setHours(10, 0, 0, 0);
|
||||
|
||||
const scheduledAt = appointmentDate.toISOString();
|
||||
|
||||
console.log(`\n📝 Criando consulta para ${scheduledAt}...`);
|
||||
|
||||
const appointmentData = {
|
||||
patient_id: patientId,
|
||||
doctor_id: "6dad001d-229b-40b5-80f3-310243c4599c",
|
||||
scheduled_at: scheduledAt,
|
||||
duration_minutes: 30,
|
||||
appointment_type: "presencial",
|
||||
chief_complaint: "Consulta de rotina",
|
||||
};
|
||||
|
||||
const appointmentResponse = await axios.post(
|
||||
`${SUPABASE_URL}/rest/v1/appointments`,
|
||||
appointmentData,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${auroraToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("\n✅ Consulta criada com sucesso!");
|
||||
console.log("\n📋 Detalhes da consulta:");
|
||||
console.log(` - Paciente: Aurora Sabrina Clara Nascimento`);
|
||||
console.log(` - Médico: Dr. Fernando Pirichowski`);
|
||||
console.log(` - Data/Hora: ${scheduledAt}`);
|
||||
console.log(` - Duração: 30 minutos`);
|
||||
console.log(` - Tipo: presencial`);
|
||||
|
||||
if (appointmentResponse.data && appointmentResponse.data.length > 0) {
|
||||
console.log(` - ID da consulta: ${appointmentResponse.data[0].id}`);
|
||||
console.log(
|
||||
` - Order Number: ${appointmentResponse.data[0].order_number}`
|
||||
);
|
||||
console.log(` - Status: ${appointmentResponse.data[0].status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"❌ Erro ao criar consulta:",
|
||||
error.response?.data || error.message
|
||||
);
|
||||
if (error.response?.data) {
|
||||
console.error("Detalhes:", JSON.stringify(error.response.data, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createAppointment();
|
||||
@ -1,93 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
// IDs
|
||||
const DOCTOR_ID = "6dad001d-229b-40b5-80f3-310243c4599c"; // Fernando (CRM 24245)
|
||||
const ADMIN_ID = "c7fcd702-9a6e-4b7c-abd3-956b25af407d"; // Admin (riseup)
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🔐 Fazendo login como admin...");
|
||||
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "riseup@popcode.com.br",
|
||||
password: "riseup",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const adminToken = loginResponse.data.access_token;
|
||||
console.log("✅ Login realizado\n");
|
||||
|
||||
console.log("📅 Criando disponibilidade para Dr. Fernando...");
|
||||
console.log("⏰ Horário: 07:00 às 19:00");
|
||||
console.log("📆 Dias: Segunda a Domingo\n");
|
||||
|
||||
const weekdays = [
|
||||
{ num: "sunday", name: "Domingo" },
|
||||
{ num: "monday", name: "Segunda-feira" },
|
||||
{ num: "tuesday", name: "Terça-feira" },
|
||||
{ num: "wednesday", name: "Quarta-feira" },
|
||||
{ num: "thursday", name: "Quinta-feira" },
|
||||
{ num: "friday", name: "Sexta-feira" },
|
||||
{ num: "saturday", name: "Sábado" },
|
||||
];
|
||||
|
||||
for (const day of weekdays) {
|
||||
try {
|
||||
const availabilityData = {
|
||||
doctor_id: DOCTOR_ID,
|
||||
weekday: day.num,
|
||||
start_time: "07:00:00",
|
||||
end_time: "19:00:00",
|
||||
slot_minutes: 30,
|
||||
appointment_type: "presencial",
|
||||
active: true,
|
||||
created_by: ADMIN_ID,
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
`${SUPABASE_URL}/rest/v1/doctor_availability`,
|
||||
availabilityData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ ${day.name}: Disponibilidade criada`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ ${day.name}: Erro -`,
|
||||
error.response?.data?.message || error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n🎉 Disponibilidade criada com sucesso!");
|
||||
console.log("\n📋 Resumo:");
|
||||
console.log("- Médico: Dr. Fernando Pirichowski");
|
||||
console.log("- Dias: Todos os dias da semana (Domingo a Sábado)");
|
||||
console.log("- Horário: 07:00 às 19:00");
|
||||
console.log("- Duração consulta: 30 minutos");
|
||||
console.log("- Tipo: Presencial");
|
||||
} catch (error) {
|
||||
console.error("❌ Erro geral:", error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,80 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
// Configuração
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
// Credenciais do admin
|
||||
const ADMIN_EMAIL = "riseup@popcode.com.br";
|
||||
const ADMIN_PASSWORD = "riseup";
|
||||
|
||||
// Dados do paciente (Aurora Sabrina Clara Nascimento)
|
||||
const PATIENT_DATA = {
|
||||
email: "aurora-nascimento94@gmx.com",
|
||||
password: "auroranasc94",
|
||||
full_name: "Aurora Sabrina Clara Nascimento",
|
||||
phone_mobile: "(21) 99856-3014",
|
||||
cpf: "66864784231", // CPF sem pontuação
|
||||
create_patient_record: true,
|
||||
role: "paciente",
|
||||
};
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🔐 1. Fazendo login como admin...");
|
||||
|
||||
// 1. Login do admin
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const adminToken = loginResponse.data.access_token;
|
||||
console.log("✅ Login realizado com sucesso!");
|
||||
console.log("🔑 Token:", adminToken.substring(0, 30) + "...");
|
||||
|
||||
console.log("\n👤 2. Criando paciente...");
|
||||
console.log("Dados:", JSON.stringify(PATIENT_DATA, null, 2));
|
||||
|
||||
// 2. Criar paciente
|
||||
const createResponse = await axios.post(
|
||||
`${SUPABASE_URL}/functions/v1/create-user-with-password`,
|
||||
PATIENT_DATA,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("\n✅ Paciente criado com sucesso!");
|
||||
console.log("Resposta:", JSON.stringify(createResponse.data, null, 2));
|
||||
|
||||
if (createResponse.data.patient_id) {
|
||||
console.log("\n📋 ID do paciente:", createResponse.data.patient_id);
|
||||
console.log("✉️ Email de confirmação enviado para:", PATIENT_DATA.email);
|
||||
console.log("🔐 Senha temporária:", PATIENT_DATA.password);
|
||||
console.log(
|
||||
"\n⚠️ O usuário precisa confirmar o email antes de fazer login!"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erro:", error.response?.data || error.message);
|
||||
console.error("Status:", error.response?.status);
|
||||
console.error("URL:", error.config?.url);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,72 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
async function fixAuroraUserId() {
|
||||
try {
|
||||
console.log("🔐 Fazendo login como admin...");
|
||||
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "riseup@popcode.com.br",
|
||||
password: "riseup",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const adminToken = loginResponse.data.access_token;
|
||||
console.log("✅ Login realizado");
|
||||
|
||||
// Fazer login como Aurora para pegar o user_id
|
||||
console.log("\n👤 Fazendo login como Aurora...");
|
||||
const auroraLoginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "aurora-nascimento94@gmx.com",
|
||||
password: "auroranasc94",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const auroraUserId = auroraLoginResponse.data.user.id;
|
||||
console.log(`✅ User ID da Aurora: ${auroraUserId}`);
|
||||
|
||||
// Atualizar patient com user_id
|
||||
console.log("\n📝 Atualizando registro da paciente...");
|
||||
const updateResponse = await axios.patch(
|
||||
`${SUPABASE_URL}/rest/v1/patients?id=eq.b85486f7-9135-4b67-9aa7-b884d9603d12`,
|
||||
{
|
||||
user_id: auroraUserId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("✅ Registro atualizado com sucesso!");
|
||||
console.log(` - Patient ID: b85486f7-9135-4b67-9aa7-b884d9603d12`);
|
||||
console.log(` - User ID: ${auroraUserId}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Erro:", error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fixAuroraUserId();
|
||||
@ -1,59 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🔐 Fazendo login como admin...");
|
||||
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "riseup@popcode.com.br",
|
||||
password: "riseup",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const adminToken = loginResponse.data.access_token;
|
||||
console.log("✅ Login realizado\n");
|
||||
|
||||
console.log("👤 Buscando dados de Aurora na tabela patients...");
|
||||
|
||||
const patientsResponse = await axios.get(
|
||||
`${SUPABASE_URL}/rest/v1/patients?email=eq.aurora-nascimento94@gmx.com`,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (patientsResponse.data.length > 0) {
|
||||
const patient = patientsResponse.data[0];
|
||||
console.log("✅ Paciente encontrada!\n");
|
||||
console.log("📋 DADOS DA AURORA:\n");
|
||||
console.log("User ID (auth):", patient.id);
|
||||
console.log("Patient ID:", patient.id); // Em patients, o id é o mesmo do auth
|
||||
console.log("Nome:", patient.full_name);
|
||||
console.log("Email:", patient.email);
|
||||
console.log("CPF:", patient.cpf);
|
||||
console.log("Telefone:", patient.phone_mobile);
|
||||
console.log("\n📄 Dados completos:", JSON.stringify(patient, null, 2));
|
||||
} else {
|
||||
console.log("❌ Paciente não encontrada na tabela patients");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erro:", error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,80 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🔐 Fazendo login como admin...");
|
||||
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "riseup@popcode.com.br",
|
||||
password: "riseup",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const adminToken = loginResponse.data.access_token;
|
||||
console.log("✅ Login realizado\n");
|
||||
|
||||
console.log("🔍 Buscando Fernando em profiles...");
|
||||
|
||||
const profilesResponse = await axios.get(
|
||||
`${SUPABASE_URL}/rest/v1/profiles?email=eq.fernando.pirichowski@souunit.com.br`,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (profilesResponse.data.length > 0) {
|
||||
console.log(
|
||||
`✅ ${profilesResponse.data.length} perfil(is) encontrado(s)!\n`
|
||||
);
|
||||
|
||||
profilesResponse.data.forEach((profile, index) => {
|
||||
console.log(`📋 PERFIL ${index + 1}:\n`);
|
||||
console.log("User ID:", profile.id);
|
||||
console.log("Email:", profile.email);
|
||||
console.log("Nome:", profile.full_name);
|
||||
console.log("Telefone:", profile.phone || "Não informado");
|
||||
console.log("\n" + "=".repeat(60) + "\n");
|
||||
});
|
||||
|
||||
// Pegar roles do primeiro perfil
|
||||
const userId = profilesResponse.data[0].id;
|
||||
console.log("🔍 Buscando roles...");
|
||||
|
||||
const rolesResponse = await axios.get(
|
||||
`${SUPABASE_URL}/rest/v1/user_roles?user_id=eq.${userId}`,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(
|
||||
"📌 Roles:",
|
||||
rolesResponse.data.map((r) => r.role).join(", ")
|
||||
);
|
||||
} else {
|
||||
console.log("❌ Nenhum perfil encontrado");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erro:", error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,82 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🔐 Fazendo login como admin...");
|
||||
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "riseup@popcode.com.br",
|
||||
password: "riseup",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const adminToken = loginResponse.data.access_token;
|
||||
console.log("✅ Login realizado\n");
|
||||
|
||||
console.log("👨⚕️ Buscando dados de Fernando na tabela doctors...");
|
||||
|
||||
const doctorsResponse = await axios.get(
|
||||
`${SUPABASE_URL}/rest/v1/doctors?email=eq.fernando.pirichowski@souunit.com.br`,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (doctorsResponse.data.length > 0) {
|
||||
console.log(
|
||||
`✅ ${doctorsResponse.data.length} médico(s) encontrado(s)!\n`
|
||||
);
|
||||
|
||||
doctorsResponse.data.forEach((doctor, index) => {
|
||||
console.log(`📋 MÉDICO ${index + 1}:\n`);
|
||||
console.log("Doctor ID:", doctor.id);
|
||||
console.log("Nome:", doctor.full_name);
|
||||
console.log("Email:", doctor.email);
|
||||
console.log("CRM:", doctor.crm);
|
||||
console.log("Especialidade:", doctor.specialty || "Não informada");
|
||||
console.log("Telefone:", doctor.phone || "Não informado");
|
||||
console.log("\n" + "=".repeat(60) + "\n");
|
||||
});
|
||||
} else {
|
||||
console.log("❌ Nenhum médico chamado Fernando encontrado");
|
||||
console.log("\n🔍 Buscando todos os médicos...");
|
||||
|
||||
const allDoctorsResponse = await axios.get(
|
||||
`${SUPABASE_URL}/rest/v1/doctors`,
|
||||
{
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(
|
||||
`\n📊 Total de médicos cadastrados: ${allDoctorsResponse.data.length}`
|
||||
);
|
||||
allDoctorsResponse.data.forEach((doctor, index) => {
|
||||
console.log(`${index + 1}. ${doctor.full_name} - ${doctor.email}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Erro:", error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -1,38 +0,0 @@
|
||||
const axios = require("axios");
|
||||
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const SUPABASE_ANON_KEY =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🔐 Fazendo login como Fernando...");
|
||||
|
||||
const loginResponse = await axios.post(
|
||||
`${SUPABASE_URL}/auth/v1/token?grant_type=password`,
|
||||
{
|
||||
email: "fernando.pirichowski@souunit.com.br",
|
||||
password: "fernando123",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const userData = loginResponse.data;
|
||||
console.log("✅ Login realizado\n");
|
||||
|
||||
console.log("📋 DADOS DO FERNANDO (AUTH):\n");
|
||||
console.log("User ID:", userData.user.id);
|
||||
console.log("Email:", userData.user.email);
|
||||
console.log("Role:", userData.user.role);
|
||||
console.log("\n" + "=".repeat(60));
|
||||
} catch (error) {
|
||||
console.error("❌ Erro:", error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -15,6 +15,8 @@
|
||||
"@supabase/supabase-js": "^2.76.1",
|
||||
"axios": "^1.12.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"lucide-react": "^0.540.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
168
MEDICONNECT 2/pnpm-lock.yaml
generated
168
MEDICONNECT 2/pnpm-lock.yaml
generated
@ -22,6 +22,12 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^2.30.0
|
||||
version: 2.30.0
|
||||
html2canvas:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
jspdf:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
lucide-react:
|
||||
specifier: ^0.540.0
|
||||
version: 0.540.0(react@18.3.1)
|
||||
@ -819,12 +825,18 @@ packages:
|
||||
'@types/normalize-package-data@2.4.4':
|
||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/phoenix@1.6.6':
|
||||
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
'@types/react-dom@18.3.7':
|
||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||
peerDependencies:
|
||||
@ -836,6 +848,9 @@ packages:
|
||||
'@types/triple-beam@1.3.5':
|
||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@ -1060,6 +1075,10 @@ packages:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@ -1120,6 +1139,10 @@ packages:
|
||||
caniuse-lite@1.0.30001750:
|
||||
resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==}
|
||||
|
||||
canvg@3.0.11:
|
||||
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -1200,6 +1223,9 @@ packages:
|
||||
resolution: {integrity: sha512-X8XDzyvYaA6msMyAM575CUoygY5b44QzLcGRKsK3MFmXcOvQa518dNPLsKYwkYsn72g3EiW+LE0ytd/FlqWmyw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.46.0:
|
||||
resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@ -1220,6 +1246,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@ -1307,6 +1336,9 @@ packages:
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
dompurify@3.3.0:
|
||||
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
|
||||
|
||||
dot-prop@9.0.0:
|
||||
resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==}
|
||||
engines: {node: '>=18'}
|
||||
@ -1488,6 +1520,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-png@6.4.0:
|
||||
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
|
||||
|
||||
fastq@1.19.1:
|
||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||
|
||||
@ -1506,6 +1541,9 @@ packages:
|
||||
fecha@4.2.3:
|
||||
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
|
||||
|
||||
fflate@0.8.2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@ -1659,6 +1697,10 @@ packages:
|
||||
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
@ -1698,6 +1740,9 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
iobuffer@5.4.0:
|
||||
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
@ -1794,6 +1839,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jspdf@3.0.3:
|
||||
resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==}
|
||||
|
||||
junk@4.0.1:
|
||||
resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==}
|
||||
engines: {node: '>=12.20'}
|
||||
@ -2036,6 +2084,9 @@ packages:
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@ -2074,6 +2125,9 @@ packages:
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@ -2178,6 +2232,9 @@ packages:
|
||||
quote-unquote@1.0.0:
|
||||
resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==}
|
||||
|
||||
raf@3.4.1:
|
||||
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
@ -2244,6 +2301,9 @@ packages:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
remove-trailing-separator@1.1.0:
|
||||
resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==}
|
||||
|
||||
@ -2275,6 +2335,10 @@ packages:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||
engines: {node: '>= 0.8.15'}
|
||||
|
||||
rollup@4.52.4:
|
||||
resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@ -2343,6 +2407,10 @@ packages:
|
||||
stack-trace@0.0.10:
|
||||
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||
engines: {node: '>=0.1.14'}
|
||||
|
||||
streamx@2.23.0:
|
||||
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
||||
|
||||
@ -2389,6 +2457,10 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tailwindcss@3.4.18:
|
||||
resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@ -2407,6 +2479,9 @@ packages:
|
||||
text-hex@1.0.0:
|
||||
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@ -2500,6 +2575,9 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
@ -3301,10 +3379,15 @@ snapshots:
|
||||
|
||||
'@types/normalize-package-data@2.4.4': {}
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/phoenix@1.6.6': {}
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.26)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.26
|
||||
@ -3316,6 +3399,9 @@ snapshots:
|
||||
|
||||
'@types/triple-beam@1.3.5': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.7.2
|
||||
@ -3613,6 +3699,8 @@ snapshots:
|
||||
|
||||
bare-events@2.8.0: {}
|
||||
|
||||
base64-arraybuffer@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.8.16: {}
|
||||
@ -3668,6 +3756,18 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001750: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@types/raf': 3.4.3
|
||||
core-js: 3.46.0
|
||||
raf: 3.4.1
|
||||
regenerator-runtime: 0.13.11
|
||||
rgbcolor: 1.0.1
|
||||
stackblur-canvas: 2.7.0
|
||||
svg-pathdata: 6.0.3
|
||||
optional: true
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@ -3749,6 +3849,9 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
p-event: 6.0.1
|
||||
|
||||
core-js@3.46.0:
|
||||
optional: true
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
crc-32@1.2.2: {}
|
||||
@ -3768,6 +3871,10 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
@ -3852,6 +3959,11 @@ snapshots:
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dompurify@3.3.0:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
optional: true
|
||||
|
||||
dot-prop@9.0.0:
|
||||
dependencies:
|
||||
type-fest: 4.41.0
|
||||
@ -4100,6 +4212,12 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-png@6.4.0:
|
||||
dependencies:
|
||||
'@types/pako': 2.0.4
|
||||
iobuffer: 5.4.0
|
||||
pako: 2.1.0
|
||||
|
||||
fastq@1.19.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@ -4114,6 +4232,8 @@ snapshots:
|
||||
|
||||
fecha@4.2.3: {}
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@ -4254,6 +4374,11 @@ snapshots:
|
||||
dependencies:
|
||||
lru-cache: 7.18.3
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
@ -4282,6 +4407,8 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
iobuffer@5.4.0: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
@ -4348,6 +4475,17 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jspdf@3.0.3:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
fast-png: 6.4.0
|
||||
fflate: 0.8.2
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.46.0
|
||||
dompurify: 3.3.0
|
||||
html2canvas: 1.4.1
|
||||
|
||||
junk@4.0.1: {}
|
||||
|
||||
jwt-decode@4.0.0: {}
|
||||
@ -4559,6 +4697,8 @@ snapshots:
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@ -4588,6 +4728,9 @@ snapshots:
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
@ -4682,6 +4825,11 @@ snapshots:
|
||||
|
||||
quote-unquote@1.0.0: {}
|
||||
|
||||
raf@3.4.1:
|
||||
dependencies:
|
||||
performance-now: 2.1.0
|
||||
optional: true
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@ -4765,6 +4913,9 @@ snapshots:
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
optional: true
|
||||
|
||||
remove-trailing-separator@1.1.0: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
@ -4789,6 +4940,9 @@ snapshots:
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
optional: true
|
||||
|
||||
rollup@4.52.4:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@ -4868,6 +5022,9 @@ snapshots:
|
||||
|
||||
stack-trace@0.0.10: {}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
optional: true
|
||||
|
||||
streamx@2.23.0:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
@ -4925,6 +5082,9 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
optional: true
|
||||
|
||||
tailwindcss@3.4.18(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
@ -4978,6 +5138,10 @@ snapshots:
|
||||
|
||||
text-hex@1.0.0: {}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@ -5058,6 +5222,10 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
|
||||
BIN
MEDICONNECT 2/public/svante_paabo.jpg
Normal file
BIN
MEDICONNECT 2/public/svante_paabo.jpg
Normal file
Binary file not shown.
@ -19,17 +19,11 @@ import {
|
||||
Clock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Stethoscope,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
availabilityService,
|
||||
exceptionsService,
|
||||
appointmentService,
|
||||
smsService,
|
||||
} from "../services";
|
||||
import { appointmentService } from "../services";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
interface Medico {
|
||||
@ -43,43 +37,6 @@ interface Medico {
|
||||
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[];
|
||||
}
|
||||
@ -99,8 +56,6 @@ export default function AgendamentoConsulta({
|
||||
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<
|
||||
@ -132,110 +87,54 @@ export default function AgendamentoConsulta({
|
||||
|
||||
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMedico) {
|
||||
loadDoctorAvailability();
|
||||
loadDoctorExceptions();
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [selectedMedico]);
|
||||
// Removemos as funções de availability e exceptions antigas
|
||||
// A API de slots já considera tudo automaticamente
|
||||
|
||||
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);
|
||||
const calculateAvailableSlots = useCallback(async () => {
|
||||
if (!selectedDate || !selectedMedico) {
|
||||
setAvailableSlots([]);
|
||||
return;
|
||||
}
|
||||
}, [selectedMedico]);
|
||||
|
||||
const loadDoctorExceptions = useCallback(async () => {
|
||||
if (!selectedMedico) return;
|
||||
try {
|
||||
const response = await exceptionService.listExceptions({
|
||||
const dateStr = format(selectedDate, "yyyy-MM-dd");
|
||||
|
||||
// Usa a Edge Function para calcular slots disponíveis
|
||||
const response = await appointmentService.getAvailableSlots({
|
||||
doctor_id: selectedMedico.id,
|
||||
date: dateStr,
|
||||
});
|
||||
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) {
|
||||
if (response && response.slots) {
|
||||
// Filtra apenas os slots disponíveis
|
||||
const available = response.slots
|
||||
.filter((slot) => slot.available)
|
||||
.map((slot) => slot.time);
|
||||
setAvailableSlots(available);
|
||||
} else {
|
||||
setAvailableSlots([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[AgendamentoConsulta] Erro ao buscar slots:", error);
|
||||
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]);
|
||||
}, [selectedDate, selectedMedico]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate && availability && selectedMedico) {
|
||||
if (selectedDate && 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);
|
||||
};
|
||||
}, [selectedDate, selectedMedico, calculateAvailableSlots]);
|
||||
|
||||
// Simplificado: a API de slots já considera disponibilidade e exceções
|
||||
const isDateAvailable = (date: Date): boolean => {
|
||||
if (!availability) return false;
|
||||
// Não permite datas passadas
|
||||
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)
|
||||
);
|
||||
// Para simplificar, consideramos todos os dias futuros como possíveis
|
||||
// A API fará a validação real quando buscar slots
|
||||
return true;
|
||||
};
|
||||
|
||||
const generateCalendarDays = () => {
|
||||
@ -271,34 +170,26 @@ export default function AgendamentoConsulta({
|
||||
if (!selectedMedico || !selectedDate || !selectedTime || !user) return;
|
||||
try {
|
||||
setBookingError("");
|
||||
// Cria o agendamento na API real
|
||||
const result = await consultasService.criar({
|
||||
|
||||
// Formata a data no formato ISO correto
|
||||
const scheduledAt = format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00Z";
|
||||
|
||||
// Cria o agendamento usando a API REST
|
||||
const appointment = await appointmentService.create({
|
||||
patient_id: user.id,
|
||||
doctor_id: selectedMedico.id,
|
||||
scheduled_at:
|
||||
format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00.000Z",
|
||||
scheduled_at: scheduledAt,
|
||||
duration_minutes: 30,
|
||||
appointment_type: appointmentType,
|
||||
appointment_type: appointmentType === "online" ? "telemedicina" : "presencial",
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[AgendamentoConsulta] Consulta criada:", appointment);
|
||||
|
||||
setBookingSuccess(true);
|
||||
setShowConfirmDialog(false);
|
||||
|
||||
// Reset form após 3 segundos
|
||||
setTimeout(() => {
|
||||
setSelectedMedico(null);
|
||||
setSelectedDate(undefined);
|
||||
@ -307,6 +198,7 @@ export default function AgendamentoConsulta({
|
||||
setBookingSuccess(false);
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error("[AgendamentoConsulta] Erro ao agendar:", error);
|
||||
setBookingError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
@ -496,7 +388,6 @@ export default function AgendamentoConsulta({
|
||||
const isTodayDate = isToday(day);
|
||||
const isAvailable =
|
||||
isCurrentMonth && isDateAvailable(day);
|
||||
const isBlocked = isCurrentMonth && isDateBlocked(day);
|
||||
const isPast = isBefore(day, startOfDay(new Date()));
|
||||
return (
|
||||
<button
|
||||
@ -517,15 +408,8 @@ export default function AgendamentoConsulta({
|
||||
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
|
||||
} ${isPast ? "text-gray-400" : ""} ${
|
||||
!isAvailable && isCurrentMonth && !isPast
|
||||
? "text-gray-300"
|
||||
: ""
|
||||
}`}
|
||||
|
||||
@ -3,8 +3,8 @@ 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, exceptionsService } from "../services/index";
|
||||
import type { DoctorException } from "../services/exceptions/types";
|
||||
import { availabilityService } from "../services/index";
|
||||
import type { DoctorException, DoctorAvailability } from "../services/availability/types";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
interface TimeSlot {
|
||||
@ -80,11 +80,12 @@ const DisponibilidadeMedico: React.FC = () => {
|
||||
});
|
||||
|
||||
// Agrupar disponibilidades por dia da semana
|
||||
availabilities.forEach((avail: any) => {
|
||||
const weekdayKey = daysOfWeek.find((d) => d.dbKey === avail.weekday);
|
||||
if (!weekdayKey) return;
|
||||
availabilities.forEach((avail: DoctorAvailability) => {
|
||||
// avail.weekday agora é um número (0-6)
|
||||
const dayKey = avail.weekday;
|
||||
|
||||
if (!newSchedule[dayKey]) return;
|
||||
|
||||
const dayKey = weekdayKey.key;
|
||||
if (!newSchedule[dayKey].enabled) {
|
||||
newSchedule[dayKey].enabled = true;
|
||||
}
|
||||
@ -122,13 +123,13 @@ const DisponibilidadeMedico: React.FC = () => {
|
||||
|
||||
const loadExceptions = React.useCallback(async () => {
|
||||
try {
|
||||
const exceptions = await exceptionsService.list({
|
||||
const exceptions = await availabilityService.listExceptions({
|
||||
doctor_id: medicoId,
|
||||
});
|
||||
setExceptions(exceptions);
|
||||
const blocked = exceptions
|
||||
.filter((exc: any) => exc.kind === "bloqueio" && exc.date)
|
||||
.map((exc: any) => new Date(exc.date!));
|
||||
.filter((exc: DoctorException) => exc.kind === "bloqueio" && exc.date)
|
||||
.map((exc: DoctorException) => new Date(exc.date!));
|
||||
setBlockedDates(blocked);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar exceções:", error);
|
||||
@ -253,7 +254,7 @@ const DisponibilidadeMedico: React.FC = () => {
|
||||
};
|
||||
|
||||
// Para cada dia, processar slots
|
||||
daysOfWeek.forEach(({ key, dbKey }) => {
|
||||
daysOfWeek.forEach(({ key }) => {
|
||||
const daySchedule = schedule[key];
|
||||
|
||||
if (!daySchedule || !daySchedule.enabled) {
|
||||
@ -284,16 +285,9 @@ const DisponibilidadeMedico: React.FC = () => {
|
||||
);
|
||||
|
||||
const payload = {
|
||||
weekday: dbKey as
|
||||
| "segunda"
|
||||
| "terca"
|
||||
| "quarta"
|
||||
| "quinta"
|
||||
| "sexta"
|
||||
| "sabado"
|
||||
| "domingo",
|
||||
start_time: inicio,
|
||||
end_time: fim,
|
||||
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
|
||||
slot_minutes: minutes,
|
||||
appointment_type: "presencial" as const,
|
||||
active: !!slot.ativo,
|
||||
@ -375,7 +369,7 @@ const DisponibilidadeMedico: React.FC = () => {
|
||||
exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
|
||||
);
|
||||
if (exception && exception.id) {
|
||||
await exceptionsService.delete(exception.id);
|
||||
await availabilityService.deleteException(exception.id);
|
||||
setBlockedDates(
|
||||
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
|
||||
);
|
||||
@ -383,11 +377,12 @@ const DisponibilidadeMedico: React.FC = () => {
|
||||
}
|
||||
} else {
|
||||
// Add block
|
||||
await exceptionsService.create({
|
||||
await availabilityService.createException({
|
||||
doctor_id: medicoId,
|
||||
date: dateString,
|
||||
kind: "bloqueio",
|
||||
reason: "Data bloqueada pelo médico",
|
||||
created_by: user?.id || medicoId,
|
||||
});
|
||||
setBlockedDates([...blockedDates, selectedDate]);
|
||||
toast.success("Data bloqueada");
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { appointmentService } from "../../services";
|
||||
|
||||
@ -6,48 +6,56 @@ 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,
|
||||
if (!doctorId || !date) return;
|
||||
|
||||
console.log("🔍 [AvailableSlotsPicker] Buscando slots:", {
|
||||
doctorId,
|
||||
date,
|
||||
});
|
||||
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");
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await appointmentService.getAvailableSlots({
|
||||
doctor_id: doctorId,
|
||||
date: date,
|
||||
});
|
||||
|
||||
console.log("📅 [AvailableSlotsPicker] Resposta da API:", res);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (res.slots && Array.isArray(res.slots)) {
|
||||
const times = res.slots.filter((s) => s.available).map((s) => s.time);
|
||||
|
||||
console.log("✅ [AvailableSlotsPicker] Horários disponíveis:", times);
|
||||
setSlots(times);
|
||||
} else {
|
||||
console.error(
|
||||
"❌ [AvailableSlotsPicker] Formato de resposta inválido:",
|
||||
res
|
||||
);
|
||||
toast.error("Erro ao processar horários disponíveis");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [AvailableSlotsPicker] Erro ao buscar slots:", error);
|
||||
setLoading(false);
|
||||
toast.error("Erro ao buscar horários disponíveis");
|
||||
}
|
||||
}
|
||||
void fetchSlots();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doctorId, date, appointment_type]);
|
||||
}, [doctorId, date]);
|
||||
|
||||
if (!date || !doctorId) return null;
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { exceptionsService } from "../../services/index";
|
||||
import { availabilityService } from "../../services/index";
|
||||
import type {
|
||||
DoctorException,
|
||||
ExceptionKind,
|
||||
} from "../../services/exceptions/types";
|
||||
} from "../../services/availability/types";
|
||||
|
||||
interface Props {
|
||||
doctorId: string;
|
||||
@ -26,7 +26,7 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
||||
if (!doctorId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const exceptions = await exceptionsService.list({ doctor_id: doctorId });
|
||||
const exceptions = await availabilityService.listExceptions({ doctor_id: doctorId });
|
||||
setList(exceptions);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar exceções:", error);
|
||||
@ -49,13 +49,14 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await exceptionsService.create({
|
||||
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({
|
||||
@ -79,7 +80,7 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
||||
const ok = confirm("Remover exceção?");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await exceptionsService.delete(item.id);
|
||||
await availabilityService.deleteException(item.id);
|
||||
toast.success("Removida");
|
||||
void load();
|
||||
} catch (error) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 472 KiB After Width: | Height: | Size: 230 KiB |
@ -16,6 +16,7 @@ interface EnderecoPaciente {
|
||||
|
||||
export interface PacienteFormData {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
nome: string;
|
||||
social_name: string;
|
||||
cpf: string;
|
||||
@ -93,12 +94,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.id}
|
||||
userId={data.user_id || data.id}
|
||||
currentAvatarUrl={data.avatar_url}
|
||||
name={data.nome || "Paciente"}
|
||||
color="blue"
|
||||
size="xl"
|
||||
editable={canEditAvatar && !!data.id}
|
||||
editable={canEditAvatar && !!(data.user_id || data.id)}
|
||||
onAvatarUpdate={(avatarUrl) => {
|
||||
onChange({ avatar_url: avatarUrl || undefined });
|
||||
}}
|
||||
|
||||
@ -81,6 +81,29 @@ export function SecretaryAppointmentList() {
|
||||
loadDoctorsAndPatients();
|
||||
}, []);
|
||||
|
||||
// Função de filtro
|
||||
const filteredAppointments = appointments.filter((appointment) => {
|
||||
// Filtro de busca por nome do paciente ou médico
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
appointment.patient?.full_name?.toLowerCase().includes(searchLower) ||
|
||||
appointment.doctor?.full_name?.toLowerCase().includes(searchLower) ||
|
||||
appointment.order_number?.toString().includes(searchTerm);
|
||||
|
||||
// Filtro de status
|
||||
const matchesStatus =
|
||||
statusFilter === "Todos" ||
|
||||
appointment.status === statusFilter;
|
||||
|
||||
// Filtro de tipo
|
||||
const matchesType =
|
||||
typeFilter === "Todos" ||
|
||||
appointment.appointment_type === typeFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesType;
|
||||
});
|
||||
|
||||
const loadDoctorsAndPatients = async () => {
|
||||
try {
|
||||
const [patientsData, doctorsData] = await Promise.all([
|
||||
@ -300,17 +323,19 @@ export function SecretaryAppointmentList() {
|
||||
Carregando consultas...
|
||||
</td>
|
||||
</tr>
|
||||
) : appointments.length === 0 ? (
|
||||
) : filteredAppointments.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
Nenhuma consulta encontrada
|
||||
{searchTerm || statusFilter !== "Todos" || typeFilter !== "Todos"
|
||||
? "Nenhuma consulta encontrada com esses filtros"
|
||||
: "Nenhuma consulta encontrada"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
appointments.map((appointment) => (
|
||||
filteredAppointments.map((appointment) => (
|
||||
<tr
|
||||
key={appointment.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
type Doctor,
|
||||
type CrmUF,
|
||||
} from "../../services";
|
||||
import type { CreateDoctorInput } from "../../services/users/types";
|
||||
|
||||
interface DoctorFormData {
|
||||
id?: string;
|
||||
@ -50,6 +51,16 @@ const UF_OPTIONS = [
|
||||
"TO",
|
||||
];
|
||||
|
||||
// Helper para formatar nome do médico sem duplicar "Dr."
|
||||
const formatDoctorName = (fullName: string): string => {
|
||||
const name = fullName.trim();
|
||||
// Verifica se já começa com Dr. ou Dr (case insensitive)
|
||||
if (/^dr\.?\s/i.test(name)) {
|
||||
return name;
|
||||
}
|
||||
return `Dr. ${name}`;
|
||||
};
|
||||
|
||||
export function SecretaryDoctorList() {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -87,6 +98,24 @@ export function SecretaryDoctorList() {
|
||||
loadDoctors();
|
||||
}, []);
|
||||
|
||||
// Função de filtro
|
||||
const filteredDoctors = doctors.filter((doctor) => {
|
||||
// Filtro de busca por nome, CRM ou especialidade
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
doctor.full_name?.toLowerCase().includes(searchLower) ||
|
||||
doctor.crm?.includes(searchTerm) ||
|
||||
doctor.specialty?.toLowerCase().includes(searchLower);
|
||||
|
||||
// Filtro de especialidade
|
||||
const matchesSpecialty =
|
||||
specialtyFilter === "Todas" ||
|
||||
doctor.specialty === specialtyFilter;
|
||||
|
||||
return matchesSearch && matchesSpecialty;
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
loadDoctors();
|
||||
};
|
||||
@ -134,11 +163,17 @@ export function SecretaryDoctorList() {
|
||||
try {
|
||||
if (modalMode === "edit" && formData.id) {
|
||||
// Para edição, usa o endpoint antigo (PATCH /doctors/:id)
|
||||
// Remove formatação de telefone e CPF
|
||||
const cleanPhone = formData.phone_mobile
|
||||
? formData.phone_mobile.replace(/\D/g, '')
|
||||
: undefined;
|
||||
const cleanCpf = formData.cpf.replace(/\D/g, '');
|
||||
|
||||
const doctorData = {
|
||||
full_name: formData.full_name,
|
||||
cpf: formData.cpf,
|
||||
cpf: cleanCpf,
|
||||
email: formData.email,
|
||||
phone_mobile: formData.phone_mobile,
|
||||
phone_mobile: cleanPhone,
|
||||
crm: formData.crm,
|
||||
crm_uf: formData.crm_uf as CrmUF,
|
||||
specialty: formData.specialty,
|
||||
@ -148,15 +183,22 @@ export function SecretaryDoctorList() {
|
||||
toast.success("Médico atualizado com sucesso!");
|
||||
} else {
|
||||
// Para criação, usa o novo endpoint create-doctor com validações completas
|
||||
const createData = {
|
||||
// Remove formatação de telefone e CPF
|
||||
const cleanPhone = formData.phone_mobile
|
||||
? formData.phone_mobile.replace(/\D/g, '')
|
||||
: undefined;
|
||||
const cleanCpf = formData.cpf.replace(/\D/g, '');
|
||||
|
||||
const createData: CreateDoctorInput = {
|
||||
email: formData.email,
|
||||
full_name: formData.full_name,
|
||||
cpf: formData.cpf,
|
||||
cpf: cleanCpf,
|
||||
crm: formData.crm,
|
||||
crm_uf: formData.crm_uf as CrmUF,
|
||||
specialty: formData.specialty,
|
||||
phone_mobile: formData.phone_mobile || undefined,
|
||||
specialty: formData.specialty || undefined,
|
||||
phone_mobile: cleanPhone,
|
||||
};
|
||||
|
||||
await userService.createDoctor(createData);
|
||||
toast.success("Médico cadastrado com sucesso!");
|
||||
}
|
||||
@ -288,17 +330,17 @@ export function SecretaryDoctorList() {
|
||||
Carregando médicos...
|
||||
</td>
|
||||
</tr>
|
||||
) : doctors.length === 0 ? (
|
||||
) : filteredDoctors.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
Nenhum médico encontrado
|
||||
{searchTerm || specialtyFilter !== "Todas" ? "Nenhum médico encontrado com esses filtros" : "Nenhum médico encontrado"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
doctors.map((doctor, index) => (
|
||||
filteredDoctors.map((doctor, index) => (
|
||||
<tr
|
||||
key={doctor.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
@ -314,7 +356,7 @@ export function SecretaryDoctorList() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Dr. {doctor.full_name}
|
||||
{formatDoctorName(doctor.full_name)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{doctor.email}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
@ -479,7 +521,7 @@ export function SecretaryDoctorList() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Especialidade *
|
||||
Especialidade
|
||||
</label>
|
||||
<select
|
||||
value={formData.specialty}
|
||||
@ -487,7 +529,6 @@ export function SecretaryDoctorList() {
|
||||
setFormData({ ...formData, specialty: 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"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
<option value="Cardiologia">Cardiologia</option>
|
||||
@ -517,7 +558,7 @@ export function SecretaryDoctorList() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefone *
|
||||
Telefone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
@ -529,8 +570,7 @@ export function SecretaryDoctorList() {
|
||||
})
|
||||
}
|
||||
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="(00) 00000-0000"
|
||||
placeholder="(11) 98888-8888"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -17,12 +17,47 @@ import {
|
||||
type DoctorAvailability,
|
||||
} from "../../services";
|
||||
|
||||
// Helper para converter weekday (string ou número) para texto em português
|
||||
const weekdayToText = (weekday: number | string | undefined | null): string => {
|
||||
if (weekday === undefined || weekday === null) {
|
||||
return "Desconhecido";
|
||||
}
|
||||
|
||||
// Se for string (formato da API atual)
|
||||
if (typeof weekday === 'string') {
|
||||
const weekdayMap: Record<string, string> = {
|
||||
'sunday': 'Domingo',
|
||||
'monday': 'Segunda-feira',
|
||||
'tuesday': 'Terça-feira',
|
||||
'wednesday': 'Quarta-feira',
|
||||
'thursday': 'Quinta-feira',
|
||||
'friday': 'Sexta-feira',
|
||||
'saturday': 'Sábado'
|
||||
};
|
||||
return weekdayMap[weekday.toLowerCase()] || "Desconhecido";
|
||||
}
|
||||
|
||||
// Se for número (0-6)
|
||||
const days = ["Domingo", "Segunda-feira", "Terça-feira", "Quarta-feira", "Quinta-feira", "Sexta-feira", "Sábado"];
|
||||
return days[weekday] ?? "Desconhecido";
|
||||
};
|
||||
|
||||
interface DayCell {
|
||||
date: Date;
|
||||
isCurrentMonth: boolean;
|
||||
appointments: Appointment[];
|
||||
}
|
||||
|
||||
// Helper para formatar nome do médico sem duplicar "Dr."
|
||||
const formatDoctorName = (fullName: string): string => {
|
||||
const name = fullName.trim();
|
||||
// Verifica se já começa com Dr. ou Dr (case insensitive)
|
||||
if (/^dr\.?\s/i.test(name)) {
|
||||
return name;
|
||||
}
|
||||
return `Dr. ${name}`;
|
||||
};
|
||||
|
||||
export function SecretaryDoctorSchedule() {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [selectedDoctorId, setSelectedDoctorId] = useState<string>("");
|
||||
@ -36,6 +71,8 @@ export function SecretaryDoctorSchedule() {
|
||||
// Modal states
|
||||
const [showAvailabilityDialog, setShowAvailabilityDialog] = useState(false);
|
||||
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [editingAvailability, setEditingAvailability] = useState<DoctorAvailability | null>(null);
|
||||
|
||||
// Availability form
|
||||
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
|
||||
@ -43,6 +80,12 @@ export function SecretaryDoctorSchedule() {
|
||||
const [endTime, setEndTime] = useState("18:00");
|
||||
const [duration, setDuration] = useState(30);
|
||||
|
||||
// Edit form
|
||||
const [editStartTime, setEditStartTime] = useState("08:00");
|
||||
const [editEndTime, setEditEndTime] = useState("18:00");
|
||||
const [editDuration, setEditDuration] = useState(30);
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
|
||||
// Exception form
|
||||
const [exceptionType, setExceptionType] = useState("férias");
|
||||
const [exceptionStartDate, setExceptionStartDate] = useState("");
|
||||
@ -173,6 +216,55 @@ export function SecretaryDoctorSchedule() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAvailability = (availability: DoctorAvailability) => {
|
||||
setEditingAvailability(availability);
|
||||
setEditStartTime(availability.start_time);
|
||||
setEditEndTime(availability.end_time);
|
||||
setEditDuration(availability.slot_minutes || 30);
|
||||
setEditActive(availability.active ?? true);
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingAvailability?.id) return;
|
||||
|
||||
try {
|
||||
await availabilityService.update(editingAvailability.id, {
|
||||
start_time: editStartTime,
|
||||
end_time: editEndTime,
|
||||
slot_minutes: editDuration,
|
||||
active: editActive,
|
||||
});
|
||||
|
||||
toast.success("Disponibilidade atualizada com sucesso");
|
||||
setShowEditDialog(false);
|
||||
setEditingAvailability(null);
|
||||
loadDoctorSchedule();
|
||||
} catch (error) {
|
||||
console.error("Erro ao atualizar disponibilidade:", error);
|
||||
toast.error("Erro ao atualizar disponibilidade");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAvailability = async (availability: DoctorAvailability) => {
|
||||
if (!availability.id) return;
|
||||
|
||||
const confirmDelete = window.confirm(
|
||||
`Tem certeza que deseja deletar a disponibilidade de ${weekdayToText(availability.weekday)} (${availability.start_time} - ${availability.end_time})?\n\n⚠️ Esta ação é permanente e não pode ser desfeita.`
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
try {
|
||||
await availabilityService.delete(availability.id);
|
||||
toast.success("Disponibilidade deletada com sucesso");
|
||||
loadDoctorSchedule();
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar disponibilidade:", error);
|
||||
toast.error("Erro ao deletar disponibilidade");
|
||||
}
|
||||
};
|
||||
|
||||
const weekdays = [
|
||||
{ value: "monday", label: "Segunda" },
|
||||
{ value: "tuesday", label: "Terça" },
|
||||
@ -207,7 +299,7 @@ export function SecretaryDoctorSchedule() {
|
||||
>
|
||||
{doctors.map((doctor) => (
|
||||
<option key={doctor.id} value={doctor.id}>
|
||||
Dr. {doctor.full_name} - {doctor.specialty}
|
||||
{formatDoctorName(doctor.full_name)} - {doctor.specialty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -313,7 +405,7 @@ export function SecretaryDoctorSchedule() {
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{avail.day_of_week}
|
||||
{weekdayToText(avail.weekday)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{avail.start_time} - {avail.end_time}
|
||||
@ -321,15 +413,17 @@ export function SecretaryDoctorSchedule() {
|
||||
</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
|
||||
{avail.active ? "Ativo" : "Inativo"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleEditAvailability(avail)}
|
||||
title="Editar"
|
||||
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAvailability(avail)}
|
||||
title="Deletar"
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
@ -521,6 +615,98 @@ export function SecretaryDoctorSchedule() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
{showEditDialog && editingAvailability && (
|
||||
<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">
|
||||
Editar Disponibilidade
|
||||
</h3>
|
||||
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-900 font-medium">
|
||||
{weekdayToText(editingAvailability.weekday)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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={editStartTime}
|
||||
onChange={(e) => setEditStartTime(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={editEndTime}
|
||||
onChange={(e) => setEditEndTime(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>
|
||||
<select
|
||||
value={editDuration}
|
||||
onChange={(e) => setEditDuration(Number(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"
|
||||
>
|
||||
<option value={15}>15 minutos</option>
|
||||
<option value={20}>20 minutos</option>
|
||||
<option value={30}>30 minutos</option>
|
||||
<option value={45}>45 minutos</option>
|
||||
<option value={60}>60 minutos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="editActive"
|
||||
checked={editActive}
|
||||
onChange={(e) => setEditActive(e.target.checked)}
|
||||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||||
/>
|
||||
<label htmlFor="editActive" className="text-sm font-medium text-gray-700">
|
||||
Disponibilidade ativa
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditDialog(false);
|
||||
setEditingAvailability(null);
|
||||
}}
|
||||
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={handleSaveEdit}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
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 { patientService, type Patient } from "../../services";
|
||||
import PacienteForm, { type PacienteFormData } from "../pacientes/PacienteForm";
|
||||
import { Avatar } from "../ui/Avatar";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
|
||||
const BLOOD_TYPES = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
|
||||
|
||||
@ -40,6 +41,7 @@ const buscarEnderecoViaCEP = async (cep: string) => {
|
||||
};
|
||||
|
||||
export function SecretaryPatientList() {
|
||||
const { user } = useAuth();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@ -50,6 +52,8 @@ export function SecretaryPatientList() {
|
||||
// Modal states
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [patientToDelete, setPatientToDelete] = useState<Patient | null>(null);
|
||||
const [formData, setFormData] = useState<PacienteFormData>({
|
||||
nome: "",
|
||||
social_name: "",
|
||||
@ -85,6 +89,15 @@ export function SecretaryPatientList() {
|
||||
try {
|
||||
const data = await patientService.list();
|
||||
console.log("✅ Pacientes carregados:", data);
|
||||
// Log para verificar se temos user_id
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
console.log("📋 Primeiro paciente (verificar user_id):", {
|
||||
full_name: data[0].full_name,
|
||||
user_id: data[0].user_id,
|
||||
avatar_url: data[0].avatar_url,
|
||||
email: data[0].email,
|
||||
});
|
||||
}
|
||||
setPatients(Array.isArray(data) ? data : []);
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
console.warn("⚠️ Nenhum paciente encontrado na API");
|
||||
@ -102,6 +115,28 @@ export function SecretaryPatientList() {
|
||||
loadPatients();
|
||||
}, []);
|
||||
|
||||
// Função de filtro
|
||||
const filteredPatients = patients.filter((patient) => {
|
||||
// Filtro de busca por nome, CPF ou email
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const matchesSearch =
|
||||
!searchTerm ||
|
||||
patient.full_name?.toLowerCase().includes(searchLower) ||
|
||||
patient.cpf?.includes(searchTerm) ||
|
||||
patient.email?.toLowerCase().includes(searchLower);
|
||||
|
||||
// Filtro de aniversariantes do mês
|
||||
const matchesBirthday = !showBirthdays || (() => {
|
||||
if (!patient.birth_date) return false;
|
||||
const birthDate = new Date(patient.birth_date);
|
||||
const currentMonth = new Date().getMonth();
|
||||
const birthMonth = birthDate.getMonth();
|
||||
return currentMonth === birthMonth;
|
||||
})();
|
||||
|
||||
return matchesSearch && matchesBirthday;
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
loadPatients();
|
||||
};
|
||||
@ -150,6 +185,7 @@ export function SecretaryPatientList() {
|
||||
setModalMode("edit");
|
||||
setFormData({
|
||||
id: patient.id,
|
||||
user_id: patient.user_id,
|
||||
nome: patient.full_name || "",
|
||||
social_name: patient.social_name || "",
|
||||
cpf: patient.cpf || "",
|
||||
@ -165,6 +201,7 @@ export function SecretaryPatientList() {
|
||||
convenio: "Particular",
|
||||
numeroCarteirinha: "",
|
||||
observacoes: "",
|
||||
avatar_url: patient.avatar_url || undefined,
|
||||
endereco: {
|
||||
cep: patient.cep || "",
|
||||
rua: patient.street || "",
|
||||
@ -213,18 +250,23 @@ export function SecretaryPatientList() {
|
||||
try {
|
||||
if (modalMode === "edit" && formData.id) {
|
||||
// Para edição, usa o endpoint antigo (PATCH /patients/:id)
|
||||
// Remove formatação de telefone, CPF e CEP
|
||||
const cleanPhone = formData.numeroTelefone.replace(/\D/g, '');
|
||||
const cleanCpf = formData.cpf.replace(/\D/g, '');
|
||||
const cleanCep = formData.endereco.cep ? formData.endereco.cep.replace(/\D/g, '') : null;
|
||||
|
||||
const patientData = {
|
||||
full_name: formData.nome,
|
||||
social_name: formData.social_name || null,
|
||||
cpf: formData.cpf,
|
||||
cpf: cleanCpf,
|
||||
sex: formData.sexo || null,
|
||||
birth_date: formData.dataNascimento || null,
|
||||
email: formData.email,
|
||||
phone_mobile: formData.numeroTelefone,
|
||||
phone_mobile: cleanPhone,
|
||||
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,
|
||||
cep: cleanCep,
|
||||
street: formData.endereco.rua || null,
|
||||
number: formData.endereco.numero || null,
|
||||
complement: formData.endereco.complemento || null,
|
||||
@ -235,26 +277,34 @@ export function SecretaryPatientList() {
|
||||
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
|
||||
// Criar novo paciente usando a API REST direta
|
||||
// Remove formatação de telefone e CPF
|
||||
const cleanPhone = formData.numeroTelefone.replace(/\D/g, '');
|
||||
const cleanCpf = formData.cpf.replace(/\D/g, '');
|
||||
const cleanCep = formData.endereco.cep ? formData.endereco.cep.replace(/\D/g, '') : null;
|
||||
|
||||
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,
|
||||
cpf: cleanCpf,
|
||||
email: formData.email,
|
||||
phone_mobile: cleanPhone,
|
||||
birth_date: formData.dataNascimento || null,
|
||||
social_name: formData.social_name || null,
|
||||
sex: formData.sexo || null,
|
||||
blood_type: formData.tipo_sanguineo || null,
|
||||
weight_kg: formData.peso ? parseFloat(formData.peso) : null,
|
||||
height_m: formData.altura ? parseFloat(formData.altura) : 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,
|
||||
cep: cleanCep,
|
||||
created_by: user?.id || undefined,
|
||||
};
|
||||
await userService.createPatient(createData);
|
||||
|
||||
await patientService.create(createData);
|
||||
toast.success("Paciente cadastrado com sucesso!");
|
||||
}
|
||||
|
||||
@ -272,6 +322,34 @@ export function SecretaryPatientList() {
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (patient: Patient) => {
|
||||
setPatientToDelete(patient);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!patientToDelete?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await patientService.delete(patientToDelete.id);
|
||||
toast.success("Paciente deletado com sucesso!");
|
||||
setShowDeleteDialog(false);
|
||||
setPatientToDelete(null);
|
||||
loadPatients();
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar paciente:", error);
|
||||
toast.error("Erro ao deletar paciente");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteDialog(false);
|
||||
setPatientToDelete(null);
|
||||
};
|
||||
|
||||
const getPatientColor = (
|
||||
index: number
|
||||
): "blue" | "green" | "purple" | "orange" | "pink" | "teal" => {
|
||||
@ -394,17 +472,17 @@ export function SecretaryPatientList() {
|
||||
Carregando pacientes...
|
||||
</td>
|
||||
</tr>
|
||||
) : patients.length === 0 ? (
|
||||
) : filteredPatients.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
Nenhum paciente encontrado
|
||||
{searchTerm ? "Nenhum paciente encontrado com esse termo" : "Nenhum paciente encontrado"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
patients.map((patient, index) => (
|
||||
filteredPatients.map((patient, index) => (
|
||||
<tr
|
||||
key={patient.id}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
@ -456,6 +534,7 @@ export function SecretaryPatientList() {
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(patient)}
|
||||
title="Deletar"
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
@ -508,6 +587,57 @@ export function SecretaryPatientList() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteDialog && patientToDelete && (
|
||||
<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-md w-full p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<Trash2 className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Confirmar Exclusão
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Tem certeza que deseja deletar o paciente{" "}
|
||||
<span className="font-semibold">{patientToDelete.full_name}</span>?
|
||||
</p>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<h4 className="text-sm font-semibold text-red-900 mb-2">
|
||||
⚠️ Atenção: Esta ação é irreversível
|
||||
</h4>
|
||||
<ul className="text-sm text-red-800 space-y-1">
|
||||
<li>• Todos os dados do paciente serão perdidos</li>
|
||||
<li>• Histórico de consultas será mantido (por auditoria)</li>
|
||||
<li>• Prontuários médicos serão mantidos (por legislação)</li>
|
||||
<li>• O paciente precisará se cadastrar novamente</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleCancelDelete}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Deletando..." : "Sim, Deletar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Search, FileText, Download, Plus } from "lucide-react";
|
||||
import { Search, FileText, Download, Plus, Eye, Edit2, X } from "lucide-react";
|
||||
import jsPDF from "jspdf";
|
||||
import html2canvas from "html2canvas";
|
||||
import {
|
||||
reportService,
|
||||
type Report,
|
||||
@ -12,15 +14,20 @@ 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");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
patient_id: "",
|
||||
exam: "",
|
||||
diagnosis: "",
|
||||
conclusion: "",
|
||||
status: "draft" as "draft" | "completed" | "pending" | "cancelled",
|
||||
cid_code: "",
|
||||
requested_by: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -43,10 +50,32 @@ export function SecretaryReportList() {
|
||||
exam: "",
|
||||
diagnosis: "",
|
||||
conclusion: "",
|
||||
status: "draft",
|
||||
cid_code: "",
|
||||
requested_by: "",
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleViewReport = (report: Report) => {
|
||||
setSelectedReport(report);
|
||||
setShowViewModal(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (report: Report) => {
|
||||
setSelectedReport(report);
|
||||
setFormData({
|
||||
patient_id: report.patient_id,
|
||||
exam: report.exam || "",
|
||||
diagnosis: report.diagnosis || "",
|
||||
conclusion: report.conclusion || "",
|
||||
status: report.status || "draft",
|
||||
cid_code: report.cid_code || "",
|
||||
requested_by: report.requested_by || "",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleCreateReport = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -72,6 +101,142 @@ export function SecretaryReportList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateReport = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedReport?.id) {
|
||||
toast.error("Relatório não identificado");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await reportService.update(selectedReport.id, {
|
||||
patient_id: formData.patient_id,
|
||||
exam: formData.exam || undefined,
|
||||
diagnosis: formData.diagnosis || undefined,
|
||||
conclusion: formData.conclusion || undefined,
|
||||
status: formData.status,
|
||||
cid_code: formData.cid_code || undefined,
|
||||
requested_by: formData.requested_by || undefined,
|
||||
});
|
||||
|
||||
toast.success("Relatório atualizado com sucesso!");
|
||||
setShowEditModal(false);
|
||||
setSelectedReport(null);
|
||||
loadReports();
|
||||
} catch (error) {
|
||||
console.error("Erro ao atualizar relatório:", error);
|
||||
toast.error("Erro ao atualizar relatório");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadReport = async (report: Report) => {
|
||||
try {
|
||||
// Criar um elemento temporário para o relatório
|
||||
const reportElement = document.createElement("div");
|
||||
reportElement.style.padding = "40px";
|
||||
reportElement.style.backgroundColor = "white";
|
||||
reportElement.style.width = "800px";
|
||||
reportElement.style.fontFamily = "Arial, sans-serif";
|
||||
|
||||
reportElement.innerHTML = `
|
||||
<div style="text-align: center; margin-bottom: 30px; border-bottom: 2px solid #333; padding-bottom: 20px;">
|
||||
<h1 style="color: #16a34a; margin: 0 0 10px 0; font-size: 28px;">Relatório Médico</h1>
|
||||
<p style="color: #666; margin: 0; font-size: 14px;">${report.order_number || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 25px;">
|
||||
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||
<div>
|
||||
<p style="margin: 0 0 5px 0; font-size: 12px; color: #6b7280; font-weight: 600;">STATUS</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #111827;">${
|
||||
report.status === "completed"
|
||||
? "✅ Concluído"
|
||||
: report.status === "pending"
|
||||
? "⏳ Pendente"
|
||||
: report.status === "draft"
|
||||
? "📝 Rascunho"
|
||||
: "❌ Cancelado"
|
||||
}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style="margin: 0 0 5px 0; font-size: 12px; color: #6b7280; font-weight: 600;">DATA</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #111827;">${formatDate(report.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${report.exam ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="color: #16a34a; font-size: 16px; margin: 0 0 10px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 5px;">EXAME REALIZADO</h3>
|
||||
<p style="margin: 0; color: #374151; line-height: 1.6;">${report.exam}</p>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${report.cid_code ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="color: #16a34a; font-size: 16px; margin: 0 0 10px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 5px;">CÓDIGO CID-10</h3>
|
||||
<p style="margin: 0; color: #374151; line-height: 1.6; font-family: monospace; background: #f9fafb; padding: 8px; border-radius: 4px;">${report.cid_code}</p>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${report.requested_by ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="color: #16a34a; font-size: 16px; margin: 0 0 10px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 5px;">SOLICITADO POR</h3>
|
||||
<p style="margin: 0; color: #374151; line-height: 1.6;">${report.requested_by}</p>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${report.diagnosis ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="color: #16a34a; font-size: 16px; margin: 0 0 10px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 5px;">DIAGNÓSTICO</h3>
|
||||
<p style="margin: 0; color: #374151; line-height: 1.8; white-space: pre-wrap;">${report.diagnosis}</p>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${report.conclusion ? `
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h3 style="color: #16a34a; font-size: 16px; margin: 0 0 10px 0; border-bottom: 1px solid #e5e7eb; padding-bottom: 5px;">CONCLUSÃO</h3>
|
||||
<p style="margin: 0; color: #374151; line-height: 1.8; white-space: pre-wrap;">${report.conclusion}</p>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px;">
|
||||
<p style="margin: 0;">Documento gerado em ${new Date().toLocaleDateString("pt-BR", { day: "2-digit", month: "long", year: "numeric" })}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Adicionar ao DOM temporariamente
|
||||
document.body.appendChild(reportElement);
|
||||
|
||||
// Capturar como imagem
|
||||
const canvas = await html2canvas(reportElement, {
|
||||
scale: 2,
|
||||
backgroundColor: "#ffffff",
|
||||
logging: false,
|
||||
});
|
||||
|
||||
// Remover elemento temporário
|
||||
document.body.removeChild(reportElement);
|
||||
|
||||
// Criar PDF
|
||||
const imgWidth = 210; // A4 width in mm
|
||||
const imgHeight = (canvas.height * imgWidth) / canvas.width;
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const imgData = canvas.toDataURL("image/png");
|
||||
|
||||
pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
|
||||
pdf.save(`relatorio-${report.order_number || "sem-numero"}.pdf`);
|
||||
|
||||
toast.success("Relatório baixado com sucesso!");
|
||||
} catch (error) {
|
||||
console.error("Erro ao gerar PDF:", error);
|
||||
toast.error("Erro ao gerar PDF do relatório");
|
||||
}
|
||||
};
|
||||
|
||||
const loadReports = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -96,8 +261,7 @@ export function SecretaryReportList() {
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchTerm("");
|
||||
setTypeFilter("Todos");
|
||||
setPeriodFilter("Todos");
|
||||
setStatusFilter("");
|
||||
loadReports();
|
||||
};
|
||||
|
||||
@ -164,31 +328,17 @@ export function SecretaryReportList() {
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Tipo:</span>
|
||||
<span className="text-sm text-gray-600">Status:</span>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
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>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>
|
||||
<option value="">Todos</option>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="completed">Concluído</option>
|
||||
<option value="pending">Pendente</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -284,18 +434,37 @@ export function SecretaryReportList() {
|
||||
{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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleViewReport(report)}
|
||||
title="Visualizar"
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Ver</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(report)}
|
||||
title="Editar"
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Editar</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadReport(report)}
|
||||
title="Baixar PDF"
|
||||
disabled={report.status !== "completed"}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${
|
||||
report.status === "completed"
|
||||
? "text-green-600 hover:bg-green-50 cursor-pointer"
|
||||
: "text-gray-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Baixar PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@ -400,6 +569,287 @@ export function SecretaryReportList() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Visualizar Relatório */}
|
||||
{showViewModal && selectedReport && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Visualizar Relatório
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowViewModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Número do Relatório
|
||||
</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{selectedReport.order_number || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${
|
||||
selectedReport.status === "completed"
|
||||
? "bg-green-100 text-green-800"
|
||||
: selectedReport.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: selectedReport.status === "draft"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{selectedReport.status === "completed"
|
||||
? "Concluído"
|
||||
: selectedReport.status === "pending"
|
||||
? "Pendente"
|
||||
: selectedReport.status === "draft"
|
||||
? "Rascunho"
|
||||
: "Cancelado"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Exame
|
||||
</label>
|
||||
<p className="text-gray-900">{selectedReport.exam || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Código CID-10
|
||||
</label>
|
||||
<p className="text-gray-900">{selectedReport.cid_code || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Solicitado por
|
||||
</label>
|
||||
<p className="text-gray-900">{selectedReport.requested_by || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Diagnóstico
|
||||
</label>
|
||||
<p className="text-gray-900 whitespace-pre-wrap">
|
||||
{selectedReport.diagnosis || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Conclusão
|
||||
</label>
|
||||
<p className="text-gray-900 whitespace-pre-wrap">
|
||||
{selectedReport.conclusion || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Criado em
|
||||
</label>
|
||||
<p className="text-gray-900 text-sm">
|
||||
{formatDate(selectedReport.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Atualizado em
|
||||
</label>
|
||||
<p className="text-gray-900 text-sm">
|
||||
{formatDate(selectedReport.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowViewModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowViewModal(false);
|
||||
handleOpenEditModal(selectedReport);
|
||||
}}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Editar Relatório
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de Editar Relatório */}
|
||||
{showEditModal && selectedReport && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Editar Relatório
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedReport(null);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdateReport} className="p-6 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Número do Relatório
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedReport.order_number || ""}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Status *
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, status: e.target.value as "draft" | "completed" | "pending" | "cancelled" })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
required
|
||||
>
|
||||
<option value="draft">Rascunho</option>
|
||||
<option value="completed">Concluído</option>
|
||||
<option value="pending">Pendente</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Exame
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.exam}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, exam: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Nome do exame realizado"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Código CID-10
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.cid_code}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cid_code: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Ex: A00.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Solicitado por
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.requested_by}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, requested_by: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Nome do médico solicitante"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Diagnóstico
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.diagnosis}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, diagnosis: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 h-32"
|
||||
placeholder="Diagnóstico do paciente"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Conclusão
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.conclusion}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, conclusion: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 h-32"
|
||||
placeholder="Conclusão e recomendações"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedReport(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,12 +2,13 @@ import { useState, useEffect } from "react";
|
||||
import { User } from "lucide-react";
|
||||
|
||||
interface AvatarProps {
|
||||
/** URL do avatar, objeto com avatar_url, ou userId para buscar */
|
||||
/** 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 };
|
||||
| { id?: string }
|
||||
| { user_id?: string };
|
||||
/** Nome completo para gerar iniciais */
|
||||
name?: string;
|
||||
/** Tamanho do avatar */
|
||||
@ -72,18 +73,34 @@ export function Avatar({
|
||||
}
|
||||
|
||||
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) {
|
||||
// Gera URL pública do Supabase Storage usando user_id
|
||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||
const url = `${SUPABASE_URL}/storage/v1/object/public/avatars/${src.user_id}/avatar.jpg`;
|
||||
console.log("[Avatar] Tentando carregar avatar:", {
|
||||
user_id: src.user_id,
|
||||
url,
|
||||
});
|
||||
setImageUrl(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`
|
||||
);
|
||||
const url = `${SUPABASE_URL}/storage/v1/object/public/avatars/${src.id}/avatar.jpg`;
|
||||
console.log("[Avatar] Tentando carregar avatar por id:", {
|
||||
id: src.id,
|
||||
url,
|
||||
});
|
||||
setImageUrl(url);
|
||||
} else {
|
||||
console.log("[Avatar] Nenhuma URL encontrada, src:", src);
|
||||
setImageUrl(null);
|
||||
}
|
||||
|
||||
@ -105,6 +122,20 @@ export function Avatar({
|
||||
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 });
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
// Log quando imagem carregar com sucesso
|
||||
const handleImageLoad = () => {
|
||||
console.log("[Avatar] ✅ Imagem carregada com sucesso:", {
|
||||
imageUrl,
|
||||
name,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
@ -126,7 +157,8 @@ export function Avatar({
|
||||
src={imageUrl}
|
||||
alt={name || "Avatar"}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setImageError(true)}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-white font-semibold select-none">{initials}</span>
|
||||
|
||||
@ -55,7 +55,25 @@ export function AvatarUpload({
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !userId) return;
|
||||
|
||||
console.log("[AvatarUpload] Arquivo selecionado:", {
|
||||
file: file?.name,
|
||||
userId,
|
||||
hasUserId: !!userId,
|
||||
});
|
||||
|
||||
if (!file) {
|
||||
console.warn("[AvatarUpload] Nenhum arquivo selecionado");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
console.error("[AvatarUpload] ❌ user_id não está definido!");
|
||||
toast.error(
|
||||
"Não foi possível identificar o usuário. Por favor, recarregue a página."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação de tamanho (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
@ -73,6 +91,11 @@ export function AvatarUpload({
|
||||
setShowMenu(false);
|
||||
|
||||
try {
|
||||
console.log("[AvatarUpload] Iniciando upload...", {
|
||||
userId,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
// Upload do avatar
|
||||
await avatarService.upload({
|
||||
userId,
|
||||
@ -91,6 +114,10 @@ export function AvatarUpload({
|
||||
// Adiciona timestamp para forçar reload da imagem
|
||||
const publicUrl = `${baseUrl}?t=${Date.now()}`;
|
||||
|
||||
console.log("[AvatarUpload] Upload concluído, atualizando perfil...", {
|
||||
baseUrl,
|
||||
});
|
||||
|
||||
// Atualiza no perfil (salva sem o timestamp)
|
||||
await profileService.updateAvatar(userId, { avatar_url: baseUrl });
|
||||
|
||||
@ -100,8 +127,9 @@ export function AvatarUpload({
|
||||
// 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("Erro ao fazer upload:", error);
|
||||
console.error("❌ [AvatarUpload] Erro ao fazer upload:", error);
|
||||
toast.error("Erro ao fazer upload do avatar");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
|
||||
@ -58,6 +58,16 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
const { user, roles = [], logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Helper para formatar nome do médico com Dr.
|
||||
const formatDoctorName = (fullName: string): string => {
|
||||
const name = fullName.trim();
|
||||
// Verifica se já começa com Dr. ou Dr (case insensitive)
|
||||
if (/^dr\.?\s/i.test(name)) {
|
||||
return name;
|
||||
}
|
||||
return `Dr. ${name}`;
|
||||
};
|
||||
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState("dashboard");
|
||||
const [consultas, setConsultas] = useState<Consulta[]>([]);
|
||||
@ -122,7 +132,7 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
const medicosData = await doctorService.list();
|
||||
const medicosFormatted: Medico[] = medicosData.map((d) => ({
|
||||
id: d.id,
|
||||
nome: d.full_name,
|
||||
nome: formatDoctorName(d.full_name),
|
||||
especialidade: d.specialty || "",
|
||||
crm: d.crm,
|
||||
email: d.email,
|
||||
|
||||
@ -73,9 +73,12 @@ export default function AuthCallback() {
|
||||
// Magic link ou qualquer callback com sessão válida:
|
||||
// Salvar tokens diretamente no localStorage
|
||||
console.log("[AuthCallback] Salvando tokens e user no localStorage");
|
||||
|
||||
|
||||
localStorage.setItem("mediconnect_access_token", session.access_token);
|
||||
localStorage.setItem("mediconnect_refresh_token", session.refresh_token);
|
||||
localStorage.setItem(
|
||||
"mediconnect_refresh_token",
|
||||
session.refresh_token
|
||||
);
|
||||
localStorage.setItem(
|
||||
"mediconnect_user",
|
||||
JSON.stringify({
|
||||
@ -94,16 +97,19 @@ export default function AuthCallback() {
|
||||
|
||||
// Verificar se há redirecionamento salvo do magic link
|
||||
const savedRedirect = localStorage.getItem("magic_link_redirect");
|
||||
|
||||
|
||||
console.log("[AuthCallback] Verificando redirecionamento:");
|
||||
console.log(" - magic_link_redirect:", savedRedirect);
|
||||
console.log(" - user role:", session.user.user_metadata?.role);
|
||||
console.log(" - localStorage keys:", Object.keys(localStorage));
|
||||
|
||||
|
||||
if (savedRedirect) {
|
||||
console.log("[AuthCallback] ✅ Redirecionando para (saved):", savedRedirect);
|
||||
console.log(
|
||||
"[AuthCallback] ✅ Redirecionando para (saved):",
|
||||
savedRedirect
|
||||
);
|
||||
localStorage.removeItem("magic_link_redirect"); // Limpar após uso
|
||||
|
||||
|
||||
// Usar window.location.href para forçar reload completo e atualizar AuthContext
|
||||
window.location.href = savedRedirect;
|
||||
return;
|
||||
@ -111,10 +117,13 @@ export default function AuthCallback() {
|
||||
|
||||
// Fallback: redirecionar baseado no role
|
||||
const userRole = session.user.user_metadata?.role || "paciente";
|
||||
console.log("[AuthCallback] ⚠️ Nenhum redirect salvo, usando role:", userRole);
|
||||
console.log(
|
||||
"[AuthCallback] ⚠️ Nenhum redirect salvo, usando role:",
|
||||
userRole
|
||||
);
|
||||
|
||||
let redirectUrl = "/acompanhamento"; // default paciente
|
||||
|
||||
|
||||
switch (userRole) {
|
||||
case "medico":
|
||||
console.log("[AuthCallback] Navegando para /painel-medico");
|
||||
@ -130,7 +139,7 @@ export default function AuthCallback() {
|
||||
redirectUrl = "/acompanhamento";
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
// Usar window.location.href para forçar reload completo
|
||||
window.location.href = redirectUrl;
|
||||
} catch (err: any) {
|
||||
|
||||
@ -28,6 +28,17 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
useState<FullUserInfo | null>(null);
|
||||
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();
|
||||
@ -122,6 +133,84 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!createForm.email || !createForm.full_name || !createForm.role) {
|
||||
toast.error("Preencha os campos obrigatórios");
|
||||
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 {
|
||||
const endpoint = createForm.usePassword
|
||||
? "/functions/v1/create-user-with-password"
|
||||
: "/functions/v1/create-user";
|
||||
|
||||
const payload: any = {
|
||||
email: createForm.email,
|
||||
full_name: createForm.full_name,
|
||||
role: createForm.role,
|
||||
};
|
||||
|
||||
if (createForm.usePassword) {
|
||||
payload.password = createForm.password;
|
||||
}
|
||||
|
||||
if (createForm.phone_mobile) {
|
||||
payload.phone_mobile = createForm.phone_mobile;
|
||||
}
|
||||
|
||||
if (createForm.create_patient_record) {
|
||||
payload.create_patient_record = true;
|
||||
payload.cpf = createForm.cpf;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://yuanqfswhberkoevtmfr.supabase.co${endpoint}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl1YW5xZnN3aGJlcmtvZXZ0bWZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ5NTQzNjksImV4cCI6MjA3MDUzMDM2OX0.g8Fm4XAvtX46zifBZnYVH4tVuQkqUH6Ia9CXQj4DztQ",
|
||||
Authorization: `Bearer ${localStorage.getItem("mediconnect_access_token")}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
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(data.message || data.error || "Erro ao criar usuário");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar usuário:", error);
|
||||
toast.error("Erro ao criar usuário");
|
||||
}
|
||||
};
|
||||
|
||||
const usuariosFiltrados = usuarios.filter((user) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
@ -150,16 +239,25 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
@ -586,6 +684,179 @@ 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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
||||
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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
||||
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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
||||
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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
||||
>
|
||||
<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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
||||
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="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-600"
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -24,8 +24,13 @@ const Home: React.FC = () => {
|
||||
// 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");
|
||||
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;
|
||||
}
|
||||
|
||||
@ -204,7 +204,7 @@ const LoginMedico: React.FC = () => {
|
||||
try {
|
||||
// Salvar contexto para redirecionamento correto após magic link
|
||||
localStorage.setItem("magic_link_redirect", "/painel-medico");
|
||||
|
||||
|
||||
await authService.sendMagicLink(formData.email);
|
||||
toast.success(
|
||||
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
||||
|
||||
@ -313,8 +313,11 @@ const LoginPaciente: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Salvar contexto para redirecionamento correto após magic link
|
||||
localStorage.setItem("magic_link_redirect", "/acompanhamento");
|
||||
|
||||
localStorage.setItem(
|
||||
"magic_link_redirect",
|
||||
"/acompanhamento"
|
||||
);
|
||||
|
||||
await authService.sendMagicLink(formData.email);
|
||||
toast.success(
|
||||
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
||||
|
||||
@ -213,8 +213,11 @@ const LoginSecretaria: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Salvar contexto para redirecionamento correto após magic link
|
||||
localStorage.setItem("magic_link_redirect", "/painel-secretaria");
|
||||
|
||||
localStorage.setItem(
|
||||
"magic_link_redirect",
|
||||
"/painel-secretaria"
|
||||
);
|
||||
|
||||
await authService.sendMagicLink(formData.email);
|
||||
toast.success(
|
||||
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
||||
|
||||
@ -37,7 +37,7 @@ type FullUserInfo = UserInfo;
|
||||
type TabType = "pacientes" | "usuarios" | "medicos";
|
||||
|
||||
const PainelAdmin: React.FC = () => {
|
||||
const { roles: authUserRoles } = useAuth();
|
||||
const { roles: authUserRoles, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TabType>("pacientes");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -87,8 +87,11 @@ const PainelAdmin: React.FC = () => {
|
||||
phone: "",
|
||||
role: "user",
|
||||
});
|
||||
const [userPassword, setUserPassword] = useState(""); // Senha opcional
|
||||
const [usePassword, setUsePassword] = useState(false); // Toggle para criar com senha
|
||||
const [userPassword, setUserPassword] = useState("");
|
||||
const [usePassword, setUsePassword] = useState(false);
|
||||
const [userCpf, setUserCpf] = useState("");
|
||||
const [userPhoneMobile, setUserPhoneMobile] = useState("");
|
||||
const [createPatientRecord, setCreatePatientRecord] = useState(false);
|
||||
|
||||
// Estados para dialog de confirmação
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
@ -274,7 +277,10 @@ const PainelAdmin: React.FC = () => {
|
||||
password: userPassword,
|
||||
full_name: formUser.full_name,
|
||||
phone: formUser.phone,
|
||||
phone_mobile: userPhoneMobile,
|
||||
cpf: userCpf,
|
||||
role: formUser.role,
|
||||
create_patient_record: createPatientRecord,
|
||||
});
|
||||
toast.success(
|
||||
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
||||
@ -294,10 +300,24 @@ const PainelAdmin: React.FC = () => {
|
||||
resetFormUser();
|
||||
setUserPassword("");
|
||||
setUsePassword(false);
|
||||
setUserCpf("");
|
||||
setUserPhoneMobile("");
|
||||
setCreatePatientRecord(false);
|
||||
loadUsuarios();
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao criar usuário:", error);
|
||||
toast.error("Erro ao criar usuário");
|
||||
|
||||
// Mostrar mensagem de erro detalhada
|
||||
const errorMessage = error?.response?.data?.message ||
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
"Erro ao criar usuário";
|
||||
|
||||
if (errorMessage.includes("already") || errorMessage.includes("exists") || errorMessage.includes("duplicate")) {
|
||||
toast.error(`Email já cadastrado no sistema`);
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -480,12 +500,20 @@ 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;
|
||||
}
|
||||
|
||||
const patientData = {
|
||||
full_name: formPaciente.full_name,
|
||||
cpf: formPaciente.cpf.replace(/\D/g, ""), // Remover máscara do CPF
|
||||
cpf: cpfLimpo,
|
||||
email: formPaciente.email,
|
||||
phone_mobile: formPaciente.phone_mobile,
|
||||
birth_date: formPaciente.birth_date,
|
||||
birth_date: formPaciente.birth_date || undefined,
|
||||
social_name: formPaciente.social_name,
|
||||
sex: formPaciente.sex,
|
||||
blood_type: formPaciente.blood_type,
|
||||
@ -512,56 +540,91 @@ const PainelAdmin: React.FC = () => {
|
||||
resetFormPaciente();
|
||||
loadPacientes();
|
||||
} else {
|
||||
// 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
|
||||
);
|
||||
// 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
|
||||
});
|
||||
|
||||
toast.success(
|
||||
"Paciente criado com sucesso! Magic link enviado para o email."
|
||||
"Paciente criado com sucesso! Link de acesso enviado para o email."
|
||||
);
|
||||
setShowPacienteModal(false);
|
||||
resetFormPaciente();
|
||||
loadPacientes();
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Erro ao salvar paciente:", error);
|
||||
toast.error("Erro ao salvar paciente");
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePaciente = async (id: string, nome: string) => {
|
||||
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");
|
||||
}
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
// Funções de gerenciamento de médicos
|
||||
@ -609,61 +672,111 @@ const PainelAdmin: React.FC = () => {
|
||||
resetFormMedico();
|
||||
loadMedicos();
|
||||
} else {
|
||||
// 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
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Depois criar registro na tabela doctors com createDoctor (sem password)
|
||||
await userService.createDoctor({
|
||||
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,
|
||||
cpf: medicoData.cpf,
|
||||
full_name: medicoData.full_name,
|
||||
});
|
||||
|
||||
await userService.createDoctor({
|
||||
email: medicoData.email,
|
||||
specialty: medicoData.specialty,
|
||||
phone_mobile: medicoData.phone_mobile,
|
||||
full_name: medicoData.full_name,
|
||||
cpf: cpfLimpo,
|
||||
crm: medicoData.crm,
|
||||
crm_uf: medicoData.crm_uf,
|
||||
specialty: medicoData.specialty || undefined,
|
||||
phone_mobile: medicoData.phone_mobile || undefined,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
"Médico criado com sucesso! Magic link enviado para o email."
|
||||
"Médico criado com sucesso! Link de acesso enviado para o email."
|
||||
);
|
||||
setShowMedicoModal(false);
|
||||
resetFormMedico();
|
||||
loadMedicos();
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Erro ao salvar médico:", error);
|
||||
toast.error("Erro ao salvar médico");
|
||||
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);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMedico = async (id: string, nome: string) => {
|
||||
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");
|
||||
}
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
const resetFormPaciente = () => {
|
||||
@ -1276,21 +1389,28 @@ const PainelAdmin: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
CPF *
|
||||
CPF * <span className="text-xs text-gray-500">(11 dígitos)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formPaciente.cpf}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, ""); // Remove não-dígitos
|
||||
setFormPaciente({
|
||||
...formPaciente,
|
||||
cpf: e.target.value,
|
||||
})
|
||||
}
|
||||
cpf: value,
|
||||
});
|
||||
}}
|
||||
maxLength={11}
|
||||
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="00000000000"
|
||||
placeholder="12345678901"
|
||||
/>
|
||||
{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">
|
||||
@ -1330,9 +1450,12 @@ 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 mágico
|
||||
(magic link) será enviado automaticamente para o email
|
||||
do paciente para ativar a conta e definir senha.
|
||||
🔐 <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
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -1519,21 +1642,83 @@ const PainelAdmin: React.FC = () => {
|
||||
|
||||
{/* 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
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* Telefone Celular (obrigatório quando usa senha) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Telefone Celular *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required={usePassword}
|
||||
value={userPhoneMobile}
|
||||
onChange={(e) => setUserPhoneMobile(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>
|
||||
|
||||
{/* CPF (obrigatório quando usa senha) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
CPF *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required={usePassword}
|
||||
value={userCpf}
|
||||
onChange={(e) => setUserCpf(e.target.value.replace(/\D/g, ''))}
|
||||
maxLength={11}
|
||||
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="12345678900"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Apenas números (11 dígitos)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Criar registro de paciente */}
|
||||
<div className="border-t pt-4">
|
||||
<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 na tabela de pacientes
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
Marque se o usuário também for um paciente
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{usePassword && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<p className="text-xs text-yellow-700">
|
||||
⚠️ Campos obrigatórios para criar com senha: Telefone Celular e CPF
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -1657,18 +1842,25 @@ const PainelAdmin: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
CPF *
|
||||
CPF * <span className="text-xs text-gray-500">(11 dígitos)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formMedico.cpf}
|
||||
onChange={(e) =>
|
||||
setFormMedico({ ...formMedico, cpf: e.target.value })
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, ""); // Remove não-dígitos
|
||||
setFormMedico({ ...formMedico, cpf: value });
|
||||
}}
|
||||
maxLength={11}
|
||||
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="000.000.000-00"
|
||||
placeholder="12345678901"
|
||||
/>
|
||||
{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>
|
||||
@ -1697,7 +1889,7 @@ const PainelAdmin: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Telefone
|
||||
Telefone Celular <span className="text-xs text-gray-500">(opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -1709,15 +1901,15 @@ const PainelAdmin: React.FC = () => {
|
||||
})
|
||||
}
|
||||
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="(00) 00000-0000"
|
||||
placeholder="(11) 98888-8888"
|
||||
/>
|
||||
</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 mágico
|
||||
(magic link) será enviado automaticamente para o email
|
||||
do médico para ativar a conta e definir senha.
|
||||
🔐 <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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -229,6 +229,35 @@ 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}`;
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
return functionsClient.post<T>(fullUrl, data, config);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@ -17,15 +17,24 @@ class AppointmentService {
|
||||
|
||||
/**
|
||||
* Busca horários disponíveis de um médico
|
||||
* POST /functions/v1/get-available-slots
|
||||
*/
|
||||
async getAvailableSlots(
|
||||
data: GetAvailableSlotsInput
|
||||
): Promise<GetAvailableSlotsResponse> {
|
||||
const response = await apiClient.post<GetAvailableSlotsResponse>(
|
||||
"/get-available-slots",
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
try {
|
||||
// Usa callFunction para chamar a Edge Function
|
||||
const response = await apiClient.callFunction<GetAvailableSlotsResponse>(
|
||||
"get-available-slots",
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("[AppointmentService] Erro ao buscar slots:", error);
|
||||
throw new Error(
|
||||
(error as Error).message || "Erro ao buscar horários disponíveis"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,11 +92,37 @@ class AppointmentService {
|
||||
|
||||
/**
|
||||
* Cria novo agendamento
|
||||
* POST /rest/v1/appointments
|
||||
* 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;
|
||||
try {
|
||||
// Adiciona created_by se não estiver presente
|
||||
const payload = {
|
||||
...data,
|
||||
duration_minutes: data.duration_minutes || 30,
|
||||
appointment_type: data.appointment_type || "presencial",
|
||||
status: "requested",
|
||||
};
|
||||
|
||||
const response = await apiClient.post<Appointment[]>(
|
||||
"/rest/v1/appointments",
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
throw new Error("Erro ao criar agendamento");
|
||||
} catch (error) {
|
||||
console.error("[AppointmentService] Erro ao criar agendamento:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -73,13 +73,11 @@ export interface AppointmentFilters {
|
||||
|
||||
export interface GetAvailableSlotsInput {
|
||||
doctor_id: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
appointment_type?: AppointmentType;
|
||||
date: string; // YYYY-MM-DD format
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
datetime: string;
|
||||
time: string; // HH:MM format (e.g., "09:00")
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -10,17 +10,35 @@ import {
|
||||
ListAvailabilityFilters,
|
||||
CreateAvailabilityInput,
|
||||
UpdateAvailabilityInput,
|
||||
DoctorException,
|
||||
CreateExceptionInput,
|
||||
} from "./types";
|
||||
|
||||
class AvailabilityService {
|
||||
private readonly basePath = "/doctor-availability";
|
||||
private readonly basePath = "/doctor_availability";
|
||||
private readonly exceptionsPath = "/doctor_exceptions";
|
||||
|
||||
/**
|
||||
* Lista as disponibilidades dos médicos
|
||||
* Lista as disponibilidades dos médicos via Supabase REST API
|
||||
*/
|
||||
async list(filters?: ListAvailabilityFilters): Promise<DoctorAvailability[]> {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (filters?.doctor_id) {
|
||||
params.doctor_id = `eq.${filters.doctor_id}`;
|
||||
}
|
||||
if (filters?.weekday !== undefined) {
|
||||
params.weekday = `eq.${filters.weekday}`;
|
||||
}
|
||||
if (filters?.active !== undefined) {
|
||||
params.active = `eq.${filters.active}`;
|
||||
}
|
||||
if (filters?.appointment_type) {
|
||||
params.appointment_type = `eq.${filters.appointment_type}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<DoctorAvailability[]>(this.basePath, {
|
||||
params: filters,
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
@ -29,11 +47,16 @@ class AvailabilityService {
|
||||
* Cria uma nova configuração de disponibilidade
|
||||
*/
|
||||
async create(data: CreateAvailabilityInput): Promise<DoctorAvailability> {
|
||||
const response = await apiClient.post<DoctorAvailability>(
|
||||
const response = await apiClient.post<DoctorAvailability[]>(
|
||||
this.basePath,
|
||||
data
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
return Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,18 +66,73 @@ class AvailabilityService {
|
||||
id: string,
|
||||
data: UpdateAvailabilityInput
|
||||
): Promise<DoctorAvailability> {
|
||||
const response = await apiClient.patch<DoctorAvailability>(
|
||||
`${this.basePath}/${id}`,
|
||||
data
|
||||
const response = await apiClient.patch<DoctorAvailability[]>(
|
||||
`${this.basePath}?id=eq.${id}`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
return Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove uma configuração de disponibilidade
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`${this.basePath}/${id}`);
|
||||
await apiClient.delete(`${this.basePath}?id=eq.${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista exceções de agenda (bloqueios e disponibilidades extras)
|
||||
*/
|
||||
async listExceptions(filters?: {
|
||||
doctor_id?: string;
|
||||
date?: string;
|
||||
kind?: "bloqueio" | "disponibilidade_extra";
|
||||
}): Promise<DoctorException[]> {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (filters?.doctor_id) {
|
||||
params.doctor_id = `eq.${filters.doctor_id}`;
|
||||
}
|
||||
if (filters?.date) {
|
||||
params.date = `eq.${filters.date}`;
|
||||
}
|
||||
if (filters?.kind) {
|
||||
params.kind = `eq.${filters.kind}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<DoctorException[]>(
|
||||
this.exceptionsPath,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma exceção de agenda
|
||||
*/
|
||||
async createException(data: CreateExceptionInput): Promise<DoctorException> {
|
||||
const response = await apiClient.post<DoctorException[]>(
|
||||
this.exceptionsPath,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
}
|
||||
);
|
||||
return Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove uma exceção de agenda
|
||||
*/
|
||||
async deleteException(id: string): Promise<void> {
|
||||
await apiClient.delete(`${this.exceptionsPath}?id=eq.${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,48 +4,58 @@
|
||||
* 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
|
||||
* Tipo de exceção
|
||||
*/
|
||||
export type ExceptionKind = "bloqueio" | "disponibilidade_extra";
|
||||
|
||||
/**
|
||||
* Interface para disponibilidade de médico (Supabase REST API)
|
||||
*/
|
||||
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;
|
||||
doctor_id: string;
|
||||
weekday: number; // 0=Domingo, 1=Segunda, ..., 6=Sábado
|
||||
start_time: string; // Formato: HH:MM (ex: "08:00")
|
||||
end_time: string; // Formato: HH:MM (ex: "18:00")
|
||||
slot_minutes?: number; // Default: 30, range: 15-120
|
||||
appointment_type?: AppointmentType; // Default: 'presencial'
|
||||
active?: boolean; // Default: true
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
updated_by?: string | null;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface para exceções de agenda
|
||||
*/
|
||||
export interface DoctorException {
|
||||
id?: string;
|
||||
doctor_id: string;
|
||||
date: string; // Formato: YYYY-MM-DD
|
||||
kind: ExceptionKind;
|
||||
start_time?: string | null; // null = dia inteiro
|
||||
end_time?: string | null; // null = dia inteiro
|
||||
reason?: string | null;
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtros para listagem de disponibilidades
|
||||
*/
|
||||
export interface ListAvailabilityFilters {
|
||||
select?: string;
|
||||
doctor_id?: string;
|
||||
weekday?: number; // 0-6
|
||||
active?: boolean;
|
||||
appointment_type?: AppointmentType;
|
||||
select?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -53,10 +63,10 @@ export interface ListAvailabilityFilters {
|
||||
*/
|
||||
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
|
||||
weekday: number; // required - 0=Domingo, 1=Segunda, ..., 6=Sábado
|
||||
start_time: string; // required - Formato: HH:MM (ex: "08:00")
|
||||
end_time: string; // required - Formato: HH:MM (ex: "18:00")
|
||||
slot_minutes?: number; // optional - Default: 30, range: 15-120
|
||||
appointment_type?: AppointmentType; // optional - Default: 'presencial'
|
||||
active?: boolean; // optional - Default: true
|
||||
}
|
||||
@ -65,10 +75,23 @@ export interface CreateAvailabilityInput {
|
||||
* 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")
|
||||
weekday?: number;
|
||||
start_time?: string; // Formato: HH:MM
|
||||
end_time?: string; // Formato: HH:MM
|
||||
slot_minutes?: number;
|
||||
appointment_type?: AppointmentType;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input para criar exceção
|
||||
*/
|
||||
export interface CreateExceptionInput {
|
||||
doctor_id: string;
|
||||
date: string; // Formato: YYYY-MM-DD
|
||||
kind: ExceptionKind;
|
||||
start_time?: string | null; // null = dia inteiro
|
||||
end_time?: string | null; // null = dia inteiro
|
||||
reason?: string | null;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
@ -16,6 +16,19 @@ class AvatarService {
|
||||
private readonly STORAGE_URL = `${this.SUPABASE_URL}/storage/v1/object`;
|
||||
private readonly BUCKET_NAME = "avatars";
|
||||
|
||||
/**
|
||||
* Cria uma instância limpa do axios sem baseURL
|
||||
* Para evitar conflitos com configurações globais
|
||||
*/
|
||||
private createAxiosInstance() {
|
||||
return axios.create({
|
||||
// NÃO definir baseURL aqui - usaremos URL completa
|
||||
timeout: 30000,
|
||||
maxContentLength: 2 * 1024 * 1024, // 2MB
|
||||
maxBodyLength: 2 * 1024 * 1024, // 2MB
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Faz upload de avatar do usuário
|
||||
*/
|
||||
@ -35,8 +48,14 @@ class AvatarService {
|
||||
const formData = new FormData();
|
||||
formData.append("file", data.file);
|
||||
|
||||
console.log("[AvatarService] Upload:", {
|
||||
url: `${this.STORAGE_URL}/${this.BUCKET_NAME}/${filePath}`,
|
||||
// URL COMPLETA (sem baseURL do axios)
|
||||
const uploadUrl = `${this.STORAGE_URL}/${this.BUCKET_NAME}/${filePath}`;
|
||||
|
||||
console.log("[AvatarService] 🚀 Upload iniciado:", {
|
||||
uploadUrl,
|
||||
STORAGE_URL: this.STORAGE_URL,
|
||||
BUCKET_NAME: this.BUCKET_NAME,
|
||||
filePath,
|
||||
userId: data.userId,
|
||||
fileName: data.file.name,
|
||||
fileSize: data.file.size,
|
||||
@ -44,22 +63,26 @@ class AvatarService {
|
||||
token: token ? `${token.substring(0, 20)}...` : "null",
|
||||
});
|
||||
|
||||
// Upload usando Supabase Storage API
|
||||
// x-upsert: true permite sobrescrever arquivos existentes
|
||||
// Importante: NÃO definir Content-Type manualmente, deixar o axios/navegador
|
||||
// definir automaticamente com o boundary correto para multipart/form-data
|
||||
const response = await axios.post(
|
||||
`${this.STORAGE_URL}/${this.BUCKET_NAME}/${filePath}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-upsert": "true",
|
||||
},
|
||||
}
|
||||
);
|
||||
// Cria instância limpa do axios
|
||||
const axiosInstance = this.createAxiosInstance();
|
||||
|
||||
console.log("[AvatarService] Upload response:", response.data);
|
||||
console.log("[AvatarService] 🔍 Verificando URL antes do POST:");
|
||||
console.log(" - URL completa:", uploadUrl);
|
||||
console.log(" - Deve começar com:", this.SUPABASE_URL);
|
||||
console.log(" - Deve conter: /storage/v1/object/avatars/");
|
||||
|
||||
// Upload usando Supabase Storage API
|
||||
// Importante: NÃO definir Content-Type manualmente
|
||||
const response = await axiosInstance.post(uploadUrl, formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: API_CONFIG.SUPABASE_ANON_KEY,
|
||||
"x-upsert": "true",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[AvatarService] ✅ Upload bem-sucedido:", response.data);
|
||||
console.log("[AvatarService] 📍 URL real usada:", response.config?.url);
|
||||
|
||||
// Retorna a URL pública
|
||||
const publicUrl = this.getPublicUrl({
|
||||
@ -71,14 +94,39 @@ class AvatarService {
|
||||
Key: publicUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer upload do avatar:", error);
|
||||
console.error("❌ [AvatarService] Erro ao fazer upload:", error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error("Detalhes do erro:", {
|
||||
console.error("📋 Detalhes do erro:", {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
url: error.config?.url,
|
||||
message: error.message,
|
||||
requestUrl: error.config?.url,
|
||||
requestMethod: error.config?.method,
|
||||
headers: error.config?.headers,
|
||||
});
|
||||
|
||||
console.error("🔍 URL que foi enviada:", error.config?.url);
|
||||
console.error(
|
||||
"🔍 URL esperada:",
|
||||
`${this.STORAGE_URL}/${this.BUCKET_NAME}/{user_id}/avatar.{ext}`
|
||||
);
|
||||
|
||||
// Mensagens de erro mais específicas
|
||||
if (error.response?.status === 400) {
|
||||
console.error(
|
||||
"💡 Erro 400: Verifique se o bucket 'avatars' existe e está configurado corretamente"
|
||||
);
|
||||
console.error(
|
||||
" OU: Verifique se a URL está correta (deve ter /storage/v1/object/avatars/)"
|
||||
);
|
||||
} else if (error.response?.status === 401) {
|
||||
console.error("💡 Erro 401: Token inválido ou expirado");
|
||||
} else if (error.response?.status === 403) {
|
||||
console.error(
|
||||
"💡 Erro 403: Sem permissão. Verifique as políticas RLS do Storage"
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -108,17 +108,20 @@ export type {
|
||||
CreateAvailabilityInput,
|
||||
UpdateAvailabilityInput,
|
||||
ListAvailabilityFilters,
|
||||
Weekday,
|
||||
} from "./availability/types";
|
||||
|
||||
// Exceptions
|
||||
export { exceptionsService } from "./exceptions/exceptionsService";
|
||||
export type {
|
||||
AppointmentType as AvailabilityAppointmentType,
|
||||
DoctorException,
|
||||
CreateExceptionInput,
|
||||
ListExceptionsFilters,
|
||||
ExceptionKind,
|
||||
} from "./exceptions/types";
|
||||
} from "./availability/types";
|
||||
|
||||
// Exceptions (deprecated - agora gerenciado via availabilityService)
|
||||
// export { exceptionsService } from "./exceptions/exceptionsService";
|
||||
// export type {
|
||||
// DoctorException,
|
||||
// CreateExceptionInput,
|
||||
// ListExceptionsFilters,
|
||||
// ExceptionKind,
|
||||
// } from "./exceptions/types";
|
||||
|
||||
// API Client (caso precise usar diretamente)
|
||||
export { apiClient } from "./api/client";
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
export interface Patient {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
full_name: string;
|
||||
cpf: string;
|
||||
email: string;
|
||||
@ -22,6 +23,7 @@ export interface Patient {
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
cep?: string | null;
|
||||
avatar_url?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
@ -45,6 +47,7 @@ export interface CreatePatientInput {
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
cep?: string | null;
|
||||
created_by?: string; // UUID do usuário que criou
|
||||
}
|
||||
|
||||
export interface RegisterPatientInput {
|
||||
|
||||
@ -167,9 +167,13 @@ class UserService {
|
||||
*/
|
||||
async createDoctor(data: CreateDoctorInput): Promise<CreateDoctorResponse> {
|
||||
const token = localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
|
||||
const url = `${API_CONFIG.FUNCTIONS_URL}/create-doctor`;
|
||||
|
||||
console.log("[userService.createDoctor] URL:", url);
|
||||
console.log("[userService.createDoctor] Data:", data);
|
||||
|
||||
const response = await axios.post<CreateDoctorResponse>(
|
||||
`${API_CONFIG.FUNCTIONS_URL}/create-doctor`,
|
||||
url,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
@ -193,9 +197,13 @@ class UserService {
|
||||
data: CreatePatientInput
|
||||
): Promise<CreatePatientResponse> {
|
||||
const token = localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
|
||||
const url = `${API_CONFIG.FUNCTIONS_URL}/create-patient`;
|
||||
|
||||
console.log("[userService.createPatient] URL:", url);
|
||||
console.log("[userService.createPatient] Data:", data);
|
||||
|
||||
const response = await axios.post<CreatePatientResponse>(
|
||||
`${API_CONFIG.FUNCTIONS_URL}/create-patient`,
|
||||
url,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user