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
|
# 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 Principal:** https://mediconnectbrasil.app/
|
||||||
- **URL Cloudflare:** https://mediconnect-5oz.pages.dev/
|
- **URL Cloudflare:** https://mediconnect-5oz.pages.dev/
|
||||||
|
|
||||||
---
|
### Credenciais de Teste
|
||||||
|
|
||||||
## 🏗️ Arquitetura Atual (Outubro 2025)
|
**Médico:**
|
||||||
|
- Email: medico@teste.com
|
||||||
|
- Senha: senha123
|
||||||
|
|
||||||
```
|
**Paciente:**
|
||||||
Frontend (Vite/React) → Supabase API
|
- Email: paciente@teste.com
|
||||||
↓ ├── Auth (JWT)
|
- Senha: senha123
|
||||||
Cloudflare Pages ├── PostgREST (PostgreSQL)
|
|
||||||
└── Storage (Avatares)
|
|
||||||
```
|
|
||||||
|
|
||||||
**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
|
## <20> Instalação e Execução
|
||||||
# 1. Instalar dependências
|
|
||||||
|
### Pré-requisitos
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- pnpm (recomendado) ou npm
|
||||||
|
|
||||||
|
### Instalação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependências
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# 2. Iniciar servidor de desenvolvimento
|
# Iniciar desenvolvimento
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
|
||||||
# 3. Acessar http://localhost:5173
|
# Acessar em http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build e Deploy
|
### Build e Deploy
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
# Build de produção
|
# Build de produção
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Deploy para Cloudflare
|
# Deploy para Cloudflare Pages
|
||||||
npx wrangler pages deploy dist --project-name=mediconnect --branch=production
|
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)
|
### 🏢 Para Secretárias
|
||||||
- `refresh_token` (para renovação automática)
|
- ✅ 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) => {
|
## 🔧 Tecnologias
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
if (token) {
|
### Frontend
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
- **React 18** - Interface moderna e reativa
|
||||||
}
|
- **TypeScript** - Tipagem estática
|
||||||
return config;
|
- **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)
|
### 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**
|
**Médicos:**
|
||||||
- ✅ Criar/editar/deletar usuários, médicos, pacientes
|
- Gerenciar agenda e disponibilidade
|
||||||
- ✅ Visualizar todos os agendamentos e prontuários
|
- 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**
|
**Secretárias:**
|
||||||
- ✅ Veem apenas **seus próprios laudos** (filtro: `created_by = médico`)
|
- Cadastrar médicos e pacientes
|
||||||
- ✅ Veem apenas **seus próprios agendamentos** (filtro: `doctor_id = médico`)
|
- Gerenciar agendamentos
|
||||||
- ✅ Editam apenas **seus próprios laudos e 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 dados**
|
||||||
- ✅ Veem apenas **seus próprios laudos** (filtro: `patient_id = paciente`)
|
- ✅ Veem apenas **seus próprios laudos** (filtro: `patient_id = paciente`)
|
||||||
@ -150,12 +444,17 @@ src/services/
|
|||||||
#### 🔐 Autenticação (authService)
|
#### 🔐 Autenticação (authService)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Login
|
// Login com Email e Senha
|
||||||
await authService.login({ email, password });
|
await authService.login({ email, password });
|
||||||
// Retorna: { access_token, refresh_token, user }
|
// 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
|
// Recuperação de senha
|
||||||
await authService.requestPasswordReset(email);
|
await authService.requestPasswordReset("email@example.com");
|
||||||
// Envia email com link de reset
|
// Envia email com link de reset
|
||||||
|
|
||||||
// Atualizar senha
|
// Atualizar senha
|
||||||
@ -166,6 +465,18 @@ await authService.updatePassword(accessToken, newPassword);
|
|||||||
await authService.refreshToken(refreshToken);
|
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)
|
#### 👤 Usuários (userService)
|
||||||
|
|
||||||
```typescript
|
```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",
|
"@supabase/supabase-js": "^2.76.1",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^3.0.3",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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:
|
date-fns:
|
||||||
specifier: ^2.30.0
|
specifier: ^2.30.0
|
||||||
version: 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:
|
lucide-react:
|
||||||
specifier: ^0.540.0
|
specifier: ^0.540.0
|
||||||
version: 0.540.0(react@18.3.1)
|
version: 0.540.0(react@18.3.1)
|
||||||
@ -819,12 +825,18 @@ packages:
|
|||||||
'@types/normalize-package-data@2.4.4':
|
'@types/normalize-package-data@2.4.4':
|
||||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||||
|
|
||||||
|
'@types/pako@2.0.4':
|
||||||
|
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||||
|
|
||||||
'@types/phoenix@1.6.6':
|
'@types/phoenix@1.6.6':
|
||||||
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
|
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
|
'@types/raf@3.4.3':
|
||||||
|
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7':
|
'@types/react-dom@18.3.7':
|
||||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -836,6 +848,9 @@ packages:
|
|||||||
'@types/triple-beam@1.3.5':
|
'@types/triple-beam@1.3.5':
|
||||||
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
@ -1060,6 +1075,10 @@ packages:
|
|||||||
bare-abort-controller:
|
bare-abort-controller:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2:
|
||||||
|
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
@ -1120,6 +1139,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001750:
|
caniuse-lite@1.0.30001750:
|
||||||
resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==}
|
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:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -1200,6 +1223,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-X8XDzyvYaA6msMyAM575CUoygY5b44QzLcGRKsK3MFmXcOvQa518dNPLsKYwkYsn72g3EiW+LE0ytd/FlqWmyw==}
|
resolution: {integrity: sha512-X8XDzyvYaA6msMyAM575CUoygY5b44QzLcGRKsK3MFmXcOvQa518dNPLsKYwkYsn72g3EiW+LE0ytd/FlqWmyw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
core-js@3.46.0:
|
||||||
|
resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==}
|
||||||
|
|
||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
@ -1220,6 +1246,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -1307,6 +1336,9 @@ packages:
|
|||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
|
dompurify@3.3.0:
|
||||||
|
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
|
||||||
|
|
||||||
dot-prop@9.0.0:
|
dot-prop@9.0.0:
|
||||||
resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==}
|
resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -1488,6 +1520,9 @@ packages:
|
|||||||
fast-levenshtein@2.0.6:
|
fast-levenshtein@2.0.6:
|
||||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||||
|
|
||||||
|
fast-png@6.4.0:
|
||||||
|
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
|
||||||
|
|
||||||
fastq@1.19.1:
|
fastq@1.19.1:
|
||||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||||
|
|
||||||
@ -1506,6 +1541,9 @@ packages:
|
|||||||
fecha@4.2.3:
|
fecha@4.2.3:
|
||||||
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
|
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:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@ -1659,6 +1697,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
|
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
|
||||||
engines: {node: ^16.14.0 || >=18.0.0}
|
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:
|
https-proxy-agent@7.0.6:
|
||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@ -1698,6 +1740,9 @@ packages:
|
|||||||
inherits@2.0.4:
|
inherits@2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
iobuffer@5.4.0:
|
||||||
|
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1794,6 +1839,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jspdf@3.0.3:
|
||||||
|
resolution: {integrity: sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==}
|
||||||
|
|
||||||
junk@4.0.1:
|
junk@4.0.1:
|
||||||
resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==}
|
resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==}
|
||||||
engines: {node: '>=12.20'}
|
engines: {node: '>=12.20'}
|
||||||
@ -2036,6 +2084,9 @@ packages:
|
|||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
|
pako@2.1.0:
|
||||||
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -2074,6 +2125,9 @@ packages:
|
|||||||
pend@1.2.0:
|
pend@1.2.0:
|
||||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||||
|
|
||||||
|
performance-now@2.1.0:
|
||||||
|
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@ -2178,6 +2232,9 @@ packages:
|
|||||||
quote-unquote@1.0.0:
|
quote-unquote@1.0.0:
|
||||||
resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==}
|
resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==}
|
||||||
|
|
||||||
|
raf@3.4.1:
|
||||||
|
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||||
|
|
||||||
react-dom@18.3.1:
|
react-dom@18.3.1:
|
||||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2244,6 +2301,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
||||||
|
regenerator-runtime@0.13.11:
|
||||||
|
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||||
|
|
||||||
remove-trailing-separator@1.1.0:
|
remove-trailing-separator@1.1.0:
|
||||||
resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==}
|
resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==}
|
||||||
|
|
||||||
@ -2275,6 +2335,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
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:
|
rollup@4.52.4:
|
||||||
resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==}
|
resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
@ -2343,6 +2407,10 @@ packages:
|
|||||||
stack-trace@0.0.10:
|
stack-trace@0.0.10:
|
||||||
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
|
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:
|
streamx@2.23.0:
|
||||||
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
||||||
|
|
||||||
@ -2389,6 +2457,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
tailwindcss@3.4.18:
|
||||||
resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==}
|
resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@ -2407,6 +2479,9 @@ packages:
|
|||||||
text-hex@1.0.0:
|
text-hex@1.0.0:
|
||||||
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
|
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:
|
thenify-all@1.6.0:
|
||||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@ -2500,6 +2575,9 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||||
|
|
||||||
uuid@11.1.0:
|
uuid@11.1.0:
|
||||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -3301,10 +3379,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/normalize-package-data@2.4.4': {}
|
'@types/normalize-package-data@2.4.4': {}
|
||||||
|
|
||||||
|
'@types/pako@2.0.4': {}
|
||||||
|
|
||||||
'@types/phoenix@1.6.6': {}
|
'@types/phoenix@1.6.6': {}
|
||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
|
'@types/raf@3.4.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.26)':
|
'@types/react-dom@18.3.7(@types/react@18.3.26)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.26
|
'@types/react': 18.3.26
|
||||||
@ -3316,6 +3399,9 @@ snapshots:
|
|||||||
|
|
||||||
'@types/triple-beam@1.3.5': {}
|
'@types/triple-beam@1.3.5': {}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.7.2
|
'@types/node': 24.7.2
|
||||||
@ -3613,6 +3699,8 @@ snapshots:
|
|||||||
|
|
||||||
bare-events@2.8.0: {}
|
bare-events@2.8.0: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.8.16: {}
|
baseline-browser-mapping@2.8.16: {}
|
||||||
@ -3668,6 +3756,18 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001750: {}
|
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:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@ -3749,6 +3849,9 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
p-event: 6.0.1
|
p-event: 6.0.1
|
||||||
|
|
||||||
|
core-js@3.46.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
crc-32@1.2.2: {}
|
crc-32@1.2.2: {}
|
||||||
@ -3768,6 +3871,10 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
@ -3852,6 +3959,11 @@ snapshots:
|
|||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
|
dompurify@3.3.0:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
optional: true
|
||||||
|
|
||||||
dot-prop@9.0.0:
|
dot-prop@9.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 4.41.0
|
type-fest: 4.41.0
|
||||||
@ -4100,6 +4212,12 @@ snapshots:
|
|||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
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:
|
fastq@1.19.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
@ -4114,6 +4232,8 @@ snapshots:
|
|||||||
|
|
||||||
fecha@4.2.3: {}
|
fecha@4.2.3: {}
|
||||||
|
|
||||||
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
@ -4254,6 +4374,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 7.18.3
|
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:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
@ -4282,6 +4407,8 @@ snapshots:
|
|||||||
|
|
||||||
inherits@2.0.4: {}
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
iobuffer@5.4.0: {}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
binary-extensions: 2.3.0
|
binary-extensions: 2.3.0
|
||||||
@ -4348,6 +4475,17 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
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: {}
|
junk@4.0.1: {}
|
||||||
|
|
||||||
jwt-decode@4.0.0: {}
|
jwt-decode@4.0.0: {}
|
||||||
@ -4559,6 +4697,8 @@ snapshots:
|
|||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
|
pako@2.1.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@ -4588,6 +4728,9 @@ snapshots:
|
|||||||
|
|
||||||
pend@1.2.0: {}
|
pend@1.2.0: {}
|
||||||
|
|
||||||
|
performance-now@2.1.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
@ -4682,6 +4825,11 @@ snapshots:
|
|||||||
|
|
||||||
quote-unquote@1.0.0: {}
|
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):
|
react-dom@18.3.1(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@ -4765,6 +4913,9 @@ snapshots:
|
|||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
|
regenerator-runtime@0.13.11:
|
||||||
|
optional: true
|
||||||
|
|
||||||
remove-trailing-separator@1.1.0: {}
|
remove-trailing-separator@1.1.0: {}
|
||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
@ -4789,6 +4940,9 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rgbcolor@1.0.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
rollup@4.52.4:
|
rollup@4.52.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@ -4868,6 +5022,9 @@ snapshots:
|
|||||||
|
|
||||||
stack-trace@0.0.10: {}
|
stack-trace@0.0.10: {}
|
||||||
|
|
||||||
|
stackblur-canvas@2.7.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
streamx@2.23.0:
|
streamx@2.23.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
events-universal: 1.0.1
|
events-universal: 1.0.1
|
||||||
@ -4925,6 +5082,9 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svg-pathdata@6.0.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tailwindcss@3.4.18(yaml@2.8.1):
|
tailwindcss@3.4.18(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@alloc/quick-lru': 5.2.0
|
'@alloc/quick-lru': 5.2.0
|
||||||
@ -4978,6 +5138,10 @@ snapshots:
|
|||||||
|
|
||||||
text-hex@1.0.0: {}
|
text-hex@1.0.0: {}
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
thenify: 3.3.1
|
thenify: 3.3.1
|
||||||
@ -5058,6 +5222,10 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
base64-arraybuffer: 1.0.2
|
||||||
|
|
||||||
uuid@11.1.0: {}
|
uuid@11.1.0: {}
|
||||||
|
|
||||||
validate-npm-package-license@3.0.4:
|
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,
|
Clock,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Stethoscope,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Search,
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { appointmentService } from "../services";
|
||||||
availabilityService,
|
|
||||||
exceptionsService,
|
|
||||||
appointmentService,
|
|
||||||
smsService,
|
|
||||||
} from "../services";
|
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
interface Medico {
|
interface Medico {
|
||||||
@ -43,43 +37,6 @@ interface Medico {
|
|||||||
valorConsulta?: number;
|
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 {
|
interface AgendamentoConsultaProps {
|
||||||
medicos: Medico[];
|
medicos: Medico[];
|
||||||
}
|
}
|
||||||
@ -99,8 +56,6 @@ export default function AgendamentoConsulta({
|
|||||||
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
|
const [selectedSpecialty, setSelectedSpecialty] = useState("all");
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
|
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 [availableSlots, setAvailableSlots] = useState<string[]>([]);
|
||||||
const [selectedTime, setSelectedTime] = useState("");
|
const [selectedTime, setSelectedTime] = useState("");
|
||||||
const [appointmentType, setAppointmentType] = useState<
|
const [appointmentType, setAppointmentType] = useState<
|
||||||
@ -132,110 +87,54 @@ export default function AgendamentoConsulta({
|
|||||||
|
|
||||||
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
|
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
|
||||||
|
|
||||||
useEffect(() => {
|
// Removemos as funções de availability e exceptions antigas
|
||||||
if (selectedMedico) {
|
// A API de slots já considera tudo automaticamente
|
||||||
loadDoctorAvailability();
|
|
||||||
loadDoctorExceptions();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line
|
|
||||||
}, [selectedMedico]);
|
|
||||||
|
|
||||||
const loadDoctorAvailability = useCallback(async () => {
|
const calculateAvailableSlots = useCallback(async () => {
|
||||||
if (!selectedMedico) return;
|
if (!selectedDate || !selectedMedico) {
|
||||||
try {
|
setAvailableSlots([]);
|
||||||
const response = await availabilityService.getAvailability(
|
return;
|
||||||
selectedMedico.id
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
response &&
|
|
||||||
response.success &&
|
|
||||||
response.data &&
|
|
||||||
response.data.length > 0
|
|
||||||
) {
|
|
||||||
const avail = response.data[0];
|
|
||||||
setAvailability({
|
|
||||||
domingo: avail.domingo || { ativo: false, horarios: [] },
|
|
||||||
segunda: avail.segunda || { ativo: false, horarios: [] },
|
|
||||||
terca: avail.terca || { ativo: false, horarios: [] },
|
|
||||||
quarta: avail.quarta || { ativo: false, horarios: [] },
|
|
||||||
quinta: avail.quinta || { ativo: false, horarios: [] },
|
|
||||||
sexta: avail.sexta || { ativo: false, horarios: [] },
|
|
||||||
sabado: avail.sabado || { ativo: false, horarios: [] },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setAvailability(null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setAvailability(null);
|
|
||||||
}
|
}
|
||||||
}, [selectedMedico]);
|
|
||||||
|
|
||||||
const loadDoctorExceptions = useCallback(async () => {
|
|
||||||
if (!selectedMedico) return;
|
|
||||||
try {
|
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,
|
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 (response && response.slots) {
|
||||||
if (!selectedDate || !availability) return;
|
// Filtra apenas os slots disponíveis
|
||||||
const dateStr = format(selectedDate, "yyyy-MM-dd");
|
const available = response.slots
|
||||||
const isBlocked = exceptions.some((exc) => exc.data === dateStr);
|
.filter((slot) => slot.available)
|
||||||
if (isBlocked) {
|
.map((slot) => slot.time);
|
||||||
|
setAvailableSlots(available);
|
||||||
|
} else {
|
||||||
|
setAvailableSlots([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AgendamentoConsulta] Erro ao buscar slots:", error);
|
||||||
setAvailableSlots([]);
|
setAvailableSlots([]);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const dayOfWeek = selectedDate.getDay();
|
}, [selectedDate, selectedMedico]);
|
||||||
const dayKey = dayOfWeekMap[dayOfWeek];
|
|
||||||
const daySchedule = availability[dayKey];
|
|
||||||
if (!daySchedule || !daySchedule.ativo) {
|
|
||||||
setAvailableSlots([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const slots = daySchedule.horarios
|
|
||||||
.filter((slot) => slot.ativo)
|
|
||||||
.map((slot) => slot.inicio);
|
|
||||||
setAvailableSlots(slots);
|
|
||||||
}, [selectedDate, availability, exceptions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDate && availability && selectedMedico) {
|
if (selectedDate && selectedMedico) {
|
||||||
calculateAvailableSlots();
|
calculateAvailableSlots();
|
||||||
} else {
|
} else {
|
||||||
setAvailableSlots([]);
|
setAvailableSlots([]);
|
||||||
}
|
}
|
||||||
}, [
|
}, [selectedDate, selectedMedico, calculateAvailableSlots]);
|
||||||
selectedDate,
|
|
||||||
availability,
|
|
||||||
exceptions,
|
|
||||||
calculateAvailableSlots,
|
|
||||||
selectedMedico,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isDateBlocked = (date: Date): boolean => {
|
|
||||||
const dateStr = format(date, "yyyy-MM-dd");
|
|
||||||
return exceptions.some((exc) => exc.data === dateStr);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Simplificado: a API de slots já considera disponibilidade e exceções
|
||||||
const isDateAvailable = (date: Date): boolean => {
|
const isDateAvailable = (date: Date): boolean => {
|
||||||
if (!availability) return false;
|
// Não permite datas passadas
|
||||||
if (isBefore(date, startOfDay(new Date()))) return false;
|
if (isBefore(date, startOfDay(new Date()))) return false;
|
||||||
if (isDateBlocked(date)) return false;
|
// Para simplificar, consideramos todos os dias futuros como possíveis
|
||||||
const dayOfWeek = date.getDay();
|
// A API fará a validação real quando buscar slots
|
||||||
const dayKey = dayOfWeekMap[dayOfWeek];
|
return true;
|
||||||
const daySchedule = availability[dayKey];
|
|
||||||
return (
|
|
||||||
daySchedule?.ativo && daySchedule.horarios.some((slot) => slot.ativo)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateCalendarDays = () => {
|
const generateCalendarDays = () => {
|
||||||
@ -271,34 +170,26 @@ export default function AgendamentoConsulta({
|
|||||||
if (!selectedMedico || !selectedDate || !selectedTime || !user) return;
|
if (!selectedMedico || !selectedDate || !selectedTime || !user) return;
|
||||||
try {
|
try {
|
||||||
setBookingError("");
|
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,
|
patient_id: user.id,
|
||||||
doctor_id: selectedMedico.id,
|
doctor_id: selectedMedico.id,
|
||||||
scheduled_at:
|
scheduled_at: scheduledAt,
|
||||||
format(selectedDate, "yyyy-MM-dd") + "T" + selectedTime + ":00.000Z",
|
|
||||||
duration_minutes: 30,
|
duration_minutes: 30,
|
||||||
appointment_type: appointmentType,
|
appointment_type: appointmentType === "online" ? "telemedicina" : "presencial",
|
||||||
chief_complaint: motivo,
|
chief_complaint: motivo,
|
||||||
patient_notes: "",
|
|
||||||
insurance_provider: "",
|
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
|
||||||
setBookingError(result.error || "Erro ao agendar consulta");
|
console.log("[AgendamentoConsulta] Consulta criada:", appointment);
|
||||||
setShowConfirmDialog(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Envia SMS de confirmação (se telefone disponível)
|
|
||||||
if (user.telefone) {
|
|
||||||
await smsService.enviarConfirmacaoConsulta(
|
|
||||||
user.telefone,
|
|
||||||
user.nome || "Paciente",
|
|
||||||
selectedMedico.nome,
|
|
||||||
format(selectedDate, "dd/MM/yyyy") + " às " + selectedTime
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setBookingSuccess(true);
|
setBookingSuccess(true);
|
||||||
setShowConfirmDialog(false);
|
setShowConfirmDialog(false);
|
||||||
|
|
||||||
|
// Reset form após 3 segundos
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSelectedMedico(null);
|
setSelectedMedico(null);
|
||||||
setSelectedDate(undefined);
|
setSelectedDate(undefined);
|
||||||
@ -307,6 +198,7 @@ export default function AgendamentoConsulta({
|
|||||||
setBookingSuccess(false);
|
setBookingSuccess(false);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("[AgendamentoConsulta] Erro ao agendar:", error);
|
||||||
setBookingError(
|
setBookingError(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
@ -496,7 +388,6 @@ export default function AgendamentoConsulta({
|
|||||||
const isTodayDate = isToday(day);
|
const isTodayDate = isToday(day);
|
||||||
const isAvailable =
|
const isAvailable =
|
||||||
isCurrentMonth && isDateAvailable(day);
|
isCurrentMonth && isDateAvailable(day);
|
||||||
const isBlocked = isCurrentMonth && isDateBlocked(day);
|
|
||||||
const isPast = isBefore(day, startOfDay(new Date()));
|
const isPast = isBefore(day, startOfDay(new Date()));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -517,15 +408,8 @@ export default function AgendamentoConsulta({
|
|||||||
isAvailable && !isSelected
|
isAvailable && !isSelected
|
||||||
? "hover:bg-blue-50 cursor-pointer"
|
? "hover:bg-blue-50 cursor-pointer"
|
||||||
: ""
|
: ""
|
||||||
} ${
|
} ${isPast ? "text-gray-400" : ""} ${
|
||||||
isBlocked
|
!isAvailable && isCurrentMonth && !isPast
|
||||||
? "bg-red-50 text-red-400 line-through"
|
|
||||||
: ""
|
|
||||||
} ${isPast && !isBlocked ? "text-gray-400" : ""} ${
|
|
||||||
!isAvailable &&
|
|
||||||
!isBlocked &&
|
|
||||||
isCurrentMonth &&
|
|
||||||
!isPast
|
|
||||||
? "text-gray-300"
|
? "text-gray-300"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { Clock, Plus, Trash2, Save, Copy } from "lucide-react";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { availabilityService, exceptionsService } from "../services/index";
|
import { availabilityService } from "../services/index";
|
||||||
import type { DoctorException } from "../services/exceptions/types";
|
import type { DoctorException, DoctorAvailability } from "../services/availability/types";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
|
|
||||||
interface TimeSlot {
|
interface TimeSlot {
|
||||||
@ -80,11 +80,12 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Agrupar disponibilidades por dia da semana
|
// Agrupar disponibilidades por dia da semana
|
||||||
availabilities.forEach((avail: any) => {
|
availabilities.forEach((avail: DoctorAvailability) => {
|
||||||
const weekdayKey = daysOfWeek.find((d) => d.dbKey === avail.weekday);
|
// avail.weekday agora é um número (0-6)
|
||||||
if (!weekdayKey) return;
|
const dayKey = avail.weekday;
|
||||||
|
|
||||||
|
if (!newSchedule[dayKey]) return;
|
||||||
|
|
||||||
const dayKey = weekdayKey.key;
|
|
||||||
if (!newSchedule[dayKey].enabled) {
|
if (!newSchedule[dayKey].enabled) {
|
||||||
newSchedule[dayKey].enabled = true;
|
newSchedule[dayKey].enabled = true;
|
||||||
}
|
}
|
||||||
@ -122,13 +123,13 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
|
|
||||||
const loadExceptions = React.useCallback(async () => {
|
const loadExceptions = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const exceptions = await exceptionsService.list({
|
const exceptions = await availabilityService.listExceptions({
|
||||||
doctor_id: medicoId,
|
doctor_id: medicoId,
|
||||||
});
|
});
|
||||||
setExceptions(exceptions);
|
setExceptions(exceptions);
|
||||||
const blocked = exceptions
|
const blocked = exceptions
|
||||||
.filter((exc: any) => exc.kind === "bloqueio" && exc.date)
|
.filter((exc: DoctorException) => exc.kind === "bloqueio" && exc.date)
|
||||||
.map((exc: any) => new Date(exc.date!));
|
.map((exc: DoctorException) => new Date(exc.date!));
|
||||||
setBlockedDates(blocked);
|
setBlockedDates(blocked);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar exceções:", error);
|
console.error("Erro ao carregar exceções:", error);
|
||||||
@ -253,7 +254,7 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Para cada dia, processar slots
|
// Para cada dia, processar slots
|
||||||
daysOfWeek.forEach(({ key, dbKey }) => {
|
daysOfWeek.forEach(({ key }) => {
|
||||||
const daySchedule = schedule[key];
|
const daySchedule = schedule[key];
|
||||||
|
|
||||||
if (!daySchedule || !daySchedule.enabled) {
|
if (!daySchedule || !daySchedule.enabled) {
|
||||||
@ -284,16 +285,9 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
weekday: dbKey as
|
weekday: key, // Agora usa número (0-6) ao invés de string
|
||||||
| "segunda"
|
start_time: inicio.slice(0, 5), // HH:MM ao invés de HH:MM:SS
|
||||||
| "terca"
|
end_time: fim.slice(0, 5), // HH:MM ao invés de HH:MM:SS
|
||||||
| "quarta"
|
|
||||||
| "quinta"
|
|
||||||
| "sexta"
|
|
||||||
| "sabado"
|
|
||||||
| "domingo",
|
|
||||||
start_time: inicio,
|
|
||||||
end_time: fim,
|
|
||||||
slot_minutes: minutes,
|
slot_minutes: minutes,
|
||||||
appointment_type: "presencial" as const,
|
appointment_type: "presencial" as const,
|
||||||
active: !!slot.ativo,
|
active: !!slot.ativo,
|
||||||
@ -375,7 +369,7 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
|
exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
|
||||||
);
|
);
|
||||||
if (exception && exception.id) {
|
if (exception && exception.id) {
|
||||||
await exceptionsService.delete(exception.id);
|
await availabilityService.deleteException(exception.id);
|
||||||
setBlockedDates(
|
setBlockedDates(
|
||||||
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
|
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
|
||||||
);
|
);
|
||||||
@ -383,11 +377,12 @@ const DisponibilidadeMedico: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add block
|
// Add block
|
||||||
await exceptionsService.create({
|
await availabilityService.createException({
|
||||||
doctor_id: medicoId,
|
doctor_id: medicoId,
|
||||||
date: dateString,
|
date: dateString,
|
||||||
kind: "bloqueio",
|
kind: "bloqueio",
|
||||||
reason: "Data bloqueada pelo médico",
|
reason: "Data bloqueada pelo médico",
|
||||||
|
created_by: user?.id || medicoId,
|
||||||
});
|
});
|
||||||
setBlockedDates([...blockedDates, selectedDate]);
|
setBlockedDates([...blockedDates, selectedDate]);
|
||||||
toast.success("Data bloqueada");
|
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 toast from "react-hot-toast";
|
||||||
import { appointmentService } from "../../services";
|
import { appointmentService } from "../../services";
|
||||||
|
|
||||||
@ -6,48 +6,56 @@ interface Props {
|
|||||||
doctorId: string;
|
doctorId: string;
|
||||||
date: string; // YYYY-MM-DD
|
date: string; // YYYY-MM-DD
|
||||||
onSelect: (time: string) => void; // HH:MM
|
onSelect: (time: string) => void; // HH:MM
|
||||||
appointment_type?: "presencial" | "telemedicina";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AvailableSlotsPicker: React.FC<Props> = ({
|
const AvailableSlotsPicker: React.FC<Props> = ({
|
||||||
doctorId,
|
doctorId,
|
||||||
date,
|
date,
|
||||||
onSelect,
|
onSelect,
|
||||||
appointment_type,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [slots, setSlots] = useState<string[]>([]);
|
const [slots, setSlots] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
async function fetchSlots() {
|
async function fetchSlots() {
|
||||||
if (!doctorId || !range) return;
|
if (!doctorId || !date) return;
|
||||||
setLoading(true);
|
|
||||||
const res = await appointmentService.getAvailableSlots({
|
console.log("🔍 [AvailableSlotsPicker] Buscando slots:", {
|
||||||
doctor_id: doctorId,
|
doctorId,
|
||||||
start_date: range.start,
|
date,
|
||||||
end_date: range.end,
|
|
||||||
appointment_type,
|
|
||||||
});
|
});
|
||||||
setLoading(false);
|
|
||||||
if (res.success && res.data) {
|
setLoading(true);
|
||||||
const times = res.data.slots
|
try {
|
||||||
.filter((s) => s.available)
|
const res = await appointmentService.getAvailableSlots({
|
||||||
.map((s) => s.datetime.slice(11, 16));
|
doctor_id: doctorId,
|
||||||
setSlots(times);
|
date: date,
|
||||||
} else {
|
});
|
||||||
toast.error(res.error || "Erro ao buscar horários");
|
|
||||||
|
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();
|
void fetchSlots();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [doctorId, date]);
|
||||||
}, [doctorId, date, appointment_type]);
|
|
||||||
|
|
||||||
if (!date || !doctorId) return null;
|
if (!date || !doctorId) return null;
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { exceptionsService } from "../../services/index";
|
import { availabilityService } from "../../services/index";
|
||||||
import type {
|
import type {
|
||||||
DoctorException,
|
DoctorException,
|
||||||
ExceptionKind,
|
ExceptionKind,
|
||||||
} from "../../services/exceptions/types";
|
} from "../../services/availability/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
doctorId: string;
|
doctorId: string;
|
||||||
@ -26,7 +26,7 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
if (!doctorId) return;
|
if (!doctorId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const exceptions = await exceptionsService.list({ doctor_id: doctorId });
|
const exceptions = await availabilityService.listExceptions({ doctor_id: doctorId });
|
||||||
setList(exceptions);
|
setList(exceptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar exceções:", error);
|
console.error("Erro ao carregar exceções:", error);
|
||||||
@ -49,13 +49,14 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await exceptionsService.create({
|
await availabilityService.createException({
|
||||||
doctor_id: doctorId,
|
doctor_id: doctorId,
|
||||||
date: form.date,
|
date: form.date,
|
||||||
start_time: form.start_time || undefined,
|
start_time: form.start_time || undefined,
|
||||||
end_time: form.end_time || undefined,
|
end_time: form.end_time || undefined,
|
||||||
kind: form.kind,
|
kind: form.kind,
|
||||||
reason: form.reason || undefined,
|
reason: form.reason || undefined,
|
||||||
|
created_by: doctorId, // Usando doctorId como criador
|
||||||
});
|
});
|
||||||
toast.success("Exceção criada");
|
toast.success("Exceção criada");
|
||||||
setForm({
|
setForm({
|
||||||
@ -79,7 +80,7 @@ const ExceptionsManager: React.FC<Props> = ({ doctorId }) => {
|
|||||||
const ok = confirm("Remover exceção?");
|
const ok = confirm("Remover exceção?");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await exceptionsService.delete(item.id);
|
await availabilityService.deleteException(item.id);
|
||||||
toast.success("Removida");
|
toast.success("Removida");
|
||||||
void load();
|
void load();
|
||||||
} catch (error) {
|
} 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 {
|
export interface PacienteFormData {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
user_id?: string;
|
||||||
nome: string;
|
nome: string;
|
||||||
social_name: string;
|
social_name: string;
|
||||||
cpf: string;
|
cpf: string;
|
||||||
@ -93,12 +94,12 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
{/* Avatar com upload */}
|
{/* Avatar com upload */}
|
||||||
<div className="flex items-start gap-4 mb-6 pb-6 border-b border-gray-200">
|
<div className="flex items-start gap-4 mb-6 pb-6 border-b border-gray-200">
|
||||||
<AvatarUpload
|
<AvatarUpload
|
||||||
userId={data.id}
|
userId={data.user_id || data.id}
|
||||||
currentAvatarUrl={data.avatar_url}
|
currentAvatarUrl={data.avatar_url}
|
||||||
name={data.nome || "Paciente"}
|
name={data.nome || "Paciente"}
|
||||||
color="blue"
|
color="blue"
|
||||||
size="xl"
|
size="xl"
|
||||||
editable={canEditAvatar && !!data.id}
|
editable={canEditAvatar && !!(data.user_id || data.id)}
|
||||||
onAvatarUpdate={(avatarUrl) => {
|
onAvatarUpdate={(avatarUrl) => {
|
||||||
onChange({ avatar_url: avatarUrl || undefined });
|
onChange({ avatar_url: avatarUrl || undefined });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -81,6 +81,29 @@ export function SecretaryAppointmentList() {
|
|||||||
loadDoctorsAndPatients();
|
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 () => {
|
const loadDoctorsAndPatients = async () => {
|
||||||
try {
|
try {
|
||||||
const [patientsData, doctorsData] = await Promise.all([
|
const [patientsData, doctorsData] = await Promise.all([
|
||||||
@ -300,17 +323,19 @@ export function SecretaryAppointmentList() {
|
|||||||
Carregando consultas...
|
Carregando consultas...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : appointments.length === 0 ? (
|
) : filteredAppointments.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={6}
|
||||||
className="px-6 py-12 text-center text-gray-500"
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
appointments.map((appointment) => (
|
filteredAppointments.map((appointment) => (
|
||||||
<tr
|
<tr
|
||||||
key={appointment.id}
|
key={appointment.id}
|
||||||
className="hover:bg-gray-50 transition-colors"
|
className="hover:bg-gray-50 transition-colors"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
type Doctor,
|
type Doctor,
|
||||||
type CrmUF,
|
type CrmUF,
|
||||||
} from "../../services";
|
} from "../../services";
|
||||||
|
import type { CreateDoctorInput } from "../../services/users/types";
|
||||||
|
|
||||||
interface DoctorFormData {
|
interface DoctorFormData {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -50,6 +51,16 @@ const UF_OPTIONS = [
|
|||||||
"TO",
|
"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() {
|
export function SecretaryDoctorList() {
|
||||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -87,6 +98,24 @@ export function SecretaryDoctorList() {
|
|||||||
loadDoctors();
|
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 = () => {
|
const handleSearch = () => {
|
||||||
loadDoctors();
|
loadDoctors();
|
||||||
};
|
};
|
||||||
@ -134,11 +163,17 @@ export function SecretaryDoctorList() {
|
|||||||
try {
|
try {
|
||||||
if (modalMode === "edit" && formData.id) {
|
if (modalMode === "edit" && formData.id) {
|
||||||
// Para edição, usa o endpoint antigo (PATCH /doctors/: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 = {
|
const doctorData = {
|
||||||
full_name: formData.full_name,
|
full_name: formData.full_name,
|
||||||
cpf: formData.cpf,
|
cpf: cleanCpf,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
phone_mobile: formData.phone_mobile,
|
phone_mobile: cleanPhone,
|
||||||
crm: formData.crm,
|
crm: formData.crm,
|
||||||
crm_uf: formData.crm_uf as CrmUF,
|
crm_uf: formData.crm_uf as CrmUF,
|
||||||
specialty: formData.specialty,
|
specialty: formData.specialty,
|
||||||
@ -148,15 +183,22 @@ export function SecretaryDoctorList() {
|
|||||||
toast.success("Médico atualizado com sucesso!");
|
toast.success("Médico atualizado com sucesso!");
|
||||||
} else {
|
} else {
|
||||||
// Para criação, usa o novo endpoint create-doctor com validações completas
|
// 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,
|
email: formData.email,
|
||||||
full_name: formData.full_name,
|
full_name: formData.full_name,
|
||||||
cpf: formData.cpf,
|
cpf: cleanCpf,
|
||||||
crm: formData.crm,
|
crm: formData.crm,
|
||||||
crm_uf: formData.crm_uf as CrmUF,
|
crm_uf: formData.crm_uf as CrmUF,
|
||||||
specialty: formData.specialty,
|
specialty: formData.specialty || undefined,
|
||||||
phone_mobile: formData.phone_mobile || undefined,
|
phone_mobile: cleanPhone,
|
||||||
};
|
};
|
||||||
|
|
||||||
await userService.createDoctor(createData);
|
await userService.createDoctor(createData);
|
||||||
toast.success("Médico cadastrado com sucesso!");
|
toast.success("Médico cadastrado com sucesso!");
|
||||||
}
|
}
|
||||||
@ -288,17 +330,17 @@ export function SecretaryDoctorList() {
|
|||||||
Carregando médicos...
|
Carregando médicos...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : doctors.length === 0 ? (
|
) : filteredDoctors.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={5}
|
colSpan={5}
|
||||||
className="px-6 py-12 text-center text-gray-500"
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
doctors.map((doctor, index) => (
|
filteredDoctors.map((doctor, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={doctor.id}
|
key={doctor.id}
|
||||||
className="hover:bg-gray-50 transition-colors"
|
className="hover:bg-gray-50 transition-colors"
|
||||||
@ -314,7 +356,7 @@ export function SecretaryDoctorList() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
Dr. {doctor.full_name}
|
{formatDoctorName(doctor.full_name)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">{doctor.email}</p>
|
<p className="text-sm text-gray-500">{doctor.email}</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
@ -479,7 +521,7 @@ export function SecretaryDoctorList() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Especialidade *
|
Especialidade
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.specialty}
|
value={formData.specialty}
|
||||||
@ -487,7 +529,6 @@ export function SecretaryDoctorList() {
|
|||||||
setFormData({ ...formData, specialty: e.target.value })
|
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"
|
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="">Selecione</option>
|
||||||
<option value="Cardiologia">Cardiologia</option>
|
<option value="Cardiologia">Cardiologia</option>
|
||||||
@ -517,7 +558,7 @@ export function SecretaryDoctorList() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Telefone *
|
Telefone
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
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"
|
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="(11) 98888-8888"
|
||||||
placeholder="(00) 00000-0000"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,12 +17,47 @@ import {
|
|||||||
type DoctorAvailability,
|
type DoctorAvailability,
|
||||||
} from "../../services";
|
} 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 {
|
interface DayCell {
|
||||||
date: Date;
|
date: Date;
|
||||||
isCurrentMonth: boolean;
|
isCurrentMonth: boolean;
|
||||||
appointments: Appointment[];
|
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() {
|
export function SecretaryDoctorSchedule() {
|
||||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
const [selectedDoctorId, setSelectedDoctorId] = useState<string>("");
|
const [selectedDoctorId, setSelectedDoctorId] = useState<string>("");
|
||||||
@ -36,6 +71,8 @@ export function SecretaryDoctorSchedule() {
|
|||||||
// Modal states
|
// Modal states
|
||||||
const [showAvailabilityDialog, setShowAvailabilityDialog] = useState(false);
|
const [showAvailabilityDialog, setShowAvailabilityDialog] = useState(false);
|
||||||
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
|
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
|
||||||
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||||
|
const [editingAvailability, setEditingAvailability] = useState<DoctorAvailability | null>(null);
|
||||||
|
|
||||||
// Availability form
|
// Availability form
|
||||||
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
|
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
|
||||||
@ -43,6 +80,12 @@ export function SecretaryDoctorSchedule() {
|
|||||||
const [endTime, setEndTime] = useState("18:00");
|
const [endTime, setEndTime] = useState("18:00");
|
||||||
const [duration, setDuration] = useState(30);
|
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
|
// Exception form
|
||||||
const [exceptionType, setExceptionType] = useState("férias");
|
const [exceptionType, setExceptionType] = useState("férias");
|
||||||
const [exceptionStartDate, setExceptionStartDate] = useState("");
|
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 = [
|
const weekdays = [
|
||||||
{ value: "monday", label: "Segunda" },
|
{ value: "monday", label: "Segunda" },
|
||||||
{ value: "tuesday", label: "Terça" },
|
{ value: "tuesday", label: "Terça" },
|
||||||
@ -207,7 +299,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
>
|
>
|
||||||
{doctors.map((doctor) => (
|
{doctors.map((doctor) => (
|
||||||
<option key={doctor.id} value={doctor.id}>
|
<option key={doctor.id} value={doctor.id}>
|
||||||
Dr. {doctor.full_name} - {doctor.specialty}
|
{formatDoctorName(doctor.full_name)} - {doctor.specialty}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -313,7 +405,7 @@ export function SecretaryDoctorSchedule() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-gray-900">
|
||||||
{avail.day_of_week}
|
{weekdayToText(avail.weekday)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{avail.start_time} - {avail.end_time}
|
{avail.start_time} - {avail.end_time}
|
||||||
@ -321,15 +413,17 @@ export function SecretaryDoctorSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
onClick={() => handleEditAvailability(avail)}
|
||||||
title="Editar"
|
title="Editar"
|
||||||
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
className="p-2 text-orange-600 hover:bg-orange-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
onClick={() => handleDeleteAvailability(avail)}
|
||||||
title="Deletar"
|
title="Deletar"
|
||||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
@ -521,6 +615,98 @@ export function SecretaryDoctorSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Search, Plus, Eye, Calendar, Edit, Trash2, X } from "lucide-react";
|
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 PacienteForm, { type PacienteFormData } from "../pacientes/PacienteForm";
|
||||||
import { Avatar } from "../ui/Avatar";
|
import { Avatar } from "../ui/Avatar";
|
||||||
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
|
|
||||||
const BLOOD_TYPES = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
|
const BLOOD_TYPES = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
|
||||||
|
|
||||||
@ -40,6 +41,7 @@ const buscarEnderecoViaCEP = async (cep: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SecretaryPatientList() {
|
export function SecretaryPatientList() {
|
||||||
|
const { user } = useAuth();
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@ -50,6 +52,8 @@ export function SecretaryPatientList() {
|
|||||||
// Modal states
|
// Modal states
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [patientToDelete, setPatientToDelete] = useState<Patient | null>(null);
|
||||||
const [formData, setFormData] = useState<PacienteFormData>({
|
const [formData, setFormData] = useState<PacienteFormData>({
|
||||||
nome: "",
|
nome: "",
|
||||||
social_name: "",
|
social_name: "",
|
||||||
@ -85,6 +89,15 @@ export function SecretaryPatientList() {
|
|||||||
try {
|
try {
|
||||||
const data = await patientService.list();
|
const data = await patientService.list();
|
||||||
console.log("✅ Pacientes carregados:", data);
|
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 : []);
|
setPatients(Array.isArray(data) ? data : []);
|
||||||
if (Array.isArray(data) && data.length === 0) {
|
if (Array.isArray(data) && data.length === 0) {
|
||||||
console.warn("⚠️ Nenhum paciente encontrado na API");
|
console.warn("⚠️ Nenhum paciente encontrado na API");
|
||||||
@ -102,6 +115,28 @@ export function SecretaryPatientList() {
|
|||||||
loadPatients();
|
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 = () => {
|
const handleSearch = () => {
|
||||||
loadPatients();
|
loadPatients();
|
||||||
};
|
};
|
||||||
@ -150,6 +185,7 @@ export function SecretaryPatientList() {
|
|||||||
setModalMode("edit");
|
setModalMode("edit");
|
||||||
setFormData({
|
setFormData({
|
||||||
id: patient.id,
|
id: patient.id,
|
||||||
|
user_id: patient.user_id,
|
||||||
nome: patient.full_name || "",
|
nome: patient.full_name || "",
|
||||||
social_name: patient.social_name || "",
|
social_name: patient.social_name || "",
|
||||||
cpf: patient.cpf || "",
|
cpf: patient.cpf || "",
|
||||||
@ -165,6 +201,7 @@ export function SecretaryPatientList() {
|
|||||||
convenio: "Particular",
|
convenio: "Particular",
|
||||||
numeroCarteirinha: "",
|
numeroCarteirinha: "",
|
||||||
observacoes: "",
|
observacoes: "",
|
||||||
|
avatar_url: patient.avatar_url || undefined,
|
||||||
endereco: {
|
endereco: {
|
||||||
cep: patient.cep || "",
|
cep: patient.cep || "",
|
||||||
rua: patient.street || "",
|
rua: patient.street || "",
|
||||||
@ -213,18 +250,23 @@ export function SecretaryPatientList() {
|
|||||||
try {
|
try {
|
||||||
if (modalMode === "edit" && formData.id) {
|
if (modalMode === "edit" && formData.id) {
|
||||||
// Para edição, usa o endpoint antigo (PATCH /patients/: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 = {
|
const patientData = {
|
||||||
full_name: formData.nome,
|
full_name: formData.nome,
|
||||||
social_name: formData.social_name || null,
|
social_name: formData.social_name || null,
|
||||||
cpf: formData.cpf,
|
cpf: cleanCpf,
|
||||||
sex: formData.sexo || null,
|
sex: formData.sexo || null,
|
||||||
birth_date: formData.dataNascimento || null,
|
birth_date: formData.dataNascimento || null,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
phone_mobile: formData.numeroTelefone,
|
phone_mobile: cleanPhone,
|
||||||
blood_type: formData.tipo_sanguineo || null,
|
blood_type: formData.tipo_sanguineo || null,
|
||||||
height_m: formData.altura ? parseFloat(formData.altura) : null,
|
height_m: formData.altura ? parseFloat(formData.altura) : null,
|
||||||
weight_kg: formData.peso ? parseFloat(formData.peso) : null,
|
weight_kg: formData.peso ? parseFloat(formData.peso) : null,
|
||||||
cep: formData.endereco.cep || null,
|
cep: cleanCep,
|
||||||
street: formData.endereco.rua || null,
|
street: formData.endereco.rua || null,
|
||||||
number: formData.endereco.numero || null,
|
number: formData.endereco.numero || null,
|
||||||
complement: formData.endereco.complemento || null,
|
complement: formData.endereco.complemento || null,
|
||||||
@ -235,26 +277,34 @@ export function SecretaryPatientList() {
|
|||||||
await patientService.update(formData.id, patientData);
|
await patientService.update(formData.id, patientData);
|
||||||
toast.success("Paciente atualizado com sucesso!");
|
toast.success("Paciente atualizado com sucesso!");
|
||||||
} else {
|
} 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 = {
|
const createData = {
|
||||||
email: formData.email,
|
|
||||||
full_name: formData.nome,
|
full_name: formData.nome,
|
||||||
cpf: formData.cpf,
|
cpf: cleanCpf,
|
||||||
phone_mobile: formData.numeroTelefone,
|
email: formData.email,
|
||||||
birth_date: formData.dataNascimento || undefined,
|
phone_mobile: cleanPhone,
|
||||||
address: formData.endereco.rua
|
birth_date: formData.dataNascimento || null,
|
||||||
? `${formData.endereco.rua}${
|
social_name: formData.social_name || null,
|
||||||
formData.endereco.numero ? ", " + formData.endereco.numero : ""
|
sex: formData.sexo || null,
|
||||||
}${
|
blood_type: formData.tipo_sanguineo || null,
|
||||||
formData.endereco.bairro ? " - " + formData.endereco.bairro : ""
|
weight_kg: formData.peso ? parseFloat(formData.peso) : null,
|
||||||
}${
|
height_m: formData.altura ? parseFloat(formData.altura) : null,
|
||||||
formData.endereco.cidade ? " - " + formData.endereco.cidade : ""
|
street: formData.endereco.rua || null,
|
||||||
}${
|
number: formData.endereco.numero || null,
|
||||||
formData.endereco.estado ? "/" + formData.endereco.estado : ""
|
complement: formData.endereco.complemento || null,
|
||||||
}`
|
neighborhood: formData.endereco.bairro || null,
|
||||||
: undefined,
|
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!");
|
toast.success("Paciente cadastrado com sucesso!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,6 +322,34 @@ export function SecretaryPatientList() {
|
|||||||
setShowModal(false);
|
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 = (
|
const getPatientColor = (
|
||||||
index: number
|
index: number
|
||||||
): "blue" | "green" | "purple" | "orange" | "pink" | "teal" => {
|
): "blue" | "green" | "purple" | "orange" | "pink" | "teal" => {
|
||||||
@ -394,17 +472,17 @@ export function SecretaryPatientList() {
|
|||||||
Carregando pacientes...
|
Carregando pacientes...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : patients.length === 0 ? (
|
) : filteredPatients.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={4}
|
colSpan={4}
|
||||||
className="px-6 py-12 text-center text-gray-500"
|
className="px-6 py-12 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
Nenhum paciente encontrado
|
{searchTerm ? "Nenhum paciente encontrado com esse termo" : "Nenhum paciente encontrado"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
patients.map((patient, index) => (
|
filteredPatients.map((patient, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={patient.id}
|
key={patient.id}
|
||||||
className="hover:bg-gray-50 transition-colors"
|
className="hover:bg-gray-50 transition-colors"
|
||||||
@ -456,6 +534,7 @@ export function SecretaryPatientList() {
|
|||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
onClick={() => handleDeleteClick(patient)}
|
||||||
title="Deletar"
|
title="Deletar"
|
||||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
@ -508,6 +587,57 @@ export function SecretaryPatientList() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import toast from "react-hot-toast";
|
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 {
|
import {
|
||||||
reportService,
|
reportService,
|
||||||
type Report,
|
type Report,
|
||||||
@ -12,15 +14,20 @@ export function SecretaryReportList() {
|
|||||||
const [reports, setReports] = useState<Report[]>([]);
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [typeFilter, setTypeFilter] = useState("Todos");
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
const [periodFilter, setPeriodFilter] = useState("Todos");
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
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 [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
patient_id: "",
|
patient_id: "",
|
||||||
exam: "",
|
exam: "",
|
||||||
diagnosis: "",
|
diagnosis: "",
|
||||||
conclusion: "",
|
conclusion: "",
|
||||||
|
status: "draft" as "draft" | "completed" | "pending" | "cancelled",
|
||||||
|
cid_code: "",
|
||||||
|
requested_by: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -43,10 +50,32 @@ export function SecretaryReportList() {
|
|||||||
exam: "",
|
exam: "",
|
||||||
diagnosis: "",
|
diagnosis: "",
|
||||||
conclusion: "",
|
conclusion: "",
|
||||||
|
status: "draft",
|
||||||
|
cid_code: "",
|
||||||
|
requested_by: "",
|
||||||
});
|
});
|
||||||
setShowCreateModal(true);
|
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) => {
|
const handleCreateReport = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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 () => {
|
const loadReports = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -96,8 +261,7 @@ export function SecretaryReportList() {
|
|||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setTypeFilter("Todos");
|
setStatusFilter("");
|
||||||
setPeriodFilter("Todos");
|
|
||||||
loadReports();
|
loadReports();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -164,31 +328,17 @@ export function SecretaryReportList() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<select
|
||||||
value={typeFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
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"
|
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 value="">Todos</option>
|
||||||
<option>Financeiro</option>
|
<option value="draft">Rascunho</option>
|
||||||
<option>Atendimentos</option>
|
<option value="completed">Concluído</option>
|
||||||
<option>Pacientes</option>
|
<option value="pending">Pendente</option>
|
||||||
<option>Médicos</option>
|
<option value="cancelled">Cancelado</option>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-600">Período:</span>
|
|
||||||
<select
|
|
||||||
value={periodFilter}
|
|
||||||
onChange={(e) => setPeriodFilter(e.target.value)}
|
|
||||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option>Todos</option>
|
|
||||||
<option>Hoje</option>
|
|
||||||
<option>Esta Semana</option>
|
|
||||||
<option>Este Mês</option>
|
|
||||||
<option>Este Ano</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -284,18 +434,37 @@ export function SecretaryReportList() {
|
|||||||
{report.requested_by || "—"}
|
{report.requested_by || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
title="Baixar"
|
<button
|
||||||
disabled={report.status !== "completed"}
|
onClick={() => handleViewReport(report)}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors ${
|
title="Visualizar"
|
||||||
report.status === "completed"
|
className="flex items-center gap-1 px-3 py-1.5 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
? "text-green-600 hover:bg-green-50"
|
>
|
||||||
: "text-gray-400 cursor-not-allowed"
|
<Eye className="h-4 w-4" />
|
||||||
}`}
|
<span className="text-sm font-medium">Ver</span>
|
||||||
>
|
</button>
|
||||||
<Download className="h-4 w-4" />
|
<button
|
||||||
<span className="text-sm font-medium">Baixar</span>
|
onClick={() => handleOpenEditModal(report)}
|
||||||
</button>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@ -400,6 +569,287 @@ export function SecretaryReportList() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,13 @@ import { useState, useEffect } from "react";
|
|||||||
import { User } from "lucide-react";
|
import { User } from "lucide-react";
|
||||||
|
|
||||||
interface AvatarProps {
|
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?:
|
src?:
|
||||||
| string
|
| string
|
||||||
| { avatar_url?: string | null }
|
| { avatar_url?: string | null }
|
||||||
| { profile?: { avatar_url?: string | null } }
|
| { profile?: { avatar_url?: string | null } }
|
||||||
| { id?: string };
|
| { id?: string }
|
||||||
|
| { user_id?: string };
|
||||||
/** Nome completo para gerar iniciais */
|
/** Nome completo para gerar iniciais */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** Tamanho do avatar */
|
/** Tamanho do avatar */
|
||||||
@ -72,18 +73,34 @@ export function Avatar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof src === "string") {
|
if (typeof src === "string") {
|
||||||
|
console.log("[Avatar] URL direta:", src);
|
||||||
setImageUrl(src);
|
setImageUrl(src);
|
||||||
} else if ("avatar_url" in src && src.avatar_url) {
|
} else if ("avatar_url" in src && src.avatar_url) {
|
||||||
|
console.log("[Avatar] avatar_url:", src.avatar_url);
|
||||||
setImageUrl(src.avatar_url);
|
setImageUrl(src.avatar_url);
|
||||||
} else if ("profile" in src && src.profile?.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);
|
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) {
|
} else if ("id" in src && src.id) {
|
||||||
// Gera URL pública do Supabase Storage
|
// Gera URL pública do Supabase Storage
|
||||||
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
const SUPABASE_URL = "https://yuanqfswhberkoevtmfr.supabase.co";
|
||||||
setImageUrl(
|
const url = `${SUPABASE_URL}/storage/v1/object/public/avatars/${src.id}/avatar.jpg`;
|
||||||
`${SUPABASE_URL}/storage/v1/object/public/avatars/${src.id}/avatar`
|
console.log("[Avatar] Tentando carregar avatar por id:", {
|
||||||
);
|
id: src.id,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
setImageUrl(url);
|
||||||
} else {
|
} else {
|
||||||
|
console.log("[Avatar] Nenhuma URL encontrada, src:", src);
|
||||||
setImageUrl(null);
|
setImageUrl(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,6 +122,20 @@ export function Avatar({
|
|||||||
const initials = getInitials(name);
|
const initials = getInitials(name);
|
||||||
const shouldShowImage = imageUrl && !imageError;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
@ -126,7 +157,8 @@ export function Avatar({
|
|||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={name || "Avatar"}
|
alt={name || "Avatar"}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={() => setImageError(true)}
|
onError={handleImageError}
|
||||||
|
onLoad={handleImageLoad}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-white font-semibold select-none">{initials}</span>
|
<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 handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
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)
|
// Validação de tamanho (max 2MB)
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
@ -73,6 +91,11 @@ export function AvatarUpload({
|
|||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("[AvatarUpload] Iniciando upload...", {
|
||||||
|
userId,
|
||||||
|
fileName: file.name,
|
||||||
|
});
|
||||||
|
|
||||||
// Upload do avatar
|
// Upload do avatar
|
||||||
await avatarService.upload({
|
await avatarService.upload({
|
||||||
userId,
|
userId,
|
||||||
@ -91,6 +114,10 @@ export function AvatarUpload({
|
|||||||
// Adiciona timestamp para forçar reload da imagem
|
// Adiciona timestamp para forçar reload da imagem
|
||||||
const publicUrl = `${baseUrl}?t=${Date.now()}`;
|
const publicUrl = `${baseUrl}?t=${Date.now()}`;
|
||||||
|
|
||||||
|
console.log("[AvatarUpload] Upload concluído, atualizando perfil...", {
|
||||||
|
baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
// Atualiza no perfil (salva sem o timestamp)
|
// Atualiza no perfil (salva sem o timestamp)
|
||||||
await profileService.updateAvatar(userId, { avatar_url: baseUrl });
|
await profileService.updateAvatar(userId, { avatar_url: baseUrl });
|
||||||
|
|
||||||
@ -100,8 +127,9 @@ export function AvatarUpload({
|
|||||||
// Callback com timestamp para forçar reload imediato no componente
|
// Callback com timestamp para forçar reload imediato no componente
|
||||||
onAvatarUpdate?.(publicUrl);
|
onAvatarUpdate?.(publicUrl);
|
||||||
toast.success("Avatar atualizado com sucesso!");
|
toast.success("Avatar atualizado com sucesso!");
|
||||||
|
console.log("[AvatarUpload] ✅ Processo concluído com sucesso");
|
||||||
} catch (error) {
|
} 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");
|
toast.error("Erro ao fazer upload do avatar");
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
|||||||
@ -58,6 +58,16 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const { user, roles = [], logout } = useAuth();
|
const { user, roles = [], logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
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
|
// State
|
||||||
const [activeTab, setActiveTab] = useState("dashboard");
|
const [activeTab, setActiveTab] = useState("dashboard");
|
||||||
const [consultas, setConsultas] = useState<Consulta[]>([]);
|
const [consultas, setConsultas] = useState<Consulta[]>([]);
|
||||||
@ -122,7 +132,7 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const medicosData = await doctorService.list();
|
const medicosData = await doctorService.list();
|
||||||
const medicosFormatted: Medico[] = medicosData.map((d) => ({
|
const medicosFormatted: Medico[] = medicosData.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
nome: d.full_name,
|
nome: formatDoctorName(d.full_name),
|
||||||
especialidade: d.specialty || "",
|
especialidade: d.specialty || "",
|
||||||
crm: d.crm,
|
crm: d.crm,
|
||||||
email: d.email,
|
email: d.email,
|
||||||
|
|||||||
@ -73,9 +73,12 @@ export default function AuthCallback() {
|
|||||||
// Magic link ou qualquer callback com sessão válida:
|
// Magic link ou qualquer callback com sessão válida:
|
||||||
// Salvar tokens diretamente no localStorage
|
// Salvar tokens diretamente no localStorage
|
||||||
console.log("[AuthCallback] Salvando tokens e user no localStorage");
|
console.log("[AuthCallback] Salvando tokens e user no localStorage");
|
||||||
|
|
||||||
localStorage.setItem("mediconnect_access_token", session.access_token);
|
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(
|
localStorage.setItem(
|
||||||
"mediconnect_user",
|
"mediconnect_user",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@ -94,16 +97,19 @@ export default function AuthCallback() {
|
|||||||
|
|
||||||
// Verificar se há redirecionamento salvo do magic link
|
// Verificar se há redirecionamento salvo do magic link
|
||||||
const savedRedirect = localStorage.getItem("magic_link_redirect");
|
const savedRedirect = localStorage.getItem("magic_link_redirect");
|
||||||
|
|
||||||
console.log("[AuthCallback] Verificando redirecionamento:");
|
console.log("[AuthCallback] Verificando redirecionamento:");
|
||||||
console.log(" - magic_link_redirect:", savedRedirect);
|
console.log(" - magic_link_redirect:", savedRedirect);
|
||||||
console.log(" - user role:", session.user.user_metadata?.role);
|
console.log(" - user role:", session.user.user_metadata?.role);
|
||||||
console.log(" - localStorage keys:", Object.keys(localStorage));
|
console.log(" - localStorage keys:", Object.keys(localStorage));
|
||||||
|
|
||||||
if (savedRedirect) {
|
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
|
localStorage.removeItem("magic_link_redirect"); // Limpar após uso
|
||||||
|
|
||||||
// Usar window.location.href para forçar reload completo e atualizar AuthContext
|
// Usar window.location.href para forçar reload completo e atualizar AuthContext
|
||||||
window.location.href = savedRedirect;
|
window.location.href = savedRedirect;
|
||||||
return;
|
return;
|
||||||
@ -111,10 +117,13 @@ export default function AuthCallback() {
|
|||||||
|
|
||||||
// Fallback: redirecionar baseado no role
|
// Fallback: redirecionar baseado no role
|
||||||
const userRole = session.user.user_metadata?.role || "paciente";
|
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
|
let redirectUrl = "/acompanhamento"; // default paciente
|
||||||
|
|
||||||
switch (userRole) {
|
switch (userRole) {
|
||||||
case "medico":
|
case "medico":
|
||||||
console.log("[AuthCallback] Navegando para /painel-medico");
|
console.log("[AuthCallback] Navegando para /painel-medico");
|
||||||
@ -130,7 +139,7 @@ export default function AuthCallback() {
|
|||||||
redirectUrl = "/acompanhamento";
|
redirectUrl = "/acompanhamento";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usar window.location.href para forçar reload completo
|
// Usar window.location.href para forçar reload completo
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@ -28,6 +28,17 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
useState<FullUserInfo | null>(null);
|
useState<FullUserInfo | null>(null);
|
||||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||||
const [newRole, setNewRole] = useState<string>("");
|
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(() => {
|
useEffect(() => {
|
||||||
carregarUsuarios();
|
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 usuariosFiltrados = usuarios.filter((user) => {
|
||||||
const searchLower = searchTerm.toLowerCase();
|
const searchLower = searchTerm.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@ -150,16 +239,25 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={carregarUsuarios}
|
<button
|
||||||
disabled={loading}
|
onClick={() => setShowCreateModal(true)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<Plus className="w-4 h-4" />
|
||||||
className={`w-4 h-4 ${loading ? "animate-spin" : ""}`}
|
Criar Usuário
|
||||||
/>
|
</button>
|
||||||
Atualizar
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
@ -586,6 +684,179 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,8 +24,13 @@ const Home: React.FC = () => {
|
|||||||
// Verificar se há parâmetros de magic link e redirecionar para AuthCallback
|
// Verificar se há parâmetros de magic link e redirecionar para AuthCallback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
if (hash && (hash.includes('access_token') || hash.includes('type=magiclink'))) {
|
if (
|
||||||
console.log("[Home] Detectado magic link, redirecionando para /auth/callback");
|
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 });
|
navigate(`/auth/callback${hash}`, { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -204,7 +204,7 @@ const LoginMedico: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
// Salvar contexto para redirecionamento correto após magic link
|
// Salvar contexto para redirecionamento correto após magic link
|
||||||
localStorage.setItem("magic_link_redirect", "/painel-medico");
|
localStorage.setItem("magic_link_redirect", "/painel-medico");
|
||||||
|
|
||||||
await authService.sendMagicLink(formData.email);
|
await authService.sendMagicLink(formData.email);
|
||||||
toast.success(
|
toast.success(
|
||||||
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
||||||
|
|||||||
@ -313,8 +313,11 @@ const LoginPaciente: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Salvar contexto para redirecionamento correto após magic link
|
// 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);
|
await authService.sendMagicLink(formData.email);
|
||||||
toast.success(
|
toast.success(
|
||||||
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
||||||
|
|||||||
@ -213,8 +213,11 @@ const LoginSecretaria: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Salvar contexto para redirecionamento correto após magic link
|
// 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);
|
await authService.sendMagicLink(formData.email);
|
||||||
toast.success(
|
toast.success(
|
||||||
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
"Link de acesso enviado para seu email! Verifique sua caixa de entrada.",
|
||||||
|
|||||||
@ -37,7 +37,7 @@ type FullUserInfo = UserInfo;
|
|||||||
type TabType = "pacientes" | "usuarios" | "medicos";
|
type TabType = "pacientes" | "usuarios" | "medicos";
|
||||||
|
|
||||||
const PainelAdmin: React.FC = () => {
|
const PainelAdmin: React.FC = () => {
|
||||||
const { roles: authUserRoles } = useAuth();
|
const { roles: authUserRoles, user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("pacientes");
|
const [activeTab, setActiveTab] = useState<TabType>("pacientes");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -87,8 +87,11 @@ const PainelAdmin: React.FC = () => {
|
|||||||
phone: "",
|
phone: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
});
|
});
|
||||||
const [userPassword, setUserPassword] = useState(""); // Senha opcional
|
const [userPassword, setUserPassword] = useState("");
|
||||||
const [usePassword, setUsePassword] = useState(false); // Toggle para criar com senha
|
const [usePassword, setUsePassword] = useState(false);
|
||||||
|
const [userCpf, setUserCpf] = useState("");
|
||||||
|
const [userPhoneMobile, setUserPhoneMobile] = useState("");
|
||||||
|
const [createPatientRecord, setCreatePatientRecord] = useState(false);
|
||||||
|
|
||||||
// Estados para dialog de confirmação
|
// Estados para dialog de confirmação
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
@ -274,7 +277,10 @@ const PainelAdmin: React.FC = () => {
|
|||||||
password: userPassword,
|
password: userPassword,
|
||||||
full_name: formUser.full_name,
|
full_name: formUser.full_name,
|
||||||
phone: formUser.phone,
|
phone: formUser.phone,
|
||||||
|
phone_mobile: userPhoneMobile,
|
||||||
|
cpf: userCpf,
|
||||||
role: formUser.role,
|
role: formUser.role,
|
||||||
|
create_patient_record: createPatientRecord,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
||||||
@ -294,10 +300,24 @@ const PainelAdmin: React.FC = () => {
|
|||||||
resetFormUser();
|
resetFormUser();
|
||||||
setUserPassword("");
|
setUserPassword("");
|
||||||
setUsePassword(false);
|
setUsePassword(false);
|
||||||
|
setUserCpf("");
|
||||||
|
setUserPhoneMobile("");
|
||||||
|
setCreatePatientRecord(false);
|
||||||
loadUsuarios();
|
loadUsuarios();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Erro ao criar usuário:", error);
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -480,12 +500,20 @@ const PainelAdmin: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
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 = {
|
const patientData = {
|
||||||
full_name: formPaciente.full_name,
|
full_name: formPaciente.full_name,
|
||||||
cpf: formPaciente.cpf.replace(/\D/g, ""), // Remover máscara do CPF
|
cpf: cpfLimpo,
|
||||||
email: formPaciente.email,
|
email: formPaciente.email,
|
||||||
phone_mobile: formPaciente.phone_mobile,
|
phone_mobile: formPaciente.phone_mobile,
|
||||||
birth_date: formPaciente.birth_date,
|
birth_date: formPaciente.birth_date || undefined,
|
||||||
social_name: formPaciente.social_name,
|
social_name: formPaciente.social_name,
|
||||||
sex: formPaciente.sex,
|
sex: formPaciente.sex,
|
||||||
blood_type: formPaciente.blood_type,
|
blood_type: formPaciente.blood_type,
|
||||||
@ -512,56 +540,91 @@ const PainelAdmin: React.FC = () => {
|
|||||||
resetFormPaciente();
|
resetFormPaciente();
|
||||||
loadPacientes();
|
loadPacientes();
|
||||||
} else {
|
} else {
|
||||||
// Usar create-user com create_patient_record=true (nova API 21/10)
|
// API create-patient já cria auth user + registro na tabela patients
|
||||||
// isPublicRegistration = false porque é admin criando
|
console.log("[PainelAdmin] Criando paciente com API /create-patient:", {
|
||||||
await userService.createUser(
|
email: patientData.email,
|
||||||
{
|
full_name: patientData.full_name,
|
||||||
email: patientData.email,
|
cpf: cpfLimpo,
|
||||||
full_name: patientData.full_name,
|
phone_mobile: patientData.phone_mobile,
|
||||||
phone: patientData.phone_mobile,
|
});
|
||||||
role: "paciente",
|
|
||||||
create_patient_record: true,
|
await userService.createPatient({
|
||||||
cpf: patientData.cpf,
|
email: patientData.email,
|
||||||
phone_mobile: patientData.phone_mobile,
|
full_name: patientData.full_name,
|
||||||
redirect_url:
|
cpf: cpfLimpo,
|
||||||
"https://mediconnectbrasil.netlify.app/paciente/agendamento",
|
phone_mobile: patientData.phone_mobile,
|
||||||
},
|
birth_date: patientData.birth_date,
|
||||||
false
|
created_by: user?.id || "", // ID do admin/secretaria que está criando
|
||||||
);
|
});
|
||||||
|
|
||||||
toast.success(
|
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);
|
setShowPacienteModal(false);
|
||||||
resetFormPaciente();
|
resetFormPaciente();
|
||||||
loadPacientes();
|
loadPacientes();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error("Erro ao salvar paciente:", error);
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePaciente = async (id: string, nome: string) => {
|
const handleDeletePaciente = async (id: string, nome: string) => {
|
||||||
if (
|
setConfirmDialog({
|
||||||
!confirm(
|
isOpen: true,
|
||||||
`Tem certeza que deseja deletar o paciente "${nome}"? Esta ação não pode ser desfeita.`
|
title: "⚠️ Deletar Paciente",
|
||||||
)
|
message: (
|
||||||
) {
|
<div className="space-y-3">
|
||||||
return;
|
<p className="text-gray-700">
|
||||||
}
|
Tem certeza que deseja <strong className="text-red-600">deletar permanentemente</strong> o paciente:
|
||||||
|
</p>
|
||||||
try {
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
console.log("[PainelAdmin] Deletando paciente:", { id, nome });
|
<p className="font-semibold text-red-900">{nome}</p>
|
||||||
await patientService.delete(id);
|
<p className="text-xs text-red-700 mt-1">ID: {id}</p>
|
||||||
console.log("[PainelAdmin] Paciente deletado com sucesso");
|
</div>
|
||||||
toast.success("Paciente deletado com sucesso!");
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||||
loadPacientes();
|
<p className="text-sm text-yellow-800">
|
||||||
} catch (error) {
|
<strong>⚠️ Atenção:</strong> Esta ação não pode ser desfeita.
|
||||||
console.error("[PainelAdmin] Erro ao deletar paciente:", error);
|
</p>
|
||||||
toast.error("Erro ao deletar paciente");
|
<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
|
// Funções de gerenciamento de médicos
|
||||||
@ -609,61 +672,111 @@ const PainelAdmin: React.FC = () => {
|
|||||||
resetFormMedico();
|
resetFormMedico();
|
||||||
loadMedicos();
|
loadMedicos();
|
||||||
} else {
|
} else {
|
||||||
// Usar create-user com role=medico (nova API 21/10 - create-doctor não cria auth user)
|
// API create-doctor já cria auth user + registro na tabela doctors
|
||||||
// isPublicRegistration = false porque é admin criando
|
// Validação: CPF deve ter 11 dígitos, CRM_UF deve ter 2 letras maiúsculas
|
||||||
await userService.createUser(
|
const cpfLimpo = medicoData.cpf.replace(/\D/g, "");
|
||||||
{
|
|
||||||
email: medicoData.email,
|
if (cpfLimpo.length !== 11) {
|
||||||
full_name: medicoData.full_name,
|
toast.error("CPF deve ter 11 dígitos");
|
||||||
phone: medicoData.phone_mobile,
|
setLoading(false);
|
||||||
role: "medico",
|
return;
|
||||||
redirect_url: "https://mediconnectbrasil.netlify.app/medico/painel",
|
}
|
||||||
},
|
|
||||||
false
|
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)
|
console.log("[PainelAdmin] Criando médico com API /create-doctor:", {
|
||||||
await userService.createDoctor({
|
email: medicoData.email,
|
||||||
|
full_name: medicoData.full_name,
|
||||||
|
cpf: cpfLimpo,
|
||||||
crm: medicoData.crm,
|
crm: medicoData.crm,
|
||||||
crm_uf: medicoData.crm_uf,
|
crm_uf: medicoData.crm_uf,
|
||||||
cpf: medicoData.cpf,
|
});
|
||||||
full_name: medicoData.full_name,
|
|
||||||
|
await userService.createDoctor({
|
||||||
email: medicoData.email,
|
email: medicoData.email,
|
||||||
specialty: medicoData.specialty,
|
full_name: medicoData.full_name,
|
||||||
phone_mobile: medicoData.phone_mobile,
|
cpf: cpfLimpo,
|
||||||
|
crm: medicoData.crm,
|
||||||
|
crm_uf: medicoData.crm_uf,
|
||||||
|
specialty: medicoData.specialty || undefined,
|
||||||
|
phone_mobile: medicoData.phone_mobile || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(
|
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);
|
setShowMedicoModal(false);
|
||||||
resetFormMedico();
|
resetFormMedico();
|
||||||
loadMedicos();
|
loadMedicos();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error("Erro ao salvar médico:", error);
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteMedico = async (id: string, nome: string) => {
|
const handleDeleteMedico = async (id: string, nome: string) => {
|
||||||
if (
|
setConfirmDialog({
|
||||||
!confirm(
|
isOpen: true,
|
||||||
`Tem certeza que deseja deletar o médico "${nome}"? Esta ação não pode ser desfeita.`
|
title: "⚠️ Deletar Médico",
|
||||||
)
|
message: (
|
||||||
) {
|
<div className="space-y-3">
|
||||||
return;
|
<p className="text-gray-700">
|
||||||
}
|
Tem certeza que deseja <strong className="text-red-600">deletar permanentemente</strong> o médico:
|
||||||
|
</p>
|
||||||
try {
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
await doctorService.delete(id);
|
<p className="font-semibold text-red-900">{nome}</p>
|
||||||
toast.success("Médico deletado com sucesso!");
|
<p className="text-xs text-red-700 mt-1">ID: {id}</p>
|
||||||
loadMedicos();
|
</div>
|
||||||
} catch {
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||||
toast.error("Erro ao deletar médico");
|
<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 = () => {
|
const resetFormPaciente = () => {
|
||||||
@ -1276,21 +1389,28 @@ const PainelAdmin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
CPF *
|
CPF * <span className="text-xs text-gray-500">(11 dígitos)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formPaciente.cpf}
|
value={formPaciente.cpf}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.replace(/\D/g, ""); // Remove não-dígitos
|
||||||
setFormPaciente({
|
setFormPaciente({
|
||||||
...formPaciente,
|
...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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
@ -1330,9 +1450,12 @@ const PainelAdmin: React.FC = () => {
|
|||||||
{!editingPaciente && (
|
{!editingPaciente && (
|
||||||
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
|
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
🔐 <strong>Ativação de Conta:</strong> Um link mágico
|
🔐 <strong>Ativação de Conta:</strong> Um link de acesso
|
||||||
(magic link) será enviado automaticamente para o email
|
será enviado automaticamente para o email do paciente. Ele
|
||||||
do paciente para ativar a conta e definir senha.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1519,21 +1642,83 @@ const PainelAdmin: React.FC = () => {
|
|||||||
|
|
||||||
{/* Campo de senha (condicional) */}
|
{/* Campo de senha (condicional) */}
|
||||||
{usePassword && (
|
{usePassword && (
|
||||||
<div>
|
<>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<div>
|
||||||
Senha *
|
<label className="block text-sm font-medium mb-1">
|
||||||
</label>
|
Senha *
|
||||||
<input
|
</label>
|
||||||
type="password"
|
<input
|
||||||
required={usePassword}
|
type="password"
|
||||||
value={userPassword}
|
required={usePassword}
|
||||||
onChange={(e) => setUserPassword(e.target.value)}
|
value={userPassword}
|
||||||
minLength={6}
|
onChange={(e) => setUserPassword(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"
|
minLength={6}
|
||||||
placeholder="Mínimo 6 caracteres"
|
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 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1657,18 +1842,25 @@ const PainelAdmin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
CPF *
|
CPF * <span className="text-xs text-gray-500">(11 dígitos)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formMedico.cpf}
|
value={formMedico.cpf}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setFormMedico({ ...formMedico, cpf: e.target.value })
|
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">RG</label>
|
<label className="block text-sm font-medium mb-1">RG</label>
|
||||||
@ -1697,7 +1889,7 @@ const PainelAdmin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Telefone
|
Telefone Celular <span className="text-xs text-gray-500">(opcional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
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"
|
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>
|
</div>
|
||||||
{!editingMedico && (
|
{!editingMedico && (
|
||||||
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
|
<div className="col-span-2 bg-blue-50 p-3 rounded-lg">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
🔐 <strong>Ativação de Conta:</strong> Um link mágico
|
🔐 <strong>Ativação de Conta:</strong> Um link de acesso
|
||||||
(magic link) será enviado automaticamente para o email
|
será enviado automaticamente para o email do médico. Ele
|
||||||
do médico para ativar a conta e definir senha.
|
poderá acessar o sistema e definir sua senha no primeiro login.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -229,6 +229,35 @@ class ApiClient {
|
|||||||
): Promise<AxiosResponse<T>> {
|
): Promise<AxiosResponse<T>> {
|
||||||
return this.client.put<T>(url, data, config);
|
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();
|
export const apiClient = new ApiClient();
|
||||||
|
|||||||
@ -17,15 +17,24 @@ class AppointmentService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Busca horários disponíveis de um médico
|
* Busca horários disponíveis de um médico
|
||||||
|
* POST /functions/v1/get-available-slots
|
||||||
*/
|
*/
|
||||||
async getAvailableSlots(
|
async getAvailableSlots(
|
||||||
data: GetAvailableSlotsInput
|
data: GetAvailableSlotsInput
|
||||||
): Promise<GetAvailableSlotsResponse> {
|
): Promise<GetAvailableSlotsResponse> {
|
||||||
const response = await apiClient.post<GetAvailableSlotsResponse>(
|
try {
|
||||||
"/get-available-slots",
|
// Usa callFunction para chamar a Edge Function
|
||||||
data
|
const response = await apiClient.callFunction<GetAvailableSlotsResponse>(
|
||||||
);
|
"get-available-slots",
|
||||||
return response.data;
|
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
|
* Cria novo agendamento
|
||||||
|
* POST /rest/v1/appointments
|
||||||
* Nota: order_number é gerado automaticamente (APT-YYYY-NNNN)
|
* Nota: order_number é gerado automaticamente (APT-YYYY-NNNN)
|
||||||
*/
|
*/
|
||||||
async create(data: CreateAppointmentInput): Promise<Appointment> {
|
async create(data: CreateAppointmentInput): Promise<Appointment> {
|
||||||
const response = await apiClient.post<Appointment>(this.basePath, data);
|
try {
|
||||||
return response.data;
|
// 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 {
|
export interface GetAvailableSlotsInput {
|
||||||
doctor_id: string;
|
doctor_id: string;
|
||||||
start_date: string;
|
date: string; // YYYY-MM-DD format
|
||||||
end_date: string;
|
|
||||||
appointment_type?: AppointmentType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeSlot {
|
export interface TimeSlot {
|
||||||
datetime: string;
|
time: string; // HH:MM format (e.g., "09:00")
|
||||||
available: boolean;
|
available: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,17 +10,35 @@ import {
|
|||||||
ListAvailabilityFilters,
|
ListAvailabilityFilters,
|
||||||
CreateAvailabilityInput,
|
CreateAvailabilityInput,
|
||||||
UpdateAvailabilityInput,
|
UpdateAvailabilityInput,
|
||||||
|
DoctorException,
|
||||||
|
CreateExceptionInput,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
class AvailabilityService {
|
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[]> {
|
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, {
|
const response = await apiClient.get<DoctorAvailability[]>(this.basePath, {
|
||||||
params: filters,
|
params,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
@ -29,11 +47,16 @@ class AvailabilityService {
|
|||||||
* Cria uma nova configuração de disponibilidade
|
* Cria uma nova configuração de disponibilidade
|
||||||
*/
|
*/
|
||||||
async create(data: CreateAvailabilityInput): Promise<DoctorAvailability> {
|
async create(data: CreateAvailabilityInput): Promise<DoctorAvailability> {
|
||||||
const response = await apiClient.post<DoctorAvailability>(
|
const response = await apiClient.post<DoctorAvailability[]>(
|
||||||
this.basePath,
|
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,
|
id: string,
|
||||||
data: UpdateAvailabilityInput
|
data: UpdateAvailabilityInput
|
||||||
): Promise<DoctorAvailability> {
|
): Promise<DoctorAvailability> {
|
||||||
const response = await apiClient.patch<DoctorAvailability>(
|
const response = await apiClient.patch<DoctorAvailability[]>(
|
||||||
`${this.basePath}/${id}`,
|
`${this.basePath}?id=eq.${id}`,
|
||||||
data
|
data,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Prefer: "return=representation",
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return Array.isArray(response.data) ? response.data[0] : response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove uma configuração de disponibilidade
|
* Remove uma configuração de disponibilidade
|
||||||
*/
|
*/
|
||||||
async delete(id: string): Promise<void> {
|
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
|
* Tipos para gerenciamento de disponibilidade dos médicos
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Dias da semana
|
|
||||||
*/
|
|
||||||
export type Weekday =
|
|
||||||
| "segunda"
|
|
||||||
| "terca"
|
|
||||||
| "quarta"
|
|
||||||
| "quinta"
|
|
||||||
| "sexta"
|
|
||||||
| "sabado"
|
|
||||||
| "domingo";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tipo de atendimento
|
* Tipo de atendimento
|
||||||
*/
|
*/
|
||||||
export type AppointmentType = "presencial" | "telemedicina";
|
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 {
|
export interface DoctorAvailability {
|
||||||
id?: string;
|
id?: string;
|
||||||
doctor_id?: string;
|
doctor_id: string;
|
||||||
weekday?: Weekday;
|
weekday: number; // 0=Domingo, 1=Segunda, ..., 6=Sábado
|
||||||
start_time?: string; // Formato: HH:MM:SS (ex: "09:00:00")
|
start_time: string; // Formato: HH:MM (ex: "08:00")
|
||||||
end_time?: string; // Formato: HH:MM:SS (ex: "17:00:00")
|
end_time: string; // Formato: HH:MM (ex: "18:00")
|
||||||
slot_minutes?: number; // Default: 30
|
slot_minutes?: number; // Default: 30, range: 15-120
|
||||||
appointment_type?: AppointmentType;
|
appointment_type?: AppointmentType; // Default: 'presencial'
|
||||||
active?: boolean; // Default: true
|
active?: boolean; // Default: true
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
created_by?: 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
|
* Filtros para listagem de disponibilidades
|
||||||
*/
|
*/
|
||||||
export interface ListAvailabilityFilters {
|
export interface ListAvailabilityFilters {
|
||||||
select?: string;
|
|
||||||
doctor_id?: string;
|
doctor_id?: string;
|
||||||
|
weekday?: number; // 0-6
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
appointment_type?: AppointmentType;
|
||||||
|
select?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,10 +63,10 @@ export interface ListAvailabilityFilters {
|
|||||||
*/
|
*/
|
||||||
export interface CreateAvailabilityInput {
|
export interface CreateAvailabilityInput {
|
||||||
doctor_id: string; // required
|
doctor_id: string; // required
|
||||||
weekday: Weekday; // required
|
weekday: number; // required - 0=Domingo, 1=Segunda, ..., 6=Sábado
|
||||||
start_time: string; // required - Formato: HH:MM:SS (ex: "09:00:00")
|
start_time: string; // required - Formato: HH:MM (ex: "08:00")
|
||||||
end_time: string; // required - Formato: HH:MM:SS (ex: "17:00:00")
|
end_time: string; // required - Formato: HH:MM (ex: "18:00")
|
||||||
slot_minutes?: number; // optional - Default: 30
|
slot_minutes?: number; // optional - Default: 30, range: 15-120
|
||||||
appointment_type?: AppointmentType; // optional - Default: 'presencial'
|
appointment_type?: AppointmentType; // optional - Default: 'presencial'
|
||||||
active?: boolean; // optional - Default: true
|
active?: boolean; // optional - Default: true
|
||||||
}
|
}
|
||||||
@ -65,10 +75,23 @@ export interface CreateAvailabilityInput {
|
|||||||
* Input para atualizar disponibilidade
|
* Input para atualizar disponibilidade
|
||||||
*/
|
*/
|
||||||
export interface UpdateAvailabilityInput {
|
export interface UpdateAvailabilityInput {
|
||||||
weekday?: Weekday;
|
weekday?: number;
|
||||||
start_time?: string; // Formato: HH:MM:SS (ex: "09:00:00")
|
start_time?: string; // Formato: HH:MM
|
||||||
end_time?: string; // Formato: HH:MM:SS (ex: "17:00:00")
|
end_time?: string; // Formato: HH:MM
|
||||||
slot_minutes?: number;
|
slot_minutes?: number;
|
||||||
appointment_type?: AppointmentType;
|
appointment_type?: AppointmentType;
|
||||||
active?: boolean;
|
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 STORAGE_URL = `${this.SUPABASE_URL}/storage/v1/object`;
|
||||||
private readonly BUCKET_NAME = "avatars";
|
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
|
* Faz upload de avatar do usuário
|
||||||
*/
|
*/
|
||||||
@ -35,8 +48,14 @@ class AvatarService {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", data.file);
|
formData.append("file", data.file);
|
||||||
|
|
||||||
console.log("[AvatarService] Upload:", {
|
// URL COMPLETA (sem baseURL do axios)
|
||||||
url: `${this.STORAGE_URL}/${this.BUCKET_NAME}/${filePath}`,
|
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,
|
userId: data.userId,
|
||||||
fileName: data.file.name,
|
fileName: data.file.name,
|
||||||
fileSize: data.file.size,
|
fileSize: data.file.size,
|
||||||
@ -44,22 +63,26 @@ class AvatarService {
|
|||||||
token: token ? `${token.substring(0, 20)}...` : "null",
|
token: token ? `${token.substring(0, 20)}...` : "null",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload usando Supabase Storage API
|
// Cria instância limpa do axios
|
||||||
// x-upsert: true permite sobrescrever arquivos existentes
|
const axiosInstance = this.createAxiosInstance();
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
// Retorna a URL pública
|
||||||
const publicUrl = this.getPublicUrl({
|
const publicUrl = this.getPublicUrl({
|
||||||
@ -71,14 +94,39 @@ class AvatarService {
|
|||||||
Key: publicUrl,
|
Key: publicUrl,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao fazer upload do avatar:", error);
|
console.error("❌ [AvatarService] Erro ao fazer upload:", error);
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
console.error("Detalhes do erro:", {
|
console.error("📋 Detalhes do erro:", {
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
statusText: error.response?.statusText,
|
statusText: error.response?.statusText,
|
||||||
data: error.response?.data,
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,17 +108,20 @@ export type {
|
|||||||
CreateAvailabilityInput,
|
CreateAvailabilityInput,
|
||||||
UpdateAvailabilityInput,
|
UpdateAvailabilityInput,
|
||||||
ListAvailabilityFilters,
|
ListAvailabilityFilters,
|
||||||
Weekday,
|
AppointmentType as AvailabilityAppointmentType,
|
||||||
} from "./availability/types";
|
|
||||||
|
|
||||||
// Exceptions
|
|
||||||
export { exceptionsService } from "./exceptions/exceptionsService";
|
|
||||||
export type {
|
|
||||||
DoctorException,
|
DoctorException,
|
||||||
CreateExceptionInput,
|
CreateExceptionInput,
|
||||||
ListExceptionsFilters,
|
|
||||||
ExceptionKind,
|
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)
|
// API Client (caso precise usar diretamente)
|
||||||
export { apiClient } from "./api/client";
|
export { apiClient } from "./api/client";
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
export interface Patient {
|
export interface Patient {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
user_id?: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
cpf: string;
|
cpf: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -22,6 +23,7 @@ export interface Patient {
|
|||||||
city?: string | null;
|
city?: string | null;
|
||||||
state?: string | null;
|
state?: string | null;
|
||||||
cep?: string | null;
|
cep?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
@ -45,6 +47,7 @@ export interface CreatePatientInput {
|
|||||||
city?: string | null;
|
city?: string | null;
|
||||||
state?: string | null;
|
state?: string | null;
|
||||||
cep?: string | null;
|
cep?: string | null;
|
||||||
|
created_by?: string; // UUID do usuário que criou
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterPatientInput {
|
export interface RegisterPatientInput {
|
||||||
|
|||||||
@ -167,9 +167,13 @@ class UserService {
|
|||||||
*/
|
*/
|
||||||
async createDoctor(data: CreateDoctorInput): Promise<CreateDoctorResponse> {
|
async createDoctor(data: CreateDoctorInput): Promise<CreateDoctorResponse> {
|
||||||
const token = localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
|
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>(
|
const response = await axios.post<CreateDoctorResponse>(
|
||||||
`${API_CONFIG.FUNCTIONS_URL}/create-doctor`,
|
url,
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@ -193,9 +197,13 @@ class UserService {
|
|||||||
data: CreatePatientInput
|
data: CreatePatientInput
|
||||||
): Promise<CreatePatientResponse> {
|
): Promise<CreatePatientResponse> {
|
||||||
const token = localStorage.getItem(API_CONFIG.STORAGE_KEYS.ACCESS_TOKEN);
|
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>(
|
const response = await axios.post<CreatePatientResponse>(
|
||||||
`${API_CONFIG.FUNCTIONS_URL}/create-patient`,
|
url,
|
||||||
data,
|
data,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user