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
|
### 🏥 Para Médicos
|
||||||
|
|
||||||
- ✅ Agenda personalizada com disponibilidade configurável
|
- ✅ 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
|
- ✅ Prontuário eletrônico completo
|
||||||
- ✅ Histórico de consultas do paciente
|
- ✅ Histórico de consultas do paciente
|
||||||
- ✅ Dashboard com métricas e estatísticas
|
- ✅ Dashboard com métricas e estatísticas
|
||||||
- ✅ Teleconsulta e presencial
|
- ✅ Teleconsulta e presencial
|
||||||
|
- ✅ Chatbot AI para suporte
|
||||||
|
|
||||||
### 👥 Para Pacientes
|
### 👥 Para Pacientes
|
||||||
|
|
||||||
- ✅ Agendamento inteligente com slots disponíveis em tempo real
|
- ✅ Agendamento inteligente com slots disponíveis em tempo real
|
||||||
- ✅ Histórico completo de consultas
|
- ✅ Histórico completo de consultas
|
||||||
|
- ✅ Visualização detalhada de laudos médicos com modal
|
||||||
- ✅ Visualização e download de relatórios médicos (PDF)
|
- ✅ Visualização e download de relatórios médicos (PDF)
|
||||||
- ✅ Perfil com avatar e dados pessoais
|
- ✅ Perfil com avatar e dados pessoais
|
||||||
- ✅ Filtros por médico, especialidade e data
|
- ✅ Filtros por médico, especialidade e data
|
||||||
|
- ✅ Chatbot AI para dúvidas e suporte
|
||||||
|
|
||||||
### 🏢 Para Secretárias
|
### 🏢 Para Secretárias
|
||||||
|
|
||||||
- ✅ Gerenciamento completo de médicos, pacientes e consultas
|
- ✅ Gerenciamento completo de médicos, pacientes e consultas
|
||||||
- ✅ Cadastro com validação de CPF e CRM
|
- ✅ Cadastro com validação de CPF e CRM
|
||||||
- ✅ Configuração de agenda médica (horários e exceções)
|
- ✅ 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
|
- ✅ Busca e filtros avançados
|
||||||
- ✅ Confirmação profissional para exclusões
|
- ✅ Confirmação profissional para exclusões
|
||||||
|
- ✅ Boas-vindas personalizadas com nome real
|
||||||
|
|
||||||
### 🔐 Sistema de Autenticação
|
### 🔐 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
|
### Sistema de Agendamento
|
||||||
|
|
||||||
@ -322,15 +359,10 @@ PATCH /rest/v1/doctors?id=eq.{uuid}
|
|||||||
- ✅ Verificação de conflitos
|
- ✅ Verificação de conflitos
|
||||||
- ✅ Interface otimizada
|
- ✅ 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
|
### 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
|
- ✅ Diálogos de confirmação profissionais
|
||||||
- ✅ Filtros de busca em todas as listas
|
- ✅ Filtros de busca em todas as listas
|
||||||
- ✅ Feedback visual melhorado
|
- ✅ Feedback visual melhorado
|
||||||
@ -339,10 +371,11 @@ PATCH /rest/v1/doctors?id=eq.{uuid}
|
|||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
- ✅ Build otimizado (~424KB)
|
- ✅ Build otimizado (~467KB)
|
||||||
- ✅ Code splitting
|
- ✅ Code splitting
|
||||||
- ✅ Lazy loading de rotas
|
- ✅ Lazy loading de rotas
|
||||||
- ✅ Cache de assets
|
- ✅ 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 {
|
import {
|
||||||
format,
|
format,
|
||||||
addMonths,
|
addMonths,
|
||||||
@ -8,7 +9,6 @@ import {
|
|||||||
eachDayOfInterval,
|
eachDayOfInterval,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
isToday,
|
|
||||||
isBefore,
|
isBefore,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
@ -45,7 +45,9 @@ export default function AgendamentoConsulta({
|
|||||||
medicos,
|
medicos,
|
||||||
}: AgendamentoConsultaProps) {
|
}: AgendamentoConsultaProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>(medicos);
|
const [filteredMedicos, setFilteredMedicos] = useState<Medico[]>(medicos);
|
||||||
|
const detailsRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Sempre que a lista de médicos da API mudar, atualiza o filtro
|
// Sempre que a lista de médicos da API mudar, atualiza o filtro
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -63,8 +65,10 @@ export default function AgendamentoConsulta({
|
|||||||
>("presencial");
|
>("presencial");
|
||||||
const [motivo, setMotivo] = useState("");
|
const [motivo, setMotivo] = useState("");
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
const [bookingSuccess, setBookingSuccess] = useState(false);
|
|
||||||
const [bookingError, setBookingError] = useState("");
|
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
|
// 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)));
|
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
|
// Removemos as funções de availability e exceptions antigas
|
||||||
// A API de slots já considera tudo automaticamente
|
// A API de slots já considera tudo automaticamente
|
||||||
|
|
||||||
@ -116,37 +204,40 @@ export default function AgendamentoConsulta({
|
|||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[AgendamentoConsulta] Disponibilidades do médico:", availabilities);
|
console.log(
|
||||||
|
"[AgendamentoConsulta] Disponibilidades do médico:",
|
||||||
|
availabilities
|
||||||
|
);
|
||||||
|
|
||||||
if (!availabilities || availabilities.length === 0) {
|
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([]);
|
setAvailableSlots([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pega o dia da semana da data selecionada
|
// Pega o dia da semana da data selecionada (0-6)
|
||||||
const weekdayMap: Record<number, string> = {
|
const dayOfWeek = selectedDate.getDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
0: "sunday",
|
console.log(
|
||||||
1: "monday",
|
"[AgendamentoConsulta] Dia da semana selecionado:",
|
||||||
2: "tuesday",
|
dayOfWeek
|
||||||
3: "wednesday",
|
);
|
||||||
4: "thursday",
|
|
||||||
5: "friday",
|
|
||||||
6: "saturday",
|
|
||||||
};
|
|
||||||
|
|
||||||
const dayOfWeek = weekdayMap[selectedDate.getDay()];
|
|
||||||
console.log("[AgendamentoConsulta] Dia da semana selecionado:", dayOfWeek);
|
|
||||||
|
|
||||||
// Filtra disponibilidades para o dia da semana
|
// Filtra disponibilidades para o dia da semana
|
||||||
const dayAvailability = availabilities.filter(
|
const dayAvailability = availabilities.filter(
|
||||||
(avail) => avail.weekday === dayOfWeek && avail.active
|
(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) {
|
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([]);
|
setAvailableSlots([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -168,18 +259,70 @@ export default function AgendamentoConsulta({
|
|||||||
while (currentMinutes < endMinutes) {
|
while (currentMinutes < endMinutes) {
|
||||||
const hours = Math.floor(currentMinutes / 60);
|
const hours = Math.floor(currentMinutes / 60);
|
||||||
const minutes = 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);
|
allSlots.push(timeStr);
|
||||||
currentMinutes += slotMinutes;
|
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
|
// Busca agendamentos existentes para esta data
|
||||||
const appointments = await appointmentService.list({
|
const appointments = await appointmentService.list({
|
||||||
doctor_id: selectedMedico.id,
|
doctor_id: selectedMedico.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[AgendamentoConsulta] Agendamentos existentes:", appointments);
|
console.log(
|
||||||
|
"[AgendamentoConsulta] Agendamentos existentes:",
|
||||||
|
appointments
|
||||||
|
);
|
||||||
|
|
||||||
// Filtra agendamentos para a data selecionada
|
// Filtra agendamentos para a data selecionada
|
||||||
const bookedSlots = appointments
|
const bookedSlots = appointments
|
||||||
@ -204,7 +347,10 @@ export default function AgendamentoConsulta({
|
|||||||
(slot) => !bookedSlots.includes(slot)
|
(slot) => !bookedSlots.includes(slot)
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[AgendamentoConsulta] Slots disponíveis calculados:", availableSlots);
|
console.log(
|
||||||
|
"[AgendamentoConsulta] Slots disponíveis calculados:",
|
||||||
|
availableSlots
|
||||||
|
);
|
||||||
|
|
||||||
setAvailableSlots(availableSlots);
|
setAvailableSlots(availableSlots);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -219,16 +365,7 @@ export default function AgendamentoConsulta({
|
|||||||
} else {
|
} else {
|
||||||
setAvailableSlots([]);
|
setAvailableSlots([]);
|
||||||
}
|
}
|
||||||
}, [selectedDate, selectedMedico, calculateAvailableSlots]);
|
}, [selectedDate, selectedMedico, appointmentType, 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateCalendarDays = () => {
|
const generateCalendarDays = () => {
|
||||||
const start = startOfMonth(currentMonth);
|
const start = startOfMonth(currentMonth);
|
||||||
@ -246,13 +383,39 @@ export default function AgendamentoConsulta({
|
|||||||
|
|
||||||
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
|
const handlePrevMonth = () => setCurrentMonth(subMonths(currentMonth, 1));
|
||||||
const handleNextMonth = () => setCurrentMonth(addMonths(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) => {
|
const handleSelectDoctor = (medico: Medico) => {
|
||||||
setSelectedMedico(medico);
|
setSelectedMedico(medico);
|
||||||
setSelectedDate(undefined);
|
setSelectedDate(undefined);
|
||||||
setSelectedTime("");
|
setSelectedTime("");
|
||||||
setMotivo("");
|
setMotivo("");
|
||||||
setBookingSuccess(false);
|
|
||||||
setBookingError("");
|
setBookingError("");
|
||||||
|
|
||||||
|
// Scroll suave para a seção de detalhes
|
||||||
|
setTimeout(() => {
|
||||||
|
detailsRef.current?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
const handleBookAppointment = () => {
|
const handleBookAppointment = () => {
|
||||||
if (selectedMedico && selectedDate && selectedTime && motivo) {
|
if (selectedMedico && selectedDate && selectedTime && motivo) {
|
||||||
@ -273,96 +436,168 @@ export default function AgendamentoConsulta({
|
|||||||
doctor_id: selectedMedico.id,
|
doctor_id: selectedMedico.id,
|
||||||
scheduled_at: scheduledAt,
|
scheduled_at: scheduledAt,
|
||||||
duration_minutes: 30,
|
duration_minutes: 30,
|
||||||
appointment_type:
|
appointment_type: (appointmentType === "online"
|
||||||
(appointmentType === "online" ? "telemedicina" : "presencial") as "presencial" | "telemedicina",
|
? "telemedicina"
|
||||||
|
: "presencial") as "presencial" | "telemedicina",
|
||||||
chief_complaint: motivo,
|
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
|
// Cria o agendamento usando a API REST
|
||||||
const appointment = await appointmentService.create(appointmentData);
|
const appointment = await appointmentService.create(appointmentData);
|
||||||
|
|
||||||
console.log("[AgendamentoConsulta] Consulta criada com sucesso:", appointment);
|
console.log(
|
||||||
|
"[AgendamentoConsulta] Consulta criada com sucesso:",
|
||||||
setBookingSuccess(true);
|
appointment
|
||||||
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."
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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);
|
setShowConfirmDialog(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const calendarDays = generateCalendarDays();
|
const calendarDays = generateCalendarDays();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
{bookingSuccess && (
|
{/* Modal de Resultado (Sucesso ou Erro) com Animação */}
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
|
{showResultModal && (
|
||||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 animate-fade-in">
|
||||||
<div>
|
<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">
|
||||||
<p className="font-medium text-green-900">
|
<div className="flex flex-col items-center text-center space-y-4">
|
||||||
Consulta agendada com sucesso!
|
{/* Í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>
|
</p>
|
||||||
<p className="text-sm text-green-700">
|
<button
|
||||||
Você receberá uma confirmação por e-mail em breve.
|
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>
|
</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>
|
||||||
</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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Agendar Consulta</h1>
|
<h1 className="text-xl sm:text-2xl font-bold">Agendar Consulta</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
Escolha um médico e horário disponível
|
Escolha um médico e horário disponível
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border p-6 space-y-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-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
||||||
<div className="space-y-2">
|
<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
|
Buscar por nome ou especialidade
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Cardiologia, Dr. Silva..."
|
placeholder="Ex: Cardiologia, Dr. Silva..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium">Especialidade</label>
|
<label className="text-xs sm:text-sm font-medium">
|
||||||
|
Especialidade
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedSpecialty}
|
value={selectedSpecialty}
|
||||||
onChange={(e) => setSelectedSpecialty(e.target.value)}
|
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>
|
<option value="all">Todas as especialidades</option>
|
||||||
{specialties.map((esp) => (
|
{specialties.map((esp) => (
|
||||||
@ -378,36 +613,48 @@ export default function AgendamentoConsulta({
|
|||||||
{filteredMedicos.map((medico) => (
|
{filteredMedicos.map((medico) => (
|
||||||
<div
|
<div
|
||||||
key={medico.id}
|
key={medico.id}
|
||||||
className={`bg-white rounded-xl border p-6 flex gap-4 items-center ${
|
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" : ""
|
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
|
{medico.nome
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
.join("")}
|
.join("")
|
||||||
|
.substring(0, 2)
|
||||||
|
.toUpperCase()}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<h3 className="font-semibold">{medico.nome}</h3>
|
<h3 className="text-sm sm:text-base font-semibold truncate">
|
||||||
<p className="text-muted-foreground">{medico.especialidade}</p>
|
{medico.nome}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground truncate">
|
||||||
|
{medico.especialidade}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
|
||||||
<span>{medico.crm}</span>
|
<span className="truncate">CRM: {medico.crm}</span>
|
||||||
{medico.valorConsulta ? (
|
{medico.valorConsulta ? (
|
||||||
<span>R$ {medico.valorConsulta.toFixed(2)}</span>
|
<span className="whitespace-nowrap">
|
||||||
|
R$ {medico.valorConsulta.toFixed(2)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||||
<span className="text-foreground">{medico.email || "-"}</span>
|
<span className="text-xs sm:text-sm text-foreground truncate w-full sm:w-auto">
|
||||||
<div className="flex gap-2">
|
{medico.email || "-"}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
<button
|
<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)}
|
onClick={() => handleSelectDoctor(medico)}
|
||||||
>
|
>
|
||||||
{selectedMedico?.id === medico.id
|
{selectedMedico?.id === medico.id
|
||||||
? "Selecionado"
|
? "✓ Selecionado"
|
||||||
: "Selecionar"}
|
: "Selecionar"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -417,104 +664,175 @@ export default function AgendamentoConsulta({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selectedMedico && (
|
{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>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">Detalhes do Agendamento</h2>
|
<h2 className="text-lg sm:text-xl font-semibold truncate dark:text-white">
|
||||||
<p className="text-gray-600">
|
Detalhes do Agendamento
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400 truncate">
|
||||||
Consulta com {selectedMedico.nome} -{" "}
|
Consulta com {selectedMedico.nome} -{" "}
|
||||||
{selectedMedico.especialidade}
|
{selectedMedico.especialidade}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setAppointmentType("presencial")}
|
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"
|
appointmentType === "presencial"
|
||||||
? "border-blue-500 bg-blue-50 text-blue-600"
|
? "border-blue-500 bg-blue-50 text-blue-600"
|
||||||
: "border-gray-300 text-gray-600"
|
: "border-gray-300 text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MapPin className="h-5 w-5" />
|
<MapPin className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||||
<span className="font-medium">Presencial</span>
|
<span className="text-sm sm:text-base font-medium">
|
||||||
|
Presencial
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setAppointmentType("online")}
|
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"
|
appointmentType === "online"
|
||||||
? "border-blue-500 bg-blue-50 text-blue-600"
|
? "border-blue-500 bg-blue-50 text-blue-600"
|
||||||
: "border-gray-300 text-gray-600"
|
: "border-gray-300 text-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Video className="h-5 w-5" />
|
<Video className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||||
<span className="font-medium">Online</span>
|
<span className="text-sm sm:text-base font-medium">Online</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<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>
|
<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="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
|
<button
|
||||||
onClick={handlePrevMonth}
|
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>
|
</button>
|
||||||
<span className="font-semibold">
|
|
||||||
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
<div className="flex items-center gap-2 flex-1 justify-center">
|
||||||
</span>
|
<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
|
<button
|
||||||
onClick={handleNextMonth}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<div className="grid grid-cols-7 bg-gray-50">
|
<div className="grid grid-cols-7 bg-gray-50">
|
||||||
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map(
|
{["D", "S", "T", "Q", "Q", "S", "S"].map((day, idx) => (
|
||||||
(day) => (
|
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={idx}
|
||||||
className="text-center py-2 text-sm font-medium text-gray-600"
|
className="text-center py-1.5 sm:py-2 text-xs sm:text-sm font-medium text-gray-600"
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-7">
|
<div className="grid grid-cols-7">
|
||||||
{calendarDays.map((day, index) => {
|
{calendarDays.map((day, index) => {
|
||||||
const isCurrentMonth = isSameMonth(day, currentMonth);
|
const isCurrentMonth = isSameMonth(day, currentMonth);
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedDate && isSameDay(day, selectedDate);
|
selectedDate && isSameDay(day, selectedDate);
|
||||||
const isTodayDate = isToday(day);
|
|
||||||
const isAvailable =
|
|
||||||
isCurrentMonth && isDateAvailable(day);
|
|
||||||
const isPast = isBefore(day, startOfDay(new Date()));
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => isAvailable && setSelectedDate(day)}
|
onClick={() => isAvailable && setSelectedDate(day)}
|
||||||
disabled={!isAvailable}
|
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" : ""
|
!isCurrentMonth ? "text-gray-300 bg-gray-50" : ""
|
||||||
} ${
|
} ${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-blue-600 text-white font-bold"
|
? "bg-blue-600 text-white font-bold"
|
||||||
: ""
|
: ""
|
||||||
} ${
|
} ${
|
||||||
isTodayDate && !isSelected
|
isAvailable && !isSelected
|
||||||
? "font-bold text-blue-600"
|
? "text-blue-600 font-semibold hover:bg-blue-50 cursor-pointer"
|
||||||
: ""
|
: ""
|
||||||
} ${
|
} ${
|
||||||
isAvailable && !isSelected
|
isUnavailable
|
||||||
? "hover:bg-blue-50 cursor-pointer"
|
? "text-red-600 font-semibold cursor-not-allowed"
|
||||||
: ""
|
: ""
|
||||||
} ${isPast ? "text-gray-400" : ""} ${
|
} ${
|
||||||
!isAvailable && isCurrentMonth && !isPast
|
isPast && isCurrentMonth
|
||||||
? "text-gray-300"
|
? "text-gray-400 cursor-not-allowed"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -524,62 +842,71 @@ export default function AgendamentoConsulta({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 space-y-1 text-xs text-gray-600">
|
<div className="mt-2 sm:mt-3 space-y-0.5 sm:space-y-1 text-xs text-gray-600">
|
||||||
<p>🟢 Datas disponíveis</p>
|
<p>
|
||||||
<p>🔴 Datas bloqueadas</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs sm:text-sm font-medium">
|
||||||
Horários Disponíveis
|
Horários Disponíveis
|
||||||
</label>
|
</label>
|
||||||
{selectedDate ? (
|
{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", {
|
{format(selectedDate, "EEEE, d 'de' MMMM 'de' yyyy", {
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
})}
|
})}
|
||||||
</p>
|
</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
|
Selecione uma data
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedDate && availableSlots.length > 0 ? (
|
{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) => (
|
{availableSlots.map((slot) => (
|
||||||
<button
|
<button
|
||||||
key={slot}
|
key={slot}
|
||||||
onClick={() => setSelectedTime(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
|
selectedTime === slot
|
||||||
? "border-blue-500 bg-blue-50 text-blue-600 font-medium"
|
? "border-blue-500 bg-blue-50 text-blue-600 font-medium"
|
||||||
: "border-gray-300 hover:border-blue-300"
|
: "border-gray-300 hover:border-blue-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||||
{slot}
|
{slot}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : selectedDate ? (
|
) : selectedDate ? (
|
||||||
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
<div className="p-3 sm:p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||||
<p className="text-gray-600">
|
<p className="text-xs sm:text-sm text-gray-600">
|
||||||
Nenhum horário disponível para esta data
|
Nenhum horário disponível para esta data
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
<div className="p-3 sm:p-4 rounded-lg border border-gray-300 bg-gray-50 text-center">
|
||||||
<p className="text-gray-600">
|
<p className="text-xs sm:text-sm text-gray-600">
|
||||||
Selecione uma data para ver os horários
|
Selecione uma data para ver os horários
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs sm:text-sm font-medium">
|
||||||
Motivo da Consulta *
|
Motivo da Consulta *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -587,13 +914,13 @@ export default function AgendamentoConsulta({
|
|||||||
value={motivo}
|
value={motivo}
|
||||||
onChange={(e) => setMotivo(e.target.value)}
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
rows={4}
|
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>
|
</div>
|
||||||
{selectedDate && selectedTime && (
|
{selectedDate && selectedTime && (
|
||||||
<div className="p-4 bg-blue-50 rounded-lg space-y-2">
|
<div className="p-3 sm:p-4 bg-blue-50 rounded-lg space-y-2">
|
||||||
<h4 className="font-semibold">Resumo</h4>
|
<h4 className="text-sm sm:text-base font-semibold">Resumo</h4>
|
||||||
<div className="space-y-1 text-sm text-gray-600">
|
<div className="space-y-1 text-xs sm:text-sm text-gray-600">
|
||||||
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
|
<p>📅 Data: {format(selectedDate, "dd/MM/yyyy")}</p>
|
||||||
<p>⏰ Horário: {selectedTime}</p>
|
<p>⏰ Horário: {selectedTime}</p>
|
||||||
<p>
|
<p>
|
||||||
@ -611,7 +938,7 @@ export default function AgendamentoConsulta({
|
|||||||
<button
|
<button
|
||||||
onClick={handleBookAppointment}
|
onClick={handleBookAppointment}
|
||||||
disabled={!selectedTime || !motivo.trim()}
|
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
|
Confirmar Agendamento
|
||||||
</button>
|
</button>
|
||||||
@ -621,30 +948,32 @@ export default function AgendamentoConsulta({
|
|||||||
)}
|
)}
|
||||||
{showConfirmDialog && (
|
{showConfirmDialog && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<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">
|
<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-xl font-semibold">Confirmar Agendamento</h3>
|
<h3 className="text-lg sm:text-xl font-semibold dark:text-white">
|
||||||
<p className="text-gray-600">
|
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
|
Revise os detalhes da sua consulta antes de confirmar
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-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
|
{selectedMedico?.nome
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((n) => n[0])
|
.map((n) => n[0])
|
||||||
.join("")
|
.join("")
|
||||||
.substring(0, 2)}
|
.substring(0, 2)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-gray-900">
|
<p className="text-sm sm:text-base font-medium text-gray-900 truncate">
|
||||||
{selectedMedico?.nome}
|
{selectedMedico?.nome}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-xs sm:text-sm text-gray-600 truncate">
|
||||||
{selectedMedico?.especialidade}
|
{selectedMedico?.especialidade}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p>
|
||||||
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
|
📅 Data: {selectedDate && format(selectedDate, "dd/MM/yyyy")}
|
||||||
</p>
|
</p>
|
||||||
@ -658,22 +987,22 @@ export default function AgendamentoConsulta({
|
|||||||
{selectedMedico?.valorConsulta && (
|
{selectedMedico?.valorConsulta && (
|
||||||
<p>💰 Valor: R$ {selectedMedico.valorConsulta.toFixed(2)}</p>
|
<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="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>
|
||||||
</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
|
<button
|
||||||
onClick={() => setShowConfirmDialog(false)}
|
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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={confirmAppointment}
|
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
|
Confirmar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,12 +1,26 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { MessageCircle, X, Send } from "lucide-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;
|
id: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
text: string;
|
text: string;
|
||||||
sender: "user" | "bot";
|
time?: string;
|
||||||
timestamp: Date;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatbotProps {
|
interface ChatbotProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -17,13 +31,16 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
|||||||
const [messages, setMessages] = useState<Message[]>([
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
{
|
{
|
||||||
id: "welcome",
|
id: "welcome",
|
||||||
text: "Olá! Sou o assistente virtual do MediConnect. Como posso ajudá-lo hoje?",
|
role: "assistant",
|
||||||
sender: "bot",
|
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?",
|
||||||
timestamp: new Date(),
|
time: new Date().toLocaleTimeString("pt-BR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [isTyping, setIsTyping] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
@ -34,94 +51,82 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const quickReplies = [
|
/**
|
||||||
"Como agendar uma consulta?",
|
* Sanitize user input before sending.
|
||||||
"Como cancelar agendamento?",
|
* This is a basic approach. For production, you might do more thorough checks.
|
||||||
"Esqueci minha senha",
|
*/
|
||||||
"Suporte técnico",
|
function sanitizeUserMessage(text: string): string {
|
||||||
];
|
// Remove potential HTML/script tags (very naive approach)
|
||||||
|
const cleaned = text.replace(/<[^>]*>/g, "");
|
||||||
const getBotResponse = (userMessage: string): string => {
|
// Truncate if too long
|
||||||
const message = userMessage.toLowerCase();
|
return cleaned.slice(0, 1000);
|
||||||
|
|
||||||
// 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 (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.";
|
* 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.includes("senha") || message.includes("login")) {
|
const data = await response.json();
|
||||||
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.";
|
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("pagamento") || message.includes("pagar")) {
|
const handleSend = async () => {
|
||||||
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 = () => {
|
|
||||||
if (!inputValue.trim()) return;
|
if (!inputValue.trim()) return;
|
||||||
|
|
||||||
// Adiciona mensagem do usuário
|
const sanitized = sanitizeUserMessage(inputValue);
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
text: inputValue,
|
role: "user",
|
||||||
sender: "user",
|
text: sanitized,
|
||||||
timestamp: new Date(),
|
time: new Date().toLocaleTimeString("pt-BR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
// Simula digitação do bot
|
// Call AI backend
|
||||||
setIsTyping(true);
|
const reply = await callChatApi(sanitized);
|
||||||
setTimeout(() => {
|
|
||||||
const botResponse: Message = {
|
const assistantMessage: Message = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
text: getBotResponse(inputValue),
|
role: "assistant",
|
||||||
sender: "bot",
|
text: reply,
|
||||||
timestamp: new Date(),
|
time: new Date().toLocaleTimeString("pt-BR", {
|
||||||
};
|
hour: "2-digit",
|
||||||
setMessages((prev) => [...prev, botResponse]);
|
minute: "2-digit",
|
||||||
setIsTyping(false);
|
}),
|
||||||
}, 1000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickReply = (reply: string) => {
|
setMessages((prev) => [...prev, assistantMessage]);
|
||||||
setInputValue(reply);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
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 (
|
return (
|
||||||
<div className={`fixed bottom-6 left-6 z-50 ${className}`}>
|
<div className={`fixed bottom-6 left-6 z-40 ${className}`}>
|
||||||
{/* Floating Button */}
|
{/* Floating Button */}
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
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"
|
aria-label="Abrir chat de ajuda"
|
||||||
>
|
>
|
||||||
<MessageCircle className="w-6 h-6" />
|
{/* MessageCircle Icon (inline SVG) */}
|
||||||
<span className="font-medium">Precisa de ajuda?</span>
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat Window */}
|
{/* Chat Window */}
|
||||||
{isOpen && (
|
{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 */}
|
{/* 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="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="flex items-center gap-3">
|
||||||
<div className="bg-white/20 rounded-full p-2">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">Assistente MediConnect</h3>
|
<h3 className="font-semibold">Assistente MediConnect</h3>
|
||||||
<p className="text-xs text-blue-100">
|
<p className="text-xs text-blue-100">Online • AI-Powered</p>
|
||||||
Online • Responde em segundos
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -166,7 +210,21 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
|||||||
className="hover:bg-white/20 rounded-full p-1 transition"
|
className="hover:bg-white/20 rounded-full p-1 transition"
|
||||||
aria-label="Fechar chat"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -176,34 +234,33 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
|||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={`flex ${
|
className={`flex ${
|
||||||
message.sender === "user" ? "justify-end" : "justify-start"
|
message.role === "user" ? "justify-end" : "justify-start"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`max-w-[80%] rounded-lg p-3 ${
|
className={`max-w-[80%] rounded-lg p-3 ${
|
||||||
message.sender === "user"
|
message.role === "user"
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
: "bg-white text-gray-800 shadow"
|
: "bg-white text-gray-800 shadow"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-sm whitespace-pre-line">{message.text}</p>
|
<p className="text-sm whitespace-pre-line">{message.text}</p>
|
||||||
|
{message.time && (
|
||||||
<p
|
<p
|
||||||
className={`text-xs mt-1 ${
|
className={`text-xs mt-1 ${
|
||||||
message.sender === "user"
|
message.role === "user"
|
||||||
? "text-blue-100"
|
? "text-blue-100"
|
||||||
: "text-gray-400"
|
: "text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{message.timestamp.toLocaleTimeString("pt-BR", {
|
{message.time}
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isTyping && (
|
{isLoading && (
|
||||||
<div className="flex justify-start">
|
<div className="flex justify-start">
|
||||||
<div className="bg-white text-gray-800 shadow rounded-lg p-3">
|
<div className="bg-white text-gray-800 shadow rounded-lg p-3">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@ -260,11 +317,25 @@ const Chatbot: React.FC<ChatbotProps> = ({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSend}
|
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"
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 React, { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { appointmentService } from "../../services";
|
import { appointmentService, availabilityService } from "../../services";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
doctorId: string;
|
doctorId: string;
|
||||||
@ -20,38 +21,129 @@ const AvailableSlotsPicker: React.FC<Props> = ({
|
|||||||
async function fetchSlots() {
|
async function fetchSlots() {
|
||||||
if (!doctorId || !date) return;
|
if (!doctorId || !date) return;
|
||||||
|
|
||||||
console.log("🔍 [AvailableSlotsPicker] Buscando slots:", {
|
console.log("🔍 [AvailableSlotsPicker] Calculando slots localmente:", {
|
||||||
doctorId,
|
doctorId,
|
||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await appointmentService.getAvailableSlots({
|
// Busca a disponibilidade do médico
|
||||||
|
const availabilities = await availabilityService.list({
|
||||||
doctor_id: doctorId,
|
doctor_id: doctorId,
|
||||||
date: date,
|
active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📅 [AvailableSlotsPicker] Resposta da API:", res);
|
console.log(
|
||||||
|
"📅 [AvailableSlotsPicker] Disponibilidades:",
|
||||||
setLoading(false);
|
availabilities
|
||||||
|
|
||||||
if (res.slots && Array.isArray(res.slots)) {
|
|
||||||
const times = res.slots.filter((s) => s.available).map((s) => s.time);
|
|
||||||
|
|
||||||
console.log("✅ [AvailableSlotsPicker] Horários disponíveis:", times);
|
|
||||||
setSlots(times);
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"❌ [AvailableSlotsPicker] Formato de resposta inválido:",
|
|
||||||
res
|
|
||||||
);
|
);
|
||||||
toast.error("Erro ao processar horários disponíveis");
|
|
||||||
|
if (!availabilities || availabilities.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
"[AvailableSlotsPicker] Nenhuma disponibilidade configurada"
|
||||||
|
);
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error("❌ [AvailableSlotsPicker] Erro ao buscar slots:", error);
|
console.error("❌ [AvailableSlotsPicker] Erro ao buscar slots:", error);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
toast.error("Erro ao buscar horários disponíveis");
|
toast.error("Erro ao calcular horários disponíveis");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void fetchSlots();
|
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);
|
const p = patients.find((px) => px.id === e.target.value);
|
||||||
setSelectedPatientName(p?.full_name || "");
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">-- Selecione um paciente --</option>
|
<option value="">-- Selecione um paciente --</option>
|
||||||
@ -277,7 +277,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
value={selectedDoctorId}
|
value={selectedDoctorId}
|
||||||
onChange={(e) => setSelectedDoctorId(e.target.value)}
|
onChange={(e) => setSelectedDoctorId(e.target.value)}
|
||||||
ref={firstFieldRef}
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">-- Selecione um médico --</option>
|
<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
|
setSelectedTime(""); // Limpa o horário ao mudar a data
|
||||||
}}
|
}}
|
||||||
min={new Date().toISOString().split("T")[0]}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500 flex items-center gap-1">
|
<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"
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="presencial">Presencial</option>
|
<option value="presencial">Presencial</option>
|
||||||
@ -377,7 +377,7 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={(e) => setReason(e.target.value)}
|
||||||
rows={3}
|
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..."
|
placeholder="Ex: Consulta de rotina, dor de cabeça..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -420,3 +420,5 @@ const ScheduleAppointmentModal: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ScheduleAppointmentModal;
|
export default ScheduleAppointmentModal;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import {
|
|||||||
type Doctor,
|
type Doctor,
|
||||||
} from "../../services";
|
} from "../../services";
|
||||||
import { useAuth } from "../../hooks/useAuth";
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
|
import { CalendarPicker } from "../agenda/CalendarPicker";
|
||||||
|
import AvailableSlotsPicker from "../agenda/AvailableSlotsPicker";
|
||||||
|
|
||||||
// Type aliases para compatibilidade com código antigo
|
// Type aliases para compatibilidade com código antigo
|
||||||
type Consulta = Appointment & {
|
type Consulta = Appointment & {
|
||||||
@ -57,11 +59,12 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
|||||||
|
|
||||||
const [pacienteId, setPacienteId] = useState("");
|
const [pacienteId, setPacienteId] = useState("");
|
||||||
const [medicoId, setMedicoId] = 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 [tipo, setTipo] = useState("");
|
||||||
const [motivo, setMotivo] = useState("");
|
const [motivo, setMotivo] = useState("");
|
||||||
const [observacoes, setObservacoes] = useState("");
|
const [observacoes, setObservacoes] = useState("");
|
||||||
const [status, setStatus] = useState<string>("agendada");
|
const [status, setStatus] = useState<string>("requested");
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -93,30 +96,34 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
if (editing) {
|
if (editing) {
|
||||||
setPacienteId(editing.pacienteId);
|
setPacienteId(editing.patient_id || "");
|
||||||
setMedicoId(editing.medicoId);
|
setMedicoId(editing.doctor_id || "");
|
||||||
// Convert ISO to local datetime-local value
|
// Convert ISO to date and time
|
||||||
try {
|
try {
|
||||||
const d = new Date(editing.dataHora);
|
const d = new Date(editing.scheduled_at);
|
||||||
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
const dateStr = d.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
.toISOString()
|
const timeStr = `${d.getHours().toString().padStart(2, "0")}:${d
|
||||||
.slice(0, 16);
|
.getMinutes()
|
||||||
setDataHora(local);
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
setSelectedDate(dateStr);
|
||||||
|
setSelectedTime(timeStr);
|
||||||
} catch {
|
} catch {
|
||||||
setDataHora("");
|
setSelectedDate("");
|
||||||
|
setSelectedTime("");
|
||||||
}
|
}
|
||||||
setTipo(editing.tipo || "");
|
setTipo(editing.appointment_type || "");
|
||||||
setMotivo(editing.motivo || "");
|
setObservacoes(editing.notes || "");
|
||||||
setObservacoes(editing.observacoes || "");
|
setStatus(editing.status || "requested");
|
||||||
setStatus(editing.status || "agendada");
|
|
||||||
} else {
|
} else {
|
||||||
setPacienteId(defaultPacienteId || "");
|
setPacienteId(defaultPacienteId || "");
|
||||||
setMedicoId(defaultMedicoId || "");
|
setMedicoId(defaultMedicoId || "");
|
||||||
setDataHora("");
|
setSelectedDate("");
|
||||||
|
setSelectedTime("");
|
||||||
setTipo("");
|
setTipo("");
|
||||||
setMotivo("");
|
setMotivo("");
|
||||||
setObservacoes("");
|
setObservacoes("");
|
||||||
setStatus("agendada");
|
setStatus("requested");
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@ -146,8 +153,8 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
|||||||
setError("Selecione um médico.");
|
setError("Selecione um médico.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!dataHora) {
|
if (!selectedDate || !selectedTime) {
|
||||||
setError("Informe data e hora.");
|
setError("Selecione data e horário da consulta.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@ -159,35 +166,40 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Convert local datetime back to ISO
|
// Combinar data e horário no formato ISO
|
||||||
const iso = new Date(dataHora).toISOString();
|
const datetime = `${selectedDate}T${selectedTime}:00`;
|
||||||
|
const iso = new Date(datetime).toISOString();
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
const payload: ConsultaUpdate = {
|
const payload = {
|
||||||
dataHora: iso,
|
scheduled_at: iso,
|
||||||
tipo: tipo || undefined,
|
appointment_type: (tipo || "presencial") as
|
||||||
motivo: motivo || undefined,
|
| "presencial"
|
||||||
observacoes: observacoes || undefined,
|
| "telemedicina",
|
||||||
status: status,
|
notes: observacoes || undefined,
|
||||||
|
status: status as
|
||||||
|
| "requested"
|
||||||
|
| "confirmed"
|
||||||
|
| "checked_in"
|
||||||
|
| "in_progress"
|
||||||
|
| "completed"
|
||||||
|
| "cancelled"
|
||||||
|
| "no_show",
|
||||||
};
|
};
|
||||||
const resp = await consultasService.atualizar(editing.id, payload);
|
const updated = await appointmentService.update(editing.id, payload);
|
||||||
if (!resp.success || !resp.data) {
|
onSaved(updated);
|
||||||
throw new Error(resp.error || "Falha ao atualizar consulta");
|
|
||||||
}
|
|
||||||
onSaved(resp.data);
|
|
||||||
} else {
|
} else {
|
||||||
const payload: ConsultaCreate = {
|
const payload = {
|
||||||
pacienteId,
|
patient_id: pacienteId,
|
||||||
medicoId,
|
doctor_id: medicoId,
|
||||||
dataHora: iso,
|
scheduled_at: iso,
|
||||||
tipo: tipo || undefined,
|
appointment_type: (tipo || "presencial") as
|
||||||
motivo: motivo || undefined,
|
| "presencial"
|
||||||
observacoes: observacoes || undefined,
|
| "telemedicina",
|
||||||
|
notes: observacoes || undefined,
|
||||||
};
|
};
|
||||||
const resp = await consultasService.criar(payload);
|
const created = await appointmentService.create(payload);
|
||||||
if (!resp.success || !resp.data) {
|
onSaved(created);
|
||||||
throw new Error(resp.error || "Falha ao criar consulta");
|
|
||||||
}
|
|
||||||
onSaved(resp.data);
|
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -232,7 +244,7 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
|||||||
<option value="">Selecione...</option>
|
<option value="">Selecione...</option>
|
||||||
{pacientes.map((p) => (
|
{pacientes.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
{p.nome}
|
{p.full_name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -250,21 +262,53 @@ const ConsultaModal: React.FC<ConsultaModalProps> = ({
|
|||||||
<option value="">Selecione...</option>
|
<option value="">Selecione...</option>
|
||||||
{medicos.map((m) => (
|
{medicos.map((m) => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{m.nome} - {m.especialidade}
|
{m.full_name} - {m.specialty}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
{/* Calendário Visual */}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<div className="md:col-span-2 space-y-4">
|
||||||
Data / Hora
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Data da Consulta *
|
||||||
</label>
|
</label>
|
||||||
<input
|
{medicoId ? (
|
||||||
type="datetime-local"
|
<CalendarPicker
|
||||||
className="w-full border rounded px-2 py-2 text-sm"
|
doctorId={medicoId}
|
||||||
value={dataHora}
|
selectedDate={selectedDate}
|
||||||
onChange={(e) => setDataHora(e.target.value)}
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
|||||||
@ -132,7 +132,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.nome}
|
value={data.nome}
|
||||||
onChange={(e) => onChange({ nome: e.target.value })}
|
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
|
required
|
||||||
placeholder="Digite o nome completo"
|
placeholder="Digite o nome completo"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
@ -150,7 +150,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.social_name}
|
value={data.social_name}
|
||||||
onChange={(e) => onChange({ social_name: e.target.value })}
|
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"
|
placeholder="Opcional"
|
||||||
autoComplete="nickname"
|
autoComplete="nickname"
|
||||||
/>
|
/>
|
||||||
@ -169,7 +169,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.rg || ""}
|
value={data.rg || ""}
|
||||||
onChange={(e) => onChange({ rg: e.target.value })}
|
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"
|
placeholder="RG"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -184,7 +184,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
id="estado_civil"
|
id="estado_civil"
|
||||||
value={data.estado_civil || ""}
|
value={data.estado_civil || ""}
|
||||||
onChange={(e) => onChange({ estado_civil: e.target.value })}
|
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="">Selecione</option>
|
||||||
<option value="solteiro(a)">Solteiro(a)</option>
|
<option value="solteiro(a)">Solteiro(a)</option>
|
||||||
@ -206,7 +206,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.profissao || ""}
|
value={data.profissao || ""}
|
||||||
onChange={(e) => onChange({ profissao: e.target.value })}
|
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"
|
placeholder="Profissão"
|
||||||
autoComplete="organization-title"
|
autoComplete="organization-title"
|
||||||
/>
|
/>
|
||||||
@ -258,7 +258,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
id="sexo"
|
id="sexo"
|
||||||
value={data.sexo}
|
value={data.sexo}
|
||||||
onChange={(e) => onChange({ sexo: e.target.value })}
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
@ -279,7 +279,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="date"
|
type="date"
|
||||||
value={data.dataNascimento}
|
value={data.dataNascimento}
|
||||||
onChange={(e) => onChange({ dataNascimento: e.target.value })}
|
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
|
required
|
||||||
autoComplete="bday"
|
autoComplete="bday"
|
||||||
/>
|
/>
|
||||||
@ -358,7 +358,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="email"
|
type="email"
|
||||||
value={data.email}
|
value={data.email}
|
||||||
onChange={(e) => onChange({ email: e.target.value })}
|
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
|
required
|
||||||
placeholder="contato@paciente.com"
|
placeholder="contato@paciente.com"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
@ -377,7 +377,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
<select
|
<select
|
||||||
value={data.tipo_sanguineo}
|
value={data.tipo_sanguineo}
|
||||||
onChange={(e) => onChange({ tipo_sanguineo: e.target.value })}
|
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>
|
<option value="">Selecione</option>
|
||||||
{bloodTypes.map((tipo) => (
|
{bloodTypes.map((tipo) => (
|
||||||
@ -398,7 +398,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={data.altura}
|
value={data.altura}
|
||||||
onChange={(e) => onChange({ altura: e.target.value })}
|
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"
|
placeholder="170"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -413,7 +413,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
value={data.peso}
|
value={data.peso}
|
||||||
onChange={(e) => onChange({ peso: e.target.value })}
|
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"
|
placeholder="70.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -424,7 +424,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
<select
|
<select
|
||||||
value={data.convenio}
|
value={data.convenio}
|
||||||
onChange={(e) => onChange({ convenio: e.target.value })}
|
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>
|
<option value="">Selecione</option>
|
||||||
{convenios.map((c) => (
|
{convenios.map((c) => (
|
||||||
@ -442,7 +442,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.numeroCarteirinha}
|
value={data.numeroCarteirinha}
|
||||||
onChange={(e) => onChange({ numeroCarteirinha: e.target.value })}
|
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"
|
placeholder="Informe se possuir convênio"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -467,7 +467,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
onChange({ endereco: { ...data.endereco, cep: e.target.value } })
|
onChange({ endereco: { ...data.endereco, cep: e.target.value } })
|
||||||
}
|
}
|
||||||
onBlur={(e) => onCepLookup(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"
|
placeholder="00000-000"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="^\d{5}-?\d{3}$"
|
pattern="^\d{5}-?\d{3}$"
|
||||||
@ -488,7 +488,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange({ endereco: { ...data.endereco, rua: e.target.value } })
|
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"
|
placeholder="Rua"
|
||||||
autoComplete="address-line1"
|
autoComplete="address-line1"
|
||||||
/>
|
/>
|
||||||
@ -509,7 +509,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
endereco: { ...data.endereco, numero: e.target.value },
|
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"
|
placeholder="Número"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="^\d+[A-Za-z0-9/-]*$"
|
pattern="^\d+[A-Za-z0-9/-]*$"
|
||||||
@ -531,7 +531,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
endereco: { ...data.endereco, complemento: e.target.value },
|
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..."
|
placeholder="Apto, bloco..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -551,7 +551,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
endereco: { ...data.endereco, bairro: e.target.value },
|
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"
|
placeholder="Bairro"
|
||||||
autoComplete="address-line2"
|
autoComplete="address-line2"
|
||||||
/>
|
/>
|
||||||
@ -572,7 +572,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
endereco: { ...data.endereco, cidade: e.target.value },
|
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"
|
placeholder="Cidade"
|
||||||
autoComplete="address-level2"
|
autoComplete="address-level2"
|
||||||
/>
|
/>
|
||||||
@ -593,7 +593,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
endereco: { ...data.endereco, estado: e.target.value },
|
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"
|
placeholder="Estado"
|
||||||
autoComplete="address-level1"
|
autoComplete="address-level1"
|
||||||
/>
|
/>
|
||||||
@ -606,7 +606,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
<textarea
|
<textarea
|
||||||
value={data.observacoes}
|
value={data.observacoes}
|
||||||
onChange={(e) => onChange({ observacoes: e.target.value })}
|
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}
|
rows={3}
|
||||||
placeholder="Observações gerais do paciente"
|
placeholder="Observações gerais do paciente"
|
||||||
/>
|
/>
|
||||||
@ -629,7 +629,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.telefoneSecundario || ""}
|
value={data.telefoneSecundario || ""}
|
||||||
onChange={(e) => onChange({ telefoneSecundario: e.target.value })}
|
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"
|
placeholder="(DDD) 00000-0000"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
@ -646,7 +646,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.telefoneReferencia || ""}
|
value={data.telefoneReferencia || ""}
|
||||||
onChange={(e) => onChange({ telefoneReferencia: e.target.value })}
|
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"
|
placeholder="Contato de apoio"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
/>
|
/>
|
||||||
@ -669,7 +669,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.responsavel_nome || ""}
|
value={data.responsavel_nome || ""}
|
||||||
onChange={(e) => onChange({ responsavel_nome: e.target.value })}
|
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"
|
placeholder="Nome completo"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
/>
|
/>
|
||||||
@ -686,7 +686,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.responsavel_cpf || ""}
|
value={data.responsavel_cpf || ""}
|
||||||
onChange={(e) => onChange({ responsavel_cpf: e.target.value })}
|
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"
|
placeholder="000.000.000-00"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="^\d{3}\.\d{3}\.\d{3}-\d{2}$|^\d{11}$"
|
pattern="^\d{3}\.\d{3}\.\d{3}-\d{2}$|^\d{11}$"
|
||||||
@ -706,7 +706,7 @@ const PacienteForm: React.FC<PacienteFormProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={data.codigo_legado || ""}
|
value={data.codigo_legado || ""}
|
||||||
onChange={(e) => onChange({ codigo_legado: e.target.value })}
|
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"
|
placeholder="ID em outro sistema"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -811,3 +811,5 @@ const DocumentosExtras: React.FC<DocumentosExtrasProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -82,3 +82,5 @@ export default function AgendaSection({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -294,3 +294,5 @@ export default function ConsultasSection({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -179,3 +179,5 @@ export default function RelatoriosSection({
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
type Doctor,
|
type Doctor,
|
||||||
} from "../../services";
|
} from "../../services";
|
||||||
import { Avatar } from "../ui/Avatar";
|
import { Avatar } from "../ui/Avatar";
|
||||||
|
import { CalendarPicker } from "../agenda/CalendarPicker";
|
||||||
|
import AvailableSlotsPicker from "../agenda/AvailableSlotsPicker";
|
||||||
|
|
||||||
interface AppointmentWithDetails extends Appointment {
|
interface AppointmentWithDetails extends Appointment {
|
||||||
patient?: Patient;
|
patient?: Patient;
|
||||||
@ -28,9 +30,8 @@ export function SecretaryAppointmentList() {
|
|||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||||
const [selectedAppointment, setSelectedAppointment] = useState<
|
const [selectedAppointment, setSelectedAppointment] =
|
||||||
AppointmentWithDetails | null
|
useState<AppointmentWithDetails | null>(null);
|
||||||
>(null);
|
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
const [formData, setFormData] = useState<any>({
|
const [formData, setFormData] = useState<any>({
|
||||||
@ -41,6 +42,8 @@ export function SecretaryAppointmentList() {
|
|||||||
appointment_type: "presencial",
|
appointment_type: "presencial",
|
||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>("");
|
||||||
|
const [selectedTime, setSelectedTime] = useState<string>("");
|
||||||
|
|
||||||
const loadAppointments = async () => {
|
const loadAppointments = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -132,7 +135,7 @@ export function SecretaryAppointmentList() {
|
|||||||
Confirmada: "confirmed",
|
Confirmada: "confirmed",
|
||||||
Agendada: "requested",
|
Agendada: "requested",
|
||||||
Cancelada: "cancelled",
|
Cancelada: "cancelled",
|
||||||
"Concluída": "completed",
|
Concluída: "completed",
|
||||||
Concluida: "completed",
|
Concluida: "completed",
|
||||||
};
|
};
|
||||||
return map[label] || label.toLowerCase();
|
return map[label] || label.toLowerCase();
|
||||||
@ -151,10 +154,12 @@ export function SecretaryAppointmentList() {
|
|||||||
const typeValue = mapTypeFilterToValue(typeFilter);
|
const typeValue = mapTypeFilterToValue(typeFilter);
|
||||||
|
|
||||||
// Filtro de status
|
// Filtro de status
|
||||||
const matchesStatus = statusValue === null || appointment.status === statusValue;
|
const matchesStatus =
|
||||||
|
statusValue === null || appointment.status === statusValue;
|
||||||
|
|
||||||
// Filtro de tipo
|
// Filtro de tipo
|
||||||
const matchesType = typeValue === null || appointment.appointment_type === typeValue;
|
const matchesType =
|
||||||
|
typeValue === null || appointment.appointment_type === typeValue;
|
||||||
|
|
||||||
return matchesSearch && matchesStatus && matchesType;
|
return matchesSearch && matchesStatus && matchesType;
|
||||||
});
|
});
|
||||||
@ -186,6 +191,8 @@ export function SecretaryAppointmentList() {
|
|||||||
appointment_type: "presencial",
|
appointment_type: "presencial",
|
||||||
notes: "",
|
notes: "",
|
||||||
});
|
});
|
||||||
|
setSelectedDate("");
|
||||||
|
setSelectedTime("");
|
||||||
setShowCreateModal(true);
|
setShowCreateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -201,7 +208,10 @@ export function SecretaryAppointmentList() {
|
|||||||
if (modalMode === "edit" && formData.id) {
|
if (modalMode === "edit" && formData.id) {
|
||||||
// Update only allowed fields per API types
|
// Update only allowed fields per API types
|
||||||
const updatePayload: any = {};
|
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;
|
if (formData.notes) updatePayload.notes = formData.notes;
|
||||||
await appointmentService.update(formData.id, updatePayload);
|
await appointmentService.update(formData.id, updatePayload);
|
||||||
toast.success("Consulta atualizada com sucesso!");
|
toast.success("Consulta atualizada com sucesso!");
|
||||||
@ -671,9 +681,9 @@ export function SecretaryAppointmentList() {
|
|||||||
{/* Modal de Criar Consulta */}
|
{/* Modal de Criar Consulta */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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="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">
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{modalMode === "edit" ? "Editar Consulta" : "Nova Consulta"}
|
{modalMode === "edit" ? "Editar Consulta" : "Nova Consulta"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -689,7 +699,7 @@ export function SecretaryAppointmentList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, patient_id: e.target.value })
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione um paciente</option>
|
<option value="">Selecione um paciente</option>
|
||||||
@ -710,7 +720,7 @@ export function SecretaryAppointmentList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, doctor_id: e.target.value })
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione um médico</option>
|
<option value="">Selecione um médico</option>
|
||||||
@ -722,21 +732,6 @@ export function SecretaryAppointmentList() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Tipo de Consulta *
|
Tipo de Consulta *
|
||||||
@ -749,7 +744,7 @@ export function SecretaryAppointmentList() {
|
|||||||
appointment_type: e.target.value,
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="presencial">Presencial</option>
|
<option value="presencial">Presencial</option>
|
||||||
@ -758,7 +753,53 @@ export function SecretaryAppointmentList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Calendário Visual */}
|
||||||
<div className="space-y-4">
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Observações
|
Observações
|
||||||
@ -768,7 +809,8 @@ export function SecretaryAppointmentList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, notes: e.target.value })
|
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"
|
placeholder="Observações da consulta"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -786,7 +828,9 @@ export function SecretaryAppointmentList() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -797,9 +841,11 @@ export function SecretaryAppointmentList() {
|
|||||||
{/* Modal de Visualizar Consulta */}
|
{/* Modal de Visualizar Consulta */}
|
||||||
{selectedAppointment && (
|
{selectedAppointment && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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="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 flex items-center justify-between">
|
<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">Visualizar Consulta</h2>
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Visualizar Consulta
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedAppointment(null)}
|
onClick={() => setSelectedAppointment(null)}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
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="p-6 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 mb-1">Paciente</label>
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
<p className="text-gray-900 font-medium">{selectedAppointment.patient?.full_name || '—'}</p>
|
Paciente
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-900 font-medium">
|
||||||
|
{selectedAppointment.patient?.full_name || "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 mb-1">Médico</label>
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
<p className="text-gray-900">{selectedAppointment.doctor?.full_name || '—'}</p>
|
Médico
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{selectedAppointment.doctor?.full_name || "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 mb-1">Data</label>
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
<p className="text-gray-900">{selectedAppointment.scheduled_at ? formatDate(selectedAppointment.scheduled_at) : '—'}</p>
|
Data
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{selectedAppointment.scheduled_at
|
||||||
|
? formatDate(selectedAppointment.scheduled_at)
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 mb-1">Hora</label>
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
<p className="text-gray-900">{selectedAppointment.scheduled_at ? formatTime(selectedAppointment.scheduled_at) : '—'}</p>
|
Hora
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{selectedAppointment.scheduled_at
|
||||||
|
? formatTime(selectedAppointment.scheduled_at)
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 mb-1">Tipo</label>
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
<p className="text-gray-900">{selectedAppointment.appointment_type === 'telemedicina' ? 'Telemedicina' : 'Presencial'}</p>
|
Tipo
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{selectedAppointment.appointment_type === "telemedicina"
|
||||||
|
? "Telemedicina"
|
||||||
|
: "Presencial"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 mb-1">Status</label>
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
<div>{getStatusBadge(selectedAppointment.status || 'agendada')}</div>
|
Status
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
{getStatusBadge(selectedAppointment.status || "agendada")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 mb-1">Observações</label>
|
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
<p className="text-gray-900 whitespace-pre-wrap">{selectedAppointment.notes || '—'}</p>
|
Observações
|
||||||
|
</label>
|
||||||
|
<p className="text-gray-900 whitespace-pre-wrap">
|
||||||
|
{selectedAppointment.notes || "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
|||||||
@ -61,6 +61,37 @@ const formatDoctorName = (fullName: string): string => {
|
|||||||
return `Dr. ${name}`;
|
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({
|
export function SecretaryDoctorList({
|
||||||
onOpenSchedule,
|
onOpenSchedule,
|
||||||
}: {
|
}: {
|
||||||
@ -413,9 +444,14 @@ export function SecretaryDoctorList({
|
|||||||
if (onOpenSchedule) {
|
if (onOpenSchedule) {
|
||||||
onOpenSchedule(doctor.id);
|
onOpenSchedule(doctor.id);
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.setItem("selectedDoctorForSchedule", doctor.id);
|
sessionStorage.setItem(
|
||||||
|
"selectedDoctorForSchedule",
|
||||||
|
doctor.id
|
||||||
|
);
|
||||||
// dispatch a custom event to inform parent (optional)
|
// 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"
|
title="Gerenciar agenda"
|
||||||
@ -503,10 +539,10 @@ export function SecretaryDoctorList({
|
|||||||
{/* Modal de Formulário */}
|
{/* Modal de Formulário */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
{modalMode === "create" ? "Novo Médico" : "Editar Médico"}
|
{modalMode === "create" ? "Novo Médico" : "Editar Médico"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@ -531,7 +567,7 @@ export function SecretaryDoctorList({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, full_name: e.target.value })
|
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
|
required
|
||||||
placeholder="Dr. João Silva"
|
placeholder="Dr. João Silva"
|
||||||
/>
|
/>
|
||||||
@ -546,10 +582,14 @@ export function SecretaryDoctorList({
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.cpf}
|
value={formData.cpf}
|
||||||
onChange={(e) =>
|
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
|
required
|
||||||
|
maxLength={14}
|
||||||
placeholder="000.000.000-00"
|
placeholder="000.000.000-00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -566,7 +606,7 @@ export function SecretaryDoctorList({
|
|||||||
birth_date: e.target.value,
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -582,7 +622,7 @@ export function SecretaryDoctorList({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, crm: e.target.value })
|
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
|
required
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
/>
|
/>
|
||||||
@ -596,7 +636,7 @@ export function SecretaryDoctorList({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, crm_uf: e.target.value })
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
@ -618,7 +658,7 @@ export function SecretaryDoctorList({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, specialty: e.target.value })
|
setFormData({ ...formData, specialty: e.target.value })
|
||||||
}
|
}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
className="form-input"
|
||||||
>
|
>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
<option value="Cardiologia">Cardiologia</option>
|
<option value="Cardiologia">Cardiologia</option>
|
||||||
@ -640,7 +680,7 @@ export function SecretaryDoctorList({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, email: e.target.value })
|
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
|
required
|
||||||
placeholder="medico@exemplo.com"
|
placeholder="medico@exemplo.com"
|
||||||
/>
|
/>
|
||||||
@ -656,10 +696,11 @@ export function SecretaryDoctorList({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...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"
|
placeholder="(11) 98888-8888"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -690,9 +731,11 @@ export function SecretaryDoctorList({
|
|||||||
{/* Modal de Visualizar Médico */}
|
{/* Modal de Visualizar Médico */}
|
||||||
{showViewModal && selectedDoctor && (
|
{showViewModal && selectedDoctor && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<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">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<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">Visualizar Médico</h2>
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Visualizar Médico
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowViewModal(false)}
|
onClick={() => setShowViewModal(false)}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
|
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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Nome</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Especialidade</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">CRM</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500">Email</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -679,14 +679,14 @@ export function SecretaryDoctorSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Availability */}
|
{/* Current Availability */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<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 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Disponibilidade Atual
|
Disponibilidade Atual
|
||||||
</h3>
|
</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500">Carregando...</p>
|
<p className="text-gray-500 dark:text-gray-400">Carregando...</p>
|
||||||
) : availabilities.length === 0 ? (
|
) : 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">
|
<div className="space-y-3">
|
||||||
{availabilities.map((avail) => (
|
{availabilities.map((avail) => (
|
||||||
@ -729,8 +729,8 @@ export function SecretaryDoctorSchedule() {
|
|||||||
|
|
||||||
{/* Exceções (Bloqueios e Disponibilidades Extras) */}
|
{/* Exceções (Bloqueios e Disponibilidades Extras) */}
|
||||||
{exceptions.length > 0 && (
|
{exceptions.length > 0 && (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<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 mb-4">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Exceções Cadastradas
|
Exceções Cadastradas
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -805,14 +805,14 @@ export function SecretaryDoctorSchedule() {
|
|||||||
{/* Availability Dialog */}
|
{/* Availability Dialog */}
|
||||||
{showAvailabilityDialog && (
|
{showAvailabilityDialog && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<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">
|
<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 mb-4">
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Adicionar Disponibilidade
|
Adicionar Disponibilidade
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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
|
Dias da Semana
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -903,14 +903,14 @@ export function SecretaryDoctorSchedule() {
|
|||||||
{/* Exception Dialog */}
|
{/* Exception Dialog */}
|
||||||
{showExceptionDialog && (
|
{showExceptionDialog && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<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">
|
<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 mb-4">
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Adicionar Exceção
|
Adicionar Exceção
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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
|
Tipo de Exceção
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -1030,13 +1030,13 @@ export function SecretaryDoctorSchedule() {
|
|||||||
{/* Edit Dialog */}
|
{/* Edit Dialog */}
|
||||||
{showEditDialog && editingAvailability && (
|
{showEditDialog && editingAvailability && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<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">
|
<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 mb-4">
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
Editar Disponibilidade
|
Editar Disponibilidade
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
|
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||||
<p className="text-sm text-blue-900 font-medium">
|
<p className="text-sm text-blue-900 dark:text-blue-200 font-medium">
|
||||||
{weekdayToText(editingAvailability.weekday)}
|
{weekdayToText(editingAvailability.weekday)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -660,10 +660,10 @@ export function SecretaryPatientList({
|
|||||||
{/* Modal de Formulário */}
|
{/* Modal de Formulário */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
{modalMode === "create" ? "Novo Paciente" : "Editar Paciente"}
|
{modalMode === "create" ? "Novo Paciente" : "Editar Paciente"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@ -699,9 +699,9 @@ export function SecretaryPatientList({
|
|||||||
{/* Modal de Visualizar Paciente */}
|
{/* Modal de Visualizar Paciente */}
|
||||||
{showViewModal && selectedPatient && (
|
{showViewModal && selectedPatient && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<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="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 flex items-center justify-between">
|
<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">Visualizar Paciente</h2>
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Visualizar Paciente</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowViewModal(false)}
|
onClick={() => setShowViewModal(false)}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
|
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg transition-colors"
|
||||||
@ -743,13 +743,13 @@ export function SecretaryPatientList({
|
|||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
{showDeleteDialog && patientToDelete && (
|
{showDeleteDialog && patientToDelete && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<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 items-start gap-4">
|
||||||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 flex items-center justify-center">
|
<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" />
|
<Trash2 className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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
|
Confirmar Exclusão
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
@ -800,3 +800,5 @@ export function SecretaryPatientList({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import {
|
|||||||
patientService,
|
patientService,
|
||||||
type Patient,
|
type Patient,
|
||||||
doctorService,
|
doctorService,
|
||||||
type Doctor,
|
|
||||||
} from "../../services";
|
} from "../../services";
|
||||||
|
|
||||||
export function SecretaryReportList() {
|
export function SecretaryReportList() {
|
||||||
@ -22,7 +21,10 @@ export function SecretaryReportList() {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
patient_id: "",
|
patient_id: "",
|
||||||
doctor_id: "",
|
doctor_id: "",
|
||||||
@ -38,12 +40,12 @@ export function SecretaryReportList() {
|
|||||||
loadReports();
|
loadReports();
|
||||||
loadPatients();
|
loadPatients();
|
||||||
loadDoctors();
|
loadDoctors();
|
||||||
|
loadDoctors();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Recarrega automaticamente quando o filtro de status muda
|
// Recarrega automaticamente quando o filtro de status muda
|
||||||
// (evita depender do clique em Buscar)
|
// (evita depender do clique em Buscar)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
loadReports();
|
loadReports();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
@ -59,7 +61,7 @@ export function SecretaryReportList() {
|
|||||||
|
|
||||||
const loadDoctors = async () => {
|
const loadDoctors = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await doctorService.list();
|
const data = await doctorService.list({});
|
||||||
setDoctors(Array.isArray(data) ? data : []);
|
setDoctors(Array.isArray(data) ? data : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar médicos:", error);
|
console.error("Erro ao carregar médicos:", error);
|
||||||
@ -152,7 +154,7 @@ export function SecretaryReportList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await reportService.update(selectedReport.id, {
|
const updatedReport = await reportService.update(selectedReport.id, {
|
||||||
patient_id: formData.patient_id,
|
patient_id: formData.patient_id,
|
||||||
exam: formData.exam || undefined,
|
exam: formData.exam || undefined,
|
||||||
diagnosis: formData.diagnosis || undefined,
|
diagnosis: formData.diagnosis || undefined,
|
||||||
@ -162,10 +164,19 @@ export function SecretaryReportList() {
|
|||||||
requested_by: formData.requested_by || undefined,
|
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!");
|
toast.success("Relatório atualizado com sucesso!");
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedReport(null);
|
setSelectedReport(null);
|
||||||
loadReports();
|
|
||||||
|
// Limpar cache de nomes antes de recarregar
|
||||||
|
setRequestedByNames({});
|
||||||
|
await loadReports();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao atualizar relatório:", error);
|
console.error("Erro ao atualizar relatório:", error);
|
||||||
toast.error("Erro ao atualizar relatório");
|
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 () => {
|
const loadReports = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// Se um filtro de status estiver aplicado, encaminhar para o serviço
|
// Se um filtro de status estiver aplicado, encaminhar para o serviço
|
||||||
// Cast explícito para o tipo esperado pelo serviço (ReportStatus)
|
// 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);
|
console.log("[SecretaryReportList] loadReports filters:", filters);
|
||||||
const data = await reportService.list(filters);
|
const data = await reportService.list(filters);
|
||||||
console.log("✅ Relatórios carregados:", data);
|
console.log("✅ Relatórios carregados:", data);
|
||||||
@ -327,6 +400,12 @@ export function SecretaryReportList() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setReports(reportsList);
|
setReports(reportsList);
|
||||||
|
|
||||||
|
// Carregar nomes dos solicitantes
|
||||||
|
if (reportsList.length > 0) {
|
||||||
|
await loadRequestedByNames(reportsList);
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(data) && data.length === 0) {
|
if (Array.isArray(data) && data.length === 0) {
|
||||||
console.warn("⚠️ Nenhum relatório encontrado na API");
|
console.warn("⚠️ Nenhum relatório encontrado na API");
|
||||||
}
|
}
|
||||||
@ -369,8 +448,8 @@ export function SecretaryReportList() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Relatórios</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Relatórios</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-gray-600 mt-1">
|
||||||
Visualize e baixe relatórios do sistema
|
Visualize e baixe relatórios do sistema
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="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 gap-3">
|
||||||
<div className="flex-1 relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar relatórios..."
|
placeholder="Buscar relatórios..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -404,7 +483,7 @@ export function SecretaryReportList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleClear}
|
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
|
Limpar
|
||||||
</button>
|
</button>
|
||||||
@ -412,11 +491,11 @@ export function SecretaryReportList() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Status:</span>
|
<span className="text-sm text-gray-600">Status:</span>
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
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="">Todos</option>
|
||||||
<option value="draft">Rascunho</option>
|
<option value="draft">Rascunho</option>
|
||||||
@ -433,29 +512,29 @@ export function SecretaryReportList() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
<thead className="bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
<tr>
|
<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
|
Relatório
|
||||||
</th>
|
</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
|
Status
|
||||||
</th>
|
</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
|
Criado Em
|
||||||
</th>
|
</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
|
Solicitante
|
||||||
</th>
|
</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
|
Ações
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={5}
|
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...
|
Carregando relatórios...
|
||||||
</td>
|
</td>
|
||||||
@ -464,7 +543,7 @@ export function SecretaryReportList() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={5}
|
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
|
Nenhum relatório encontrado
|
||||||
</td>
|
</td>
|
||||||
@ -473,18 +552,18 @@ export function SecretaryReportList() {
|
|||||||
reports.map((report) => (
|
reports.map((report) => (
|
||||||
<tr
|
<tr
|
||||||
key={report.id}
|
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">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<FileText className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<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}
|
{report.order_number}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs text-gray-500">
|
||||||
{report.exam || "Sem exame"}
|
{report.exam || "Sem exame"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -494,12 +573,12 @@ export function SecretaryReportList() {
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
report.status === "completed"
|
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"
|
: 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"
|
: report.status === "draft"
|
||||||
? "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
? "bg-gray-100 text-gray-800"
|
||||||
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
|
: "bg-red-100 text-red-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{report.status === "completed"
|
{report.status === "completed"
|
||||||
@ -511,11 +590,14 @@ export function SecretaryReportList() {
|
|||||||
: "Cancelado"}
|
: "Cancelado"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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)}
|
{formatDate(report.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-700">
|
<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>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -560,9 +642,9 @@ export function SecretaryReportList() {
|
|||||||
{/* Modal de Criar Relatório */}
|
{/* Modal de Criar Relatório */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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="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">
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
Novo Relatório
|
Novo Relatório
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -578,7 +660,7 @@ export function SecretaryReportList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, patient_id: e.target.value })
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione um paciente</option>
|
<option value="">Selecione um paciente</option>
|
||||||
@ -590,6 +672,21 @@ export function SecretaryReportList() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Médico Solicitante *
|
Médico Solicitante *
|
||||||
@ -604,7 +701,7 @@ export function SecretaryReportList() {
|
|||||||
requested_by: selectedDoctor ? `Dr. ${selectedDoctor.full_name}` : ""
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione um médico</option>
|
<option value="">Selecione um médico</option>
|
||||||
@ -616,21 +713,6 @@ export function SecretaryReportList() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Diagnóstico
|
Diagnóstico
|
||||||
@ -640,7 +722,7 @@ export function SecretaryReportList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, diagnosis: e.target.value })
|
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"
|
placeholder="Diagnóstico do paciente"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -654,7 +736,7 @@ export function SecretaryReportList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, conclusion: e.target.value })
|
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"
|
placeholder="Conclusão e recomendações"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -683,9 +765,9 @@ export function SecretaryReportList() {
|
|||||||
{/* Modal de Visualizar Relatório */}
|
{/* Modal de Visualizar Relatório */}
|
||||||
{showViewModal && selectedReport && (
|
{showViewModal && selectedReport && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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="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 flex items-center justify-between">
|
<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">
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
Visualizar Relatório
|
Visualizar Relatório
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@ -753,7 +835,10 @@ export function SecretaryReportList() {
|
|||||||
Solicitado por
|
Solicitado por
|
||||||
</label>
|
</label>
|
||||||
<p className="text-gray-900">
|
<p className="text-gray-900">
|
||||||
{selectedReport.requested_by || "—"}
|
{selectedReport.requested_by
|
||||||
|
? requestedByNames[selectedReport.requested_by] ||
|
||||||
|
selectedReport.requested_by
|
||||||
|
: "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -819,9 +904,9 @@ export function SecretaryReportList() {
|
|||||||
{/* Modal de Editar Relatório */}
|
{/* Modal de Editar Relatório */}
|
||||||
{showEditModal && selectedReport && (
|
{showEditModal && selectedReport && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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="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 flex items-center justify-between">
|
<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">
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
Editar Relatório
|
Editar Relatório
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@ -845,7 +930,7 @@ export function SecretaryReportList() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={selectedReport.order_number || ""}
|
value={selectedReport.order_number || ""}
|
||||||
disabled
|
disabled
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -865,7 +950,7 @@ export function SecretaryReportList() {
|
|||||||
| "cancelled",
|
| "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
|
required
|
||||||
>
|
>
|
||||||
<option value="draft">Rascunho</option>
|
<option value="draft">Rascunho</option>
|
||||||
@ -885,7 +970,7 @@ export function SecretaryReportList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, exam: e.target.value })
|
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"
|
placeholder="Nome do exame realizado"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -900,49 +985,29 @@ export function SecretaryReportList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, cid_code: e.target.value })
|
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"
|
placeholder="Ex: A00.0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Médico Solicitante
|
Solicitado por
|
||||||
</label>
|
</label>
|
||||||
<select
|
<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}
|
value={formData.requested_by}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, requested_by: e.target.value })
|
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"
|
className="form-input"
|
||||||
placeholder="Nome do médico solicitante"
|
>
|
||||||
/>
|
<option value="">Selecione um médico</option>
|
||||||
|
{doctors.map((doctor) => (
|
||||||
|
<option key={doctor.id} value={doctor.id}>
|
||||||
|
{doctor.full_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -954,7 +1019,7 @@ export function SecretaryReportList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, diagnosis: e.target.value })
|
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"
|
placeholder="Diagnóstico do paciente"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -968,7 +1033,7 @@ export function SecretaryReportList() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, conclusion: e.target.value })
|
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"
|
placeholder="Conclusão e recomendações"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -247,3 +247,5 @@ export function AvatarUpload({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
value={typedConfirmation}
|
value={typedConfirmation}
|
||||||
onChange={(e) => setTypedConfirmation(e.target.value)}
|
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}
|
placeholder={confirmationWord}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@ -130,3 +130,5 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -379,7 +379,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
};
|
};
|
||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
persist({ user: newUser, savedAt: new Date().toISOString() });
|
persist({ user: newUser, savedAt: new Date().toISOString() });
|
||||||
toast.success("Login realizado");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
toast.error("Credenciais inválidas");
|
toast.error("Credenciais inválidas");
|
||||||
@ -443,7 +442,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
token: loginResp.access_token,
|
token: loginResp.access_token,
|
||||||
refreshToken: loginResp.refresh_token,
|
refreshToken: loginResp.refresh_token,
|
||||||
});
|
});
|
||||||
toast.success("Login realizado");
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AuthContext] Login falhou:", error);
|
console.error("[AuthContext] Login falhou:", error);
|
||||||
|
|||||||
@ -8,6 +8,37 @@
|
|||||||
body {
|
body {
|
||||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
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) */
|
/* Dark mode hard fallback (ensure full-page background) */
|
||||||
@ -156,6 +187,29 @@ html.focus-mode.dark *:focus-visible,
|
|||||||
.gradient-blue-light {
|
.gradient-blue-light {
|
||||||
@apply bg-gradient-to-l from-blue-600 to-blue-400;
|
@apply bg-gradient-to-l from-blue-600 to-blue-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Classes padronizadas para formulários */
|
||||||
|
.form-input {
|
||||||
|
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
||||||
|
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base bg-white;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
||||||
|
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
@apply w-full border border-gray-300 rounded-lg py-2.5 px-3 text-sm sm:text-base;
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
||||||
|
@apply dark:bg-slate-800 dark:border-slate-600 dark:text-white resize-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
@apply block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Estilos de Acessibilidade - Alto Contraste */
|
/* Estilos de Acessibilidade - Alto Contraste */
|
||||||
|
|||||||
@ -14,11 +14,13 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FileText,
|
FileText,
|
||||||
|
Eye,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from "date-fns/locale";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { appointmentService, doctorService, reportService } from "../services";
|
import { appointmentService, doctorService, reportService } from "../services";
|
||||||
import type { Report } from "../services/reports/types";
|
import type { Report } from "../services/reports/types";
|
||||||
@ -57,6 +59,7 @@ interface Medico {
|
|||||||
const AcompanhamentoPaciente: React.FC = () => {
|
const AcompanhamentoPaciente: React.FC = () => {
|
||||||
const { user, roles = [], logout } = useAuth();
|
const { user, roles = [], logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
// Helper para formatar nome do médico com Dr.
|
// Helper para formatar nome do médico com Dr.
|
||||||
const formatDoctorName = (fullName: string): string => {
|
const formatDoctorName = (fullName: string): string => {
|
||||||
@ -78,12 +81,15 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const [especialidadeFiltro, setEspecialidadeFiltro] = useState<string>("");
|
const [especialidadeFiltro, setEspecialidadeFiltro] = useState<string>("");
|
||||||
const [laudos, setLaudos] = useState<Report[]>([]);
|
const [laudos, setLaudos] = useState<Report[]>([]);
|
||||||
const [loadingLaudos, setLoadingLaudos] = useState(false);
|
const [loadingLaudos, setLoadingLaudos] = useState(false);
|
||||||
const [selectedLaudo, setSelectedLaudo] = useState<Report | null>(null);
|
|
||||||
const [showLaudoModal, setShowLaudoModal] = useState(false);
|
|
||||||
const [paginaProximas, setPaginaProximas] = useState(1);
|
const [paginaProximas, setPaginaProximas] = useState(1);
|
||||||
const [paginaPassadas, setPaginaPassadas] = 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 [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 pacienteId = user?.id || "";
|
||||||
const pacienteNome = user?.nome || "Paciente";
|
const pacienteNome = user?.nome || "Paciente";
|
||||||
@ -94,6 +100,19 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
if (!user || !isPaciente) navigate("/paciente");
|
if (!user || !isPaciente) navigate("/paciente");
|
||||||
}, [user, roles, navigate]);
|
}, [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
|
// Carregar avatar ao montar componente
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
@ -126,10 +145,10 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadingMedicos(true);
|
setLoadingMedicos(true);
|
||||||
try {
|
try {
|
||||||
// Buscar agendamentos da API
|
// Buscar TODOS os agendamentos da API (sem limite)
|
||||||
const appointments = await appointmentService.list({
|
const appointments = await appointmentService.list({
|
||||||
patient_id: pacienteId,
|
patient_id: pacienteId,
|
||||||
limit: 50,
|
limit: 1000, // Aumenta limite para buscar todas
|
||||||
order: "scheduled_at.desc",
|
order: "scheduled_at.desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -183,6 +202,32 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
fetchConsultas();
|
fetchConsultas();
|
||||||
}, [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
|
// Recarregar consultas quando mudar para a aba de consultas
|
||||||
const fetchLaudos = useCallback(async () => {
|
const fetchLaudos = useCallback(async () => {
|
||||||
if (!pacienteId) return;
|
if (!pacienteId) return;
|
||||||
@ -190,6 +235,8 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await reportService.list({ patient_id: pacienteId });
|
const data = await reportService.list({ patient_id: pacienteId });
|
||||||
setLaudos(data);
|
setLaudos(data);
|
||||||
|
// Carregar nomes dos médicos
|
||||||
|
await loadRequestedByNames(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao buscar laudos:", error);
|
console.error("Erro ao buscar laudos:", error);
|
||||||
toast.error("Erro ao carregar laudos");
|
toast.error("Erro ao carregar laudos");
|
||||||
@ -197,7 +244,7 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingLaudos(false);
|
setLoadingLaudos(false);
|
||||||
}
|
}
|
||||||
}, [pacienteId]);
|
}, [pacienteId, loadRequestedByNames]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === "appointments") {
|
if (activeTab === "appointments") {
|
||||||
@ -260,8 +307,12 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const consultasPassadasDashboard = todasConsultasPassadas.slice(0, 3);
|
const consultasPassadasDashboard = todasConsultasPassadas.slice(0, 3);
|
||||||
|
|
||||||
// Para a página de consultas (com paginação)
|
// Para a página de consultas (com paginação)
|
||||||
const totalPaginasProximas = Math.ceil(todasConsultasProximas.length / consultasPorPagina);
|
const totalPaginasProximas = Math.ceil(
|
||||||
const totalPaginasPassadas = Math.ceil(todasConsultasPassadas.length / consultasPorPagina);
|
todasConsultasProximas.length / consultasPorPagina
|
||||||
|
);
|
||||||
|
const totalPaginasPassadas = Math.ceil(
|
||||||
|
todasConsultasPassadas.length / consultasPorPagina
|
||||||
|
);
|
||||||
|
|
||||||
const consultasProximas = todasConsultasProximas.slice(
|
const consultasProximas = todasConsultasProximas.slice(
|
||||||
(paginaProximas - 1) * consultasPorPagina,
|
(paginaProximas - 1) * consultasPorPagina,
|
||||||
@ -339,10 +390,10 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
const renderSidebar = () => (
|
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 */}
|
{/* Patient Profile */}
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-slate-700">
|
<div className="p-4 sm:p-6 border-b border-gray-200 dark:border-slate-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<AvatarUpload
|
<AvatarUpload
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
currentAvatarUrl={avatarUrl}
|
currentAvatarUrl={avatarUrl}
|
||||||
@ -352,17 +403,19 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
editable={true}
|
editable={true}
|
||||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium text-gray-900 dark:text-white">
|
<p className="font-medium text-gray-900 dark:text-white truncate text-sm sm:text-base">
|
||||||
{pacienteNome}
|
{pacienteNome}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 p-4">
|
<nav className="flex-1 p-3 sm:p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{menuItems.map((item) => {
|
{menuItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@ -379,14 +432,14 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
setActiveTab(item.id);
|
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
|
isActive
|
||||||
? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
|
? "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"
|
: "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||||
{item.label}
|
<span className="truncate">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -394,15 +447,15 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* 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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
logout();
|
logout();
|
||||||
navigate("/paciente");
|
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
|
Sair
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -551,18 +604,18 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
const proximaConsulta = consultasProximas[0];
|
const proximaConsulta = consultasProximas[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div>
|
<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]}!
|
Bem-vindo, {pacienteNome.split(" ")[0]}!
|
||||||
</h1>
|
</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
|
Gerencie suas consultas e cuide da sua saúde
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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(
|
{renderStatCard(
|
||||||
"Próxima Consulta",
|
"Próxima Consulta",
|
||||||
proximaConsulta
|
proximaConsulta
|
||||||
@ -636,9 +689,13 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
{getMedicoEspecialidade(c.medicoId)}
|
{getMedicoEspecialidade(c.medicoId)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{format(new Date(c.dataHora), "dd/MM/yyyy - HH:mm", {
|
{format(
|
||||||
|
new Date(c.dataHora),
|
||||||
|
"dd/MM/yyyy - HH:mm",
|
||||||
|
{
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
})}
|
}
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -649,7 +706,8 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
onClick={() => setActiveTab("appointments")}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -666,29 +724,26 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
<div className="p-6 space-y-2">
|
<div className="p-6 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("book")}
|
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" />
|
<Calendar className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
<span>Agendar Nova Consulta</span>
|
<span>Agendar Nova Consulta</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("messages")}
|
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" />
|
<MessageCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
<span>Mensagens</span>
|
<span>Mensagens</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("profile")}
|
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" />
|
<User className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
<span>Editar Perfil</span>
|
<span>Editar Perfil</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => navigate("/ajuda")} className="form-input">
|
||||||
onClick={() => navigate("/ajuda")}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 text-left text-gray-900 dark:text-white bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<HelpCircle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
<span>Central de Ajuda</span>
|
<span>Central de Ajuda</span>
|
||||||
</button>
|
</button>
|
||||||
@ -766,25 +821,41 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
{/* Paginação Próximas Consultas */}
|
{/* Paginação Próximas Consultas */}
|
||||||
{totalPaginasProximas > 1 && (
|
{totalPaginasProximas > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
|
<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
|
<button
|
||||||
onClick={() => setPaginaProximas(Math.max(1, paginaProximas - 1))}
|
onClick={() =>
|
||||||
|
setPaginaProximas(Math.max(1, paginaProximas - 1))
|
||||||
|
}
|
||||||
disabled={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"
|
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
|
← Anterior
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
<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}
|
Página {paginaProximas} de {totalPaginasProximas}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPaginaProximas(Math.min(totalPaginasProximas, paginaProximas + 1))}
|
onClick={() =>
|
||||||
|
setPaginaProximas(
|
||||||
|
Math.min(totalPaginasProximas, paginaProximas + 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={paginaProximas === totalPaginasProximas}
|
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"
|
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
|
Próxima →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -811,25 +882,41 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
{/* Paginação Consultas Passadas */}
|
{/* Paginação Consultas Passadas */}
|
||||||
{totalPaginasPassadas > 1 && (
|
{totalPaginasPassadas > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 mt-6 pt-4 border-t border-gray-200 dark:border-slate-700">
|
<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
|
<button
|
||||||
onClick={() => setPaginaPassadas(Math.max(1, paginaPassadas - 1))}
|
onClick={() =>
|
||||||
|
setPaginaPassadas(Math.max(1, paginaPassadas - 1))
|
||||||
|
}
|
||||||
disabled={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"
|
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
|
← Anterior
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
<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}
|
Página {paginaPassadas} de {totalPaginasPassadas}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPaginaPassadas(Math.min(totalPaginasPassadas, paginaPassadas + 1))}
|
onClick={() =>
|
||||||
|
setPaginaPassadas(
|
||||||
|
Math.min(totalPaginasPassadas, paginaPassadas + 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={paginaPassadas === totalPaginasPassadas}
|
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"
|
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
|
Próxima →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -962,7 +1049,9 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
{laudo.exam || "-"}
|
{laudo.exam || "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
@ -994,9 +1083,11 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
setSelectedLaudo(laudo);
|
setSelectedLaudo(laudo);
|
||||||
setShowLaudoModal(true);
|
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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -1006,146 +1097,6 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1192,11 +1143,271 @@ const AcompanhamentoPaciente: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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()}
|
{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">
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -184,22 +184,25 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
if (etapa === 4) {
|
if (etapa === 4) {
|
||||||
return (
|
return (
|
||||||
|
<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="max-w-2xl mx-auto">
|
||||||
<div className="bg-white rounded-lg shadow-md p-8 text-center">
|
<div className="bg-white rounded-lg sm:rounded-xl shadow-md p-6 sm:p-8 text-center">
|
||||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
<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-2xl font-bold text-gray-900 mb-4">
|
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 mb-3 sm:mb-4">
|
||||||
Consulta Agendada com Sucesso!
|
Consulta Agendada com Sucesso!
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-gray-50 rounded-lg p-6 mb-6 text-left">
|
<div className="bg-gray-50 rounded-lg p-4 sm:p-6 mb-4 sm:mb-6 text-left">
|
||||||
<h3 className="font-semibold mb-3">Detalhes do Agendamento:</h3>
|
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
|
||||||
<div className="space-y-2">
|
Detalhes do Agendamento:
|
||||||
<p>
|
</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}
|
<strong>Paciente:</strong> {pacienteLogado.nome}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p className="break-words">
|
||||||
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p className="break-words">
|
||||||
<strong>Especialidade:</strong>{" "}
|
<strong>Especialidade:</strong>{" "}
|
||||||
{medicoSelecionado?.especialidade}
|
{medicoSelecionado?.especialidade}
|
||||||
</p>
|
</p>
|
||||||
@ -216,34 +219,41 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
<strong>Tipo:</strong> {agendamento.tipoConsulta}
|
<strong>Tipo:</strong> {agendamento.tipoConsulta}
|
||||||
</p>
|
</p>
|
||||||
{agendamento.motivoConsulta && (
|
{agendamento.motivoConsulta && (
|
||||||
<p>
|
<p className="break-words">
|
||||||
<strong>Motivo:</strong> {agendamento.motivoConsulta}
|
<strong>Motivo:</strong> {agendamento.motivoConsulta}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={resetarAgendamento} className="btn-primary">
|
<button
|
||||||
|
onClick={resetarAgendamento}
|
||||||
|
className="btn-primary w-full sm:w-auto text-sm sm:text-base"
|
||||||
|
>
|
||||||
Fazer Novo Agendamento
|
Fazer Novo Agendamento
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<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 */}
|
{/* 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="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 justify-between items-center">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold truncate">
|
||||||
Bem-vindo(a), {pacienteLogado.nome}!
|
Bem-vindo(a), {pacienteLogado.nome}!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="opacity-90">Agende sua consulta médica</p>
|
<p className="opacity-90 text-sm sm:text-base">
|
||||||
|
Agende sua consulta médica
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
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"
|
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" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Sair</span>
|
<span>Sair</span>
|
||||||
@ -254,11 +264,11 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
{/* As consultas locais serão exibidas na Dashboard do paciente */}
|
{/* As consultas locais serão exibidas na Dashboard do paciente */}
|
||||||
|
|
||||||
{/* Indicador de Etapas */}
|
{/* Indicador de Etapas */}
|
||||||
<div className="flex items-center justify-center mb-8">
|
<div className="flex items-center justify-center mb-6 sm:mb-8">
|
||||||
{[1, 2, 3].map((numero) => (
|
{[1, 2, 3].map((numero) => (
|
||||||
<React.Fragment key={numero}>
|
<React.Fragment key={numero}>
|
||||||
<div
|
<div
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
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
|
etapa >= numero
|
||||||
? "bg-blue-600 text-white"
|
? "bg-blue-600 text-white"
|
||||||
: "bg-gray-300 text-gray-600"
|
: "bg-gray-300 text-gray-600"
|
||||||
@ -268,7 +278,7 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{numero < 3 && (
|
{numero < 3 && (
|
||||||
<div
|
<div
|
||||||
className={`w-16 h-1 ${
|
className={`w-12 sm:w-16 h-1 ${
|
||||||
etapa > numero ? "bg-blue-600" : "bg-gray-300"
|
etapa > numero ? "bg-blue-600" : "bg-gray-300"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
@ -277,23 +287,23 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow border border-gray-200 p-6">
|
<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: Seleção de Médico */}
|
||||||
{etapa === 1 && (
|
{etapa === 1 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<h2 className="text-xl font-semibold flex items-center">
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
||||||
<User className="w-5 h-5 mr-2" />
|
<User className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||||
Selecione o Médico
|
Selecione o Médico
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||||
Médico/Especialidade
|
Médico/Especialidade
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={agendamento.medicoId}
|
value={agendamento.medicoId}
|
||||||
onChange={(e) => handleMedicoChange(e.target.value)}
|
onChange={(e) => handleMedicoChange(e.target.value)}
|
||||||
className="form-input"
|
className="form-input text-sm sm:text-base"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione um médico</option>
|
<option value="">Selecione um médico</option>
|
||||||
@ -306,11 +316,11 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEtapa(2)}
|
onClick={() => setEtapa(2)}
|
||||||
disabled={!agendamento.medicoId}
|
disabled={!agendamento.medicoId}
|
||||||
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto text-sm sm:text-base"
|
||||||
>
|
>
|
||||||
Próximo
|
Próximo
|
||||||
</button>
|
</button>
|
||||||
@ -320,20 +330,20 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
{/* Etapa 2: Seleção de Data e Horário */}
|
{/* Etapa 2: Seleção de Data e Horário */}
|
||||||
{etapa === 2 && (
|
{etapa === 2 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<h2 className="text-xl font-semibold flex items-center">
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
||||||
<Calendar className="w-5 h-5 mr-2" />
|
<Calendar className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||||
Selecione Data e Horário
|
Selecione Data e Horário
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||||
Data da Consulta
|
Data da Consulta
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={agendamento.data}
|
value={agendamento.data}
|
||||||
onChange={(e) => handleDataChange(e.target.value)}
|
onChange={(e) => handleDataChange(e.target.value)}
|
||||||
className="form-input"
|
className="form-input text-sm sm:text-base"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione uma data</option>
|
<option value="">Selecione uma data</option>
|
||||||
@ -347,7 +357,7 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
{agendamento.data && agendamento.medicoId && (
|
{agendamento.data && agendamento.medicoId && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||||
Horários Disponíveis
|
Horários Disponíveis
|
||||||
</label>
|
</label>
|
||||||
<AvailableSlotsPicker
|
<AvailableSlotsPicker
|
||||||
@ -360,17 +370,17 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEtapa(1)}
|
onClick={() => setEtapa(1)}
|
||||||
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
|
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
|
Voltar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEtapa(3)}
|
onClick={() => setEtapa(3)}
|
||||||
disabled={!agendamento.horario}
|
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"
|
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
|
Próximo
|
||||||
</button>
|
</button>
|
||||||
@ -380,14 +390,14 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
|
|
||||||
{/* Etapa 3: Informações Adicionais */}
|
{/* Etapa 3: Informações Adicionais */}
|
||||||
{etapa === 3 && (
|
{etapa === 3 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<h2 className="text-xl font-semibold flex items-center">
|
<h2 className="text-lg sm:text-xl font-semibold flex items-center">
|
||||||
<FileText className="w-5 h-5 mr-2" />
|
<FileText className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" />
|
||||||
Informações da Consulta
|
Informações da Consulta
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||||
Tipo de Consulta
|
Tipo de Consulta
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -398,7 +408,7 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
tipoConsulta: e.target.value,
|
tipoConsulta: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="form-input"
|
className="form-input text-sm sm:text-base"
|
||||||
>
|
>
|
||||||
<option value="primeira-vez">Primeira Consulta</option>
|
<option value="primeira-vez">Primeira Consulta</option>
|
||||||
<option value="retorno">Retorno</option>
|
<option value="retorno">Retorno</option>
|
||||||
@ -407,7 +417,7 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||||
Motivo da Consulta
|
Motivo da Consulta
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -418,14 +428,14 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
motivoConsulta: e.target.value,
|
motivoConsulta: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="form-input"
|
className="form-input text-sm sm:text-base"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Descreva brevemente o motivo da consulta"
|
placeholder="Descreva brevemente o motivo da consulta"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-xs sm:text-sm font-medium text-gray-700 mb-2">
|
||||||
Observações (opcional)
|
Observações (opcional)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -436,20 +446,22 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
observacoes: e.target.value,
|
observacoes: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="form-input"
|
className="form-input text-sm sm:text-base"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder="Informações adicionais relevantes"
|
placeholder="Informações adicionais relevantes"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resumo do Agendamento */}
|
{/* Resumo do Agendamento */}
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
||||||
<h3 className="font-semibold mb-3">Resumo do Agendamento:</h3>
|
<h3 className="text-sm sm:text-base font-semibold mb-2 sm:mb-3">
|
||||||
<div className="space-y-1 text-sm">
|
Resumo do Agendamento:
|
||||||
<p>
|
</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}
|
<strong>Paciente:</strong> {pacienteLogado.nome}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p className="break-words">
|
||||||
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
<strong>Médico:</strong> {medicoSelecionado?.nome}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@ -462,22 +474,23 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
<strong>Horário:</strong> {agendamento.horario}
|
<strong>Horário:</strong> {agendamento.horario}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Valor:</strong> R$ {medicoSelecionado?.valorConsulta}
|
<strong>Valor:</strong> R${" "}
|
||||||
|
{medicoSelecionado?.valorConsulta}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex flex-col sm:flex-row justify-between gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEtapa(2)}
|
onClick={() => setEtapa(2)}
|
||||||
className="btn-secondary focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-300"
|
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
|
Voltar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={confirmarAgendamento}
|
onClick={confirmarAgendamento}
|
||||||
disabled={loading}
|
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"
|
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"}
|
{loading ? "Agendando..." : "Confirmar Agendamento"}
|
||||||
</button>
|
</button>
|
||||||
@ -486,6 +499,7 @@ const AgendamentoPaciente: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -93,7 +93,6 @@ export default function AuthCallback() {
|
|||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setMessage("Autenticado com sucesso! Redirecionando...");
|
setMessage("Autenticado com sucesso! Redirecionando...");
|
||||||
toast.success("Login realizado com sucesso!");
|
|
||||||
|
|
||||||
// Verificar se há redirecionamento salvo do magic link
|
// Verificar se há redirecionamento salvo do magic link
|
||||||
const savedRedirect = localStorage.getItem("magic_link_redirect");
|
const savedRedirect = localStorage.getItem("magic_link_redirect");
|
||||||
|
|||||||
@ -491,7 +491,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, full_name: e.target.value })
|
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>
|
</div>
|
||||||
|
|
||||||
@ -505,7 +505,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, email: e.target.value })
|
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>
|
</div>
|
||||||
|
|
||||||
@ -519,7 +519,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, phone: e.target.value })
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -753,7 +753,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCreateForm({ ...createForm, email: e.target.value })
|
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"
|
placeholder="usuario@exemplo.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -773,7 +773,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
password: e.target.value,
|
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"
|
placeholder="Mínimo 6 caracteres"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -793,7 +793,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
full_name: e.target.value,
|
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"
|
placeholder="João da Silva"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -808,7 +808,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCreateForm({ ...createForm, role: e.target.value })
|
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="">Selecione...</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
@ -833,7 +833,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
phone_mobile: e.target.value,
|
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"
|
placeholder="(11) 99999-9999"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -873,7 +873,7 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
cpf: e.target.value.replace(/\D/g, ""),
|
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"
|
placeholder="12345678901"
|
||||||
maxLength={11}
|
maxLength={11}
|
||||||
/>
|
/>
|
||||||
@ -907,3 +907,5 @@ const GerenciarUsuarios: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default GerenciarUsuarios;
|
export default GerenciarUsuarios;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -112,7 +112,10 @@ const Home: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Componente invisível que detecta tokens de recuperação e redireciona */}
|
||||||
<RecoveryRedirect />
|
<RecoveryRedirect />
|
||||||
|
|
||||||
@ -121,7 +124,7 @@ const Home: React.FC = () => {
|
|||||||
|
|
||||||
{/* Métricas */}
|
{/* Métricas */}
|
||||||
<div
|
<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"
|
role="region"
|
||||||
aria-label="Estatísticas do sistema"
|
aria-label="Estatísticas do sistema"
|
||||||
>
|
>
|
||||||
@ -184,7 +187,7 @@ const Home: React.FC = () => {
|
|||||||
|
|
||||||
{/* Cards de Ação */}
|
{/* Cards de Ação */}
|
||||||
<div
|
<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"
|
role="region"
|
||||||
aria-label="Ações rápidas"
|
aria-label="Ações rápidas"
|
||||||
>
|
>
|
||||||
@ -253,24 +256,29 @@ const ActionCard: React.FC<ActionCardProps> = ({
|
|||||||
onAction,
|
onAction,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
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
|
<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>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">{title}</h3>
|
<h3 className="text-base sm:text-lg font-semibold mb-2 text-gray-900">
|
||||||
<p className="text-sm text-gray-600 mb-4 leading-relaxed">
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-600 mb-3 sm:mb-4 leading-relaxed">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onAction}
|
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}
|
aria-label={ctaAriaLabel}
|
||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
<ArrowRight
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -60,67 +60,94 @@ const ListaMedicos: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||||
<div className="flex items-center gap-2">
|
<div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
|
||||||
<Stethoscope className="w-6 h-6 text-indigo-600" />
|
{/* Cabeçalho Responsivo */}
|
||||||
<h2 className="text-2xl font-bold">Médicos Cadastrados</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
{loading && <div className="text-gray-500">Carregando médicos...</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 && (
|
{!loading && error && (
|
||||||
<div className="flex items-center gap-2 text-red-700 bg-red-50 border border-red-200 p-3 rounded-lg">
|
<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" />
|
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5 sm:mt-0" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && medicos.length === 0 && (
|
{!loading && !error && medicos.length === 0 && (
|
||||||
<div className="text-gray-500">Nenhum médico cadastrado.</div>
|
<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 && (
|
{!loading && !error && medicos.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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) => (
|
{medicos.map((medico) => (
|
||||||
<article
|
<article
|
||||||
key={medico.id}
|
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"
|
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}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-2">
|
{/* Header do Card */}
|
||||||
|
<header className="flex items-center gap-2 sm:gap-3">
|
||||||
{medico.avatar_url ? (
|
{medico.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={medico.avatar_url}
|
src={medico.avatar_url}
|
||||||
alt={medico.nome}
|
alt={medico.nome}
|
||||||
className="h-10 w-10 rounded-full object-cover border"
|
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} />
|
<AvatarInitials name={medico.nome} size={40} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Stethoscope className="w-5 h-5 text-indigo-600" />
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-lg text-gray-900">
|
<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}
|
{medico.nome}
|
||||||
</h3>
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="text-sm text-gray-700">
|
|
||||||
<strong>Especialidade:</strong> {medico.especialidade}
|
{/* 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>
|
||||||
<div className="text-sm text-gray-700">
|
<div className="text-xs sm:text-sm text-gray-700">
|
||||||
<strong>CRM:</strong> {medico.crm}
|
<strong className="font-medium">CRM:</strong> {medico.crm}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
<Mail className="w-4 h-4" /> {medico.email}
|
<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>
|
</div>
|
||||||
{medico.telefone && (
|
{medico.telefone && (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
<Phone className="w-4 h-4" /> {medico.telefone}
|
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||||
|
<span>{medico.telefone}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -58,57 +58,85 @@ const ListaPacientes: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||||
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
<div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
|
||||||
<Users className="w-6 h-6 text-blue-600" /> Pacientes Cadastrados
|
<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>
|
</h2>
|
||||||
{loading && <div className="text-gray-500">Carregando pacientes...</div>}
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||||
|
Carregando pacientes...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
<div className="text-red-600 bg-red-50 border border-red-200 p-3 rounded">
|
<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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && pacientes.length === 0 && (
|
{!loading && !error && pacientes.length === 0 && (
|
||||||
<div className="text-gray-500">Nenhum paciente cadastrado.</div>
|
<div className="text-sm sm:text-base text-gray-500 text-center py-8">
|
||||||
|
Nenhum paciente cadastrado.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && pacientes.length > 0 && (
|
{!loading && !error && pacientes.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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) => (
|
{pacientes.map((paciente, idx) => (
|
||||||
<div
|
<div
|
||||||
key={paciente.id}
|
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 ${
|
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"
|
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<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} />
|
<AvatarInitials name={paciente.full_name} size={40} />
|
||||||
<Users className="w-5 h-5 text-blue-600" />
|
</div>
|
||||||
<span className="font-semibold text-lg">
|
<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}
|
{paciente.full_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">
|
|
||||||
<strong>CPF:</strong> {formatCPF(paciente.cpf)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<Mail className="w-4 h-4" /> {formatEmail(paciente.email)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
|
||||||
<Phone className="w-4 h-4" />{" "}
|
<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)}
|
{formatPhone(paciente.phone_mobile)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs sm:text-sm text-gray-500 pt-1">
|
||||||
Nascimento:{" "}
|
<strong className="font-medium">Nascimento:</strong>{" "}
|
||||||
{paciente.birth_date
|
{paciente.birth_date
|
||||||
? new Date(paciente.birth_date).toLocaleDateString()
|
? new Date(paciente.birth_date).toLocaleDateString()
|
||||||
: "Não informado"}
|
: "Não informado"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -18,43 +18,57 @@ const ListaSecretarias: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
||||||
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
<div className="max-w-7xl mx-auto space-y-4 sm:space-y-6">
|
||||||
<UserPlus className="w-6 h-6 text-green-600" /> Secretárias Cadastradas
|
<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>
|
</h2>
|
||||||
|
|
||||||
{secretarias.length === 0 ? (
|
{secretarias.length === 0 ? (
|
||||||
<div className="text-gray-500">Nenhuma secretária cadastrada.</div>
|
<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 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<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) => (
|
{secretarias.map((sec, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
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 ${
|
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"
|
idx % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
|
||||||
<UserPlus className="w-5 h-5 text-green-600" />
|
<UserPlus className="w-4 h-4 sm:w-5 sm:h-5 text-green-600 flex-shrink-0" />
|
||||||
<span className="font-semibold text-lg">{sec.nome}</span>
|
<span className="font-semibold text-sm sm:text-base lg:text-lg truncate">
|
||||||
|
{sec.nome}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">
|
|
||||||
<strong>CPF:</strong> {sec.cpf}
|
<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>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
<div className="flex items-start gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
<Mail className="w-4 h-4" /> {sec.email}
|
<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>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-700">
|
||||||
<Phone className="w-4 h-4" /> {sec.telefone}
|
<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 className="text-xs text-gray-500">
|
|
||||||
Cadastrada em: {new Date(sec.criadoEm).toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
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 toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
@ -11,6 +11,7 @@ const LoginMedico: React.FC = () => {
|
|||||||
senha: "",
|
senha: "",
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { loginComEmailSenha } = useAuth();
|
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" />
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
id="med_password"
|
id="med_password"
|
||||||
type="password"
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.senha}
|
value={formData.senha}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, senha: e.target.value }))
|
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"
|
placeholder="Sua senha"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
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>
|
||||||
<div className="text-right mt-2">
|
<div className="text-right mt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
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 toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
@ -12,6 +12,7 @@ const LoginPaciente: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCadastro, setShowCadastro] = useState(false);
|
const [showCadastro, setShowCadastro] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [cadastroData, setCadastroData] = useState({
|
const [cadastroData, setCadastroData] = useState({
|
||||||
nome: "",
|
nome: "",
|
||||||
email: "",
|
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" />
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
id="login_password"
|
id="login_password"
|
||||||
type="password"
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.senha}
|
value={formData.senha}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
@ -252,11 +253,23 @@ const LoginPaciente: React.FC = () => {
|
|||||||
senha: e.target.value,
|
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"
|
placeholder="Sua senha"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
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>
|
||||||
<div className="text-right mt-2">
|
<div className="text-right mt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
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 toast from "react-hot-toast";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
@ -11,6 +11,7 @@ const LoginSecretaria: React.FC = () => {
|
|||||||
senha: "",
|
senha: "",
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { loginComEmailSenha } = useAuth();
|
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" />
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
id="sec_password"
|
id="sec_password"
|
||||||
type="password"
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.senha}
|
value={formData.senha}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData((prev) => ({ ...prev, senha: e.target.value }))
|
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"
|
placeholder="Sua senha"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
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>
|
||||||
<div className="text-right mt-2">
|
<div className="text-right mt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -88,7 +88,6 @@ const PainelAdmin: React.FC = () => {
|
|||||||
role: "user",
|
role: "user",
|
||||||
});
|
});
|
||||||
const [userPassword, setUserPassword] = useState("");
|
const [userPassword, setUserPassword] = useState("");
|
||||||
const [usePassword, setUsePassword] = useState(false);
|
|
||||||
const [userCpf, setUserCpf] = useState("");
|
const [userCpf, setUserCpf] = useState("");
|
||||||
const [userPhoneMobile, setUserPhoneMobile] = useState("");
|
const [userPhoneMobile, setUserPhoneMobile] = useState("");
|
||||||
const [createPatientRecord, setCreatePatientRecord] = useState(false);
|
const [createPatientRecord, setCreatePatientRecord] = useState(false);
|
||||||
@ -256,70 +255,74 @@ const PainelAdmin: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determina redirect_url baseado no role
|
// Validação: CPF é obrigatório
|
||||||
let redirectUrl = "https://mediconnectbrasil.netlify.app/";
|
if (!userCpf || getOnlyNumbers(userCpf).length !== 11) {
|
||||||
if (formUser.role === "medico") {
|
toast.error("CPF é obrigatório e deve ter 11 dígitos");
|
||||||
redirectUrl = "https://mediconnectbrasil.netlify.app/medico/painel";
|
setLoading(false);
|
||||||
} else if (formUser.role === "paciente") {
|
return;
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Criar com senha OU magic link
|
// Validação: Senha é obrigatória
|
||||||
if (usePassword && userPassword.trim()) {
|
if (!userPassword || userPassword.length < 6) {
|
||||||
// Criar com senha
|
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({
|
await userService.createUserWithPassword({
|
||||||
email: formUser.email,
|
email: formUser.email.trim(),
|
||||||
password: userPassword,
|
password: userPassword,
|
||||||
full_name: formUser.full_name,
|
full_name: formUser.full_name.trim(),
|
||||||
phone: formUser.phone,
|
phone: formUser.phone || undefined,
|
||||||
phone_mobile: userPhoneMobile,
|
phone_mobile: formattedPhoneMobile || undefined,
|
||||||
cpf: userCpf,
|
cpf: formattedCpf,
|
||||||
role: formUser.role,
|
role: formUser.role,
|
||||||
create_patient_record: createPatientRecord,
|
create_patient_record: createPatientRecord,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
`Usuário ${formUser.full_name} criado com sucesso! Email de confirmação enviado.`
|
||||||
);
|
);
|
||||||
} 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.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowUserModal(false);
|
setShowUserModal(false);
|
||||||
resetFormUser();
|
resetFormUser();
|
||||||
setUserPassword("");
|
|
||||||
setUsePassword(false);
|
|
||||||
setUserCpf("");
|
|
||||||
setUserPhoneMobile("");
|
|
||||||
setCreatePatientRecord(false);
|
|
||||||
loadUsuarios();
|
loadUsuarios();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error("Erro ao criar usuário:", error);
|
console.error("Erro ao criar usuário:", error);
|
||||||
|
|
||||||
// Mostrar mensagem de erro detalhada
|
// Mostrar mensagem de erro detalhada
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error?.response?.data?.message ||
|
(
|
||||||
error?.response?.data?.error ||
|
error as {
|
||||||
error?.message ||
|
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";
|
"Erro ao criar usuário";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
errorMessage.includes("already") ||
|
errorMessage.includes("already") ||
|
||||||
errorMessage.includes("exists") ||
|
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 {
|
} else {
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
@ -513,11 +516,14 @@ const PainelAdmin: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limpar telefone (remover formatação)
|
||||||
|
const phoneLimpo = formPaciente.phone_mobile.replace(/\D/g, "");
|
||||||
|
|
||||||
const patientData = {
|
const patientData = {
|
||||||
full_name: formPaciente.full_name,
|
full_name: formPaciente.full_name,
|
||||||
cpf: cpfLimpo,
|
cpf: cpfLimpo,
|
||||||
email: formPaciente.email,
|
email: formPaciente.email,
|
||||||
phone_mobile: formPaciente.phone_mobile,
|
phone_mobile: phoneLimpo,
|
||||||
birth_date: formPaciente.birth_date || undefined,
|
birth_date: formPaciente.birth_date || undefined,
|
||||||
social_name: formPaciente.social_name,
|
social_name: formPaciente.social_name,
|
||||||
sex: formPaciente.sex,
|
sex: formPaciente.sex,
|
||||||
@ -702,6 +708,11 @@ const PainelAdmin: React.FC = () => {
|
|||||||
return;
|
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:", {
|
console.log("[PainelAdmin] Criando médico com API /create-doctor:", {
|
||||||
email: medicoData.email,
|
email: medicoData.email,
|
||||||
full_name: medicoData.full_name,
|
full_name: medicoData.full_name,
|
||||||
@ -717,7 +728,7 @@ const PainelAdmin: React.FC = () => {
|
|||||||
crm: medicoData.crm,
|
crm: medicoData.crm,
|
||||||
crm_uf: medicoData.crm_uf,
|
crm_uf: medicoData.crm_uf,
|
||||||
specialty: medicoData.specialty || undefined,
|
specialty: medicoData.specialty || undefined,
|
||||||
phone_mobile: medicoData.phone_mobile || undefined,
|
phone_mobile: phoneLimpo,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
@ -832,6 +843,47 @@ const PainelAdmin: React.FC = () => {
|
|||||||
phone: "",
|
phone: "",
|
||||||
role: "user",
|
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 = () => {
|
const resetFormMedico = () => {
|
||||||
@ -1413,25 +1465,21 @@ const PainelAdmin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
CPF *{" "}
|
CPF *
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
(11 dígitos)
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formPaciente.cpf}
|
value={formPaciente.cpf}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const value = e.target.value.replace(/\D/g, ""); // Remove não-dígitos
|
|
||||||
setFormPaciente({
|
setFormPaciente({
|
||||||
...formPaciente,
|
...formPaciente,
|
||||||
cpf: value,
|
cpf: formatCPF(e.target.value),
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
maxLength={11}
|
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"
|
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 &&
|
||||||
formPaciente.cpf.replace(/\D/g, "").length !== 11 && (
|
formPaciente.cpf.replace(/\D/g, "").length !== 11 && (
|
||||||
@ -1468,9 +1516,10 @@ const PainelAdmin: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormPaciente({
|
setFormPaciente({
|
||||||
...formPaciente,
|
...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"
|
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"
|
placeholder="(00) 00000-0000"
|
||||||
/>
|
/>
|
||||||
@ -1620,18 +1669,37 @@ const PainelAdmin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Telefone
|
CPF *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formUser.phone || ""}
|
required
|
||||||
onChange={(e) =>
|
value={userCpf}
|
||||||
setFormUser({ ...formUser, phone: e.target.value })
|
onChange={(e) => setUserCpf(formatCPF(e.target.value))}
|
||||||
}
|
maxLength={14}
|
||||||
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-input"
|
||||||
placeholder="(00) 00000-0000"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Role/Papel *
|
Role/Papel *
|
||||||
@ -1645,91 +1713,70 @@ const PainelAdmin: React.FC = () => {
|
|||||||
role: e.target.value as UserRole,
|
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) => (
|
{availableRoles.map((role) => (
|
||||||
<option key={role} value={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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle para criar com senha */}
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<h3 className="text-sm font-semibold mb-3">
|
||||||
<input
|
Campos Opcionais
|
||||||
type="checkbox"
|
</h3>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Campo de senha (condicional) */}
|
<div className="space-y-3">
|
||||||
{usePassword && (
|
|
||||||
<>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
Senha *
|
Telefone Fixo
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="text"
|
||||||
required={usePassword}
|
value={formUser.phone || ""}
|
||||||
value={userPassword}
|
onChange={(e) =>
|
||||||
onChange={(e) => setUserPassword(e.target.value)}
|
setFormUser({
|
||||||
minLength={6}
|
...formUser,
|
||||||
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"
|
phone: formatPhone(e.target.value),
|
||||||
placeholder="Mínimo 6 caracteres"
|
})
|
||||||
/>
|
}
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
maxLength={15}
|
||||||
O usuário precisará confirmar o email antes de fazer
|
className="form-input"
|
||||||
login
|
placeholder="(00) 0000-0000"
|
||||||
</p>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Telefone Celular (obrigatório quando usa senha) */}
|
<div>
|
||||||
<div>
|
<label className="block text-sm font-medium mb-1">
|
||||||
<label className="block text-sm font-medium mb-1">
|
Telefone Celular
|
||||||
Telefone Celular *
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required={usePassword}
|
|
||||||
value={userPhoneMobile}
|
value={userPhoneMobile}
|
||||||
onChange={(e) => setUserPhoneMobile(e.target.value)}
|
onChange={(e) =>
|
||||||
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"
|
setUserPhoneMobile(formatPhone(e.target.value))
|
||||||
|
}
|
||||||
|
maxLength={15}
|
||||||
|
className="form-input"
|
||||||
placeholder="(00) 00000-0000"
|
placeholder="(00) 00000-0000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CPF (obrigatório quando usa senha) */}
|
{/* Criar registro de paciente - apenas para role paciente */}
|
||||||
<div>
|
{formUser.role === "paciente" && (
|
||||||
<label className="block text-sm font-medium mb-1">
|
<div className="border-t pt-3">
|
||||||
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">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -1740,33 +1787,32 @@ const PainelAdmin: React.FC = () => {
|
|||||||
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Criar também registro na tabela de pacientes
|
Criar também registro completo de paciente
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||||
Marque se o usuário também for um paciente
|
Recomendado para ter acesso completo aos dados médicos
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{usePassword && (
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
|
||||||
<p className="text-xs text-yellow-700">
|
|
||||||
⚠️ Campos obrigatórios para criar com senha: Telefone
|
|
||||||
Celular e CPF
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!usePassword && (
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
<p className="text-xs text-blue-700">
|
<p className="text-sm font-semibold text-blue-900 mb-1">
|
||||||
ℹ️ Um Magic Link será enviado para o email do usuário para
|
✅ Campos Obrigatórios (Todos os Roles)
|
||||||
ativação da conta
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-4">
|
<div className="flex gap-2 justify-end pt-4">
|
||||||
<button
|
<button
|
||||||
@ -1878,20 +1924,19 @@ const PainelAdmin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">
|
<label className="block text-sm font-medium mb-1">
|
||||||
CPF *{" "}
|
CPF *
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
(11 dígitos)
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formMedico.cpf}
|
value={formMedico.cpf}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
const value = e.target.value.replace(/\D/g, ""); // Remove não-dígitos
|
setFormMedico({
|
||||||
setFormMedico({ ...formMedico, cpf: value });
|
...formMedico,
|
||||||
}}
|
cpf: formatCPF(e.target.value),
|
||||||
maxLength={11}
|
})
|
||||||
|
}
|
||||||
|
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"
|
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
|
||||||
placeholder="12345678901"
|
placeholder="12345678901"
|
||||||
/>
|
/>
|
||||||
@ -1938,9 +1983,10 @@ const PainelAdmin: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormMedico({
|
setFormMedico({
|
||||||
...formMedico,
|
...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"
|
className="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-purple-600/40"
|
||||||
placeholder="(11) 98888-8888"
|
placeholder="(11) 98888-8888"
|
||||||
/>
|
/>
|
||||||
@ -2048,7 +2094,7 @@ const PainelAdmin: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, full_name: e.target.value })
|
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>
|
</div>
|
||||||
|
|
||||||
@ -2062,7 +2108,7 @@ const PainelAdmin: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, email: e.target.value })
|
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>
|
</div>
|
||||||
|
|
||||||
@ -2076,7 +2122,7 @@ const PainelAdmin: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, phone: e.target.value })
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
|
Save,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
appointmentService,
|
appointmentService,
|
||||||
patientService,
|
patientService,
|
||||||
reportService,
|
reportService,
|
||||||
|
doctorService,
|
||||||
type Appointment,
|
type Appointment,
|
||||||
type Patient,
|
type Patient,
|
||||||
type CreateReportInput,
|
type CreateReportInput,
|
||||||
@ -64,14 +66,14 @@ const PainelMedico: React.FC = () => {
|
|||||||
(user.role === "medico" ||
|
(user.role === "medico" ||
|
||||||
roles.includes("medico") ||
|
roles.includes("medico") ||
|
||||||
roles.includes("admin"));
|
roles.includes("admin"));
|
||||||
const medicoId = temAcessoMedico ? user.id : "";
|
|
||||||
const medicoNome = user?.nome || "Médico";
|
const medicoNome = user?.nome || "Médico";
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [doctorId, setDoctorId] = useState<string | null>(null); // ID real do médico na tabela doctors
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [activeTab, setActiveTab] = useState("dashboard");
|
const [activeTab, setActiveTab] = useState("dashboard");
|
||||||
const [consultas, setConsultas] = useState<ConsultaUI[]>([]);
|
const [consultas, setConsultas] = useState<ConsultaUI[]>([]);
|
||||||
const [filtroData, setFiltroData] = useState("hoje");
|
const [filtroData, setFiltroData] = useState("todas");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<ConsultaUI | null>(null);
|
const [editing, setEditing] = useState<ConsultaUI | null>(null);
|
||||||
@ -97,9 +99,77 @@ const PainelMedico: React.FC = () => {
|
|||||||
hide_signature: false,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!medicoId) navigate("/login-medico");
|
const fetchDoctorId = async () => {
|
||||||
}, [medicoId, navigate]);
|
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
|
// Carregar avatar ao montar componente
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -119,7 +189,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
console.log(`[PainelMedico] Avatar encontrado: ${url}`);
|
console.log(`[PainelMedico] Avatar encontrado: ${url}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Continua testando próxima extensão
|
// Continua testando próxima extensão
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,8 +207,11 @@ const PainelMedico: React.FC = () => {
|
|||||||
appointments = await appointmentService.list();
|
appointments = await appointmentService.list();
|
||||||
} else {
|
} else {
|
||||||
// Médico comum: busca todas as consultas do próprio médico
|
// Médico comum: busca todas as consultas do próprio médico
|
||||||
if (!medicoId) return;
|
if (!doctorId) {
|
||||||
appointments = await appointmentService.list({ doctor_id: medicoId });
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appointments = await appointmentService.list({ doctor_id: doctorId });
|
||||||
}
|
}
|
||||||
if (appointments && appointments.length > 0) {
|
if (appointments && appointments.length > 0) {
|
||||||
// Buscar nomes dos pacientes
|
// Buscar nomes dos pacientes
|
||||||
@ -177,17 +250,17 @@ const PainelMedico: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [user, roles, medicoId, medicoNome]);
|
}, [user, roles, doctorId, medicoNome]);
|
||||||
|
|
||||||
const fetchLaudos = useCallback(async () => {
|
const fetchLaudos = useCallback(async () => {
|
||||||
if (!medicoId) return;
|
if (!doctorId) return;
|
||||||
setLoadingLaudos(true);
|
setLoadingLaudos(true);
|
||||||
try {
|
try {
|
||||||
// Buscar todos os laudos e filtrar pelo médico criador
|
// Buscar todos os laudos e filtrar pelo médico criador
|
||||||
const allReports = await reportService.list();
|
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(
|
const meusLaudos = allReports.filter(
|
||||||
(report: Report) => report.created_by === medicoId
|
(report: Report) => report.created_by === doctorId
|
||||||
);
|
);
|
||||||
setLaudos(meusLaudos);
|
setLaudos(meusLaudos);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -197,7 +270,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingLaudos(false);
|
setLoadingLaudos(false);
|
||||||
}
|
}
|
||||||
}, [medicoId]);
|
}, [doctorId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConsultas();
|
fetchConsultas();
|
||||||
@ -746,7 +819,39 @@ const PainelMedico: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAppointments = () => (
|
// Função para filtrar consultas por data
|
||||||
|
const filtrarConsultasPorData = (consultas: ConsultaUI[]) => {
|
||||||
|
const hoje = new Date();
|
||||||
|
hoje.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
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="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
@ -793,19 +898,26 @@ const PainelMedico: React.FC = () => {
|
|||||||
Carregando consultas...
|
Carregando consultas...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : consultas.length === 0 ? (
|
) : consultasFiltradas.length === 0 ? (
|
||||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
||||||
Nenhuma consulta encontrada
|
{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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{consultas.map(renderAppointmentCard)}
|
{consultasFiltradas.map(renderAppointmentCard)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderAvailability = () => <DisponibilidadeMedico />;
|
const renderAvailability = () => <DisponibilidadeMedico />;
|
||||||
|
|
||||||
@ -907,18 +1019,444 @@ const PainelMedico: React.FC = () => {
|
|||||||
</div>
|
</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 = () => (
|
const renderSettings = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
Configurações
|
Meu Perfil
|
||||||
</h1>
|
</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="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">
|
<div className="p-6">
|
||||||
<p className="text-center py-8 text-gray-600 dark:text-gray-400">
|
{/* 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
|
Funcionalidade em desenvolvimento
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -961,10 +1499,12 @@ const PainelMedico: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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()}
|
{renderSidebar()}
|
||||||
<main className="flex-1 overflow-y-auto">
|
<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>
|
</main>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
@ -977,7 +1517,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onSaved={handleSaveConsulta}
|
onSaved={handleSaveConsulta}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
defaultMedicoId={medicoId}
|
defaultMedicoId={doctorId || ""}
|
||||||
lockMedico={false}
|
lockMedico={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -1010,7 +1550,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
required
|
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>
|
<option value="">Selecione um paciente</option>
|
||||||
{pacientesDisponiveis.map((p) => (
|
{pacientesDisponiveis.map((p) => (
|
||||||
@ -1031,7 +1571,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
setFormRelatorio((p) => ({ ...p, exam: e.target.value }))
|
setFormRelatorio((p) => ({ ...p, exam: e.target.value }))
|
||||||
}
|
}
|
||||||
required
|
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>
|
||||||
<div>
|
<div>
|
||||||
@ -1047,7 +1587,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
rows={3}
|
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>
|
||||||
<div>
|
<div>
|
||||||
@ -1063,7 +1603,7 @@ const PainelMedico: React.FC = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
rows={3}
|
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>
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
|||||||
@ -2121,7 +2121,7 @@ const PainelSecretaria = () => {
|
|||||||
nome: event.target.value,
|
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
|
required
|
||||||
placeholder="Maria Santos Silva"
|
placeholder="Maria Santos Silva"
|
||||||
/>
|
/>
|
||||||
@ -2140,7 +2140,7 @@ const PainelSecretaria = () => {
|
|||||||
social_name: event.target.value,
|
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"
|
placeholder="Maria Santos"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2153,7 +2153,7 @@ const PainelSecretaria = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formDataPaciente.cpf}
|
value={formDataPaciente.cpf}
|
||||||
onChange={handleCpfChange}
|
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
|
required
|
||||||
placeholder="000.000.000-00"
|
placeholder="000.000.000-00"
|
||||||
maxLength={14}
|
maxLength={14}
|
||||||
@ -2173,7 +2173,7 @@ const PainelSecretaria = () => {
|
|||||||
dataNascimento: event.target.value,
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2190,7 +2190,7 @@ const PainelSecretaria = () => {
|
|||||||
sexo: event.target.value,
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
@ -2221,7 +2221,7 @@ const PainelSecretaria = () => {
|
|||||||
email: event.target.value,
|
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
|
required
|
||||||
placeholder="maria@email.com"
|
placeholder="maria@email.com"
|
||||||
/>
|
/>
|
||||||
@ -2302,7 +2302,7 @@ const PainelSecretaria = () => {
|
|||||||
tipo_sanguineo: event.target.value,
|
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>
|
<option value="">Selecione</option>
|
||||||
{BLOOD_TYPES.map((tipo) => (
|
{BLOOD_TYPES.map((tipo) => (
|
||||||
@ -2329,7 +2329,7 @@ const PainelSecretaria = () => {
|
|||||||
peso: event.target.value,
|
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"
|
placeholder="65.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2350,7 +2350,7 @@ const PainelSecretaria = () => {
|
|||||||
altura: event.target.value,
|
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"
|
placeholder="1.65"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2369,7 +2369,7 @@ const PainelSecretaria = () => {
|
|||||||
convenio: event.target.value,
|
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>
|
<option value="">Selecione</option>
|
||||||
{CONVENIOS.map((option) => (
|
{CONVENIOS.map((option) => (
|
||||||
@ -2393,7 +2393,7 @@ const PainelSecretaria = () => {
|
|||||||
numeroCarteirinha: event.target.value,
|
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"
|
placeholder="Número da carteirinha"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
placeholder="Rua das Flores"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
placeholder="123"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
placeholder="Centro"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
placeholder="São Paulo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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"
|
placeholder="SP"
|
||||||
maxLength={2}
|
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..."
|
placeholder="Apto 45, Bloco B..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2599,7 +2599,7 @@ const PainelSecretaria = () => {
|
|||||||
observacoes: event.target.value,
|
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}
|
rows={3}
|
||||||
placeholder="Observações gerais sobre o paciente..."
|
placeholder="Observações gerais sobre o paciente..."
|
||||||
/>
|
/>
|
||||||
@ -2725,7 +2725,7 @@ const PainelSecretaria = () => {
|
|||||||
patientId: e.target.value,
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">-- Selecione --</option>
|
<option value="">-- Selecione --</option>
|
||||||
@ -2749,7 +2749,7 @@ const PainelSecretaria = () => {
|
|||||||
orderNumber: e.target.value.toUpperCase(),
|
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
|
required
|
||||||
placeholder="Ex: REL-2025-10-MUS3TN"
|
placeholder="Ex: REL-2025-10-MUS3TN"
|
||||||
pattern="^REL-\d{4}-\d{2}-[A-Z0-9]{6}$"
|
pattern="^REL-\d{4}-\d{2}-[A-Z0-9]{6}$"
|
||||||
@ -2769,7 +2769,7 @@ const PainelSecretaria = () => {
|
|||||||
exam: e.target.value,
|
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"
|
placeholder="Ex: Hemograma"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2786,7 +2786,7 @@ const PainelSecretaria = () => {
|
|||||||
dueAt: e.target.value,
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -2803,7 +2803,7 @@ const PainelSecretaria = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
rows={3}
|
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>
|
||||||
<div>
|
<div>
|
||||||
@ -2819,7 +2819,7 @@ const PainelSecretaria = () => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
rows={3}
|
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>
|
||||||
<div className="flex justify-end gap-3 border-t pt-4">
|
<div className="flex justify-end gap-3 border-t pt-4">
|
||||||
@ -3048,7 +3048,7 @@ const PainelSecretaria = () => {
|
|||||||
nome: event.target.value,
|
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
|
required
|
||||||
placeholder="Dr. João da Silva"
|
placeholder="Dr. João da Silva"
|
||||||
/>
|
/>
|
||||||
@ -3069,7 +3069,7 @@ const PainelSecretaria = () => {
|
|||||||
cpf: digits,
|
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
|
required
|
||||||
placeholder="000.000.000-00"
|
placeholder="000.000.000-00"
|
||||||
maxLength={14}
|
maxLength={14}
|
||||||
@ -3089,7 +3089,7 @@ const PainelSecretaria = () => {
|
|||||||
rg: event.target.value,
|
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"
|
placeholder="00.000.000-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -3108,7 +3108,7 @@ const PainelSecretaria = () => {
|
|||||||
dataNascimento: event.target.value,
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -3134,7 +3134,7 @@ const PainelSecretaria = () => {
|
|||||||
crm: event.target.value,
|
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
|
required
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
/>
|
/>
|
||||||
@ -3152,7 +3152,7 @@ const PainelSecretaria = () => {
|
|||||||
crmUf: event.target.value,
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
@ -3205,7 +3205,7 @@ const PainelSecretaria = () => {
|
|||||||
especialidade: event.target.value,
|
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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Selecione</option>
|
<option value="">Selecione</option>
|
||||||
@ -3248,7 +3248,7 @@ const PainelSecretaria = () => {
|
|||||||
email: event.target.value,
|
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
|
required
|
||||||
placeholder="medico@email.com"
|
placeholder="medico@email.com"
|
||||||
/>
|
/>
|
||||||
@ -3268,7 +3268,7 @@ const PainelSecretaria = () => {
|
|||||||
telefone: buildMedicoTelefone(event.target.value),
|
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
|
required
|
||||||
placeholder="(11) 99999-9999"
|
placeholder="(11) 99999-9999"
|
||||||
/>
|
/>
|
||||||
@ -3287,7 +3287,7 @@ const PainelSecretaria = () => {
|
|||||||
telefone2: event.target.value,
|
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"
|
placeholder="(11) 3333-4444"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -3355,7 +3355,7 @@ const PainelSecretaria = () => {
|
|||||||
rua: event.target.value,
|
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"
|
placeholder="Nome da rua"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -3374,7 +3374,7 @@ const PainelSecretaria = () => {
|
|||||||
numero: event.target.value,
|
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"
|
placeholder="123"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -3395,7 +3395,7 @@ const PainelSecretaria = () => {
|
|||||||
bairro: event.target.value,
|
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"
|
placeholder="Bairro"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -3414,7 +3414,7 @@ const PainelSecretaria = () => {
|
|||||||
cidade: event.target.value,
|
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"
|
placeholder="Cidade"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -3433,7 +3433,7 @@ const PainelSecretaria = () => {
|
|||||||
estado: event.target.value.toUpperCase(),
|
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"
|
placeholder="UF"
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
required
|
required
|
||||||
@ -3454,7 +3454,7 @@ const PainelSecretaria = () => {
|
|||||||
complemento: event.target.value,
|
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..."
|
placeholder="Apto, sala, bloco..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -3479,7 +3479,7 @@ const PainelSecretaria = () => {
|
|||||||
senha: event.target.value,
|
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
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
placeholder="Mínimo 6 caracteres"
|
placeholder="Mínimo 6 caracteres"
|
||||||
@ -3542,3 +3542,5 @@ const PainelSecretaria = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default PainelSecretaria;
|
export default PainelSecretaria;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -39,33 +39,33 @@ export default function PainelSecretaria() {
|
|||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
<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="max-w-[1400px] mx-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
<h1 className="text-lg sm:text-xl lg:text-2xl font-bold text-gray-900 dark:text-white truncate">
|
||||||
Painel da Secretaria
|
Painel da Secretaria
|
||||||
</h1>
|
</h1>
|
||||||
{user && (
|
{user && (
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<p className="text-xs sm:text-sm text-gray-600 mt-0.5 sm:mt-1 truncate">
|
||||||
Bem-vinda, {user.email}
|
Bem-vindo(a), {user.nome || user.email}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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" />
|
<LogOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
Sair
|
<span className="hidden sm:inline">Sair</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation */}
|
||||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<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-6">
|
<div className="max-w-[1400px] mx-auto px-4 sm:px-6">
|
||||||
<nav className="flex gap-2">
|
<nav className="flex gap-1 sm:gap-2 min-w-max">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
const isActive = activeTab === tab.id;
|
const isActive = activeTab === tab.id;
|
||||||
@ -73,14 +73,15 @@ export default function PainelSecretaria() {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(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
|
isActive
|
||||||
? "border-green-600 text-green-600 dark:text-green-400 font-medium"
|
? "border-green-600 text-green-600 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-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-3.5 w-3.5 sm:h-4 sm:w-4 flex-shrink-0" />
|
||||||
{tab.label}
|
<span className="hidden sm:inline">{tab.label}</span>
|
||||||
|
<span className="sm:hidden">{tab.label.split(" ")[0]}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -89,12 +90,15 @@ export default function PainelSecretaria() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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" && (
|
{activeTab === "pacientes" && (
|
||||||
<SecretaryPatientList
|
<SecretaryPatientList
|
||||||
onOpenAppointment={(patientId: string) => {
|
onOpenAppointment={(patientId: string) => {
|
||||||
// store selected patient for appointment and switch to consultas tab
|
// store selected patient for appointment and switch to consultas tab
|
||||||
sessionStorage.setItem("selectedPatientForAppointment", patientId);
|
sessionStorage.setItem(
|
||||||
|
"selectedPatientForAppointment",
|
||||||
|
patientId
|
||||||
|
);
|
||||||
setActiveTab("consultas");
|
setActiveTab("consultas");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Save } from "lucide-react";
|
import { Save, ArrowLeft } from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { doctorService } from "../services";
|
import { doctorService } from "../services";
|
||||||
import { AvatarUpload } from "../components/ui/AvatarUpload";
|
import { AvatarUpload } from "../components/ui/AvatarUpload";
|
||||||
|
|
||||||
export default function PerfilMedico() {
|
export default function PerfilMedico() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
@ -43,13 +45,30 @@ export default function PerfilMedico() {
|
|||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
const loadDoctorData = async () => {
|
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 {
|
try {
|
||||||
setLoading(true);
|
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) {
|
if (doctor) {
|
||||||
|
console.log("[PerfilMedico] Dados do médico carregados:", doctor);
|
||||||
setFormData({
|
setFormData({
|
||||||
full_name: doctor.full_name || "",
|
full_name: doctor.full_name || "",
|
||||||
email: doctor.email || "",
|
email: doctor.email || "",
|
||||||
@ -64,11 +83,28 @@ export default function PerfilMedico() {
|
|||||||
education: "", // Doctor type não tem education
|
education: "", // Doctor type não tem education
|
||||||
experience_years: "", // Doctor type não tem experience_years
|
experience_years: "", // Doctor type não tem experience_years
|
||||||
});
|
});
|
||||||
// Doctor type não tem avatar_url ainda
|
|
||||||
setAvatarUrl(undefined);
|
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) {
|
} 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");
|
toast.error("Erro ao carregar dados do perfil");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -132,37 +168,48 @@ export default function PerfilMedico() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8 px-4">
|
<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-6">
|
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div className="flex items-start sm:items-center gap-2 sm:gap-3 w-full sm:w-auto">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1>
|
<button
|
||||||
<p className="text-gray-600">
|
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
|
Gerencie suas informações pessoais e profissionais
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
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
|
Editar Perfil
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
loadDoctorData();
|
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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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" />
|
<Save className="w-4 h-4" />
|
||||||
Salvar
|
Salvar
|
||||||
@ -172,9 +219,11 @@ export default function PerfilMedico() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avatar Card */}
|
{/* Avatar Card */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Foto de Perfil</h2>
|
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">
|
||||||
<div className="flex items-center gap-6">
|
Foto de Perfil
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
|
||||||
<AvatarUpload
|
<AvatarUpload
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
currentAvatarUrl={avatarUrl}
|
currentAvatarUrl={avatarUrl}
|
||||||
@ -184,10 +233,14 @@ export default function PerfilMedico() {
|
|||||||
editable={true}
|
editable={true}
|
||||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className="text-center sm:text-left min-w-0 flex-1">
|
||||||
<p className="font-medium text-gray-900">{formData.full_name}</p>
|
<p className="font-medium text-gray-900 text-sm sm:text-base truncate">
|
||||||
<p className="text-gray-500">{formData.specialty}</p>
|
{formData.full_name}
|
||||||
<p className="text-sm text-gray-500">
|
</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}
|
CRM: {formData.crm} - {formData.crm_state}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -196,11 +249,11 @@ export default function PerfilMedico() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200 overflow-x-auto">
|
||||||
<nav className="flex -mb-px">
|
<nav className="flex -mb-px min-w-max">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("personal")}
|
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"
|
activeTab === "personal"
|
||||||
? "border-green-600 text-green-600"
|
? "border-green-600 text-green-600"
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
@ -210,17 +263,17 @@ export default function PerfilMedico() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("professional")}
|
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"
|
activeTab === "professional"
|
||||||
? "border-green-600 text-green-600"
|
? "border-green-600 text-green-600"
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Informações Profissionais
|
Info. Profissionais
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("security")}
|
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"
|
activeTab === "security"
|
||||||
? "border-green-600 text-green-600"
|
? "border-green-600 text-green-600"
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
@ -231,7 +284,7 @@ export default function PerfilMedico() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-4 sm:p-6">
|
||||||
{/* Tab: Dados Pessoais */}
|
{/* Tab: Dados Pessoais */}
|
||||||
{activeTab === "personal" && (
|
{activeTab === "personal" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -255,7 +308,7 @@ export default function PerfilMedico() {
|
|||||||
handleChange("full_name", e.target.value)
|
handleChange("full_name", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -268,7 +321,7 @@ export default function PerfilMedico() {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => handleChange("email", e.target.value)}
|
onChange={(e) => handleChange("email", e.target.value)}
|
||||||
disabled={!isEditing}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -281,7 +334,7 @@ export default function PerfilMedico() {
|
|||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => handleChange("phone", e.target.value)}
|
onChange={(e) => handleChange("phone", e.target.value)}
|
||||||
disabled={!isEditing}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -293,7 +346,7 @@ export default function PerfilMedico() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.cpf}
|
value={formData.cpf}
|
||||||
disabled
|
disabled
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -308,7 +361,7 @@ export default function PerfilMedico() {
|
|||||||
handleChange("birth_date", e.target.value)
|
handleChange("birth_date", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -320,7 +373,7 @@ export default function PerfilMedico() {
|
|||||||
value={formData.gender}
|
value={formData.gender}
|
||||||
onChange={(e) => handleChange("gender", e.target.value)}
|
onChange={(e) => handleChange("gender", e.target.value)}
|
||||||
disabled={!isEditing}
|
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="">Selecione</option>
|
||||||
<option value="male">Masculino</option>
|
<option value="male">Masculino</option>
|
||||||
@ -356,7 +409,7 @@ export default function PerfilMedico() {
|
|||||||
handleChange("specialty", e.target.value)
|
handleChange("specialty", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -368,7 +421,7 @@ export default function PerfilMedico() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.crm}
|
value={formData.crm}
|
||||||
disabled
|
disabled
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -381,7 +434,7 @@ export default function PerfilMedico() {
|
|||||||
value={formData.crm_state}
|
value={formData.crm_state}
|
||||||
disabled
|
disabled
|
||||||
maxLength={2}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -397,7 +450,7 @@ export default function PerfilMedico() {
|
|||||||
}
|
}
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
min="0"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -412,7 +465,7 @@ export default function PerfilMedico() {
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
placeholder="Conte um pouco sobre sua trajetória profissional..."
|
placeholder="Conte um pouco sobre sua trajetória profissional..."
|
||||||
rows={4}
|
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>
|
||||||
|
|
||||||
@ -428,7 +481,7 @@ export default function PerfilMedico() {
|
|||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
placeholder="Universidades, residências, especializações..."
|
placeholder="Universidades, residências, especializações..."
|
||||||
rows={4}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -459,7 +512,7 @@ export default function PerfilMedico() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Digite sua senha atual"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -477,7 +530,7 @@ export default function PerfilMedico() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Digite a nova senha"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -495,7 +548,7 @@ export default function PerfilMedico() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Confirme a nova senha"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -215,46 +215,48 @@ export default function PerfilPaciente() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8 px-4">
|
<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-6">
|
<div className="max-w-4xl mx-auto space-y-4 sm:space-y-6">
|
||||||
{/* Botão Voltar */}
|
{/* Botão Voltar */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/acompanhamento")}
|
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
|
Voltar para o Painel
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Meu Perfil</h1>
|
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 truncate">
|
||||||
<p className="text-gray-600">
|
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
|
Gerencie suas informações pessoais e médicas
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(true)}
|
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
|
Editar Perfil
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
loadPatientData();
|
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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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" />
|
<Save className="w-4 h-4" />
|
||||||
Salvar
|
Salvar
|
||||||
@ -264,9 +266,11 @@ export default function PerfilPaciente() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Avatar Card */}
|
{/* Avatar Card */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-4 sm:p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Foto de Perfil</h2>
|
<h2 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">
|
||||||
<div className="flex items-center gap-6">
|
Foto de Perfil
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6">
|
||||||
<AvatarUpload
|
<AvatarUpload
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
currentAvatarUrl={avatarUrl}
|
currentAvatarUrl={avatarUrl}
|
||||||
@ -276,22 +280,24 @@ export default function PerfilPaciente() {
|
|||||||
editable={true}
|
editable={true}
|
||||||
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
onAvatarUpdate={(url) => setAvatarUrl(url || undefined)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className="text-center sm:text-left min-w-0 flex-1">
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-gray-900 text-sm sm:text-base truncate">
|
||||||
{formData.full_name || "Carregando..."}
|
{formData.full_name || "Carregando..."}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<div className="border-b border-gray-200">
|
<div className="border-b border-gray-200 overflow-x-auto">
|
||||||
<nav className="flex -mb-px">
|
<nav className="flex -mb-px min-w-max">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("personal")}
|
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"
|
activeTab === "personal"
|
||||||
? "border-blue-600 text-blue-600"
|
? "border-blue-600 text-blue-600"
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
@ -301,17 +307,17 @@ export default function PerfilPaciente() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("medical")}
|
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"
|
activeTab === "medical"
|
||||||
? "border-blue-600 text-blue-600"
|
? "border-blue-600 text-blue-600"
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Informações Médicas
|
Info. Médicas
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("security")}
|
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"
|
activeTab === "security"
|
||||||
? "border-blue-600 text-blue-600"
|
? "border-blue-600 text-blue-600"
|
||||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
: "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)
|
handleChange("full_name", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -359,7 +365,7 @@ export default function PerfilPaciente() {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => handleChange("email", e.target.value)}
|
onChange={(e) => handleChange("email", e.target.value)}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -374,7 +380,7 @@ export default function PerfilPaciente() {
|
|||||||
handleChange("phone_mobile", e.target.value)
|
handleChange("phone_mobile", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -386,7 +392,7 @@ export default function PerfilPaciente() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.cpf}
|
value={formData.cpf}
|
||||||
disabled
|
disabled
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -401,7 +407,7 @@ export default function PerfilPaciente() {
|
|||||||
handleChange("birth_date", e.target.value)
|
handleChange("birth_date", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -413,7 +419,7 @@ export default function PerfilPaciente() {
|
|||||||
value={formData.sex}
|
value={formData.sex}
|
||||||
onChange={(e) => handleChange("sex", e.target.value)}
|
onChange={(e) => handleChange("sex", e.target.value)}
|
||||||
disabled={!isEditing}
|
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="">Selecione</option>
|
||||||
<option value="M">Masculino</option>
|
<option value="M">Masculino</option>
|
||||||
@ -437,7 +443,7 @@ export default function PerfilPaciente() {
|
|||||||
value={formData.street}
|
value={formData.street}
|
||||||
onChange={(e) => handleChange("street", e.target.value)}
|
onChange={(e) => handleChange("street", e.target.value)}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -450,7 +456,7 @@ export default function PerfilPaciente() {
|
|||||||
value={formData.number}
|
value={formData.number}
|
||||||
onChange={(e) => handleChange("number", e.target.value)}
|
onChange={(e) => handleChange("number", e.target.value)}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -465,7 +471,7 @@ export default function PerfilPaciente() {
|
|||||||
handleChange("complement", e.target.value)
|
handleChange("complement", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -480,7 +486,7 @@ export default function PerfilPaciente() {
|
|||||||
handleChange("neighborhood", e.target.value)
|
handleChange("neighborhood", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -493,7 +499,7 @@ export default function PerfilPaciente() {
|
|||||||
value={formData.city}
|
value={formData.city}
|
||||||
onChange={(e) => handleChange("city", e.target.value)}
|
onChange={(e) => handleChange("city", e.target.value)}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -507,7 +513,7 @@ export default function PerfilPaciente() {
|
|||||||
onChange={(e) => handleChange("state", e.target.value)}
|
onChange={(e) => handleChange("state", e.target.value)}
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
maxLength={2}
|
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>
|
</div>
|
||||||
|
|
||||||
@ -520,7 +526,7 @@ export default function PerfilPaciente() {
|
|||||||
value={formData.cep}
|
value={formData.cep}
|
||||||
onChange={(e) => handleChange("cep", e.target.value)}
|
onChange={(e) => handleChange("cep", e.target.value)}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -550,7 +556,7 @@ export default function PerfilPaciente() {
|
|||||||
handleChange("blood_type", e.target.value)
|
handleChange("blood_type", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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="">Selecione</option>
|
||||||
<option value="A+">A+</option>
|
<option value="A+">A+</option>
|
||||||
@ -575,7 +581,7 @@ export default function PerfilPaciente() {
|
|||||||
handleChange("weight_kg", e.target.value)
|
handleChange("weight_kg", e.target.value)
|
||||||
}
|
}
|
||||||
disabled={!isEditing}
|
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>
|
||||||
|
|
||||||
@ -592,7 +598,7 @@ export default function PerfilPaciente() {
|
|||||||
}
|
}
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
placeholder="Ex: 1.75"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -624,7 +630,7 @@ export default function PerfilPaciente() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Digite sua senha atual"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -642,7 +648,7 @@ export default function PerfilPaciente() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Digite a nova senha"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -660,7 +666,7 @@ export default function PerfilPaciente() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Confirme a nova senha"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -175,7 +175,29 @@ class ApiClient {
|
|||||||
url: string,
|
url: string,
|
||||||
config?: AxiosRequestConfig
|
config?: AxiosRequestConfig
|
||||||
): Promise<AxiosResponse<T>> {
|
): 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>(
|
async post<T>(
|
||||||
@ -243,7 +265,16 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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:", {
|
console.log("[ApiClient] PATCH Response:", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
|
|||||||
@ -31,15 +31,25 @@ class AppointmentService {
|
|||||||
data
|
data
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("[AppointmentService] Resposta get-available-slots:", response.data);
|
console.log(
|
||||||
|
"[AppointmentService] Resposta get-available-slots:",
|
||||||
|
response.data
|
||||||
|
);
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[AppointmentService] Erro ao buscar slots:", {
|
console.error("[AppointmentService] ❌ Erro ao buscar slots:");
|
||||||
error,
|
console.error("[AppointmentService] Status:", error?.response?.status);
|
||||||
message: error?.message,
|
console.error(
|
||||||
response: error?.response?.data,
|
"[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(
|
throw new Error(
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
error?.message ||
|
error?.message ||
|
||||||
|
|||||||
@ -43,16 +43,51 @@ class AvailabilityService {
|
|||||||
url: this.basePath,
|
url: this.basePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await apiClient.get<DoctorAvailability[]>(this.basePath, {
|
const response = await apiClient.get<any[]>(this.basePath, {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[AvailabilityService] Resposta da listagem:", {
|
console.log("[AvailabilityService] Resposta:", {
|
||||||
count: response.data?.length || 0,
|
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;
|
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
|
* Tipo de atendimento
|
||||||
@ -25,7 +26,7 @@ export type ExceptionKind = "bloqueio" | "disponibilidade_extra";
|
|||||||
export interface DoctorAvailability {
|
export interface DoctorAvailability {
|
||||||
id?: string;
|
id?: string;
|
||||||
doctor_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")
|
start_time: string; // Formato: HH:MM (ex: "08:00")
|
||||||
end_time: string; // Formato: HH:MM (ex: "18:00")
|
end_time: string; // Formato: HH:MM (ex: "18:00")
|
||||||
slot_minutes?: number; // Default: 30, range: 15-120
|
slot_minutes?: number; // Default: 30, range: 15-120
|
||||||
@ -57,7 +58,7 @@ export interface DoctorException {
|
|||||||
*/
|
*/
|
||||||
export interface ListAvailabilityFilters {
|
export interface ListAvailabilityFilters {
|
||||||
doctor_id?: string;
|
doctor_id?: string;
|
||||||
weekday?: Weekday; // "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday"
|
weekday?: Weekday; // 0=Domingo, 1=Segunda, ..., 6=Sábado
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
appointment_type?: AppointmentType;
|
appointment_type?: AppointmentType;
|
||||||
select?: string;
|
select?: string;
|
||||||
@ -68,7 +69,7 @@ export interface ListAvailabilityFilters {
|
|||||||
*/
|
*/
|
||||||
export interface CreateAvailabilityInput {
|
export interface CreateAvailabilityInput {
|
||||||
doctor_id: string; // required
|
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")
|
start_time: string; // required - Formato: HH:MM (ex: "08:00")
|
||||||
end_time: string; // required - Formato: HH:MM (ex: "18:00")
|
end_time: string; // required - Formato: HH:MM (ex: "18:00")
|
||||||
slot_minutes?: number; // optional - Default: 30, range: 15-120
|
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
|
* Cria novo médico
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -61,14 +61,41 @@ class ReportService {
|
|||||||
* Nota: order_number não pode ser modificado
|
* Nota: order_number não pode ser modificado
|
||||||
*/
|
*/
|
||||||
async update(id: string, data: UpdateReportInput): Promise<Report> {
|
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}`,
|
`${this.basePath}?id=eq.${id}`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
if (response.data && response.data.length > 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];
|
return response.data[0];
|
||||||
}
|
}
|
||||||
throw new Error("Relatório não encontrado");
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ú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}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
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: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
@ -49,6 +57,31 @@ export default {
|
|||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
bounce: "bounce 1s infinite",
|
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: {
|
animationDelay: {
|
||||||
100: "100ms",
|
100: "100ms",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user