Compare commits
4 Commits
81562e0737
...
9b6fa7ff36
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b6fa7ff36 | |||
| 5a60e9a233 | |||
|
|
3a3e4c1f55 | ||
|
|
3443e46ca3 |
@ -1,294 +0,0 @@
|
||||
# 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
|
||||
53
README.md
53
README.md
@ -83,27 +83,33 @@ pnpm wrangler pages deploy dist --project-name=mediconnect --branch=production
|
||||
### 🏥 Para Médicos
|
||||
|
||||
- ✅ Agenda personalizada com disponibilidade configurável
|
||||
- ✅ Gerenciamento de exceções (bloqueios e horários extras)
|
||||
- ✅ Gerenciamento completo de disponibilidade semanal
|
||||
- ✅ Sistema 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
|
||||
- ✅ Chatbot AI para suporte
|
||||
|
||||
### 👥 Para Pacientes
|
||||
|
||||
- ✅ Agendamento inteligente com slots disponíveis em tempo real
|
||||
- ✅ Histórico completo de consultas
|
||||
- ✅ Visualização detalhada de laudos médicos com modal
|
||||
- ✅ Visualização e download de relatórios médicos (PDF)
|
||||
- ✅ Perfil com avatar e dados pessoais
|
||||
- ✅ Filtros por médico, especialidade e data
|
||||
- ✅ Chatbot AI para dúvidas e suporte
|
||||
|
||||
### 🏢 Para Secretárias
|
||||
|
||||
- ✅ Gerenciamento completo de médicos, pacientes e consultas
|
||||
- ✅ Cadastro com validação de CPF e CRM
|
||||
- ✅ Configuração de agenda médica (horários e exceções)
|
||||
- ✅ Relatórios com nomes de médicos (não apenas IDs)
|
||||
- ✅ Busca e filtros avançados
|
||||
- ✅ Confirmação profissional para exclusões
|
||||
- ✅ Boas-vindas personalizadas com nome real
|
||||
|
||||
### 🔐 Sistema de Autenticação
|
||||
|
||||
@ -312,7 +318,38 @@ PATCH /rest/v1/doctors?id=eq.{uuid}
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Melhorias Recentes (Outubro 2025)
|
||||
## 🚀 Melhorias Recentes (Novembro 2025)
|
||||
|
||||
### Chatbot AI 🤖
|
||||
|
||||
- ✅ Assistente virtual inteligente com IA
|
||||
- ✅ Interface de chat moderna e responsiva
|
||||
- ✅ Posicionamento otimizado (canto inferior esquerdo)
|
||||
- ✅ Respostas personalizadas sobre o sistema
|
||||
- ✅ Suporte a dúvidas sobre agendamento e funcionalidades
|
||||
|
||||
### Gerenciamento de Disponibilidade Médica 📅
|
||||
|
||||
- ✅ Painel completo de disponibilidade no painel do médico
|
||||
- ✅ Criação e edição de horários semanais
|
||||
- ✅ Sistema de exceções (bloqueios e horários extras)
|
||||
- ✅ Visualização em abas (Horário Semanal e Exceções)
|
||||
- ✅ Interface intuitiva com validações completas
|
||||
|
||||
### Visualização de Laudos 🔍
|
||||
|
||||
- ✅ Botão de visualização (ícone de olho) no painel do paciente
|
||||
- ✅ Modal detalhado com informações completas do laudo
|
||||
- ✅ Exibição de: número do pedido, status, exame, diagnóstico, CID, conclusão
|
||||
- ✅ Suporte a modo escuro
|
||||
- ✅ Formatação de datas em português
|
||||
|
||||
### Melhorias no Painel da Secretária 👩💼
|
||||
|
||||
- ✅ Relatórios mostram nome do médico ao invés de ID
|
||||
- ✅ Mensagem de boas-vindas personalizada com nome real
|
||||
- ✅ Busca e resolução automática de nomes de médicos
|
||||
- ✅ Fallback para email caso nome não esteja disponível
|
||||
|
||||
### Sistema de Agendamento
|
||||
|
||||
@ -322,15 +359,10 @@ PATCH /rest/v1/doctors?id=eq.{uuid}
|
||||
- ✅ 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
|
||||
|
||||
- ✅ Toast único de boas-vindas após login (removidas mensagens duplicadas)
|
||||
- ✅ Chatbot responsivo adaptado ao tamanho da tela
|
||||
- ✅ Diálogos de confirmação profissionais
|
||||
- ✅ Filtros de busca em todas as listas
|
||||
- ✅ Feedback visual melhorado
|
||||
@ -339,10 +371,11 @@ PATCH /rest/v1/doctors?id=eq.{uuid}
|
||||
|
||||
### Performance
|
||||
|
||||
- ✅ Build otimizado (~424KB)
|
||||
- ✅ Build otimizado (~467KB)
|
||||
- ✅ Code splitting
|
||||
- ✅ Lazy loading de rotas
|
||||
- ✅ Cache de assets
|
||||
- ✅ Remoção de dependências não utilizadas
|
||||
|
||||
---
|
||||
|
||||
|
||||
390
api-testing-results.md
Normal file
390
api-testing-results.md
Normal file
@ -0,0 +1,390 @@
|
||||
# API User Creation Testing Results
|
||||
|
||||
**Test Date:** 2025-11-05 13:21:51
|
||||
**Admin User:** riseup@popcode.com.br
|
||||
**Total Users Tested:** 18
|
||||
|
||||
**Secretaria Tests:** 2025-11-05 (quemquiser1@gmail.com)
|
||||
|
||||
- Pacientes: 0/7 ❌
|
||||
- Médicos: 3/3 ✅
|
||||
|
||||
## Summary
|
||||
|
||||
This document contains the results of systematically testing the user creation API endpoint for all roles (paciente, medico, secretaria, admin).
|
||||
|
||||
## Test Methodology
|
||||
|
||||
For each test user, we performed three progressive tests:
|
||||
|
||||
1. **Minimal fields test**: email, password, full_name, role only
|
||||
2. **With CPF**: If minimal failed, add cpf field
|
||||
3. **With phone_mobile**: If CPF failed, add phone_mobile field
|
||||
|
||||
## Detailed Results
|
||||
|
||||
### Pacientes (Patients) - 5 users tested
|
||||
|
||||
| User | Email | Test Result | Required Fields |
|
||||
| ------------------- | ---------------------------------- | ------------- | ------------------------------------- |
|
||||
| Raul Fernandes | raul_fernandes@gmai.com | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Ricardo Galvao | ricardo-galvao88@multcap.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Mirella Brito | mirella_brito@santoandre.sp.gov.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Gael Nascimento | gael_nascimento@jpmchase.com | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Eliane Olivia Assis | eliane_olivia_assis@vivalle.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
|
||||
### Medicos (Doctors) - 5 users tested
|
||||
|
||||
| User | Email | Test Result | Required Fields |
|
||||
| ------------------------------ | ------------------------------------------ | ------------- | ------------------------------------- |
|
||||
| Vinicius Fernando Lucas Almada | viniciusfernandoalmada@leonardopereira.com | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Rafaela Sabrina Ribeiro | rafaela_sabrina_ribeiro@multmed.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Juliana Nina Cristiane Souza | juliana_souza@tasaut.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Sabrina Cristiane Jesus | sabrina_cristiane_jesus@moderna.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Levi Marcelo Vitor Bernardes | levi-bernardes73@ibest.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
|
||||
### Secretarias (Secretaries) - 5 users tested
|
||||
|
||||
| User | Email | Test Result | Required Fields |
|
||||
| ------------------------------ | ------------------------------------- | ------------- | ------------------------------------- |
|
||||
| Mario Geraldo Barbosa | mario_geraldo_barbosa@weatherford.com | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Isabel Lavinia Dias | isabel-dias74@edpbr.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Luan Lorenzo Mendes | luan.lorenzo.mendes@atualvendas.com | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Julio Tiago Bento Rocha | julio-rocha85@lonza.com | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Flavia Luiza Priscila da Silva | flavia-dasilva86@prositeweb.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
|
||||
### Administrators - 3 users tested
|
||||
|
||||
| User | Email | Test Result | Required Fields |
|
||||
| ---------------------------- | --------------------------------- | ------------- | ------------------------------------- |
|
||||
| Nicole Manuela Vanessa Viana | nicole-viana74@queirozgalvao.com | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Danilo Kaue Gustavo Lopes | danilo_lopes@tursi.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
| Thiago Enzo Vieira | thiago_vieira@gracomonline.com.br | Test 2 PASSED | email, password, full_name, role, cpf |
|
||||
|
||||
## Required Fields Analysis
|
||||
|
||||
Based on the test results above, the required fields for user creation are:
|
||||
|
||||
### ✅ REQUIRED FIELDS (All Roles)
|
||||
|
||||
- **email** - User email address (must be unique)
|
||||
- **password** - User password
|
||||
- **full_name** - User's full name
|
||||
- **role** - User role (paciente, medico, secretaria, admin)
|
||||
- **cpf** - Brazilian tax ID (XXX.XXX.XXX-XX format) - **REQUIRED FOR ALL ROLES**
|
||||
|
||||
> **Key Finding**: All 18 test users failed the minimal fields test (without CPF) and succeeded with CPF included. This confirms that CPF is mandatory for user creation across all roles.
|
||||
|
||||
### ❌ NOT REQUIRED
|
||||
|
||||
- **phone_mobile** - Mobile phone number (optional, but recommended)
|
||||
|
||||
### Optional Fields
|
||||
|
||||
- **phone** - Landline phone number
|
||||
- **create_patient_record** - Boolean flag (default: true for paciente role)
|
||||
|
||||
---
|
||||
|
||||
## Form Fields Summary by Role
|
||||
|
||||
### All Roles - Common Required Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "string (required, unique)",
|
||||
"password": "string (required, min 6 chars)",
|
||||
"full_name": "string (required)",
|
||||
"cpf": "string (required, format: XXX.XXX.XXX-XX)",
|
||||
"role": "string (required: paciente|medico|secretaria|admin)"
|
||||
}
|
||||
```
|
||||
|
||||
### Paciente (Patient) - Complete Form Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "string (required)",
|
||||
"password": "string (required)",
|
||||
"full_name": "string (required)",
|
||||
"cpf": "string (required)",
|
||||
"role": "paciente",
|
||||
"phone_mobile": "string (optional, format: (XX) XXXXX-XXXX)",
|
||||
"phone": "string (optional)",
|
||||
"create_patient_record": "boolean (optional, default: true)"
|
||||
}
|
||||
```
|
||||
|
||||
### Medico (Doctor) - Complete Form Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "string (required)",
|
||||
"password": "string (required)",
|
||||
"full_name": "string (required)",
|
||||
"cpf": "string (required)",
|
||||
"role": "medico",
|
||||
"phone_mobile": "string (optional)",
|
||||
"phone": "string (optional)",
|
||||
"crm": "string (optional - doctor registration number)",
|
||||
"specialty": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
### Secretaria (Secretary) - Complete Form Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "string (required)",
|
||||
"password": "string (required)",
|
||||
"full_name": "string (required)",
|
||||
"cpf": "string (required)",
|
||||
"role": "secretaria",
|
||||
"phone_mobile": "string (optional)",
|
||||
"phone": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
### Admin (Administrator) - Complete Form Fields
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "string (required)",
|
||||
"password": "string (required)",
|
||||
"full_name": "string (required)",
|
||||
"cpf": "string (required)",
|
||||
"role": "admin",
|
||||
"phone_mobile": "string (optional)",
|
||||
"phone": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoint Documentation
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://yuanqfswhberkoevtmfr.supabase.co/functions/v1/create-user-with-password
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
Requires admin user authentication token in Authorization header.
|
||||
|
||||
### Headers
|
||||
|
||||
```json
|
||||
{
|
||||
"Authorization": "Bearer <access_token>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
### Request Body Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "string (required)",
|
||||
"password": "string (required)",
|
||||
"full_name": "string (required)",
|
||||
"role": "paciente|medico|secretaria|admin (required)",
|
||||
"cpf": "string (format: XXX.XXX.XXX-XX)",
|
||||
"phone_mobile": "string (format: (XX) XXXXX-XXXX)",
|
||||
"phone": "string (optional)",
|
||||
"create_patient_record": "boolean (optional, default: true)"
|
||||
}
|
||||
```
|
||||
|
||||
### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST "https://yuanqfswhberkoevtmfr.supabase.co/functions/v1/create-user-with-password" \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "securePassword123",
|
||||
"full_name": "John Doe",
|
||||
"role": "paciente",
|
||||
"cpf": "123.456.789-00",
|
||||
"phone_mobile": "(11) 98765-4321"
|
||||
}'
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **Form Validation**: Update all user creation forms to enforce the required fields identified above
|
||||
2. **Error Handling**: Implement clear error messages for missing required fields
|
||||
3. **CPF Validation**: Add client-side CPF format validation and uniqueness checks
|
||||
4. **Phone Format**: Validate phone number format before submission
|
||||
5. **Role-Based Fields**: Consider if certain roles require additional specific fields
|
||||
|
||||
## Test Statistics
|
||||
|
||||
- **Total Tests**: 18
|
||||
- **Successful Creations**: 18
|
||||
- **Failed Creations**: 0
|
||||
- **Success Rate**: 100%
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementações Realizadas no PainelAdmin.tsx
|
||||
|
||||
**Data de Implementação:** 2025-11-05
|
||||
|
||||
### 1. Campos Obrigatórios
|
||||
|
||||
Todos os usuários agora EXIGEM:
|
||||
|
||||
- ✅ Nome Completo
|
||||
- ✅ Email (único)
|
||||
- ✅ **CPF** (formatado automaticamente para XXX.XXX.XXX-XX)
|
||||
- ✅ **Senha** (mínimo 6 caracteres)
|
||||
- ✅ Role/Papel
|
||||
|
||||
### 2. Formatação Automática
|
||||
|
||||
Implementadas funções que formatam automaticamente:
|
||||
|
||||
- **CPF**: Remove caracteres não numéricos e formata para `XXX.XXX.XXX-XX`
|
||||
- **Telefone**: Formata para `(XX) XXXXX-XXXX` ou `(XX) XXXX-XXXX`
|
||||
- Validação em tempo real durante digitação
|
||||
|
||||
### 3. Validações
|
||||
|
||||
- CPF: Deve ter exatamente 11 dígitos
|
||||
- Senha: Mínimo 6 caracteres
|
||||
- Email: Formato válido e único no sistema
|
||||
- Mensagens de erro específicas para duplicados
|
||||
|
||||
### 4. Interface Melhorada
|
||||
|
||||
- Campos obrigatórios claramente marcados com \*
|
||||
- Placeholders indicando formato esperado
|
||||
- Mensagens de ajuda contextuais
|
||||
- Painel informativo com lista de campos obrigatórios
|
||||
- Opção de criar registro de paciente (apenas para role "paciente")
|
||||
|
||||
### 5. Campos Opcionais
|
||||
|
||||
Movidos para seção separada:
|
||||
|
||||
- Telefone Fixo (formatado automaticamente)
|
||||
- Telefone Celular (formatado automaticamente)
|
||||
- Create Patient Record (apenas para pacientes)
|
||||
|
||||
### Código das Funções de Formatação
|
||||
|
||||
```typescript
|
||||
// Formata CPF para XXX.XXX.XXX-XX
|
||||
const formatCPF = (value: string): string => {
|
||||
const numbers = value.replace(/\D/g, "");
|
||||
if (numbers.length <= 3) return numbers;
|
||||
if (numbers.length <= 6) return `${numbers.slice(0, 3)}.${numbers.slice(3)}`;
|
||||
if (numbers.length <= 9)
|
||||
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(6)}`;
|
||||
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
|
||||
6,
|
||||
9
|
||||
)}-${numbers.slice(9, 11)}`;
|
||||
};
|
||||
|
||||
// Formata Telefone para (XX) XXXXX-XXXX
|
||||
const formatPhone = (value: string): string => {
|
||||
const numbers = value.replace(/\D/g, "");
|
||||
if (numbers.length <= 2) return numbers;
|
||||
if (numbers.length <= 7)
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
|
||||
if (numbers.length <= 11)
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
|
||||
7
|
||||
)}`;
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
|
||||
7,
|
||||
11
|
||||
)}`;
|
||||
};
|
||||
```
|
||||
|
||||
### Exemplo de Uso no Formulário
|
||||
|
||||
```tsx
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={userCpf}
|
||||
onChange={(e) => setUserCpf(formatCPF(e.target.value))}
|
||||
maxLength={14}
|
||||
placeholder="000.000.000-00"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secretaria Role Tests (2025-11-05)
|
||||
|
||||
**User:** quemquiser1@gmail.com (Secretária)
|
||||
**Test Script:** test-secretaria-api.ps1
|
||||
|
||||
### API: `/functions/v1/create-doctor`
|
||||
|
||||
**Status:** ✅ **WORKING**
|
||||
|
||||
- **Tested:** 3 médicos
|
||||
- **Success:** 3/3 (100%)
|
||||
- **Failed:** 0/3
|
||||
|
||||
**Required Fields:**
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "dr.exemplo@example.com",
|
||||
"full_name": "Dr. Nome Completo",
|
||||
"cpf": "12345678901",
|
||||
"crm": "123456",
|
||||
"crm_uf": "SP",
|
||||
"phone_mobile": "(11) 98765-4321"
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
|
||||
- CPF must be without formatting (only digits)
|
||||
- CRM and CRM_UF are mandatory
|
||||
- phone_mobile is accepted with or without formatting
|
||||
|
||||
### API: `/rest/v1/patients` (REST Direct)
|
||||
|
||||
**Status:** ✅ **WORKING**
|
||||
|
||||
- **Tested:** 7 pacientes
|
||||
- **Success:** 4/7 (57%)
|
||||
- **Failed:** 3/7 (CPF inválido, 1 duplicado)
|
||||
|
||||
**Required Fields:**
|
||||
|
||||
```json
|
||||
{
|
||||
"full_name": "Nome Completo",
|
||||
"cpf": "11144477735",
|
||||
"email": "paciente@example.com",
|
||||
"phone_mobile": "11987654321",
|
||||
"birth_date": "1995-03-15",
|
||||
"created_by": "96cd275a-ec2c-4fee-80dc-43be35aea28c"
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
- ✅ CPF must be **without formatting** (only 11 digits)
|
||||
- ✅ CPF must be **algorithmically valid** (check digit validation)
|
||||
- ✅ Phone must be **without formatting** (only digits)
|
||||
- ✅ Uses REST API `/rest/v1/patients` (not Edge Function)
|
||||
- ❌ CPF must pass `patients_cpf_valid_check` constraint
|
||||
- ⚠️ The Edge Function `/functions/v1/create-patient` does NOT exist or is broken
|
||||
|
||||
---
|
||||
|
||||
_Report generated automatically by test-api-simple.ps1 and test-secretaria-api.ps1_
|
||||
_PainelAdmin.tsx updated: 2025-11-05_
|
||||
_For questions or issues, contact the development team_
|
||||
@ -1,20 +0,0 @@
|
||||
# Script de limpeza de dependências não utilizadas
|
||||
# Execute este arquivo no PowerShell
|
||||
|
||||
Write-Host "🧹 Limpando dependências não utilizadas..." -ForegroundColor Cyan
|
||||
|
||||
# Remover pacotes não utilizados
|
||||
Write-Host "`n📦 Removendo @lumi.new/sdk..." -ForegroundColor Yellow
|
||||
pnpm remove @lumi.new/sdk
|
||||
|
||||
Write-Host "`n📦 Removendo node-fetch..." -ForegroundColor Yellow
|
||||
pnpm remove node-fetch
|
||||
|
||||
Write-Host "`n📦 Removendo react-toastify..." -ForegroundColor Yellow
|
||||
pnpm remove react-toastify
|
||||
|
||||
Write-Host "`n✅ Limpeza concluída!" -ForegroundColor Green
|
||||
Write-Host "📊 Verificando tamanho de node_modules..." -ForegroundColor Cyan
|
||||
|
||||
$size = (Get-ChildItem "node_modules" -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum / 1MB
|
||||
Write-Host "Tamanho atual: $([math]::Round($size, 2)) MB" -ForegroundColor White
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
@ -8,7 +9,6 @@ import {
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
isBefore,
|
||||
startOfDay,
|
||||
} from "date-fns";
|
||||
@ -45,7 +45,9 @@ export default function AgendamentoConsulta({
|
||||
medicos,
|
||||
}: AgendamentoConsultaProps) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>(medicos);
|
||||
const detailsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sempre que a lista de médicos da API mudar, atualiza o filtro
|
||||
useEffect(() => {
|
||||
@ -63,8 +65,10 @@ export default function AgendamentoConsulta({
|
||||
>("presencial");
|
||||
const [motivo, setMotivo] = useState("");
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [bookingSuccess, setBookingSuccess] = useState(false);
|
||||
const [bookingError, setBookingError] = useState("");
|
||||
const [showResultModal, setShowResultModal] = useState(false);
|
||||
const [resultType, setResultType] = useState<"success" | "error">("success");
|
||||
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
||||
|
||||
// Removido o carregamento interno de médicos, pois agora vem por prop
|
||||
|
||||
@ -87,6 +91,90 @@ export default function AgendamentoConsulta({
|
||||
|
||||
const specialties = Array.from(new Set(medicos.map((m) => m.especialidade)));
|
||||
|
||||
// Busca as disponibilidades do médico e calcula as datas disponíveis
|
||||
useEffect(() => {
|
||||
const loadAvailableDates = async () => {
|
||||
if (!selectedMedico) {
|
||||
setAvailableDates(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { availabilityService } = await import("../services");
|
||||
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Buscando disponibilidades para médico:",
|
||||
{
|
||||
id: selectedMedico.id,
|
||||
nome: selectedMedico.nome,
|
||||
}
|
||||
);
|
||||
|
||||
// Busca todas as disponibilidades ativas do médico
|
||||
const availabilities = await availabilityService.list({
|
||||
doctor_id: selectedMedico.id,
|
||||
active: true,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Disponibilidades retornadas da API:",
|
||||
{
|
||||
count: availabilities?.length || 0,
|
||||
data: availabilities,
|
||||
}
|
||||
);
|
||||
|
||||
if (!availabilities || availabilities.length === 0) {
|
||||
console.warn(
|
||||
"[AgendamentoConsulta] Nenhuma disponibilidade encontrada para o médico"
|
||||
);
|
||||
setAvailableDates(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
// Mapeia os dias da semana que o médico atende (weekday é sempre número 0-6)
|
||||
const availableWeekdays = new Set<number>(
|
||||
availabilities.map((avail) => avail.weekday)
|
||||
);
|
||||
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Dias da semana disponíveis (números):",
|
||||
Array.from(availableWeekdays)
|
||||
);
|
||||
|
||||
// Calcula todas as datas do mês atual e próximos 2 meses que têm disponibilidade
|
||||
const today = startOfDay(new Date());
|
||||
const endDate = endOfMonth(addMonths(today, 2));
|
||||
const allDates = eachDayOfInterval({ start: today, end: endDate });
|
||||
|
||||
const availableDatesSet = new Set<string>();
|
||||
|
||||
allDates.forEach((date) => {
|
||||
const weekday = date.getDay();
|
||||
if (availableWeekdays.has(weekday) && !isBefore(date, today)) {
|
||||
availableDatesSet.add(format(date, "yyyy-MM-dd"));
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[AgendamentoConsulta] Resumo do cálculo:", {
|
||||
weekdaysDisponiveis: Array.from(availableWeekdays),
|
||||
datasCalculadas: availableDatesSet.size,
|
||||
primeiras5Datas: Array.from(availableDatesSet).slice(0, 5),
|
||||
});
|
||||
|
||||
setAvailableDates(availableDatesSet);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[AgendamentoConsulta] Erro ao carregar disponibilidades:",
|
||||
error
|
||||
);
|
||||
setAvailableDates(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
loadAvailableDates();
|
||||
}, [selectedMedico]);
|
||||
|
||||
// Removemos as funções de availability e exceptions antigas
|
||||
// A API de slots já considera tudo automaticamente
|
||||
|
||||
@ -116,37 +204,40 @@ export default function AgendamentoConsulta({
|
||||
active: true,
|
||||
});
|
||||
|
||||
console.log("[AgendamentoConsulta] Disponibilidades do médico:", availabilities);
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Disponibilidades do médico:",
|
||||
availabilities
|
||||
);
|
||||
|
||||
if (!availabilities || availabilities.length === 0) {
|
||||
console.warn("[AgendamentoConsulta] Nenhuma disponibilidade configurada para este médico");
|
||||
console.warn(
|
||||
"[AgendamentoConsulta] Nenhuma disponibilidade configurada para este médico"
|
||||
);
|
||||
setAvailableSlots([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pega o dia da semana da data selecionada
|
||||
const weekdayMap: Record<number, string> = {
|
||||
0: "sunday",
|
||||
1: "monday",
|
||||
2: "tuesday",
|
||||
3: "wednesday",
|
||||
4: "thursday",
|
||||
5: "friday",
|
||||
6: "saturday",
|
||||
};
|
||||
|
||||
const dayOfWeek = weekdayMap[selectedDate.getDay()];
|
||||
console.log("[AgendamentoConsulta] Dia da semana selecionado:", dayOfWeek);
|
||||
// Pega o dia da semana da data selecionada (0-6)
|
||||
const dayOfWeek = selectedDate.getDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Dia da semana selecionado:",
|
||||
dayOfWeek
|
||||
);
|
||||
|
||||
// Filtra disponibilidades para o dia da semana
|
||||
const dayAvailability = availabilities.filter(
|
||||
(avail) => avail.weekday === dayOfWeek && avail.active
|
||||
);
|
||||
|
||||
console.log("[AgendamentoConsulta] Disponibilidades para o dia:", dayAvailability);
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Disponibilidades para o dia:",
|
||||
dayAvailability
|
||||
);
|
||||
|
||||
if (dayAvailability.length === 0) {
|
||||
console.warn("[AgendamentoConsulta] Médico não atende neste dia da semana");
|
||||
console.warn(
|
||||
"[AgendamentoConsulta] Médico não atende neste dia da semana"
|
||||
);
|
||||
setAvailableSlots([]);
|
||||
return;
|
||||
}
|
||||
@ -168,18 +259,70 @@ export default function AgendamentoConsulta({
|
||||
while (currentMinutes < endMinutes) {
|
||||
const hours = Math.floor(currentMinutes / 60);
|
||||
const minutes = currentMinutes % 60;
|
||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
allSlots.push(timeStr);
|
||||
currentMinutes += slotMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
// Busca exceções (bloqueios) para este médico
|
||||
const exceptions = await availabilityService.listExceptions({
|
||||
doctor_id: selectedMedico.id,
|
||||
});
|
||||
|
||||
console.log("[AgendamentoConsulta] Exceções encontradas:", exceptions);
|
||||
|
||||
// Verifica se a data está bloqueada (exceção de bloqueio)
|
||||
const dayException = exceptions.find(
|
||||
(exc) => exc.date === dateStr && exc.kind === "bloqueio"
|
||||
);
|
||||
|
||||
if (dayException) {
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Data bloqueada por exceção:",
|
||||
dayException
|
||||
);
|
||||
|
||||
// Se for bloqueio de dia inteiro (start_time e end_time são null), não há horários disponíveis
|
||||
if (!dayException.start_time || !dayException.end_time) {
|
||||
console.log("[AgendamentoConsulta] Dia completamente bloqueado");
|
||||
setAvailableSlots([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Se for bloqueio parcial, remove os horários bloqueados
|
||||
if (dayException.start_time && dayException.end_time) {
|
||||
const [blockStartHour, blockStartMin] = dayException.start_time.split(":").map(Number);
|
||||
const [blockEndHour, blockEndMin] = dayException.end_time.split(":").map(Number);
|
||||
const blockStartMinutes = blockStartHour * 60 + blockStartMin;
|
||||
const blockEndMinutes = blockEndHour * 60 + blockEndMin;
|
||||
|
||||
// Filtra slots que não estão no período bloqueado
|
||||
const slotsAfterBlock = allSlots.filter(slot => {
|
||||
const [slotHour, slotMin] = slot.split(":").map(Number);
|
||||
const slotMinutes = slotHour * 60 + slotMin;
|
||||
return slotMinutes < blockStartMinutes || slotMinutes >= blockEndMinutes;
|
||||
});
|
||||
|
||||
console.log("[AgendamentoConsulta] Slots após remover bloqueio parcial:", slotsAfterBlock);
|
||||
|
||||
// Usa os slots filtrados em vez de todos
|
||||
allSlots.length = 0;
|
||||
allSlots.push(...slotsAfterBlock);
|
||||
}
|
||||
}
|
||||
|
||||
// Busca agendamentos existentes para esta data
|
||||
const appointments = await appointmentService.list({
|
||||
doctor_id: selectedMedico.id,
|
||||
});
|
||||
|
||||
console.log("[AgendamentoConsulta] Agendamentos existentes:", appointments);
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Agendamentos existentes:",
|
||||
appointments
|
||||
);
|
||||
|
||||
// Filtra agendamentos para a data selecionada
|
||||
const bookedSlots = appointments
|
||||
@ -204,7 +347,10 @@ export default function AgendamentoConsulta({
|
||||
(slot) => !bookedSlots.includes(slot)
|
||||
);
|
||||
|
||||
console.log("[AgendamentoConsulta] Slots disponíveis calculados:", availableSlots);
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Slots disponíveis calculados:",
|
||||
availableSlots
|
||||
);
|
||||
|
||||
setAvailableSlots(availableSlots);
|
||||
} catch (error) {
|
||||
@ -219,16 +365,7 @@ export default function AgendamentoConsulta({
|
||||
} else {
|
||||
setAvailableSlots([]);
|
||||
}
|
||||
}, [selectedDate, selectedMedico, calculateAvailableSlots]);
|
||||
|
||||
// Simplificado: a API de slots já considera disponibilidade e exceções
|
||||
const isDateAvailable = (date: Date): boolean => {
|
||||
// Não permite datas passadas
|
||||
if (isBefore(date, startOfDay(new Date()))) return false;
|
||||
// Para simplificar, consideramos todos os dias futuros como possíveis
|
||||
// A API fará a validação real quando buscar slots
|
||||
return true;
|
||||
};
|
||||
}, [selectedDate, selectedMedico, appointmentType, calculateAvailableSlots]);
|
||||
|
||||
const generateCalendarDays = () => {
|
||||
const start = startOfMonth(currentMonth);
|
||||
@ -246,13 +383,39 @@ export default function AgendamentoConsulta({
|
||||
|
||||
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
|
||||
const handleNextMonth = () => setCurrentMonth(addMonths(currentMonth, 1));
|
||||
|
||||
const handleMonthChange = (monthIndex: number) => {
|
||||
const newDate = new Date(currentMonth);
|
||||
newDate.setMonth(monthIndex);
|
||||
setCurrentMonth(newDate);
|
||||
};
|
||||
|
||||
const handleYearChange = (year: number) => {
|
||||
const newDate = new Date(currentMonth);
|
||||
newDate.setFullYear(year);
|
||||
setCurrentMonth(newDate);
|
||||
};
|
||||
|
||||
// Gera lista de anos (ano atual até +10 anos)
|
||||
const availableYears = Array.from(
|
||||
{ length: 11 },
|
||||
(_, i) => new Date().getFullYear() + i
|
||||
);
|
||||
|
||||
const handleSelectDoctor = (medico: Medico) => {
|
||||
setSelectedMedico(medico);
|
||||
setSelectedDate(undefined);
|
||||
setSelectedTime("");
|
||||
setMotivo("");
|
||||
setBookingSuccess(false);
|
||||
setBookingError("");
|
||||
|
||||
// Scroll suave para a seção de detalhes
|
||||
setTimeout(() => {
|
||||
detailsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
const handleBookAppointment = () => {
|
||||
if (selectedMedico && selectedDate && selectedTime && motivo) {
|
||||
@ -273,96 +436,168 @@ export default function AgendamentoConsulta({
|
||||
doctor_id: selectedMedico.id,
|
||||
scheduled_at: scheduledAt,
|
||||
duration_minutes: 30,
|
||||
appointment_type:
|
||||
(appointmentType === "online" ? "telemedicina" : "presencial") as "presencial" | "telemedicina",
|
||||
appointment_type: (appointmentType === "online"
|
||||
? "telemedicina"
|
||||
: "presencial") as "presencial" | "telemedicina",
|
||||
chief_complaint: motivo,
|
||||
};
|
||||
|
||||
console.log("[AgendamentoConsulta] Criando agendamento com dados:", appointmentData);
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Criando agendamento com dados:",
|
||||
appointmentData
|
||||
);
|
||||
|
||||
// Cria o agendamento usando a API REST
|
||||
const appointment = await appointmentService.create(appointmentData);
|
||||
|
||||
console.log("[AgendamentoConsulta] Consulta criada com sucesso:", appointment);
|
||||
|
||||
setBookingSuccess(true);
|
||||
setShowConfirmDialog(false);
|
||||
|
||||
// Reset form após 3 segundos
|
||||
setTimeout(() => {
|
||||
setSelectedMedico(null);
|
||||
setSelectedDate(undefined);
|
||||
setSelectedTime("");
|
||||
setMotivo("");
|
||||
setBookingSuccess(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
console.error("[AgendamentoConsulta] Erro ao agendar:", {
|
||||
error,
|
||||
message: error?.message,
|
||||
response: error?.response,
|
||||
data: error?.response?.data,
|
||||
});
|
||||
setBookingError(
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"Erro ao agendar consulta. Tente novamente."
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Consulta criada com sucesso:",
|
||||
appointment
|
||||
);
|
||||
|
||||
// Mostra modal de sucesso
|
||||
setResultType("success");
|
||||
setShowResultModal(true);
|
||||
setShowConfirmDialog(false);
|
||||
} catch (error: unknown) {
|
||||
console.error("[AgendamentoConsulta] Erro ao agendar:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao agendar consulta. Tente novamente.";
|
||||
|
||||
// Mostra modal de erro
|
||||
setResultType("error");
|
||||
setShowResultModal(true);
|
||||
setBookingError(errorMessage);
|
||||
setShowConfirmDialog(false);
|
||||
}
|
||||
};
|
||||
const calendarDays = generateCalendarDays();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{bookingSuccess && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-green-900">
|
||||
Consulta agendada com sucesso!
|
||||
</p>
|
||||
<p className="text-sm text-green-700">
|
||||
Você receberá uma confirmação por e-mail em breve.
|
||||
</p>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Modal de Resultado (Sucesso ou Erro) com Animação */}
|
||||
{showResultModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 animate-fade-in">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-6 sm:p-8 max-w-md w-full animate-scale-in">
|
||||
<div className="flex flex-col items-center text-center space-y-4">
|
||||
{/* Ícone com Animação Giratória (1 volta) */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full animate-pulse-ring ${
|
||||
resultType === "success" ? "bg-blue-100" : "bg-red-100"
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`relative rounded-full p-4 sm:p-5 ${
|
||||
resultType === "success" ? "bg-blue-500" : "bg-red-500"
|
||||
}`}
|
||||
>
|
||||
{resultType === "success" ? (
|
||||
<CheckCircle2 className="h-12 w-12 sm:h-16 sm:w-16 text-white animate-spin-once" />
|
||||
) : (
|
||||
<AlertCircle className="h-12 w-12 sm:h-16 sm:w-16 text-white animate-spin-once" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mensagem */}
|
||||
<div className="space-y-2">
|
||||
<h3
|
||||
className={`text-xl sm:text-2xl font-bold ${
|
||||
resultType === "success" ? "text-blue-900" : "text-red-900"
|
||||
}`}
|
||||
>
|
||||
{resultType === "success"
|
||||
? "Consulta Agendada!"
|
||||
: "Erro no Agendamento"}
|
||||
</h3>
|
||||
{resultType === "success" ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
Sua consulta foi agendada com sucesso. Você receberá uma
|
||||
confirmação por e-mail ou SMS.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowResultModal(false);
|
||||
setBookingError("");
|
||||
navigate("/acompanhamento", {
|
||||
state: { activeTab: "consultas" },
|
||||
});
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 underline text-sm sm:text-base font-medium"
|
||||
>
|
||||
Ou veja no painel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
{bookingError ||
|
||||
"Não foi possível agendar a consulta. Tente novamente."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão OK */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowResultModal(false);
|
||||
setBookingError("");
|
||||
// Limpa o formulário se for sucesso
|
||||
if (resultType === "success") {
|
||||
setSelectedMedico(null);
|
||||
setSelectedDate(undefined);
|
||||
setSelectedTime("");
|
||||
setMotivo("");
|
||||
}
|
||||
}}
|
||||
className={`w-full font-semibold py-3 px-6 rounded-lg transition-colors ${
|
||||
resultType === "success"
|
||||
? "bg-blue-500 hover:bg-blue-600 text-white"
|
||||
: "bg-red-500 hover:bg-red-600 text-white"
|
||||
}`}
|
||||
>
|
||||
OK, Entendi!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookingError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
<p className="text-red-900">{bookingError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Agendar Consulta</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<h1 className="text-xl sm:text-2xl font-bold">Agendar Consulta</h1>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">
|
||||
Escolha um médico e horário disponível
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg sm:rounded-xl border dark:border-gray-700 p-4 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium">
|
||||
<label className="text-xs sm:text-sm font-medium dark:text-gray-200">
|
||||
Buscar por nome ou especialidade
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ex: Cardiologia, Dr. Silva..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 w-full border rounded-lg py-2 px-3"
|
||||
className="pl-9 w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium">Especialidade</label>
|
||||
<label className="text-xs sm:text-sm font-medium">
|
||||
Especialidade
|
||||
</label>
|
||||
<select
|
||||
value={selectedSpecialty}
|
||||
onChange={(e) => setSelectedSpecialty(e.target.value)}
|
||||
className="w-full border rounded-lg py-2 px-3"
|
||||
className="w-full border border-gray-300 dark:border-gray-600 rounded-lg py-2.5 px-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="all">Todas as especialidades</option>
|
||||
{specialties.map((esp) => (
|
||||
@ -378,36 +613,48 @@ export default function AgendamentoConsulta({
|
||||
{filteredMedicos.map((medico) => (
|
||||
<div
|
||||
key={medico.id}
|
||||
className={`bg-white rounded-xl border p-6 flex gap-4 items-center ${
|
||||
selectedMedico?.id === medico.id ? "border-blue-500" : ""
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg sm:rounded-xl border dark:border-gray-700 p-4 sm:p-6 flex flex-col sm:flex-row gap-3 sm:gap-4 items-start sm:items-center ${
|
||||
selectedMedico?.id === medico.id
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/30"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center justify-center text-xl font-bold">
|
||||
<div className="h-12 w-12 sm:h-16 sm:w-16 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-base sm:text-xl font-bold text-white flex-shrink-0">
|
||||
{medico.nome
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
.join("")
|
||||
.substring(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex-1 min-w-0 space-y-1.5 sm:space-y-2 w-full">
|
||||
<div>
|
||||
<h3 className="font-semibold">{medico.nome}</h3>
|
||||
<p className="text-muted-foreground">{medico.especialidade}</p>
|
||||
<h3 className="text-sm sm:text-base font-semibold truncate">
|
||||
{medico.nome}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground truncate">
|
||||
{medico.especialidade}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-muted-foreground">
|
||||
<span>{medico.crm}</span>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
|
||||
<span className="truncate">CRM: {medico.crm}</span>
|
||||
{medico.valorConsulta ? (
|
||||
<span>R$ {medico.valorConsulta.toFixed(2)}</span>
|
||||
<span className="whitespace-nowrap">
|
||||
R$ {medico.valorConsulta.toFixed(2)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-foreground">{medico.email || "-"}</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<span className="text-xs sm:text-sm text-foreground truncate w-full sm:w-auto">
|
||||
{medico.email || "-"}
|
||||
</span>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg border text-sm hover:bg-blue-50"
|
||||
className="flex-1 sm:flex-none px-3 py-1.5 sm:py-1 rounded-lg border text-xs sm:text-sm hover:bg-blue-50 transition-colors whitespace-nowrap"
|
||||
onClick={() => handleSelectDoctor(medico)}
|
||||
>
|
||||
{selectedMedico?.id === medico.id
|
||||
? "Selecionado"
|
||||
? "✓ Selecionado"
|
||||
: "Selecionar"}
|
||||
</button>
|
||||
</div>
|
||||
@ -417,104 +664,175 @@ export default function AgendamentoConsulta({
|
||||
))}
|
||||
</div>
|
||||
{selectedMedico && (
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
<div
|
||||
ref={detailsRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-900/50 p-4 sm:p-6 space-y-4 sm:space-y-6"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Detalhes do Agendamento</h2>
|
||||
<p className="text-gray-600">
|
||||
<h2 className="text-lg sm:text-xl font-semibold truncate dark:text-white">
|
||||
Detalhes do Agendamento
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 truncate">
|
||||
Consulta com {selectedMedico.nome} -{" "}
|
||||
{selectedMedico.especialidade}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
onClick={() => setAppointmentType("presencial")}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2.5 sm:py-3 rounded-lg border-2 transition-colors ${
|
||||
appointmentType === "presencial"
|
||||
? "border-blue-500 bg-blue-50 text-blue-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span className="font-medium">Presencial</span>
|
||||
<MapPin className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||
<span className="text-sm sm:text-base font-medium">
|
||||
Presencial
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAppointmentType("online")}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border-2 transition-colors ${
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2.5 sm:py-3 rounded-lg border-2 transition-colors ${
|
||||
appointmentType === "online"
|
||||
? "border-blue-500 bg-blue-50 text-blue-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<Video className="h-5 w-5" />
|
||||
<span className="font-medium">Online</span>
|
||||
<Video className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||
<span className="text-sm sm:text-base font-medium">Online</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Selecione a Data</label>
|
||||
<label className="text-xs sm:text-sm font-medium">
|
||||
Selecione a Data
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-3 sm:mb-4">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
className="p-1.5 sm:p-2 hover:bg-gray-100 rounded-lg flex-shrink-0"
|
||||
aria-label="Mês anterior"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<ChevronLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
<span className="font-semibold">
|
||||
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 justify-center">
|
||||
<select
|
||||
value={currentMonth.getMonth()}
|
||||
onChange={(e) =>
|
||||
handleMonthChange(Number(e.target.value))
|
||||
}
|
||||
className="text-xs sm:text-sm font-semibold border border-gray-300 rounded-lg px-2 py-1 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={0}>Janeiro</option>
|
||||
<option value={1}>Fevereiro</option>
|
||||
<option value={2}>Março</option>
|
||||
<option value={3}>Abril</option>
|
||||
<option value={4}>Maio</option>
|
||||
<option value={5}>Junho</option>
|
||||
<option value={6}>Julho</option>
|
||||
<option value={7}>Agosto</option>
|
||||
<option value={8}>Setembro</option>
|
||||
<option value={9}>Outubro</option>
|
||||
<option value={10}>Novembro</option>
|
||||
<option value={11}>Dezembro</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={currentMonth.getFullYear()}
|
||||
onChange={(e) =>
|
||||
handleYearChange(Number(e.target.value))
|
||||
}
|
||||
className="text-xs sm:text-sm font-semibold border border-gray-300 rounded-lg px-2 py-1 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{availableYears.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
className="p-1.5 sm:p-2 hover:bg-gray-100 rounded-lg flex-shrink-0"
|
||||
aria-label="Próximo mês"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
<ChevronRight className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="grid grid-cols-7 bg-gray-50">
|
||||
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map(
|
||||
(day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center py-2 text-sm font-medium text-gray-600"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{["D", "S", "T", "Q", "Q", "S", "S"].map((day, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-center py-1.5 sm:py-2 text-xs sm:text-sm font-medium text-gray-600"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7">
|
||||
{calendarDays.map((day, index) => {
|
||||
const isCurrentMonth = isSameMonth(day, currentMonth);
|
||||
const isSelected =
|
||||
selectedDate && isSameDay(day, selectedDate);
|
||||
const isTodayDate = isToday(day);
|
||||
const isAvailable =
|
||||
isCurrentMonth && isDateAvailable(day);
|
||||
const isPast = isBefore(day, startOfDay(new Date()));
|
||||
|
||||
// Verifica se a data está no conjunto de datas disponíveis
|
||||
const dateStr = format(day, "yyyy-MM-dd");
|
||||
const isAvailable =
|
||||
isCurrentMonth &&
|
||||
!isPast &&
|
||||
availableDates.has(dateStr);
|
||||
const isUnavailable =
|
||||
isCurrentMonth &&
|
||||
!isPast &&
|
||||
!availableDates.has(dateStr);
|
||||
|
||||
// Debug apenas para o primeiro dia do mês atual
|
||||
if (index === 0 && isCurrentMonth) {
|
||||
console.log(
|
||||
"[AgendamentoConsulta] Debug calendário:",
|
||||
{
|
||||
totalDatasDisponiveis: availableDates.size,
|
||||
primeiraData: dateStr,
|
||||
diaDaSemana: day.getDay(),
|
||||
isAvailable,
|
||||
isUnavailable,
|
||||
datas5Primeiras: Array.from(availableDates).slice(
|
||||
0,
|
||||
5
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => isAvailable && setSelectedDate(day)}
|
||||
disabled={!isAvailable}
|
||||
className={`aspect-square p-2 text-sm border-r border-b border-gray-200 ${
|
||||
className={`aspect-square p-1 sm:p-2 text-xs sm:text-sm border-r border-b border-gray-200 transition-colors ${
|
||||
!isCurrentMonth ? "text-gray-300 bg-gray-50" : ""
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-blue-600 text-white font-bold"
|
||||
: ""
|
||||
} ${
|
||||
isTodayDate && !isSelected
|
||||
? "font-bold text-blue-600"
|
||||
isAvailable && !isSelected
|
||||
? "text-blue-600 font-semibold hover:bg-blue-50 cursor-pointer"
|
||||
: ""
|
||||
} ${
|
||||
isAvailable && !isSelected
|
||||
? "hover:bg-blue-50 cursor-pointer"
|
||||
isUnavailable
|
||||
? "text-red-600 font-semibold cursor-not-allowed"
|
||||
: ""
|
||||
} ${isPast ? "text-gray-400" : ""} ${
|
||||
!isAvailable && isCurrentMonth && !isPast
|
||||
? "text-gray-300"
|
||||
} ${
|
||||
isPast && isCurrentMonth
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
@ -524,62 +842,71 @@ export default function AgendamentoConsulta({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-xs text-gray-600">
|
||||
<p>🟢 Datas disponíveis</p>
|
||||
<p>🔴 Datas bloqueadas</p>
|
||||
<div className="mt-2 sm:mt-3 space-y-0.5 sm:space-y-1 text-xs text-gray-600">
|
||||
<p>
|
||||
<span className="text-blue-600 font-semibold">●</span>{" "}
|
||||
Datas disponíveis
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-red-600 font-semibold">●</span>{" "}
|
||||
Datas indisponíveis
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-gray-400">●</span> Datas passadas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
<label className="text-xs sm:text-sm font-medium">
|
||||
Horários Disponíveis
|
||||
</label>
|
||||
{selectedDate ? (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-1 break-words">
|
||||
{format(selectedDate, "EEEE, d 'de' MMMM 'de' yyyy", {
|
||||
locale: ptBR,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-1">
|
||||
Selecione uma data
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedDate && availableSlots.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{availableSlots.map((slot) => (
|
||||
<button
|
||||
key={slot}
|
||||
onClick={() => setSelectedTime(slot)}
|
||||
className={`flex items-center justify-center gap-1 py-2 rounded-lg border-2 transition-colors ${
|
||||
className={`flex items-center justify-center gap-1 py-2 rounded-lg border-2 transition-colors text-xs sm:text-sm ${
|
||||
selectedTime === slot
|
||||
? "border-blue-500 bg-blue-50 text-blue-600 font-medium"
|
||||
: "border-gray-300 hover:border-blue-300"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||
{slot}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : selectedDate ? (
|
||||
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||
<p className="text-gray-600">
|
||||
<div className="p-3 sm:p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||
<p className="text-xs sm:text-sm text-gray-600">
|
||||
Nenhum horário disponível para esta data
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||
<p className="text-gray-600">
|
||||
<div className="p-3 sm:p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||
<p className="text-xs sm:text-sm text-gray-600">
|
||||
Selecione uma data para ver os horários
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
<label className="text-xs sm:text-sm font-medium">
|
||||
Motivo da Consulta *
|
||||
</label>
|
||||
<textarea
|
||||
@ -587,13 +914,13 @@ export default function AgendamentoConsulta({
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
{selectedDate && selectedTime && (
|
||||
<div className="p-4 bg-blue-50 rounded-lg space-y-2">
|
||||
<h4 className="font-semibold">Resumo</h4>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div className="p-3 sm:p-4 bg-blue-50 rounded-lg space-y-2">
|
||||
<h4 className="text-sm sm:text-base font-semibold">Resumo</h4>
|
||||
<div className="space-y-1 text-xs sm:text-sm text-gray-600">
|
||||
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
|
||||
<p>⏰ Horário: {selectedTime}</p>
|
||||
<p>
|
||||
@ -611,7 +938,7 @@ export default function AgendamentoConsulta({
|
||||
<button
|
||||
onClick={handleBookAppointment}
|
||||
disabled={!selectedTime || !motivo.trim()}
|
||||
className="w-full py-3 rounded-lg font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-full py-2.5 sm:py-3 rounded-lg text-sm sm:text-base font-semibold bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Confirmar Agendamento
|
||||
</button>
|
||||
@ -621,30 +948,32 @@ export default function AgendamentoConsulta({
|
||||
)}
|
||||
{showConfirmDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="text-xl font-semibold">Confirmar Agendamento</h3>
|
||||
<p className="text-gray-600">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-4 sm:p-6 space-y-3 sm:space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg sm:text-xl font-semibold dark:text-white">
|
||||
Confirmar Agendamento
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
||||
Revise os detalhes da sua consulta antes de confirmar
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">
|
||||
<div className="h-10 w-10 sm:h-12 sm:w-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-sm sm:text-base flex-shrink-0">
|
||||
{selectedMedico?.nome
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.substring(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm sm:text-base font-medium text-gray-900 truncate">
|
||||
{selectedMedico?.nome}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-xs sm:text-sm text-gray-600 truncate">
|
||||
{selectedMedico?.especialidade}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<div className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm text-gray-600">
|
||||
<p>
|
||||
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
|
||||
</p>
|
||||
@ -658,22 +987,22 @@ export default function AgendamentoConsulta({
|
||||
{selectedMedico?.valorConsulta && (
|
||||
<p>💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}</p>
|
||||
)}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
||||
<p className="font-medium text-gray-900 mb-1">Motivo:</p>
|
||||
<p className="text-gray-600">{motivo}</p>
|
||||
<p className="text-gray-600 break-words">{motivo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 pt-3 sm:pt-4">
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors text-sm sm:text-base order-2 sm:order-1"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmAppointment}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors font-medium"
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors font-medium text-sm sm:text-base order-1 sm:order-2"
|
||||
>
|
||||
Confirmar
|
||||
</button>
|
||||
|
||||
@ -1,12 +1,26 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { MessageCircle, X, Send } from "lucide-react";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
interface Message {
|
||||
/**
|
||||
* Chatbot.tsx
|
||||
* React + TypeScript component designed for MediConnect.
|
||||
* - Floating action button (bottom-right)
|
||||
* - Modal / popup chat window
|
||||
* - Sends user messages to a backend endpoint (/api/chat) which proxies to an LLM
|
||||
* - DOES NOT send/collect any sensitive data (PHI). The frontend strips/flags sensitive fields.
|
||||
* - Configurable persona: "Assistente Virtual do MediConnect"
|
||||
*
|
||||
* Integration notes (short):
|
||||
* - Backend should be a Supabase Edge Function (or Cloudflare Worker) at /api/chat
|
||||
* - The Edge Function will contain the OpenAI (or other LLM) key and apply the system prompt.
|
||||
* - Frontend only uses a short-term session id; it never stores patient-identifying data.
|
||||
*/
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
text: string;
|
||||
sender: "user" | "bot";
|
||||
timestamp: Date;
|
||||
}
|
||||
time?: string;
|
||||
};
|
||||
|
||||
interface ChatbotProps {
|
||||
className?: string;
|
||||
@ -17,13 +31,16 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: "welcome",
|
||||
text: "Olá! Sou o assistente virtual do MediConnect. Como posso ajudá-lo hoje?",
|
||||
sender: "bot",
|
||||
timestamp: new Date(),
|
||||
role: "assistant",
|
||||
text: "Olá! 👋 Sou o Assistente Virtual do MediConnect. Estou aqui para ajudá-lo com dúvidas sobre agendamento de consultas, navegação no sistema, funcionalidades e suporte. Como posso ajudar você hoje?",
|
||||
time: new Date().toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@ -34,94 +51,82 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const quickReplies = [
|
||||
"Como agendar uma consulta?",
|
||||
"Como cancelar agendamento?",
|
||||
"Esqueci minha senha",
|
||||
"Suporte técnico",
|
||||
];
|
||||
/**
|
||||
* Sanitize user input before sending.
|
||||
* This is a basic approach. For production, you might do more thorough checks.
|
||||
*/
|
||||
function sanitizeUserMessage(text: string): string {
|
||||
// Remove potential HTML/script tags (very naive approach)
|
||||
const cleaned = text.replace(/<[^>]*>/g, "");
|
||||
// Truncate if too long
|
||||
return cleaned.slice(0, 1000);
|
||||
}
|
||||
|
||||
const getBotResponse = (userMessage: string): string => {
|
||||
const message = userMessage.toLowerCase();
|
||||
/**
|
||||
* Send message to backend /api/chat.
|
||||
* The backend returns { reply: string } in JSON.
|
||||
*/
|
||||
async function callChatApi(userText: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: userText,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
// Respostas baseadas em palavras-chave
|
||||
if (message.includes("agendar") || message.includes("marcar")) {
|
||||
return "Para agendar uma consulta:\n\n1. Acesse 'Agendar Consulta' no menu\n2. Selecione o médico desejado\n3. Escolha data e horário disponível\n4. Confirme o agendamento\n\nVocê receberá uma confirmação por e-mail!";
|
||||
if (!response.ok) {
|
||||
console.error("Chat API error:", response.status, response.statusText);
|
||||
return "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente em alguns instantes.";
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.reply || "Sem resposta do servidor.";
|
||||
} catch (error) {
|
||||
console.error("Erro ao chamar a API de chat:", error);
|
||||
return "Não foi possível conectar ao servidor. Verifique sua conexão e tente novamente.";
|
||||
}
|
||||
}
|
||||
|
||||
if (message.includes("cancelar") || message.includes("remarcar")) {
|
||||
return "Para cancelar ou remarcar uma consulta:\n\n1. Vá em 'Minhas Consultas'\n2. Localize a consulta\n3. Clique em 'Cancelar' ou 'Remarcar'\n\nRecomendamos fazer isso com 24h de antecedência para evitar taxas.";
|
||||
}
|
||||
|
||||
if (message.includes("senha") || message.includes("login")) {
|
||||
return "Para recuperar sua senha:\n\n1. Clique em 'Esqueceu a senha?' na tela de login\n2. Insira seu e-mail cadastrado\n3. Você receberá um link para redefinir a senha\n\nSe não receber o e-mail, verifique sua caixa de spam.";
|
||||
}
|
||||
|
||||
if (message.includes("pagamento") || message.includes("pagar")) {
|
||||
return "Aceitamos as seguintes formas de pagamento:\n\n• Cartão de crédito (parcelamento em até 3x)\n• Cartão de débito\n• PIX\n• Boleto bancário\n\nTodos os pagamentos são processados com segurança.";
|
||||
}
|
||||
|
||||
if (message.includes("teleconsulta") || message.includes("online")) {
|
||||
return "Para realizar uma teleconsulta:\n\n1. Acesse 'Minhas Consultas' no horário agendado\n2. Clique em 'Iniciar Consulta Online'\n3. Permita acesso à câmera e microfone\n\nCertifique-se de ter uma boa conexão de internet!";
|
||||
}
|
||||
|
||||
if (message.includes("histórico") || message.includes("prontuário")) {
|
||||
return "Seu histórico médico pode ser acessado em:\n\n• 'Meu Perfil' > 'Histórico Médico'\n• 'Minhas Consultas' (consultas anteriores)\n\nVocê pode fazer download de relatórios e receitas quando necessário.";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("suporte") ||
|
||||
message.includes("ajuda") ||
|
||||
message.includes("atendimento")
|
||||
) {
|
||||
return "Nossa equipe de suporte está disponível:\n\n📞 Telefone: 0800-123-4567\n📧 E-mail: suporte@mediconnect.com.br\n⏰ Horário: Segunda a Sexta, 8h às 18h\n\nVocê também pode acessar nossa Central de Ajuda completa no menu.";
|
||||
}
|
||||
|
||||
if (message.includes("obrigad") || message.includes("valeu")) {
|
||||
return "Por nada! Estou sempre aqui para ajudar. Se tiver mais dúvidas, é só chamar! 😊";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("oi") ||
|
||||
message.includes("olá") ||
|
||||
message.includes("hello")
|
||||
) {
|
||||
return "Olá! Como posso ajudá-lo hoje? Você pode perguntar sobre agendamentos, consultas, pagamentos ou qualquer dúvida sobre o MediConnect.";
|
||||
}
|
||||
|
||||
// Resposta padrão
|
||||
return "Desculpe, não entendi sua pergunta. Você pode:\n\n• Perguntar sobre agendamentos\n• Consultar formas de pagamento\n• Saber sobre teleconsultas\n• Acessar histórico médico\n• Falar com suporte\n\nOu visite nossa Central de Ajuda para mais informações!";
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
// Adiciona mensagem do usuário
|
||||
const sanitized = sanitizeUserMessage(inputValue);
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
text: inputValue,
|
||||
sender: "user",
|
||||
timestamp: new Date(),
|
||||
role: "user",
|
||||
text: sanitized,
|
||||
time: new Date().toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputValue("");
|
||||
setIsLoading(true);
|
||||
|
||||
// Simula digitação do bot
|
||||
setIsTyping(true);
|
||||
setTimeout(() => {
|
||||
const botResponse: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
text: getBotResponse(inputValue),
|
||||
sender: "bot",
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setMessages((prev) => [...prev, botResponse]);
|
||||
setIsTyping(false);
|
||||
}, 1000);
|
||||
};
|
||||
// Call AI backend
|
||||
const reply = await callChatApi(sanitized);
|
||||
|
||||
const handleQuickReply = (reply: string) => {
|
||||
setInputValue(reply);
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: "assistant",
|
||||
text: reply,
|
||||
time: new Date().toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
@ -131,34 +136,73 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const quickReplies = [
|
||||
"Como agendar uma consulta?",
|
||||
"Como cancelar um agendamento?",
|
||||
"Esqueci minha senha",
|
||||
"Onde vejo minhas consultas?",
|
||||
];
|
||||
|
||||
const handleQuickReply = (text: string) => {
|
||||
setInputValue(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-6 left-6 z-50 ${className}`}>
|
||||
<div className={`fixed bottom-6 left-6 z-40 ${className}`}>
|
||||
{/* Floating Button */}
|
||||
{!isOpen && (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white rounded-full p-4 shadow-lg transition-all hover:scale-110 flex items-center gap-2"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white rounded-full p-3 shadow-lg transition-all hover:scale-110 flex items-center gap-2 group"
|
||||
aria-label="Abrir chat de ajuda"
|
||||
>
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
<span className="font-medium">Precisa de ajuda?</span>
|
||||
{/* MessageCircle Icon (inline SVG) */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium hidden sm:inline">
|
||||
Precisa de ajuda?
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Chat Window */}
|
||||
{isOpen && (
|
||||
<div className="bg-white rounded-lg shadow-2xl w-96 h-[600px] flex flex-col">
|
||||
<div className="bg-white rounded-lg shadow-2xl w-96 max-w-[calc(100vw-3rem)] max-h-[75vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 rounded-t-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-white/20 rounded-full p-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
{/* MessageCircle Icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Assistente MediConnect</h3>
|
||||
<p className="text-xs text-blue-100">
|
||||
Online • Responde em segundos
|
||||
</p>
|
||||
<p className="text-xs text-blue-100">Online • AI-Powered</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@ -166,7 +210,21 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
||||
className="hover:bg-white/20 rounded-full p-1 transition"
|
||||
aria-label="Fechar chat"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
{/* X Icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -176,34 +234,33 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${
|
||||
message.sender === "user" ? "justify-end" : "justify-start"
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg p-3 ${
|
||||
message.sender === "user"
|
||||
message.role === "user"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-800 shadow"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-line">{message.text}</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
message.sender === "user"
|
||||
? "text-blue-100"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
{message.time && (
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
message.role === "user"
|
||||
? "text-blue-100"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{message.time}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isTyping && (
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-white text-gray-800 shadow rounded-lg p-3">
|
||||
<div className="flex gap-1">
|
||||
@ -260,11 +317,25 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg p-2 transition"
|
||||
aria-label="Enviar mensagem"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
{/* Send Icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
737
src/components/DisponibilidadeMedico.old.tsx
Normal file
737
src/components/DisponibilidadeMedico.old.tsx
Normal file
@ -0,0 +1,737 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Clock, Plus, Trash2, Save, Copy, Calendar as CalendarIcon, X } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { availabilityService, doctorService } from "../services/index";
|
||||
import type {
|
||||
DoctorException,
|
||||
DoctorAvailability,
|
||||
} from "../services/availability/types";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
interface TimeSlot {
|
||||
id: string;
|
||||
dbId?: string; // ID do banco de dados (se já existir)
|
||||
inicio: string;
|
||||
fim: string;
|
||||
ativo: boolean;
|
||||
slotMinutes?: number;
|
||||
appointmentType?: "presencial" | "telemedicina";
|
||||
}
|
||||
|
||||
interface DaySchedule {
|
||||
day: string;
|
||||
dayOfWeek: number;
|
||||
enabled: boolean;
|
||||
slots: TimeSlot[];
|
||||
}
|
||||
|
||||
const daysOfWeek = [
|
||||
{ key: 0, label: "Domingo", dbKey: "domingo" },
|
||||
{ key: 1, label: "Segunda-feira", dbKey: "segunda" },
|
||||
{ key: 2, label: "Terça-feira", dbKey: "terca" },
|
||||
{ key: 3, label: "Quarta-feira", dbKey: "quarta" },
|
||||
{ key: 4, label: "Quinta-feira", dbKey: "quinta" },
|
||||
{ key: 5, label: "Sexta-feira", dbKey: "sexta" },
|
||||
{ key: 6, label: "Sábado", dbKey: "sabado" },
|
||||
];
|
||||
|
||||
const DisponibilidadeMedico: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [doctorId, setDoctorId] = useState<string | null>(null);
|
||||
|
||||
const [schedule, setSchedule] = useState<Record<number, DaySchedule>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"weekly" | "blocked">("weekly");
|
||||
|
||||
// States for adding/editing slots
|
||||
const [showAddSlotDialog, setShowAddSlotDialog] = useState(false);
|
||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||
const [newSlot, setNewSlot] = useState({
|
||||
inicio: "09:00",
|
||||
fim: "10:00",
|
||||
slotMinutes: 30,
|
||||
appointmentType: "presencial" as "presencial" | "telemedicina"
|
||||
});
|
||||
|
||||
// States for blocked dates
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
||||
new Date()
|
||||
);
|
||||
const [blockedDates, setBlockedDates] = useState<Date[]>([]);
|
||||
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
|
||||
|
||||
// States for exceptions form
|
||||
const [showExceptionDialog, setShowExceptionDialog] = useState(false);
|
||||
const [exceptionForm, setExceptionForm] = useState({
|
||||
date: format(new Date(), "yyyy-MM-dd"),
|
||||
kind: "bloqueio" as "bloqueio" | "disponibilidade_extra",
|
||||
start_time: "09:00",
|
||||
end_time: "18:00",
|
||||
wholeDayBlock: true,
|
||||
reason: "",
|
||||
});
|
||||
|
||||
// Load doctor ID from doctors table
|
||||
useEffect(() => {
|
||||
const loadDoctorId = async () => {
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
const doctors = await doctorService.list({ user_id: user.id });
|
||||
if (doctors.length > 0) {
|
||||
setDoctorId(doctors[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar ID do médico:", error);
|
||||
}
|
||||
};
|
||||
loadDoctorId();
|
||||
}, [user?.id]);
|
||||
|
||||
const loadAvailability = React.useCallback(async () => {
|
||||
if (!doctorId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const availabilities = await availabilityService.list({
|
||||
doctor_id: doctorId,
|
||||
});
|
||||
|
||||
if (availabilities && availabilities.length > 0) {
|
||||
const newSchedule: Record<number, DaySchedule> = {};
|
||||
|
||||
// Inicializar todos os dias
|
||||
daysOfWeek.forEach(({ key, label }) => {
|
||||
newSchedule[key] = {
|
||||
day: label,
|
||||
dayOfWeek: key,
|
||||
enabled: false,
|
||||
slots: [],
|
||||
};
|
||||
});
|
||||
|
||||
// Agrupar disponibilidades por dia da semana
|
||||
availabilities.forEach((avail: DoctorAvailability) => {
|
||||
// avail.weekday agora é um número (0-6)
|
||||
const dayKey = avail.weekday;
|
||||
|
||||
if (!newSchedule[dayKey]) return;
|
||||
|
||||
if (!newSchedule[dayKey].enabled) {
|
||||
newSchedule[dayKey].enabled = true;
|
||||
}
|
||||
|
||||
newSchedule[dayKey].slots.push({
|
||||
id: `${dayKey}-${avail.id || Math.random().toString(36).slice(2)}`,
|
||||
dbId: avail.id, // Armazenar ID do banco
|
||||
inicio: avail.start_time?.slice(0, 5) || "09:00",
|
||||
fim: avail.end_time?.slice(0, 5) || "17:00",
|
||||
ativo: avail.active ?? true,
|
||||
});
|
||||
});
|
||||
|
||||
setSchedule(newSchedule);
|
||||
} else {
|
||||
// Initialize empty schedule
|
||||
const newSchedule: Record<number, DaySchedule> = {};
|
||||
daysOfWeek.forEach(({ key, label }) => {
|
||||
newSchedule[key] = {
|
||||
day: label,
|
||||
dayOfWeek: key,
|
||||
enabled: false,
|
||||
slots: [],
|
||||
};
|
||||
});
|
||||
setSchedule(newSchedule);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar disponibilidade:", error);
|
||||
toast.error("Erro ao carregar disponibilidade");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [doctorId]);
|
||||
|
||||
const loadExceptions = React.useCallback(async () => {
|
||||
if (!doctorId) return;
|
||||
|
||||
try {
|
||||
const exceptions = await availabilityService.listExceptions({
|
||||
doctor_id: doctorId,
|
||||
});
|
||||
setExceptions(exceptions);
|
||||
const blocked = exceptions
|
||||
.filter((exc: DoctorException) => exc.kind === "bloqueio" && exc.date)
|
||||
.map((exc: DoctorException) => new Date(exc.date!));
|
||||
setBlockedDates(blocked);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar exceções:", error);
|
||||
}
|
||||
}, [doctorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (doctorId) {
|
||||
loadAvailability();
|
||||
loadExceptions();
|
||||
}
|
||||
}, [doctorId, loadAvailability, loadExceptions]);
|
||||
|
||||
const toggleDay = (dayKey: number) => {
|
||||
setSchedule((prev) => ({
|
||||
...prev,
|
||||
[dayKey]: {
|
||||
...prev[dayKey],
|
||||
enabled: !prev[dayKey].enabled,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const addTimeSlot = () => {
|
||||
if (selectedDay !== null) {
|
||||
const newSlotId = `${selectedDay}-${Date.now()}`;
|
||||
setSchedule((prev) => ({
|
||||
...prev,
|
||||
[selectedDay]: {
|
||||
...prev[selectedDay],
|
||||
slots: [
|
||||
...prev[selectedDay].slots,
|
||||
{
|
||||
id: newSlotId,
|
||||
inicio: newSlot.inicio,
|
||||
fim: newSlot.fim,
|
||||
ativo: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
setShowAddSlotDialog(false);
|
||||
setNewSlot({ inicio: "09:00", fim: "10:00", slotMinutes: 30, appointmentType: "presencial" });
|
||||
setSelectedDay(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTimeSlot = async (dayKey: number, slotId: string) => {
|
||||
const slot = schedule[dayKey]?.slots.find((s) => s.id === slotId);
|
||||
|
||||
// Se o slot tem um ID do banco, deletar imediatamente
|
||||
if (slot?.dbId) {
|
||||
try {
|
||||
await availabilityService.delete(slot.dbId);
|
||||
toast.success("Horário removido com sucesso");
|
||||
} catch (error) {
|
||||
console.error("Erro ao remover horário:", error);
|
||||
toast.error("Erro ao remover horário");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar o estado local
|
||||
setSchedule((prev) => ({
|
||||
...prev,
|
||||
[dayKey]: {
|
||||
...prev[dayKey],
|
||||
slots: prev[dayKey].slots.filter((slot) => slot.id !== slotId),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleSlotAvailability = (dayKey: number, slotId: string) => {
|
||||
setSchedule((prev) => ({
|
||||
...prev,
|
||||
[dayKey]: {
|
||||
...prev[dayKey],
|
||||
slots: prev[dayKey].slots.map((slot) =>
|
||||
slot.id === slotId ? { ...slot, ativo: !slot.ativo } : slot
|
||||
),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const copySchedule = (fromDay: number) => {
|
||||
const sourceSchedule = schedule[fromDay];
|
||||
if (!sourceSchedule.enabled || sourceSchedule.slots.length === 0) {
|
||||
toast.error("Dia não tem horários configurados");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSchedule = { ...schedule };
|
||||
Object.keys(updatedSchedule).forEach((key) => {
|
||||
const dayKey = Number(key);
|
||||
if (dayKey !== fromDay && updatedSchedule[dayKey].enabled) {
|
||||
updatedSchedule[dayKey].slots = sourceSchedule.slots.map((slot) => ({
|
||||
...slot,
|
||||
id: `${dayKey}-${slot.id}`,
|
||||
}));
|
||||
}
|
||||
});
|
||||
setSchedule(updatedSchedule);
|
||||
toast.success("Horários copiados com sucesso!");
|
||||
};
|
||||
|
||||
const handleSaveSchedule = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
if (!doctorId) {
|
||||
toast.error("Médico não autenticado");
|
||||
return;
|
||||
}
|
||||
|
||||
const requests: Array<Promise<unknown>> = [];
|
||||
|
||||
const timeToMinutes = (t: string) => {
|
||||
const [hStr, mStr] = t.split(":");
|
||||
const h = Number(hStr || "0");
|
||||
const m = Number(mStr || "0");
|
||||
return h * 60 + m;
|
||||
};
|
||||
|
||||
// Para cada dia, processar slots
|
||||
daysOfWeek.forEach(({ key }) => {
|
||||
const daySchedule = schedule[key];
|
||||
|
||||
if (!daySchedule || !daySchedule.enabled) {
|
||||
// Se o dia foi desabilitado, deletar todos os slots existentes
|
||||
daySchedule?.slots.forEach((slot) => {
|
||||
if (slot.dbId) {
|
||||
requests.push(availabilityService.delete(slot.dbId));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Processar cada slot do dia
|
||||
daySchedule.slots.forEach((slot) => {
|
||||
const inicio = slot.inicio
|
||||
? slot.inicio.length === 5
|
||||
? `${slot.inicio}:00`
|
||||
: slot.inicio
|
||||
: "00:00:00";
|
||||
const fim = slot.fim
|
||||
? slot.fim.length === 5
|
||||
? `${slot.fim}:00`
|
||||
: slot.fim
|
||||
: "00:00:00";
|
||||
const minutes = Math.max(
|
||||
1,
|
||||
timeToMinutes(fim.slice(0, 5)) - timeToMinutes(inicio.slice(0, 5))
|
||||
);
|
||||
|
||||
const payload = {
|
||||
weekday: key, // Agora usa número (0-6) ao invés de string
|
||||
start_time: inicio.slice(0, 5), // HH:MM ao invés de HH:MM:SS
|
||||
end_time: fim.slice(0, 5), // HH:MM ao invés de HH:MM:SS
|
||||
slot_minutes: minutes,
|
||||
appointment_type: "presencial" as const,
|
||||
active: !!slot.ativo,
|
||||
};
|
||||
|
||||
if (slot.dbId) {
|
||||
// Atualizar slot existente
|
||||
requests.push(availabilityService.update(slot.dbId, payload as any));
|
||||
} else {
|
||||
// Criar novo slot
|
||||
requests.push(
|
||||
availabilityService.create({
|
||||
doctor_id: doctorId,
|
||||
...payload,
|
||||
} as any)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (requests.length === 0) {
|
||||
toast.error("Nenhuma alteração para salvar");
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(requests);
|
||||
const errors: string[] = [];
|
||||
let successCount = 0;
|
||||
results.forEach((r, idx) => {
|
||||
if (r.status === "fulfilled") {
|
||||
const val = r.value as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (val && val.success) successCount++;
|
||||
else
|
||||
errors.push(`Item ${idx}: ${val?.error || val?.message || "Erro"}`);
|
||||
} else {
|
||||
errors.push(`Item ${idx}: ${r.reason?.message || String(r.reason)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("Erros ao salvar disponibilidades:", errors);
|
||||
toast.error(
|
||||
`Algumas disponibilidades não foram salvas (${errors.length})`
|
||||
);
|
||||
}
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} alteração(ões) salvas com sucesso!`);
|
||||
await loadAvailability();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao salvar disponibilidade:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao salvar disponibilidade";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBlockedDate = async () => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
const dateString = format(selectedDate, "yyyy-MM-dd");
|
||||
const dateExists = blockedDates.some(
|
||||
(d) => format(d, "yyyy-MM-dd") === dateString
|
||||
);
|
||||
|
||||
try {
|
||||
if (dateExists) {
|
||||
// Remove block
|
||||
const exception = exceptions.find(
|
||||
(exc) =>
|
||||
exc.date && format(new Date(exc.date), "yyyy-MM-dd") === dateString
|
||||
);
|
||||
if (exception && exception.id) {
|
||||
await availabilityService.deleteException(exception.id);
|
||||
setBlockedDates(
|
||||
blockedDates.filter((d) => format(d, "yyyy-MM-dd") !== dateString)
|
||||
);
|
||||
toast.success("Data desbloqueada");
|
||||
}
|
||||
} else {
|
||||
// Add block
|
||||
await availabilityService.createException({
|
||||
doctor_id: doctorId!,
|
||||
date: dateString,
|
||||
kind: "bloqueio",
|
||||
reason: "Data bloqueada pelo médico",
|
||||
created_by: user?.id || doctorId!,
|
||||
});
|
||||
setBlockedDates([...blockedDates, selectedDate]);
|
||||
toast.success("Data bloqueada");
|
||||
}
|
||||
loadExceptions();
|
||||
} catch (error) {
|
||||
console.error("Erro ao alternar bloqueio de data:", error);
|
||||
toast.error("Erro ao bloquear/desbloquear data");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Gerenciar Disponibilidade
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Configure seus horários de atendimento
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveSchedule}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? "Salvando..." : "Salvar Alterações"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab("weekly")}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === "weekly"
|
||||
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
Horário Semanal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("blocked")}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === "blocked"
|
||||
? "border-indigo-500 text-indigo-600 dark:text-indigo-400"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
Exceções ({exceptions.length})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content - Weekly Schedule */}
|
||||
{activeTab === "weekly" && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Horários por Dia da Semana
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Defina seus horários de atendimento para cada dia da semana
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{daysOfWeek.map(({ key, label }) => (
|
||||
<div key={key} className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={schedule[key]?.enabled || false}
|
||||
onChange={() => toggleDay(key)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
<span className="text-gray-900 dark:text-white font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{schedule[key]?.enabled && (
|
||||
<span className="px-2 py-1 bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 text-xs rounded">
|
||||
{schedule[key]?.slots.length || 0} horário(s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{schedule[key]?.enabled && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => copySchedule(key)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copiar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDay(key);
|
||||
setShowAddSlotDialog(true);
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Adicionar Horário
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{schedule[key]?.enabled && (
|
||||
<div className="ml-14 space-y-2">
|
||||
{schedule[key]?.slots.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Nenhum horário configurado
|
||||
</p>
|
||||
) : (
|
||||
schedule[key]?.slots.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={slot.ativo}
|
||||
onChange={() =>
|
||||
toggleSlotAvailability(key, slot.id)
|
||||
}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 dark:peer-focus:ring-indigo-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
<Clock className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{slot.inicio} - {slot.fim}
|
||||
</span>
|
||||
{!slot.ativo && (
|
||||
<span className="px-2 py-1 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 text-xs rounded">
|
||||
Bloqueado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeTimeSlot(key, slot.id)}
|
||||
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Content - Blocked Dates */}
|
||||
{activeTab === "blocked" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Selecionar Datas
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Clique em uma data no calendário e depois no botão para
|
||||
bloquear/desbloquear
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate ? format(selectedDate, "yyyy-MM-dd") : ""}
|
||||
onChange={(e) => setSelectedDate(new Date(e.target.value))}
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
onClick={toggleBlockedDate}
|
||||
disabled={!selectedDate}
|
||||
className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{selectedDate &&
|
||||
blockedDates.some(
|
||||
(d) =>
|
||||
format(d, "yyyy-MM-dd") ===
|
||||
format(selectedDate, "yyyy-MM-dd")
|
||||
)
|
||||
? "Desbloquear Data"
|
||||
: "Bloquear Data"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Datas Bloqueadas
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{blockedDates.length} data(s) bloqueada(s)
|
||||
</p>
|
||||
{blockedDates.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
Nenhuma data bloqueada
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{blockedDates.map((date, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{format(date, "EEEE, dd 'de' MMMM 'de' yyyy", {
|
||||
locale: ptBR,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDate(date);
|
||||
toggleBlockedDate();
|
||||
}}
|
||||
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Time Slot Dialog */}
|
||||
{showAddSlotDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Adicionar Horário
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Defina o período de atendimento para{" "}
|
||||
{selectedDay !== null ? schedule[selectedDay]?.day : ""}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Horário de Início
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newSlot.inicio}
|
||||
onChange={(e) =>
|
||||
setNewSlot({ ...newSlot, inicio: e.target.value })
|
||||
}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Horário de Término
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={newSlot.fim}
|
||||
onChange={(e) =>
|
||||
setNewSlot({ ...newSlot, fim: e.target.value })
|
||||
}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddSlotDialog(false);
|
||||
setSelectedDay(null);
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={addTimeSlot}
|
||||
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DisponibilidadeMedico;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { appointmentService } from "../../services";
|
||||
import { appointmentService, availabilityService } from "../../services";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Props {
|
||||
doctorId: string;
|
||||
@ -20,38 +21,129 @@ const AvailableSlotsPicker: React.FC<Props> = ({
|
||||
async function fetchSlots() {
|
||||
if (!doctorId || !date) return;
|
||||
|
||||
console.log("🔍 [AvailableSlotsPicker] Buscando slots:", {
|
||||
console.log("🔍 [AvailableSlotsPicker] Calculando slots localmente:", {
|
||||
doctorId,
|
||||
date,
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await appointmentService.getAvailableSlots({
|
||||
// Busca a disponibilidade do médico
|
||||
const availabilities = await availabilityService.list({
|
||||
doctor_id: doctorId,
|
||||
date: date,
|
||||
active: true,
|
||||
});
|
||||
|
||||
console.log("📅 [AvailableSlotsPicker] Resposta da API:", res);
|
||||
console.log(
|
||||
"📅 [AvailableSlotsPicker] Disponibilidades:",
|
||||
availabilities
|
||||
);
|
||||
|
||||
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
|
||||
if (!availabilities || availabilities.length === 0) {
|
||||
console.warn(
|
||||
"[AvailableSlotsPicker] Nenhuma disponibilidade configurada"
|
||||
);
|
||||
toast.error("Erro ao processar horários disponíveis");
|
||||
setSlots([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pega o dia da semana da data selecionada
|
||||
const selectedDate = new Date(`${date}T00:00:00`);
|
||||
const dayOfWeek = selectedDate.getDay(); // 0-6
|
||||
|
||||
console.log("[AvailableSlotsPicker] Dia da semana:", dayOfWeek);
|
||||
|
||||
// Filtra disponibilidades para o dia da semana
|
||||
const dayAvailability = availabilities.filter(
|
||||
(avail) => avail.weekday === dayOfWeek && avail.active
|
||||
);
|
||||
|
||||
console.log(
|
||||
"[AvailableSlotsPicker] Disponibilidades para o dia:",
|
||||
dayAvailability
|
||||
);
|
||||
|
||||
if (dayAvailability.length === 0) {
|
||||
console.warn(
|
||||
"[AvailableSlotsPicker] Médico não atende neste dia da semana"
|
||||
);
|
||||
setSlots([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gera slots para cada disponibilidade
|
||||
const allSlots: string[] = [];
|
||||
for (const avail of dayAvailability) {
|
||||
const startTime = avail.start_time; // "08:00"
|
||||
const endTime = avail.end_time; // "18:00"
|
||||
const slotMinutes = avail.slot_minutes || 30;
|
||||
|
||||
// Converte para minutos desde meia-noite
|
||||
const [startHour, startMin] = startTime.split(":").map(Number);
|
||||
const [endHour, endMin] = endTime.split(":").map(Number);
|
||||
|
||||
let currentMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
|
||||
while (currentMinutes < endMinutes) {
|
||||
const hours = Math.floor(currentMinutes / 60);
|
||||
const minutes = currentMinutes % 60;
|
||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
allSlots.push(timeStr);
|
||||
currentMinutes += slotMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
// Busca agendamentos existentes para esta data
|
||||
const appointments = await appointmentService.list({
|
||||
doctor_id: doctorId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[AvailableSlotsPicker] Agendamentos existentes:",
|
||||
appointments
|
||||
);
|
||||
|
||||
// Filtra agendamentos para a data selecionada
|
||||
const bookedSlots = (Array.isArray(appointments) ? appointments : [])
|
||||
.filter((apt) => {
|
||||
if (!apt.scheduled_at) return false;
|
||||
const aptDate = new Date(apt.scheduled_at);
|
||||
return (
|
||||
format(aptDate, "yyyy-MM-dd") === date &&
|
||||
apt.status !== "cancelled" &&
|
||||
apt.status !== "no_show"
|
||||
);
|
||||
})
|
||||
.map((apt) => {
|
||||
const aptDate = new Date(apt.scheduled_at);
|
||||
return format(aptDate, "HH:mm");
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[AvailableSlotsPicker] Horários já ocupados:",
|
||||
bookedSlots
|
||||
);
|
||||
|
||||
// Remove slots já ocupados
|
||||
const availableSlots = allSlots.filter(
|
||||
(slot) => !bookedSlots.includes(slot)
|
||||
);
|
||||
|
||||
console.log(
|
||||
"✅ [AvailableSlotsPicker] Horários disponíveis:",
|
||||
availableSlots
|
||||
);
|
||||
setSlots(availableSlots);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("❌ [AvailableSlotsPicker] Erro ao buscar slots:", error);
|
||||
setLoading(false);
|
||||
toast.error("Erro ao buscar horários disponíveis");
|
||||
toast.error("Erro ao calcular horários disponíveis");
|
||||
}
|
||||
}
|
||||
void fetchSlots();
|
||||
|
||||
383
src/components/agenda/CalendarPicker.tsx
Normal file
383
src/components/agenda/CalendarPicker.tsx
Normal file
@ -0,0 +1,383 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isBefore,
|
||||
startOfDay,
|
||||
addMonths,
|
||||
subMonths,
|
||||
getDay,
|
||||
} from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { availabilityService, appointmentService } from "../../services";
|
||||
import type { DoctorAvailability, DoctorException } from "../../services";
|
||||
|
||||
interface CalendarPickerProps {
|
||||
doctorId: string;
|
||||
selectedDate?: string;
|
||||
onSelectDate: (date: string) => void;
|
||||
}
|
||||
|
||||
interface DayStatus {
|
||||
date: Date;
|
||||
available: boolean; // Tem horários disponíveis
|
||||
hasAvailability: boolean; // Médico trabalha neste dia da semana
|
||||
hasBlockException: boolean; // Dia bloqueado por exceção
|
||||
isPast: boolean; // Data já passou
|
||||
}
|
||||
|
||||
export function CalendarPicker({
|
||||
doctorId,
|
||||
selectedDate,
|
||||
onSelectDate,
|
||||
}: CalendarPickerProps) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [availabilities, setAvailabilities] = useState<DoctorAvailability[]>(
|
||||
[]
|
||||
);
|
||||
const [exceptions, setExceptions] = useState<DoctorException[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [availableSlots, setAvailableSlots] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
// Carregar disponibilidades e exceções do médico
|
||||
useEffect(() => {
|
||||
if (!doctorId) return;
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [availData, exceptData] = await Promise.all([
|
||||
availabilityService.list({ doctor_id: doctorId, active: true }),
|
||||
availabilityService.listExceptions({ doctor_id: doctorId }),
|
||||
]);
|
||||
|
||||
setAvailabilities(Array.isArray(availData) ? availData : []);
|
||||
setExceptions(Array.isArray(exceptData) ? exceptData : []);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar dados do calendário:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [doctorId, currentMonth]);
|
||||
|
||||
// Calcular disponibilidade de slots localmente (sem chamar Edge Function)
|
||||
useEffect(() => {
|
||||
if (!doctorId || availabilities.length === 0) return;
|
||||
|
||||
const checkAvailableSlots = async () => {
|
||||
const start = startOfMonth(currentMonth);
|
||||
const end = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start, end });
|
||||
|
||||
const slotsMap: Record<string, boolean> = {};
|
||||
|
||||
// Verificar apenas dias futuros que têm configuração de disponibilidade
|
||||
const today = startOfDay(new Date());
|
||||
const daysToCheck = days.filter((day) => {
|
||||
const dayOfWeek = getDay(day); // 0-6
|
||||
const hasConfig = availabilities.some((a) => a.weekday === dayOfWeek);
|
||||
return !isBefore(day, today) && hasConfig;
|
||||
});
|
||||
|
||||
// Buscar todos os agendamentos do médico uma vez só
|
||||
let allAppointments: Array<{ scheduled_at: string; status: string }> = [];
|
||||
try {
|
||||
const appointments = await appointmentService.list({
|
||||
doctor_id: doctorId,
|
||||
});
|
||||
allAppointments = Array.isArray(appointments) ? appointments : [];
|
||||
} catch (error) {
|
||||
console.error("[CalendarPicker] Erro ao buscar agendamentos:", error);
|
||||
}
|
||||
|
||||
// Calcular slots para cada dia
|
||||
for (const day of daysToCheck) {
|
||||
try {
|
||||
const dateStr = format(day, "yyyy-MM-dd");
|
||||
const dayOfWeek = getDay(day);
|
||||
|
||||
// Filtra disponibilidades para o dia da semana
|
||||
const dayAvailability = availabilities.filter(
|
||||
(avail) => avail.weekday === dayOfWeek && avail.active
|
||||
);
|
||||
|
||||
if (dayAvailability.length === 0) {
|
||||
slotsMap[dateStr] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verifica se há exceção de bloqueio
|
||||
const hasBlockException = exceptions.some(
|
||||
(exc) => exc.date === dateStr && exc.kind === "bloqueio"
|
||||
);
|
||||
|
||||
if (hasBlockException) {
|
||||
slotsMap[dateStr] = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gera todos os slots possíveis
|
||||
const allSlots: string[] = [];
|
||||
for (const avail of dayAvailability) {
|
||||
const startTime = avail.start_time;
|
||||
const endTime = avail.end_time;
|
||||
const slotMinutes = avail.slot_minutes || 30;
|
||||
|
||||
const [startHour, startMin] = startTime.split(":").map(Number);
|
||||
const [endHour, endMin] = endTime.split(":").map(Number);
|
||||
|
||||
let currentMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
|
||||
while (currentMinutes < endMinutes) {
|
||||
const hours = Math.floor(currentMinutes / 60);
|
||||
const minutes = currentMinutes % 60;
|
||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
allSlots.push(timeStr);
|
||||
currentMinutes += slotMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtra agendamentos já ocupados para esta data
|
||||
const bookedSlots = allAppointments
|
||||
.filter((apt) => {
|
||||
if (!apt.scheduled_at) return false;
|
||||
const aptDate = new Date(apt.scheduled_at);
|
||||
return (
|
||||
format(aptDate, "yyyy-MM-dd") === dateStr &&
|
||||
apt.status !== "cancelled" &&
|
||||
apt.status !== "no_show"
|
||||
);
|
||||
})
|
||||
.map((apt) => {
|
||||
const aptDate = new Date(apt.scheduled_at);
|
||||
return format(aptDate, "HH:mm");
|
||||
});
|
||||
|
||||
// Verifica se há pelo menos um slot disponível
|
||||
const availableSlots = allSlots.filter(
|
||||
(slot) => !bookedSlots.includes(slot)
|
||||
);
|
||||
slotsMap[dateStr] = availableSlots.length > 0;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[CalendarPicker] Erro ao verificar slots para ${format(
|
||||
day,
|
||||
"yyyy-MM-dd"
|
||||
)}:`,
|
||||
error
|
||||
);
|
||||
slotsMap[format(day, "yyyy-MM-dd")] = false;
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableSlots(slotsMap);
|
||||
};
|
||||
|
||||
checkAvailableSlots();
|
||||
}, [doctorId, currentMonth, availabilities, exceptions]);
|
||||
|
||||
const getDayStatus = (date: Date): DayStatus => {
|
||||
const today = startOfDay(new Date());
|
||||
const isPast = isBefore(date, today);
|
||||
const dayOfWeek = getDay(date); // 0-6 (domingo-sábado)
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
|
||||
// Verifica se há exceção de bloqueio para este dia
|
||||
const hasBlockException = exceptions.some(
|
||||
(exc) => exc.date === dateStr && exc.kind === "bloqueio"
|
||||
);
|
||||
|
||||
// Verifica se médico trabalha neste dia da semana
|
||||
const hasAvailability = availabilities.some((a) => a.weekday === dayOfWeek);
|
||||
|
||||
// Verifica se há slots disponíveis (baseado na verificação assíncrona)
|
||||
const available = availableSlots[dateStr] === true;
|
||||
|
||||
return {
|
||||
date,
|
||||
available,
|
||||
hasAvailability,
|
||||
hasBlockException,
|
||||
isPast,
|
||||
};
|
||||
};
|
||||
|
||||
const getDayClasses = (status: DayStatus, isSelected: boolean): string => {
|
||||
const base =
|
||||
"w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-colors";
|
||||
|
||||
if (isSelected) {
|
||||
return `${base} bg-blue-600 text-white ring-2 ring-blue-400`;
|
||||
}
|
||||
|
||||
if (status.isPast) {
|
||||
return `${base} bg-gray-100 text-gray-400 cursor-not-allowed`;
|
||||
}
|
||||
|
||||
if (status.hasBlockException) {
|
||||
return `${base} bg-red-100 text-red-700 cursor-not-allowed`;
|
||||
}
|
||||
|
||||
if (status.available) {
|
||||
return `${base} bg-blue-100 text-blue-700 hover:bg-blue-200 cursor-pointer`;
|
||||
}
|
||||
|
||||
if (status.hasAvailability) {
|
||||
return `${base} bg-gray-50 text-gray-600 hover:bg-gray-100 cursor-pointer`;
|
||||
}
|
||||
|
||||
return `${base} bg-white text-gray-400 cursor-not-allowed`;
|
||||
};
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
setCurrentMonth(subMonths(currentMonth, 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setCurrentMonth(addMonths(currentMonth, 1));
|
||||
};
|
||||
|
||||
const handleDayClick = (date: Date, status: DayStatus) => {
|
||||
if (status.isPast || status.hasBlockException) return;
|
||||
if (!status.hasAvailability && !status.available) return;
|
||||
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
onSelectDate(dateStr);
|
||||
};
|
||||
|
||||
const renderCalendar = () => {
|
||||
const start = startOfMonth(currentMonth);
|
||||
const end = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start, end });
|
||||
|
||||
// Preencher dias do início (para alinhar o primeiro dia da semana)
|
||||
const startDayOfWeek = getDay(start);
|
||||
const emptyDays = Array(startDayOfWeek).fill(null);
|
||||
|
||||
const allDays = [...emptyDays, ...days];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Cabeçalho dos dias da semana */}
|
||||
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-xs font-semibold text-gray-600 py-2"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Dias do mês */}
|
||||
{allDays.map((day, index) => {
|
||||
if (!day) {
|
||||
return <div key={`empty-${index}`} className="w-10 h-10" />;
|
||||
}
|
||||
|
||||
const status = getDayStatus(day);
|
||||
const isSelected = selectedDate === format(day, "yyyy-MM-dd");
|
||||
const classes = getDayClasses(status, isSelected);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={format(day, "yyyy-MM-dd")}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDayClick(day, status)}
|
||||
disabled={
|
||||
status.isPast ||
|
||||
status.hasBlockException ||
|
||||
(!status.hasAvailability && !status.available)
|
||||
}
|
||||
className={classes}
|
||||
title={
|
||||
status.isPast
|
||||
? "Data passada"
|
||||
: status.hasBlockException
|
||||
? "Dia bloqueado"
|
||||
: status.available
|
||||
? "Horários disponíveis"
|
||||
: status.hasAvailability
|
||||
? "Verificando disponibilidade..."
|
||||
: "Médico não trabalha neste dia"
|
||||
}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
{/* Navegação do mês */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-800 capitalize">
|
||||
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextMonth}
|
||||
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderCalendar()}
|
||||
|
||||
{/* Legenda */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-blue-100"></div>
|
||||
<span className="text-gray-600">Disponível</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-red-100"></div>
|
||||
<span className="text-gray-600">Bloqueado</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gray-100"></div>
|
||||
<span className="text-gray-600">Data passada</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gray-50"></div>
|
||||
<span className="text-gray-600">Sem horários</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -245,7 +245,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
||||
const p = patients.find((px) => px.id === e.target.value);
|
||||
setSelectedPatientName(p?.full_name || "");
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">-- Selecione um paciente --</option>
|
||||
@ -277,7 +277,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
||||
value={selectedDoctorId}
|
||||
onChange={(e) => setSelectedDoctorId(e.target.value)}
|
||||
ref={firstFieldRef}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">-- Selecione um médico --</option>
|
||||
@ -311,7 +311,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
||||
setSelectedTime(""); // Limpa o horário ao mudar a data
|
||||
}}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
|
||||
@ -335,7 +335,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
||||
e.target.value as "presencial" | "telemedicina"
|
||||
)
|
||||
}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="presencial">Presencial</option>
|
||||
@ -377,7 +377,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40 transition-colors"
|
||||
className="form-input"
|
||||
placeholder="Ex: Consulta de rotina, dor de cabeça..."
|
||||
/>
|
||||
</div>
|
||||
@ -420,3 +420,5 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
export default ScheduleAppointmentModal;
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
type Doctor,
|
||||
} from "../../services";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
import { CalendarPicker } from "../agenda/CalendarPicker";
|
||||
import AvailableSlotsPicker from "../agenda/AvailableSlotsPicker";
|
||||
|
||||
// Type aliases para compatibilidade com código antigo
|
||||
type Consulta = Appointment & {
|
||||
@ -57,11 +59,12 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
||||
|
||||
const [pacienteId, setPacienteId] = useState("");
|
||||
const [medicoId, setMedicoId] = useState("");
|
||||
const [dataHora, setDataHora] = useState(""); // value for datetime-local
|
||||
const [selectedDate, setSelectedDate] = useState<string>("");
|
||||
const [selectedTime, setSelectedTime] = useState<string>("");
|
||||
const [tipo, setTipo] = useState("");
|
||||
const [motivo, setMotivo] = useState("");
|
||||
const [observacoes, setObservacoes] = useState("");
|
||||
const [status, setStatus] = useState<string>("agendada");
|
||||
const [status, setStatus] = useState<string>("requested");
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -93,30 +96,34 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (editing) {
|
||||
setPacienteId(editing.pacienteId);
|
||||
setMedicoId(editing.medicoId);
|
||||
// Convert ISO to local datetime-local value
|
||||
setPacienteId(editing.patient_id || "");
|
||||
setMedicoId(editing.doctor_id || "");
|
||||
// Convert ISO to date and time
|
||||
try {
|
||||
const d = new Date(editing.dataHora);
|
||||
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
setDataHora(local);
|
||||
const d = new Date(editing.scheduled_at);
|
||||
const dateStr = d.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
const timeStr = `${d.getHours().toString().padStart(2, "0")}:${d
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
setSelectedDate(dateStr);
|
||||
setSelectedTime(timeStr);
|
||||
} catch {
|
||||
setDataHora("");
|
||||
setSelectedDate("");
|
||||
setSelectedTime("");
|
||||
}
|
||||
setTipo(editing.tipo || "");
|
||||
setMotivo(editing.motivo || "");
|
||||
setObservacoes(editing.observacoes || "");
|
||||
setStatus(editing.status || "agendada");
|
||||
setTipo(editing.appointment_type || "");
|
||||
setObservacoes(editing.notes || "");
|
||||
setStatus(editing.status || "requested");
|
||||
} else {
|
||||
setPacienteId(defaultPacienteId || "");
|
||||
setMedicoId(defaultMedicoId || "");
|
||||
setDataHora("");
|
||||
setSelectedDate("");
|
||||
setSelectedTime("");
|
||||
setTipo("");
|
||||
setMotivo("");
|
||||
setObservacoes("");
|
||||
setStatus("agendada");
|
||||
setStatus("requested");
|
||||
}
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
@ -146,8 +153,8 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
||||
setError("Selecione um médico.");
|
||||
return false;
|
||||
}
|
||||
if (!dataHora) {
|
||||
setError("Informe data e hora.");
|
||||
if (!selectedDate || !selectedTime) {
|
||||
setError("Selecione data e horário da consulta.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@ -159,35 +166,40 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Convert local datetime back to ISO
|
||||
const iso = new Date(dataHora).toISOString();
|
||||
// Combinar data e horário no formato ISO
|
||||
const datetime = `${selectedDate}T${selectedTime}:00`;
|
||||
const iso = new Date(datetime).toISOString();
|
||||
|
||||
if (editing) {
|
||||
const payload: ConsultaUpdate = {
|
||||
dataHora: iso,
|
||||
tipo: tipo || undefined,
|
||||
motivo: motivo || undefined,
|
||||
observacoes: observacoes || undefined,
|
||||
status: status,
|
||||
const payload = {
|
||||
scheduled_at: iso,
|
||||
appointment_type: (tipo || "presencial") as
|
||||
| "presencial"
|
||||
| "telemedicina",
|
||||
notes: observacoes || undefined,
|
||||
status: status as
|
||||
| "requested"
|
||||
| "confirmed"
|
||||
| "checked_in"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "no_show",
|
||||
};
|
||||
const resp = await consultasService.atualizar(editing.id, payload);
|
||||
if (!resp.success || !resp.data) {
|
||||
throw new Error(resp.error || "Falha ao atualizar consulta");
|
||||
}
|
||||
onSaved(resp.data);
|
||||
const updated = await appointmentService.update(editing.id, payload);
|
||||
onSaved(updated);
|
||||
} else {
|
||||
const payload: ConsultaCreate = {
|
||||
pacienteId,
|
||||
medicoId,
|
||||
dataHora: iso,
|
||||
tipo: tipo || undefined,
|
||||
motivo: motivo || undefined,
|
||||
observacoes: observacoes || undefined,
|
||||
const payload = {
|
||||
patient_id: pacienteId,
|
||||
doctor_id: medicoId,
|
||||
scheduled_at: iso,
|
||||
appointment_type: (tipo || "presencial") as
|
||||
| "presencial"
|
||||
| "telemedicina",
|
||||
notes: observacoes || undefined,
|
||||
};
|
||||
const resp = await consultasService.criar(payload);
|
||||
if (!resp.success || !resp.data) {
|
||||
throw new Error(resp.error || "Falha ao criar consulta");
|
||||
}
|
||||
onSaved(resp.data);
|
||||
const created = await appointmentService.create(payload);
|
||||
onSaved(created);
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@ -232,7 +244,7 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
||||
<option value="">Selecione...</option>
|
||||
{pacientes.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nome}
|
||||
{p.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -250,21 +262,53 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
||||
<option value="">Selecione...</option>
|
||||
{medicos.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.nome} - {m.especialidade}
|
||||
{m.full_name} - {m.specialty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Data / Hora
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full border rounded px-2 py-2 text-sm"
|
||||
value={dataHora}
|
||||
onChange={(e) => setDataHora(e.target.value)}
|
||||
/>
|
||||
{/* Calendário Visual */}
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Data da Consulta *
|
||||
</label>
|
||||
{medicoId ? (
|
||||
<CalendarPicker
|
||||
doctorId={medicoId}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={(date) => {
|
||||
setSelectedDate(date);
|
||||
setSelectedTime(""); // Resetar horário ao mudar data
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center text-gray-500 text-sm">
|
||||
Selecione um médico primeiro para ver a disponibilidade
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Seletor de Horários */}
|
||||
{selectedDate && medicoId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Horário *{" "}
|
||||
{selectedTime && (
|
||||
<span className="text-blue-600 font-semibold">
|
||||
({selectedTime})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<AvailableSlotsPicker
|
||||
doctorId={medicoId}
|
||||
date={selectedDate}
|
||||
onSelect={(time) => {
|
||||
setSelectedTime(time);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
|
||||
@ -132,7 +132,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.nome}
|
||||
onChange={(e) => onChange({ nome: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="Digite o nome completo"
|
||||
autoComplete="name"
|
||||
@ -150,7 +150,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.social_name}
|
||||
onChange={(e) => onChange({ social_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
placeholder="Opcional"
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
@ -169,7 +169,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.rg || ""}
|
||||
onChange={(e) => onChange({ rg: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
placeholder="RG"
|
||||
/>
|
||||
</div>
|
||||
@ -184,7 +184,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
id="estado_civil"
|
||||
value={data.estado_civil || ""}
|
||||
onChange={(e) => onChange({ estado_civil: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
<option value="solteiro(a)">Solteiro(a)</option>
|
||||
@ -206,7 +206,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.profissao || ""}
|
||||
onChange={(e) => onChange({ profissao: 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="form-input"
|
||||
placeholder="Profissão"
|
||||
autoComplete="organization-title"
|
||||
/>
|
||||
@ -258,7 +258,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
id="sexo"
|
||||
value={data.sexo}
|
||||
onChange={(e) => onChange({ sexo: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
@ -279,7 +279,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="date"
|
||||
value={data.dataNascimento}
|
||||
onChange={(e) => onChange({ dataNascimento: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
autoComplete="bday"
|
||||
/>
|
||||
@ -358,7 +358,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={(e) => onChange({ email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="contato@paciente.com"
|
||||
autoComplete="email"
|
||||
@ -377,7 +377,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
<select
|
||||
value={data.tipo_sanguineo}
|
||||
onChange={(e) => onChange({ tipo_sanguineo: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
{bloodTypes.map((tipo) => (
|
||||
@ -398,7 +398,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
step="0.1"
|
||||
value={data.altura}
|
||||
onChange={(e) => onChange({ altura: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
placeholder="170"
|
||||
/>
|
||||
</div>
|
||||
@ -413,7 +413,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
step="0.1"
|
||||
value={data.peso}
|
||||
onChange={(e) => onChange({ peso: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
placeholder="70.5"
|
||||
/>
|
||||
</div>
|
||||
@ -424,7 +424,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
<select
|
||||
value={data.convenio}
|
||||
onChange={(e) => onChange({ convenio: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
{convenios.map((c) => (
|
||||
@ -442,7 +442,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.numeroCarteirinha}
|
||||
onChange={(e) => onChange({ numeroCarteirinha: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-600 focus:border-green-600/40 transition-colors"
|
||||
className="form-input"
|
||||
placeholder="Informe se possuir convênio"
|
||||
/>
|
||||
</div>
|
||||
@ -467,7 +467,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
onChange({ endereco: { ...data.endereco, cep: e.target.value } })
|
||||
}
|
||||
onBlur={(e) => onCepLookup(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="form-input"
|
||||
placeholder="00000-000"
|
||||
inputMode="numeric"
|
||||
pattern="^\d{5}-?\d{3}$"
|
||||
@ -488,7 +488,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
onChange={(e) =>
|
||||
onChange({ endereco: { ...data.endereco, rua: 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="form-input"
|
||||
placeholder="Rua"
|
||||
autoComplete="address-line1"
|
||||
/>
|
||||
@ -509,7 +509,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
endereco: { ...data.endereco, numero: 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="form-input"
|
||||
placeholder="Número"
|
||||
inputMode="numeric"
|
||||
pattern="^\d+[A-Za-z0-9/-]*$"
|
||||
@ -531,7 +531,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
endereco: { ...data.endereco, complemento: 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="form-input"
|
||||
placeholder="Apto, bloco..."
|
||||
/>
|
||||
</div>
|
||||
@ -551,7 +551,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
endereco: { ...data.endereco, bairro: 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="form-input"
|
||||
placeholder="Bairro"
|
||||
autoComplete="address-line2"
|
||||
/>
|
||||
@ -572,7 +572,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
endereco: { ...data.endereco, cidade: 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="form-input"
|
||||
placeholder="Cidade"
|
||||
autoComplete="address-level2"
|
||||
/>
|
||||
@ -593,7 +593,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
endereco: { ...data.endereco, estado: 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="form-input"
|
||||
placeholder="Estado"
|
||||
autoComplete="address-level1"
|
||||
/>
|
||||
@ -606,7 +606,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
<textarea
|
||||
value={data.observacoes}
|
||||
onChange={(e) => onChange({ observacoes: 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="form-input"
|
||||
rows={3}
|
||||
placeholder="Observações gerais do paciente"
|
||||
/>
|
||||
@ -629,7 +629,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.telefoneSecundario || ""}
|
||||
onChange={(e) => onChange({ telefoneSecundario: 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="form-input"
|
||||
placeholder="(DDD) 00000-0000"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
@ -646,7 +646,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.telefoneReferencia || ""}
|
||||
onChange={(e) => onChange({ telefoneReferencia: 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="form-input"
|
||||
placeholder="Contato de apoio"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
@ -669,7 +669,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.responsavel_nome || ""}
|
||||
onChange={(e) => onChange({ responsavel_nome: 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="form-input"
|
||||
placeholder="Nome completo"
|
||||
autoComplete="name"
|
||||
/>
|
||||
@ -686,7 +686,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.responsavel_cpf || ""}
|
||||
onChange={(e) => onChange({ responsavel_cpf: 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="form-input"
|
||||
placeholder="000.000.000-00"
|
||||
inputMode="numeric"
|
||||
pattern="^\d{3}\.\d{3}\.\d{3}-\d{2}$|^\d{11}$"
|
||||
@ -706,7 +706,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
||||
type="text"
|
||||
value={data.codigo_legado || ""}
|
||||
onChange={(e) => onChange({ codigo_legado: 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="form-input"
|
||||
placeholder="ID em outro sistema"
|
||||
/>
|
||||
</div>
|
||||
@ -811,3 +811,5 @@ const DocumentosExtras: React.FC<DocumentosExtrasProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -82,3 +82,5 @@ export default function AgendaSection({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -294,3 +294,5 @@ export default function ConsultasSection({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -179,3 +179,5 @@ export default function RelatoriosSection({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
type Doctor,
|
||||
} from "../../services";
|
||||
import { Avatar } from "../ui/Avatar";
|
||||
import { CalendarPicker } from "../agenda/CalendarPicker";
|
||||
import AvailableSlotsPicker from "../agenda/AvailableSlotsPicker";
|
||||
|
||||
interface AppointmentWithDetails extends Appointment {
|
||||
patient?: Patient;
|
||||
@ -28,9 +30,8 @@ export function SecretaryAppointmentList() {
|
||||
const [itemsPerPage] = useState(10);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<
|
||||
AppointmentWithDetails | null
|
||||
>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] =
|
||||
useState<AppointmentWithDetails | null>(null);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [formData, setFormData] = useState<any>({
|
||||
@ -41,6 +42,8 @@ export function SecretaryAppointmentList() {
|
||||
appointment_type: "presencial",
|
||||
notes: "",
|
||||
});
|
||||
const [selectedDate, setSelectedDate] = useState<string>("");
|
||||
const [selectedTime, setSelectedTime] = useState<string>("");
|
||||
|
||||
const loadAppointments = async () => {
|
||||
setLoading(true);
|
||||
@ -132,7 +135,7 @@ export function SecretaryAppointmentList() {
|
||||
Confirmada: "confirmed",
|
||||
Agendada: "requested",
|
||||
Cancelada: "cancelled",
|
||||
"Concluída": "completed",
|
||||
Concluída: "completed",
|
||||
Concluida: "completed",
|
||||
};
|
||||
return map[label] || label.toLowerCase();
|
||||
@ -151,10 +154,12 @@ export function SecretaryAppointmentList() {
|
||||
const typeValue = mapTypeFilterToValue(typeFilter);
|
||||
|
||||
// Filtro de status
|
||||
const matchesStatus = statusValue === null || appointment.status === statusValue;
|
||||
const matchesStatus =
|
||||
statusValue === null || appointment.status === statusValue;
|
||||
|
||||
// Filtro de tipo
|
||||
const matchesType = typeValue === null || appointment.appointment_type === typeValue;
|
||||
const matchesType =
|
||||
typeValue === null || appointment.appointment_type === typeValue;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesType;
|
||||
});
|
||||
@ -186,6 +191,8 @@ export function SecretaryAppointmentList() {
|
||||
appointment_type: "presencial",
|
||||
notes: "",
|
||||
});
|
||||
setSelectedDate("");
|
||||
setSelectedTime("");
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
@ -201,7 +208,10 @@ export function SecretaryAppointmentList() {
|
||||
if (modalMode === "edit" && formData.id) {
|
||||
// Update only allowed fields per API types
|
||||
const updatePayload: any = {};
|
||||
if (formData.scheduled_at) updatePayload.scheduled_at = new Date(formData.scheduled_at).toISOString();
|
||||
if (formData.scheduled_at)
|
||||
updatePayload.scheduled_at = new Date(
|
||||
formData.scheduled_at
|
||||
).toISOString();
|
||||
if (formData.notes) updatePayload.notes = formData.notes;
|
||||
await appointmentService.update(formData.id, updatePayload);
|
||||
toast.success("Consulta atualizada com sucesso!");
|
||||
@ -671,9 +681,9 @@ export function SecretaryAppointmentList() {
|
||||
{/* Modal de Criar Consulta */}
|
||||
{showCreateModal && (
|
||||
<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-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{modalMode === "edit" ? "Editar Consulta" : "Nova Consulta"}
|
||||
</h2>
|
||||
</div>
|
||||
@ -689,7 +699,7 @@ export function SecretaryAppointmentList() {
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, patient_id: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um paciente</option>
|
||||
@ -710,7 +720,7 @@ export function SecretaryAppointmentList() {
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, doctor_id: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um médico</option>
|
||||
@ -722,21 +732,6 @@ export function SecretaryAppointmentList() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Data e Hora *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_at}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, scheduled_at: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de Consulta *
|
||||
@ -749,7 +744,7 @@ export function SecretaryAppointmentList() {
|
||||
appointment_type: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="presencial">Presencial</option>
|
||||
@ -758,7 +753,53 @@ export function SecretaryAppointmentList() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendário Visual */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Data da Consulta *
|
||||
</label>
|
||||
{formData.doctor_id ? (
|
||||
<CalendarPicker
|
||||
doctorId={formData.doctor_id}
|
||||
selectedDate={selectedDate}
|
||||
onSelectDate={(date) => {
|
||||
setSelectedDate(date);
|
||||
setSelectedTime(""); // Resetar horário ao mudar data
|
||||
setFormData({ ...formData, scheduled_at: "" });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center text-gray-500">
|
||||
Selecione um médico primeiro para ver a disponibilidade
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Seletor de Horários */}
|
||||
{selectedDate && formData.doctor_id && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Horário *{" "}
|
||||
{selectedTime && (
|
||||
<span className="text-blue-600 font-semibold">
|
||||
({selectedTime})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<AvailableSlotsPicker
|
||||
doctorId={formData.doctor_id}
|
||||
date={selectedDate}
|
||||
onSelect={(time) => {
|
||||
setSelectedTime(time);
|
||||
// Combinar data + horário no formato ISO
|
||||
const datetime = `${selectedDate}T${time}:00`;
|
||||
setFormData({ ...formData, scheduled_at: datetime });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Observações
|
||||
@ -768,7 +809,8 @@ export function SecretaryAppointmentList() {
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notes: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 h-24"
|
||||
className="form-input"
|
||||
rows={3}
|
||||
placeholder="Observações da consulta"
|
||||
/>
|
||||
</div>
|
||||
@ -786,7 +828,9 @@ export function SecretaryAppointmentList() {
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
{modalMode === "edit" ? "Salvar Alterações" : "Agendar Consulta"}
|
||||
{modalMode === "edit"
|
||||
? "Salvar Alterações"
|
||||
: "Agendar Consulta"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -797,9 +841,11 @@ export function SecretaryAppointmentList() {
|
||||
{/* Modal de Visualizar Consulta */}
|
||||
{selectedAppointment && (
|
||||
<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-2xl 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 Consulta</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Visualizar Consulta
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedAppointment(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@ -811,39 +857,73 @@ export function SecretaryAppointmentList() {
|
||||
<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">Paciente</label>
|
||||
<p className="text-gray-900 font-medium">{selectedAppointment.patient?.full_name || '—'}</p>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Paciente
|
||||
</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{selectedAppointment.patient?.full_name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Médico</label>
|
||||
<p className="text-gray-900">{selectedAppointment.doctor?.full_name || '—'}</p>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Médico
|
||||
</label>
|
||||
<p className="text-gray-900">
|
||||
{selectedAppointment.doctor?.full_name || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Data</label>
|
||||
<p className="text-gray-900">{selectedAppointment.scheduled_at ? formatDate(selectedAppointment.scheduled_at) : '—'}</p>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Data
|
||||
</label>
|
||||
<p className="text-gray-900">
|
||||
{selectedAppointment.scheduled_at
|
||||
? formatDate(selectedAppointment.scheduled_at)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Hora</label>
|
||||
<p className="text-gray-900">{selectedAppointment.scheduled_at ? formatTime(selectedAppointment.scheduled_at) : '—'}</p>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Hora
|
||||
</label>
|
||||
<p className="text-gray-900">
|
||||
{selectedAppointment.scheduled_at
|
||||
? formatTime(selectedAppointment.scheduled_at)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Tipo</label>
|
||||
<p className="text-gray-900">{selectedAppointment.appointment_type === 'telemedicina' ? 'Telemedicina' : 'Presencial'}</p>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Tipo
|
||||
</label>
|
||||
<p className="text-gray-900">
|
||||
{selectedAppointment.appointment_type === "telemedicina"
|
||||
? "Telemedicina"
|
||||
: "Presencial"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Status</label>
|
||||
<div>{getStatusBadge(selectedAppointment.status || 'agendada')}</div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<div>
|
||||
{getStatusBadge(selectedAppointment.status || "agendada")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">Observações</label>
|
||||
<p className="text-gray-900 whitespace-pre-wrap">{selectedAppointment.notes || '—'}</p>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Observações
|
||||
</label>
|
||||
<p className="text-gray-900 whitespace-pre-wrap">
|
||||
{selectedAppointment.notes || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
|
||||
@ -61,6 +61,37 @@ const formatDoctorName = (fullName: string): string => {
|
||||
return `Dr. ${name}`;
|
||||
};
|
||||
|
||||
// Função para formatar CPF: XXX.XXX.XXX-XX
|
||||
const formatCPF = (value: string): string => {
|
||||
const numbers = value.replace(/\D/g, "");
|
||||
if (numbers.length === 0) return "";
|
||||
if (numbers.length <= 3) return numbers;
|
||||
if (numbers.length <= 6) return `${numbers.slice(0, 3)}.${numbers.slice(3)}`;
|
||||
if (numbers.length <= 9)
|
||||
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(6)}`;
|
||||
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
|
||||
6,
|
||||
9
|
||||
)}-${numbers.slice(9, 11)}`;
|
||||
};
|
||||
|
||||
// Função para formatar telefone: (XX) XXXXX-XXXX
|
||||
const formatPhone = (value: string): string => {
|
||||
const numbers = value.replace(/\D/g, "");
|
||||
if (numbers.length === 0) return "";
|
||||
if (numbers.length <= 2) return `(${numbers}`;
|
||||
if (numbers.length <= 7)
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
|
||||
if (numbers.length <= 11)
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
|
||||
7
|
||||
)}`;
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
|
||||
7,
|
||||
11
|
||||
)}`;
|
||||
};
|
||||
|
||||
export function SecretaryDoctorList({
|
||||
onOpenSchedule,
|
||||
}: {
|
||||
@ -413,9 +444,14 @@ export function SecretaryDoctorList({
|
||||
if (onOpenSchedule) {
|
||||
onOpenSchedule(doctor.id);
|
||||
} else {
|
||||
sessionStorage.setItem("selectedDoctorForSchedule", doctor.id);
|
||||
sessionStorage.setItem(
|
||||
"selectedDoctorForSchedule",
|
||||
doctor.id
|
||||
);
|
||||
// dispatch a custom event to inform parent (optional)
|
||||
window.dispatchEvent(new CustomEvent("open-doctor-schedule"));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-doctor-schedule")
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Gerenciar agenda"
|
||||
@ -503,10 +539,10 @@ export function SecretaryDoctorList({
|
||||
{/* Modal de Formulário */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{modalMode === "create" ? "Novo Médico" : "Editar Médico"}
|
||||
</h2>
|
||||
<button
|
||||
@ -531,7 +567,7 @@ export function SecretaryDoctorList({
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, full_name: 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="form-input"
|
||||
required
|
||||
placeholder="Dr. João Silva"
|
||||
/>
|
||||
@ -546,10 +582,14 @@ export function SecretaryDoctorList({
|
||||
type="text"
|
||||
value={formData.cpf}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cpf: e.target.value })
|
||||
setFormData({
|
||||
...formData,
|
||||
cpf: formatCPF(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="form-input"
|
||||
required
|
||||
maxLength={14}
|
||||
placeholder="000.000.000-00"
|
||||
/>
|
||||
</div>
|
||||
@ -566,7 +606,7 @@ export function SecretaryDoctorList({
|
||||
birth_date: 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="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -582,7 +622,7 @@ export function SecretaryDoctorList({
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, crm: 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="form-input"
|
||||
required
|
||||
placeholder="123456"
|
||||
/>
|
||||
@ -596,7 +636,7 @@ export function SecretaryDoctorList({
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, crm_uf: 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="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
@ -618,7 +658,7 @@ export function SecretaryDoctorList({
|
||||
onChange={(e) =>
|
||||
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="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
<option value="Cardiologia">Cardiologia</option>
|
||||
@ -640,7 +680,7 @@ export function SecretaryDoctorList({
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: 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="form-input"
|
||||
required
|
||||
placeholder="medico@exemplo.com"
|
||||
/>
|
||||
@ -656,10 +696,11 @@ export function SecretaryDoctorList({
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
phone_mobile: e.target.value,
|
||||
phone_mobile: formatPhone(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="form-input"
|
||||
maxLength={15}
|
||||
placeholder="(11) 98888-8888"
|
||||
/>
|
||||
</div>
|
||||
@ -690,9 +731,11 @@ export function SecretaryDoctorList({
|
||||
{/* Modal de Visualizar Médico */}
|
||||
{showViewModal && selectedDoctor && (
|
||||
<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-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Visualizar Médico</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Visualizar Médico
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowViewModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
|
||||
@ -704,19 +747,23 @@ export function SecretaryDoctorList({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Nome</p>
|
||||
<p className="text-gray-900 font-medium">{selectedDoctor.full_name}</p>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{selectedDoctor.full_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Especialidade</p>
|
||||
<p className="text-gray-900">{selectedDoctor.specialty || '—'}</p>
|
||||
<p className="text-gray-900">
|
||||
{selectedDoctor.specialty || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">CRM</p>
|
||||
<p className="text-gray-900">{selectedDoctor.crm || '—'}</p>
|
||||
<p className="text-gray-900">{selectedDoctor.crm || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Email</p>
|
||||
<p className="text-gray-900">{selectedDoctor.email || '—'}</p>
|
||||
<p className="text-gray-900">{selectedDoctor.email || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -679,14 +679,14 @@ export function SecretaryDoctorSchedule() {
|
||||
</div>
|
||||
|
||||
{/* Current Availability */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Disponibilidade Atual
|
||||
</h3>
|
||||
{loading ? (
|
||||
<p className="text-gray-500">Carregando...</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">Carregando...</p>
|
||||
) : availabilities.length === 0 ? (
|
||||
<p className="text-gray-500">Nenhuma disponibilidade configurada</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">Nenhuma disponibilidade configurada</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{availabilities.map((avail) => (
|
||||
@ -729,8 +729,8 @@ export function SecretaryDoctorSchedule() {
|
||||
|
||||
{/* Exceções (Bloqueios e Disponibilidades Extras) */}
|
||||
{exceptions.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Exceções Cadastradas
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
@ -805,14 +805,14 @@ export function SecretaryDoctorSchedule() {
|
||||
{/* Availability Dialog */}
|
||||
{showAvailabilityDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Adicionar Disponibilidade
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Dias da Semana
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
@ -903,14 +903,14 @@ export function SecretaryDoctorSchedule() {
|
||||
{/* Exception Dialog */}
|
||||
{showExceptionDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Adicionar Exceção
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
Tipo de Exceção
|
||||
</label>
|
||||
<select
|
||||
@ -1030,13 +1030,13 @@ export function SecretaryDoctorSchedule() {
|
||||
{/* 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">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white 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">
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||
<p className="text-sm text-blue-900 dark:text-blue-200 font-medium">
|
||||
{weekdayToText(editingAvailability.weekday)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -660,10 +660,10 @@ export function SecretaryPatientList({
|
||||
{/* Modal de Formulário */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{modalMode === "create" ? "Novo Paciente" : "Editar Paciente"}
|
||||
</h2>
|
||||
<button
|
||||
@ -699,9 +699,9 @@ export function SecretaryPatientList({
|
||||
{/* Modal de Visualizar Paciente */}
|
||||
{showViewModal && selectedPatient && (
|
||||
<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-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-xl font-semibold text-gray-900">Visualizar Paciente</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Visualizar Paciente</h2>
|
||||
<button
|
||||
onClick={() => setShowViewModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
|
||||
@ -743,13 +743,13 @@ export function SecretaryPatientList({
|
||||
{/* 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="bg-white dark:bg-gray-800 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 className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<Trash2 className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Confirmar Exclusão
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
@ -800,3 +800,5 @@ export function SecretaryPatientList({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
patientService,
|
||||
type Patient,
|
||||
doctorService,
|
||||
type Doctor,
|
||||
} from "../../services";
|
||||
|
||||
export function SecretaryReportList() {
|
||||
@ -22,7 +21,10 @@ export function SecretaryReportList() {
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [doctors, setDoctors] = useState<any[]>([]);
|
||||
const [requestedByNames, setRequestedByNames] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [formData, setFormData] = useState({
|
||||
patient_id: "",
|
||||
doctor_id: "",
|
||||
@ -38,12 +40,12 @@ export function SecretaryReportList() {
|
||||
loadReports();
|
||||
loadPatients();
|
||||
loadDoctors();
|
||||
loadDoctors();
|
||||
}, []);
|
||||
|
||||
// Recarrega automaticamente quando o filtro de status muda
|
||||
// (evita depender do clique em Buscar)
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
loadReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter]);
|
||||
@ -59,7 +61,7 @@ export function SecretaryReportList() {
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const data = await doctorService.list();
|
||||
const data = await doctorService.list({});
|
||||
setDoctors(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar médicos:", error);
|
||||
@ -152,7 +154,7 @@ export function SecretaryReportList() {
|
||||
}
|
||||
|
||||
try {
|
||||
await reportService.update(selectedReport.id, {
|
||||
const updatedReport = await reportService.update(selectedReport.id, {
|
||||
patient_id: formData.patient_id,
|
||||
exam: formData.exam || undefined,
|
||||
diagnosis: formData.diagnosis || undefined,
|
||||
@ -162,10 +164,19 @@ export function SecretaryReportList() {
|
||||
requested_by: formData.requested_by || undefined,
|
||||
});
|
||||
|
||||
console.log("[SecretaryReportList] Relatório atualizado:", updatedReport);
|
||||
console.log(
|
||||
"[SecretaryReportList] Novo requested_by:",
|
||||
formData.requested_by
|
||||
);
|
||||
|
||||
toast.success("Relatório atualizado com sucesso!");
|
||||
setShowEditModal(false);
|
||||
setSelectedReport(null);
|
||||
loadReports();
|
||||
|
||||
// Limpar cache de nomes antes de recarregar
|
||||
setRequestedByNames({});
|
||||
await loadReports();
|
||||
} catch (error) {
|
||||
console.error("Erro ao atualizar relatório:", error);
|
||||
toast.error("Erro ao atualizar relatório");
|
||||
@ -306,12 +317,74 @@ export function SecretaryReportList() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadRequestedByNames = async (reportsList: Report[]) => {
|
||||
const names: Record<string, string> = {};
|
||||
|
||||
// Buscar nomes únicos de requested_by
|
||||
const uniqueIds = [
|
||||
...new Set(reportsList.map((r) => r.requested_by).filter(Boolean)),
|
||||
];
|
||||
|
||||
try {
|
||||
// Buscar todos os médicos de uma vez
|
||||
const doctors = await doctorService.list({});
|
||||
|
||||
for (const id of uniqueIds) {
|
||||
// Verificar se já é um nome (não é UUID)
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
id!
|
||||
);
|
||||
|
||||
if (!isUUID) {
|
||||
// Já é um nome direto (dados legados), manter como está
|
||||
names[id!] = id!;
|
||||
continue;
|
||||
}
|
||||
|
||||
// É um UUID, procurar o médico
|
||||
const doctor = doctors.find((d) => {
|
||||
const doctorAny = d as any;
|
||||
return doctorAny.user_id === id || d.id === id || doctorAny.id === id;
|
||||
});
|
||||
|
||||
if (doctor && doctor.full_name) {
|
||||
// Formatar o nome com "Dr." se não tiver
|
||||
const doctorName = doctor.full_name;
|
||||
names[id!] = /^dr\.?\s/i.test(doctorName)
|
||||
? doctorName
|
||||
: `Dr. ${doctorName}`;
|
||||
} else {
|
||||
// UUID não encontrado - médico pode ter sido deletado
|
||||
// Mostrar mensagem mais amigável
|
||||
names[id!] = "Médico não cadastrado";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar nomes dos médicos:", error);
|
||||
// Em caso de erro, manter os IDs originais
|
||||
uniqueIds.forEach((id) => {
|
||||
names[id!] = id!;
|
||||
});
|
||||
}
|
||||
|
||||
setRequestedByNames(names);
|
||||
};
|
||||
|
||||
const loadReports = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Se um filtro de status estiver aplicado, encaminhar para o serviço
|
||||
// Cast explícito para o tipo esperado pelo serviço (ReportStatus)
|
||||
const filters = statusFilter ? { status: statusFilter as any } : undefined;
|
||||
const filters = statusFilter
|
||||
? {
|
||||
status: statusFilter as
|
||||
| "draft"
|
||||
| "completed"
|
||||
| "pending"
|
||||
| "cancelled",
|
||||
}
|
||||
: undefined;
|
||||
console.log("[SecretaryReportList] loadReports filters:", filters);
|
||||
const data = await reportService.list(filters);
|
||||
console.log("✅ Relatórios carregados:", data);
|
||||
@ -327,6 +400,12 @@ export function SecretaryReportList() {
|
||||
});
|
||||
}
|
||||
setReports(reportsList);
|
||||
|
||||
// Carregar nomes dos solicitantes
|
||||
if (reportsList.length > 0) {
|
||||
await loadRequestedByNames(reportsList);
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
console.warn("⚠️ Nenhum relatório encontrado na API");
|
||||
}
|
||||
@ -369,8 +448,8 @@ export function SecretaryReportList() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Relatórios</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Relatórios</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Visualize e baixe relatórios do sistema
|
||||
</p>
|
||||
</div>
|
||||
@ -387,13 +466,13 @@ export function SecretaryReportList() {
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar relatórios..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@ -404,7 +483,7 @@ export function SecretaryReportList() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
className="px-6 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
@ -412,11 +491,11 @@ export function SecretaryReportList() {
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className="text-sm text-gray-600">Status:</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-green-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
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 value="">Todos</option>
|
||||
<option value="draft">Rascunho</option>
|
||||
@ -433,29 +512,29 @@ export function SecretaryReportList() {
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider">
|
||||
Relatório
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Criado Em
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Solicitante
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">
|
||||
Ações
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
Carregando relatórios...
|
||||
</td>
|
||||
@ -464,7 +543,7 @@ export function SecretaryReportList() {
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="px-6 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
className="px-6 py-12 text-center text-gray-500"
|
||||
>
|
||||
Nenhum relatório encontrado
|
||||
</td>
|
||||
@ -473,18 +552,18 @@ export function SecretaryReportList() {
|
||||
reports.map((report) => (
|
||||
<tr
|
||||
key={report.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{report.order_number}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<p className="text-xs text-gray-500">
|
||||
{report.exam || "Sem exame"}
|
||||
</p>
|
||||
</div>
|
||||
@ -494,12 +573,12 @@ export function SecretaryReportList() {
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
report.status === "completed"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
? "bg-green-100 text-green-800"
|
||||
: report.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: report.status === "draft"
|
||||
? "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{report.status === "completed"
|
||||
@ -511,11 +590,14 @@ export function SecretaryReportList() {
|
||||
: "Cancelado"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<td className="px-6 py-4 text-sm text-gray-700">
|
||||
{formatDate(report.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">
|
||||
{report.requested_by || "—"}
|
||||
{report.requested_by
|
||||
? requestedByNames[report.requested_by] ||
|
||||
report.requested_by
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -560,9 +642,9 @@ export function SecretaryReportList() {
|
||||
{/* Modal de Criar Relatório */}
|
||||
{showCreateModal && (
|
||||
<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-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Novo Relatório
|
||||
</h2>
|
||||
</div>
|
||||
@ -578,7 +660,7 @@ export function SecretaryReportList() {
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, patient_id: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um paciente</option>
|
||||
@ -590,6 +672,21 @@ export function SecretaryReportList() {
|
||||
</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="form-input"
|
||||
placeholder="Nome do exame realizado"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Médico Solicitante *
|
||||
@ -604,7 +701,7 @@ export function SecretaryReportList() {
|
||||
requested_by: selectedDoctor ? `Dr. ${selectedDoctor.full_name}` : ""
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um médico</option>
|
||||
@ -616,21 +713,6 @@ export function SecretaryReportList() {
|
||||
</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-green-500"
|
||||
placeholder="Nome do exame realizado"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Diagnóstico
|
||||
@ -640,7 +722,7 @@ export function SecretaryReportList() {
|
||||
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-green-500 h-24"
|
||||
className="form-input"
|
||||
placeholder="Diagnóstico do paciente"
|
||||
/>
|
||||
</div>
|
||||
@ -654,7 +736,7 @@ export function SecretaryReportList() {
|
||||
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-green-500 h-24"
|
||||
className="form-input"
|
||||
placeholder="Conclusão e recomendações"
|
||||
/>
|
||||
</div>
|
||||
@ -683,9 +765,9 @@ export function SecretaryReportList() {
|
||||
{/* 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">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Visualizar Relatório
|
||||
</h2>
|
||||
<button
|
||||
@ -753,7 +835,10 @@ export function SecretaryReportList() {
|
||||
Solicitado por
|
||||
</label>
|
||||
<p className="text-gray-900">
|
||||
{selectedReport.requested_by || "—"}
|
||||
{selectedReport.requested_by
|
||||
? requestedByNames[selectedReport.requested_by] ||
|
||||
selectedReport.requested_by
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -819,9 +904,9 @@ export function SecretaryReportList() {
|
||||
{/* 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">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Editar Relatório
|
||||
</h2>
|
||||
<button
|
||||
@ -845,7 +930,7 @@ export function SecretaryReportList() {
|
||||
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"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -865,7 +950,7 @@ export function SecretaryReportList() {
|
||||
| "cancelled",
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="draft">Rascunho</option>
|
||||
@ -885,7 +970,7 @@ export function SecretaryReportList() {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="Nome do exame realizado"
|
||||
/>
|
||||
</div>
|
||||
@ -900,49 +985,29 @@ export function SecretaryReportList() {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="Ex: A00.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Médico Solicitante
|
||||
Solicitado por
|
||||
</label>
|
||||
<select
|
||||
value={formData.doctor_id}
|
||||
onChange={(e) => {
|
||||
const selectedDoctor = doctors.find(d => d.id === e.target.value);
|
||||
setFormData({
|
||||
...formData,
|
||||
doctor_id: e.target.value,
|
||||
requested_by: selectedDoctor ? `Dr. ${selectedDoctor.full_name}` : ""
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Selecione um médico</option>
|
||||
{doctors.map((doctor) => (
|
||||
<option key={doctor.id} value={doctor.id}>
|
||||
Dr. {doctor.full_name} - {doctor.specialty}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Solicitado por (texto livre)
|
||||
</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"
|
||||
/>
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione um médico</option>
|
||||
{doctors.map((doctor) => (
|
||||
<option key={doctor.id} value={doctor.id}>
|
||||
{doctor.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -954,7 +1019,7 @@ export function SecretaryReportList() {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="Diagnóstico do paciente"
|
||||
/>
|
||||
</div>
|
||||
@ -968,7 +1033,7 @@ export function SecretaryReportList() {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="Conclusão e recomendações"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -247,3 +247,5 @@ export function AvatarUpload({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -98,7 +98,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
type="text"
|
||||
value={typedConfirmation}
|
||||
onChange={(e) => setTypedConfirmation(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder={confirmationWord}
|
||||
autoFocus
|
||||
/>
|
||||
@ -130,3 +130,5 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -379,7 +379,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
};
|
||||
setUser(newUser);
|
||||
persist({ user: newUser, savedAt: new Date().toISOString() });
|
||||
toast.success("Login realizado");
|
||||
return true;
|
||||
}
|
||||
toast.error("Credenciais inválidas");
|
||||
@ -443,7 +442,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
token: loginResp.access_token,
|
||||
refreshToken: loginResp.refresh_token,
|
||||
});
|
||||
toast.success("Login realizado");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[AuthContext] Login falhou:", error);
|
||||
|
||||
@ -8,6 +8,37 @@
|
||||
body {
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Garantir que o texto nunca fique muito grande */
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Animação de rotação única */
|
||||
@keyframes spin-once {
|
||||
0% {
|
||||
transform: rotate(0deg) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(180deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-once {
|
||||
animation: spin-once 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Dark mode hard fallback (ensure full-page background) */
|
||||
@ -156,6 +187,29 @@ html.focus-mode.dark *:focus-visible,
|
||||
.gradient-blue-light {
|
||||
@apply bg-gradient-to-l from-blue-600 to-blue-400;
|
||||
}
|
||||
|
||||
/* Classes padronizadas para formulários */
|
||||
.form-input {
|
||||
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
||||
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base bg-white;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
||||
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
||||
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white resize-none;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Estilos de Acessibilidade - Alto Contraste */
|
||||
|
||||
@ -14,11 +14,13 @@ import {
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Eye,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { appointmentService, doctorService, reportService } from "../services";
|
||||
import type { Report } from "../services/reports/types";
|
||||
@ -57,6 +59,7 @@ interface Medico {
|
||||
const AcompanhamentoPaciente: React.FC = () => {
|
||||
const { user, roles = [], logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Helper para formatar nome do médico com Dr.
|
||||
const formatDoctorName = (fullName: string): string => {
|
||||
@ -78,12 +81,15 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
const [especialidadeFiltro, setEspecialidadeFiltro] = useState<string>("");
|
||||
const [laudos, setLaudos] = useState<Report[]>([]);
|
||||
const [loadingLaudos, setLoadingLaudos] = useState(false);
|
||||
const [selectedLaudo, setSelectedLaudo] = useState<Report | null>(null);
|
||||
const [showLaudoModal, setShowLaudoModal] = useState(false);
|
||||
const [paginaProximas, setPaginaProximas] = useState(1);
|
||||
const [paginaPassadas, setPaginaPassadas] = useState(1);
|
||||
const consultasPorPagina = 10;
|
||||
const consultasPorPagina = 20; // Aumentado de 10 para 20
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
const [selectedLaudo, setSelectedLaudo] = useState<Report | null>(null);
|
||||
const [showLaudoModal, setShowLaudoModal] = useState(false);
|
||||
const [requestedByNames, setRequestedByNames] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const pacienteId = user?.id || "";
|
||||
const pacienteNome = user?.nome || "Paciente";
|
||||
@ -94,6 +100,19 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
if (!user || !isPaciente) navigate("/paciente");
|
||||
}, [user, roles, navigate]);
|
||||
|
||||
// Detecta se veio de navegação com estado para abrir aba específica
|
||||
useEffect(() => {
|
||||
if (
|
||||
location.state &&
|
||||
(location.state as { activeTab?: string }).activeTab
|
||||
) {
|
||||
const state = location.state as { activeTab: string };
|
||||
setActiveTab(state.activeTab);
|
||||
// Limpa o estado após usar
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// Carregar avatar ao montar componente
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
@ -126,10 +145,10 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
setLoading(true);
|
||||
setLoadingMedicos(true);
|
||||
try {
|
||||
// Buscar agendamentos da API
|
||||
// Buscar TODOS os agendamentos da API (sem limite)
|
||||
const appointments = await appointmentService.list({
|
||||
patient_id: pacienteId,
|
||||
limit: 50,
|
||||
limit: 1000, // Aumenta limite para buscar todas
|
||||
order: "scheduled_at.desc",
|
||||
});
|
||||
|
||||
@ -183,6 +202,32 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
fetchConsultas();
|
||||
}, [fetchConsultas]);
|
||||
|
||||
// Função para carregar nomes dos médicos solicitantes
|
||||
const loadRequestedByNames = useCallback(async (reports: Report[]) => {
|
||||
const uniqueIds = [
|
||||
...new Set(
|
||||
reports.map((r) => r.requested_by).filter((id): id is string => !!id)
|
||||
),
|
||||
];
|
||||
if (uniqueIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const doctors = await doctorService.list();
|
||||
const nameMap: Record<string, string> = {};
|
||||
|
||||
uniqueIds.forEach((id) => {
|
||||
const doctor = doctors.find((d) => d.id === id);
|
||||
if (doctor && doctor.full_name) {
|
||||
nameMap[id] = formatDoctorName(doctor.full_name);
|
||||
}
|
||||
});
|
||||
|
||||
setRequestedByNames(nameMap);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar nomes dos médicos:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Recarregar consultas quando mudar para a aba de consultas
|
||||
const fetchLaudos = useCallback(async () => {
|
||||
if (!pacienteId) return;
|
||||
@ -190,6 +235,8 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
try {
|
||||
const data = await reportService.list({ patient_id: pacienteId });
|
||||
setLaudos(data);
|
||||
// Carregar nomes dos médicos
|
||||
await loadRequestedByNames(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar laudos:", error);
|
||||
toast.error("Erro ao carregar laudos");
|
||||
@ -197,7 +244,7 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
} finally {
|
||||
setLoadingLaudos(false);
|
||||
}
|
||||
}, [pacienteId]);
|
||||
}, [pacienteId, loadRequestedByNames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "appointments") {
|
||||
@ -260,8 +307,12 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
const consultasPassadasDashboard = todasConsultasPassadas.slice(0, 3);
|
||||
|
||||
// Para a página de consultas (com paginação)
|
||||
const totalPaginasProximas = Math.ceil(todasConsultasProximas.length / consultasPorPagina);
|
||||
const totalPaginasPassadas = Math.ceil(todasConsultasPassadas.length / consultasPorPagina);
|
||||
const totalPaginasProximas = Math.ceil(
|
||||
todasConsultasProximas.length / consultasPorPagina
|
||||
);
|
||||
const totalPaginasPassadas = Math.ceil(
|
||||
todasConsultasPassadas.length / consultasPorPagina
|
||||
);
|
||||
|
||||
const consultasProximas = todasConsultasProximas.slice(
|
||||
(paginaProximas - 1) * consultasPorPagina,
|
||||
@ -339,10 +390,10 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
|
||||
// Sidebar
|
||||
const renderSidebar = () => (
|
||||
<div className="w-64 h-screen bg-white dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col">
|
||||
<div className="hidden lg:flex w-64 h-screen bg-white dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex-col">
|
||||
{/* Patient Profile */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-4 sm:p-6 border-b border-gray-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<AvatarUpload
|
||||
userId={user?.id}
|
||||
currentAvatarUrl={avatarUrl}
|
||||
@ -352,17 +403,19 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
editable={true}
|
||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white truncate text-sm sm:text-base">
|
||||
{pacienteNome}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Paciente</p>
|
||||
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">
|
||||
Paciente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4">
|
||||
<nav className="flex-1 p-3 sm:p-4">
|
||||
<div className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@ -379,14 +432,14 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
setActiveTab(item.id);
|
||||
}
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
||||
className={`w-full flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 ${
|
||||
isActive
|
||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
<Icon className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -394,15 +447,15 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div className="p-3 sm:p-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate("/paciente");
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
||||
className="w-full flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 rounded-lg text-xs sm:text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<LogOut className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
@ -551,18 +604,18 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
const proximaConsulta = consultasProximas[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Bem-vindo, {pacienteNome.split(" ")[0]}!
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
|
||||
Gerencie suas consultas e cuide da sua saúde
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
|
||||
{renderStatCard(
|
||||
"Próxima Consulta",
|
||||
proximaConsulta
|
||||
@ -636,9 +689,13 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
{getMedicoEspecialidade(c.medicoId)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{format(new Date(c.dataHora), "dd/MM/yyyy - HH:mm", {
|
||||
locale: ptBR,
|
||||
})}
|
||||
{format(
|
||||
new Date(c.dataHora),
|
||||
"dd/MM/yyyy - HH:mm",
|
||||
{
|
||||
locale: ptBR,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -649,7 +706,8 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
onClick={() => setActiveTab("appointments")}
|
||||
className="w-full mt-4 px-4 py-2 text-sm text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
Ver mais consultas ({todasConsultasProximas.length - 3} restantes)
|
||||
Ver mais consultas ({todasConsultasProximas.length - 3}{" "}
|
||||
restantes)
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@ -666,29 +724,26 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
<div className="p-6 space-y-2">
|
||||
<button
|
||||
onClick={() => setActiveTab("book")}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
className="form-input"
|
||||
>
|
||||
<Calendar className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<span>Agendar Nova Consulta</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("messages")}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
className="form-input"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<span>Mensagens</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("profile")}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
className="form-input"
|
||||
>
|
||||
<User className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<span>Editar Perfil</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/ajuda")}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
<button onClick={() => navigate("/ajuda")} className="form-input">
|
||||
<HelpCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<span>Central de Ajuda</span>
|
||||
</button>
|
||||
@ -766,24 +821,40 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
|
||||
{/* Paginação Próximas Consultas */}
|
||||
{totalPaginasProximas > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={() => setPaginaProximas(Math.max(1, paginaProximas - 1))}
|
||||
disabled={paginaProximas === 1}
|
||||
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Página {paginaProximas} de {totalPaginasProximas}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPaginaProximas(Math.min(totalPaginasProximas, paginaProximas + 1))}
|
||||
disabled={paginaProximas === totalPaginasProximas}
|
||||
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
Próxima
|
||||
</button>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Mostrando {(paginaProximas - 1) * consultasPorPagina + 1} a{" "}
|
||||
{Math.min(
|
||||
paginaProximas * consultasPorPagina,
|
||||
todasConsultasProximas.length
|
||||
)}{" "}
|
||||
de {todasConsultasProximas.length} consultas
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setPaginaProximas(Math.max(1, paginaProximas - 1))
|
||||
}
|
||||
disabled={paginaProximas === 1}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-slate-800 rounded-lg">
|
||||
Página {paginaProximas} de {totalPaginasProximas}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPaginaProximas(
|
||||
Math.min(totalPaginasProximas, paginaProximas + 1)
|
||||
)
|
||||
}
|
||||
disabled={paginaProximas === totalPaginasProximas}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
|
||||
>
|
||||
Próxima →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -811,24 +882,40 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
|
||||
{/* Paginação Consultas Passadas */}
|
||||
{totalPaginasPassadas > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={() => setPaginaPassadas(Math.max(1, paginaPassadas - 1))}
|
||||
disabled={paginaPassadas === 1}
|
||||
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Página {paginaPassadas} de {totalPaginasPassadas}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPaginaPassadas(Math.min(totalPaginasPassadas, paginaPassadas + 1))}
|
||||
disabled={paginaPassadas === totalPaginasPassadas}
|
||||
className="px-3 py-1 rounded border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
Próxima
|
||||
</button>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Mostrando {(paginaPassadas - 1) * consultasPorPagina + 1} a{" "}
|
||||
{Math.min(
|
||||
paginaPassadas * consultasPorPagina,
|
||||
todasConsultasPassadas.length
|
||||
)}{" "}
|
||||
de {todasConsultasPassadas.length} consultas
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setPaginaPassadas(Math.max(1, paginaPassadas - 1))
|
||||
}
|
||||
disabled={paginaPassadas === 1}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<span className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-slate-800 rounded-lg">
|
||||
Página {paginaPassadas} de {totalPaginasPassadas}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPaginaPassadas(
|
||||
Math.min(totalPaginasPassadas, paginaPassadas + 1)
|
||||
)
|
||||
}
|
||||
disabled={paginaPassadas === totalPaginasPassadas}
|
||||
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 font-medium"
|
||||
>
|
||||
Próxima →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -962,7 +1049,9 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
{laudo.exam || "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{laudo.requested_by || "-"}
|
||||
{laudo.requested_by
|
||||
? (requestedByNames[laudo.requested_by] || laudo.requested_by)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
@ -994,9 +1083,11 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
setSelectedLaudo(laudo);
|
||||
setShowLaudoModal(true);
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 font-medium"
|
||||
className="inline-flex items-center gap-2 px-3 py-1 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title="Ver detalhes"
|
||||
>
|
||||
Ver Detalhes
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>Ver</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -1006,146 +1097,6 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de Detalhes do Laudo */}
|
||||
{showLaudoModal && selectedLaudo && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Detalhes do Laudo
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLaudoModal(false);
|
||||
setSelectedLaudo(null);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<XCircle className="h-6 w-6 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Informações Básicas */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Número do Laudo
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{selectedLaudo.order_number}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Data de Criação
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{new Date(selectedLaudo.created_at).toLocaleDateString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 text-xs font-semibold rounded-full ${
|
||||
selectedLaudo.status === "completed"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: selectedLaudo.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: selectedLaudo.status === "cancelled"
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selectedLaudo.status === "completed"
|
||||
? "Concluído"
|
||||
: selectedLaudo.status === "pending"
|
||||
? "Pendente"
|
||||
: selectedLaudo.status === "cancelled"
|
||||
? "Cancelado"
|
||||
: "Rascunho"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Médico Solicitante
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{selectedLaudo.requested_by || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exame */}
|
||||
{selectedLaudo.exam && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Exame Realizado
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white bg-gray-50 dark:bg-slate-700 p-4 rounded-lg">
|
||||
{selectedLaudo.exam}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Código CID */}
|
||||
{selectedLaudo.cid_code && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Código CID-10
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white bg-gray-50 dark:bg-slate-700 p-4 rounded-lg font-mono">
|
||||
{selectedLaudo.cid_code}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnóstico */}
|
||||
{selectedLaudo.diagnosis && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Diagnóstico
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white bg-gray-50 dark:bg-slate-700 p-4 rounded-lg whitespace-pre-wrap">
|
||||
{selectedLaudo.diagnosis}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conclusão */}
|
||||
{selectedLaudo.conclusion && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Conclusão
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-white bg-gray-50 dark:bg-slate-700 p-4 rounded-lg whitespace-pre-wrap">
|
||||
{selectedLaudo.conclusion}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 dark:border-slate-700 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLaudoModal(false);
|
||||
setSelectedLaudo(null);
|
||||
}}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1192,11 +1143,271 @@ const AcompanhamentoPaciente: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-slate-950">
|
||||
<div className="flex flex-col lg:flex-row min-h-screen bg-gray-50 dark:bg-slate-950">
|
||||
{renderSidebar()}
|
||||
|
||||
{/* Mobile Header */}
|
||||
<div className="lg:hidden bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-700 p-4 sticky top-0 z-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AvatarUpload
|
||||
userId={user?.id}
|
||||
currentAvatarUrl={avatarUrl}
|
||||
name={pacienteNome}
|
||||
color="blue"
|
||||
size="lg"
|
||||
editable={false}
|
||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{pacienteNome}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Paciente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate("/paciente");
|
||||
}}
|
||||
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Nav */}
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
if (item.isLink && item.path) {
|
||||
navigate(item.path);
|
||||
} else if (item.id === "help") {
|
||||
navigate("/ajuda");
|
||||
} else {
|
||||
setActiveTab(item.id);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 dark:bg-slate-800 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="container mx-auto p-8">{renderContent()}</div>
|
||||
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modal de Visualização do Laudo */}
|
||||
{showLaudoModal && selectedLaudo && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header do Modal */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Detalhes do Laudo
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLaudoModal(false);
|
||||
setSelectedLaudo(null);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conteúdo do Modal */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Informações Principais */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Número do Pedido
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white font-semibold">
|
||||
{selectedLaudo.order_number}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
selectedLaudo.status === "completed"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
|
||||
: selectedLaudo.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: selectedLaudo.status === "cancelled"
|
||||
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selectedLaudo.status === "completed"
|
||||
? "Concluído"
|
||||
: selectedLaudo.status === "pending"
|
||||
? "Pendente"
|
||||
: selectedLaudo.status === "cancelled"
|
||||
? "Cancelado"
|
||||
: "Rascunho"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Data de Criação
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{new Date(selectedLaudo.created_at).toLocaleDateString(
|
||||
"pt-BR",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{selectedLaudo.due_at && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Prazo de Entrega
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{new Date(selectedLaudo.due_at).toLocaleDateString(
|
||||
"pt-BR",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Exame */}
|
||||
{selectedLaudo.exam && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Exame
|
||||
</label>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{selectedLaudo.exam}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnóstico */}
|
||||
{selectedLaudo.diagnosis && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Diagnóstico
|
||||
</label>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{selectedLaudo.diagnosis}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CID */}
|
||||
{selectedLaudo.cid_code && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
CID-10
|
||||
</label>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-900 dark:text-white font-mono">
|
||||
{selectedLaudo.cid_code}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conclusão */}
|
||||
{selectedLaudo.conclusion && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Conclusão
|
||||
</label>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
|
||||
{selectedLaudo.conclusion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Solicitado por */}
|
||||
{selectedLaudo.requested_by && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Solicitado por
|
||||
</label>
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-900 dark:text-white">
|
||||
{requestedByNames[selectedLaudo.requested_by] ||
|
||||
selectedLaudo.requested_by}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conteúdo HTML (se houver) */}
|
||||
{selectedLaudo.content_html && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Conteúdo Completo
|
||||
</label>
|
||||
<div
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 prose dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedLaudo.content_html,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer do Modal */}
|
||||
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-6 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowLaudoModal(false);
|
||||
setSelectedLaudo(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -184,274 +184,28 @@ const AgendamentoPaciente: React.FC = () => {
|
||||
|
||||
if (etapa === 4) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Consulta Agendada com Sucesso!
|
||||
</h2>
|
||||
<div className="bg-gray-50 rounded-lg p-6 mb-6 text-left">
|
||||
<h3 className="font-semibold mb-3">Detalhes do Agendamento:</h3>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<strong>Paciente:</strong> {pacienteLogado.nome}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Especialidade:</strong>{" "}
|
||||
{medicoSelecionado?.especialidade}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Data:</strong>{" "}
|
||||
{format(new Date(agendamento.data), "dd/MM/yyyy", {
|
||||
locale: ptBR,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Horário:</strong> {agendamento.horario}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Tipo:</strong> {agendamento.tipoConsulta}
|
||||
</p>
|
||||
{agendamento.motivoConsulta && (
|
||||
<p>
|
||||
<strong>Motivo:</strong> {agendamento.motivoConsulta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={resetarAgendamento} className="btn-primary">
|
||||
Fazer Novo Agendamento
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header com informações do paciente */}
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-500 rounded-xl p-6 mb-8 text-white shadow">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
Bem-vindo(a), {pacienteLogado.nome}!
|
||||
</h1>
|
||||
<p className="opacity-90">Agende sua consulta médica</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center space-x-2 bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/70"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sair</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* As consultas locais serão exibidas na Dashboard do paciente */}
|
||||
|
||||
{/* Indicador de Etapas */}
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{[1, 2, 3].map((numero) => (
|
||||
<React.Fragment key={numero}>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
etapa >= numero
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{numero}
|
||||
</div>
|
||||
{numero < 3 && (
|
||||
<div
|
||||
className={`w-16 h-1 ${
|
||||
etapa > numero ? "bg-blue-600" : "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow border border-gray-200 p-6">
|
||||
{/* Etapa 1: Seleção de Médico */}
|
||||
{etapa === 1 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold flex items-center">
|
||||
<User className="w-5 h-5 mr-2" />
|
||||
Selecione o Médico
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-lg sm:rounded-xl shadow-md p-6 sm:p-8 text-center">
|
||||
<CheckCircle className="w-12 h-12 sm:w-16 sm:h-16 text-green-500 mx-auto mb-3 sm:mb-4" />
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
|
||||
Consulta Agendada com Sucesso!
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Médico/Especialidade
|
||||
</label>
|
||||
<select
|
||||
value={agendamento.medicoId}
|
||||
onChange={(e) => handleMedicoChange(e.target.value)}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um médico</option>
|
||||
{medicos.map((medico) => (
|
||||
<option key={medico._id} value={medico._id}>
|
||||
{medico.nome} - {medico.especialidade} (R${" "}
|
||||
{medico.valorConsulta})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setEtapa(2)}
|
||||
disabled={!agendamento.medicoId}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Próximo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Etapa 2: Seleção de Data e Horário */}
|
||||
{etapa === 2 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
Selecione Data e Horário
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Data da Consulta
|
||||
</label>
|
||||
<select
|
||||
value={agendamento.data}
|
||||
onChange={(e) => handleDataChange(e.target.value)}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione uma data</option>
|
||||
{proximosSeteDias().map((dia) => (
|
||||
<option key={dia.valor} value={dia.valor}>
|
||||
{dia.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{agendamento.data && agendamento.medicoId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Horários Disponíveis
|
||||
</label>
|
||||
<AvailableSlotsPicker
|
||||
doctorId={agendamento.medicoId}
|
||||
date={agendamento.data}
|
||||
onSelect={(t) =>
|
||||
setAgendamento((prev) => ({ ...prev, horario: t }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setEtapa(1)}
|
||||
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEtapa(3)}
|
||||
disabled={!agendamento.horario}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
Próximo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Etapa 3: Informações Adicionais */}
|
||||
{etapa === 3 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
Informações da Consulta
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de Consulta
|
||||
</label>
|
||||
<select
|
||||
value={agendamento.tipoConsulta}
|
||||
onChange={(e) =>
|
||||
setAgendamento((prev) => ({
|
||||
...prev,
|
||||
tipoConsulta: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="primeira-vez">Primeira Consulta</option>
|
||||
<option value="retorno">Retorno</option>
|
||||
<option value="urgencia">Urgência</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Motivo da Consulta
|
||||
</label>
|
||||
<textarea
|
||||
value={agendamento.motivoConsulta}
|
||||
onChange={(e) =>
|
||||
setAgendamento((prev) => ({
|
||||
...prev,
|
||||
motivoConsulta: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input"
|
||||
rows={3}
|
||||
placeholder="Descreva brevemente o motivo da consulta"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Observações (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={agendamento.observacoes}
|
||||
onChange={(e) =>
|
||||
setAgendamento((prev) => ({
|
||||
...prev,
|
||||
observacoes: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input"
|
||||
rows={2}
|
||||
placeholder="Informações adicionais relevantes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resumo do Agendamento */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<h3 className="font-semibold mb-3">Resumo do Agendamento:</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>
|
||||
<div className="bg-gray-50 rounded-lg p-4 sm:p-6 mb-4 sm:mb-6 text-left">
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
|
||||
Detalhes do Agendamento:
|
||||
</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2 text-xs sm:text-sm">
|
||||
<p className="break-words">
|
||||
<strong>Paciente:</strong> {pacienteLogado.nome}
|
||||
</p>
|
||||
<p>
|
||||
<p className="break-words">
|
||||
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
||||
</p>
|
||||
<p className="break-words">
|
||||
<strong>Especialidade:</strong>{" "}
|
||||
{medicoSelecionado?.especialidade}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Data:</strong>{" "}
|
||||
{format(new Date(agendamento.data), "dd/MM/yyyy", {
|
||||
@ -462,28 +216,288 @@ const AgendamentoPaciente: React.FC = () => {
|
||||
<strong>Horário:</strong> {agendamento.horario}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Valor:</strong> R$ {medicoSelecionado?.valorConsulta}
|
||||
<strong>Tipo:</strong> {agendamento.tipoConsulta}
|
||||
</p>
|
||||
{agendamento.motivoConsulta && (
|
||||
<p className="break-words">
|
||||
<strong>Motivo:</strong> {agendamento.motivoConsulta}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setEtapa(2)}
|
||||
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmarAgendamento}
|
||||
disabled={loading}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
|
||||
>
|
||||
{loading ? "Agendando..." : "Confirmar Agendamento"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetarAgendamento}
|
||||
className="btn-primary w-full sm:w-auto text-sm sm:text-base"
|
||||
>
|
||||
Fazer Novo Agendamento
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6 lg:space-y-8">
|
||||
{/* Header com informações do paciente */}
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-500 rounded-lg sm:rounded-xl p-4 sm:p-6 text-white shadow">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold truncate">
|
||||
Bem-vindo(a), {pacienteLogado.nome}!
|
||||
</h1>
|
||||
<p className="opacity-90 text-sm sm:text-base">
|
||||
Agende sua consulta médica
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-white/20 hover:bg-white/30 px-3 sm:px-4 py-2 rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/70 text-sm sm:text-base whitespace-nowrap"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sair</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* As consultas locais serão exibidas na Dashboard do paciente */}
|
||||
|
||||
{/* Indicador de Etapas */}
|
||||
<div className="flex items-center justify-center mb-6 sm:mb-8">
|
||||
{[1, 2, 3].map((numero) => (
|
||||
<React.Fragment key={numero}>
|
||||
<div
|
||||
className={`w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-sm sm:text-base font-medium ${
|
||||
etapa >= numero
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{numero}
|
||||
</div>
|
||||
{numero < 3 && (
|
||||
<div
|
||||
className={`w-12 sm:w-16 h-1 ${
|
||||
etapa > numero ? "bg-blue-600" : "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg sm:rounded-xl shadow border border-gray-200 p-4 sm:p-6">
|
||||
{/* Etapa 1: Seleção de Médico */}
|
||||
{etapa === 1 && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
||||
<User className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||
Selecione o Médico
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||
Médico/Especialidade
|
||||
</label>
|
||||
<select
|
||||
value={agendamento.medicoId}
|
||||
onChange={(e) => handleMedicoChange(e.target.value)}
|
||||
className="form-input text-sm sm:text-base"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um médico</option>
|
||||
{medicos.map((medico) => (
|
||||
<option key={medico._id} value={medico._id}>
|
||||
{medico.nome} - {medico.especialidade} (R${" "}
|
||||
{medico.valorConsulta})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={() => setEtapa(2)}
|
||||
disabled={!agendamento.medicoId}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-sm sm:text-base"
|
||||
>
|
||||
Próximo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Etapa 2: Seleção de Data e Horário */}
|
||||
{etapa === 2 && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
||||
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||
Selecione Data e Horário
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||
Data da Consulta
|
||||
</label>
|
||||
<select
|
||||
value={agendamento.data}
|
||||
onChange={(e) => handleDataChange(e.target.value)}
|
||||
className="form-input text-sm sm:text-base"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione uma data</option>
|
||||
{proximosSeteDias().map((dia) => (
|
||||
<option key={dia.valor} value={dia.valor}>
|
||||
{dia.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{agendamento.data && agendamento.medicoId && (
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||
Horários Disponíveis
|
||||
</label>
|
||||
<AvailableSlotsPicker
|
||||
doctorId={agendamento.medicoId}
|
||||
date={agendamento.data}
|
||||
onSelect={(t) =>
|
||||
setAgendamento((prev) => ({ ...prev, horario: t }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setEtapa(1)}
|
||||
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEtapa(3)}
|
||||
disabled={!agendamento.horario}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
|
||||
>
|
||||
Próximo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Etapa 3: Informações Adicionais */}
|
||||
{etapa === 3 && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
||||
<FileText className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||
Informações da Consulta
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de Consulta
|
||||
</label>
|
||||
<select
|
||||
value={agendamento.tipoConsulta}
|
||||
onChange={(e) =>
|
||||
setAgendamento((prev) => ({
|
||||
...prev,
|
||||
tipoConsulta: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input text-sm sm:text-base"
|
||||
>
|
||||
<option value="primeira-vez">Primeira Consulta</option>
|
||||
<option value="retorno">Retorno</option>
|
||||
<option value="urgencia">Urgência</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||
Motivo da Consulta
|
||||
</label>
|
||||
<textarea
|
||||
value={agendamento.motivoConsulta}
|
||||
onChange={(e) =>
|
||||
setAgendamento((prev) => ({
|
||||
...prev,
|
||||
motivoConsulta: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input text-sm sm:text-base"
|
||||
rows={3}
|
||||
placeholder="Descreva brevemente o motivo da consulta"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||
Observações (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={agendamento.observacoes}
|
||||
onChange={(e) =>
|
||||
setAgendamento((prev) => ({
|
||||
...prev,
|
||||
observacoes: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input text-sm sm:text-base"
|
||||
rows={2}
|
||||
placeholder="Informações adicionais relevantes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resumo do Agendamento */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
|
||||
Resumo do Agendamento:
|
||||
</h3>
|
||||
<div className="space-y-1 sm:space-y-1.5 text-xs sm:text-sm">
|
||||
<p className="break-words">
|
||||
<strong>Paciente:</strong> {pacienteLogado.nome}
|
||||
</p>
|
||||
<p className="break-words">
|
||||
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Data:</strong>{" "}
|
||||
{format(new Date(agendamento.data), "dd/MM/yyyy", {
|
||||
locale: ptBR,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Horário:</strong> {agendamento.horario}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Valor:</strong> R${" "}
|
||||
{medicoSelecionado?.valorConsulta}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setEtapa(2)}
|
||||
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300 w-full sm:w-auto text-sm sm:text-base order-2 sm:order-1"
|
||||
>
|
||||
Voltar
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmarAgendamento}
|
||||
disabled={loading}
|
||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 w-full sm:w-auto text-sm sm:text-base order-1 sm:order-2"
|
||||
>
|
||||
{loading ? "Agendando..." : "Confirmar Agendamento"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -93,7 +93,6 @@ export default function AuthCallback() {
|
||||
|
||||
setStatus("success");
|
||||
setMessage("Autenticado com sucesso! Redirecionando...");
|
||||
toast.success("Login realizado com sucesso!");
|
||||
|
||||
// Verificar se há redirecionamento salvo do magic link
|
||||
const savedRedirect = localStorage.getItem("magic_link_redirect");
|
||||
|
||||
@ -491,7 +491,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, 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 focus:border-indigo-600/40"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -505,7 +505,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, 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 focus:border-indigo-600/40"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -519,7 +519,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, phone: 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 focus:border-indigo-600/40"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -753,7 +753,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="usuario@exemplo.com"
|
||||
/>
|
||||
</div>
|
||||
@ -773,7 +773,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
/>
|
||||
</div>
|
||||
@ -793,7 +793,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="João da Silva"
|
||||
/>
|
||||
</div>
|
||||
@ -808,7 +808,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
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"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
<option value="admin">Admin</option>
|
||||
@ -833,7 +833,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="(11) 99999-9999"
|
||||
/>
|
||||
</div>
|
||||
@ -873,7 +873,7 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
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"
|
||||
className="form-input"
|
||||
placeholder="12345678901"
|
||||
maxLength={11}
|
||||
/>
|
||||
@ -907,3 +907,5 @@ const GerenciarUsuarios: React.FC = () => {
|
||||
};
|
||||
|
||||
export default GerenciarUsuarios;
|
||||
|
||||
|
||||
|
||||
@ -112,7 +112,10 @@ const Home: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8" id="main-content">
|
||||
<div
|
||||
className="space-y-6 sm:space-y-8 px-4 sm:px-6 lg:px-8"
|
||||
id="main-content"
|
||||
>
|
||||
{/* Componente invisível que detecta tokens de recuperação e redireciona */}
|
||||
<RecoveryRedirect />
|
||||
|
||||
@ -121,7 +124,7 @@ const Home: React.FC = () => {
|
||||
|
||||
{/* Métricas */}
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6"
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6"
|
||||
role="region"
|
||||
aria-label="Estatísticas do sistema"
|
||||
>
|
||||
@ -184,7 +187,7 @@ const Home: React.FC = () => {
|
||||
|
||||
{/* Cards de Ação */}
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6"
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 md:gap-6"
|
||||
role="region"
|
||||
aria-label="Ações rápidas"
|
||||
>
|
||||
@ -253,24 +256,29 @@ const ActionCard: React.FC<ActionCardProps> = ({
|
||||
onAction,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-5 md:p-6 hover:shadow-xl transition-all duration-200 group border border-gray-100 focus-within:ring-2 focus-within:ring-blue-500/50 focus-within:ring-offset-2">
|
||||
<div className="bg-white rounded-lg shadow-md p-4 sm:p-5 md:p-6 hover:shadow-xl transition-all duration-200 group border border-gray-100 focus-within:ring-2 focus-within:ring-blue-500/50 focus-within:ring-offset-2">
|
||||
<div
|
||||
className={`w-12 h-12 ${iconBgColor} rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}
|
||||
className={`w-10 h-10 sm:w-12 sm:h-12 ${iconBgColor} rounded-lg flex items-center justify-center mb-3 sm:mb-4 group-hover:scale-110 transition-transform`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 text-white`} aria-hidden="true" />
|
||||
<Icon
|
||||
className={`w-5 h-5 sm:w-6 sm:h-6 text-white`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">{title}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4 leading-relaxed">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-2 text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-sm text-gray-600 mb-3 sm:mb-4 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="w-full inline-flex items-center justify-center px-4 py-2.5 bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 hover:scale-105 active:scale-95 text-white rounded-lg font-medium transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/80 group-hover:shadow-lg"
|
||||
className="w-full inline-flex items-center justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-gradient-to-r from-blue-700 to-blue-400 hover:from-blue-800 hover:to-blue-500 hover:scale-105 active:scale-95 text-white rounded-lg text-sm sm:text-base font-medium transition-all duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-white/80 group-hover:shadow-lg"
|
||||
aria-label={ctaAriaLabel}
|
||||
>
|
||||
{ctaLabel}
|
||||
<ArrowRight
|
||||
className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform"
|
||||
className="w-3.5 h-3.5 sm:w-4 sm:h-4 ml-2 group-hover:translate-x-1 transition-transform"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@ -60,66 +60,93 @@ const ListaMedicos: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope className="w-6 h-6 text-indigo-600" />
|
||||
<h2 className="text-2xl font-bold">Médicos Cadastrados</h2>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-gray-500">Carregando médicos...</div>}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="flex items-center gap-2 text-red-700 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
|
||||
{/* Cabeçalho Responsivo */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Stethoscope className="w-5 h-5 sm:w-6 sm:h-6 text-indigo-600 flex-shrink-0" />
|
||||
<h2 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900">
|
||||
Médicos Cadastrados
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && medicos.length === 0 && (
|
||||
<div className="text-gray-500">Nenhum médico cadastrado.</div>
|
||||
)}
|
||||
{/* Estados de Loading/Error */}
|
||||
{loading && (
|
||||
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||
Carregando médicos...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && medicos.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{medicos.map((medico) => (
|
||||
<article
|
||||
key={medico.id}
|
||||
className="bg-white rounded-xl shadow border border-gray-200 p-6 flex flex-col gap-3 hover:shadow-md transition-shadow focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
||||
tabIndex={0}
|
||||
>
|
||||
<header className="flex items-center gap-2">
|
||||
{medico.avatar_url ? (
|
||||
<img
|
||||
src={medico.avatar_url}
|
||||
alt={medico.nome}
|
||||
className="h-10 w-10 rounded-full object-cover border"
|
||||
/>
|
||||
) : (
|
||||
<AvatarInitials name={medico.nome} size={40} />
|
||||
)}
|
||||
<Stethoscope className="w-5 h-5 text-indigo-600" />
|
||||
<h3 className="font-semibold text-lg text-gray-900">
|
||||
{medico.nome}
|
||||
</h3>
|
||||
</header>
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>Especialidade:</strong> {medico.especialidade}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>CRM:</strong> {medico.crm}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<Mail className="w-4 h-4" /> {medico.email}
|
||||
</div>
|
||||
{medico.telefone && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<Phone className="w-4 h-4" /> {medico.telefone}
|
||||
{!loading && error && (
|
||||
<div className="flex items-start sm:items-center gap-2 text-red-700 bg-red-50 border border-red-200 p-3 sm:p-4 rounded-lg text-sm sm:text-base">
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5 sm:mt-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && medicos.length === 0 && (
|
||||
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||
Nenhum médico cadastrado.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid de Médicos - Responsivo */}
|
||||
{!loading && !error && medicos.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
|
||||
{medicos.map((medico) => (
|
||||
<article
|
||||
key={medico.id}
|
||||
className="bg-white rounded-lg sm:rounded-xl shadow-sm hover:shadow-md border border-gray-200 p-4 sm:p-5 lg:p-6 flex flex-col gap-2.5 sm:gap-3 transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Header do Card */}
|
||||
<header className="flex items-center gap-2 sm:gap-3">
|
||||
{medico.avatar_url ? (
|
||||
<img
|
||||
src={medico.avatar_url}
|
||||
alt={medico.nome}
|
||||
className="h-8 w-8 sm:h-10 sm:w-10 rounded-full object-cover border flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-shrink-0">
|
||||
<AvatarInitials name={medico.nome} size={40} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Stethoscope className="w-4 h-4 text-indigo-600 flex-shrink-0" />
|
||||
<h3 className="font-semibold text-sm sm:text-base lg:text-lg text-gray-900 truncate">
|
||||
{medico.nome}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Informações do Médico */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<div className="text-xs sm:text-sm text-gray-700">
|
||||
<strong className="font-medium">Especialidade:</strong>{" "}
|
||||
<span className="break-words">{medico.especialidade}</span>
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-700">
|
||||
<strong className="font-medium">CRM:</strong> {medico.crm}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
|
||||
<span className="break-all">{medico.email}</span>
|
||||
</div>
|
||||
{medico.telefone && (
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span>{medico.telefone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -58,56 +58,84 @@ const ListaPacientes: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-blue-600" /> Pacientes Cadastrados
|
||||
</h2>
|
||||
{loading && <div className="text-gray-500">Carregando pacientes...</div>}
|
||||
{!loading && error && (
|
||||
<div className="text-red-600 bg-red-50 border border-red-200 p-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && pacientes.length === 0 && (
|
||||
<div className="text-gray-500">Nenhum paciente cadastrado.</div>
|
||||
)}
|
||||
{!loading && !error && pacientes.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pacientes.map((paciente, idx) => (
|
||||
<div
|
||||
key={paciente.id}
|
||||
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${
|
||||
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||
}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AvatarInitials name={paciente.full_name} size={40} />
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-semibold text-lg">
|
||||
{paciente.full_name}
|
||||
</span>
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
|
||||
<h2 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-3 sm:mb-4 flex items-center gap-2 sm:gap-3">
|
||||
<Users className="w-5 h-5 sm:w-6 sm:h-6 text-blue-600 flex-shrink-0" />{" "}
|
||||
Pacientes Cadastrados
|
||||
</h2>
|
||||
|
||||
{loading && (
|
||||
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||
Carregando pacientes...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="text-sm sm:text-base text-red-600 bg-red-50 border border-red-200 p-3 sm:p-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && pacientes.length === 0 && (
|
||||
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||
Nenhum paciente cadastrado.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && pacientes.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
|
||||
{pacientes.map((paciente, idx) => (
|
||||
<div
|
||||
key={paciente.id}
|
||||
className={`rounded-lg sm:rounded-xl p-4 sm:p-5 lg:p-6 flex flex-col gap-2 sm:gap-2.5 transition-all border border-gray-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 ${
|
||||
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||
}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
|
||||
<div className="flex-shrink-0">
|
||||
<AvatarInitials name={paciente.full_name} size={40} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600 flex-shrink-0" />
|
||||
<span className="font-semibold text-sm sm:text-base lg:text-lg truncate">
|
||||
{paciente.full_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<div className="text-xs sm:text-sm text-gray-700">
|
||||
<strong className="font-medium">CPF:</strong>{" "}
|
||||
{formatCPF(paciente.cpf)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
|
||||
<span className="break-all">
|
||||
{formatEmail(paciente.email)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{formatPhone(paciente.phone_mobile)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 pt-1">
|
||||
<strong className="font-medium">Nascimento:</strong>{" "}
|
||||
{paciente.birth_date
|
||||
? new Date(paciente.birth_date).toLocaleDateString()
|
||||
: "Não informado"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>CPF:</strong> {formatCPF(paciente.cpf)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<Phone className="w-4 h-4" />{" "}
|
||||
{formatPhone(paciente.phone_mobile)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Nascimento:{" "}
|
||||
{paciente.birth_date
|
||||
? new Date(paciente.birth_date).toLocaleDateString()
|
||||
: "Não informado"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -18,42 +18,56 @@ const ListaSecretarias: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
<UserPlus className="w-6 h-6 text-green-600" /> Secretárias Cadastradas
|
||||
</h2>
|
||||
{secretarias.length === 0 ? (
|
||||
<div className="text-gray-500">Nenhuma secretária cadastrada.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{secretarias.map((sec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`rounded-lg p-6 flex flex-col gap-2 transition-colors border border-gray-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50 ${
|
||||
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||
}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<UserPlus className="w-5 h-5 text-green-600" />
|
||||
<span className="font-semibold text-lg">{sec.nome}</span>
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
|
||||
<h2 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-3 sm:mb-4 flex items-center gap-2 sm:gap-3">
|
||||
<UserPlus className="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />{" "}
|
||||
Secretárias Cadastradas
|
||||
</h2>
|
||||
|
||||
{secretarias.length === 0 ? (
|
||||
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||
Nenhuma secretária cadastrada.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-5 lg:gap-6">
|
||||
{secretarias.map((sec, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`rounded-lg sm:rounded-xl p-4 sm:p-5 lg:p-6 flex flex-col gap-2 sm:gap-2.5 transition-all border border-gray-200 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50 ${
|
||||
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||
}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
|
||||
<UserPlus className="w-4 h-4 sm:w-5 sm:h-5 text-green-600 flex-shrink-0" />
|
||||
<span className="font-semibold text-sm sm:text-base lg:text-lg truncate">
|
||||
{sec.nome}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<div className="text-xs sm:text-sm text-gray-700">
|
||||
<strong className="font-medium">CPF:</strong> {sec.cpf}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0 mt-0.5" />
|
||||
<span className="break-all">{sec.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="break-words">{sec.telefone}</span>
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 pt-1">
|
||||
<strong className="font-medium">Cadastrada em:</strong>{" "}
|
||||
{new Date(sec.criadoEm).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>CPF:</strong> {sec.cpf}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<Mail className="w-4 h-4" /> {sec.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<Phone className="w-4 h-4" /> {sec.telefone}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Cadastrada em: {new Date(sec.criadoEm).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Mail, Lock, Stethoscope } from "lucide-react";
|
||||
import { Mail, Lock, Stethoscope, Eye, EyeOff } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
@ -11,6 +11,7 @@ const LoginMedico: React.FC = () => {
|
||||
senha: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { loginComEmailSenha } = useAuth();
|
||||
@ -137,16 +138,28 @@ const LoginMedico: React.FC = () => {
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="med_password"
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.senha}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, senha: e.target.value }))
|
||||
}
|
||||
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
className="form-input pl-10 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
placeholder="Sua senha"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right mt-2">
|
||||
<button
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { User, Mail, Lock } from "lucide-react";
|
||||
import { User, Mail, Lock, Eye, EyeOff } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
@ -12,6 +12,7 @@ const LoginPaciente: React.FC = () => {
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCadastro, setShowCadastro] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [cadastroData, setCadastroData] = useState({
|
||||
nome: "",
|
||||
email: "",
|
||||
@ -244,7 +245,7 @@ const LoginPaciente: React.FC = () => {
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="login_password"
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.senha}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
@ -252,11 +253,23 @@ const LoginPaciente: React.FC = () => {
|
||||
senha: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
className="form-input pl-10 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
placeholder="Sua senha"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right mt-2">
|
||||
<button
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Mail, Lock, Clipboard } from "lucide-react";
|
||||
import { Mail, Lock, Clipboard, Eye, EyeOff } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
@ -11,6 +11,7 @@ const LoginSecretaria: React.FC = () => {
|
||||
senha: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { loginComEmailSenha } = useAuth();
|
||||
@ -149,16 +150,28 @@ const LoginSecretaria: React.FC = () => {
|
||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
id="sec_password"
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.senha}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, senha: e.target.value }))
|
||||
}
|
||||
className="form-input pl-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
className="form-input pl-10 pr-10 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-100"
|
||||
placeholder="Sua senha"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title={showPassword ? "Ocultar senha" : "Mostrar senha"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right mt-2">
|
||||
<button
|
||||
|
||||
@ -88,7 +88,6 @@ const PainelAdmin: React.FC = () => {
|
||||
role: "user",
|
||||
});
|
||||
const [userPassword, setUserPassword] = useState("");
|
||||
const [usePassword, setUsePassword] = useState(false);
|
||||
const [userCpf, setUserCpf] = useState("");
|
||||
const [userPhoneMobile, setUserPhoneMobile] = useState("");
|
||||
const [createPatientRecord, setCreatePatientRecord] = useState(false);
|
||||
@ -256,70 +255,74 @@ const PainelAdmin: React.FC = () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Determina redirect_url baseado no role
|
||||
let redirectUrl = "https://mediconnectbrasil.netlify.app/";
|
||||
if (formUser.role === "medico") {
|
||||
redirectUrl = "https://mediconnectbrasil.netlify.app/medico/painel";
|
||||
} else if (formUser.role === "paciente") {
|
||||
redirectUrl =
|
||||
"https://mediconnectbrasil.netlify.app/paciente/agendamento";
|
||||
} else if (formUser.role === "secretaria") {
|
||||
redirectUrl = "https://mediconnectbrasil.netlify.app/secretaria/painel";
|
||||
} else if (formUser.role === "admin" || formUser.role === "gestor") {
|
||||
redirectUrl = "https://mediconnectbrasil.netlify.app/admin/painel";
|
||||
// Validação: CPF é obrigatório
|
||||
if (!userCpf || getOnlyNumbers(userCpf).length !== 11) {
|
||||
toast.error("CPF é obrigatório e deve ter 11 dígitos");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Criar com senha OU magic link
|
||||
if (usePassword && userPassword.trim()) {
|
||||
// Criar com senha
|
||||
await userService.createUserWithPassword({
|
||||
email: formUser.email,
|
||||
password: userPassword,
|
||||
full_name: formUser.full_name,
|
||||
phone: formUser.phone,
|
||||
phone_mobile: userPhoneMobile,
|
||||
cpf: userCpf,
|
||||
role: formUser.role,
|
||||
create_patient_record: createPatientRecord,
|
||||
});
|
||||
toast.success(
|
||||
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
||||
);
|
||||
} else {
|
||||
// Criar com magic link (padrão)
|
||||
await userService.createUser(
|
||||
{ ...formUser, redirect_url: redirectUrl },
|
||||
false
|
||||
);
|
||||
toast.success(
|
||||
`Usuário ${formUser.full_name} criado com sucesso! Magic link enviado para o email.`
|
||||
);
|
||||
// Validação: Senha é obrigatória
|
||||
if (!userPassword || userPassword.length < 6) {
|
||||
toast.error("Senha é obrigatória e deve ter no mínimo 6 caracteres");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Formatar CPF para o formato esperado pela API (XXX.XXX.XXX-XX)
|
||||
const formattedCpf = formatCPF(userCpf);
|
||||
|
||||
// Formatar telefone celular se fornecido
|
||||
const formattedPhoneMobile = userPhoneMobile
|
||||
? formatPhone(userPhoneMobile)
|
||||
: "";
|
||||
|
||||
// Criar usuário com senha (método obrigatório com CPF)
|
||||
await userService.createUserWithPassword({
|
||||
email: formUser.email.trim(),
|
||||
password: userPassword,
|
||||
full_name: formUser.full_name.trim(),
|
||||
phone: formUser.phone || undefined,
|
||||
phone_mobile: formattedPhoneMobile || undefined,
|
||||
cpf: formattedCpf,
|
||||
role: formUser.role,
|
||||
create_patient_record: createPatientRecord,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
||||
);
|
||||
|
||||
setShowUserModal(false);
|
||||
resetFormUser();
|
||||
setUserPassword("");
|
||||
setUsePassword(false);
|
||||
setUserCpf("");
|
||||
setUserPhoneMobile("");
|
||||
setCreatePatientRecord(false);
|
||||
loadUsuarios();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Erro ao criar usuário:", error);
|
||||
|
||||
// Mostrar mensagem de erro detalhada
|
||||
const errorMessage =
|
||||
error?.response?.data?.message ||
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
(
|
||||
error as {
|
||||
response?: { data?: { message?: string; error?: string } };
|
||||
message?: string;
|
||||
}
|
||||
)?.response?.data?.message ||
|
||||
(
|
||||
error as {
|
||||
response?: { data?: { message?: string; error?: string } };
|
||||
message?: string;
|
||||
}
|
||||
)?.response?.data?.error ||
|
||||
(error as { message?: string })?.message ||
|
||||
"Erro ao criar usuário";
|
||||
|
||||
if (
|
||||
errorMessage.includes("already") ||
|
||||
errorMessage.includes("exists") ||
|
||||
errorMessage.includes("duplicate")
|
||||
errorMessage.includes("duplicate") ||
|
||||
errorMessage.includes("já existe")
|
||||
) {
|
||||
toast.error(`Email já cadastrado no sistema`);
|
||||
toast.error("Email ou CPF já cadastrado no sistema");
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
@ -513,11 +516,14 @@ const PainelAdmin: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limpar telefone (remover formatação)
|
||||
const phoneLimpo = formPaciente.phone_mobile.replace(/\D/g, "");
|
||||
|
||||
const patientData = {
|
||||
full_name: formPaciente.full_name,
|
||||
cpf: cpfLimpo,
|
||||
email: formPaciente.email,
|
||||
phone_mobile: formPaciente.phone_mobile,
|
||||
phone_mobile: phoneLimpo,
|
||||
birth_date: formPaciente.birth_date || undefined,
|
||||
social_name: formPaciente.social_name,
|
||||
sex: formPaciente.sex,
|
||||
@ -702,6 +708,11 @@ const PainelAdmin: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Limpar telefone (remover formatação)
|
||||
const phoneLimpo = medicoData.phone_mobile
|
||||
? medicoData.phone_mobile.replace(/\D/g, "")
|
||||
: undefined;
|
||||
|
||||
console.log("[PainelAdmin] Criando médico com API /create-doctor:", {
|
||||
email: medicoData.email,
|
||||
full_name: medicoData.full_name,
|
||||
@ -717,7 +728,7 @@ const PainelAdmin: React.FC = () => {
|
||||
crm: medicoData.crm,
|
||||
crm_uf: medicoData.crm_uf,
|
||||
specialty: medicoData.specialty || undefined,
|
||||
phone_mobile: medicoData.phone_mobile || undefined,
|
||||
phone_mobile: phoneLimpo,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
@ -832,6 +843,47 @@ const PainelAdmin: React.FC = () => {
|
||||
phone: "",
|
||||
role: "user",
|
||||
});
|
||||
setUserCpf("");
|
||||
setUserPhoneMobile("");
|
||||
setUserPassword("");
|
||||
setCreatePatientRecord(false);
|
||||
};
|
||||
|
||||
// Função para formatar CPF (XXX.XXX.XXX-XX)
|
||||
const formatCPF = (value: string): string => {
|
||||
const numbers = value.replace(/\D/g, "");
|
||||
if (numbers.length <= 3) return numbers;
|
||||
if (numbers.length <= 6)
|
||||
return `${numbers.slice(0, 3)}.${numbers.slice(3)}`;
|
||||
if (numbers.length <= 9)
|
||||
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
|
||||
6
|
||||
)}`;
|
||||
return `${numbers.slice(0, 3)}.${numbers.slice(3, 6)}.${numbers.slice(
|
||||
6,
|
||||
9
|
||||
)}-${numbers.slice(9, 11)}`;
|
||||
};
|
||||
|
||||
// Função para formatar telefone ((XX) XXXXX-XXXX)
|
||||
const formatPhone = (value: string): string => {
|
||||
const numbers = value.replace(/\D/g, "");
|
||||
if (numbers.length <= 2) return numbers;
|
||||
if (numbers.length <= 7)
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
|
||||
if (numbers.length <= 11)
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
|
||||
7
|
||||
)}`;
|
||||
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(
|
||||
7,
|
||||
11
|
||||
)}`;
|
||||
};
|
||||
|
||||
// Função para obter apenas números do CPF/telefone
|
||||
const getOnlyNumbers = (value: string): string => {
|
||||
return value.replace(/\D/g, "");
|
||||
};
|
||||
|
||||
const resetFormMedico = () => {
|
||||
@ -1413,25 +1465,21 @@ const PainelAdmin: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
CPF *{" "}
|
||||
<span className="text-xs text-gray-500">
|
||||
(11 dígitos)
|
||||
</span>
|
||||
CPF *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formPaciente.cpf}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, ""); // Remove não-dígitos
|
||||
onChange={(e) =>
|
||||
setFormPaciente({
|
||||
...formPaciente,
|
||||
cpf: value,
|
||||
});
|
||||
}}
|
||||
maxLength={11}
|
||||
cpf: formatCPF(e.target.value),
|
||||
})
|
||||
}
|
||||
maxLength={14}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-green-600/40"
|
||||
placeholder="12345678901"
|
||||
placeholder="000.000.000-00"
|
||||
/>
|
||||
{formPaciente.cpf &&
|
||||
formPaciente.cpf.replace(/\D/g, "").length !== 11 && (
|
||||
@ -1468,9 +1516,10 @@ const PainelAdmin: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setFormPaciente({
|
||||
...formPaciente,
|
||||
phone_mobile: e.target.value,
|
||||
phone_mobile: formatPhone(e.target.value),
|
||||
})
|
||||
}
|
||||
maxLength={15}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-600 focus:border-green-600/40"
|
||||
placeholder="(00) 00000-0000"
|
||||
/>
|
||||
@ -1620,18 +1669,37 @@ const PainelAdmin: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Telefone
|
||||
CPF *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formUser.phone || ""}
|
||||
onChange={(e) =>
|
||||
setFormUser({ ...formUser, phone: e.target.value })
|
||||
}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
|
||||
placeholder="(00) 00000-0000"
|
||||
required
|
||||
value={userCpf}
|
||||
onChange={(e) => setUserCpf(formatCPF(e.target.value))}
|
||||
maxLength={14}
|
||||
className="form-input"
|
||||
placeholder="000.000.000-00"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Obrigatório para todos os usuários
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Senha *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={userPassword}
|
||||
onChange={(e) => setUserPassword(e.target.value)}
|
||||
minLength={6}
|
||||
className="form-input"
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Role/Papel *
|
||||
@ -1645,128 +1713,106 @@ const PainelAdmin: React.FC = () => {
|
||||
role: e.target.value as UserRole,
|
||||
})
|
||||
}
|
||||
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"
|
||||
className="form-select"
|
||||
>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
{role === "paciente"
|
||||
? "Paciente"
|
||||
: role === "medico"
|
||||
? "Médico"
|
||||
: role === "secretaria"
|
||||
? "Secretária"
|
||||
: role === "admin"
|
||||
? "Administrador"
|
||||
: role === "gestor"
|
||||
? "Gestor"
|
||||
: role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Toggle para criar com senha */}
|
||||
<div className="border-t pt-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usePassword}
|
||||
onChange={(e) => setUsePassword(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
Criar com senha (alternativa ao Magic Link)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
Campos Opcionais
|
||||
</h3>
|
||||
|
||||
{/* Campo de senha (condicional) */}
|
||||
{usePassword && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Senha *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required={usePassword}
|
||||
value={userPassword}
|
||||
onChange={(e) => setUserPassword(e.target.value)}
|
||||
minLength={6}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
O usuário precisará confirmar o email antes de fazer
|
||||
login
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Telefone Celular (obrigatório quando usa senha) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Telefone Celular *
|
||||
Telefone Fixo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formUser.phone || ""}
|
||||
onChange={(e) =>
|
||||
setFormUser({
|
||||
...formUser,
|
||||
phone: formatPhone(e.target.value),
|
||||
})
|
||||
}
|
||||
maxLength={15}
|
||||
className="form-input"
|
||||
placeholder="(00) 0000-0000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Telefone Celular
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
onChange={(e) =>
|
||||
setUserPhoneMobile(formatPhone(e.target.value))
|
||||
}
|
||||
maxLength={15}
|
||||
className="form-input"
|
||||
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>
|
||||
{/* Criar registro de paciente - apenas para role paciente */}
|
||||
{formUser.role === "paciente" && (
|
||||
<div className="border-t pt-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createPatientRecord}
|
||||
onChange={(e) =>
|
||||
setCreatePatientRecord(e.target.checked)
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
Criar também registro completo de paciente
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
Recomendado para ter acesso completo aos dados médicos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!usePassword && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Um Magic Link será enviado para o email do usuário para
|
||||
ativação da conta
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p className="text-sm font-semibold text-blue-900 mb-1">
|
||||
✅ Campos Obrigatórios (Todos os Roles)
|
||||
</p>
|
||||
<ul className="text-xs text-blue-700 space-y-0.5 ml-4 list-disc">
|
||||
<li>Nome Completo</li>
|
||||
<li>Email (único no sistema)</li>
|
||||
<li>CPF (formato: XXX.XXX.XXX-XX)</li>
|
||||
<li>Senha (mínimo 6 caracteres)</li>
|
||||
<li>Role/Papel</li>
|
||||
</ul>
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
ℹ️ Email de confirmação será enviado automaticamente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-4">
|
||||
<button
|
||||
@ -1878,20 +1924,19 @@ const PainelAdmin: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
CPF *{" "}
|
||||
<span className="text-xs text-gray-500">
|
||||
(11 dígitos)
|
||||
</span>
|
||||
CPF *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formMedico.cpf}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, ""); // Remove não-dígitos
|
||||
setFormMedico({ ...formMedico, cpf: value });
|
||||
}}
|
||||
maxLength={11}
|
||||
onChange={(e) =>
|
||||
setFormMedico({
|
||||
...formMedico,
|
||||
cpf: formatCPF(e.target.value),
|
||||
})
|
||||
}
|
||||
maxLength={14}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
|
||||
placeholder="12345678901"
|
||||
/>
|
||||
@ -1938,9 +1983,10 @@ const PainelAdmin: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setFormMedico({
|
||||
...formMedico,
|
||||
phone_mobile: e.target.value,
|
||||
phone_mobile: formatPhone(e.target.value),
|
||||
})
|
||||
}
|
||||
maxLength={15}
|
||||
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
|
||||
placeholder="(11) 98888-8888"
|
||||
/>
|
||||
@ -2048,7 +2094,7 @@ const PainelAdmin: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, 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-blue-600 focus:border-blue-600/40"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -2062,7 +2108,7 @@ const PainelAdmin: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, 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-blue-600 focus:border-blue-600/40"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -2076,7 +2122,7 @@ const PainelAdmin: React.FC = () => {
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, phone: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600/40"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
User,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { format } from "date-fns";
|
||||
@ -27,6 +28,7 @@ import {
|
||||
appointmentService,
|
||||
patientService,
|
||||
reportService,
|
||||
doctorService,
|
||||
type Appointment,
|
||||
type Patient,
|
||||
type CreateReportInput,
|
||||
@ -64,14 +66,14 @@ const PainelMedico: React.FC = () => {
|
||||
(user.role === "medico" ||
|
||||
roles.includes("medico") ||
|
||||
roles.includes("admin"));
|
||||
const medicoId = temAcessoMedico ? user.id : "";
|
||||
const medicoNome = user?.nome || "Médico";
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
const [doctorId, setDoctorId] = useState<string | null>(null); // ID real do médico na tabela doctors
|
||||
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState("dashboard");
|
||||
const [consultas, setConsultas] = useState<ConsultaUI[]>([]);
|
||||
const [filtroData, setFiltroData] = useState("hoje");
|
||||
const [filtroData, setFiltroData] = useState("todas");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ConsultaUI | null>(null);
|
||||
@ -97,9 +99,77 @@ const PainelMedico: React.FC = () => {
|
||||
hide_signature: false,
|
||||
});
|
||||
|
||||
// Estados para perfil do médico
|
||||
const [isEditingProfile, setIsEditingProfile] = useState(false);
|
||||
const [profileTab, setProfileTab] = useState<
|
||||
"personal" | "professional" | "security"
|
||||
>("personal");
|
||||
const [profileData, setProfileData] = useState({
|
||||
full_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
cpf: "",
|
||||
birth_date: "",
|
||||
sex: "",
|
||||
street: "",
|
||||
number: "",
|
||||
complement: "",
|
||||
neighborhood: "",
|
||||
city: "",
|
||||
state: "",
|
||||
cep: "",
|
||||
crm: "",
|
||||
specialty: "",
|
||||
});
|
||||
|
||||
// Buscar o ID do médico na tabela doctors usando o user_id ou email do Supabase Auth
|
||||
useEffect(() => {
|
||||
if (!medicoId) navigate("/login-medico");
|
||||
}, [medicoId, navigate]);
|
||||
const fetchDoctorId = async () => {
|
||||
if (user?.id && user.role === "medico") {
|
||||
try {
|
||||
// Tentar buscar por user_id primeiro
|
||||
let doctor = await doctorService.getByUserId(user.id);
|
||||
|
||||
// Se não encontrar por user_id, tentar por email
|
||||
if (!doctor && user.email) {
|
||||
console.log(
|
||||
"[PainelMedico] Médico não encontrado por user_id, tentando por email:",
|
||||
user.email
|
||||
);
|
||||
doctor = await doctorService.getByEmail(user.email);
|
||||
}
|
||||
|
||||
if (doctor) {
|
||||
setDoctorId(doctor.id);
|
||||
console.log(
|
||||
"[PainelMedico] Doctor ID encontrado:",
|
||||
doctor.id,
|
||||
"para",
|
||||
doctor.full_name
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[PainelMedico] Médico não encontrado na tabela doctors para user_id:",
|
||||
user.id,
|
||||
"ou email:",
|
||||
user.email
|
||||
);
|
||||
toast.error(
|
||||
"Perfil de médico não encontrado. Entre em contato com o administrador para vincular seu usuário."
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[PainelMedico] Erro ao buscar doctor_id:", error);
|
||||
toast.error("Erro ao carregar perfil do médico");
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchDoctorId();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) navigate("/login-medico");
|
||||
}, [user, navigate]);
|
||||
|
||||
// Carregar avatar ao montar componente
|
||||
useEffect(() => {
|
||||
@ -119,7 +189,7 @@ const PainelMedico: React.FC = () => {
|
||||
console.log(`[PainelMedico] Avatar encontrado: ${url}`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Continua testando próxima extensão
|
||||
}
|
||||
}
|
||||
@ -137,8 +207,11 @@ const PainelMedico: React.FC = () => {
|
||||
appointments = await appointmentService.list();
|
||||
} else {
|
||||
// Médico comum: busca todas as consultas do próprio médico
|
||||
if (!medicoId) return;
|
||||
appointments = await appointmentService.list({ doctor_id: medicoId });
|
||||
if (!doctorId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
appointments = await appointmentService.list({ doctor_id: doctorId });
|
||||
}
|
||||
if (appointments && appointments.length > 0) {
|
||||
// Buscar nomes dos pacientes
|
||||
@ -177,17 +250,17 @@ const PainelMedico: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user, roles, medicoId, medicoNome]);
|
||||
}, [user, roles, doctorId, medicoNome]);
|
||||
|
||||
const fetchLaudos = useCallback(async () => {
|
||||
if (!medicoId) return;
|
||||
if (!doctorId) return;
|
||||
setLoadingLaudos(true);
|
||||
try {
|
||||
// Buscar todos os laudos e filtrar pelo médico criador
|
||||
const allReports = await reportService.list();
|
||||
// Filtrar apenas laudos criados por este médico (created_by = medicoId)
|
||||
// Filtrar apenas laudos criados por este médico (created_by = doctorId)
|
||||
const meusLaudos = allReports.filter(
|
||||
(report: Report) => report.created_by === medicoId
|
||||
(report: Report) => report.created_by === doctorId
|
||||
);
|
||||
setLaudos(meusLaudos);
|
||||
} catch (error) {
|
||||
@ -197,7 +270,7 @@ const PainelMedico: React.FC = () => {
|
||||
} finally {
|
||||
setLoadingLaudos(false);
|
||||
}
|
||||
}, [medicoId]);
|
||||
}, [doctorId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConsultas();
|
||||
@ -746,66 +819,105 @@ const PainelMedico: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAppointments = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Todas as Consultas
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleNovaConsulta}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nova Consulta
|
||||
</button>
|
||||
</div>
|
||||
// Função para filtrar consultas por data
|
||||
const filtrarConsultasPorData = (consultas: ConsultaUI[]) => {
|
||||
const hoje = new Date();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
{["hoje", "amanha", "semana", "todas"].map((filtro) => (
|
||||
const amanha = new Date(hoje);
|
||||
amanha.setDate(amanha.getDate() + 1);
|
||||
|
||||
const fimDaSemana = new Date(hoje);
|
||||
fimDaSemana.setDate(fimDaSemana.getDate() + 7);
|
||||
|
||||
return consultas.filter((consulta) => {
|
||||
const dataConsulta = new Date(consulta.dataHora);
|
||||
dataConsulta.setHours(0, 0, 0, 0);
|
||||
|
||||
switch (filtroData) {
|
||||
case "hoje":
|
||||
return dataConsulta.getTime() === hoje.getTime();
|
||||
case "amanha":
|
||||
return dataConsulta.getTime() === amanha.getTime();
|
||||
case "semana":
|
||||
return dataConsulta >= hoje && dataConsulta <= fimDaSemana;
|
||||
case "todas":
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderAppointments = () => {
|
||||
const consultasFiltradas = filtrarConsultasPorData(consultas);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Todas as Consultas
|
||||
</h1>
|
||||
<button
|
||||
key={filtro}
|
||||
onClick={() => setFiltroData(filtro)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
|
||||
filtroData === filtro
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-white dark:bg-slate-900 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
onClick={handleNovaConsulta}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
|
||||
>
|
||||
{filtro === "hoje"
|
||||
? "Hoje"
|
||||
: filtro === "amanha"
|
||||
? "Amanhã"
|
||||
: filtro === "semana"
|
||||
? "Esta Semana"
|
||||
: "Todas"}
|
||||
<Plus className="h-4 w-4" />
|
||||
Nova Consulta
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-600 border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Carregando consultas...
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
{["hoje", "amanha", "semana", "todas"].map((filtro) => (
|
||||
<button
|
||||
key={filtro}
|
||||
onClick={() => setFiltroData(filtro)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
|
||||
filtroData === filtro
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-white dark:bg-slate-900 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{filtro === "hoje"
|
||||
? "Hoje"
|
||||
: filtro === "amanha"
|
||||
? "Amanhã"
|
||||
: filtro === "semana"
|
||||
? "Esta Semana"
|
||||
: "Todas"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-indigo-600 border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Carregando consultas...
|
||||
</p>
|
||||
</div>
|
||||
) : consultasFiltradas.length === 0 ? (
|
||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||
{filtroData === "hoje"
|
||||
? "Nenhuma consulta agendada para hoje"
|
||||
: filtroData === "amanha"
|
||||
? "Nenhuma consulta agendada para amanhã"
|
||||
: filtroData === "semana"
|
||||
? "Nenhuma consulta agendada para esta semana"
|
||||
: "Nenhuma consulta encontrada"}
|
||||
</p>
|
||||
</div>
|
||||
) : consultas.length === 0 ? (
|
||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||
Nenhuma consulta encontrada
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{consultas.map(renderAppointmentCard)}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{consultasFiltradas.map(renderAppointmentCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderAvailability = () => <DisponibilidadeMedico />;
|
||||
|
||||
@ -907,17 +1019,443 @@ const PainelMedico: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Carregar dados do perfil do médico
|
||||
const loadDoctorProfile = useCallback(async () => {
|
||||
if (!doctorId) return;
|
||||
|
||||
try {
|
||||
const doctor = await doctorService.getById(doctorId);
|
||||
setProfileData({
|
||||
full_name: doctor.full_name || "",
|
||||
email: doctor.email || "",
|
||||
phone: doctor.phone || "",
|
||||
cpf: doctor.cpf || "",
|
||||
birth_date: doctor.birth_date || "",
|
||||
sex: doctor.sex || "",
|
||||
street: doctor.street || "",
|
||||
number: doctor.number || "",
|
||||
complement: doctor.complement || "",
|
||||
neighborhood: doctor.neighborhood || "",
|
||||
city: doctor.city || "",
|
||||
state: doctor.state || "",
|
||||
cep: doctor.cep || "",
|
||||
crm: doctor.crm || "",
|
||||
specialty: doctor.specialty || "",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[PainelMedico] Erro ao carregar perfil:", error);
|
||||
toast.error("Erro ao carregar perfil");
|
||||
}
|
||||
}, [doctorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (doctorId) {
|
||||
loadDoctorProfile();
|
||||
}
|
||||
}, [doctorId, loadDoctorProfile]);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!doctorId) return;
|
||||
|
||||
try {
|
||||
await doctorService.update(doctorId, profileData);
|
||||
toast.success("Perfil atualizado com sucesso!");
|
||||
setIsEditingProfile(false);
|
||||
await loadDoctorProfile();
|
||||
} catch (error) {
|
||||
console.error("[PainelMedico] Erro ao salvar perfil:", error);
|
||||
toast.error("Erro ao salvar perfil");
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileChange = (field: string, value: string) => {
|
||||
setProfileData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const renderSettings = () => (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Configurações
|
||||
</h1>
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<div className="p-6">
|
||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||
Funcionalidade em desenvolvimento
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Meu Perfil
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Gerencie suas informações pessoais e profissionais
|
||||
</p>
|
||||
</div>
|
||||
{!isEditingProfile ? (
|
||||
<button
|
||||
onClick={() => setIsEditingProfile(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
Editar Perfil
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditingProfile(false);
|
||||
loadDoctorProfile();
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Avatar Card */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 dark:text-white">
|
||||
Foto de Perfil
|
||||
</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<AvatarUpload
|
||||
userId={user?.id}
|
||||
currentAvatarUrl={avatarUrl}
|
||||
name={profileData.full_name || medicoNome}
|
||||
color="indigo"
|
||||
size="xl"
|
||||
editable={true}
|
||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{profileData.full_name || medicoNome}
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{profileData.email || user?.email || "Sem email"}
|
||||
</p>
|
||||
<p className="text-sm text-indigo-600 dark:text-indigo-400 mt-1">
|
||||
CRM: {profileData.crm || "Não informado"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<div className="border-b border-gray-200 dark:border-slate-700">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setProfileTab("personal")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
profileTab === "personal"
|
||||
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
Dados Pessoais
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProfileTab("professional")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
profileTab === "professional"
|
||||
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
Info. Profissionais
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setProfileTab("security")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
profileTab === "security"
|
||||
? "border-indigo-600 text-indigo-600 dark:text-indigo-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
Segurança
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Tab: Dados Pessoais */}
|
||||
{profileTab === "personal" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Informações Pessoais
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nome Completo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.full_name}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("full_name", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profileData.email}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("email", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Telefone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={profileData.phone}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("phone", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
CPF
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.cpf}
|
||||
disabled
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Data de Nascimento
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={profileData.birth_date}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("birth_date", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Sexo
|
||||
</label>
|
||||
<select
|
||||
value={profileData.sex}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("sex", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
<option value="M">Masculino</option>
|
||||
<option value="F">Feminino</option>
|
||||
<option value="O">Outro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Endereço
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Rua
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.street}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("street", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Número
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.number}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("number", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Complemento
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.complement}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("complement", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Bairro
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.neighborhood}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("neighborhood", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Cidade
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.city}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("city", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.state}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("state", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
CEP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.cep}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("cep", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Info Profissionais */}
|
||||
{profileTab === "professional" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Informações Profissionais
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
CRM
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.crm}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("crm", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Especialidade
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profileData.specialty}
|
||||
onChange={(e) =>
|
||||
handleProfileChange("specialty", e.target.value)
|
||||
}
|
||||
disabled={!isEditingProfile}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Segurança */}
|
||||
{profileTab === "security" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Alteração de Senha
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Funcionalidade em desenvolvimento
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -961,10 +1499,12 @@ const PainelMedico: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-slate-950">
|
||||
<div className="flex flex-col lg:flex-row min-h-screen bg-gray-50 dark:bg-slate-950">
|
||||
{renderSidebar()}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="container mx-auto p-8">{renderContent()}</div>
|
||||
<div className="container mx-auto p-4 sm:p-6 lg:p-8">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
@ -977,7 +1517,7 @@ const PainelMedico: React.FC = () => {
|
||||
}}
|
||||
onSaved={handleSaveConsulta}
|
||||
editing={editing}
|
||||
defaultMedicoId={medicoId}
|
||||
defaultMedicoId={doctorId || ""}
|
||||
lockMedico={false}
|
||||
/>
|
||||
)}
|
||||
@ -1010,7 +1550,7 @@ const PainelMedico: React.FC = () => {
|
||||
}))
|
||||
}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione um paciente</option>
|
||||
{pacientesDisponiveis.map((p) => (
|
||||
@ -1031,7 +1571,7 @@ const PainelMedico: React.FC = () => {
|
||||
setFormRelatorio((p) => ({ ...p, exam: e.target.value }))
|
||||
}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -1047,7 +1587,7 @@ const PainelMedico: React.FC = () => {
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -1063,7 +1603,7 @@ const PainelMedico: React.FC = () => {
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
|
||||
@ -2121,7 +2121,7 @@ const PainelSecretaria = () => {
|
||||
nome: event.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="form-input"
|
||||
required
|
||||
placeholder="Maria Santos Silva"
|
||||
/>
|
||||
@ -2140,7 +2140,7 @@ const PainelSecretaria = () => {
|
||||
social_name: event.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="form-input"
|
||||
placeholder="Maria Santos"
|
||||
/>
|
||||
</div>
|
||||
@ -2153,7 +2153,7 @@ const PainelSecretaria = () => {
|
||||
type="text"
|
||||
value={formDataPaciente.cpf}
|
||||
onChange={handleCpfChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="000.000.000-00"
|
||||
maxLength={14}
|
||||
@ -2173,7 +2173,7 @@ const PainelSecretaria = () => {
|
||||
dataNascimento: event.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="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -2190,7 +2190,7 @@ const PainelSecretaria = () => {
|
||||
sexo: event.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="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
@ -2221,7 +2221,7 @@ const PainelSecretaria = () => {
|
||||
email: event.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="form-input"
|
||||
required
|
||||
placeholder="maria@email.com"
|
||||
/>
|
||||
@ -2302,7 +2302,7 @@ const PainelSecretaria = () => {
|
||||
tipo_sanguineo: event.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="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
{BLOOD_TYPES.map((tipo) => (
|
||||
@ -2329,7 +2329,7 @@ const PainelSecretaria = () => {
|
||||
peso: event.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="form-input"
|
||||
placeholder="65.5"
|
||||
/>
|
||||
</div>
|
||||
@ -2350,7 +2350,7 @@ const PainelSecretaria = () => {
|
||||
altura: event.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="form-input"
|
||||
placeholder="1.65"
|
||||
/>
|
||||
</div>
|
||||
@ -2369,7 +2369,7 @@ const PainelSecretaria = () => {
|
||||
convenio: event.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="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
{CONVENIOS.map((option) => (
|
||||
@ -2393,7 +2393,7 @@ const PainelSecretaria = () => {
|
||||
numeroCarteirinha: event.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="form-input"
|
||||
placeholder="Número da carteirinha"
|
||||
/>
|
||||
</div>
|
||||
@ -2466,7 +2466,7 @@ const PainelSecretaria = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Rua das Flores"
|
||||
/>
|
||||
</div>
|
||||
@ -2487,7 +2487,7 @@ const PainelSecretaria = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="123"
|
||||
/>
|
||||
</div>
|
||||
@ -2510,7 +2510,7 @@ const PainelSecretaria = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Centro"
|
||||
/>
|
||||
</div>
|
||||
@ -2531,7 +2531,7 @@ const PainelSecretaria = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="São Paulo"
|
||||
/>
|
||||
</div>
|
||||
@ -2552,7 +2552,7 @@ const PainelSecretaria = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="SP"
|
||||
maxLength={2}
|
||||
/>
|
||||
@ -2575,7 +2575,7 @@ const PainelSecretaria = () => {
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Apto 45, Bloco B..."
|
||||
/>
|
||||
</div>
|
||||
@ -2599,7 +2599,7 @@ const PainelSecretaria = () => {
|
||||
observacoes: event.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="form-input"
|
||||
rows={3}
|
||||
placeholder="Observações gerais sobre o paciente..."
|
||||
/>
|
||||
@ -2725,7 +2725,7 @@ const PainelSecretaria = () => {
|
||||
patientId: 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="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">-- Selecione --</option>
|
||||
@ -2749,7 +2749,7 @@ const PainelSecretaria = () => {
|
||||
orderNumber: e.target.value.toUpperCase(),
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="Ex: REL-2025-10-MUS3TN"
|
||||
pattern="^REL-\d{4}-\d{2}-[A-Z0-9]{6}$"
|
||||
@ -2769,7 +2769,7 @@ const PainelSecretaria = () => {
|
||||
exam: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Ex: Hemograma"
|
||||
/>
|
||||
</div>
|
||||
@ -2786,7 +2786,7 @@ const PainelSecretaria = () => {
|
||||
dueAt: 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="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -2803,7 +2803,7 @@ const PainelSecretaria = () => {
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -2819,7 +2819,7 @@ const PainelSecretaria = () => {
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
@ -3048,7 +3048,7 @@ const PainelSecretaria = () => {
|
||||
nome: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="Dr. João da Silva"
|
||||
/>
|
||||
@ -3069,7 +3069,7 @@ const PainelSecretaria = () => {
|
||||
cpf: digits,
|
||||
}));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="000.000.000-00"
|
||||
maxLength={14}
|
||||
@ -3089,7 +3089,7 @@ const PainelSecretaria = () => {
|
||||
rg: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="00.000.000-0"
|
||||
/>
|
||||
</div>
|
||||
@ -3108,7 +3108,7 @@ const PainelSecretaria = () => {
|
||||
dataNascimento: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -3134,7 +3134,7 @@ const PainelSecretaria = () => {
|
||||
crm: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="123456"
|
||||
/>
|
||||
@ -3152,7 +3152,7 @@ const PainelSecretaria = () => {
|
||||
crmUf: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
@ -3205,7 +3205,7 @@ const PainelSecretaria = () => {
|
||||
especialidade: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
@ -3248,7 +3248,7 @@ const PainelSecretaria = () => {
|
||||
email: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="medico@email.com"
|
||||
/>
|
||||
@ -3268,7 +3268,7 @@ const PainelSecretaria = () => {
|
||||
telefone: buildMedicoTelefone(event.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
placeholder="(11) 99999-9999"
|
||||
/>
|
||||
@ -3287,7 +3287,7 @@ const PainelSecretaria = () => {
|
||||
telefone2: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="(11) 3333-4444"
|
||||
/>
|
||||
</div>
|
||||
@ -3355,7 +3355,7 @@ const PainelSecretaria = () => {
|
||||
rua: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Nome da rua"
|
||||
required
|
||||
/>
|
||||
@ -3374,7 +3374,7 @@ const PainelSecretaria = () => {
|
||||
numero: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="123"
|
||||
required
|
||||
/>
|
||||
@ -3395,7 +3395,7 @@ const PainelSecretaria = () => {
|
||||
bairro: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Bairro"
|
||||
required
|
||||
/>
|
||||
@ -3414,7 +3414,7 @@ const PainelSecretaria = () => {
|
||||
cidade: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Cidade"
|
||||
required
|
||||
/>
|
||||
@ -3433,7 +3433,7 @@ const PainelSecretaria = () => {
|
||||
estado: event.target.value.toUpperCase(),
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="UF"
|
||||
maxLength={2}
|
||||
required
|
||||
@ -3454,7 +3454,7 @@ const PainelSecretaria = () => {
|
||||
complemento: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
placeholder="Apto, sala, bloco..."
|
||||
/>
|
||||
</div>
|
||||
@ -3479,7 +3479,7 @@ const PainelSecretaria = () => {
|
||||
senha: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
@ -3542,3 +3542,5 @@ const PainelSecretaria = () => {
|
||||
};
|
||||
|
||||
export default PainelSecretaria;
|
||||
|
||||
|
||||
|
||||
@ -39,33 +39,33 @@ export default function PainelSecretaria() {
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold text-gray-900 dark:text-white truncate">
|
||||
Painel da Secretaria
|
||||
</h1>
|
||||
{user && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Bem-vinda, {user.email}
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1 truncate">
|
||||
Bem-vindo(a), {user.nome || user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
className="flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 text-sm sm:text-base text-gray-700 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sair
|
||||
<LogOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
<span className="hidden sm:inline">Sair</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-[1400px] mx-auto px-6">
|
||||
<nav className="flex gap-2">
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6">
|
||||
<nav className="flex gap-1 sm:gap-2 min-w-max">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
@ -73,14 +73,15 @@ export default function PainelSecretaria() {
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-colors ${
|
||||
className={`flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 border-b-2 transition-colors text-sm sm:text-base whitespace-nowrap ${
|
||||
isActive
|
||||
? "border-green-600 text-green-600 dark:text-green-400 font-medium"
|
||||
: "border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
? "border-green-600 text-green-600 font-medium"
|
||||
: "border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
<Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden">{tab.label.split(" ")[0]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -89,12 +90,15 @@ export default function PainelSecretaria() {
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-[1400px] mx-auto px-6 py-8">
|
||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 py-6 sm:py-8">
|
||||
{activeTab === "pacientes" && (
|
||||
<SecretaryPatientList
|
||||
onOpenAppointment={(patientId: string) => {
|
||||
// store selected patient for appointment and switch to consultas tab
|
||||
sessionStorage.setItem("selectedPatientForAppointment", patientId);
|
||||
sessionStorage.setItem(
|
||||
"selectedPatientForAppointment",
|
||||
patientId
|
||||
);
|
||||
setActiveTab("consultas");
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Save } from "lucide-react";
|
||||
import { Save, ArrowLeft } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { doctorService } from "../services";
|
||||
import { AvatarUpload } from "../components/ui/AvatarUpload";
|
||||
|
||||
export default function PerfilMedico() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
@ -43,13 +45,30 @@ export default function PerfilMedico() {
|
||||
}, [user?.id]);
|
||||
|
||||
const loadDoctorData = async () => {
|
||||
if (!user?.id) return;
|
||||
if (!user?.id) {
|
||||
console.error("[PerfilMedico] Sem user.id:", user);
|
||||
toast.error("Usuário não identificado");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const doctor = await doctorService.getById(user.id);
|
||||
console.log("[PerfilMedico] Buscando dados do médico...");
|
||||
|
||||
// Tentar buscar por user_id primeiro
|
||||
let doctor = await doctorService.getByUserId(user.id);
|
||||
|
||||
// Se não encontrar por user_id, tentar por email
|
||||
if (!doctor && user.email) {
|
||||
console.log(
|
||||
"[PerfilMedico] Médico não encontrado por user_id, tentando por email:",
|
||||
user.email
|
||||
);
|
||||
doctor = await doctorService.getByEmail(user.email);
|
||||
}
|
||||
|
||||
if (doctor) {
|
||||
console.log("[PerfilMedico] Dados do médico carregados:", doctor);
|
||||
setFormData({
|
||||
full_name: doctor.full_name || "",
|
||||
email: doctor.email || "",
|
||||
@ -64,11 +83,28 @@ export default function PerfilMedico() {
|
||||
education: "", // Doctor type não tem education
|
||||
experience_years: "", // Doctor type não tem experience_years
|
||||
});
|
||||
// Doctor type não tem avatar_url ainda
|
||||
setAvatarUrl(undefined);
|
||||
} else {
|
||||
console.warn("[PerfilMedico] Médico não encontrado na tabela doctors");
|
||||
// Usar dados básicos do usuário logado
|
||||
setFormData({
|
||||
full_name: user.nome || "",
|
||||
email: user.email || "",
|
||||
phone: "",
|
||||
cpf: "",
|
||||
birth_date: "",
|
||||
gender: "",
|
||||
specialty: "",
|
||||
crm: "",
|
||||
crm_state: "",
|
||||
bio: "",
|
||||
education: "",
|
||||
experience_years: "",
|
||||
});
|
||||
toast("Preencha seus dados para completar o cadastro", { icon: "ℹ️" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar dados do médico:", error);
|
||||
console.error("[PerfilMedico] Erro ao carregar dados do médico:", error);
|
||||
toast.error("Erro ao carregar dados do perfil");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -132,37 +168,48 @@ export default function PerfilMedico() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="min-h-screen bg-gray-50 py-4 sm:py-6 lg:py-8 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1>
|
||||
<p className="text-gray-600">
|
||||
Gerencie suas informações pessoais e profissionais
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-start sm:items-center gap-2 sm:gap-3 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
|
||||
title="Voltar"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 truncate">
|
||||
Meu Perfil
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1">
|
||||
Gerencie suas informações pessoais e profissionais
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
className="w-full sm:w-auto px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm sm:text-base whitespace-nowrap"
|
||||
>
|
||||
Editar Perfil
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
loadDoctorData();
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Salvar
|
||||
@ -172,9 +219,11 @@ export default function PerfilMedico() {
|
||||
</div>
|
||||
|
||||
{/* Avatar Card */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Foto de Perfil</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">
|
||||
Foto de Perfil
|
||||
</h2>
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
|
||||
<AvatarUpload
|
||||
userId={user?.id}
|
||||
currentAvatarUrl={avatarUrl}
|
||||
@ -184,10 +233,14 @@ export default function PerfilMedico() {
|
||||
editable={true}
|
||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{formData.full_name}</p>
|
||||
<p className="text-gray-500">{formData.specialty}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="text-center sm:text-left min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 text-sm sm:text-base truncate">
|
||||
{formData.full_name}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs sm:text-sm truncate">
|
||||
{formData.specialty}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-gray-500 truncate">
|
||||
CRM: {formData.crm} - {formData.crm_state}
|
||||
</p>
|
||||
</div>
|
||||
@ -196,11 +249,11 @@ export default function PerfilMedico() {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px">
|
||||
<div className="border-b border-gray-200 overflow-x-auto">
|
||||
<nav className="flex -mb-px min-w-max">
|
||||
<button
|
||||
onClick={() => setActiveTab("personal")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === "personal"
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
@ -210,17 +263,17 @@ export default function PerfilMedico() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("professional")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === "professional"
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
Informações Profissionais
|
||||
Info. Profissionais
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("security")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === "security"
|
||||
? "border-green-600 text-green-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
@ -231,7 +284,7 @@ export default function PerfilMedico() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Tab: Dados Pessoais */}
|
||||
{activeTab === "personal" && (
|
||||
<div className="space-y-6">
|
||||
@ -255,7 +308,7 @@ export default function PerfilMedico() {
|
||||
handleChange("full_name", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -268,7 +321,7 @@ export default function PerfilMedico() {
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -281,7 +334,7 @@ export default function PerfilMedico() {
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange("phone", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -293,7 +346,7 @@ export default function PerfilMedico() {
|
||||
type="text"
|
||||
value={formData.cpf}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -308,7 +361,7 @@ export default function PerfilMedico() {
|
||||
handleChange("birth_date", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -320,7 +373,7 @@ export default function PerfilMedico() {
|
||||
value={formData.gender}
|
||||
onChange={(e) => handleChange("gender", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
<option value="male">Masculino</option>
|
||||
@ -356,7 +409,7 @@ export default function PerfilMedico() {
|
||||
handleChange("specialty", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -368,7 +421,7 @@ export default function PerfilMedico() {
|
||||
type="text"
|
||||
value={formData.crm}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -381,7 +434,7 @@ export default function PerfilMedico() {
|
||||
value={formData.crm_state}
|
||||
disabled
|
||||
maxLength={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -397,7 +450,7 @@ export default function PerfilMedico() {
|
||||
}
|
||||
disabled={!isEditing}
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -412,7 +465,7 @@ export default function PerfilMedico() {
|
||||
disabled={!isEditing}
|
||||
placeholder="Conte um pouco sobre sua trajetória profissional..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -428,7 +481,7 @@ export default function PerfilMedico() {
|
||||
disabled={!isEditing}
|
||||
placeholder="Universidades, residências, especializações..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -459,7 +512,7 @@ export default function PerfilMedico() {
|
||||
})
|
||||
}
|
||||
placeholder="Digite sua senha atual"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -477,7 +530,7 @@ export default function PerfilMedico() {
|
||||
})
|
||||
}
|
||||
placeholder="Digite a nova senha"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -495,7 +548,7 @@ export default function PerfilMedico() {
|
||||
})
|
||||
}
|
||||
placeholder="Confirme a nova senha"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -215,46 +215,48 @@ export default function PerfilPaciente() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="min-h-screen bg-gray-50 py-4 sm:py-6 lg:py-8 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6">
|
||||
{/* Botão Voltar */}
|
||||
<button
|
||||
onClick={() => navigate("/acompanhamento")}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors mb-4"
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors mb-2 sm:mb-4 text-sm sm:text-base"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<ArrowLeft className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
Voltar para o Painel
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1>
|
||||
<p className="text-gray-600">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 truncate">
|
||||
Meu Perfil
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1">
|
||||
Gerencie suas informações pessoais e médicas
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
className="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm sm:text-base whitespace-nowrap"
|
||||
>
|
||||
Editar Perfil
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
loadPatientData();
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Salvar
|
||||
@ -264,9 +266,11 @@ export default function PerfilPaciente() {
|
||||
</div>
|
||||
|
||||
{/* Avatar Card */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Foto de Perfil</h2>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">
|
||||
Foto de Perfil
|
||||
</h2>
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
|
||||
<AvatarUpload
|
||||
userId={user?.id}
|
||||
currentAvatarUrl={avatarUrl}
|
||||
@ -276,22 +280,24 @@ export default function PerfilPaciente() {
|
||||
editable={true}
|
||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
<div className="text-center sm:text-left min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 text-sm sm:text-base truncate">
|
||||
{formData.full_name || "Carregando..."}
|
||||
</p>
|
||||
<p className="text-gray-500">{formData.email || "Sem email"}</p>
|
||||
<p className="text-gray-500 text-xs sm:text-sm truncate">
|
||||
{formData.email || "Sem email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex -mb-px">
|
||||
<div className="border-b border-gray-200 overflow-x-auto">
|
||||
<nav className="flex -mb-px min-w-max">
|
||||
<button
|
||||
onClick={() => setActiveTab("personal")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === "personal"
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
@ -301,17 +307,17 @@ export default function PerfilPaciente() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("medical")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === "medical"
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
Informações Médicas
|
||||
Info. Médicas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("security")}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`px-4 sm:px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === "security"
|
||||
? "border-blue-600 text-blue-600"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||
@ -346,7 +352,7 @@ export default function PerfilPaciente() {
|
||||
handleChange("full_name", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -359,7 +365,7 @@ export default function PerfilPaciente() {
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -374,7 +380,7 @@ export default function PerfilPaciente() {
|
||||
handleChange("phone_mobile", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -386,7 +392,7 @@ export default function PerfilPaciente() {
|
||||
type="text"
|
||||
value={formData.cpf}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -401,7 +407,7 @@ export default function PerfilPaciente() {
|
||||
handleChange("birth_date", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -413,7 +419,7 @@ export default function PerfilPaciente() {
|
||||
value={formData.sex}
|
||||
onChange={(e) => handleChange("sex", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
<option value="M">Masculino</option>
|
||||
@ -437,7 +443,7 @@ export default function PerfilPaciente() {
|
||||
value={formData.street}
|
||||
onChange={(e) => handleChange("street", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -450,7 +456,7 @@ export default function PerfilPaciente() {
|
||||
value={formData.number}
|
||||
onChange={(e) => handleChange("number", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -465,7 +471,7 @@ export default function PerfilPaciente() {
|
||||
handleChange("complement", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -480,7 +486,7 @@ export default function PerfilPaciente() {
|
||||
handleChange("neighborhood", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -493,7 +499,7 @@ export default function PerfilPaciente() {
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange("city", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -507,7 +513,7 @@ export default function PerfilPaciente() {
|
||||
onChange={(e) => handleChange("state", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
maxLength={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -520,7 +526,7 @@ export default function PerfilPaciente() {
|
||||
value={formData.cep}
|
||||
onChange={(e) => handleChange("cep", e.target.value)}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -550,7 +556,7 @@ export default function PerfilPaciente() {
|
||||
handleChange("blood_type", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
<option value="A+">A+</option>
|
||||
@ -575,7 +581,7 @@ export default function PerfilPaciente() {
|
||||
handleChange("weight_kg", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -592,7 +598,7 @@ export default function PerfilPaciente() {
|
||||
}
|
||||
disabled={!isEditing}
|
||||
placeholder="Ex: 1.75"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:text-gray-500 bg-white text-gray-900"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -624,7 +630,7 @@ export default function PerfilPaciente() {
|
||||
})
|
||||
}
|
||||
placeholder="Digite sua senha atual"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -642,7 +648,7 @@ export default function PerfilPaciente() {
|
||||
})
|
||||
}
|
||||
placeholder="Digite a nova senha"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -660,7 +666,7 @@ export default function PerfilPaciente() {
|
||||
})
|
||||
}
|
||||
placeholder="Confirme a nova senha"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -175,7 +175,29 @@ class ApiClient {
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.get<T>(url, config);
|
||||
console.log(
|
||||
"[ApiClient] GET Request:",
|
||||
url,
|
||||
"Params:",
|
||||
JSON.stringify(config?.params)
|
||||
);
|
||||
|
||||
const response = await this.client.get<T>(url, config);
|
||||
|
||||
console.log("[ApiClient] GET Response:", {
|
||||
status: response.status,
|
||||
dataType: typeof response.data,
|
||||
isArray: Array.isArray(response.data),
|
||||
dataLength: Array.isArray(response.data)
|
||||
? response.data.length
|
||||
: "not array",
|
||||
});
|
||||
console.log(
|
||||
"[ApiClient] Response Data:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async post<T>(
|
||||
@ -243,7 +265,16 @@ class ApiClient {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.client.patch<T>(url, data, config);
|
||||
// Adicionar header Prefer para Supabase retornar os dados atualizados
|
||||
const configWithPrefer = {
|
||||
...config,
|
||||
headers: {
|
||||
...config?.headers,
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.client.patch<T>(url, data, configWithPrefer);
|
||||
console.log("[ApiClient] PATCH Response:", {
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
|
||||
@ -31,19 +31,29 @@ class AppointmentService {
|
||||
data
|
||||
);
|
||||
|
||||
console.log("[AppointmentService] Resposta get-available-slots:", response.data);
|
||||
console.log(
|
||||
"[AppointmentService] Resposta get-available-slots:",
|
||||
response.data
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("[AppointmentService] Erro ao buscar slots:", {
|
||||
error,
|
||||
message: error?.message,
|
||||
response: error?.response?.data,
|
||||
});
|
||||
console.error("[AppointmentService] ❌ Erro ao buscar slots:");
|
||||
console.error("[AppointmentService] Status:", error?.response?.status);
|
||||
console.error(
|
||||
"[AppointmentService] Response Data:",
|
||||
JSON.stringify(error?.response?.data, null, 2)
|
||||
);
|
||||
console.error("[AppointmentService] Message:", error?.message);
|
||||
console.error(
|
||||
"[AppointmentService] Input enviado:",
|
||||
JSON.stringify(data, null, 2)
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
"Erro ao buscar horários disponíveis"
|
||||
error?.message ||
|
||||
"Erro ao buscar horários disponíveis"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,16 +43,51 @@ class AvailabilityService {
|
||||
url: this.basePath,
|
||||
});
|
||||
|
||||
const response = await apiClient.get<DoctorAvailability[]>(this.basePath, {
|
||||
const response = await apiClient.get<any[]>(this.basePath, {
|
||||
params,
|
||||
});
|
||||
|
||||
console.log("[AvailabilityService] Resposta da listagem:", {
|
||||
console.log("[AvailabilityService] Resposta:", {
|
||||
count: response.data?.length || 0,
|
||||
data: response.data,
|
||||
isArray: Array.isArray(response.data),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
// Converter weekday de string para número (compatibilidade com banco antigo)
|
||||
const convertedData: DoctorAvailability[] = Array.isArray(response.data)
|
||||
? response.data.map((item) => {
|
||||
const weekdayMap: Record<string, number> = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
weekday:
|
||||
typeof item.weekday === "string"
|
||||
? weekdayMap[item.weekday.toLowerCase()]
|
||||
: item.weekday,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
if (convertedData.length > 0) {
|
||||
console.log(
|
||||
"[AvailabilityService] ✅ Convertido:",
|
||||
convertedData.length,
|
||||
"registros"
|
||||
);
|
||||
console.log(
|
||||
"[AvailabilityService] Primeiro item convertido:",
|
||||
JSON.stringify(convertedData[0], null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
return convertedData;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -102,7 +137,10 @@ class AvailabilityService {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("[AvailabilityService] Resposta da atualização:", response.data);
|
||||
console.log(
|
||||
"[AvailabilityService] Resposta da atualização:",
|
||||
response.data
|
||||
);
|
||||
|
||||
return Array.isArray(response.data) ? response.data[0] : response.data;
|
||||
}
|
||||
|
||||
@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tipo de dia da semana (formato da API em inglês)
|
||||
* Tipo de dia da semana (formato da API: números 0-6)
|
||||
* 0 = Domingo, 1 = Segunda, 2 = Terça, 3 = Quarta, 4 = Quinta, 5 = Sexta, 6 = Sábado
|
||||
*/
|
||||
export type Weekday = "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday";
|
||||
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/**
|
||||
* Tipo de atendimento
|
||||
@ -25,7 +26,7 @@ export type ExceptionKind = "bloqueio" | "disponibilidade_extra";
|
||||
export interface DoctorAvailability {
|
||||
id?: string;
|
||||
doctor_id: string;
|
||||
weekday: Weekday; // "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
|
||||
weekday: Weekday; // 0=Domingo, 1=Segunda, 2=Terça, 3=Quarta, 4=Quinta, 5=Sexta, 6=Sábado
|
||||
start_time: string; // Formato: HH:MM (ex: "08:00")
|
||||
end_time: string; // Formato: HH:MM (ex: "18:00")
|
||||
slot_minutes?: number; // Default: 30, range: 15-120
|
||||
@ -57,7 +58,7 @@ export interface DoctorException {
|
||||
*/
|
||||
export interface ListAvailabilityFilters {
|
||||
doctor_id?: string;
|
||||
weekday?: Weekday; // "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
|
||||
weekday?: Weekday; // 0=Domingo, 1=Segunda, ..., 6=Sábado
|
||||
active?: boolean;
|
||||
appointment_type?: AppointmentType;
|
||||
select?: string;
|
||||
@ -68,7 +69,7 @@ export interface ListAvailabilityFilters {
|
||||
*/
|
||||
export interface CreateAvailabilityInput {
|
||||
doctor_id: string; // required
|
||||
weekday: Weekday; // required - "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
|
||||
weekday: Weekday; // required - 0=Domingo, 1=Segunda, ..., 6=Sábado
|
||||
start_time: string; // required - Formato: HH:MM (ex: "08:00")
|
||||
end_time: string; // required - Formato: HH:MM (ex: "18:00")
|
||||
slot_minutes?: number; // optional - Default: 30, range: 15-120
|
||||
|
||||
@ -64,6 +64,42 @@ class DoctorService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca médico por user_id (Supabase Auth)
|
||||
*/
|
||||
async getByUserId(userId: string): Promise<Doctor | null> {
|
||||
try {
|
||||
const response = await apiClient.get<Doctor[]>(
|
||||
`/doctors?user_id=eq.${userId}`
|
||||
);
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar médico por user_id:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca médico por email
|
||||
*/
|
||||
async getByEmail(email: string): Promise<Doctor | null> {
|
||||
try {
|
||||
const response = await apiClient.get<Doctor[]>(
|
||||
`/doctors?email=eq.${email}`
|
||||
);
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar médico por email:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria novo médico
|
||||
*/
|
||||
|
||||
@ -61,14 +61,41 @@ class ReportService {
|
||||
* Nota: order_number não pode ser modificado
|
||||
*/
|
||||
async update(id: string, data: UpdateReportInput): Promise<Report> {
|
||||
const response = await apiClient.patch<Report[]>(
|
||||
console.log("[ReportService] update() - id:", id, "data:", data);
|
||||
|
||||
const response = await apiClient.patch<Report | Report[]>(
|
||||
`${this.basePath}?id=eq.${id}`,
|
||||
data
|
||||
);
|
||||
if (response.data && response.data.length > 0) {
|
||||
return response.data[0];
|
||||
|
||||
console.log("[ReportService] update() - response status:", response.status);
|
||||
console.log("[ReportService] update() - response.data:", response.data);
|
||||
console.log(
|
||||
"[ReportService] update() - response type:",
|
||||
typeof response.data,
|
||||
"isArray:",
|
||||
Array.isArray(response.data)
|
||||
);
|
||||
|
||||
// Supabase com Prefer: return=representation pode retornar array ou objeto
|
||||
if (Array.isArray(response.data)) {
|
||||
if (response.data.length > 0) {
|
||||
return response.data[0];
|
||||
}
|
||||
// Array vazio - buscar o relatório atualizado
|
||||
console.warn(
|
||||
"[ReportService] update() - Array vazio, buscando relatório..."
|
||||
);
|
||||
return await this.getById(id);
|
||||
} else if (response.data) {
|
||||
return response.data as Report;
|
||||
}
|
||||
throw new Error("Relatório não encontrado");
|
||||
|
||||
// Última tentativa - buscar o relatório
|
||||
console.warn(
|
||||
"[ReportService] update() - Resposta vazia, buscando relatório..."
|
||||
);
|
||||
return await this.getById(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,14 @@ export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontSize: {
|
||||
xs: ["0.75rem", { lineHeight: "1rem" }],
|
||||
sm: ["0.875rem", { lineHeight: "1.25rem" }],
|
||||
base: ["1rem", { lineHeight: "1.5rem" }],
|
||||
lg: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||
xl: ["1.25rem", { lineHeight: "1.75rem" }],
|
||||
"2xl": ["1.5rem", { lineHeight: "2rem" }],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
@ -49,6 +57,31 @@ export default {
|
||||
},
|
||||
animation: {
|
||||
bounce: "bounce 1s infinite",
|
||||
"spin-slow": "spin 2s linear infinite",
|
||||
"spin-once": "spinOnce 0.6s ease-out forwards",
|
||||
"scale-in": "scaleIn 0.3s ease-out",
|
||||
"fade-in": "fadeIn 0.3s ease-out",
|
||||
"pulse-ring": "pulseRing 1.5s ease-out infinite",
|
||||
},
|
||||
keyframes: {
|
||||
spinOnce: {
|
||||
"0%": { transform: "rotate(0deg) scale(0)", opacity: "0" },
|
||||
"50%": { transform: "rotate(180deg) scale(1)", opacity: "1" },
|
||||
"100%": { transform: "rotate(360deg) scale(1)", opacity: "1" },
|
||||
},
|
||||
scaleIn: {
|
||||
"0%": { transform: "scale(0.8)", opacity: "0" },
|
||||
"100%": { transform: "scale(1)", opacity: "1" },
|
||||
},
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0" },
|
||||
"100%": { opacity: "1" },
|
||||
},
|
||||
pulseRing: {
|
||||
"0%": { transform: "scale(0.8)", opacity: "0.8" },
|
||||
"50%": { transform: "scale(1.2)", opacity: "0.4" },
|
||||
"100%": { transform: "scale(1.5)", opacity: "0" },
|
||||
},
|
||||
},
|
||||
animationDelay: {
|
||||
100: "100ms",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user